Created
May 25, 2024 18:57
-
-
Save bdsqqq/85bc761762aaeec1440353788f7de431 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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