dydx-v4-web/src/components/ComboboxMenu.tsx
aleka e3da2ee4b1
update copy and other polish (#157)
* update copy and other polish

* fix mobile nav menu nested buttons

* feedback
2023-11-22 15:06:21 -05:00

349 lines
10 KiB
TypeScript

import { Fragment, type ReactNode, useState } from 'react';
import styled, { type AnyStyledComponent, css } from 'styled-components';
import { Command } from 'cmdk';
import { MenuItem, type MenuConfig } from '@/constants/menus';
import { popoverMixins } from '@/styles/popoverMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { Tag } from '@/components/Tag';
type ElementProps<MenuItemValue extends string | number, MenuGroupValue extends string | number> = {
items: MenuConfig<MenuItemValue, MenuGroupValue>;
onItemSelected?: () => void;
title?: string;
inputPlaceholder?: string;
slotEmpty?: ReactNode;
withSearch?: boolean;
};
type StyleProps = {
className?: string;
withItemBorders?: boolean;
withStickyLayout?: boolean;
};
export type ComboboxMenuProps<
MenuItemValue extends string | number,
MenuGroupValue extends string | number
> = ElementProps<MenuItemValue, MenuGroupValue> & StyleProps;
export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extends string>({
items,
onItemSelected,
title,
inputPlaceholder = 'Search…',
slotEmpty = 'No items found.',
withSearch = true,
className,
withItemBorders,
withStickyLayout,
}: ComboboxMenuProps<MenuItemValue, MenuGroupValue>) => {
const [highlightedCommand, setHighlightedCommand] = useState<MenuItemValue>();
const [searchValue, setSearchValue] = useState('');
return (
<Styled.Command
label={title}
// value={highlightedCommand}
// onValueChange={setHighlightedCommand}
filter={(value: string, search: string) => {
if (value.replace(/ /g, '').includes(search.replace(/ /g, ''))) return 1;
return 0;
}}
className={className}
$withStickyLayout={withStickyLayout}
>
{withSearch && (
<Styled.Header $withStickyLayout={withStickyLayout}>
<Styled.Input
/**
* Mobile Issue: Search Input will always trigger mobile keyboard drawer. There is no fix.
* https://github.com/pacocoursey/cmdk/issues/127
*/
autoFocus
type="search"
value={searchValue}
onValueChange={setSearchValue}
placeholder={inputPlaceholder}
/>
</Styled.Header>
)}
<Styled.List $withStickyLayout={withStickyLayout}>
{items.map((group) => (
<Styled.Group
key={group.group}
heading={group.groupLabel}
$withItemBorders={withItemBorders}
$withStickyLayout={withStickyLayout}
>
{group.items.map((item) => (
<Fragment key={item.value}>
<Styled.Item
// value={item.value} // search by both value and textContent
// value={[group.groupLabel, item.label, item.tag].filter(Boolean).join('|')} // exclude item.value from searchable terms (not guaranteed to be unique)
value={[group.groupLabel, item.value, item.description, item.label, item.tag]
.filter(Boolean)
.join('|')}
onSelect={() => {
if (item.subitems) {
// open submenu
} else {
item.onSelect?.(item.value);
onItemSelected?.();
}
}}
disabled={item.disabled}
$withItemBorders={withItemBorders}
>
{
<>
{item.slotBefore}
{item.slotCustomContent ?? (
<Styled.ItemLabel>
<span>
{typeof item.label === 'string'
? `${item.label}${item.subitems?.length ? '…' : ''}`
: item.label}
{item.tag && (
<>
{' '}
<Tag>{item.tag}</Tag>
</>
)}
</span>
{item.description && <span>{item.description}</span>}
</Styled.ItemLabel>
)}
{item.slotAfter}
{item.subitems && '→'}
</>
}
</Styled.Item>
{searchValue &&
item.subitems?.map((subitem) => (
<Fragment key={subitem.value}>
<Styled.Item
// value={subitem.value} // search by both value and textContent
// value={[group.groupLabel, item.label, subitem.label, subitem.tag].filter(Boolean).join('|')}
value={[
group.groupLabel,
item.value,
item.description,
item.label,
item.tag,
]
.filter(Boolean)
.join('|')}
onSelect={() => {
subitem.onSelect?.(subitem.value);
onItemSelected?.();
}}
disabled={subitem.disabled}
$withItemBorders={withItemBorders}
>
{subitem.slotBefore}
<Styled.ItemLabel>
<span>
{subitem.label}
{subitem.tag && (
<>
{' '}
<Tag>{subitem.tag}</Tag>
</>
)}
</span>
{item.description && <span>{item.description}</span>}
</Styled.ItemLabel>
{subitem.slotAfter}
</Styled.Item>
</Fragment>
))}
</Fragment>
))}
</Styled.Group>
))}
{slotEmpty && searchValue.trim() !== '' && <Styled.Empty>{slotEmpty}</Styled.Empty>}
</Styled.List>
</Styled.Command>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Command = styled(Command)<{ $withStickyLayout?: boolean }>`
--comboboxMenu-backgroundColor: var(--color-layer-2);
--comboboxMenu-input-backgroundColor: var(--color-layer-3);
--comboboxMenu-input-height: 2.5rem;
--comboboxMenu-item-checked-backgroundColor: ;
--comboboxMenu-item-checked-textColor: ;
--comboboxMenu-item-highlighted-backgroundColor: var(--color-layer-3);
--comboboxMenu-item-highlighted-textColor: var(--color-text-1);
--comboboxMenu-item-backgroundColor: ;
--comboboxMenu-item-gap: 0.5rem;
--comboboxMenu-item-padding: 0.5em 1em;
display: grid;
align-content: start;
background-color: var(--comboboxMenu-backgroundColor);
border-radius: inherit;
input:focus-visible {
outline: none;
}
${({ $withStickyLayout }) =>
$withStickyLayout
? css`
${layoutMixins.stickyArea1}
--stickyArea1-background: var(--comboboxMenu-backgroundColor);
--stickyArea1-topHeight: 4rem;
`
: css`
${() => Styled.List} {
overflow-y: auto;
}
`}
`;
Styled.Header = styled.header<{ $withStickyLayout?: boolean }>`
display: grid;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
background-color: var(--comboboxMenu-backgroundColor);
${({ $withStickyLayout }) =>
$withStickyLayout &&
css`
${layoutMixins.stickyHeader}
${layoutMixins.scrollSnapItem}
`}
`;
Styled.Input = styled(Command.Input)`
height: var(--comboboxMenu-input-height);
padding: 0.5rem;
background-color: var(--comboboxMenu-input-backgroundColor);
border-radius: 0.5rem;
gap: 0.5rem;
`;
Styled.Group = styled(Command.Group)<{ $withItemBorders?: boolean; $withStickyLayout?: boolean }>`
color: var(--color-text-0);
> [cmdk-group-heading] {
padding: 0.5rem 0.75rem 0.3rem;
font: var(--font-base-medium);
background-color: var(--comboboxMenu-backgroundColor, inherit);
}
${({ $withStickyLayout }) =>
$withStickyLayout &&
css`
${layoutMixins.stickyArea2}
--stickyArea2-topHeight: 2rem;
> [cmdk-group-heading] {
${layoutMixins.stickyHeader}
z-index: 2;
}
> [cmdk-group-items] {
${layoutMixins.stickyArea3}
}
`}
${({ $withItemBorders }) =>
$withItemBorders &&
css`
> [cmdk-group-items] {
padding: var(--border-width) 0;
}
`}
`;
Styled.List = styled(Command.List)<{ $withStickyLayout?: boolean }>`
isolation: isolate;
background-color: var(--comboboxMenu-backgroundColor, inherit);
> [cmdk-list-sizer] {
display: grid;
${layoutMixins.withOuterAndInnerBorders}
}
@media (prefers-reduced-motion: no-preference) {
/* transition: height 0.3s var(--ease-in-out-expo); */
transition: height 0.5s var(--ease-out-expo);
/* transition: height 0.5s ease-in-out; */
}
${({ $withStickyLayout }) =>
$withStickyLayout &&
css`
/* prevent sticky group borders from randomly bleeding under sticky group headers' backdrop-filter */
contain: strict;
height: var(--cmdk-list-height);
`}
`;
Styled.Item = styled(Command.Item)<{ $withItemBorders?: boolean }>`
${layoutMixins.scrollSnapItem}
${popoverMixins.item}
--item-checked-backgroundColor: var(--comboboxMenu-item-checked-backgroundColor);
--item-checked-textColor: var(--comboboxMenu-item-checked-textColor);
--item-highlighted-textColor: var(--comboboxMenu-item-highlighted-textColor);
--item-gap: var(--comboboxMenu-item-gap);
--item-padding: var(--comboboxMenu-item-padding);
background-color: var(--comboboxMenu-backgroundColor, inherit);
display: flex;
align-items: center;
&[aria-disabled='true'] {
opacity: 0.75;
cursor: not-allowed;
}
${({ $withItemBorders }) =>
$withItemBorders &&
css`
${layoutMixins.withOuterBorder}
`}
`;
Styled.ItemLabel = styled.div`
flex: 1;
${layoutMixins.rowColumn}
> span {
display: inline-flex;
align-items: center;
gap: 0.5ch;
&:nth-child(2) {
font: var(--font-small-book);
color: var(--color-text-0);
opacity: 0.8;
}
}
min-width: 0;
`;
Styled.Empty = styled(Command.Empty)`
color: var(--color-text-0);
padding: 1rem;
height: 100%;
`;