From d1b0fc979d6ebdaca07339b6ba23b1c196fd6971 Mon Sep 17 00:00:00 2001 From: Ahmed Shaheen Date: Sat, 10 Feb 2024 02:29:43 +0200 Subject: [PATCH] feat: add Combobox component (#243) Co-authored-by: Hunter Johnston Co-authored-by: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> --- .changeset/moody-apes-beam.md | 5 + content/components/combobox.md | 39 +++ src/components/demos/combobox-demo.svelte | 53 ++++ src/components/demos/index.ts | 1 + src/components/icons/index.ts | 1 + src/config/navigation.ts | 8 +- src/content/api-reference/combobox.ts | 251 ++++++++++++++++ src/content/api-reference/index.ts | 3 + src/lib/bits/combobox/_export.types.ts | 18 ++ src/lib/bits/combobox/_types.ts | 96 ++++++ .../combobox/components/combobox-arrow.svelte | 27 ++ .../components/combobox-content.svelte | 116 +++++++ .../components/combobox-group-label.svelte | 30 ++ .../combobox/components/combobox-group.svelte | 23 ++ .../components/combobox-hidden-input.svelte | 30 ++ .../combobox/components/combobox-input.svelte | 38 +++ .../components/combobox-item-indicator.svelte | 22 ++ .../combobox/components/combobox-item.svelte | 52 ++++ .../combobox/components/combobox-label.svelte | 28 ++ .../bits/combobox/components/combobox.svelte | 116 +++++++ src/lib/bits/combobox/ctx.ts | 135 +++++++++ src/lib/bits/combobox/index.ts | 13 + src/lib/bits/combobox/types.ts | 68 +++++ src/lib/bits/index.ts | 1 + src/tests/combobox/Combobox.spec.ts | 282 ++++++++++++++++++ src/tests/combobox/ComboboxTest.svelte | 60 ++++ 26 files changed, 1514 insertions(+), 2 deletions(-) create mode 100644 .changeset/moody-apes-beam.md create mode 100644 content/components/combobox.md create mode 100644 src/components/demos/combobox-demo.svelte create mode 100644 src/content/api-reference/combobox.ts create mode 100644 src/lib/bits/combobox/_export.types.ts create mode 100644 src/lib/bits/combobox/_types.ts create mode 100644 src/lib/bits/combobox/components/combobox-arrow.svelte create mode 100644 src/lib/bits/combobox/components/combobox-content.svelte create mode 100644 src/lib/bits/combobox/components/combobox-group-label.svelte create mode 100644 src/lib/bits/combobox/components/combobox-group.svelte create mode 100644 src/lib/bits/combobox/components/combobox-hidden-input.svelte create mode 100644 src/lib/bits/combobox/components/combobox-input.svelte create mode 100644 src/lib/bits/combobox/components/combobox-item-indicator.svelte create mode 100644 src/lib/bits/combobox/components/combobox-item.svelte create mode 100644 src/lib/bits/combobox/components/combobox-label.svelte create mode 100644 src/lib/bits/combobox/components/combobox.svelte create mode 100644 src/lib/bits/combobox/ctx.ts create mode 100644 src/lib/bits/combobox/index.ts create mode 100644 src/lib/bits/combobox/types.ts create mode 100644 src/tests/combobox/Combobox.spec.ts create mode 100644 src/tests/combobox/ComboboxTest.svelte diff --git a/.changeset/moody-apes-beam.md b/.changeset/moody-apes-beam.md new file mode 100644 index 000000000..47e3c19ef --- /dev/null +++ b/.changeset/moody-apes-beam.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +New Component: [Combobox](https://bits-ui.com/docs/components/combobox) diff --git a/content/components/combobox.md b/content/components/combobox.md new file mode 100644 index 000000000..58989f601 --- /dev/null +++ b/content/components/combobox.md @@ -0,0 +1,39 @@ +--- +title: Combobox +description: Enables users to pick from a list of options displayed in a dropdown. +--- + + + + + + + + + +## Structure + +```svelte + + + + + + + + + + + + + + + +``` + + diff --git a/src/components/demos/combobox-demo.svelte b/src/components/demos/combobox-demo.svelte new file mode 100644 index 000000000..33b4c052f --- /dev/null +++ b/src/components/demos/combobox-demo.svelte @@ -0,0 +1,53 @@ + + + +
+ + + +
+ + + {#each filteredFruits as fruit (fruit.value)} + + {fruit.label} + + + + + {:else} + No results found + {/each} + + +
diff --git a/src/components/demos/index.ts b/src/components/demos/index.ts index 15b8cf15e..0c8dae02d 100644 --- a/src/components/demos/index.ts +++ b/src/components/demos/index.ts @@ -6,6 +6,7 @@ export { default as ButtonDemo } from "./button-demo.svelte"; export { default as CalendarDemo } from "./calendar-demo.svelte"; export { default as CheckboxDemo } from "./checkbox-demo.svelte"; export { default as CollapsibleDemo } from "./collapsible-demo.svelte"; +export { default as ComboboxDemo } from "./combobox-demo.svelte"; export { default as ContextMenuDemo } from "./context-menu-demo.svelte"; export { default as DateFieldDemo } from "./date-field-demo.svelte"; export { default as DateRangeFieldDemo } from "./date-range-field-demo.svelte"; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 955d62d49..078cf8c6f 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -50,6 +50,7 @@ export { default as Compass } from "phosphor-svelte/lib/Compass"; export { default as Sticker } from "phosphor-svelte/lib/Sticker"; export { default as UserCircle } from "phosphor-svelte/lib/UserCircle"; export { default as PlusCircle } from "phosphor-svelte/lib/PlusCircle"; +export { default as OrangeSlice } from "phosphor-svelte/lib/OrangeSlice"; export type IconProps = Partial> & { class?: string; diff --git a/src/config/navigation.ts b/src/config/navigation.ts index b22b1352c..0dd58da9b 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -83,7 +83,6 @@ export const navigation: Navigation = { { title: "Calendar", href: "/docs/components/calendar", - label: "New", items: [], }, { @@ -96,6 +95,12 @@ export const navigation: Navigation = { href: "/docs/components/collapsible", items: [], }, + { + title: "Combobox", + href: "/docs/components/combobox", + label: "New", + items: [], + }, { title: "Context Menu", href: "/docs/components/context-menu", @@ -154,7 +159,6 @@ export const navigation: Navigation = { { title: "PIN Input", href: "/docs/components/pin-input", - label: "New", items: [], }, { diff --git a/src/content/api-reference/combobox.ts b/src/content/api-reference/combobox.ts new file mode 100644 index 000000000..565a799cd --- /dev/null +++ b/src/content/api-reference/combobox.ts @@ -0,0 +1,251 @@ +import type { APISchema } from "@/types/index.js"; +import { + arrowProps, + asChild, + attrsSlotProp, + domElProps, + enums, + idsSlotProp, + portalProp, + transitionProps, + builderAndAttrsSlotProps, + onOutsideClickProp, +} from "@/content/api-reference/helpers.js"; +import { floatingPositioning } from "./floating.js"; +import * as C from "@/content/constants.js"; +import type * as Combobox from "$lib/bits/combobox/_types.js"; + +export const root: APISchema = { + title: "Root", + description: "The root combobox component which manages & scopes the state of the select.", + props: { + disabled: { + default: C.FALSE, + type: C.BOOLEAN, + description: "Whether or not the combobox component is disabled.", + }, + multiple: { + default: C.FALSE, + type: C.BOOLEAN, + description: "Whether or not the combobox menu allows multiple selections.", + }, + preventScroll: { + default: C.TRUE, + type: C.BOOLEAN, + description: "Whether or not to prevent scrolling the body when the menu is open.", + }, + closeOnEscape: { + default: C.TRUE, + type: C.BOOLEAN, + description: "Whether to close the combobox menu when the escape key is pressed.", + }, + closeOnOutsideClick: { + type: C.BOOLEAN, + default: C.TRUE, + description: "Whether to close the combobox menu when a click occurs outside of it.", + }, + loop: { + type: C.BOOLEAN, + default: C.FALSE, + description: + "Whether or not to loop through the menu items when navigating with the keyboard.", + }, + open: { + type: C.BOOLEAN, + default: C.FALSE, + description: "The open state of the combobox menu.", + }, + onOpenChange: { + type: { + type: C.FUNCTION, + definition: "(open: boolean) => void", + }, + description: "A callback that is fired when the combobox menu's open state changes.", + }, + selected: { + type: { + type: C.OBJECT, + definition: "{ value: unknown; label?: string }", + }, + description: "The value of the currently selected item.", + }, + onSelectedChange: { + type: { + type: C.FUNCTION, + definition: "(value: unknown | undefined) => void", + }, + description: "A callback that is fired when the combobox menu's value changes.", + }, + portal: { ...portalProp("combobox menu") }, + highlightOnHover: { + type: C.BOOLEAN, + default: C.TRUE, + description: "Whether or not to highlight the currently hovered item.", + }, + name: { + type: C.STRING, + description: "The name to apply to the hidden input element for form submission.", + }, + required: { + default: C.FALSE, + type: C.BOOLEAN, + description: "Whether or not the combobox menu is required.", + }, + scrollAlignment: { + default: "'nearest'", + type: { + type: C.ENUM, + definition: enums("nearest", "center"), + }, + description: "The alignment of the highlighted item when scrolling.", + }, + inputValue: { + default: "", + type: C.STRING, + description: "The value of the combobox input element.", + }, + items: { + type: { + type: "Selected[]", + definition: "Array<{ value: T; label?: string }>", + }, + description: "An array of items to add type-safety to the `onSelectedChange` callback.", + }, + onOutsideClick: onOutsideClickProp, + }, + slotProps: { ids: idsSlotProp }, +}; + +export const content: APISchema = { + title: "Content", + description: "The element which contains the combobox menu's items.", + props: { ...transitionProps, ...floatingPositioning, ...domElProps("HTMLDivElement") }, + slotProps: { ...builderAndAttrsSlotProps }, + dataAttributes: [ + { + name: "combobox-content", + description: "Present on the content element.", + }, + ], +}; + +export const item: APISchema = { + title: "Item", + description: "A combobox item, which must be a child of the `Combobox.Content` component.", + props: { + label: { + type: C.STRING, + description: "The label of the select item, which is displayed in the menu.", + }, + value: { + type: C.UNKNOWN, + description: "The value of the select item.", + }, + disabled: { + type: C.BOOLEAN, + default: C.FALSE, + description: + "Whether or not the combobox item is disabled. This will prevent interaction/selection.", + }, + ...domElProps("HTMLDivElement"), + }, + slotProps: { ...builderAndAttrsSlotProps }, + dataAttributes: [ + { + name: "state", + description: "The state of the item.", + value: enums("selected", "hovered"), + isEnum: true, + }, + { + name: "disabled", + description: "Present when the item is disabled.", + }, + { + name: "combobox-item", + description: "Present on the item element.", + }, + ], +}; + +export const input: APISchema = { + title: "Input", + description: + "A representation of the combobox input element, which is typically displayed in the content.", + props: { + placeholder: { + type: C.STRING, + description: "A placeholder value to display when no value is selected.", + }, + asChild, + }, + slotProps: { + attrs: attrsSlotProp, + label: { + type: C.STRING, + description: "The label of the currently selected item.", + }, + }, + dataAttributes: [ + { + name: "select-input", + description: "Present on the input element.", + }, + ], +}; + +export const hiddenInput: APISchema = { + title: "hidden-input", + description: + "A hidden input element which is used to store the combobox menu's value, used for form submission. It receives the same value as the `Select.Value` component and can receive any props that a normal input element can receive.", + props: domElProps("HTMLInputElement"), + slotProps: { ...builderAndAttrsSlotProps }, +}; + +export const label: APISchema = { + title: "Label", + description: + "A label for the combobox input element, which is typically displayed in the content.", + props: domElProps("HTMLLabelElement"), + slotProps: { ...builderAndAttrsSlotProps }, + dataAttributes: [ + { + name: "combobox-label", + description: "Present on the label element.", + }, + ], +}; + +export const indicator: APISchema = { + title: "Indicator", + description: "A visual indicator for use between combobox items or groups.", + props: domElProps("HTMLDivElement"), + slotProps: { + attrs: attrsSlotProp, + isSelected: { + type: C.BOOLEAN, + description: "Whether or not the item is selected.", + }, + }, + dataAttributes: [ + { + name: "combobox-indicator", + description: "Present on the indicator element.", + }, + ], +}; + +export const arrow: APISchema = { + title: "Arrow", + description: "An optional arrow element which points to the selected item when menu open.", + props: arrowProps, + slotProps: { ...builderAndAttrsSlotProps }, + dataAttributes: [ + { + name: "arrow", + description: "Present on the arrow element.", + }, + ], +}; + +export const combobox = [root, content, item, input, label, hiddenInput, arrow]; diff --git a/src/content/api-reference/index.ts b/src/content/api-reference/index.ts index cfbf8d08a..9aae9ee68 100644 --- a/src/content/api-reference/index.ts +++ b/src/content/api-reference/index.ts @@ -7,6 +7,7 @@ import { button } from "./button"; import { calendar } from "./calendar"; import { checkbox } from "./checkbox"; import { collapsible } from "./collapsible"; +import { combobox } from "./combobox"; import { contextMenu } from "./context-menu"; import { dateField } from "./date-field"; import { datePicker } from "./date-picker"; @@ -42,6 +43,7 @@ export const bits = [ "calendar", "checkbox", "collapsible", + "combobox", "context-menu", "date-field", "date-picker", @@ -86,6 +88,7 @@ export const apiSchemas: Record = { calendar, checkbox, collapsible, + combobox, "context-menu": contextMenu, "date-field": dateField, "date-picker": datePicker, diff --git a/src/lib/bits/combobox/_export.types.ts b/src/lib/bits/combobox/_export.types.ts new file mode 100644 index 000000000..54b807821 --- /dev/null +++ b/src/lib/bits/combobox/_export.types.ts @@ -0,0 +1,18 @@ +export type { + Props as ComboboxProps, + ContentProps as ComboboxContentProps, + InputProps as ComboboxInputProps, + ItemProps as ComboboxItemProps, + LabelProps as ComboboxLabelProps, + GroupProps as ComboboxGroupProps, + GroupLabelProps as ComboboxGroupLabelProps, + ArrowProps as ComboboxArrowProps, + HiddenInputProps as ComboboxHiddenInputProps, + SeparatorProps as ComboboxSeparatorProps, + IndicatorProps as ComboboxIndicatorProps, + // + ItemEvents as ComboboxItemEvents, + InputEvents as ComboboxInputEvents, + GroupLabelEvents as ComboboxGroupLabelEvents, + ContentEvents as ComboboxContentEvents, +} from "./types.js"; diff --git a/src/lib/bits/combobox/_types.ts b/src/lib/bits/combobox/_types.ts new file mode 100644 index 000000000..ba86be638 --- /dev/null +++ b/src/lib/bits/combobox/_types.ts @@ -0,0 +1,96 @@ +/** + * We define prop types without the HTMLAttributes here so that we can use them + * to type-check our API documentation, which requires we document each prop, + * but we don't want to document the HTML attributes. + */ +import type { CreateComboboxProps, ComboboxOptionProps } from "@melt-ui/svelte"; +import type { DOMElement, Expand, OmitFloating, OnChangeFn } from "$lib/internal/index.js"; +import type { ContentProps, ArrowProps } from "$lib/bits/floating/_types.js"; +import type { Selected } from "$lib"; + +export type WhenTrue = [ + TrueOrFalse +] extends [true] + ? IfTrue + : [TrueOrFalse] extends [false] + ? IfFalse + : IfNeither; + +type SelectValue = WhenTrue; + +type Props = Expand< + OmitFloating< + Omit + > & { + /** + * The selected value of the combobox. + * You can bind this to a value to programmatically control the selected value. + * + * @defaultValue undefined + */ + selected?: SelectValue, Multiple> | undefined; + + /** + * A callback function called when the selected value changes. + */ + onSelectedChange?: OnChangeFn, Multiple>>; + + /** + * The open state of the combobox menu. + * You can bind this to a boolean value to programmatically control the open state. + * + * @defaultValue false + */ + open?: boolean; + + /** + * A callback function called when the open state changes. + */ + onOpenChange?: OnChangeFn; + + /** + * Whether or not multiple values can be selected. + */ + multiple?: Multiple; + + /** + * The value of the input. + * You can bind this to a value to programmatically control the input value. + * + * @defaultValue "" + */ + inputValue?: string; + + /** + * Optionally provide an array of `Selected` objects to + * type the `selected` and `onSelectedChange` props. + */ + items?: Selected[]; + } +>; + +type InputProps = DOMElement; +type LabelProps = DOMElement; + +type GroupProps = DOMElement; +type GroupLabelProps = DOMElement; + +type ItemProps = Expand; + +type HiddenInputProps = DOMElement; +type SeparatorProps = DOMElement; +type IndicatorProps = DOMElement; + +export type { + Props, + ContentProps, + InputProps, + ItemProps, + LabelProps, + GroupProps, + GroupLabelProps, + ArrowProps, + HiddenInputProps, + SeparatorProps, + IndicatorProps, +}; diff --git a/src/lib/bits/combobox/components/combobox-arrow.svelte b/src/lib/bits/combobox/components/combobox-arrow.svelte new file mode 100644 index 000000000..5f2ce2f41 --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-arrow.svelte @@ -0,0 +1,27 @@ + + +{#if asChild} + +{:else} +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-content.svelte b/src/lib/bits/combobox/components/combobox-content.svelte new file mode 100644 index 000000000..2a6e8925d --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-content.svelte @@ -0,0 +1,116 @@ + + + +{#if asChild && $open} + +{:else if transition && $open} +
+ +
+{:else if inTransition && outTransition && $open} +
+ +
+{:else if inTransition && $open} +
+ +
+{:else if outTransition && $open} +
+ +
+{:else if $open} +
+ +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-group-label.svelte b/src/lib/bits/combobox/components/combobox-group-label.svelte new file mode 100644 index 000000000..733650a8d --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-group-label.svelte @@ -0,0 +1,30 @@ + + +{#if asChild} + +{:else} +
+ +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-group.svelte b/src/lib/bits/combobox/components/combobox-group.svelte new file mode 100644 index 000000000..dce01be8e --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-group.svelte @@ -0,0 +1,23 @@ + + +{#if asChild} + +{:else} +
+ +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-hidden-input.svelte b/src/lib/bits/combobox/components/combobox-hidden-input.svelte new file mode 100644 index 000000000..460c8b7fb --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-hidden-input.svelte @@ -0,0 +1,30 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/combobox/components/combobox-input.svelte b/src/lib/bits/combobox/components/combobox-input.svelte new file mode 100644 index 000000000..dba389ac2 --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-input.svelte @@ -0,0 +1,38 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/combobox/components/combobox-item-indicator.svelte b/src/lib/bits/combobox/components/combobox-item-indicator.svelte new file mode 100644 index 000000000..6752dcb7a --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-item-indicator.svelte @@ -0,0 +1,22 @@ + + +{#if asChild} + +{:else} +
+ {#if $isSelected(value)} + + {/if} +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-item.svelte b/src/lib/bits/combobox/components/combobox-item.svelte new file mode 100644 index 000000000..4eef3774c --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-item.svelte @@ -0,0 +1,52 @@ + + + + +{#if asChild} + +{:else} +
+ + {label ? label : value} + +
+{/if} diff --git a/src/lib/bits/combobox/components/combobox-label.svelte b/src/lib/bits/combobox/components/combobox-label.svelte new file mode 100644 index 000000000..462426737 --- /dev/null +++ b/src/lib/bits/combobox/components/combobox-label.svelte @@ -0,0 +1,28 @@ + + +{#if asChild} + +{:else} + +{/if} diff --git a/src/lib/bits/combobox/components/combobox.svelte b/src/lib/bits/combobox/components/combobox.svelte new file mode 100644 index 000000000..938bd871d --- /dev/null +++ b/src/lib/bits/combobox/components/combobox.svelte @@ -0,0 +1,116 @@ + + + + + diff --git a/src/lib/bits/combobox/ctx.ts b/src/lib/bits/combobox/ctx.ts new file mode 100644 index 000000000..e1990ef9e --- /dev/null +++ b/src/lib/bits/combobox/ctx.ts @@ -0,0 +1,135 @@ +import { type CreateComboboxProps, createCombobox } from "@melt-ui/svelte"; +import { getContext, setContext } from "svelte"; +import { + createBitAttrs, + generateId, + getOptionUpdater, + removeUndefined, +} from "$lib/internal/index.js"; +import { getPositioningUpdater } from "../floating/helpers.js"; +import type { Writable } from "svelte/store"; +import type { FloatingConfig } from "../floating/floating-config.js"; +import type { FloatingProps } from "../floating/_types.js"; + +function getSelectData() { + const NAME = "combobox" as const; + const GROUP_NAME = "combobox-group"; + const ITEM_NAME = "combobox-item"; + + const PARTS = [ + "content", + "menu", + "input", + "item", + "label", + "group", + "group-label", + "arrow", + "hidden-input", + "indicator", + ] as const; + + return { + NAME, + GROUP_NAME, + ITEM_NAME, + PARTS, + }; +} + +type GetReturn = Omit, "updateOption">; + +export function getCtx() { + const { NAME } = getSelectData(); + return getContext(NAME); +} + +type Items = { + value: T; + label?: string; +}; + +type Props = CreateComboboxProps & { + items?: Items[]; +}; + +export function setCtx( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: Props +) { + const { NAME, PARTS } = getSelectData(); + const getAttrs = createBitAttrs(NAME, PARTS); + + const combobox = { + ...createCombobox({ ...removeUndefined(props), forceVisible: true }), + getAttrs, + }; + setContext(NAME, combobox); + return { + ...combobox, + updateOption: getOptionUpdater(combobox.options), + }; +} + +export function setGroupCtx() { + const { GROUP_NAME } = getSelectData(); + const id = generateId(); + setContext(GROUP_NAME, id); + const { + elements: { group }, + getAttrs, + } = getCtx(); + return { group, id, getAttrs }; +} + +export function setItemCtx(value: unknown) { + const { ITEM_NAME } = getSelectData(); + const combobox = getCtx(); + setContext(ITEM_NAME, value); + return combobox; +} + +export function getGroupLabel() { + const { GROUP_NAME } = getSelectData(); + const id = getContext(GROUP_NAME); + const { + elements: { groupLabel }, + getAttrs, + } = getCtx(); + return { groupLabel, id, getAttrs }; +} + +export function getItemIndicator() { + const { ITEM_NAME } = getSelectData(); + const { + helpers: { isSelected }, + getAttrs, + } = getCtx(); + const value = getContext(ITEM_NAME); + return { + value, + isSelected, + getAttrs, + }; +} + +export function setArrow(size = 8) { + const combobox = getCtx(); + combobox.options.arrowSize?.set(size); + return combobox; +} + +export function updatePositioning(props: FloatingProps) { + const defaultPlacement = { + side: "bottom", + align: "center", + sameWidth: true, + } satisfies FloatingProps; + const withDefaults = { ...defaultPlacement, ...props } satisfies FloatingProps; + const { + options: { positioning }, + } = getCtx(); + + const updater = getPositioningUpdater(positioning as Writable); + updater(withDefaults); +} diff --git a/src/lib/bits/combobox/index.ts b/src/lib/bits/combobox/index.ts new file mode 100644 index 000000000..58ebc05fa --- /dev/null +++ b/src/lib/bits/combobox/index.ts @@ -0,0 +1,13 @@ +export { default as Root } from "./components/combobox.svelte"; +export { default as Content } from "./components/combobox-content.svelte"; +export { default as Input } from "./components/combobox-input.svelte"; +export { default as Item } from "./components/combobox-item.svelte"; +export { default as Label } from "./components/combobox-label.svelte"; +export { default as Group } from "./components/combobox-group.svelte"; +export { default as GroupLabel } from "./components/combobox-group-label.svelte"; +export { default as Arrow } from "./components/combobox-arrow.svelte"; +export { default as HiddenInput } from "./components/combobox-hidden-input.svelte"; +export { default as Separator } from "../separator/components/separator.svelte"; +export { default as ItemIndicator } from "./components/combobox-item-indicator.svelte"; + +export * from "./types.js"; diff --git a/src/lib/bits/combobox/types.ts b/src/lib/bits/combobox/types.ts new file mode 100644 index 000000000..b2b17dc39 --- /dev/null +++ b/src/lib/bits/combobox/types.ts @@ -0,0 +1,68 @@ +import type { HTMLDivAttributes, Transition } from "$lib/internal/index.js"; +import type { EventHandler, HTMLInputAttributes } from "svelte/elements"; +import type { CustomEventHandler } from "$lib/index.js"; +import type * as I from "./_types.js"; + +type Props = I.Props; + +type ContentProps< + T extends Transition = Transition, + In extends Transition = Transition, + Out extends Transition = Transition +> = I.ContentProps & HTMLDivAttributes; + +type InputProps = I.InputProps & HTMLInputAttributes; +type LabelProps = I.LabelProps & HTMLDivAttributes; + +type GroupProps = I.GroupProps & HTMLDivAttributes; +type GroupLabelProps = I.GroupLabelProps & HTMLDivAttributes; + +type ItemProps = I.ItemProps & HTMLDivAttributes; + +type HiddenInputProps = I.HiddenInputProps & HTMLInputAttributes; +type SeparatorProps = I.SeparatorProps & HTMLDivAttributes; +type IndicatorProps = I.IndicatorProps & HTMLDivAttributes; +type ArrowProps = I.ArrowProps & HTMLDivAttributes; + +type ItemEvents = { + click: CustomEventHandler; + pointermove: CustomEventHandler; + focusin: EventHandler; + keydown: EventHandler; + focusout: EventHandler; + pointerleave: EventHandler; +}; + +type ContentEvents = { + pointerleave: CustomEventHandler; + keydown: EventHandler; +}; + +type GroupLabelEvents = { + click: CustomEventHandler; +}; + +type InputEvents = { + keydown: CustomEventHandler; + input: CustomEventHandler; + click: CustomEventHandler; +}; + +export type { + Props, + ContentProps, + InputProps, + ItemProps, + LabelProps, + GroupProps, + GroupLabelProps, + ArrowProps, + HiddenInputProps, + SeparatorProps, + IndicatorProps, + // + ContentEvents, + InputEvents, + ItemEvents, + GroupLabelEvents, +}; diff --git a/src/lib/bits/index.ts b/src/lib/bits/index.ts index f46892f99..38d0f4348 100644 --- a/src/lib/bits/index.ts +++ b/src/lib/bits/index.ts @@ -6,6 +6,7 @@ export * as Button from "./button/index.js"; export * as Calendar from "./calendar/index.js"; export * as Checkbox from "./checkbox/index.js"; export * as Collapsible from "./collapsible/index.js"; +export * as Combobox from "./combobox/index.js"; export * as ContextMenu from "./context-menu/index.js"; export * as DateField from "./date-field/index.js"; export * as DatePicker from "./date-picker/index.js"; diff --git a/src/tests/combobox/Combobox.spec.ts b/src/tests/combobox/Combobox.spec.ts new file mode 100644 index 000000000..4fd777396 --- /dev/null +++ b/src/tests/combobox/Combobox.spec.ts @@ -0,0 +1,282 @@ +import { render, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import { describe, it } from "vitest"; +import ComboboxTest from "./ComboboxTest.svelte"; +import type { Item } from "./ComboboxTest.svelte"; +import { getTestKbd } from "../utils.js"; +import type { Combobox } from "$lib"; + +const kbd = getTestKbd(); + +const testItems: Item[] = [ + { + value: "1", + label: "A", + }, + { + value: "2", + label: "B", + }, + { + value: "3", + label: "C", + }, + { + value: "4", + label: "D", + }, +]; + +function setup(props: Combobox.Props = {}, options: Item[] = testItems) { + const user = userEvent.setup(); + const returned = render(ComboboxTest, { ...props, options }); + const input = returned.getByTestId("input"); + const hiddenInput = returned.getByTestId("hidden-input"); + return { + input, + user, + hiddenInput, + ...returned, + }; +} +async function open( + props: Combobox.Props = {}, + openWith: "click" | "type" | (string & {}) = "click", + inputValue?: string +) { + const returned = setup(props); + const { input, getByTestId, queryByTestId, user } = returned; + expect(queryByTestId("content")).toBeNull(); + if (openWith === "click") { + await user.click(input); + } else if (openWith === "type" && inputValue) { + await user.type(input, inputValue); + } else { + input.focus(); + await user.keyboard(openWith); + } + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + const menu = getByTestId("content"); + return { menu, ...returned }; +} + +const OPEN_KEYS = [kbd.ARROW_DOWN, kbd.ARROW_UP]; + +describe("Combobox", () => { + it("has no accessibility violations", async () => { + const { container } = render(ComboboxTest); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("has bits data attrs", async () => { + const { getByTestId } = await open(); + const parts = ["content", "input", "group", "group-label"]; + + parts.forEach((part) => { + const el = getByTestId(part); + expect(el).toHaveAttribute(`data-combobox-${part}`); + }); + + const item = getByTestId("1"); + expect(item).toHaveAttribute("data-combobox-item"); + 1; + }); + + it("opens on click", async () => { + await open(); + }); + + it.each(OPEN_KEYS)("opens on %s keydown", async (key) => { + await open({}, key); + }); + + it("doesnt display the hidden input", async () => { + const { hiddenInput } = await open(); + expect(hiddenInput).not.toBeVisible(); + }); + + it("selects item with the enter key", async () => { + const { user, queryByTestId, getByTestId } = await open(); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ENTER); + await waitFor(() => expect(queryByTestId("content")).toBeNull()); + expect(getByTestId("input")).toHaveValue("A"); + }); + + it("syncs the name prop to the hidden input", async () => { + const { hiddenInput } = setup({ name: "test" }); + expect(hiddenInput).toHaveAttribute("name", "test"); + }); + + it("syncs the value prop to the hidden input", async () => { + const { hiddenInput } = setup({ selected: { value: "test" } }); + expect(hiddenInput).toHaveValue("test"); + }); + + it("syncs the required prop to the hidden input", async () => { + const { hiddenInput } = setup({ required: true }); + expect(hiddenInput).toHaveAttribute("required"); + }); + + it("syncs the disabled prop to the hidden input", async () => { + const { hiddenInput } = setup({ disabled: true }); + await waitFor(() => expect(hiddenInput).toHaveAttribute("disabled", "")); + }); + + it("closes on escape keydown", async () => { + const { user, queryByTestId } = await open(); + await user.keyboard(kbd.ESCAPE); + expect(queryByTestId("content")).toBeNull(); + }); + + it("closes on outside click", async () => { + const { user, queryByTestId, getByTestId } = await open(); + const outside = getByTestId("outside"); + await user.click(outside); + expect(queryByTestId("content")).toBeNull(); + }); + + it("portals to the body by default", async () => { + const { menu } = await open(); + expect(menu.parentElement).toBe(document.body); + }); + + it("portals to a custom element if specified", async () => { + const { menu, getByTestId } = await open({ portal: "#portal-target" }); + const portalTarget = getByTestId("portal-target"); + expect(menu.parentElement).toBe(portalTarget); + }); + + it("does not portal if `null` is passed as portal prop", async () => { + const { menu, getByTestId } = await open({ portal: null }); + const main = getByTestId("main"); + expect(menu.parentElement).toBe(main); + }); + + it("respects the `closeOnEscape` prop", async () => { + const { user, queryByTestId } = await open({ closeOnEscape: false }); + await user.keyboard(kbd.ESCAPE); + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + }); + + it('respects the "closeOnOutsideClick" prop', async () => { + const { user, queryByTestId, getByTestId } = await open({ + closeOnOutsideClick: false, + }); + const outside = getByTestId("outside"); + await user.click(outside); + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + }); + + it("respects binding the `inputValue` prop", async () => { + const { getByTestId, user } = await open({ inputValue: "A" }); + const binding = getByTestId("input-binding"); + expect(binding).toHaveTextContent("A"); + await user.click(binding); + expect(binding).toHaveTextContent("empty"); + }); + + it("respects binding the `open` prop", async () => { + const { queryByTestId, getByTestId, user } = await open({ closeOnOutsideClick: false }); + const binding = getByTestId("open-binding"); + expect(binding).toHaveTextContent("true"); + await user.click(binding); + expect(binding).toHaveTextContent("false"); + await waitFor(() => expect(queryByTestId("content")).toBeNull()); + await user.click(binding); + expect(binding).toHaveTextContent("true"); + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + }); + + it("respects binding the `selected` prop", async () => { + const { getByTestId, user } = await open({ selected: { label: "A", value: "1" } }); + const binding = getByTestId("selected-binding"); + expect(binding).toHaveTextContent("1"); + await user.click(binding); + expect(binding).toHaveTextContent("undefined"); + }); + + it("selects items when clicked", async () => { + const { getByTestId, user, queryByTestId, hiddenInput, input } = await open(); + const item = getByTestId("1"); + await waitFor(() => expect(queryByTestId("1-indicator")).toBeNull()); + await user.click(item); + await waitFor(() => expect(queryByTestId("content")).toBeNull()); + expect(input).toHaveValue("A"); + expect(hiddenInput).toHaveValue("1"); + await user.click(input); + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + expect(item).toHaveAttribute("aria-selected", "true"); + expect(item).toHaveAttribute("data-selected"); + await waitFor(() => expect(queryByTestId("1-indicator")).not.toBeNull()); + }); + + it("navigates through the items using the keyboard", async () => { + const { getByTestId, user } = await open({}, kbd.ARROW_DOWN); + + const item0 = getByTestId("1"); + const item1 = getByTestId("2"); + const item2 = getByTestId("3"); + const item3 = getByTestId("4"); + await user.keyboard(kbd.ARROW_DOWN); + await waitFor(() => expect(item0).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_DOWN); + await waitFor(() => expect(item1).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_DOWN); + await waitFor(() => expect(item2).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_DOWN); + await waitFor(() => expect(item3).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_UP); + await waitFor(() => expect(item2).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_UP); + await waitFor(() => expect(item1).toHaveAttribute("data-highlighted")); + await user.keyboard(kbd.ARROW_UP); + await waitFor(() => expect(item0).toHaveAttribute("data-highlighted")); + }); + + it("allows items to be selected using the keyboard", async () => { + const { getByTestId, user, queryByTestId, hiddenInput, input } = await open({}, kbd.ARROW_DOWN); + + const item0 = getByTestId("1"); + const item1 = getByTestId("2"); + const item2 = getByTestId("3"); + const item3 = getByTestId("4"); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ARROW_DOWN); + await user.keyboard(kbd.ENTER); + await waitFor(() => expect(queryByTestId("content")).toBeNull()); + expect(input).toHaveValue("C"); + expect(hiddenInput).toHaveValue("3"); + await user.click(input); + await waitFor(() => expect(queryByTestId("content")).not.toBeNull()); + expect(item0).not.toHaveAttribute("data-selected"); + expect(item1).not.toHaveAttribute("data-selected"); + expect(item2).toHaveAttribute("data-selected"); + expect(item3).not.toHaveAttribute("data-selected"); + }); + + it("applies the `data-highlighted` attribute on mouseover", async () => { + const { getByTestId, user } = await open({}, kbd.ARROW_DOWN); + const item1 = getByTestId("1"); + const item2 = getByTestId("2"); + await user.hover(item1); + await waitFor(() => expect(item1).toHaveAttribute("data-highlighted")); + await user.hover(item2); + await waitFor(() => expect(item2).toHaveAttribute("data-highlighted")); + await waitFor(() => expect(item1).not.toHaveAttribute("data-highlighted")); + }); + + it("selects a default item when provided", async () => { + const { getByTestId, queryByTestId, input, hiddenInput } = await open({ + selected: { value: "2", label: "B" }, + }); + expect(queryByTestId("2-indicator")).not.toBeNull(); + expect(input).toHaveValue("B"); + expect(hiddenInput).toHaveValue("2"); + const item = getByTestId("2"); + expect(item).toHaveAttribute("aria-selected", "true"); + expect(item).toHaveAttribute("data-selected"); + }); +}); diff --git a/src/tests/combobox/ComboboxTest.svelte b/src/tests/combobox/ComboboxTest.svelte new file mode 100644 index 000000000..870f966de --- /dev/null +++ b/src/tests/combobox/ComboboxTest.svelte @@ -0,0 +1,60 @@ + + + + +
+ + + + + Options + {#each options as { value, label, disabled }} + + + x + + {label} + + {/each} + + + + +
+ + + +
+