Skip to content

Instantly share code, notes, and snippets.

@bdsqqq
Created May 25, 2024 18:57
Show Gist options
  • Save bdsqqq/85bc761762aaeec1440353788f7de431 to your computer and use it in GitHub Desktop.
Save bdsqqq/85bc761762aaeec1440353788f7de431 to your computer and use it in GitHub Desktop.
// Modified version of component from https://craft.mxkaske.dev/post/fancy-multi-select
import { createContextScope, type Scope } from '@radix-ui/react-context';
import * as PopperPrimitive from '@radix-ui/react-popper';
import { createPopperScope } from '@radix-ui/react-popper';
import { Portal as PortalPrimitive } from '@radix-ui/react-portal';
import { Presence } from '@radix-ui/react-presence';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { X } from 'lucide-react';
import * as React from 'react';
import { FLOATING_CONTAINER_OFFSET } from '../../styles/base/spacing';
import { cn } from '../../util/styles';
import { Command, CommandGroup, CommandItem, CommandInput } from './Command';
import { popoverFadeAnimations, popoverSlideAnimations, popoverZoomAnimations } from './Popover';
const Badge = (props: React.ComponentPropsWithoutRef<'div'>) => <div {...props} />;
type Option = Record<'value' | 'label', string>;
export const MOCK_OPTIONS = [
{
value: '1',
label: 'Option 1',
},
{
value: '2',
label: 'Option 2',
},
{
value: '3',
label: 'Option 3',
},
{
value: '4',
label: 'Option 4',
},
{
value: '5',
label: 'Option 5',
},
{
value: '6',
label: 'Option 6',
},
{
value: '7',
label: 'Option 7',
},
{
value: '8',
label: 'Option 8',
},
{
value: '9',
label: 'Option 9',
},
{
value: '10',
label: 'Option 10',
},
{
value: '11',
label: 'Option 11',
},
{
value: '12',
label: 'Option 12',
},
{
value: '13',
label: 'Option 13',
},
{
value: '14',
label: 'Option 14',
},
{
value: '15',
label: 'Option 15',
},
{
value: '16',
label: 'Option 16',
},
{
value: '17',
label: 'Option 17',
},
{
value: '18',
label: 'Option 18',
},
{
value: '19',
label: 'Option 19',
},
{
value: '20',
label: 'Option 20',
},
{
value: '21',
label: 'Option 21',
},
{
value: '22',
label: 'Option 22',
},
{
value: '23',
label: 'Option 23',
},
{
value: '24',
label: 'Option 24',
},
{
value: '25',
label: 'Option 25',
},
{
value: '26',
label: 'Option 26',
},
{
value: '27',
label: 'Option 27',
},
{
value: '28',
label: 'Option 28',
},
{
value: '29',
label: 'Option 29',
},
{
value: '30',
label: 'Option 30',
},
{
value: '31',
label: 'Option 31',
},
{
value: '32',
label: 'Option 32',
},
{
value: '33',
label: 'Option 33',
},
{
value: '34',
label: 'Option 34',
},
{
value: '35',
label: 'Option 35',
},
{
value: '36',
label: 'Option 36',
},
{
value: '37',
label: 'Option 37',
},
{
value: '38',
label: 'Option 38',
},
{
value: '39',
label: 'Option 39',
},
{
value: '40',
label: 'Option 40',
},
{
value: '41',
label: 'Option 41',
},
{
value: '42',
label: 'Option 42',
},
] satisfies Option[];
export type MultiSelectProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
options: Option[];
value?: string[];
setValue?: (selected: string[]) => void;
disabled?: boolean;
};
const MULTI_SELECT_NAME = 'MultiSelect';
// const [createMultiSelectContext, createMultiSelectScope] = createContextScope(MULTI_SELECT_NAME, [createPopperScope]);
const [createMultiSelectContext] = createContextScope(MULTI_SELECT_NAME, [createPopperScope]);
const usePopperScope = createPopperScope();
export type MultiSelectContextValue = {
triggerRef: React.RefObject<HTMLButtonElement>;
contentId: string;
open: boolean;
onOpenChange(open: boolean): void;
onOpenToggle(): void;
hasCustomAnchor: boolean;
onCustomAnchorAdd(): void;
onCustomAnchorRemove(): void;
};
type ScopedProps<P> = P & { __scopeMultiSelect?: Scope };
const [MultiSelectProvider, useMultiSelectContext] =
createMultiSelectContext<MultiSelectContextValue>(MULTI_SELECT_NAME);
/* -------------------------------------------------------------------------------------------------
* MultiSelectPortal
* -----------------------------------------------------------------------------------------------*/
const PORTAL_NAME = 'MultiSelectPortal';
type PortalContextValue = { forceMount?: true };
// const [PortalProvider, usePortalContext] = createMultiSelectContext<PortalContextValue>(PORTAL_NAME, {
// forceMount: undefined,
// });
const [PortalProvider] = createMultiSelectContext<PortalContextValue>(PORTAL_NAME, {
forceMount: undefined,
});
type PortalProps = React.ComponentPropsWithoutRef<typeof PortalPrimitive>;
interface MultiSelectPortalProps {
children?: React.ReactNode;
/**
* Specify a container element to portal the content into.
*/
container?: PortalProps['container'];
/**
* Used to force mounting when more control is needed. Useful when
* controlling animation with React animation libraries.
*/
forceMount?: true;
}
const MultiSelectPortal: React.FC<MultiSelectPortalProps> = (props: ScopedProps<MultiSelectPortalProps>) => {
const { __scopeMultiSelect: __scopeMultiSelect, forceMount, children, container } = props;
const context = useMultiSelectContext(PORTAL_NAME, __scopeMultiSelect);
return (
<PortalProvider scope={__scopeMultiSelect} forceMount={forceMount}>
<Presence present={forceMount || context.open}>
<PortalPrimitive asChild container={container}>
{children}
</PortalPrimitive>
</Presence>
</PortalProvider>
);
};
MultiSelectPortal.displayName = PORTAL_NAME;
export function MultiSelect({
options,
value: propSelected,
setValue: propSetSelected,
disabled,
open: openProp,
defaultOpen,
onOpenChange,
__scopeMultiSelect,
}: ScopedProps<MultiSelectProps>) {
const popperScope = usePopperScope(__scopeMultiSelect);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [hasCustomAnchor, setHasCustomAnchor] = React.useState(false);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const inputRef = React.useRef<HTMLInputElement>(null);
const [internalSelected, internalSetSelected] = React.useState<string[]>([]);
const [inputValue, setInputValue] = React.useState('');
// If consumer passed selected/setSelected, use those instead of the internal state.
const selected = propSelected !== undefined ? propSelected : internalSelected;
const setSelected = propSetSelected !== undefined ? propSetSelected : internalSetSelected;
const handleUnselect = React.useCallback(
(option: string) => {
setSelected(selected.filter((s) => s !== option)); // don't love this not using prev, but since we're exposing setSelected as a prop, we can't be sure it will be a useState dispatcher that provides the previous value for a callback.
},
[setSelected, selected]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '') {
const newSelected = [...selected];
newSelected.pop();
setSelected(newSelected); // don't love this not using prev, but since we're exposing setSelected as a prop, we can't be sure it will be a useState dispatcher that provides the previous value for a callback.
}
}
// This is not a default behaviour of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[selected, setSelected]
);
const selectables = options.filter((option) => !selected.includes(option.value));
return (
<MultiSelectProvider
scope={__scopeMultiSelect}
triggerRef={triggerRef}
contentId={React.useId()}
open={open}
onOpenChange={setOpen}
onOpenToggle={React.useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
hasCustomAnchor={hasCustomAnchor}
onCustomAnchorAdd={React.useCallback(() => setHasCustomAnchor(true), [])}
onCustomAnchorRemove={React.useCallback(() => setHasCustomAnchor(false), [])}
>
<Command onKeyDown={handleKeyDown} className="w-fit overflow-visible bg-transparent">
<PopperPrimitive.Root {...popperScope}>
<PopperPrimitive.Popper>
{/* can't use popover because traps focus. Using popper instead but that means we need to hook up presence, portals, and data-attributes ourselves.*/}
<div className="group w-fit text-sm">
<div className="flex flex-wrap gap-1">
{selected.map((selectedOption) => {
const option = options.find((o) => o.value === selectedOption);
if (!option) {
return null;
}
return (
<Badge
className={cn(
'flex items-center gap-2 rounded border bg-subtle px-1.5 py-1',
disabled && 'opacity-60'
)}
key={option.value}
>
{option.label}
<button
disabled={disabled}
type="button"
className="rounded"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(selectedOption);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
aria-label={`Remove ${option.label}`}
onClick={() => handleUnselect(selectedOption)}
>
<X className="hover:text-foreground h-3 w-3 text-subtle" />
</button>
</Badge>
);
})}
<PopperPrimitive.Anchor asChild>
<CommandInput
disabled={disabled}
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder="Select options..."
rootClassName="border rounded p-0"
className="px-2 py-1"
/>
</PopperPrimitive.Anchor>
</div>
</div>
{/* if there's problems with z-index, throw this in a portal. Or tell Igor to do it. Reference: https://github.com/radix-ui/primitives/blob/main/packages/react/popover/src/Popover.tsx */}
{/* <MultiSelectPortal> */}
{/* Igor did throw this in a portal and broke stuff. Better to wait for proper combobox support before spending more time on this. A credible source told me radix would release it soon. But if it takes too long we should just go with ariakit/react aria already. for more details see: https://www.loom.com/share/c2b01cfdb76742d09eed9e771abb0919?sid=a939d8b5-35d1-4bc2-bda2-82ff95d5bd6e - igor */}
<Presence present={open}>
<PopperPrimitive.Content
className={cn(
'max-h-[--radix-popper-available-height] min-w-[--radix-popper-anchor-width] overflow-auto rounded border bg shadow-paper-3 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out',
'isolate z-20', // Portalling makes cmdk not set active items properly/not handle keyboard selection. For now, this is a temporary fix to make the popper render above everything else. Probably causes it to render over some things it shouldn't, but it's better than doing nothing.
popoverZoomAnimations,
popoverFadeAnimations,
popoverSlideAnimations
)}
data-state={open ? 'open' : 'closed'}
align="start"
sideOffset={FLOATING_CONTAINER_OFFSET}
>
<CommandGroup className="h-full overflow-auto">
{selectables.map((option) => {
return (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue('');
// set the whole option instead of the value given by cmdk to keep capitalization.
// cmdk values are always lowercase and trimmed
setSelected([...selected, option.value]); // don't love this not using prev, but since we're exposing setSelected as a prop, we can't be sure it will be a useState dispatcher that provides the previous value for a callback.
}}
className={'cursor-pointer'}
>
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</PopperPrimitive.Content>
{/* </MultiSelectPortal> */}
</Presence>
</PopperPrimitive.Popper>
</PopperPrimitive.Root>
</Command>
</MultiSelectProvider>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment