diff --git a/.changeset/mighty-chefs-cheer.md b/.changeset/mighty-chefs-cheer.md new file mode 100644 index 000000000..4a98aab49 --- /dev/null +++ b/.changeset/mighty-chefs-cheer.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +Breaking: replace existing `Select` implementation with `Listbox` and remove standalone `Listbox` as `Select` now has the exact functionality diff --git a/.changeset/weak-ducks-brush.md b/.changeset/weak-ducks-brush.md new file mode 100644 index 000000000..b7c85c1f5 --- /dev/null +++ b/.changeset/weak-ducks-brush.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +Add support for typeahead select when the trigger is focused and content is closed via the `items` prop on `Select.Root`. diff --git a/package.json b/package.json index 2d5853890..8d8c227b6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "lint": "prettier --check . && eslint .", "lint:fix": "eslint --fix .", "test": "pnpm -F \"./packages/**\" --parallel --reporter append-only --color test", - "test:components": "pnpm -F tests test" + "test:components": "pnpm -F tests test", + "test:utils": "pnpm -F bits-ui test" + }, "keywords": [], "author": "Hunter Johnston ", diff --git a/packages/bits-ui/package.json b/packages/bits-ui/package.json index c7303c1d6..bd8b9ed5e 100644 --- a/packages/bits-ui/package.json +++ b/packages/bits-ui/package.json @@ -52,7 +52,7 @@ "@internationalized/date": "^3.5.6", "esm-env": "^1.0.0", "runed": "^0.15.2", - "svelte-toolbelt": "^0.4.1" + "svelte-toolbelt": "^0.4.4" }, "peerDependencies": { "svelte": "^5.0.0-next.1" diff --git a/packages/bits-ui/src/lib/bits/combobox/components/combobox-input.svelte b/packages/bits-ui/src/lib/bits/combobox/components/combobox-input.svelte index 9b0fa8863..9402ab788 100644 --- a/packages/bits-ui/src/lib/bits/combobox/components/combobox-input.svelte +++ b/packages/bits-ui/src/lib/bits/combobox/components/combobox-input.svelte @@ -3,7 +3,7 @@ import type { ComboboxInputProps } from "../types.js"; import { useId } from "$lib/internal/use-id.js"; import { FloatingLayer } from "$lib/bits/utilities/floating-layer/index.js"; - import { useListboxInput } from "$lib/bits/listbox/listbox.svelte.js"; + import { useSelectInput } from "$lib/bits/select/select.svelte.js"; let { id = useId(), @@ -13,7 +13,7 @@ ...restProps }: ComboboxInputProps = $props(); - const inputState = useListboxInput({ + const inputState = useSelectInput({ id: box.with(() => id), ref: box.with( () => ref, diff --git a/packages/bits-ui/src/lib/bits/combobox/components/combobox-trigger.svelte b/packages/bits-ui/src/lib/bits/combobox/components/combobox-trigger.svelte index 26c588a84..cebf121fa 100644 --- a/packages/bits-ui/src/lib/bits/combobox/components/combobox-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/combobox/components/combobox-trigger.svelte @@ -2,7 +2,7 @@ import { box, mergeProps } from "svelte-toolbelt"; import type { ComboboxTriggerProps } from "../types.js"; import { useId } from "$lib/internal/use-id.js"; - import { useListboxComboTrigger } from "$lib/bits/listbox/listbox.svelte.js"; + import { useSelectComboTrigger } from "$lib/bits/select/select.svelte.js"; let { id = useId(), @@ -12,7 +12,7 @@ ...restProps }: ComboboxTriggerProps = $props(); - const triggerState = useListboxComboTrigger({ + const triggerState = useSelectComboTrigger({ id: box.with(() => id), ref: box.with( () => ref, diff --git a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte index 0d00d83d7..a901a319d 100644 --- a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte +++ b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte @@ -3,8 +3,8 @@ import type { ComboboxRootProps } from "../types.js"; import { noop } from "$lib/internal/noop.js"; import FloatingLayer from "$lib/bits/utilities/floating-layer/components/floating-layer.svelte"; - import { useListboxRoot } from "$lib/bits/listbox/listbox.svelte.js"; - import ListboxHiddenInput from "$lib/bits/listbox/components/listbox-hidden-input.svelte"; + import { useSelectRoot } from "$lib/bits/select/select.svelte.js"; + import ListboxHiddenInput from "$lib/bits/select/components/select-hidden-input.svelte"; let { value = $bindable(), @@ -19,6 +19,7 @@ required = false, controlledOpen = false, controlledValue = false, + items = [], children, }: ComboboxRootProps = $props(); @@ -31,7 +32,7 @@ } } - useListboxRoot({ + const rootState = useSelectRoot({ type, value: box.with( () => value!, @@ -61,6 +62,7 @@ scrollAlignment: box.with(() => scrollAlignment), name: box.with(() => name), isCombobox: true, + items: box.with(() => items), }); @@ -68,14 +70,14 @@ {@render children?.()} -{#if Array.isArray(value)} - {#if value.length === 0} +{#if Array.isArray(rootState.value.current)} + {#if rootState.value.current.length === 0} {:else} - {#each value as item} + {#each rootState.value.current as item} {/each} {/if} {:else} - + {/if} diff --git a/packages/bits-ui/src/lib/bits/combobox/exports.ts b/packages/bits-ui/src/lib/bits/combobox/exports.ts index 3a00d9062..4c1bde127 100644 --- a/packages/bits-ui/src/lib/bits/combobox/exports.ts +++ b/packages/bits-ui/src/lib/bits/combobox/exports.ts @@ -4,14 +4,14 @@ export { default as Separator } from "../separator/components/separator.svelte"; export { default as Arrow } from "$lib/bits/utilities/arrow/arrow.svelte"; export { default as Trigger } from "./components/combobox-trigger.svelte"; export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; -export { default as Content } from "$lib/bits/listbox/components/listbox-content.svelte"; -export { default as ContentStatic } from "$lib/bits/listbox/components/listbox-content-static.svelte"; -export { default as Item } from "$lib/bits/listbox/components/listbox-item.svelte"; -export { default as Group } from "$lib/bits/listbox/components/listbox-group.svelte"; -export { default as GroupHeading } from "$lib/bits/listbox/components/listbox-group-heading.svelte"; -export { default as Viewport } from "$lib/bits/listbox/components/listbox-viewport.svelte"; -export { default as ScrollDownButton } from "$lib/bits/listbox/components/listbox-scroll-down-button.svelte"; -export { default as ScrollUpButton } from "$lib/bits/listbox/components/listbox-scroll-up-button.svelte"; +export { default as Content } from "$lib/bits/select/components/select-content.svelte"; +export { default as ContentStatic } from "$lib/bits/select/components/select-content-static.svelte"; +export { default as Item } from "$lib/bits/select/components/select-item.svelte"; +export { default as Group } from "$lib/bits/select/components/select-group.svelte"; +export { default as GroupHeading } from "$lib/bits/select/components/select-group-heading.svelte"; +export { default as Viewport } from "$lib/bits/select/components/select-viewport.svelte"; +export { default as ScrollDownButton } from "$lib/bits/select/components/select-scroll-down-button.svelte"; +export { default as ScrollUpButton } from "$lib/bits/select/components/select-scroll-up-button.svelte"; export type { ComboboxRootProps as RootProps, diff --git a/packages/bits-ui/src/lib/bits/combobox/types.ts b/packages/bits-ui/src/lib/bits/combobox/types.ts index 4cc4d570e..683128ecf 100644 --- a/packages/bits-ui/src/lib/bits/combobox/types.ts +++ b/packages/bits-ui/src/lib/bits/combobox/types.ts @@ -2,37 +2,37 @@ import type { BitsPrimitiveInputAttributes } from "$lib/shared/attributes.js"; import type { WithChild, Without } from "$lib/internal/types.js"; export type { - ListboxBaseRootPropsWithoutHTML as ComboboxBaseRootPropsWithoutHTML, - ListboxContentProps as ComboboxContentProps, - ListboxContentPropsWithoutHTML as ComboboxContentPropsWithoutHTML, - ListboxContentStaticProps as ComboboxContentStaticProps, - ListboxContentStaticPropsWithoutHTML as ComboboxContentStaticPropsWithoutHTML, - ListboxItemProps as ComboboxItemProps, - ListboxItemPropsWithoutHTML as ComboboxItemPropsWithoutHTML, - ListboxItemSnippetProps as ComboboxItemSnippetProps, - ListboxMultipleRootProps as ComboboxMultipleRootProps, - ListboxMultipleRootPropsWithoutHTML as ComboboxMultipleRootPropsWithoutHTML, - ListboxRootProps as ComboboxRootProps, - ListboxRootPropsWithoutHTML as ComboboxRootPropsWithoutHTML, - ListboxSingleRootProps as ComboboxSingleRootProps, - ListboxSingleRootPropsWithoutHTML as ComboboxSingleRootPropsWithoutHTML, - ListboxTriggerProps as ComboboxTriggerProps, - ListboxTriggerPropsWithoutHTML as ComboboxTriggerPropsWithoutHTML, - ListboxGroupPropsWithoutHTML as ComboboxGroupPropsWithoutHTML, - ListboxGroupProps as ComboboxGroupProps, - ListboxGroupHeadingPropsWithoutHTML as ComboboxGroupHeadingPropsWithoutHTML, - ListboxGroupHeadingProps as ComboboxGroupHeadingProps, - ListboxViewportPropsWithoutHTML as ComboboxViewportPropsWithoutHTML, - ListboxViewportProps as ComboboxViewportProps, - ListboxScrollDownButtonProps as ComboboxScrollDownButtonProps, - ListboxScrollDownButtonPropsWithoutHTML as ComboboxScrollDownButtonPropsWithoutHTML, - ListboxScrollUpButtonProps as ComboboxScrollUpButtonProps, - ListboxScrollUpButtonPropsWithoutHTML as ComboboxScrollUpButtonPropsWithoutHTML, - ListboxArrowProps as ComboboxArrowProps, - ListboxArrowPropsWithoutHTML as ComboboxArrowPropsWithoutHTML, - ListboxPortalProps as ComboboxPortalProps, - ListboxPortalPropsWithoutHTML as ComboboxPortalPropsWithoutHTML, -} from "$lib/bits/listbox/types.js"; + SelectBaseRootPropsWithoutHTML as ComboboxBaseRootPropsWithoutHTML, + SelectContentProps as ComboboxContentProps, + SelectContentPropsWithoutHTML as ComboboxContentPropsWithoutHTML, + SelectContentStaticProps as ComboboxContentStaticProps, + SelectContentStaticPropsWithoutHTML as ComboboxContentStaticPropsWithoutHTML, + SelectItemProps as ComboboxItemProps, + SelectItemPropsWithoutHTML as ComboboxItemPropsWithoutHTML, + SelectItemSnippetProps as ComboboxItemSnippetProps, + SelectMultipleRootProps as ComboboxMultipleRootProps, + SelectMultipleRootPropsWithoutHTML as ComboboxMultipleRootPropsWithoutHTML, + SelectRootProps as ComboboxRootProps, + SelectRootPropsWithoutHTML as ComboboxRootPropsWithoutHTML, + SelectSingleRootProps as ComboboxSingleRootProps, + SelectSingleRootPropsWithoutHTML as ComboboxSingleRootPropsWithoutHTML, + SelectTriggerProps as ComboboxTriggerProps, + SelectTriggerPropsWithoutHTML as ComboboxTriggerPropsWithoutHTML, + SelectGroupPropsWithoutHTML as ComboboxGroupPropsWithoutHTML, + SelectGroupProps as ComboboxGroupProps, + SelectGroupHeadingPropsWithoutHTML as ComboboxGroupHeadingPropsWithoutHTML, + SelectGroupHeadingProps as ComboboxGroupHeadingProps, + SelectViewportPropsWithoutHTML as ComboboxViewportPropsWithoutHTML, + SelectViewportProps as ComboboxViewportProps, + SelectScrollDownButtonProps as ComboboxScrollDownButtonProps, + SelectScrollDownButtonPropsWithoutHTML as ComboboxScrollDownButtonPropsWithoutHTML, + SelectScrollUpButtonProps as ComboboxScrollUpButtonProps, + SelectScrollUpButtonPropsWithoutHTML as ComboboxScrollUpButtonPropsWithoutHTML, + SelectArrowProps as ComboboxArrowProps, + SelectArrowPropsWithoutHTML as ComboboxArrowPropsWithoutHTML, + SelectPortalProps as ComboboxPortalProps, + SelectPortalPropsWithoutHTML as ComboboxPortalPropsWithoutHTML, +} from "$lib/bits/select/types.js"; export type ComboboxInputPropsWithoutHTML = WithChild<{ /** diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 53a267809..c19d8477e 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -17,7 +17,6 @@ export { Dialog } from "./dialog/index.js"; export { DropdownMenu } from "./dropdown-menu/index.js"; export { Label } from "./label/index.js"; export { LinkPreview } from "./link-preview/index.js"; -export { Listbox } from "./listbox/index.js"; export { Menubar } from "./menubar/index.js"; export { NavigationMenu } from "./navigation-menu/index.js"; export { Pagination } from "./pagination/index.js"; diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-content.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-content.svelte deleted file mode 100644 index bd033e8ff..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-content.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - { - contentState.handleInteractOutside(e); - if (e.defaultPrevented) return; - onInteractOutside(e); - if (e.defaultPrevented) return; - contentState.root.closeMenu(); - }} - onEscapeKeydown={(e) => { - onEscapeKeydown(e); - if (e.defaultPrevented) return; - contentState.root.closeMenu(); - }} - onOpenAutoFocus={(e) => e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - trapFocus={false} - loop={false} - preventScroll={false} - onPlaced={() => (contentState.isPositioned = true)} - {forceMount} -> - {#snippet popper({ props })} - {@const finalProps = mergeProps(props, { - style: { - "--bits-listbox-content-transform-origin": "var(--bits-floating-transform-origin)", - "--bits-listbox-content-available-width": "var(--bits-floating-available-width)", - "--bits-listbox-content-available-height": "var(--bits-floating-available-height)", - "--bits-listbox-anchor-width": "var(--bits-floating-anchor-width)", - "--bits-listbox-anchor-height": "var(--bits-floating-anchor-height)", - ...contentState.props.style, - }, - })} - {#if child} - {@render child({ props: finalProps, ...contentState.snippetProps })} - {:else} -
- {@render children?.()} -
- {/if} - {/snippet} -
diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-heading.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-heading.svelte deleted file mode 100644 index 378a9750d..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-heading.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} -
- {@render children?.()} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-group.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-group.svelte deleted file mode 100644 index 4a5a8e50a..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-group.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} -
- {@render children?.()} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-item.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-item.svelte deleted file mode 100644 index 4e5db2ae5..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-item.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps, ...itemState.snippetProps })} -{:else} -
- {@render children?.(itemState.snippetProps)} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-down-button.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-down-button.svelte deleted file mode 100644 index e59329e83..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-down-button.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if scrollDownButtonState.canScrollDown} - (mounted = m)} /> - {#if child} - {@render child({ props: restProps })} - {:else} -
- {@render children?.()} -
- {/if} -{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-up-button.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-up-button.svelte deleted file mode 100644 index db43e2bf4..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-scroll-up-button.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if scrollDownButtonState.canScrollUp} - (mounted = m)} /> - {#if child} - {@render child({ props: restProps })} - {:else} -
- {@render children?.()} -
- {/if} -{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-trigger.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-trigger.svelte deleted file mode 100644 index 8c2c423ed..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-trigger.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - {#if child} - {@render child({ props: mergedProps })} - {:else} - - {/if} - diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-viewport.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-viewport.svelte deleted file mode 100644 index e15600251..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-viewport.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} -
- {@render children?.()} -
-{/if} - - diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox.svelte deleted file mode 100644 index e3ad0c7bc..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - {@render children?.()} - - -{#if Array.isArray(value)} - {#if value.length === 0} - - {:else} - {#each value as item} - - {/each} - {/if} -{:else} - -{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/exports.ts b/packages/bits-ui/src/lib/bits/listbox/exports.ts deleted file mode 100644 index abf2e0746..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/exports.ts +++ /dev/null @@ -1,25 +0,0 @@ -export { default as Root } from "./components/listbox.svelte"; -export { default as Content } from "./components/listbox-content.svelte"; -export { default as ContentStatic } from "./components/listbox-content-static.svelte"; -export { default as Item } from "./components/listbox-item.svelte"; -export { default as Group } from "./components/listbox-group.svelte"; -export { default as GroupHeading } from "./components/listbox-group-heading.svelte"; -export { default as Trigger } from "./components/listbox-trigger.svelte"; -export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; -export { default as Viewport } from "./components/listbox-viewport.svelte"; -export { default as ScrollUpButton } from "./components/listbox-scroll-up-button.svelte"; -export { default as ScrollDownButton } from "./components/listbox-scroll-down-button.svelte"; - -export type { - ListboxRootProps as RootProps, - ListboxContentProps as ContentProps, - ListboxContentStaticProps as ContentStaticProps, - ListboxItemProps as ItemProps, - ListboxGroupProps as GroupProps, - ListboxGroupHeadingProps as GroupHeadingProps, - ListboxTriggerProps as TriggerProps, - ListboxViewportProps as ViewportProps, - ListboxScrollUpButtonProps as ScrollUpButtonProps, - ListboxScrollDownButtonProps as ScrollDownButtonProps, - ListboxPortalProps as PortalProps, -} from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/listbox/index.ts b/packages/bits-ui/src/lib/bits/listbox/index.ts deleted file mode 100644 index d8ae5c2a4..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as Listbox from "./exports.js"; diff --git a/packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts b/packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts deleted file mode 100644 index c1e34f397..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts +++ /dev/null @@ -1,1203 +0,0 @@ -import { Previous } from "runed"; -import { untrack } from "svelte"; -import { afterTick, srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt"; -import type { InteractOutsideEvent } from "../utilities/dismissible-layer/types.js"; -import { backward, forward, next, prev } from "$lib/internal/arrays.js"; -import { - getAriaExpanded, - getAriaHidden, - getDataDisabled, - getDataOpenClosed, - getDisabled, - getRequired, -} from "$lib/internal/attrs.js"; -import type { Box, ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; -import { createContext } from "$lib/internal/create-context.js"; -import { kbd } from "$lib/internal/kbd.js"; -import type { WithRefProps } from "$lib/internal/types.js"; -import { noop } from "$lib/internal/noop.js"; -import { addEventListener } from "$lib/internal/events.js"; -import { type Typeahead, useTypeahead } from "$lib/internal/use-typeahead.svelte.js"; - -// prettier-ignore -export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12]; - -export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; -export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; -export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; -export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; - -type ListboxBaseRootStateProps = ReadableBoxedValues<{ - disabled: boolean; - required: boolean; - name: string; - loop: boolean; - scrollAlignment: "nearest" | "center"; -}> & - WritableBoxedValues<{ - open: boolean; - }> & { - isCombobox: boolean; - }; - -class ListboxBaseRootState { - disabled: ListboxBaseRootStateProps["disabled"]; - required: ListboxBaseRootStateProps["required"]; - name: ListboxBaseRootStateProps["name"]; - loop: ListboxBaseRootStateProps["loop"]; - open: ListboxBaseRootStateProps["open"]; - scrollAlignment: ListboxBaseRootStateProps["scrollAlignment"]; - touchedInput = $state(false); - inputValue = $state(""); - inputNode = $state(null); - contentNode = $state(null); - triggerNode = $state(null); - highlightedNode = $state(null); - highlightedValue = $derived.by(() => { - if (!this.highlightedNode) return null; - return this.highlightedNode.getAttribute("data-value"); - }); - highlightedId = $derived.by(() => { - if (!this.highlightedNode) return undefined; - return this.highlightedNode.id; - }); - highlightedLabel = $derived.by(() => { - if (!this.highlightedNode) return null; - return this.highlightedNode.getAttribute("data-label"); - }); - isUsingKeyboard = $state(false); - isCombobox = $state(false); - bitsAttrs: ListboxBitsAttrs; - - constructor(props: ListboxBaseRootStateProps) { - this.disabled = props.disabled; - this.required = props.required; - this.name = props.name; - this.loop = props.loop; - this.open = props.open; - this.scrollAlignment = props.scrollAlignment; - this.isCombobox = props.isCombobox; - - this.bitsAttrs = getListboxBitsAttrs(this); - - $effect.pre(() => { - if (!this.open.current) { - this.setHighlightedNode(null); - } - }); - } - - setHighlightedNode = (node: HTMLElement | null) => { - this.highlightedNode = node; - if (node) { - if (this.isUsingKeyboard) { - node.scrollIntoView({ block: "nearest" }); - } - } - }; - - getCandidateNodes = (): HTMLElement[] => { - const node = this.contentNode; - if (!node) return []; - const nodes = Array.from( - node.querySelectorAll(`[${this.bitsAttrs.item}]:not([data-disabled])`) - ); - return nodes; - }; - - setHighlightedToFirstCandidate = () => { - this.setHighlightedNode(null); - const candidateNodes = this.getCandidateNodes(); - if (!candidateNodes.length) return; - this.setHighlightedNode(candidateNodes[0]!); - }; - - getNodeByValue = (value: string): HTMLElement | null => { - const candidateNodes = this.getCandidateNodes(); - return candidateNodes.find((node) => node.dataset.value === value) ?? null; - }; - - setOpen = (open: boolean) => { - this.open.current = open; - }; - - toggleOpen = () => { - this.open.current = !this.open.current; - }; - - openMenu = () => { - this.setOpen(true); - }; - - closeMenu = () => { - this.setHighlightedNode(null); - this.setOpen(false); - }; - - toggleMenu = () => { - this.toggleOpen(); - }; -} - -type ListboxSingleRootStateProps = ListboxBaseRootStateProps & - WritableBoxedValues<{ - value: string; - }>; - -class ListboxSingleRootState extends ListboxBaseRootState { - value: ListboxSingleRootStateProps["value"]; - isMulti = false as const; - hasValue = $derived.by(() => this.value.current !== ""); - - constructor(props: ListboxSingleRootStateProps) { - super(props); - this.value = props.value; - - $effect(() => { - if (!this.open.current && this.highlightedNode) { - this.setHighlightedNode(null); - } - }); - - $effect(() => { - if (!this.open.current) return; - afterTick(() => { - this.#setInitialHighlightedNode(); - }); - }); - } - - includesItem = (itemValue: string) => { - return this.value.current === itemValue; - }; - - toggleItem = (itemValue: string, itemLabel: string = itemValue) => { - this.value.current = this.includesItem(itemValue) ? "" : itemValue; - this.inputValue = itemLabel; - }; - - #setInitialHighlightedNode = () => { - if (this.highlightedNode) return; - if (this.value.current !== "") { - const node = this.getNodeByValue(this.value.current); - if (node) { - this.setHighlightedNode(node); - return; - } - } - // if no value is set, we want to highlight the first item - const firstCandidate = this.getCandidateNodes()[0]; - if (!firstCandidate) return; - this.setHighlightedNode(firstCandidate); - }; -} - -type ListboxMultipleRootStateProps = ListboxBaseRootStateProps & - WritableBoxedValues<{ - value: string[]; - }>; - -class ListboxMultipleRootState extends ListboxBaseRootState { - value: ListboxMultipleRootStateProps["value"]; - isMulti = true as const; - hasValue = $derived.by(() => this.value.current.length > 0); - - constructor(props: ListboxMultipleRootStateProps) { - super(props); - this.value = props.value; - - $effect(() => { - if (!this.open.current) return; - afterTick(() => { - if (!this.highlightedNode) { - this.#setInitialHighlightedNode(); - } - }); - }); - } - - includesItem = (itemValue: string) => { - return this.value.current.includes(itemValue); - }; - - toggleItem = (itemValue: string, itemLabel: string = itemValue) => { - if (this.includesItem(itemValue)) { - this.value.current = this.value.current.filter((v) => v !== itemValue); - } else { - this.value.current = [...this.value.current, itemValue]; - } - this.inputValue = itemLabel; - }; - - #setInitialHighlightedNode = () => { - if (this.highlightedNode) return; - if (this.value.current.length && this.value.current[0] !== "") { - const node = this.getNodeByValue(this.value.current[0]!); - if (node) { - this.setHighlightedNode(node); - return; - } - } - // if no value is set, we want to highlight the first item - const firstCandidate = this.getCandidateNodes()[0]; - if (!firstCandidate) return; - this.setHighlightedNode(firstCandidate); - }; -} - -type ListboxRootState = ListboxSingleRootState | ListboxMultipleRootState; - -type ListboxInputStateProps = WithRefProps; - -class ListboxInputState { - #id: ListboxInputStateProps["id"]; - #ref: ListboxInputStateProps["ref"]; - root: ListboxRootState; - - constructor(props: ListboxInputStateProps, root: ListboxRootState) { - this.root = root; - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - this.root.inputNode = node; - }, - }); - } - - #onkeydown = async (e: KeyboardEvent) => { - this.root.isUsingKeyboard = true; - if (e.key === kbd.ESCAPE) return; - const open = this.root.open.current; - const inputValue = this.root.inputValue; - - // prevent arrow up/down from moving the position of the cursor in the input - if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); - if (!open) { - if (INTERACTION_KEYS.includes(e.key)) return; - if (e.key === kbd.TAB) return; - if (e.key === kbd.BACKSPACE && inputValue === "") return; - this.root.openMenu(); - // we need to wait for a tick after the menu opens to ensure the highlighted nodes are - // set correctly. - afterTick(() => { - if (this.root.hasValue) return; - const candidateNodes = this.root.getCandidateNodes(); - if (!candidateNodes.length) return; - - if (e.key === kbd.ARROW_DOWN) { - const firstCandidate = candidateNodes[0]!; - this.root.setHighlightedNode(firstCandidate); - } else if (e.key === kbd.ARROW_UP) { - const lastCandidate = candidateNodes[candidateNodes.length - 1]!; - this.root.setHighlightedNode(lastCandidate); - } - }); - return; - } - - if (e.key === kbd.TAB) { - this.root.closeMenu(); - return; - } - - if (e.key === kbd.ENTER && !e.isComposing) { - e.preventDefault(); - const highlightedValue = this.root.highlightedValue; - if (highlightedValue) { - this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); - } - if (!this.root.isMulti) { - this.root.closeMenu(); - } - } - - if (e.key === kbd.ARROW_UP && e.altKey) { - this.root.closeMenu(); - } - - if (FIRST_LAST_KEYS.includes(e.key)) { - e.preventDefault(); - const candidateNodes = this.root.getCandidateNodes(); - const currHighlightedNode = this.root.highlightedNode; - const currIndex = currHighlightedNode - ? candidateNodes.indexOf(currHighlightedNode) - : -1; - - const loop = this.root.loop.current; - let nextItem: HTMLElement | undefined; - - if (e.key === kbd.ARROW_DOWN) { - nextItem = next(candidateNodes, currIndex, loop); - } else if (e.key === kbd.ARROW_UP) { - nextItem = prev(candidateNodes, currIndex, loop); - } else if (e.key === kbd.PAGE_DOWN) { - nextItem = forward(candidateNodes, currIndex, 10, loop); - } else if (e.key === kbd.PAGE_UP) { - nextItem = backward(candidateNodes, currIndex, 10, loop); - } else if (e.key === kbd.HOME) { - nextItem = candidateNodes[0]; - } else if (e.key === kbd.END) { - nextItem = candidateNodes[candidateNodes.length - 1]; - } - if (!nextItem) return; - this.root.setHighlightedNode(nextItem); - return; - } - - if (INTERACTION_KEYS.includes(e.key)) return; - if (!this.root.highlightedNode) { - this.root.setHighlightedToFirstCandidate(); - } - // this.root.setHighlightedToFirstCandidate(); - }; - - #oninput = (e: Event & { currentTarget: HTMLInputElement }) => { - this.root.inputValue = e.currentTarget.value; - this.root.setHighlightedToFirstCandidate(); - }; - - props = $derived.by( - () => - ({ - id: this.#id.current, - role: "combobox", - disabled: this.root.disabled.current ? true : undefined, - "aria-activedescendant": this.root.highlightedId, - "aria-autocomplete": "list", - "aria-expanded": getAriaExpanded(this.root.open.current), - "data-state": getDataOpenClosed(this.root.open.current), - "data-disabled": getDataDisabled(this.root.disabled.current), - onkeydown: this.#onkeydown, - oninput: this.#oninput, - [this.root.bitsAttrs.input]: "", - }) as const - ); -} - -type ListboxComboTriggerStateProps = WithRefProps; - -class ListboxComboTriggerState { - #id: ListboxComboTriggerStateProps["id"]; - #ref: ListboxComboTriggerStateProps["ref"]; - root: ListboxBaseRootState; - - constructor(props: ListboxComboTriggerStateProps, root: ListboxBaseRootState) { - this.root = root; - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - - #onkeydown = (e: KeyboardEvent) => { - if (e.key === kbd.ENTER || e.key === kbd.SPACE) { - e.preventDefault(); - if (document.activeElement !== this.root.inputNode) { - this.root.inputNode?.focus(); - } - this.root.toggleMenu(); - } - }; - - /** - * `pointerdown` fires before the `focus` event, so we can prevent the default - * behavior of focusing the button and keep focus on the input. - */ - #onpointerdown = (e: MouseEvent) => { - if (this.root.disabled.current) return; - e.preventDefault(); - if (document.activeElement !== this.root.inputNode) { - this.root.inputNode?.focus(); - } - this.root.toggleMenu(); - }; - - props = $derived.by( - () => - ({ - id: this.#id.current, - disabled: this.root.disabled.current ? true : undefined, - "aria-haspopup": "listbox", - "data-state": getDataOpenClosed(this.root.open.current), - "data-disabled": getDataDisabled(this.root.disabled.current), - [this.root.bitsAttrs.trigger]: "", - onpointerdown: this.#onpointerdown, - onkeydown: this.#onkeydown, - }) as const - ); -} - -type ListboxTriggerStateProps = WithRefProps; - -class ListboxTriggerState { - #id: ListboxTriggerStateProps["id"]; - #ref: ListboxTriggerStateProps["ref"]; - root: ListboxRootState; - #typeahead: Typeahead; - - constructor(props: ListboxTriggerStateProps, root: ListboxRootState) { - this.root = root; - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - this.root.triggerNode = node; - }, - }); - - this.#typeahead = useTypeahead({ - getCurrentItem: () => this.root.highlightedNode, - onMatch: (node) => { - this.root.setHighlightedNode(node); - }, - }); - } - - #onkeydown = (e: KeyboardEvent) => { - this.root.isUsingKeyboard = true; - if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); - - if (!this.root.open.current) { - if (e.key === kbd.ENTER) return; - - if (e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_UP) { - e.preventDefault(); - this.root.openMenu(); - } - - // we need to wait for a tick after the menu opens to ensure - // the highlighted nodes are set correctly - afterTick(() => { - if (this.root.hasValue) return; - const candidateNodes = this.root.getCandidateNodes(); - if (!candidateNodes.length) return; - - if (e.key === kbd.ARROW_DOWN) { - const firstCandidate = candidateNodes[0]!; - this.root.setHighlightedNode(firstCandidate); - } else if (e.key === kbd.ARROW_UP) { - const lastCandidate = candidateNodes[candidateNodes.length - 1]!; - this.root.setHighlightedNode(lastCandidate); - } - }); - return; - } - - if (e.key === kbd.TAB) { - this.root.closeMenu(); - return; - } - - if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) { - e.preventDefault(); - const highlightedValue = this.root.highlightedValue; - if (highlightedValue) { - this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); - } - if (!this.root.isMulti) { - this.root.closeMenu(); - } - } - - if (e.key === kbd.ARROW_UP && e.altKey) { - this.root.closeMenu(); - } - - if (FIRST_LAST_KEYS.includes(e.key)) { - e.preventDefault(); - const candidateNodes = this.root.getCandidateNodes(); - const currHighlightedNode = this.root.highlightedNode; - const currIndex = currHighlightedNode - ? candidateNodes.indexOf(currHighlightedNode) - : -1; - - const loop = this.root.loop.current; - let nextItem: HTMLElement | undefined; - - if (e.key === kbd.ARROW_DOWN) { - nextItem = next(candidateNodes, currIndex, loop); - } else if (e.key === kbd.ARROW_UP) { - nextItem = prev(candidateNodes, currIndex, loop); - } else if (e.key === kbd.PAGE_DOWN) { - nextItem = forward(candidateNodes, currIndex, 10, loop); - } else if (e.key === kbd.PAGE_UP) { - nextItem = backward(candidateNodes, currIndex, 10, loop); - } else if (e.key === kbd.HOME) { - nextItem = candidateNodes[0]; - } else if (e.key === kbd.END) { - nextItem = candidateNodes[candidateNodes.length - 1]; - } - if (!nextItem) return; - this.root.setHighlightedNode(nextItem); - return; - } - const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; - const isCharacterKey = e.key.length === 1; - - // prevent space from being considered with typeahead - if (e.code === "Space") return; - - const candidateNodes = this.root.getCandidateNodes(); - - if (e.key === kbd.TAB) return; - - if (!isModifierKey && isCharacterKey) { - this.#typeahead.handleTypeaheadSearch(e.key, candidateNodes); - return; - } - - if (!this.root.highlightedNode) { - this.root.setHighlightedToFirstCandidate(); - } - }; - - /** - * `pointerdown` fires before the `focus` event, so we can prevent the default - * behavior of focusing the button and keep focus on the input. - */ - #onpointerdown = () => { - if (this.root.disabled.current) return; - this.root.toggleMenu(); - }; - - props = $derived.by( - () => - ({ - id: this.#id.current, - disabled: this.root.disabled.current ? true : undefined, - "aria-haspopup": "listbox", - "data-state": getDataOpenClosed(this.root.open.current), - "data-disabled": getDataDisabled(this.root.disabled.current), - [this.root.bitsAttrs.trigger]: "", - onpointerdown: this.#onpointerdown, - onkeydown: this.#onkeydown, - // onclick: this.#onclick, - }) as const - ); -} - -type ListboxContentStateProps = WithRefProps; - -class ListboxContentState { - #id: ListboxContentStateProps["id"]; - #ref: ListboxContentStateProps["ref"]; - viewportNode = $state(null); - root: ListboxBaseRootState; - isPositioned = $state(false); - - constructor(props: ListboxContentStateProps, root: ListboxBaseRootState) { - this.root = root; - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - this.root.contentNode = node; - }, - deps: () => this.root.open.current, - }); - - $effect(() => { - return () => { - this.root.contentNode = null; - }; - }); - - $effect(() => { - if (this.root.open.current === false) { - this.isPositioned = false; - } - }); - } - - #onpointermove = () => { - this.root.isUsingKeyboard = false; - }; - - #styles = $derived.by(() => { - if (this.root.isCombobox) { - return { - "--bits-combobox-content-transform-origin": "var(--bits-floating-transform-origin)", - "--bits-combobox-content-available-width": "var(--bits-floating-available-width)", - "--bits-combobox-content-available-height": "var(--bits-floating-available-height)", - "--bits-combobox-anchor-width": "var(--bits-floating-anchor-width)", - "--bits-combobox-anchor-height": "var(--bits-floating-anchor-height)", - }; - } else { - return { - "--bits-listbox-content-transform-origin": "var(--bits-floating-transform-origin)", - "--bits-listbox-content-available-width": "var(--bits-floating-available-width)", - "--bits-listbox-content-available-height": "var(--bits-floating-available-height)", - "--bits-listbox-anchor-width": "var(--bits-floating-anchor-width)", - "--bits-listbox-anchor-height": "var(--bits-floating-anchor-height)", - }; - } - }); - - handleInteractOutside = (e: InteractOutsideEvent) => { - if (e.target === this.root.triggerNode || e.target === this.root.inputNode) { - e.preventDefault(); - } - }; - - snippetProps = $derived.by(() => ({ open: this.root.open.current })); - - props = $derived.by( - () => - ({ - id: this.#id.current, - role: "listbox", - "data-state": getDataOpenClosed(this.root.open.current), - [this.root.bitsAttrs.content]: "", - style: { - display: "flex", - flexDirection: "column", - outline: "none", - boxSizing: "border-box", - ...this.#styles, - }, - onpointermove: this.#onpointermove, - }) as const - ); -} - -type ListboxItemStateProps = WithRefProps< - ReadableBoxedValues<{ - value: string; - disabled: boolean; - label: string; - onHighlight: () => void; - onUnhighlight: () => void; - }> ->; - -class ListboxItemState { - #id: ListboxItemStateProps["id"]; - #ref: ListboxItemStateProps["ref"]; - root: ListboxRootState; - value: ListboxItemStateProps["value"]; - label: ListboxItemStateProps["label"]; - onHighlight: ListboxItemStateProps["onHighlight"]; - onUnhighlight: ListboxItemStateProps["onUnhighlight"]; - disabled: ListboxItemStateProps["disabled"]; - isSelected = $derived.by(() => this.root.includesItem(this.value.current)); - isHighlighted = $derived.by(() => this.root.highlightedValue === this.value.current); - prevHighlighted = new Previous(() => this.isHighlighted); - - constructor(props: ListboxItemStateProps, root: ListboxRootState) { - this.root = root; - this.value = props.value; - this.disabled = props.disabled; - this.label = props.label; - this.onHighlight = props.onHighlight; - this.onUnhighlight = props.onUnhighlight; - this.#id = props.id; - this.#ref = props.ref; - - $effect(() => { - if (this.isHighlighted) { - this.onHighlight.current(); - } else if (this.prevHighlighted.current) { - this.onUnhighlight.current(); - } - }); - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - - snippetProps = $derived.by(() => ({ - selected: this.isSelected, - highlighted: this.isHighlighted, - })); - - #onpointerdown = (e: PointerEvent) => { - // prevent focus from leaving the combobox - e.preventDefault(); - }; - - /** - * Using `pointerup` instead of `click` allows power users to pointerdown - * the trigger, then release pointerup on an item to select it vs having to do - * multiple clicks. - */ - #onpointerup = (e: PointerEvent) => { - // prevent any default behavior - e.preventDefault(); - if (this.disabled.current) return; - const isCurrentSelectedValue = this.value.current === this.root.value.current; - this.root.toggleItem(this.value.current, this.label.current); - - if (!this.root.isMulti && !isCurrentSelectedValue) { - this.root.closeMenu(); - } - }; - - #onpointermove = (_: PointerEvent) => { - if (this.root.highlightedNode !== this.#ref.current) { - this.root.setHighlightedNode(this.#ref.current); - } - }; - - props = $derived.by( - () => - ({ - id: this.#id.current, - "aria-selected": this.root.includesItem(this.value.current) ? "true" : undefined, - "data-value": this.value.current, - "data-disabled": getDataDisabled(this.disabled.current), - "data-highlighted": - this.root.highlightedValue === this.value.current ? "" : undefined, - "data-selected": this.root.includesItem(this.value.current) ? "" : undefined, - "data-label": this.label.current, - [this.root.bitsAttrs.item]: "", - - onpointermove: this.#onpointermove, - onpointerdown: this.#onpointerdown, - onpointerup: this.#onpointerup, - }) as const - ); -} - -type ListboxGroupStateProps = WithRefProps; - -class ListboxGroupState { - #id: ListboxGroupStateProps["id"]; - #ref: ListboxGroupStateProps["ref"]; - root: ListboxBaseRootState; - labelNode = $state(null); - - constructor(props: ListboxGroupStateProps, root: ListboxBaseRootState) { - this.#id = props.id; - this.#ref = props.ref; - this.root = root; - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - role: "group", - [this.root.bitsAttrs.group]: "", - "aria-labelledby": this.labelNode?.id ?? undefined, - }) as const - ); -} - -type ListboxGroupHeadingStateProps = WithRefProps; - -class ListboxGroupHeadingState { - #id: ListboxGroupHeadingStateProps["id"]; - #ref: ListboxGroupHeadingStateProps["ref"]; - group: ListboxGroupState; - - constructor(props: ListboxGroupHeadingStateProps, group: ListboxGroupState) { - this.#id = props.id; - this.#ref = props.ref; - this.group = group; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - group.labelNode = node; - }, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - [this.group.root.bitsAttrs["group-label"]]: "", - }) as const - ); -} - -type ListboxHiddenInputStateProps = ReadableBoxedValues<{ - value: string; -}>; - -class ListboxHiddenInputState { - #value: ListboxHiddenInputStateProps["value"]; - root: ListboxBaseRootState; - shouldRender = $derived.by(() => this.root.name.current !== ""); - - constructor(props: ListboxHiddenInputStateProps, root: ListboxBaseRootState) { - this.root = root; - this.#value = props.value; - } - - props = $derived.by( - () => - ({ - disabled: getDisabled(this.root.disabled.current), - required: getRequired(this.root.required.current), - name: this.root.name.current, - value: this.#value.current, - "aria-hidden": getAriaHidden(true), - style: styleToString(srOnlyStyles), - }) as const - ); -} - -type ListboxViewportStateProps = WithRefProps; - -class ListboxViewportState { - #id: ListboxViewportStateProps["id"]; - #ref: ListboxViewportStateProps["ref"]; - root: ListboxBaseRootState; - content: ListboxContentState; - - constructor(props: ListboxViewportStateProps, content: ListboxContentState) { - this.#id = props.id; - this.#ref = props.ref; - this.content = content; - this.root = content.root; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - this.content.viewportNode = node; - }, - deps: () => this.root.open.current, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - role: "presentation", - [this.root.bitsAttrs.viewport]: "", - style: { - // we use position: 'relative' here on the `viewport` so that when we call - // `selectedItem.offsetTop` in calculations, the offset is relative to the viewport - // (independent of the scrollUpButton). - position: "relative", - flex: 1, - overflow: "auto", - }, - }) as const - ); -} - -type ListboxScrollButtonImplStateProps = WithRefProps>; - -class ListboxScrollButtonImplState { - id: ListboxScrollButtonImplStateProps["id"]; - ref: ListboxScrollButtonImplStateProps["ref"]; - content: ListboxContentState; - root: ListboxBaseRootState; - autoScrollTimer = $state(null); - onAutoScroll: () => void = noop; - mounted: ListboxScrollButtonImplStateProps["mounted"]; - - constructor(props: ListboxScrollButtonImplStateProps, content: ListboxContentState) { - this.ref = props.ref; - this.id = props.id; - this.mounted = props.mounted; - this.content = content; - this.root = content.root; - - useRefById({ - id: this.id, - ref: this.ref, - deps: () => this.mounted.current, - }); - - $effect(() => { - if (!this.mounted.current) return; - const activeItem = untrack(() => this.root.highlightedNode); - activeItem?.scrollIntoView({ block: "nearest" }); - }); - } - - clearAutoScrollTimer = () => { - if (this.autoScrollTimer === null) return; - window.clearInterval(this.autoScrollTimer); - this.autoScrollTimer = null; - }; - - #onpointerdown = () => { - if (this.autoScrollTimer !== null) return; - this.autoScrollTimer = window.setInterval(() => { - this.onAutoScroll(); - }, 50); - }; - - #onpointermove = () => { - if (this.autoScrollTimer !== null) return; - this.autoScrollTimer = window.setInterval(() => { - this.onAutoScroll(); - }, 50); - }; - - #onpointerleave = () => { - this.clearAutoScrollTimer(); - }; - - props = $derived.by( - () => - ({ - id: this.id.current, - "aria-hidden": getAriaHidden(true), - style: { - flexShrink: 0, - }, - onpointerdown: this.#onpointerdown, - onpointermove: this.#onpointermove, - onpointerleave: this.#onpointerleave, - }) as const - ); -} - -class ListboxScrollDownButtonState { - state: ListboxScrollButtonImplState; - content: ListboxContentState; - root: ListboxBaseRootState; - canScrollDown = $state(false); - - constructor(state: ListboxScrollButtonImplState) { - this.state = state; - this.content = state.content; - this.root = state.root; - this.state.onAutoScroll = this.handleAutoScroll; - - $effect(() => { - const viewport = this.content.viewportNode; - const isPositioned = this.content.isPositioned; - if (!viewport || !isPositioned) return; - - let cleanup = noop; - - untrack(() => { - const handleScroll = () => { - afterTick(() => { - const maxScroll = viewport.scrollHeight - viewport.clientHeight; - const paddingTop = Number.parseInt( - getComputedStyle(viewport).paddingTop, - 10 - ); - - this.canScrollDown = Math.ceil(viewport.scrollTop) < maxScroll - paddingTop; - }); - }; - handleScroll(); - - cleanup = addEventListener(viewport, "scroll", handleScroll); - }); - - return cleanup; - }); - - $effect(() => { - if (this.state.mounted.current) return; - this.state.clearAutoScrollTimer(); - }); - } - - handleAutoScroll = () => { - afterTick(() => { - const viewport = this.content.viewportNode; - const selectedItem = this.root.highlightedNode; - if (!viewport || !selectedItem) return; - viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight; - }); - }; - - props = $derived.by( - () => ({ ...this.state.props, [this.root.bitsAttrs["scroll-down-button"]]: "" }) as const - ); -} - -class ListboxScrollUpButtonState { - state: ListboxScrollButtonImplState; - content: ListboxContentState; - root: ListboxBaseRootState; - canScrollUp = $state(false); - - constructor(state: ListboxScrollButtonImplState) { - this.state = state; - this.content = state.content; - this.root = state.root; - this.state.onAutoScroll = this.handleAutoScroll; - - $effect(() => { - const viewport = this.content.viewportNode; - const isPositioned = this.content.isPositioned; - if (!viewport || !isPositioned) return; - - let cleanup = noop; - - untrack(() => { - const handleScroll = () => { - const paddingTop = Number.parseInt(getComputedStyle(viewport).paddingTop, 10); - this.canScrollUp = viewport.scrollTop - paddingTop > 0; - }; - handleScroll(); - - cleanup = addEventListener(viewport, "scroll", handleScroll); - }); - - return cleanup; - }); - - $effect(() => { - if (this.state.mounted.current) return; - this.state.clearAutoScrollTimer(); - }); - } - - handleAutoScroll = () => { - afterTick(() => { - const viewport = this.content.viewportNode; - const selectedItem = this.root.highlightedNode; - if (!viewport || !selectedItem) return; - viewport.scrollTop = viewport.scrollTop - selectedItem.offsetHeight; - }); - }; - - props = $derived.by( - () => ({ ...this.state.props, [this.root.bitsAttrs["scroll-up-button"]]: "" }) as const - ); -} - -type InitListboxProps = { - type: "single" | "multiple"; - value: Box | Box; -} & ReadableBoxedValues<{ - disabled: boolean; - required: boolean; - loop: boolean; - scrollAlignment: "nearest" | "center"; - name: string; -}> & - WritableBoxedValues<{ - open: boolean; - }> & { - isCombobox: boolean; - }; - -const [setListboxRootContext, getListboxRootContext] = createContext([ - "Listbox.Root", - "Combobox.Root", -]); - -const [setListboxGroupContext, getListboxGroupContext] = createContext([ - "Listbox.Group", - "Combobox.Group", -]); - -const [setListboxContentContext, getListboxContentContext] = createContext([ - "Listbox.Content", - "Combobox.Content", -]); - -export function useListboxRoot(props: InitListboxProps) { - const { type, ...rest } = props; - - const rootState = - type === "single" - ? new ListboxSingleRootState(rest as ListboxSingleRootStateProps) - : new ListboxMultipleRootState(rest as ListboxMultipleRootStateProps); - - return setListboxRootContext(rootState); -} - -export function useListboxInput(props: ListboxInputStateProps) { - return new ListboxInputState(props, getListboxRootContext()); -} - -export function useListboxContent(props: ListboxContentStateProps) { - return setListboxContentContext(new ListboxContentState(props, getListboxRootContext())); -} - -export function useListboxTrigger(props: ListboxTriggerStateProps) { - return new ListboxTriggerState(props, getListboxRootContext()); -} - -export function useListboxComboTrigger(props: ListboxComboTriggerStateProps) { - return new ListboxComboTriggerState(props, getListboxRootContext()); -} - -export function useListboxItem(props: ListboxItemStateProps) { - return new ListboxItemState(props, getListboxRootContext()); -} - -export function useListboxViewport(props: ListboxViewportStateProps) { - return new ListboxViewportState(props, getListboxContentContext()); -} - -export function useListboxScrollUpButton(props: ListboxScrollButtonImplStateProps) { - return new ListboxScrollUpButtonState( - new ListboxScrollButtonImplState(props, getListboxContentContext()) - ); -} - -export function useListboxScrollDownButton(props: ListboxScrollButtonImplStateProps) { - return new ListboxScrollDownButtonState( - new ListboxScrollButtonImplState(props, getListboxContentContext()) - ); -} - -export function useListboxGroup(props: ListboxGroupStateProps) { - return setListboxGroupContext(new ListboxGroupState(props, getListboxRootContext())); -} - -export function useListboxGroupHeading(props: ListboxGroupHeadingStateProps) { - return new ListboxGroupHeadingState(props, getListboxGroupContext()); -} - -export function useListboxHiddenInput(props: ListboxHiddenInputStateProps) { - return new ListboxHiddenInputState(props, getListboxRootContext()); -} - -//////////////////////////////////// -// Helpers -//////////////////////////////////// - -const listboxParts = [ - "trigger", - "content", - "item", - "viewport", - "scroll-up-button", - "scroll-down-button", - "group", - "group-label", - "separator", - "arrow", - "input", -] as const; - -type ListboxBitsAttrs = Record<(typeof listboxParts)[number], string>; - -export function getListboxBitsAttrs(root: ListboxBaseRootState): ListboxBitsAttrs { - const isCombobox = root.isCombobox; - const attrObj = {} as ListboxBitsAttrs; - for (const part of listboxParts) { - attrObj[part] = isCombobox ? `data-combobox-${part}` : `data-listbox-${part}`; - } - return attrObj; -} diff --git a/packages/bits-ui/src/lib/bits/listbox/types.ts b/packages/bits-ui/src/lib/bits/listbox/types.ts deleted file mode 100644 index ce0d22659..000000000 --- a/packages/bits-ui/src/lib/bits/listbox/types.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { Expand } from "svelte-toolbelt"; -import type { PortalProps } from "../utilities/portal/types.js"; -import type { PopperLayerProps, PopperLayerStaticProps } from "../utilities/popper-layer/types.js"; -import type { ArrowProps, ArrowPropsWithoutHTML } from "../utilities/arrow/types.js"; -import type { - BitsPrimitiveButtonAttributes, - BitsPrimitiveDivAttributes, -} from "$lib/shared/attributes.js"; -import type { - OnChangeFn, - WithChild, - WithChildNoChildrenSnippetProps, - WithChildren, - Without, -} from "$lib/internal/types.js"; - -export type ListboxBaseRootPropsWithoutHTML = WithChildren<{ - /** - * Whether the combobox is disabled. - * - * @defaultValue `false` - */ - disabled?: boolean; - - /** - * Whether the combobox is required (for form submission). - * - * @defaultValue `false` - */ - required?: boolean; - - /** - * The name to apply to the hidden input element for form submission. - * If not provided, a hidden input will not be rendered and the combobox will not be part of a form. - */ - name?: string; - - /** - * Whether the combobox popover is open. - * - * @defaultValue `false` - * @bindable - */ - open?: boolean; - - /** - * A callback function called when the open state changes. - */ - onOpenChange?: OnChangeFn; - - /** - * Whether or not the combobox menu should loop through the items when navigating with the keyboard. - * - * @defaultValue `false` - */ - loop?: boolean; - - /** - * How to scroll the combobox items into view when navigating with the keyboard. - * - * @defaultValue `"nearest"` - */ - scrollAlignment?: "nearest" | "center"; - - /** - * Whether or not the open state is controlled or not. If `true`, the component will not update - * the open state internally, instead it will call `onOpenChange` when it would have - * otherwise, and it is up to you to update the `open` prop that is passed to the component. - * - * @defaultValue false - */ - controlledOpen?: boolean; - - /** - * Whether or not the value state is controlled or not. If `true`, the component will not update - * the value state internally, instead it will call `onValueChange` when it would have - * otherwise, and it is up to you to update the `value` prop that is passed to the component. - * - * @defaultValue false - */ - controlledValue?: boolean; -}>; - -export type ListboxSingleRootPropsWithoutHTML = { - /** - * The value of the selected combobox item. - * - * @bindable - */ - value?: string; - - /** - * A callback function called when the value changes. - */ - onValueChange?: OnChangeFn; - - /** - * The type of combobox. - * - * @required - */ - type: "single"; -}; - -export type ListboxMultipleRootPropsWithoutHTML = { - /** - * The value of the selected combobox item. - * - * @bindable - */ - value?: string[]; - - /** - * A callback function called when the value changes. - */ - onValueChange?: OnChangeFn; - - /** - * The type of combobox. - * - * @required - */ - type: "multiple"; -}; - -export type ListboxSingleRootProps = ListboxBaseRootPropsWithoutHTML & - ListboxSingleRootPropsWithoutHTML & - Without< - BitsPrimitiveDivAttributes, - ListboxSingleRootPropsWithoutHTML | ListboxBaseRootPropsWithoutHTML - >; - -export type ListboxMultipleRootProps = ListboxBaseRootPropsWithoutHTML & - ListboxMultipleRootPropsWithoutHTML & - Without< - BitsPrimitiveDivAttributes, - ListboxMultipleRootPropsWithoutHTML | ListboxBaseRootPropsWithoutHTML - >; - -export type ListboxRootPropsWithoutHTML = ListboxBaseRootPropsWithoutHTML & - (ListboxSingleRootPropsWithoutHTML | ListboxMultipleRootPropsWithoutHTML); - -export type ListboxRootProps = ListboxRootPropsWithoutHTML; - -export type _SharedListboxContentProps = { - /** - * Whether or not to loop through the items when navigating with the keyboard. - * - * @defaultValue `false` - */ - loop?: boolean; -}; - -export type ListboxContentSnippetProps = { - /** - * Whether the content is open or closed. Used alongside the `forceMount` prop to conditionally - * render the content using Svelte transitions. - */ - open: boolean; -}; - -export type ListboxContentPropsWithoutHTML = Expand< - WithChildNoChildrenSnippetProps< - Omit & - _SharedListboxContentProps, - ListboxContentSnippetProps - > ->; - -export type ListboxContentProps = ListboxContentPropsWithoutHTML & - Without; - -export type ListboxContentStaticPropsWithoutHTML = Expand< - WithChildNoChildrenSnippetProps< - Omit & - _SharedListboxContentProps, - ListboxContentSnippetProps - > ->; - -export type ListboxContentStaticProps = ListboxContentStaticPropsWithoutHTML & - Without; - -export type ListboxTriggerPropsWithoutHTML = WithChild; - -export type ListboxTriggerProps = ListboxTriggerPropsWithoutHTML & - Without; - -export type ListboxItemSnippetProps = { selected: boolean; highlighted: boolean }; - -export type ListboxItemPropsWithoutHTML = WithChild< - { - /** - * The value of the item. - * - * @required - */ - value: string; - - /** - * The label of the item. If provided, this is the item that users will search for. - * If not provided, the value will be used as the label. - */ - label?: string; - - /** - * Whether the item is disabled. - * - * @defaultValeu `false` - */ - disabled?: boolean; - - /** - * A callback function called when the item is highlighted. This can be used as a - * replacement for `onfocus` since we don't actually focus the item and instead - * rely on the `aria-activedescendant` attribute to indicate the highlighted item. - */ - onHighlight?: () => void; - - /** - * A callback function called when the item is unhighlighted. This can be used as a - * replacement for `onblur` since we don't actually focus the item and instead - * rely on the `aria-activedescendant` attribute to indicate the highlighted item. - */ - onUnhighlight?: () => void; - }, - ListboxItemSnippetProps ->; - -export type ListboxItemProps = ListboxItemPropsWithoutHTML & - Without; - -export type ListboxGroupPropsWithoutHTML = WithChild; - -export type ListboxGroupProps = ListboxGroupPropsWithoutHTML & - Without; - -export type ListboxGroupHeadingPropsWithoutHTML = WithChild; - -export type ListboxGroupHeadingProps = ListboxGroupHeadingPropsWithoutHTML & - Without; - -export type ListboxSeparatorPropsWithoutHTML = WithChild; - -export type ListboxSeparatorProps = ListboxSeparatorPropsWithoutHTML & - Without; - -export type ListboxPortalPropsWithoutHTML = PortalProps; - -export type ListboxPortalProps = ListboxPortalPropsWithoutHTML; - -export type ListboxArrowPropsWithoutHTML = ArrowPropsWithoutHTML; - -export type ListboxArrowProps = ArrowProps; - -export type ListboxViewportPropsWithoutHTML = WithChild; - -export type ListboxViewportProps = ListboxViewportPropsWithoutHTML & - Without; - -export type ListboxScrollUpButtonPropsWithoutHTML = WithChild; - -export type ListboxScrollUpButtonProps = ListboxScrollUpButtonPropsWithoutHTML & - Without; - -export type ListboxScrollDownButtonPropsWithoutHTML = WithChild; - -export type ListboxScrollDownButtonProps = ListboxScrollDownButtonPropsWithoutHTML & - Without; diff --git a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts index ff26ed641..23b753d30 100644 --- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts @@ -23,7 +23,7 @@ import { focusFirst } from "$lib/internal/focus.js"; import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; import { addEventListener } from "$lib/internal/events.js"; import type { AnyFn, WithRefProps } from "$lib/internal/types.js"; -import { useTypeahead } from "$lib/internal/use-typeahead.svelte.js"; +import { useDOMTypeahead } from "$lib/internal/use-dom-typeahead.svelte.js"; import { isElement, isElementOrSVGElement, isHTMLElement } from "$lib/internal/is.js"; import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.js"; import { kbd } from "$lib/internal/kbd.js"; @@ -180,7 +180,7 @@ class MenuContentState { #pointerGraceIntent = $state(null); #pointerDir = $state("right"); #lastPointerX = $state(0); - #handleTypeaheadSearch: ReturnType["handleTypeaheadSearch"]; + #handleTypeaheadSearch: ReturnType["handleTypeaheadSearch"]; rovingFocusGroup: ReturnType; isMounted: MenuContentStateProps["isMounted"]; isFocusWithin = new IsFocusWithin(() => this.parentMenu.contentNode ?? undefined); @@ -208,7 +208,7 @@ class MenuContentState { window.clearTimeout(this.#timer); }); - this.#handleTypeaheadSearch = useTypeahead().handleTypeaheadSearch; + this.#handleTypeaheadSearch = useDOMTypeahead().handleTypeaheadSearch; this.rovingFocusGroup = useRovingFocus({ rootNodeId: this.parentMenu.contentId, candidateAttr: this.parentMenu.root.getAttr("item"), diff --git a/packages/bits-ui/src/lib/bits/menubar/menubar.svelte.ts b/packages/bits-ui/src/lib/bits/menubar/menubar.svelte.ts index e37e8b503..5575b9d45 100644 --- a/packages/bits-ui/src/lib/bits/menubar/menubar.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menubar/menubar.svelte.ts @@ -10,7 +10,7 @@ import type { Direction } from "$lib/shared/index.js"; import { createContext } from "$lib/internal/create-context.js"; import { getAriaExpanded, getDataDisabled, getDataOpenClosed } from "$lib/internal/attrs.js"; import { kbd } from "$lib/internal/kbd.js"; -import { wrapArray } from "$lib/internal/use-typeahead.svelte.js"; +import { wrapArray } from "$lib/internal/arrays.js"; import { isBrowser } from "$lib/internal/is.js"; import type { WithRefProps } from "$lib/internal/types.js"; diff --git a/packages/bits-ui/src/lib/bits/select/components/select-arrow.svelte b/packages/bits-ui/src/lib/bits/select/components/select-arrow.svelte deleted file mode 100644 index 4283053e1..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-arrow.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/packages/bits-ui/src/lib/bits/select/components/select-content-floating.svelte b/packages/bits-ui/src/lib/bits/select/components/select-content-floating.svelte deleted file mode 100644 index c876fdf65..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-content-floating.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - - - {#snippet content({ props })} - - {@const finalProps = mergeProps(props, mergedProps, { - style: contentFloatingState.props.style, - })} - {#if child} - {@render child({ props: finalProps })} - {:else} -
- {@render children?.()} -
- {/if} - {/snippet} -
diff --git a/packages/bits-ui/src/lib/bits/select/components/select-content-impl.svelte b/packages/bits-ui/src/lib/bits/select/components/select-content-impl.svelte deleted file mode 100644 index 68d501ea7..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-content-impl.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - { - onOpenAutoFocus(e); - e.preventDefault(); - }} - onCloseAutoFocus={(e) => { - onCloseAutoFocus(e); - }} -> - {#snippet focusScope({ props: focusScopeProps })} - { - onEscapeKeydown(e); - if (e.defaultPrevented) return; - contentState.root.handleClose(); - }} - > - { - onInteractOutside(e); - if (e.defaultPrevented) return; - contentState.root.handleClose(); - }} - > - {#snippet children({ props: dismissibleProps })} - - {@const mergedProps = mergeProps( - restWithoutChildren, - dismissibleProps, - focusScopeProps, - contentState.props, - { style: { pointerEvents: "auto" } } - ) as any} - {#if position === "floating"} - (contentState.isPositioned.current = true)} - /> - {:else} - (contentState.isPositioned.current = true)} - /> - {/if} - - {/snippet} - - - {/snippet} - diff --git a/packages/bits-ui/src/lib/bits/select/components/select-content-item-aligned.svelte b/packages/bits-ui/src/lib/bits/select/components/select-content-item-aligned.svelte deleted file mode 100644 index 4059ba7bc..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-content-item-aligned.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -
- {#if child} - {@render child({ props: mergedProps })} - {:else} -
- {@render children?.()} -
- {/if} -
diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-content-static.svelte b/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte similarity index 84% rename from packages/bits-ui/src/lib/bits/listbox/components/listbox-content-static.svelte rename to packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte index 83106906f..ea6601dbf 100644 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-content-static.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte @@ -1,7 +1,7 @@ -{#if isPresent} - - {#snippet presence({ present })} - {@const finalProps = restProps as any} - - {/snippet} - -{:else if contentState.root.contentFragment} - -
- - {@render restProps.children?.()} - -
-
-{/if} + { + contentState.handleInteractOutside(e); + if (e.defaultPrevented) return; + onInteractOutside(e); + if (e.defaultPrevented) return; + contentState.root.handleClose(); + }} + onEscapeKeydown={(e) => { + onEscapeKeydown(e); + if (e.defaultPrevented) return; + contentState.root.handleClose(); + }} + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + trapFocus={false} + loop={false} + preventScroll={false} + onPlaced={() => (contentState.isPositioned = true)} + {forceMount} +> + {#snippet popper({ props })} + {@const finalProps = mergeProps(props, { + style: { + "--bits-select-content-transform-origin": "var(--bits-floating-transform-origin)", + "--bits-select-content-available-width": "var(--bits-floating-available-width)", + "--bits-select-content-available-height": "var(--bits-floating-available-height)", + "--bits-select-anchor-width": "var(--bits-floating-anchor-width)", + "--bits-select-anchor-height": "var(--bits-floating-anchor-height)", + ...contentState.props.style, + }, + })} + {#if child} + {@render child({ props: finalProps, ...contentState.snippetProps })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/snippet} +
diff --git a/packages/bits-ui/src/lib/bits/select/components/select-group-heading.svelte b/packages/bits-ui/src/lib/bits/select/components/select-group-heading.svelte index b91971fa4..3a0a569be 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-group-heading.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-group-heading.svelte @@ -5,10 +5,10 @@ import { useId } from "$lib/internal/use-id.js"; let { - children, - child, - ref = $bindable(null), id = useId(), + ref = $bindable(null), + child, + children, ...restProps }: SelectGroupHeadingProps = $props(); diff --git a/packages/bits-ui/src/lib/bits/select/components/select-group.svelte b/packages/bits-ui/src/lib/bits/select/components/select-group.svelte index 72a48ce69..8d422e2b1 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-group.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-group.svelte @@ -1,14 +1,14 @@ diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-hidden-input.svelte b/packages/bits-ui/src/lib/bits/select/components/select-hidden-input.svelte similarity index 60% rename from packages/bits-ui/src/lib/bits/listbox/components/listbox-hidden-input.svelte rename to packages/bits-ui/src/lib/bits/select/components/select-hidden-input.svelte index 6391f9c0c..b899386a5 100644 --- a/packages/bits-ui/src/lib/bits/listbox/components/listbox-hidden-input.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-hidden-input.svelte @@ -1,21 +1,21 @@ {#if hiddenInputState.shouldRender} - + {/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-icon.svelte b/packages/bits-ui/src/lib/bits/select/components/select-icon.svelte deleted file mode 100644 index d33a52086..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-icon.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} - - {#if children} - {@render children()} - {:else} - â–¼ - {/if} - -{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-item-text.svelte b/packages/bits-ui/src/lib/bits/select/components/select-item-text.svelte deleted file mode 100644 index 02d85e861..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-item-text.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if itemTextState.item.isSelected && itemTextState.item.root.valueId.current && !itemTextState.item.root.valueNodeHasChildren.current && itemTextState.item.root.valueNode} - - {@render children?.()} - -{/if} - -{#if child} - {@render child({ props: mergedProps })} -{:else} - - {@render children?.()} - -{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-item.svelte b/packages/bits-ui/src/lib/bits/select/components/select-item.svelte index 7cc0f292f..b6a38b44e 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-item.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-item.svelte @@ -1,38 +1,43 @@ {#if child} - {@render child({ props: mergedProps, selected: itemState.isSelected })} + {@render child({ props: mergedProps, ...itemState.snippetProps })} {:else}
- {@render children?.({ selected: itemState.isSelected })} + {@render children?.(itemState.snippetProps)}
{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-native.svelte b/packages/bits-ui/src/lib/bits/select/components/select-native.svelte deleted file mode 100644 index 25cbf089e..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-native.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - {#snippet child({ props })} - {@const mergedProps = mergeProps(props, restProps, { "aria-hidden": "true" })} - - {/snippet} - diff --git a/packages/bits-ui/src/lib/bits/select/components/select-provider.svelte b/packages/bits-ui/src/lib/bits/select/components/select-provider.svelte deleted file mode 100644 index 73d74aec9..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-provider.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button-mounted.svelte b/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button-mounted.svelte deleted file mode 100644 index a9940ef33..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button-mounted.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{#if child} - {@render child({ props: restProps })} -{:else} -
- {@render children?.()} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button.svelte b/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button.svelte index a900e1717..78bbe6369 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-scroll-down-button.svelte @@ -1,28 +1,39 @@ {#if scrollDownButtonState.canScrollDown} - + (mounted = m)} /> + {#if child} + {@render child({ props: restProps })} + {:else} +
+ {@render children?.()} +
+ {/if} {/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button-mounted.svelte b/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button-mounted.svelte deleted file mode 100644 index 4ab80e133..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button-mounted.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{#if child} - {@render child({ props: restProps })} -{:else} -
- {@render children?.()} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button.svelte b/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button.svelte index 928198d46..5b0e2d6a6 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-scroll-up-button.svelte @@ -2,27 +2,38 @@ import { box, mergeProps } from "svelte-toolbelt"; import type { SelectScrollUpButtonProps } from "../types.js"; import { useSelectScrollUpButton } from "../select.svelte.js"; - import SelectScrollUpButtonMounted from "./select-scroll-up-button-mounted.svelte"; import { useId } from "$lib/internal/use-id.js"; + import { Mounted } from "$lib/bits/utilities/index.js"; - let { id = useId(), ref = $bindable(null), ...restProps }: SelectScrollUpButtonProps = $props(); + let { + id = useId(), + ref = $bindable(null), + child, + children, + ...restProps + }: SelectScrollUpButtonProps = $props(); - const mounted = box(false); + let mounted = $state(false); - const scrollUpButtonState = useSelectScrollUpButton({ + const scrollDownButtonState = useSelectScrollUpButton({ id: box.with(() => id), - mounted: box.from(mounted), + mounted: box.with(() => mounted), ref: box.with( () => ref, (v) => (ref = v) ), }); - const { child: _child, children: _children, ...restWithoutChildren } = restProps; - const mergedProps = $derived(mergeProps(restWithoutChildren, scrollUpButtonState.props)); - const { style: _style, ...restWithoutStyle } = restProps; + const mergedProps = $derived(mergeProps(restProps, scrollDownButtonState.props)); -{#if scrollUpButtonState.canScrollUp} - +{#if scrollDownButtonState.canScrollUp} + (mounted = m)} /> + {#if child} + {@render child({ props: restProps })} + {:else} +
+ {@render children?.()} +
+ {/if} {/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-separator.svelte b/packages/bits-ui/src/lib/bits/select/components/select-separator.svelte deleted file mode 100644 index 3b1fa4ba7..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-separator.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} -
- {@render children?.()} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-trigger.svelte b/packages/bits-ui/src/lib/bits/select/components/select-trigger.svelte index 3e389523a..77888259e 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-trigger.svelte @@ -1,22 +1,20 @@ - + {#if child} {@render child({ props: mergedProps })} {:else} @@ -34,4 +32,4 @@ {@render children?.()} {/if} - + diff --git a/packages/bits-ui/src/lib/bits/select/components/select-value.svelte b/packages/bits-ui/src/lib/bits/select/components/select-value.svelte deleted file mode 100644 index 01b52694c..000000000 --- a/packages/bits-ui/src/lib/bits/select/components/select-value.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} - - {#if valueState.showPlaceholder} - {placeholder} - {:else} - {@render children?.()} - {/if} - -{/if} diff --git a/packages/bits-ui/src/lib/bits/select/components/select-viewport.svelte b/packages/bits-ui/src/lib/bits/select/components/select-viewport.svelte index b2a997445..c8dce801f 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-viewport.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-viewport.svelte @@ -38,6 +38,16 @@ -ms-overflow-style: none !important; -webkit-overflow-scrolling: touch !important; } + + :global([data-combobox-viewport]) { + scrollbar-width: none !important; + -ms-overflow-style: none !important; + -webkit-overflow-scrolling: touch !important; + } + + :global([data-combobox-viewport])::-webkit-scrollbar { + display: none !important; + } :global([data-select-viewport])::-webkit-scrollbar { display: none !important; } diff --git a/packages/bits-ui/src/lib/bits/select/components/select.svelte b/packages/bits-ui/src/lib/bits/select/components/select.svelte index 908d61bb1..ba2e5a4d9 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select.svelte @@ -1,28 +1,52 @@ {@render children?.()} - {#if rootState.isFormControl.current} - {#key rootState.nativeSelectKey} - - {/key} - {/if} + +{#if Array.isArray(rootState.value.current)} + {#if rootState.value.current.length === 0} + + {:else} + {#each rootState.value.current as item} + + {/each} + {/if} +{:else} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/select/exports.ts b/packages/bits-ui/src/lib/bits/select/exports.ts index 91123efbb..67b18df33 100644 --- a/packages/bits-ui/src/lib/bits/select/exports.ts +++ b/packages/bits-ui/src/lib/bits/select/exports.ts @@ -1,33 +1,25 @@ export { default as Root } from "./components/select.svelte"; -export { default as Arrow } from "./components/select-arrow.svelte"; export { default as Content } from "./components/select-content.svelte"; +export { default as ContentStatic } from "./components/select-content-static.svelte"; +export { default as Item } from "./components/select-item.svelte"; export { default as Group } from "./components/select-group.svelte"; export { default as GroupHeading } from "./components/select-group-heading.svelte"; -export { default as Item } from "./components/select-item.svelte"; -export { default as ItemText } from "./components/select-item-text.svelte"; -export { default as Separator } from "./components/select-separator.svelte"; export { default as Trigger } from "./components/select-trigger.svelte"; -export { default as Value } from "./components/select-value.svelte"; +export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; export { default as Viewport } from "./components/select-viewport.svelte"; export { default as ScrollUpButton } from "./components/select-scroll-up-button.svelte"; export { default as ScrollDownButton } from "./components/select-scroll-down-button.svelte"; -export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; export type { SelectRootProps as RootProps, SelectContentProps as ContentProps, + SelectContentStaticProps as ContentStaticProps, SelectItemProps as ItemProps, + SelectGroupProps as GroupProps, + SelectGroupHeadingProps as GroupHeadingProps, SelectTriggerProps as TriggerProps, - SelectValueProps as ValueProps, - SelectItemTextProps as ItemTextProps, - SelectContentImplProps as ContentImplProps, SelectViewportProps as ViewportProps, - SelectPortalProps as PortalProps, - SelectScrollUpButtonProps as ScrollDownButtonProps, SelectScrollUpButtonProps as ScrollUpButtonProps, - SelectIconProps as IconProps, - SelectGroupProps as GroupProps, - SelectGroupHeadingProps as GroupHeadingProps, - SelectSeparatorProps as SeparatorProps, - SelectArrowProps as ArrowProps, + SelectScrollDownButtonProps as ScrollDownButtonProps, + SelectPortalProps as PortalProps, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/select/select.svelte.ts b/packages/bits-ui/src/lib/bits/select/select.svelte.ts index ca42430ab..626109769 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -1,209 +1,617 @@ -/** - * This logic is adapted from Radix UI Select component. - * https://github.com/radix-ui/primitives/blob/main/packages/react/select/src/Select.tsx - * Credit to the Radix UI team for the original implementation. - */ -import { - type ReadableBox, - type WritableBox, - afterSleep, - afterTick, - box, - useRefById, -} from "svelte-toolbelt"; -import { SvelteMap } from "svelte/reactivity"; +import { Previous } from "runed"; import { untrack } from "svelte"; -import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; -import { useId } from "$lib/internal/use-id.js"; -import type { Direction } from "$lib/shared/index.js"; -import { createContext } from "$lib/internal/create-context.js"; -import { useFormControl } from "$lib/internal/use-form-control.svelte.js"; -import { type Typeahead, useTypeahead } from "$lib/internal/use-typeahead.svelte.js"; +import { afterTick, srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt"; +import type { InteractOutsideEvent } from "../utilities/dismissible-layer/types.js"; +import { backward, forward, next, prev } from "$lib/internal/arrays.js"; import { - getAriaDisabled, getAriaExpanded, getAriaHidden, - getAriaRequired, - getAriaSelected, - getDataChecked, getDataDisabled, getDataOpenClosed, + getDisabled, + getRequired, } from "$lib/internal/attrs.js"; +import type { Box, ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; +import { createContext } from "$lib/internal/create-context.js"; import { kbd } from "$lib/internal/kbd.js"; -import { clamp } from "$lib/internal/clamp.js"; +import type { WithRefProps } from "$lib/internal/types.js"; import { noop } from "$lib/internal/noop.js"; import { addEventListener } from "$lib/internal/events.js"; -import type { WithRefProps } from "$lib/internal/types.js"; +import { type DOMTypeahead, useDOMTypeahead } from "$lib/internal/use-dom-typeahead.svelte.js"; +import { type DataTypeahead, useDataTypeahead } from "$lib/internal/use-data-typeahead.svelte.js"; + +// prettier-ignore +export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12]; + +export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME]; +export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END]; +export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]; +export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; -export const OPEN_KEYS = [kbd.SPACE, kbd.ENTER, kbd.ARROW_UP, kbd.ARROW_DOWN]; -export const SELECTION_KEYS = [" ", kbd.ENTER]; export const CONTENT_MARGIN = 10; -const TRIGGER_ATTR = "data-select-trigger"; -const CONTENT_ATTR = "data-select-content"; -const ITEM_ATTR = "data-select-item"; -const VIEWPORT_ATTR = "data-select-viewport"; -const VALUE_ATTR = "data-select-value"; -const ITEM_TEXT_ATTR = "data-select-item-text"; -const CONTENT_WRAPPER_ATTR = "data-select-content-wrapper"; -const SCROLL_UP_BUTTON_ATTR = "data-select-scroll-up-button"; -const SCROLL_DOWN_BUTTON_ATTR = "data-select-scroll-down-button"; -const GROUP_ATTR = "data-select-group"; -const GROUP_LABEL_ATTR = "data-select-group-label"; -const SEPARATOR_ATTR = "data-select-separator"; -const ARROW_ATTR = "data-select-arrow"; -const ICON_ATTR = "data-select-icon"; +type SelectBaseRootStateProps = ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + name: string; + loop: boolean; + scrollAlignment: "nearest" | "center"; + items: { value: string; label: string; disabled?: boolean }[]; +}> & + WritableBoxedValues<{ + open: boolean; + }> & { + isCombobox: boolean; + }; + +class SelectBaseRootState { + disabled: SelectBaseRootStateProps["disabled"]; + required: SelectBaseRootStateProps["required"]; + name: SelectBaseRootStateProps["name"]; + loop: SelectBaseRootStateProps["loop"]; + open: SelectBaseRootStateProps["open"]; + scrollAlignment: SelectBaseRootStateProps["scrollAlignment"]; + items: SelectBaseRootStateProps["items"]; + touchedInput = $state(false); + inputValue = $state(""); + inputNode = $state(null); + contentNode = $state(null); + triggerNode = $state(null); + valueId = $state(""); + highlightedNode = $state(null); + highlightedValue = $derived.by(() => { + if (!this.highlightedNode) return null; + return this.highlightedNode.getAttribute("data-value"); + }); + highlightedId = $derived.by(() => { + if (!this.highlightedNode) return undefined; + return this.highlightedNode.id; + }); + highlightedLabel = $derived.by(() => { + if (!this.highlightedNode) return null; + return this.highlightedNode.getAttribute("data-label"); + }); + isUsingKeyboard = $state(false); + isCombobox = $state(false); + bitsAttrs: SelectBitsAttrs; + triggerPointerDownPos = $state.raw<{ x: number; y: number } | null>({ x: 0, y: 0 }); + + constructor(props: SelectBaseRootStateProps) { + this.disabled = props.disabled; + this.required = props.required; + this.name = props.name; + this.loop = props.loop; + this.open = props.open; + this.scrollAlignment = props.scrollAlignment; + this.isCombobox = props.isCombobox; + this.items = props.items; + + this.bitsAttrs = getSelectBitsAttrs(this); + + $effect.pre(() => { + if (!this.open.current) { + this.setHighlightedNode(null); + } + }); + } + + setHighlightedNode = (node: HTMLElement | null) => { + this.highlightedNode = node; + if (node) { + if (this.isUsingKeyboard) { + node.scrollIntoView({ block: "nearest" }); + } + } + }; + + getCandidateNodes = (): HTMLElement[] => { + const node = this.contentNode; + if (!node) return []; + const nodes = Array.from( + node.querySelectorAll(`[${this.bitsAttrs.item}]:not([data-disabled])`) + ); + return nodes; + }; -export const [setSelectRootContext, getSelectRootContext] = - createContext("Select.Root"); + setHighlightedToFirstCandidate = () => { + this.setHighlightedNode(null); + const candidateNodes = this.getCandidateNodes(); + if (!candidateNodes.length) return; + this.setHighlightedNode(candidateNodes[0]!); + }; -export const [setSelectTriggerContext] = createContext("Select.Trigger"); + getNodeByValue = (value: string): HTMLElement | null => { + const candidateNodes = this.getCandidateNodes(); + return candidateNodes.find((node) => node.dataset.value === value) ?? null; + }; -export const [setSelectContentContext, getSelectContentContext] = - createContext("Select.Content"); + setOpen = (open: boolean) => { + this.open.current = open; + }; -export const [setSelectItemContext, getSelectItemContext] = - createContext("Select.Item"); + toggleOpen = () => { + this.open.current = !this.open.current; + }; -export const [setSelectContentItemAlignedContext, getSelectContentItemAlignedContext] = - createContext("Select.ContentItemAligned"); + handleOpen = () => { + this.setOpen(true); + }; -const [setSelectGroupContext, getSelectGroupContext] = - createContext("Select.Group"); + handleClose = () => { + this.setHighlightedNode(null); + this.setOpen(false); + }; -type SelectRootStateProps = WritableBoxedValues<{ - open: boolean; - value: string; -}> & - ReadableBoxedValues<{ - dir: Direction; - disabled: boolean; - required: boolean; + toggleMenu = () => { + this.toggleOpen(); + }; +} + +type SelectSingleRootStateProps = SelectBaseRootStateProps & + WritableBoxedValues<{ + value: string; }>; -type SelectNativeOption = { - value: string; - key: string; - disabled: boolean; - innerHTML?: string | null; -}; - -export class SelectRootState { - open: SelectRootStateProps["open"]; - value: SelectRootStateProps["value"]; - dir: SelectRootStateProps["dir"]; - disabled: SelectRootStateProps["disabled"]; - required: SelectRootStateProps["required"]; - triggerNode = $state(null); - valueId = box(useId()); - valueNodeHasChildren = box(false); - valueNode = $state(null); - contentNode = $state(null); - contentId = $state(undefined); - triggerPointerDownPos = box<{ x: number; y: number } | null>({ x: 0, y: 0 }); - contentFragment = $state(null); - - // A set of all the native options we'll use to render the native select element under the hood - #nativeOptionsSet = new SvelteMap>(); - // A key we'll use to rerender the native select when the options change to keep it in sync - nativeSelectKey = $derived.by(() => { - return Array.from(this.#nativeOptionsSet.values()) - .map((opt) => opt.current.value) - .join(";"); +class SelectSingleRootState extends SelectBaseRootState { + value: SelectSingleRootStateProps["value"]; + isMulti = false as const; + hasValue = $derived.by(() => this.value.current !== ""); + currentLabel = $derived.by(() => { + if (!this.items.current.length) return ""; + const match = this.items.current.find((item) => item.value === this.value.current)?.label; + return match ?? ""; + }); + candidateLabels: string[] = $derived.by(() => { + if (!this.items.current.length) return []; + const filteredItems = this.items.current.filter((item) => !item.disabled); + return filteredItems.map((item) => item.label); + }); + dataTypeaheadEnabled = $derived.by(() => { + if (this.isMulti) return false; + if (this.items.current.length === 0) return false; + return true; }); - nativeOptionsArr = $derived.by(() => Array.from(this.#nativeOptionsSet.values())); - isFormControl = useFormControl(() => this.triggerNode); - - constructor(props: SelectRootStateProps) { - this.open = props.open; + constructor(props: SelectSingleRootStateProps) { + super(props); this.value = props.value; - this.dir = props.dir; - this.disabled = props.disabled; - this.required = props.required; + + $effect(() => { + if (!this.open.current && this.highlightedNode) { + this.setHighlightedNode(null); + } + }); + + $effect(() => { + if (!this.open.current) return; + afterTick(() => { + this.#setInitialHighlightedNode(); + }); + }); } - handleClose = () => { - this.open.current = false; - this.focusTriggerNode(); + includesItem = (itemValue: string) => { + return this.value.current === itemValue; + }; + + toggleItem = (itemValue: string, itemLabel: string = itemValue) => { + this.value.current = this.includesItem(itemValue) ? "" : itemValue; + this.inputValue = itemLabel; }; - focusTriggerNode = (preventScroll: boolean = true) => { - const node = this.triggerNode; - if (!node) return; - // this needs to be 10 otherwise Firefox doesn't focus the correct node - afterSleep(10, () => { - node.focus({ preventScroll }); + #setInitialHighlightedNode = () => { + if (this.highlightedNode) return; + if (this.value.current !== "") { + const node = this.getNodeByValue(this.value.current); + if (node) { + this.setHighlightedNode(node); + return; + } + } + // if no value is set, we want to highlight the first item + const firstCandidate = this.getCandidateNodes()[0]; + if (!firstCandidate) return; + this.setHighlightedNode(firstCandidate); + }; +} + +type SelectMultipleRootStateProps = SelectBaseRootStateProps & + WritableBoxedValues<{ + value: string[]; + }>; + +class SelectMultipleRootState extends SelectBaseRootState { + value: SelectMultipleRootStateProps["value"]; + isMulti = true as const; + hasValue = $derived.by(() => this.value.current.length > 0); + + constructor(props: SelectMultipleRootStateProps) { + super(props); + this.value = props.value; + + $effect(() => { + if (!this.open.current) return; + afterTick(() => { + if (!this.highlightedNode) { + this.#setInitialHighlightedNode(); + } + }); }); + } + + includesItem = (itemValue: string) => { + return this.value.current.includes(itemValue); }; - onNativeOptionAdd = (option: ReadableBox) => { - this.#nativeOptionsSet.set(option.current.value, option); + toggleItem = (itemValue: string, itemLabel: string = itemValue) => { + if (this.includesItem(itemValue)) { + this.value.current = this.value.current.filter((v) => v !== itemValue); + } else { + this.value.current = [...this.value.current, itemValue]; + } + this.inputValue = itemLabel; }; - onNativeOptionRemove = (option: ReadableBox) => { - this.#nativeOptionsSet.delete(option.current.value); + #setInitialHighlightedNode = () => { + if (this.highlightedNode) return; + if (this.value.current.length && this.value.current[0] !== "") { + const node = this.getNodeByValue(this.value.current[0]!); + if (node) { + this.setHighlightedNode(node); + return; + } + } + // if no value is set, we want to highlight the first item + const firstCandidate = this.getCandidateNodes()[0]; + if (!firstCandidate) return; + this.setHighlightedNode(firstCandidate); }; +} - getTriggerTypeaheadCandidateNodes = () => { - const node = this.contentFragment; - if (!node) return []; - return Array.from( - node.querySelectorAll(`[${ITEM_ATTR}]:not([data-disabled])`) - ); +type SelectRootState = SelectSingleRootState | SelectMultipleRootState; + +type SelectInputStateProps = WithRefProps; + +class SelectInputState { + #id: SelectInputStateProps["id"]; + #ref: SelectInputStateProps["ref"]; + root: SelectRootState; + + constructor(props: SelectInputStateProps, root: SelectRootState) { + this.root = root; + this.#id = props.id; + this.#ref = props.ref; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + this.root.inputNode = node; + }, + }); + } + + #onkeydown = async (e: KeyboardEvent) => { + this.root.isUsingKeyboard = true; + if (e.key === kbd.ESCAPE) return; + const open = this.root.open.current; + const inputValue = this.root.inputValue; + + // prevent arrow up/down from moving the position of the cursor in the input + if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); + if (!open) { + if (INTERACTION_KEYS.includes(e.key)) return; + if (e.key === kbd.TAB) return; + if (e.key === kbd.BACKSPACE && inputValue === "") return; + this.root.handleOpen(); + // we need to wait for a tick after the menu opens to ensure the highlighted nodes are + // set correctly. + afterTick(() => { + if (this.root.hasValue) return; + const candidateNodes = this.root.getCandidateNodes(); + if (!candidateNodes.length) return; + + if (e.key === kbd.ARROW_DOWN) { + const firstCandidate = candidateNodes[0]!; + this.root.setHighlightedNode(firstCandidate); + } else if (e.key === kbd.ARROW_UP) { + const lastCandidate = candidateNodes[candidateNodes.length - 1]!; + this.root.setHighlightedNode(lastCandidate); + } + }); + return; + } + + if (e.key === kbd.TAB) { + this.root.handleClose(); + return; + } + + if (e.key === kbd.ENTER && !e.isComposing) { + e.preventDefault(); + const highlightedValue = this.root.highlightedValue; + if (highlightedValue) { + this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); + } + if (!this.root.isMulti) { + this.root.handleClose(); + } + } + + if (e.key === kbd.ARROW_UP && e.altKey) { + this.root.handleClose(); + } + + if (FIRST_LAST_KEYS.includes(e.key)) { + e.preventDefault(); + const candidateNodes = this.root.getCandidateNodes(); + const currHighlightedNode = this.root.highlightedNode; + const currIndex = currHighlightedNode + ? candidateNodes.indexOf(currHighlightedNode) + : -1; + + const loop = this.root.loop.current; + let nextItem: HTMLElement | undefined; + + if (e.key === kbd.ARROW_DOWN) { + nextItem = next(candidateNodes, currIndex, loop); + } else if (e.key === kbd.ARROW_UP) { + nextItem = prev(candidateNodes, currIndex, loop); + } else if (e.key === kbd.PAGE_DOWN) { + nextItem = forward(candidateNodes, currIndex, 10, loop); + } else if (e.key === kbd.PAGE_UP) { + nextItem = backward(candidateNodes, currIndex, 10, loop); + } else if (e.key === kbd.HOME) { + nextItem = candidateNodes[0]; + } else if (e.key === kbd.END) { + nextItem = candidateNodes[candidateNodes.length - 1]; + } + if (!nextItem) return; + this.root.setHighlightedNode(nextItem); + return; + } + + if (INTERACTION_KEYS.includes(e.key)) return; + if (!this.root.highlightedNode) { + this.root.setHighlightedToFirstCandidate(); + } + // this.root.setHighlightedToFirstCandidate(); }; - getCandidateNodes = () => { - const node = this.contentNode; - if (!node) return []; - return Array.from( - node.querySelectorAll(`[${ITEM_ATTR}]:not([data-disabled])`) - ); + #oninput = (e: Event & { currentTarget: HTMLInputElement }) => { + this.root.inputValue = e.currentTarget.value; + this.root.setHighlightedToFirstCandidate(); }; + + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "combobox", + disabled: this.root.disabled.current ? true : undefined, + "aria-activedescendant": this.root.highlightedId, + "aria-autocomplete": "list", + "aria-expanded": getAriaExpanded(this.root.open.current), + "data-state": getDataOpenClosed(this.root.open.current), + "data-disabled": getDataDisabled(this.root.disabled.current), + onkeydown: this.#onkeydown, + oninput: this.#oninput, + [this.root.bitsAttrs.input]: "", + }) as const + ); } -type SelectTriggerStateProps = WithRefProps< - ReadableBoxedValues<{ - disabled: boolean; - }> ->; +type SelectComboTriggerStateProps = WithRefProps; + +class SelectComboTriggerState { + #id: SelectComboTriggerStateProps["id"]; + #ref: SelectComboTriggerStateProps["ref"]; + root: SelectBaseRootState; + + constructor(props: SelectComboTriggerStateProps, root: SelectBaseRootState) { + this.root = root; + this.#id = props.id; + this.#ref = props.ref; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key === kbd.ENTER || e.key === kbd.SPACE) { + e.preventDefault(); + if (document.activeElement !== this.root.inputNode) { + this.root.inputNode?.focus(); + } + this.root.toggleMenu(); + } + }; + + /** + * `pointerdown` fires before the `focus` event, so we can prevent the default + * behavior of focusing the button and keep focus on the input. + */ + #onpointerdown = (e: MouseEvent) => { + if (this.root.disabled.current) return; + e.preventDefault(); + if (document.activeElement !== this.root.inputNode) { + this.root.inputNode?.focus(); + } + this.root.toggleMenu(); + }; + + props = $derived.by( + () => + ({ + id: this.#id.current, + disabled: this.root.disabled.current ? true : undefined, + "aria-haspopup": "listbox", + "data-state": getDataOpenClosed(this.root.open.current), + "data-disabled": getDataDisabled(this.root.disabled.current), + [this.root.bitsAttrs.trigger]: "", + onpointerdown: this.#onpointerdown, + onkeydown: this.#onkeydown, + }) as const + ); +} + +type SelectTriggerStateProps = WithRefProps; class SelectTriggerState { - #root: SelectRootState; #id: SelectTriggerStateProps["id"]; #ref: SelectTriggerStateProps["ref"]; - #disabled: SelectTriggerStateProps["disabled"]; - #typeahead: Typeahead; - #isDisabled = $derived.by(() => { - return this.#root.disabled.current || this.#disabled.current; - }); + root: SelectRootState; + #domTypeahead: DOMTypeahead; + #dataTypeahead: DataTypeahead; constructor(props: SelectTriggerStateProps, root: SelectRootState) { + this.root = root; this.#id = props.id; this.#ref = props.ref; - this.#root = root; - this.#disabled = props.disabled; useRefById({ id: this.#id, ref: this.#ref, onRefChange: (node) => { - this.#root.triggerNode = node; + this.root.triggerNode = node; }, }); - this.#typeahead = useTypeahead(); + this.#domTypeahead = useDOMTypeahead({ + getCurrentItem: () => this.root.highlightedNode, + onMatch: (node) => { + this.root.setHighlightedNode(node); + }, + }); + + this.#dataTypeahead = useDataTypeahead({ + getCurrentItem: () => { + if (this.root.isMulti) return ""; + return this.root.currentLabel; + }, + onMatch: (label: string) => { + if (this.root.isMulti) return; + if (!this.root.items.current) return; + const matchedItem = this.root.items.current.find((item) => item.label === label); + if (!matchedItem) return; + this.root.value.current = matchedItem.value; + }, + enabled: !this.root.isMulti && this.root.dataTypeaheadEnabled, + }); } + #onkeydown = (e: KeyboardEvent) => { + this.root.isUsingKeyboard = true; + if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) e.preventDefault(); + + if (!this.root.open.current) { + if (e.key === kbd.ENTER) { + return; + } else if (e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_UP) { + e.preventDefault(); + this.root.handleOpen(); + } else if (!this.root.isMulti && this.root.dataTypeaheadEnabled) { + this.#dataTypeahead.handleTypeaheadSearch(e.key, this.root.candidateLabels); + return; + } + + // we need to wait for a tick after the menu opens to ensure + // the highlighted nodes are set correctly + afterTick(() => { + if (this.root.hasValue) return; + const candidateNodes = this.root.getCandidateNodes(); + if (!candidateNodes.length) return; + + if (e.key === kbd.ARROW_DOWN) { + const firstCandidate = candidateNodes[0]!; + this.root.setHighlightedNode(firstCandidate); + } else if (e.key === kbd.ARROW_UP) { + const lastCandidate = candidateNodes[candidateNodes.length - 1]!; + this.root.setHighlightedNode(lastCandidate); + } + }); + return; + } + + if (e.key === kbd.TAB) { + this.root.handleClose(); + return; + } + + if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) { + e.preventDefault(); + const highlightedValue = this.root.highlightedValue; + if (highlightedValue) { + this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); + } + if (!this.root.isMulti) { + this.root.handleClose(); + } + } + + if (e.key === kbd.ARROW_UP && e.altKey) { + this.root.handleClose(); + } + + if (FIRST_LAST_KEYS.includes(e.key)) { + e.preventDefault(); + const candidateNodes = this.root.getCandidateNodes(); + const currHighlightedNode = this.root.highlightedNode; + const currIndex = currHighlightedNode + ? candidateNodes.indexOf(currHighlightedNode) + : -1; + + const loop = this.root.loop.current; + let nextItem: HTMLElement | undefined; + + if (e.key === kbd.ARROW_DOWN) { + nextItem = next(candidateNodes, currIndex, loop); + } else if (e.key === kbd.ARROW_UP) { + nextItem = prev(candidateNodes, currIndex, loop); + } else if (e.key === kbd.PAGE_DOWN) { + nextItem = forward(candidateNodes, currIndex, 10, loop); + } else if (e.key === kbd.PAGE_UP) { + nextItem = backward(candidateNodes, currIndex, 10, loop); + } else if (e.key === kbd.HOME) { + nextItem = candidateNodes[0]; + } else if (e.key === kbd.END) { + nextItem = candidateNodes[candidateNodes.length - 1]; + } + if (!nextItem) return; + this.root.setHighlightedNode(nextItem); + return; + } + const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; + const isCharacterKey = e.key.length === 1; + + // prevent space from being considered with typeahead + if (e.code === "Space") return; + + const candidateNodes = this.root.getCandidateNodes(); + + if (e.key === kbd.TAB) return; + + if (!isModifierKey && isCharacterKey) { + this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes); + return; + } + + if (!this.root.highlightedNode) { + this.root.setHighlightedToFirstCandidate(); + } + }; + #handleOpen = () => { - if (this.#isDisabled) return; - this.#root.open.current = true; - this.#typeahead.resetTypeahead(); + this.root.open.current = true; + this.#dataTypeahead.resetTypeahead(); + this.#domTypeahead.resetTypeahead(); }; #handlePointerOpen = (e: PointerEvent) => { this.#handleOpen(); - this.#root.triggerPointerDownPos.current = { + this.root.triggerPointerDownPos = { x: Math.round(e.pageX), y: Math.round(e.pageY), }; @@ -219,9 +627,13 @@ class SelectTriggerState { currTarget.focus(); }; + /** + * `pointerdown` fires before the `focus` event, so we can prevent the default + * behavior of focusing the button and keep focus on the input. + */ #onpointerdown = (e: PointerEvent) => { - // prevent opening on touch down which can be triggered - // when scrolling on touch devices (unexpected) + if (this.root.disabled.current) return; + // prevent opening on touch down which can be triggered when scrolling on touch devices if (e.pointerType === "touch") return e.preventDefault(); // prevent implicit pointer capture @@ -233,11 +645,11 @@ class SelectTriggerState { // only call the handle if it's a left click, since pointerdown is triggered // by right clicks as well, but not when ctrl is pressed if (e.button === 0 && e.ctrlKey === false) { - if (this.#root.open.current === false) { + if (this.root.open.current === false) { this.#handlePointerOpen(e); e.preventDefault(); } else { - this.#root.handleClose(); + this.root.handleClose(); } } }; @@ -249,324 +661,91 @@ class SelectTriggerState { } }; - #onkeydown = (e: KeyboardEvent) => { - const isTypingAhead = this.#typeahead.search.current !== ""; - const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; - - if (!isModifierKey && e.key.length === 1) { - if (isTypingAhead && e.key === " ") return; - } - const newItem = this.#typeahead.handleTypeaheadSearch( - e.key, - this.#root.getTriggerTypeaheadCandidateNodes() - ); - - if (newItem && newItem.dataset.value) { - this.#root.value.current = newItem.dataset.value; - } - - if (OPEN_KEYS.includes(e.key)) { - this.#handleOpen(); - e.preventDefault(); - } - }; - props = $derived.by( () => ({ id: this.#id.current, - disabled: this.#isDisabled, - role: "combobox", - type: "button", - "aria-controls": this.#root.contentId, - "aria-expanded": getAriaExpanded(this.#root.open.current), - "aria-required": getAriaRequired(this.#root.required.current), - "aria-autocomplete": "none", - dir: this.#root.dir.current, - "data-state": getDataOpenClosed(this.#root.open.current), - "data-disabled": getDataDisabled(this.#isDisabled), - "data-placeholder": shouldShowPlaceholder(this.#root.value.current) - ? "" - : undefined, - [TRIGGER_ATTR]: "", - onclick: this.#onclick, - onpointerdown: this.#onpointerdown, - onpointerup: this.#onpointerup, - onkeydown: this.#onkeydown, - }) as const - ); -} - -class SelectValueState { - root: SelectRootState; - showPlaceholder = $derived.by(() => shouldShowPlaceholder(this.root.value.current)); - ref: WritableBox = box(null); - - constructor(root: SelectRootState) { - this.root = root; - - useRefById({ - id: this.root.valueId, - ref: this.ref, - onRefChange: (node) => { - this.root.valueNode = node; - }, - }); - } - - props = $derived.by( - () => - ({ - id: this.root.valueId.current, + disabled: this.root.disabled.current ? true : undefined, + "aria-haspopup": "listbox", "data-state": getDataOpenClosed(this.root.open.current), "data-disabled": getDataDisabled(this.root.disabled.current), - [VALUE_ATTR]: "", - style: { - pointerEvents: "none", - }, + [this.root.bitsAttrs.trigger]: "", + onpointerdown: this.#onpointerdown, + onkeydown: this.#onkeydown, + onclick: this.#onclick, + onpointerup: this.#onpointerup, + // onclick: this.#onclick, }) as const ); } -class SelectContentFragState { - root: SelectRootState; - - constructor(root: SelectRootState) { - this.root = root; - - $effect(() => { - this.root.contentFragment = new DocumentFragment(); - }); - } -} +type SelectContentStateProps = WithRefProps; -type SelectContentStateProps = WithRefProps< - ReadableBoxedValues<{ - position: "item-aligned" | "floating"; - }> ->; - -export class SelectContentState { +class SelectContentState { id: SelectContentStateProps["id"]; ref: SelectContentStateProps["ref"]; - root: SelectRootState; viewportNode = $state(null); - selectedItemId = box(useId()); - selectedItemTextId = box(useId()); - selectedItemText = box(null); - position: SelectContentStateProps["position"]; - isPositioned = box(false); - firstValidItemFound = box(false); - typeahead: Typeahead; - alignedPositionState: SelectItemAlignedPositionState | null = null; + root: SelectRootState; + isPositioned = $state(false); constructor(props: SelectContentStateProps, root: SelectRootState) { - this.position = props.position; + this.root = root; this.id = props.id; this.ref = props.ref; - this.root = root; - this.typeahead = useTypeahead(); useRefById({ id: this.id, ref: this.ref, - deps: () => this.root.open.current, onRefChange: (node) => { this.root.contentNode = node; - this.root.contentId = node?.id; }, + deps: () => this.root.open.current, }); $effect(() => { - this.root.open.current; - return untrack(() => { - let cleanup = [noop]; - - afterTick(() => { - const node = document.getElementById(this.id.current); - if (!node) return; - - let pointerMoveDelta = { x: 0, y: 0 }; - - const handlePointerMove = (e: PointerEvent) => { - pointerMoveDelta = { - x: Math.abs( - Math.round(e.pageX) - - (this.root.triggerPointerDownPos.current?.x ?? 0) - ), - y: Math.abs( - Math.round(e.pageY) - - (this.root.triggerPointerDownPos.current?.y ?? 0) - ), - }; - }; - - const handlePointerUp = (e: PointerEvent) => { - if (e.pointerType === "touch") return; - - if (pointerMoveDelta.x <= 10 && pointerMoveDelta.y <= 10) { - e.preventDefault(); - } else { - if (!this.root.contentNode?.contains(e.target as HTMLElement)) { - this.root.handleClose(); - } - } - document.removeEventListener("pointermove", handlePointerMove); - this.root.triggerPointerDownPos.current = null; - }; - - if (this.root.triggerPointerDownPos.current !== null) { - const pointerMove = addEventListener( - document, - "pointermove", - handlePointerMove - ); - const pointerUp = addEventListener(document, "pointerup", handlePointerUp, { - capture: true, - once: true, - }); - for (const cleanupFn of cleanup) cleanupFn(); - cleanup = [pointerMove, pointerUp]; - } - - return () => { - for (const cleanupFn of cleanup) cleanupFn(); - }; - }); - }); - }); - - $effect(() => { - if (this.isPositioned.current) { - this.focusSelectedItem(); - } + return () => { + this.root.contentNode = null; + }; }); $effect(() => { if (this.root.open.current === false) { - this.isPositioned.current = false; + this.isPositioned = false; } }); } - focusFirst = (candidates: Array) => { - const [firstItem, ...restItems] = this.root.getCandidateNodes(); - const [lastItem] = restItems.slice(-1); - - const PREV_FOCUSED_ELEMENT = document.activeElement; - - for (const candidate of candidates) { - if (candidate === PREV_FOCUSED_ELEMENT) return; - candidate?.scrollIntoView({ block: "nearest" }); - // viewport might have padding so scroll to the edge when focusing first/last - const viewport = this.viewportNode; - if (candidate === firstItem && viewport) { - viewport.scrollTop = 0; - } - if (candidate === lastItem && viewport) { - viewport.scrollTop = viewport.scrollHeight; - } - - candidate?.focus(); - - if (document.activeElement !== PREV_FOCUSED_ELEMENT) return; - } - }; - - onItemLeave = () => { - this.root.contentNode?.focus(); + #onpointermove = () => { + this.root.isUsingKeyboard = false; }; - getSelectedItem = () => { - const candidates = this.root.getCandidateNodes(); - const selectedItemNode = - candidates.find((node) => node?.dataset.value === this.root.value.current) ?? null; - const first = candidates[0] ?? null; - if (selectedItemNode) { - const selectedItemTextNode = selectedItemNode.querySelector( - `[${ITEM_TEXT_ATTR}]` - ); + #styles = $derived.by(() => { + if (this.root.isCombobox) { return { - selectedItemNode, - selectedItemTextNode, + "--bits-combobox-content-transform-origin": "var(--bits-floating-transform-origin)", + "--bits-combobox-content-available-width": "var(--bits-floating-available-width)", + "--bits-combobox-content-available-height": "var(--bits-floating-available-height)", + "--bits-combobox-anchor-width": "var(--bits-floating-anchor-width)", + "--bits-combobox-anchor-height": "var(--bits-floating-anchor-height)", }; } else { - if (first) { - const firstItemText = first.querySelector(`[${ITEM_TEXT_ATTR}]`); - return { - selectedItemNode: first, - selectedItemTextNode: firstItemText, - }; - } - } - return { - selectedItemNode: null, - selectedItemTextNode: null, - }; - }; - - focusSelectedItem = () => { - afterTick(() => { - const candidates = this.root.getCandidateNodes(); - const selected = - candidates.find((node) => node?.dataset.value === this.root.value.current) ?? null; - const first = candidates[0] ?? null; - this.focusFirst([selected, first]); - }); - }; - - itemRegister = (value: string, disabled: boolean) => { - const isFirstValidItem = !this.firstValidItemFound.current && !disabled; - const isSelectedItem = - this.root.value.current !== undefined && this.root.value.current === value; - - if (isSelectedItem || isFirstValidItem) { - if (isFirstValidItem) { - this.firstValidItemFound.current = true; - } - } - }; - - itemTextRegister = (node: HTMLElement | null, value: string, disabled: boolean) => { - const isFirstValidItem = !this.firstValidItemFound.current && !disabled; - const isSelectedItem = - this.root.value.current !== undefined && this.root.value.current === value; - - if (isSelectedItem || isFirstValidItem) { - this.selectedItemText.current = node; - } - }; - - #onkeydown = (e: KeyboardEvent) => { - const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; - - if (e.key === "Tab") e.preventDefault(); - - if (!isModifierKey && e.key.length === 1) { - this.typeahead.handleTypeaheadSearch(e.key, this.root.getCandidateNodes()); + return { + "--bits-select-content-transform-origin": "var(--bits-floating-transform-origin)", + "--bits-select-content-available-width": "var(--bits-floating-available-width)", + "--bits-select-content-available-height": "var(--bits-floating-available-height)", + "--bits-select-anchor-width": "var(--bits-floating-anchor-width)", + "--bits-select-anchor-height": "var(--bits-floating-anchor-height)", + }; } + }); - if ([kbd.ARROW_UP, kbd.ARROW_DOWN, kbd.HOME, kbd.END].includes(e.key)) { - let candidateNodes = this.root.getCandidateNodes(); - - if (e.key === kbd.ARROW_UP || e.key === kbd.END) { - candidateNodes = candidateNodes.slice().reverse(); - } - - if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) { - const currElement = e.target as HTMLElement; - const currIndex = candidateNodes.indexOf(currElement); - candidateNodes = candidateNodes.slice(currIndex + 1); - } - - setTimeout(() => this.focusFirst(candidateNodes)); + handleInteractOutside = (e: InteractOutsideEvent) => { + if (e.target === this.root.triggerNode || e.target === this.root.inputNode) { e.preventDefault(); } }; - #oncontextmenu = (e: Event) => { - e.preventDefault(); - }; + snippetProps = $derived.by(() => ({ open: this.root.open.current })); props = $derived.by( () => @@ -574,15 +753,15 @@ export class SelectContentState { id: this.id.current, role: "listbox", "data-state": getDataOpenClosed(this.root.open.current), + [this.root.bitsAttrs.content]: "", style: { display: "flex", flexDirection: "column", outline: "none", + boxSizing: "border-box", + ...this.#styles, }, - oncontextmenu: this.#oncontextmenu, - onkeydown: this.#onkeydown, - tabIndex: -1, - [CONTENT_ATTR]: "", + onpointermove: this.#onpointermove, }) as const ); } @@ -591,7 +770,9 @@ type SelectItemStateProps = WithRefProps< ReadableBoxedValues<{ value: string; disabled: boolean; - textValue?: string; + label: string; + onHighlight: () => void; + onUnhighlight: () => void; }> >; @@ -599,191 +780,146 @@ class SelectItemState { #id: SelectItemStateProps["id"]; #ref: SelectItemStateProps["ref"]; root: SelectRootState; - content: SelectContentState; - textId = box(undefined); value: SelectItemStateProps["value"]; + label: SelectItemStateProps["label"]; + onHighlight: SelectItemStateProps["onHighlight"]; + onUnhighlight: SelectItemStateProps["onUnhighlight"]; disabled: SelectItemStateProps["disabled"]; - textValue: SelectItemStateProps["textValue"]; - isSelected = $derived.by(() => this.root.value.current === this.value.current); - isFocused = box(false); - node = box(null); - trueTextValue = box(""); + isSelected = $derived.by(() => this.root.includesItem(this.value.current)); + isHighlighted = $derived.by(() => this.root.highlightedValue === this.value.current); + prevHighlighted = new Previous(() => this.isHighlighted); + textId = $state(""); - constructor(props: SelectItemStateProps, content: SelectContentState) { - this.#id = props.id; - this.#ref = props.ref; - this.root = content.root; - this.content = content; + constructor(props: SelectItemStateProps, root: SelectRootState) { + this.root = root; this.value = props.value; this.disabled = props.disabled; - this.textValue = props.textValue; + this.label = props.label; + this.onHighlight = props.onHighlight; + this.onUnhighlight = props.onUnhighlight; + this.#id = props.id; + this.#ref = props.ref; + + $effect(() => { + if (this.isHighlighted) { + this.onHighlight.current(); + } else if (this.prevHighlighted.current) { + this.onUnhighlight.current(); + } + }); useRefById({ id: this.#id, ref: this.#ref, }); - - $effect(() => { - const node = this.#ref.current; - if (!node) return; - this.content.itemRegister(this.value.current, this.disabled.current); - }); } - onItemTextChange = (node: HTMLElement | null) => { - this.trueTextValue.current = ((this.textValue?.current || node?.textContent) ?? "").trim(); - }; - - setTextId = (id: string) => { - this.textId.current = id; - }; - - handleSelect = async (e?: PointerEvent) => { - if (e?.defaultPrevented) return; - - if (!this.disabled.current) { - this.root.value.current = this.value.current; - this.root.handleClose(); - } - }; - - #onpointermove = async (e: PointerEvent) => { - if (e.defaultPrevented) return; - if (this.disabled.current) { - this.content.onItemLeave(); - } else { - (e.currentTarget as HTMLElement).focus({ preventScroll: true }); - } - }; - - #onpointerleave = async (e: PointerEvent) => { - if (e.defaultPrevented) return; - if (e.currentTarget === document.activeElement) { - this.content.onItemLeave(); - } - }; + snippetProps = $derived.by(() => ({ + selected: this.isSelected, + highlighted: this.isHighlighted, + })); #onpointerdown = (e: PointerEvent) => { - (e.currentTarget as HTMLElement).focus({ preventScroll: true }); - }; - - #onpointerup = async (e: PointerEvent) => { - await this.handleSelect(e); + // prevent focus from leaving the combobox + e.preventDefault(); }; - #onkeydown = async (e: KeyboardEvent) => { + /** + * Using `pointerup` instead of `click` allows power users to pointerdown + * the trigger, then release pointerup on an item to select it vs having to do + * multiple clicks. + */ + #onpointerup = (e: PointerEvent) => { if (e.defaultPrevented) return; + // prevent any default behavior + e.preventDefault(); + if (this.disabled.current) return; + const isCurrentSelectedValue = this.value.current === this.root.value.current; + this.root.toggleItem(this.value.current, this.label.current); - const isTypingAhead = this.content.typeahead.search.current !== ""; - if (isTypingAhead && e.key === kbd.SPACE) return; - - if (SELECTION_KEYS.includes(e.key)) { - this.handleSelect(); + if (!this.root.isMulti && !isCurrentSelectedValue) { + this.root.handleClose(); } - - // prevent page scroll on space - if (e.key === kbd.SPACE) e.preventDefault(); }; - #onfocus = () => { - this.isFocused.current = true; - }; - - #onblur = () => { - this.isFocused.current = false; + #onpointermove = (_: PointerEvent) => { + if (this.root.highlightedNode !== this.#ref.current) { + this.root.setHighlightedNode(this.#ref.current); + } }; - #ontouchend = (e: TouchEvent) => { - e.preventDefault(); - e.stopPropagation(); + setTextId = (id: string) => { + this.textId = id; }; props = $derived.by( () => ({ id: this.#id.current, - role: "option", - "aria-labelledby": this.textId.current ?? undefined, - "data-highlighted": this.isFocused.current ? "" : undefined, - "aria-selected": getAriaSelected(this.isSelected), - "data-state": getDataChecked(this.isSelected), - "aria-disabled": getAriaDisabled(this.disabled.current), - "data-disabled": getDataDisabled(this.disabled.current), - "data-selected": this.isSelected ? "" : undefined, + "aria-selected": this.root.includesItem(this.value.current) ? "true" : undefined, "data-value": this.value.current, - tabindex: this.disabled.current ? undefined : -1, - [ITEM_ATTR]: "", - // - onfocus: this.#onfocus, + "data-disabled": getDataDisabled(this.disabled.current), + "data-highlighted": + this.root.highlightedValue === this.value.current ? "" : undefined, + "data-selected": this.root.includesItem(this.value.current) ? "" : undefined, + "data-label": this.label.current, + [this.root.bitsAttrs.item]: "", + onpointermove: this.#onpointermove, - onpointerleave: this.#onpointerleave, onpointerdown: this.#onpointerdown, onpointerup: this.#onpointerup, - onkeydown: this.#onkeydown, - onblur: this.#onblur, - ontouchend: this.#ontouchend, }) as const ); } -type SelectItemTextStateProps = WithRefProps; +type SelectGroupStateProps = WithRefProps; -class SelectItemTextState { - item: SelectItemState; - #id: SelectItemTextStateProps["id"]; - #ref: SelectItemTextStateProps["ref"]; - node = box(null); - nativeOption = box.with( - () => - ({ - key: this.item.value.current, - value: this.item.value.current, - disabled: this.item.disabled.current, - innerHTML: this.node.current?.textContent, - }) as const - ); +class SelectGroupState { + #id: SelectGroupStateProps["id"]; + #ref: SelectGroupStateProps["ref"]; + root: SelectBaseRootState; + labelNode = $state(null); - constructor(props: SelectItemTextStateProps, item: SelectItemState) { + constructor(props: SelectGroupStateProps, root: SelectBaseRootState) { this.#id = props.id; this.#ref = props.ref; - this.item = item; - this.item.setTextId(this.#id.current); + this.root = root; useRefById({ id: this.#id, ref: this.#ref, }); + } - $effect(() => { - this.item.setTextId(this.#id.current); - }); + props = $derived.by( + () => + ({ + id: this.#id.current, + role: "group", + [this.root.bitsAttrs.group]: "", + "aria-labelledby": this.labelNode?.id ?? undefined, + }) as const + ); +} - $effect(() => { - untrack(() => { - const textNode = this.item.root.contentFragment?.getElementById(this.#id.current); - if (!textNode) return; - this.item.onItemTextChange(textNode); - this.item.content.itemTextRegister( - textNode, - this.item.value.current, - this.item.disabled.current - ); - - this.item.root.onNativeOptionAdd( - box.with(() => ({ - key: this.item.value.current, - value: this.item.value.current, - disabled: this.item.disabled.current, - innerHTML: textNode?.textContent, - })) - ); - }); - }); +type SelectGroupHeadingStateProps = WithRefProps; + +class SelectGroupHeadingState { + #id: SelectGroupHeadingStateProps["id"]; + #ref: SelectGroupHeadingStateProps["ref"]; + group: SelectGroupState; - $effect(() => { - return () => { - this.item.root.onNativeOptionRemove(this.nativeOption); - }; + constructor(props: SelectGroupHeadingStateProps, group: SelectGroupState) { + this.#id = props.id; + this.#ref = props.ref; + this.group = group; + + useRefById({ + id: this.#id, + ref: this.#ref, + onRefChange: (node) => { + group.labelNode = node; + }, }); } @@ -791,294 +927,80 @@ class SelectItemTextState { () => ({ id: this.#id.current, - [ITEM_TEXT_ATTR]: "", + [this.group.root.bitsAttrs["group-label"]]: "", }) as const ); } -type SelectItemAlignedPositionStateProps = ReadableBoxedValues<{ - onPlaced: () => void; +type SelectHiddenInputStateProps = ReadableBoxedValues<{ + value: string; }>; -class SelectItemAlignedPositionState { - root: SelectRootState; - content: SelectContentState; - shouldExpandOnScroll = $state(false); - shouldReposition = $state(false); - contentWrapperId = $state(useId()); - onPlaced: SelectItemAlignedPositionStateProps["onPlaced"]; - contentZIndex = $state(""); - - constructor(props: SelectItemAlignedPositionStateProps, content: SelectContentState) { - this.root = content.root; - this.content = content; - this.onPlaced = props.onPlaced; +class SelectHiddenInputState { + #value: SelectHiddenInputStateProps["value"]; + root: SelectBaseRootState; + shouldRender = $derived.by(() => this.root.name.current !== ""); - $effect(() => { - afterTick(() => { - this.position(); - const contentNode = document.getElementById(this.content.id.current); - if (contentNode) { - this.contentZIndex = window.getComputedStyle(contentNode).zIndex; - } - }); - }); + constructor(props: SelectHiddenInputStateProps, root: SelectBaseRootState) { + this.root = root; + this.#value = props.value; } - position = () => { - afterTick(() => { - const { selectedItemNode, selectedItemTextNode } = this.content.getSelectedItem(); - const contentNode = this.root.contentNode; - const contentWrapperNode = document.getElementById(this.contentWrapperId); - const viewportNode = this.content.viewportNode; - const triggerNode = this.root.triggerNode; - const valueNode = document.getElementById(this.root.valueId.current); - - if ( - !contentNode || - !contentWrapperNode || - !viewportNode || - !selectedItemNode || - !selectedItemTextNode || - !triggerNode || - !valueNode - ) { - return; - } - - const triggerRect = triggerNode.getBoundingClientRect(); - - // horizontal positioning - const contentRect = contentNode.getBoundingClientRect(); - const valueRect = valueNode.getBoundingClientRect(); - const itemTextRect = selectedItemTextNode.getBoundingClientRect(); - - if (this.root.dir.current === "rtl") { - const itemTextOffset = itemTextRect.left - contentRect.left; - const left = valueRect.left - itemTextOffset; - const leftDelta = triggerRect.left - left; - const minContentWidth = triggerRect.width + leftDelta; - const contentWidth = Math.max(minContentWidth, contentRect.width); - const rightEdge = window.innerWidth - CONTENT_MARGIN; - const clampedLeft = clamp(left, CONTENT_MARGIN, rightEdge - contentWidth); - - contentWrapperNode.style.minWidth = `${minContentWidth}px`; - contentWrapperNode.style.left = `${clampedLeft}px`; - } else { - const itemTextOffset = contentRect.right - itemTextRect.right; - const right = window.innerWidth - valueRect.right - itemTextOffset; - const rightDelta = window.innerWidth - triggerRect.right - right; - const minContentWidth = triggerRect.width + rightDelta; - const contentWidth = Math.max(minContentWidth, contentRect.width); - const leftEdge = window.innerWidth - CONTENT_MARGIN; - const clampedRight = clamp(right, CONTENT_MARGIN, leftEdge - contentWidth); - - contentWrapperNode.style.minWidth = `${minContentWidth}px`; - contentWrapperNode.style.right = `${clampedRight}px`; - } - - // vertical positioning - const items = this.root.getCandidateNodes(); - - const availableHeight = window.innerHeight - CONTENT_MARGIN * 2; - const itemsHeight = viewportNode.scrollHeight; - - const contentStyles = window.getComputedStyle(contentNode); - - const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10); - const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10); - - const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10); - const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10); - - const fullContentHeight = - contentBorderTopWidth + - contentPaddingTop + - itemsHeight + - contentPaddingBottom + - contentBorderBottomWidth; - - const minContentHeight = Math.min(selectedItemNode.offsetHeight * 5, fullContentHeight); - - const viewportStyles = window.getComputedStyle(viewportNode); - const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10); - const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10); - - const topEdgeToTriggerMiddle = - triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN; - const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle; - - const selectedItemHalfHeight = selectedItemNode.offsetHeight / 2; - const itemOffsetMiddle = selectedItemNode.offsetTop + selectedItemHalfHeight; - const contentTopToItemMiddle = - contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; - const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; - - const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle; - - if (willAlignWithoutTopOverflow) { - const isLastItem = selectedItemNode === items[items.length - 1]; - contentWrapperNode.style.bottom = `${0}px`; - const viewportOffsetBottom = - contentNode.clientHeight - viewportNode.offsetTop - viewportNode.offsetHeight; - const clampedTriggerMiddleToBottomEdge = Math.max( - triggerMiddleToBottomEdge, - selectedItemHalfHeight + - // viewport might have padding bottom, include it to avoid a scrollable viewport - (isLastItem ? viewportPaddingBottom : 0) + - viewportOffsetBottom + - contentBorderBottomWidth - ); - const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge; - contentWrapperNode.style.height = `${height}px`; - } else { - const isFirstItem = selectedItemNode === items[0]; - contentWrapperNode.style.top = `${0}px`; - const clampedTopEdgeToTriggerMiddle = Math.max( - topEdgeToTriggerMiddle, - contentBorderTopWidth + - viewportNode.offsetTop + - // viewport might have padding top, include it to avoid a scrollable viewport - (isFirstItem ? viewportPaddingTop : 0) + - selectedItemHalfHeight - ); - const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom; - contentWrapperNode.style.height = `${height}px`; - viewportNode.scrollTop = - contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportNode.offsetTop; - } - - contentWrapperNode.style.margin = `${CONTENT_MARGIN}px 0`; - contentWrapperNode.style.minHeight = `${minContentHeight}px`; - contentWrapperNode.style.maxHeight = `${availableHeight}px`; - - this.onPlaced.current(); - }); - requestAnimationFrame(() => (this.shouldExpandOnScroll = true)); - }; + #onfocus = (e: FocusEvent) => { + e.preventDefault(); - handleScrollButtonChange = (id: string) => { - afterTick(() => { - const node = document.getElementById(id); - if (!node) return; - if (!this.shouldReposition) return; - this.position(); - this.content.focusSelectedItem(); - this.shouldReposition = false; - }); + if (!this.root.isCombobox) { + this.root.triggerNode?.focus(); + } else { + this.root.inputNode?.focus(); + } }; - wrapperProps = $derived.by( - () => - ({ - id: this.contentWrapperId, - style: { - display: "flex", - flexDirection: "column", - position: "fixed", - zIndex: this.contentZIndex, - }, - [CONTENT_WRAPPER_ATTR]: "", - }) as const - ); - props = $derived.by( () => ({ - id: this.content.id.current, - style: { - boxSizing: "border-box", - maxHeight: "100%", - }, + disabled: getDisabled(this.root.disabled.current), + required: getRequired(this.root.required.current), + name: this.root.name.current, + value: this.#value.current, + style: styleToString(srOnlyStyles), + tabindex: -1, + onfocus: this.#onfocus, }) as const ); } -class SelectFloatingPositionState { - root: SelectRootState; - content: SelectContentState; - - constructor(content: SelectContentState) { - this.root = content.root; - this.content = content; - } - - props = { - style: { - boxSizing: "border-box", - "--bits-select-content-transform-origin": "var(--bits-floating-transform-origin)", - "--bits-select-content-available-width": "var(--bits-floating-available-width)", - "--bits-select-content-available-height": "var(--bits-floating-available-height)", - "--bits-select-anchor-width": "var(--bits-floating-anchor-width)", - "--bits-select-anchor-height": "var(--bits-floating-anchor-height)", - }, - } as const; -} - type SelectViewportStateProps = WithRefProps; class SelectViewportState { - id: SelectViewportStateProps["id"]; - ref: SelectViewportStateProps["ref"]; + #id: SelectViewportStateProps["id"]; + #ref: SelectViewportStateProps["ref"]; + root: SelectBaseRootState; content: SelectContentState; prevScrollTop = $state(0); constructor(props: SelectViewportStateProps, content: SelectContentState) { - this.id = props.id; + this.#id = props.id; + this.#ref = props.ref; this.content = content; - this.ref = props.ref; + this.root = content.root; useRefById({ - id: this.id, - ref: this.ref, + id: this.#id, + ref: this.#ref, onRefChange: (node) => { this.content.viewportNode = node; }, - deps: () => this.content.root.open.current, + deps: () => this.root.open.current, }); } - #onscroll = (e: WheelEvent) => { - afterTick(() => { - const viewport = e.currentTarget as HTMLElement; - const shouldExpandOnScroll = - this.content.alignedPositionState?.shouldExpandOnScroll ?? undefined; - - const contentWrapper = document.getElementById( - this.content.alignedPositionState?.contentWrapperId ?? "" - ); - - if (shouldExpandOnScroll && contentWrapper) { - const scrolledBy = Math.abs(this.prevScrollTop - viewport.scrollTop); - if (scrolledBy > 0) { - const availableHeight = window.innerHeight - CONTENT_MARGIN * 2; - const cssMinHeight = Number.parseFloat(contentWrapper.style.minHeight); - const cssHeight = Number.parseFloat(contentWrapper.style.height); - const prevHeight = Math.max(cssMinHeight, cssHeight); - - if (prevHeight < availableHeight) { - const nextHeight = prevHeight + scrolledBy; - const clampedNextHeight = Math.min(availableHeight, nextHeight); - const heightDiff = nextHeight - clampedNextHeight; - - contentWrapper.style.height = `${clampedNextHeight}px`; - if (contentWrapper.style.bottom === "0px") { - viewport.scrollTop = heightDiff > 0 ? heightDiff : 0; - contentWrapper.style.justifyContent = "flex-end"; - } - } - } - } - this.prevScrollTop = viewport.scrollTop; - }); - }; - props = $derived.by( () => ({ - id: this.id.current, + id: this.#id.current, role: "presentation", - [VIEWPORT_ATTR]: "", + [this.root.bitsAttrs.viewport]: "", style: { // we use position: 'relative' here on the `viewport` so that when we call // `selectedItem.offsetTop` in calculations, the offset is relative to the viewport @@ -1087,32 +1009,27 @@ class SelectViewportState { flex: 1, overflow: "auto", }, - onscroll: this.#onscroll, }) as const ); } -type SelectScrollButtonImplStateProps = WithRefProps< - ReadableBoxedValues<{ - mounted: boolean; - }> ->; +type SelectScrollButtonImplStateProps = WithRefProps>; class SelectScrollButtonImplState { id: SelectScrollButtonImplStateProps["id"]; ref: SelectScrollButtonImplStateProps["ref"]; content: SelectContentState; - alignedPositionState: SelectItemAlignedPositionState | null; + root: SelectBaseRootState; autoScrollTimer = $state(null); onAutoScroll: () => void = noop; mounted: SelectScrollButtonImplStateProps["mounted"]; constructor(props: SelectScrollButtonImplStateProps, content: SelectContentState) { - this.content = content; this.ref = props.ref; - this.alignedPositionState = content.alignedPositionState; this.id = props.id; this.mounted = props.mounted; + this.content = content; + this.root = content.root; useRefById({ id: this.id, @@ -1121,26 +1038,16 @@ class SelectScrollButtonImplState { }); $effect(() => { - if (this.mounted.current) { - const activeItem = this.content.root - .getCandidateNodes() - .find((node) => node === document.activeElement); - activeItem?.scrollIntoView({ block: "nearest" }); - } - }); - - $effect(() => { - return () => { - this.clearAutoScrollTimer(); - }; + if (!this.mounted.current) return; + const activeItem = untrack(() => this.root.highlightedNode); + activeItem?.scrollIntoView({ block: "nearest" }); }); } clearAutoScrollTimer = () => { - if (this.autoScrollTimer !== null) { - window.clearInterval(this.autoScrollTimer); - this.autoScrollTimer = null; - } + if (this.autoScrollTimer === null) return; + window.clearInterval(this.autoScrollTimer); + this.autoScrollTimer = null; }; #onpointerdown = () => { @@ -1151,7 +1058,6 @@ class SelectScrollButtonImplState { }; #onpointermove = () => { - this.content.onItemLeave?.(); if (this.autoScrollTimer !== null) return; this.autoScrollTimer = window.setInterval(() => { this.onAutoScroll(); @@ -1166,7 +1072,7 @@ class SelectScrollButtonImplState { () => ({ id: this.id.current, - "aria-hidden": "true", + "aria-hidden": getAriaHidden(true), style: { flexShrink: 0, }, @@ -1180,292 +1086,175 @@ class SelectScrollButtonImplState { class SelectScrollDownButtonState { state: SelectScrollButtonImplState; content: SelectContentState; + root: SelectBaseRootState; canScrollDown = $state(false); constructor(state: SelectScrollButtonImplState) { this.state = state; this.content = state.content; + this.root = state.root; this.state.onAutoScroll = this.handleAutoScroll; $effect(() => { const viewport = this.content.viewportNode; - const isPositioned = this.content.isPositioned.current; - + const isPositioned = this.content.isPositioned; if (!viewport || !isPositioned) return; let cleanup = noop; untrack(() => { const handleScroll = () => { - const maxScroll = viewport.scrollHeight - viewport.clientHeight; - this.canScrollDown = Math.ceil(viewport.scrollTop) < maxScroll; + afterTick(() => { + const maxScroll = viewport.scrollHeight - viewport.clientHeight; + const paddingTop = Number.parseInt( + getComputedStyle(viewport).paddingTop, + 10 + ); + + this.canScrollDown = Math.ceil(viewport.scrollTop) < maxScroll - paddingTop; + }); }; handleScroll(); cleanup = addEventListener(viewport, "scroll", handleScroll); }); - return () => { - cleanup(); - }; - }); - - $effect(() => { - if (this.state.mounted.current) { - this.state.alignedPositionState?.handleScrollButtonChange(this.state.id.current); - } + return cleanup; }); $effect(() => { - if (!this.state.mounted.current) { - this.state.clearAutoScrollTimer(); - } + if (this.state.mounted.current) return; + this.state.clearAutoScrollTimer(); }); } handleAutoScroll = () => { afterTick(() => { const viewport = this.content.viewportNode; - const selectedItem = this.content.getSelectedItem().selectedItemNode; - if (!viewport || !selectedItem) { - return; - } + const selectedItem = this.root.highlightedNode; + if (!viewport || !selectedItem) return; viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight; }); }; - props = $derived.by(() => ({ ...this.state.props, [SCROLL_DOWN_BUTTON_ATTR]: "" }) as const); + props = $derived.by( + () => ({ ...this.state.props, [this.root.bitsAttrs["scroll-down-button"]]: "" }) as const + ); } class SelectScrollUpButtonState { state: SelectScrollButtonImplState; content: SelectContentState; + root: SelectBaseRootState; canScrollUp = $state(false); constructor(state: SelectScrollButtonImplState) { this.state = state; this.content = state.content; + this.root = state.root; this.state.onAutoScroll = this.handleAutoScroll; $effect(() => { - let cleanup = noop; - - cleanup(); const viewport = this.content.viewportNode; - const isPositioned = this.content.isPositioned.current; - + const isPositioned = this.content.isPositioned; if (!viewport || !isPositioned) return; - const handleScroll = () => { - this.canScrollUp = viewport.scrollTop > 0; - }; - handleScroll(); + let cleanup = noop; - cleanup = addEventListener(viewport, "scroll", handleScroll); + untrack(() => { + const handleScroll = () => { + const paddingTop = Number.parseInt(getComputedStyle(viewport).paddingTop, 10); + this.canScrollUp = viewport.scrollTop - paddingTop > 0; + }; + handleScroll(); - return () => { - cleanup(); - }; - }); + cleanup = addEventListener(viewport, "scroll", handleScroll); + }); - $effect(() => { - if (this.state.mounted.current) { - this.state.alignedPositionState?.handleScrollButtonChange(this.state.id.current); - } + return cleanup; }); $effect(() => { - if (!this.state.mounted.current) { - this.state.clearAutoScrollTimer(); - } + if (this.state.mounted.current) return; + this.state.clearAutoScrollTimer(); }); } handleAutoScroll = () => { afterTick(() => { const viewport = this.content.viewportNode; - const selectedItem = this.content.getSelectedItem().selectedItemNode; + const selectedItem = this.root.highlightedNode; if (!viewport || !selectedItem) return; viewport.scrollTop = viewport.scrollTop - selectedItem.offsetHeight; }); }; - props = $derived.by(() => ({ ...this.state.props, [SCROLL_UP_BUTTON_ATTR]: "" }) as const); -} - -type SelectGroupStateProps = WithRefProps; - -class SelectGroupState { - #id: SelectGroupStateProps["id"]; - #ref: SelectGroupStateProps["ref"]; - labelNode = $state(null); - - constructor(props: SelectGroupStateProps) { - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - role: "group", - "aria-labelledby": this.labelNode?.id ?? undefined, - [GROUP_ATTR]: "", - }) as const - ); -} - -type SelectGroupHeadingStateProps = WithRefProps; - -class SelectGroupHeadingState { - #id: SelectGroupHeadingStateProps["id"]; - #ref: SelectGroupHeadingStateProps["ref"]; - group: SelectGroupState; - - constructor(props: SelectGroupHeadingStateProps, group: SelectGroupState) { - this.#ref = props.ref; - this.#id = props.id; - this.group = group; - - useRefById({ - id: this.#id, - ref: this.#ref, - onRefChange: (node) => { - this.group.labelNode = node; - }, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - [GROUP_LABEL_ATTR]: "", - }) as const - ); -} - -type SelectSeparatorStateProps = WithRefProps; - -class SelectSeparatorState { - #id: SelectSeparatorStateProps["id"]; - #ref: SelectSeparatorStateProps["ref"]; - - constructor(props: SelectSeparatorStateProps) { - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - props = $derived.by( - () => - ({ - id: this.#id.current, - [SEPARATOR_ATTR]: "", - "aria-hidden": getAriaHidden(true), - }) as const + () => ({ ...this.state.props, [this.root.bitsAttrs["scroll-up-button"]]: "" }) as const ); } -type SelectArrowStateProps = WithRefProps; - -class SelectArrowState { - #id: SelectArrowStateProps["id"]; - #ref: SelectArrowStateProps["ref"]; - - constructor(props: SelectArrowStateProps) { - this.#id = props.id; - this.#ref = props.ref; - - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } - - props = $derived.by( - () => - ({ - id: this.#id.current, - [ARROW_ATTR]: "", - "aria-hidden": getAriaHidden(true), - }) as const - ); -} +type InitSelectProps = { + type: "single" | "multiple"; + value: Box | Box; +} & ReadableBoxedValues<{ + disabled: boolean; + required: boolean; + loop: boolean; + scrollAlignment: "nearest" | "center"; + name: string; + items: { value: string; label: string; disabled?: boolean }[]; +}> & + WritableBoxedValues<{ + open: boolean; + }> & { + isCombobox: boolean; + }; -type SelectIconStateProps = WithRefProps; +const [setSelectRootContext, getSelectRootContext] = createContext([ + "Select.Root", + "Combobox.Root", +]); -class SelectIconState { - #id: SelectIconStateProps["id"]; - #ref: SelectIconStateProps["ref"]; +const [setSelectGroupContext, getSelectGroupContext] = createContext([ + "Select.Group", + "Combobox.Group", +]); - constructor(props: SelectIconStateProps) { - this.#id = props.id; - this.#ref = props.ref; +const [setSelectContentContext, getSelectContentContext] = createContext([ + "Select.Content", + "Combobox.Content", +]); - useRefById({ - id: this.#id, - ref: this.#ref, - }); - } +export function useSelectRoot(props: InitSelectProps) { + const { type, ...rest } = props; - props = $derived.by( - () => - ({ - id: this.#id.current, - [ICON_ATTR]: "", - "aria-hidden": getAriaHidden(true), - }) as const - ); -} + const rootState = + type === "single" + ? new SelectSingleRootState(rest as SelectSingleRootStateProps) + : new SelectMultipleRootState(rest as SelectMultipleRootStateProps); -export function useSelectRoot(props: SelectRootStateProps) { - return setSelectRootContext(new SelectRootState(props)); + return setSelectRootContext(rootState); } -export function useSelectContentFrag() { - return new SelectContentFragState(getSelectRootContext()); +export function useSelectInput(props: SelectInputStateProps) { + return new SelectInputState(props, getSelectRootContext()); } export function useSelectContent(props: SelectContentStateProps) { return setSelectContentContext(new SelectContentState(props, getSelectRootContext())); } -export function useSelectItemAlignedPosition(props: SelectItemAlignedPositionStateProps) { - const contentContext = getSelectContentContext(); - const alignedPositionState = new SelectItemAlignedPositionState(props, contentContext); - contentContext.alignedPositionState = alignedPositionState; - return setSelectContentItemAlignedContext(alignedPositionState); -} - -export function useSelectFloatingPosition() { - return new SelectFloatingPositionState(getSelectContentContext()); -} - export function useSelectTrigger(props: SelectTriggerStateProps) { return new SelectTriggerState(props, getSelectRootContext()); } -export function useSelectValue() { - return new SelectValueState(getSelectRootContext()); +export function useSelectComboTrigger(props: SelectComboTriggerStateProps) { + return new SelectComboTriggerState(props, getSelectRootContext()); } export function useSelectItem(props: SelectItemStateProps) { - return setSelectItemContext(new SelectItemState(props, getSelectContentContext())); -} - -export function useSelectItemText(props: SelectItemTextStateProps) { - return new SelectItemTextState(props, getSelectItemContext()); + return new SelectItemState(props, getSelectRootContext()); } export function useSelectViewport(props: SelectViewportStateProps) { @@ -1473,37 +1262,57 @@ export function useSelectViewport(props: SelectViewportStateProps) { } export function useSelectScrollUpButton(props: SelectScrollButtonImplStateProps) { - const state = new SelectScrollButtonImplState(props, getSelectContentContext()); - return new SelectScrollUpButtonState(state); + return new SelectScrollUpButtonState( + new SelectScrollButtonImplState(props, getSelectContentContext()) + ); } export function useSelectScrollDownButton(props: SelectScrollButtonImplStateProps) { - const state = new SelectScrollButtonImplState(props, getSelectContentContext()); - return new SelectScrollDownButtonState(state); + return new SelectScrollDownButtonState( + new SelectScrollButtonImplState(props, getSelectContentContext()) + ); } export function useSelectGroup(props: SelectGroupStateProps) { - return setSelectGroupContext(new SelectGroupState(props)); + return setSelectGroupContext(new SelectGroupState(props, getSelectRootContext())); } export function useSelectGroupHeading(props: SelectGroupHeadingStateProps) { return new SelectGroupHeadingState(props, getSelectGroupContext()); } -export function useSelectArrow(props: SelectArrowStateProps) { - return new SelectArrowState(props); -} - -export function useSelectSeparator(props: SelectSeparatorStateProps) { - return new SelectSeparatorState(props); +export function useSelectHiddenInput(props: SelectHiddenInputStateProps) { + return new SelectHiddenInputState(props, getSelectRootContext()); } -export function useSelectIcon(props: SelectIconStateProps) { - return new SelectIconState(props); -} - -// - -export function shouldShowPlaceholder(value?: string) { - return value === "" || value === undefined; +//////////////////////////////////// +// Helpers +//////////////////////////////////// + +const selectParts = [ + "trigger", + "content", + "item", + "viewport", + "scroll-up-button", + "scroll-down-button", + "group", + "group-label", + "separator", + "arrow", + "input", + "content-wrapper", + "item-text", + "value", +] as const; + +type SelectBitsAttrs = Record<(typeof selectParts)[number], string>; + +export function getSelectBitsAttrs(root: SelectBaseRootState): SelectBitsAttrs { + const isCombobox = root.isCombobox; + const attrObj = {} as SelectBitsAttrs; + for (const part of selectParts) { + attrObj[part] = isCombobox ? `data-combobox-${part}` : `data-select-${part}`; + } + return attrObj; } diff --git a/packages/bits-ui/src/lib/bits/select/types.ts b/packages/bits-ui/src/lib/bits/select/types.ts index 747e6f84b..25621d2ea 100644 --- a/packages/bits-ui/src/lib/bits/select/types.ts +++ b/packages/bits-ui/src/lib/bits/select/types.ts @@ -1,66 +1,66 @@ -import type { HTMLSelectAttributes } from "svelte/elements"; import type { Expand } from "svelte-toolbelt"; -import type { PopperLayerProps } from "../utilities/popper-layer/types.js"; +import type { PortalProps } from "../utilities/portal/types.js"; +import type { PopperLayerProps, PopperLayerStaticProps } from "../utilities/popper-layer/types.js"; import type { ArrowProps, ArrowPropsWithoutHTML } from "../utilities/arrow/types.js"; -import type { OnChangeFn, WithChild, WithChildren, Without } from "$lib/internal/types.js"; import type { BitsPrimitiveButtonAttributes, BitsPrimitiveDivAttributes, - BitsPrimitiveSpanAttributes, } from "$lib/shared/attributes.js"; -import type { Direction } from "$lib/shared/index.js"; -import type { PortalProps } from "$lib/bits/utilities/portal/index.js"; - -export type SelectRootPropsWithoutHTML = WithChildren<{ - /** - * The open state of the select. - */ - open?: boolean; - - /** - * A callback that is called when the select's open state changes. - */ - onOpenChange?: OnChangeFn; - - /** - * The value of the select. - */ - value?: string; - +import type { + OnChangeFn, + WithChild, + WithChildNoChildrenSnippetProps, + WithChildren, + Without, +} from "$lib/internal/types.js"; + +export type SelectBaseRootPropsWithoutHTML = WithChildren<{ /** - * A callback that is called when the select's value changes. + * Whether the combobox is disabled. + * + * @defaultValue `false` */ - onValueChange?: OnChangeFn; + disabled?: boolean; /** - * The reading direction of the select. + * Whether the combobox is required (for form submission). + * + * @defaultValue `false` */ - dir?: Direction; + required?: boolean; /** - * The name of the select used in form submission. + * The name to apply to the hidden input element for form submission. + * If not provided, a hidden input will not be rendered and the combobox will not be part of a form. */ name?: string; /** - * The native HTML select autocomplete attribute. + * Whether the combobox popover is open. + * + * @defaultValue `false` + * @bindable */ - autocomplete?: HTMLSelectAttributes["autocomplete"]; + open?: boolean; /** - * The native HTML select `form` attribute. + * A callback function called when the open state changes. */ - form?: string; + onOpenChange?: OnChangeFn; /** - * Whether the select is disabled. + * Whether or not the combobox menu should loop through the items when navigating with the keyboard. + * + * @defaultValue `false` */ - disabled?: boolean; + loop?: boolean; /** - * Whether the select is required. + * How to scroll the combobox items into view when navigating with the keyboard. + * + * @defaultValue `"nearest"` */ - required?: boolean; + scrollAlignment?: "nearest" | "center"; /** * Whether or not the open state is controlled or not. If `true`, the component will not update @@ -79,8 +79,87 @@ export type SelectRootPropsWithoutHTML = WithChildren<{ * @defaultValue false */ controlledValue?: boolean; + + /** + * Optionally provide an array of `value` and `label` pairs that will be used to match + * and trigger selection when the trigger is focused and a key is pressed while the content + * is closed. It's also used to handle form autofill. + * + * By providing this value, you enable selecting a value when the trigger is focused and a key + * is pressed without the content being open, similar to how a native ``, supporting form auto-fill. Use `Listbox` for multi-select or custom single-select needs outside forms. For single-select within forms, prefer `Select`. - - diff --git a/sites/docs/content/components/select.md b/sites/docs/content/components/select.md index 65edc139a..aa67b5a62 100644 --- a/sites/docs/content/components/select.md +++ b/sites/docs/content/components/select.md @@ -4,7 +4,7 @@ description: Enables users to choose from a list of options presented in a dropd --- @@ -18,34 +18,54 @@ description: Enables users to choose from a list of options presented in a dropd ## Overview -The `Select` component can be used as a replacement for the native `` el ## Reusable Components -As you can see from the structure above, there are a number of pieces that make up the `Select` component. These pieces are provided to give you maximum flexibility and customization options, but can be a burden to write out everywhere you need to use a `Select` in your application. +As you can see from the structure above, there are a number of pieces that make up the `Select` component. These pieces are provided to give you maximum flexibility and customization options, but can be a burden to write out everywhere you need to use a select in your application. -To ease this burden, it's recommended to create your own reusable `Select` component that wraps the primitives and provides a more convenient API for your use cases. +To ease this burden, it's recommended to create your own reusable select component that wraps the primitives and provides a more convenient API for your use cases. Here's an example of how you might create a reusable `MySelect` component that receives a list of options and renders each of them as an item. @@ -94,7 +114,7 @@ Here's an example of how you might create a reusable `MySelect` component that r {#snippet children({ selected })} {selected ? "✅" : ""} - {label} + {item.label} {/snippet} @@ -124,37 +144,41 @@ You can then use the `MySelect` component throughout your application like so: ``` -## Value State +## Managing Value State -The `value` represents the currently selected item/option within the select menu. Bits UI provides flexible options for controlling and synchronizing the Select's value state. +Bits UI offers several approaches to manage and synchronize the Select's value state, catering to different levels of control and integration needs. -### Two-Way Binding +### 1. Two-Way Binding -Use the `bind:value` directive for effortless two-way synchronization between your local state and the Select's internal state. +For seamless state synchronization, use Svelte's `bind:value` directive. This method automatically keeps your local state in sync with the component's internal state. -```svelte {3,6,8} +```svelte - + ``` -This setup enables toggling the value via the custom button and ensures the local `myValue` state updates when the Select's value changes through any internal means (e.g., clicking on an item's button). +#### Key Benefits -### Change Handler +- Simplifies state management +- Automatically updates `myValue` when the internal state changes (e.g., via clicking on an item) +- Allows external control (e.g., selecting an item via a separate button) -You can also use the `onValueChange` prop to update local state when the Select's `value` state changes. This is useful when you don't want two-way binding for one reason or another, or you want to perform additional logic when the Select changes. +### 2. Change Handler -```svelte {3,7-11} +For more granular control or to perform additional logic on state changes, use the `onValueChange` prop. This approach is useful when you need to execute custom logic alongside state updates. + +```svelte ``` -## Open State +#### Use Cases -The `open` state represents whether or not the select content is open. Bits UI provides flexible options for controlling and synchronizing the Select's open state. +- Implementing custom behaviors on value change +- Integrating with external state management solutions +- Triggering side effects (e.g., logging, data fetching) -### Two-Way Binding +### 3. Fully Controlled -Use the `bind:open` directive for effortless two-way synchronization between your local state and the Select's internal state. +For complete control over the component's value state, use the `controlledValue` prop. This approach requires you to manually manage the value state, giving you full control over when and how the component responds to value change events. -```svelte {3,6,8} +To implement controlled state: + +1. Set the `controlledValue` prop to `true` on the `Select.Root` component. +2. Provide a `value` prop to `Select.Root`, which should be a variable holding the current state. +3. Implement an `onValueChange` handler to update the state when the internal state changes. + +```svelte - + (myValue = v)}> + + +``` + +#### When to Use + +- Implementing complex open/close logic +- Coordinating multiple UI elements +- Debugging state-related issues + + + +While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully. - +For more in-depth information on controlled components and advanced state management techniques, refer to our [Controlled State](/docs/controlled-state) documentation. + + + +## Managing Open State + +Bits UI offers several approaches to manage and synchronize the Select's open state, catering to different levels of control and integration needs. + +### 1. Two-Way Binding + +For seamless state synchronization, use Svelte's `bind:open` directive. This method automatically keeps your local state in sync with the component's internal state. + +```svelte + + + + + ``` -This setup enables toggling the Select via the custom button and ensures the local `isOpen` state updates when the Select's open state changes through any internal means e.g. clicking on the trigger or outside the content. +#### Key Benefits + +- Simplifies state management +- Automatically updates `myOpen` when the internal state changes (e.g., via clicking on the trigger/input) +- Allows external control (e.g., opening via a separate button) -### Change Handler +### 2. Change Handler -You can also use the `onOpenChange` prop to update local state when the Select's `open` state changes. This is useful when you don't want two-way binding for one reason or another, or you want to perform additional logic when the Select changes. +For more granular control or to perform additional logic on state changes, use the `onOpenChange` prop. This approach is useful when you need to execute custom logic alongside state updates. -```svelte {3,7-11} +```svelte { - isOpen = open; + open={myOpen} + onOpenChange={(o) => { + myOpen = o; // additional logic here. }} > @@ -212,98 +281,167 @@ You can also use the `onOpenChange` prop to update local state when the Select's ``` -## Positioning +#### Use Cases -The `Select` component supports two different positioning strategies for the content. The default positioning strategy is `floating`, which uses Floating UI to position the content relative to the trigger, similar to other popover-like components. If you prefer a more native-like experience, you can set the `position` prop to `item-aligned`, which will position the content relative to the trigger, similar to a native ``, supporting form auto-fill. Use `Listbox` for multi-select or custom single-select needs outside forms. For single-select within forms, prefer `Select`. +```svelte + console.log('I am highlighted!')} onUnhighlight={() => console.log('I am unhighlighted!')} /> + + +``` diff --git a/sites/docs/src/lib/components/demos/index.ts b/sites/docs/src/lib/components/demos/index.ts index 44728440b..ce27f1a9b 100644 --- a/sites/docs/src/lib/components/demos/index.ts +++ b/sites/docs/src/lib/components/demos/index.ts @@ -24,10 +24,10 @@ export { default as DialogDemoNested } from "./dialog-demo-nested.svelte"; export { default as DropdownMenuDemo } from "./dropdown-menu-demo.svelte"; export { default as LabelDemo } from "./label-demo.svelte"; export { default as LinkPreviewDemo } from "./link-preview-demo.svelte"; -export { default as ListboxDemo } from "./listbox-demo.svelte"; -export { default as ListboxDemoCustom } from "./listbox-demo-custom.svelte"; -export { default as ListboxDemoCustomAnchor } from "./listbox-demo-custom-anchor.svelte"; -export { default as ListboxDemoMultiple } from "./listbox-demo-multiple.svelte"; +export { default as SelectDemo } from "./select-demo.svelte"; +export { default as SelectDemoCustom } from "./select-demo-custom.svelte"; +export { default as SelectDemoCustomAnchor } from "./select-demo-custom-anchor.svelte"; +export { default as SelectDemoMultiple } from "./select-demo-multiple.svelte"; export { default as MenubarDemo } from "./menubar-demo.svelte"; export { default as NavigationMenuDemo } from "./navigation-menu-demo.svelte"; export { default as PaginationDemo } from "./pagination-demo.svelte"; @@ -38,7 +38,6 @@ export { default as RadioGroupDemo } from "./radio-group-demo.svelte"; export { default as RangeCalendarDemo } from "./range-calendar-demo.svelte"; export { default as ScrollAreaDemo } from "./scroll-area-demo.svelte"; export { default as ScrollAreaDemoCustom } from "./scroll-area-demo-custom.svelte"; -export { default as SelectDemo } from "./select-demo.svelte"; export { default as SeparatorDemo } from "./separator-demo.svelte"; export { default as SliderDemo } from "./slider-demo.svelte"; export { default as SwitchDemo } from "./switch-demo.svelte"; @@ -51,5 +50,3 @@ export { default as TooltipDemo } from "./tooltip-demo.svelte"; export { default as TooltipDemoCustom } from "./tooltip-demo-custom.svelte"; export { default as TooltipDemoDelayDuration } from "./tooltip-demo-delay-duration.svelte"; export { default as DateFieldDemoCustom } from "./date-field-demo-custom.svelte"; -export { default as SelectDemoCustom } from "./select-demo-custom.svelte"; -export { default as SelectDemoPositioning } from "./select-demo-positioning.svelte"; diff --git a/sites/docs/src/lib/components/demos/listbox-demo-custom.svelte b/sites/docs/src/lib/components/demos/listbox-demo-custom.svelte deleted file mode 100644 index df1c5e977..000000000 --- a/sites/docs/src/lib/components/demos/listbox-demo-custom.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - {selectedLabel} - - - - - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
- -
- {/if} - {/snippet} -
- {/each} -
- - - -
-
-
diff --git a/sites/docs/src/lib/components/demos/listbox-demo.svelte b/sites/docs/src/lib/components/demos/listbox-demo.svelte deleted file mode 100644 index acc57802f..000000000 --- a/sites/docs/src/lib/components/demos/listbox-demo.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - {selectedLabel} - - - - - - - - - {#each themes as theme, i (i + theme.value)} - - {#snippet children({ selected })} - {theme.label} - {#if selected} -
- -
- {/if} - {/snippet} -
- {/each} -
- - - -
-
-
diff --git a/sites/docs/src/lib/components/demos/listbox-demo-custom-anchor.svelte b/sites/docs/src/lib/components/demos/select-demo-custom-anchor.svelte similarity index 82% rename from sites/docs/src/lib/components/demos/listbox-demo-custom-anchor.svelte rename to sites/docs/src/lib/components/demos/select-demo-custom-anchor.svelte index 2fdc3ff73..b3c053bb7 100644 --- a/sites/docs/src/lib/components/demos/listbox-demo-custom-anchor.svelte +++ b/sites/docs/src/lib/components/demos/select-demo-custom-anchor.svelte @@ -1,6 +1,6 @@ @@ -8,7 +8,7 @@
Custom Anchor
- - import { Select, type WithoutChildren } from "bits-ui"; - import CaretUpDown from "phosphor-svelte/lib/CaretUpDown"; + import { Select, type SelectSingleRootProps, type WithoutChildrenOrChild } from "bits-ui"; import Check from "phosphor-svelte/lib/Check"; import Palette from "phosphor-svelte/lib/Palette"; - import CaretDoubleDown from "phosphor-svelte/lib/CaretDoubleDown"; + import CaretUpDown from "phosphor-svelte/lib/CaretUpDown"; import CaretDoubleUp from "phosphor-svelte/lib/CaretDoubleUp"; + import CaretDoubleDown from "phosphor-svelte/lib/CaretDoubleDown"; + + type Props = Omit & { + contentProps?: WithoutChildrenOrChild; + }; - let { - value = $bindable(""), - contentProps, - ...restProps - }: WithoutChildren & { - contentProps?: WithoutChildren; - } = $props(); + let { value = $bindable(""), contentProps, ...restProps }: Props = $props(); const themes = [ { value: "light-monochrome", label: "Light Monochrome" }, @@ -37,53 +35,48 @@ { value: "burnt-orange", label: "Burnt Orange" }, ]; - const selectedLabel = $derived(themes.find((theme) => theme.value === value)?.label); + const selectedLabel = $derived( + value ? themes.find((theme) => theme.value === value)?.label : "Select a theme" + ); - + - {#if selectedLabel} - - {selectedLabel} - - {:else} - - {/if} + {selectedLabel} - {#each themes as theme} + {#each themes as theme, i (i + theme.value)} {#snippet children({ selected })} - - {theme.label} - + {theme.label} {#if selected} - +
- +
{/if} {/snippet}
{/each}
- +
diff --git a/sites/docs/src/lib/components/demos/listbox-demo-multiple.svelte b/sites/docs/src/lib/components/demos/select-demo-multiple.svelte similarity index 78% rename from sites/docs/src/lib/components/demos/listbox-demo-multiple.svelte rename to sites/docs/src/lib/components/demos/select-demo-multiple.svelte index ce4b622b0..d7d0d3f74 100644 --- a/sites/docs/src/lib/components/demos/listbox-demo-multiple.svelte +++ b/sites/docs/src/lib/components/demos/select-demo-multiple.svelte @@ -1,5 +1,5 @@ - - + {selectedLabel} - - - + + - + - - + + {#each themes as theme, i (i + theme.value)} - {/if} {/snippet} - + {/each} - - + + - - - - + + +
+
diff --git a/sites/docs/src/lib/components/demos/select-demo-positioning.svelte b/sites/docs/src/lib/components/demos/select-demo-positioning.svelte deleted file mode 100644 index f2d9aefc5..000000000 --- a/sites/docs/src/lib/components/demos/select-demo-positioning.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - -
- -
position="floating"
-
-
- -
position="item-aligned"
-
-
diff --git a/sites/docs/src/lib/components/demos/select-demo.svelte b/sites/docs/src/lib/components/demos/select-demo.svelte index e8e9e98ff..695044187 100644 --- a/sites/docs/src/lib/components/demos/select-demo.svelte +++ b/sites/docs/src/lib/components/demos/select-demo.svelte @@ -1,10 +1,10 @@ - + - {#if selectedLabel} - - {selectedLabel} - - {:else} - - {/if} + {selectedLabel} - {#each themes as theme} + {#each themes as theme, i (i + theme.value)} {#snippet children({ selected })} - - {theme.label} - + {theme.label} {#if selected} - +
- +
{/if} {/snippet}
{/each}
- +
diff --git a/sites/docs/src/lib/content/api-reference/combobox.api.ts b/sites/docs/src/lib/content/api-reference/combobox.api.ts index 6562af778..ffe3f9bb1 100644 --- a/sites/docs/src/lib/content/api-reference/combobox.api.ts +++ b/sites/docs/src/lib/content/api-reference/combobox.api.ts @@ -20,6 +20,7 @@ import { StringOrArrayStringProp, } from "./extended-types/shared/index.js"; import { ComboboxScrollAlignmentProp } from "./extended-types/combobox/index.js"; +import { ItemsProp } from "./extended-types/select/index.js"; import { arrowProps, childrenSnippet, @@ -32,6 +33,7 @@ import { createEnumDataAttr, createEnumProp, createFunctionProp, + createPropSchema, createStringProp, createUnionProp, dirProp, @@ -110,6 +112,14 @@ export const root = createApiSchema({ default: C.FALSE, description: "Whether or not the combobox menu should loop through items.", }), + items: createPropSchema({ + type: { + type: "array", + definition: ItemsProp, + }, + description: + "Optionally provide an array of objects representing the items in the select for autofill capabilities. Only applicable to combobox's with type `single`", + }), children: childrenSnippet(), }, }); diff --git a/sites/docs/src/lib/content/api-reference/extended-types/select/index.ts b/sites/docs/src/lib/content/api-reference/extended-types/select/index.ts index 252829efc..d2a5cea52 100644 --- a/sites/docs/src/lib/content/api-reference/extended-types/select/index.ts +++ b/sites/docs/src/lib/content/api-reference/extended-types/select/index.ts @@ -1 +1 @@ -export { default as SelectPositionProp } from "./position-prop.md"; +export { default as ItemsProp } from "./items-prop.md"; diff --git a/sites/docs/src/lib/content/api-reference/extended-types/select/items-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/select/items-prop.md new file mode 100644 index 000000000..6b72f1bac --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/select/items-prop.md @@ -0,0 +1,3 @@ +```ts +{ value: string; label: string; disabled?: boolean}[] +``` diff --git a/sites/docs/src/lib/content/api-reference/extended-types/select/position-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/select/position-prop.md deleted file mode 100644 index c5b45a39d..000000000 --- a/sites/docs/src/lib/content/api-reference/extended-types/select/position-prop.md +++ /dev/null @@ -1,3 +0,0 @@ -```ts -"floating" | "item-aligned"; -``` diff --git a/sites/docs/src/lib/content/api-reference/index.ts b/sites/docs/src/lib/content/api-reference/index.ts index c922615ff..b71bca756 100644 --- a/sites/docs/src/lib/content/api-reference/index.ts +++ b/sites/docs/src/lib/content/api-reference/index.ts @@ -35,7 +35,6 @@ import { toggle } from "./toggle.api.js"; import { toolbar } from "./toolbar.api.js"; import { tooltip } from "./tooltip.api.js"; import { menubar } from "./menubar.api.js"; -import { listbox } from "./listbox.api.js"; import type { APISchema } from "$lib/types/index.js"; export const bits = [ @@ -58,7 +57,6 @@ export const bits = [ "dropdown-menu", "label", "link-preview", - "listbox", "menubar", "navigation-menu", "pagination", @@ -107,7 +105,6 @@ export const apiSchemas: Record = { "dropdown-menu": dropdownMenu, label, "link-preview": linkPreview, - listbox, menubar, "navigation-menu": navigationMenu, pagination, diff --git a/sites/docs/src/lib/content/api-reference/listbox.api.ts b/sites/docs/src/lib/content/api-reference/listbox.api.ts deleted file mode 100644 index 6f8bcc305..000000000 --- a/sites/docs/src/lib/content/api-reference/listbox.api.ts +++ /dev/null @@ -1,370 +0,0 @@ -import type { - ListboxArrowPropsWithoutHTML, - ListboxContentPropsWithoutHTML, - ListboxContentStaticPropsWithoutHTML, - ListboxGroupHeadingPropsWithoutHTML, - ListboxGroupPropsWithoutHTML, - ListboxItemPropsWithoutHTML, - ListboxRootPropsWithoutHTML, - ListboxScrollDownButtonPropsWithoutHTML, - ListboxScrollUpButtonPropsWithoutHTML, - ListboxTriggerPropsWithoutHTML, - ListboxViewportPropsWithoutHTML, -} from "bits-ui"; -import { - NoopProp, - OnChangeStringOrArrayProp, - OnOpenChangeProp, - OpenChildSnippetProps, - OpenChildrenSnippetProps, - OpenClosedProp, - SingleOrMultipleProp, - StringOrArrayStringProp, -} from "./extended-types/shared/index.js"; -import { ComboboxScrollAlignmentProp } from "./extended-types/combobox/index.js"; -import { - arrowProps, - childrenSnippet, - controlledOpenProp, - controlledValueProp, - createApiSchema, - createBooleanProp, - createCSSVarSchema, - createDataAttrSchema, - createEnumDataAttr, - createEnumProp, - createFunctionProp, - createStringProp, - createUnionProp, - dirProp, - dismissibleLayerProps, - escapeLayerProps, - floatingProps, - focusScopeProps, - forceMountProp, - onCloseAutoFocusProp, - preventOverflowTextSelectionProp, - preventScrollProp, - withChildProps, -} from "$lib/content/api-reference/helpers.js"; -import * as C from "$lib/content/constants.js"; - -const stateDataAttr = createEnumDataAttr({ - name: "state", - options: ["open", "closed"], - description: "The listbox's open state.", - definition: OpenClosedProp, -}); - -export const root = createApiSchema({ - title: "Root", - description: "The root listbox component which manages & scopes the state of the listbox.", - props: { - type: createEnumProp({ - options: ["single", "multiple"], - description: "The type of selection to use for the listbox.", - required: true, - definition: SingleOrMultipleProp, - }), - value: createUnionProp({ - options: ["string", "string[]"], - default: "", - description: - "The value of the listbox. When the type is `'single'`, this should be a string. When the type is `'multiple'`, this should be an array of strings.", - bindable: true, - definition: StringOrArrayStringProp, - }), - onValueChange: createFunctionProp({ - definition: OnChangeStringOrArrayProp, - description: - "A callback that is fired when the listbox value changes. When the type is `'single'`, the argument will be a string. When the type is `'multiple'`, the argument will be an array of strings.", - }), - controlledValue: controlledValueProp, - open: createBooleanProp({ - default: C.FALSE, - description: "The open state of the listbox menu.", - bindable: true, - }), - onOpenChange: createFunctionProp({ - definition: OnOpenChangeProp, - description: "A callback that is fired when the listbox menu's open state changes.", - }), - controlledOpen: controlledOpenProp, - disabled: createBooleanProp({ - default: C.FALSE, - description: "Whether or not the listbox component is disabled.", - }), - name: createStringProp({ - description: - "The name to apply to the hidden input element for form submission. If provided, a hidden input element will be rendered to submit the value of the listbox.", - }), - required: createBooleanProp({ - default: C.FALSE, - description: "Whether or not the listbox menu is required.", - }), - scrollAlignment: createEnumProp({ - options: ["nearest", "center"], - default: "'nearest'", - description: "The alignment of the highlighted item when scrolling.", - definition: ComboboxScrollAlignmentProp, - }), - loop: createBooleanProp({ - default: C.FALSE, - description: "Whether or not the listbox menu should loop through items.", - }), - children: childrenSnippet(), - }, -}); - -export const content = createApiSchema({ - title: "Content", - description: "The element which contains the listbox's items.", - props: { - ...floatingProps(), - ...escapeLayerProps, - ...dismissibleLayerProps, - onCloseAutoFocus: onCloseAutoFocusProp, - preventOverflowTextSelection: preventOverflowTextSelectionProp, - dir: dirProp, - loop: createBooleanProp({ - default: C.FALSE, - description: - "Whether or not the listbox should loop through items when reaching the end.", - }), - forceMount: forceMountProp, - preventScroll: { - ...preventScrollProp, - default: C.FALSE, - }, - ...withChildProps({ - elType: "HTMLDivElement", - childrenDef: OpenChildrenSnippetProps, - childDef: OpenChildSnippetProps, - }), - }, - dataAttributes: [ - stateDataAttr, - createDataAttrSchema({ - name: "listbox-content", - description: "Present on the content element.", - }), - ], - cssVars: [ - createCSSVarSchema({ - name: "--bits-listbox-content-transform-origin", - description: "The transform origin of the listbox content element.", - }), - createCSSVarSchema({ - name: "--bits-listbox-content-available-width", - description: "The available width of the listbox content element.", - }), - createCSSVarSchema({ - name: "--bits-listbox-content-available-height", - description: "The available height of the listbox content element.", - }), - createCSSVarSchema({ - name: "--bits-listbox-anchor-width", - description: "The width of the listbox trigger element.", - }), - createCSSVarSchema({ - name: "--bits-listbox-anchor-height", - description: "The height of the listbox trigger element.", - }), - ], -}); - -export const contentStatic = createApiSchema({ - title: "ContentStatic", - description: "The element which contains the listbox's items. (Static/No Floating UI)", - props: { - ...escapeLayerProps, - ...dismissibleLayerProps, - ...focusScopeProps, - preventScroll: preventScrollProp, - preventOverflowTextSelection: preventOverflowTextSelectionProp, - dir: dirProp, - loop: createBooleanProp({ - default: C.FALSE, - description: - "Whether or not the listbox should loop through items when reaching the end.", - }), - forceMount: forceMountProp, - ...withChildProps({ - elType: "HTMLDivElement", - childrenDef: OpenChildrenSnippetProps, - childDef: OpenChildSnippetProps, - }), - }, - dataAttributes: [ - stateDataAttr, - createDataAttrSchema({ - name: "listbox-content", - description: "Present on the content element.", - }), - ], -}); - -export const item = createApiSchema({ - title: "Item", - description: "A listbox item, which must be a child of the `Listbox.Content` component.", - props: { - value: createStringProp({ - description: "The value of the item.", - required: true, - }), - label: createStringProp({ - description: - "The label of the item, which is what the list will be filtered by using typeahead behavior.", - }), - disabled: createBooleanProp({ - default: C.FALSE, - description: - "Whether or not the listbox item is disabled. This will prevent interaction/selection.", - }), - onHighlight: createFunctionProp({ - definition: NoopProp, - description: "A callback that is fired when the item is highlighted.", - }), - onUnhighlight: createFunctionProp({ - definition: NoopProp, - description: "A callback that is fired when the item is unhighlighted.", - }), - ...withChildProps({ elType: "HTMLDivElement" }), - }, - dataAttributes: [ - createDataAttrSchema({ - name: "value", - description: "The value of the listbox item.", - value: "string", - }), - createDataAttrSchema({ - name: "label", - description: "The label of the listbox item.", - value: "string", - }), - createDataAttrSchema({ - name: "disabled", - description: "Present when the item is disabled.", - }), - createDataAttrSchema({ - name: "highlighted", - description: - "Present when the item is highlighted, which is either via keyboard navigation of the menu or hover.", - }), - createDataAttrSchema({ - name: "selected", - description: "Present when the item is selected.", - }), - createDataAttrSchema({ - name: "listbox-item", - description: "Present on the item element.", - }), - ], -}); - -export const trigger = createApiSchema({ - title: "Trigger", - description: "A button which toggles the listbox's open state.", - props: withChildProps({ elType: "HTMLButtonElement" }), - dataAttributes: [ - stateDataAttr, - createDataAttrSchema({ - name: "disabled", - description: "Present when the listbox is disabled.", - }), - createDataAttrSchema({ - name: "listbox-trigger", - description: "Present on the trigger element.", - }), - ], -}); - -export const viewport = createApiSchema({ - title: "Viewport", - description: - "An optional element to track the scroll position of the listbox for rendering the scroll up/down buttons.", - props: withChildProps({ elType: "HTMLDivElement" }), - dataAttributes: [ - createDataAttrSchema({ - name: "listbox-viewport", - description: "Present on the viewport element.", - }), - ], -}); - -export const scrollUpButton = createApiSchema({ - title: "ScrollUpButton", - description: - "An optional scroll up button element to improve the scroll experience within the listbox. Should be used in conjunction with the `Listbox.Viewport` component.", - props: withChildProps({ elType: "HTMLDivElement" }), - dataAttributes: [ - createDataAttrSchema({ - name: "listbox-scroll-up-button", - description: "Present on the scroll up button element.", - }), - ], -}); - -export const scrollDownButton = createApiSchema({ - title: "ScrollDownButton", - description: - "An optional scroll down button element to improve the scroll experience within the listbox. Should be used in conjunction with the `Listbox.Viewport` component.", - props: withChildProps({ elType: "HTMLDivElement" }), - dataAttributes: [ - createDataAttrSchema({ - name: "listbox-scroll-down-button", - description: "Present on the scroll down button element.", - }), - ], -}); - -export const group = createApiSchema({ - title: "Group", - description: "A group of related listbox items.", - props: withChildProps({ elType: "HTMLDivElement" }), - dataAttributes: [ - createDataAttrSchema({ - name: "listbox-group", - description: "Present on the group element.", - }), - ], -}); - -export const groupHeading = createApiSchema({ - title: "GroupHeading", - description: - "A heading for the parent listbox group. This is used to describe a group of related listbox items.", - props: withChildProps({ elType: "HTMLDivElement" }), - dataAttributes: [ - createDataAttrSchema({ - name: "listbox-group-heading", - description: "Present on the group heading element.", - }), - ], -}); - -export const arrow = createApiSchema({ - title: "Arrow", - description: "An optional arrow element which points to the content when open.", - props: arrowProps, - dataAttributes: [ - createDataAttrSchema({ - name: "arrow", - description: "Present on the arrow element.", - }), - ], -}); - -export const listbox = [ - root, - trigger, - content, - contentStatic, - item, - viewport, - scrollUpButton, - scrollDownButton, - group, - groupHeading, - arrow, -]; diff --git a/sites/docs/src/lib/content/api-reference/select.api.ts b/sites/docs/src/lib/content/api-reference/select.api.ts index f720610df..a4b9fe1f9 100644 --- a/sites/docs/src/lib/content/api-reference/select.api.ts +++ b/sites/docs/src/lib/content/api-reference/select.api.ts @@ -1,23 +1,28 @@ import type { SelectArrowPropsWithoutHTML, SelectContentPropsWithoutHTML, + SelectContentStaticPropsWithoutHTML, SelectGroupHeadingPropsWithoutHTML, SelectGroupPropsWithoutHTML, SelectItemPropsWithoutHTML, SelectRootPropsWithoutHTML, SelectScrollDownButtonPropsWithoutHTML, SelectScrollUpButtonPropsWithoutHTML, - SelectSeparatorPropsWithoutHTML, SelectTriggerPropsWithoutHTML, - SelectValuePropsWithoutHTML, SelectViewportPropsWithoutHTML, } from "bits-ui"; import { + NoopProp, + OnChangeStringOrArrayProp, OnOpenChangeProp, - OnStringValueChangeProp, + OpenChildSnippetProps, + OpenChildrenSnippetProps, OpenClosedProp, + SingleOrMultipleProp, + StringOrArrayStringProp, } from "./extended-types/shared/index.js"; -import { SelectPositionProp } from "./extended-types/select/index.js"; +import { ComboboxScrollAlignmentProp } from "./extended-types/combobox/index.js"; +import { ItemsProp } from "./extended-types/select/index.js"; import { arrowProps, childrenSnippet, @@ -25,35 +30,56 @@ import { controlledValueProp, createApiSchema, createBooleanProp, + createCSSVarSchema, createDataAttrSchema, + createEnumDataAttr, createEnumProp, createFunctionProp, + createObjectProp, createStringProp, + createUnionProp, dirProp, dismissibleLayerProps, - enums, escapeLayerProps, floatingProps, focusScopeProps, forceMountProp, + onCloseAutoFocusProp, preventOverflowTextSelectionProp, preventScrollProp, withChildProps, } from "$lib/content/api-reference/helpers.js"; import * as C from "$lib/content/constants.js"; +const stateDataAttr = createEnumDataAttr({ + name: "state", + options: ["open", "closed"], + description: "The select's open state.", + definition: OpenClosedProp, +}); + export const root = createApiSchema({ title: "Root", description: "The root select component which manages & scopes the state of the select.", props: { - value: createStringProp({ - description: "The value of the currently selected select item.", + type: createEnumProp({ + options: ["single", "multiple"], + description: "The type of selection to use for the select.", + required: true, + definition: SingleOrMultipleProp, + }), + value: createUnionProp({ + options: ["string", "string[]"], + default: "", + description: + "The value of the select. When the type is `'single'`, this should be a string. When the type is `'multiple'`, this should be an array of strings.", bindable: true, - default: "''", + definition: StringOrArrayStringProp, }), onValueChange: createFunctionProp({ - definition: OnStringValueChangeProp, - description: "A callback that is fired when the select menu's value changes.", + definition: OnChangeStringOrArrayProp, + description: + "A callback that is fired when the select value changes. When the type is `'single'`, the argument will be a string. When the type is `'multiple'`, the argument will be an array of strings.", }), controlledValue: controlledValueProp, open: createBooleanProp({ @@ -68,237 +94,270 @@ export const root = createApiSchema({ controlledOpen: controlledOpenProp, disabled: createBooleanProp({ default: C.FALSE, - description: "Whether or not the select menu is disabled.", - }), - autocomplete: createStringProp({ - description: "The autocomplete attribute of the select.", - }), - dir: dirProp, - form: createStringProp({ - description: "The form attribute of the select.", + description: "Whether or not the select component is disabled.", }), name: createStringProp({ - description: "The name to apply to the hidden input element for form submission.", + description: + "The name to apply to the hidden input element for form submission. If provided, a hidden input element will be rendered to submit the value of the select.", }), required: createBooleanProp({ default: C.FALSE, description: "Whether or not the select menu is required.", }), + scrollAlignment: createEnumProp({ + options: ["nearest", "center"], + default: "'nearest'", + description: "The alignment of the highlighted item when scrolling.", + definition: ComboboxScrollAlignmentProp, + }), + loop: createBooleanProp({ + default: C.FALSE, + description: "Whether or not the select menu should loop through items.", + }), + items: createObjectProp({ + definition: ItemsProp, + description: + "Optionally provide an array of `value` and `label` pairs that will be used to match and trigger selection when the trigger is focused and a key is pressed while the content is closed. Additionally, this will be used for form autofill when the type is single.", + }), children: childrenSnippet(), }, }); -export const trigger = createApiSchema({ - title: "Trigger", - description: "The button element which toggles the select menu's open state.", +export const content = createApiSchema({ + title: "Content", + description: "The element which contains the select's items.", props: { - disabled: createBooleanProp({ + ...floatingProps(), + ...escapeLayerProps, + ...dismissibleLayerProps, + onCloseAutoFocus: onCloseAutoFocusProp, + preventOverflowTextSelection: preventOverflowTextSelectionProp, + dir: dirProp, + loop: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the select should loop through items when reaching the end.", + }), + forceMount: forceMountProp, + preventScroll: { + ...preventScrollProp, default: C.FALSE, - description: "Whether or not the select menu trigger is disabled.", + }, + ...withChildProps({ + elType: "HTMLDivElement", + childrenDef: OpenChildrenSnippetProps, + childDef: OpenChildSnippetProps, }), - ...withChildProps({ elType: "HTMLButtonElement" }), }, dataAttributes: [ + stateDataAttr, createDataAttrSchema({ - name: "state", - definition: OpenClosedProp, - description: "The dropdown menu's open state.", - isEnum: true, + name: "select-content", + description: "Present on the content element.", }), - createDataAttrSchema({ - name: "disabled", - description: "Present when the trigger is disabled.", + ], + cssVars: [ + createCSSVarSchema({ + name: "--bits-select-content-transform-origin", + description: "The transform origin of the select content element.", }), - createDataAttrSchema({ - name: "select-trigger", - description: "Present on the select trigger element.", + createCSSVarSchema({ + name: "--bits-select-content-available-width", + description: "The available width of the select content element.", + }), + createCSSVarSchema({ + name: "--bits-select-content-available-height", + description: "The available height of the select content element.", + }), + createCSSVarSchema({ + name: "--bits-select-anchor-width", + description: "The width of the select trigger element.", + }), + createCSSVarSchema({ + name: "--bits-select-anchor-height", + description: "The height of the select trigger element.", }), ], }); -export const content = createApiSchema({ - title: "Content", - description: "The content/menu element which contains the select menu's items.", +export const contentStatic = createApiSchema({ + title: "ContentStatic", + description: "The element which contains the select's items. (Static/No Floating UI)", props: { - position: createEnumProp({ - options: ["floating", "item-aligned"], - default: "floating", - description: - "The positioning strategy to use for the content. If set to 'item-aligned', the content will be positioned relative to the trigger, similar to a native select. If set to `floating`, the content will use Floating UI to position itself similar to other popover-like components.", - definition: SelectPositionProp, - }), - dir: dirProp, - ...floatingProps(), - ...dismissibleLayerProps, ...escapeLayerProps, + ...dismissibleLayerProps, ...focusScopeProps, - preventOverflowTextSelection: preventOverflowTextSelectionProp, preventScroll: preventScrollProp, - forceMount: forceMountProp, + preventOverflowTextSelection: preventOverflowTextSelectionProp, + dir: dirProp, loop: createBooleanProp({ default: C.FALSE, description: - "Whether or not the select menu should loop through items when reaching the end.", + "Whether or not the select should loop through items when reaching the end.", + }), + forceMount: forceMountProp, + ...withChildProps({ + elType: "HTMLDivElement", + childrenDef: OpenChildrenSnippetProps, + childDef: OpenChildSnippetProps, }), - ...withChildProps({ elType: "HTMLDivElement" }), }, dataAttributes: [ + stateDataAttr, createDataAttrSchema({ name: "select-content", - description: "Present on the select content element.", + description: "Present on the content element.", }), ], }); export const item = createApiSchema({ title: "Item", - description: "A select item, which must be a child of the `Select.Content` component.", + description: "A select item, which must be a child of the `select.Content` component.", props: { value: createStringProp({ - description: "The value of the select item.", + description: "The value of the item.", required: true, }), - textValue: createStringProp({ - description: "The text value of the select item, which is used for typeahead purposes.", + label: createStringProp({ + description: + "The label of the item, which is what the list will be filtered by using typeahead behavior.", }), disabled: createBooleanProp({ default: C.FALSE, description: "Whether or not the select item is disabled. This will prevent interaction/selection.", }), + onHighlight: createFunctionProp({ + definition: NoopProp, + description: "A callback that is fired when the item is highlighted.", + }), + onUnhighlight: createFunctionProp({ + definition: NoopProp, + description: "A callback that is fired when the item is unhighlighted.", + }), ...withChildProps({ elType: "HTMLDivElement" }), }, dataAttributes: [ - { - name: "state", - description: "The state of the item.", - value: enums("selected", "hovered"), - isEnum: true, - definition: OpenClosedProp, - }, - createDataAttrSchema({ - name: "highlighted", - description: "Present when the item is highlighted, via keyboard navigation or hover.", + name: "value", + description: "The value of the select item.", + value: "string", + }), + createDataAttrSchema({ + name: "label", + description: "The label of the select item.", + value: "string", }), createDataAttrSchema({ name: "disabled", description: "Present when the item is disabled.", }), createDataAttrSchema({ - name: "select-item", - description: "Present on the select item element.", - }), - ], -}); - -export const value = createApiSchema({ - title: "Value", - description: - "A representation of the select menu's value, which is typically displayed in the trigger.", - props: { - placeholder: createStringProp({ - description: "A placeholder value to display when no value is selected.", + name: "highlighted", + description: + "Present when the item is highlighted, which is either via keyboard navigation of the menu or hover.", }), - ...withChildProps({ elType: "HTMLDivElement" }), - }, - dataAttributes: [ createDataAttrSchema({ - name: "select-value", - description: "Present on the select value element.", + name: "selected", + description: "Present when the item is selected.", }), createDataAttrSchema({ - name: "placeholder", - description: - "Present when the placeholder is being displayed (there isn't a value selected). You can use this to style the placeholder differently than the selected value.", + name: "select-item", + description: "Present on the item element.", }), ], }); -export const group = createApiSchema({ - title: "Group", - description: "An accessible group of select menu items.", - props: withChildProps({ elType: "HTMLDivElement" }), +export const trigger = createApiSchema({ + title: "Trigger", + description: "A button which toggles the select's open state.", + props: withChildProps({ elType: "HTMLButtonElement" }), dataAttributes: [ + stateDataAttr, createDataAttrSchema({ - name: "select-group", - description: "Present on the select group element.", + name: "disabled", + description: "Present when the select is disabled.", + }), + createDataAttrSchema({ + name: "select-trigger", + description: "Present on the trigger element.", }), ], }); -export const groupHeading = createApiSchema({ - title: "GroupHeading", +export const viewport = createApiSchema({ + title: "Viewport", description: - "A heading for the select menu which will be skipped when navigating with the keyboard. This must be a child of the `Select.Group` component.", + "An optional element to track the scroll position of the select for rendering the scroll up/down buttons.", props: withChildProps({ elType: "HTMLDivElement" }), dataAttributes: [ createDataAttrSchema({ - name: "select-group-heading", - description: "Present on the select group heading element.", + name: "select-viewport", + description: "Present on the viewport element.", }), ], }); -export const separator = createApiSchema({ - title: "Separator", - description: "A visual separator for use between select items or groups.", +export const scrollUpButton = createApiSchema({ + title: "ScrollUpButton", + description: + "An optional scroll up button element to improve the scroll experience within the select. Should be used in conjunction with the `select.Viewport` component.", props: withChildProps({ elType: "HTMLDivElement" }), dataAttributes: [ createDataAttrSchema({ - name: "separator-root", - description: "Present on the select separator element.", + name: "select-scroll-up-button", + description: "Present on the scroll up button element.", }), ], }); -export const arrow = createApiSchema({ - title: "Arrow", - description: "An optional arrow element which points to the trigger when open.", - props: arrowProps, +export const scrollDownButton = createApiSchema({ + title: "ScrollDownButton", + description: + "An optional scroll down button element to improve the scroll experience within the select. Should be used in conjunction with the `select.Viewport` component.", + props: withChildProps({ elType: "HTMLDivElement" }), dataAttributes: [ createDataAttrSchema({ - name: "arrow", - description: "Present on the select arrow element.", + name: "select-scroll-down-button", + description: "Present on the scroll down button element.", }), ], }); -export const viewport = createApiSchema({ - title: "Viewport", - description: - "An optional element to track the scroll position of the select for rendering the scroll up/down buttons.", +export const group = createApiSchema({ + title: "Group", + description: "A group of related select items.", props: withChildProps({ elType: "HTMLDivElement" }), dataAttributes: [ createDataAttrSchema({ - name: "select-viewport", - description: "Present on the viewport element.", + name: "select-group", + description: "Present on the group element.", }), ], }); -export const scrollUpButton = createApiSchema({ - title: "ScrollUpButton", +export const groupHeading = createApiSchema({ + title: "GroupHeading", description: - "An optional scroll up button element to improve the scroll experience within the select. Should be used in conjunction with the `Select.Viewport` component.", + "A heading for the parent select group. This is used to describe a group of related select items.", props: withChildProps({ elType: "HTMLDivElement" }), dataAttributes: [ createDataAttrSchema({ - name: "select-scroll-up-button", - description: "Present on the scroll up button element.", + name: "select-group-heading", + description: "Present on the group heading element.", }), ], }); -export const scrollDownButton = createApiSchema({ - title: "ScrollDownButton", - description: - "An optional scroll down button element to improve the scroll experience within the select. Should be used in conjunction with the `Select.Viewport` component.", - props: withChildProps({ elType: "HTMLDivElement" }), +export const arrow = createApiSchema({ + title: "Arrow", + description: "An optional arrow element which points to the content when open.", + props: arrowProps, dataAttributes: [ createDataAttrSchema({ - name: "select-scroll-down-button", - description: "Present on the scroll down button element.", + name: "arrow", + description: "Present on the arrow element.", }), ], }); @@ -307,13 +366,12 @@ export const select = [ root, trigger, content, + contentStatic, item, - value, viewport, scrollUpButton, scrollDownButton, group, groupHeading, - separator, arrow, ]; diff --git a/sites/docs/src/routes/(main)/sink/+page.svelte b/sites/docs/src/routes/(main)/sink/+page.svelte index 1c7ddef50..e69de29bb 100644 --- a/sites/docs/src/routes/(main)/sink/+page.svelte +++ b/sites/docs/src/routes/(main)/sink/+page.svelte @@ -1,19 +0,0 @@ - - -
- - - Not loaded - - - - - Not loaded - -