From 7667efc1b3a8bf1b4ffcfcda23ff6f14c984e869 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:46:26 -0400 Subject: [PATCH] next: Slider (#607) --- NOTICE.txt | 25 + .../lib/bits/accordion/accordion.svelte.ts | 93 ++- .../components/alert-dialog-cancel.svelte | 2 +- .../components/alert-dialog-content.svelte | 2 +- packages/bits-ui/src/lib/bits/index.ts | 1 + .../listbox/components/listbox-content.svelte | 33 + .../components/listbox-group-label.svelte | 33 + .../listbox/components/listbox-group.svelte | 33 + .../listbox/components/listbox-item.svelte | 48 ++ .../listbox/components/listbox-label.svelte | 33 + .../bits/listbox/components/listbox.svelte | 36 + .../bits-ui/src/lib/bits/listbox/index.ts | 15 + .../src/lib/bits/listbox/listbox.svelte.ts | 529 ++++++++++++++ .../bits-ui/src/lib/bits/listbox/types.ts | 125 ++++ .../slider/components/slider-input.svelte | 31 - .../slider/components/slider-range.svelte | 42 +- .../slider/components/slider-thumb.svelte | 48 +- .../bits/slider/components/slider-tick.svelte | 39 +- .../lib/bits/slider/components/slider.svelte | 97 +-- packages/bits-ui/src/lib/bits/slider/ctx.ts | 31 - .../bits-ui/src/lib/bits/slider/helpers.ts | 67 ++ packages/bits-ui/src/lib/bits/slider/index.ts | 6 +- .../src/lib/bits/slider/slider.svelte.ts | 666 ++++++++++++++++++ packages/bits-ui/src/lib/bits/slider/types.ts | 142 ++-- .../src/lib/bits/toggle-group/types.ts | 20 +- packages/bits-ui/src/lib/internal/math.ts | 40 ++ .../bits-ui/src/lib/internal/mergeProps.ts | 5 +- .../src/lib/internal/useRovingFocus.svelte.ts | 1 + .../bits-ui/src/tests/slider/Slider.spec.ts | 74 +- .../src/tests/slider/SliderRangeTest.svelte | 41 +- .../src/tests/slider/SliderTest.svelte | 91 +-- sites/docs/content/components/listbox.md | 33 + sites/docs/src/lib/components/demos/index.ts | 1 + .../lib/components/demos/listbox-demo.svelte | 58 ++ .../lib/components/demos/slider-demo.svelte | 35 +- .../lib/components/icons/switch-off.svelte | 2 +- .../src/lib/content/api-reference/index.ts | 2 + 37 files changed, 2185 insertions(+), 395 deletions(-) create mode 100644 NOTICE.txt create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox-content.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox-group-label.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox-group.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox-item.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox-label.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/components/listbox.svelte create mode 100644 packages/bits-ui/src/lib/bits/listbox/index.ts create mode 100644 packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/listbox/types.ts delete mode 100644 packages/bits-ui/src/lib/bits/slider/components/slider-input.svelte delete mode 100644 packages/bits-ui/src/lib/bits/slider/ctx.ts create mode 100644 packages/bits-ui/src/lib/bits/slider/helpers.ts create mode 100644 packages/bits-ui/src/lib/bits/slider/slider.svelte.ts create mode 100644 packages/bits-ui/src/lib/internal/math.ts create mode 100644 sites/docs/content/components/listbox.md create mode 100644 sites/docs/src/lib/components/demos/listbox-demo.svelte diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 000000000..693fff61a --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,25 @@ +Bits UI +================= +The following is a list of sources from which code was used/modified in this codebase. + +------------------------------------------------------------------------------- +This codebase contains a modified portion of code from Adobe which can be obtained at: + * SOURCE: + * https://www.npmjs.com/package/@react-aria/utils + * LICENSE: + * https://unpkg.com/@react-aria/utils@3.24.1/LICENSE + + * SOURCE: + * https://www.npmjs.com/package/react-stately + * LICENSE: + * https://unpkg.com/react-stately@3.31.1/LICENSE + +------------------------------------------------------------------------------- + +This codebase contains a modified portion of code from Melt UI which can be obtained at: + * SOURCE: + * https://www.npmjs.com/package/@melt-ui/svelte + * LICENSE: + * https://unpkg.com/@melt-ui/svelte@0.76.2/LICENSE + +------------------------------------------------------------------------------- \ No newline at end of file diff --git a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts index 94bc14476..a6e787708 100644 --- a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts +++ b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts @@ -1,6 +1,7 @@ import { type Box, type ReadableBoxedValues, + type WithRefProps, type WritableBoxedValues, afterTick, getAriaDisabled, @@ -25,15 +26,13 @@ const ACCORDION_HEADER_ATTR = "data-accordion-header"; // BASE // -type AccordionBaseStateProps = ReadableBoxedValues<{ - id: string; - disabled: boolean; - orientation: Orientation; - loop: boolean; -}> & - WritableBoxedValues<{ - ref: HTMLElement | null; - }>; +type AccordionBaseStateProps = WithRefProps< + ReadableBoxedValues<{ + disabled: boolean; + orientation: Orientation; + loop: boolean; + }> +>; class AccordionBaseState { #id: AccordionBaseStateProps["id"]; @@ -130,15 +129,14 @@ export class AccordionMultiState extends AccordionBaseState { // ITEM // -type AccordionItemStateProps = ReadableBoxedValues<{ - value: string; - disabled: boolean; - id: string; -}> & { - rootState: AccordionState; -} & WritableBoxedValues<{ - ref: HTMLElement | null; - }>; +type AccordionItemStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + disabled: boolean; + }> & { + rootState: AccordionState; + } +>; export class AccordionItemState { #id: AccordionItemStateProps["id"]; @@ -190,13 +188,11 @@ export class AccordionItemState { // TRIGGER // -type AccordionTriggerStateProps = ReadableBoxedValues<{ - disabled: boolean; - id: string; -}> & - WritableBoxedValues<{ - ref: HTMLElement | null; - }>; +type AccordionTriggerStateProps = WithRefProps< + ReadableBoxedValues<{ + disabled: boolean; + }> +>; class AccordionTriggerState { #ref: AccordionTriggerStateProps["ref"]; @@ -259,14 +255,11 @@ class AccordionTriggerState { // CONTENT // -type AccordionContentStateProps = ReadableBoxedValues<{ - forceMount: boolean; - id: string; -}> & - WritableBoxedValues<{ - ref: HTMLElement | null; - }>; - +type AccordionContentStateProps = WithRefProps< + ReadableBoxedValues<{ + forceMount: boolean; + }> +>; class AccordionContentState { item: AccordionItemState; #ref: AccordionContentStateProps["ref"]; @@ -348,13 +341,11 @@ class AccordionContentState { ); } -type AccordionHeaderStateProps = ReadableBoxedValues<{ - level: 1 | 2 | 3 | 4 | 5 | 6; - id: string; -}> & - WritableBoxedValues<{ - ref: HTMLElement | null; - }>; +type AccordionHeaderStateProps = WithRefProps< + ReadableBoxedValues<{ + level: 1 | 2 | 3 | 4 | 5 | 6; + }> +>; class AccordionHeaderState { #id: AccordionHeaderStateProps["id"]; @@ -394,18 +385,16 @@ class AccordionHeaderState { type AccordionState = AccordionSingleState | AccordionMultiState; -type InitAccordionProps = { - type: "single" | "multiple"; - value: Box | Box; -} & ReadableBoxedValues<{ - id: string; - disabled: boolean; - orientation: Orientation; - loop: boolean; -}> & - WritableBoxedValues<{ - ref: HTMLElement | null; - }>; +type InitAccordionProps = WithRefProps< + { + type: "single" | "multiple"; + value: Box | Box; + } & ReadableBoxedValues<{ + disabled: boolean; + orientation: Orientation; + loop: boolean; + }> +>; const [setAccordionRootContext, getAccordionRootContext] = createContext("Accordion.Root"); diff --git a/packages/bits-ui/src/lib/bits/alert-dialog/components/alert-dialog-cancel.svelte b/packages/bits-ui/src/lib/bits/alert-dialog/components/alert-dialog-cancel.svelte index 4967b9bb8..cdbebba5d 100644 --- a/packages/bits-ui/src/lib/bits/alert-dialog/components/alert-dialog-cancel.svelte +++ b/packages/bits-ui/src/lib/bits/alert-dialog/components/alert-dialog-cancel.svelte @@ -1,7 +1,7 @@ + +{#if child} + {@render child?.({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-label.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-label.svelte new file mode 100644 index 000000000..42395bbca --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/components/listbox-group-label.svelte @@ -0,0 +1,33 @@ + + +{#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 new file mode 100644 index 000000000..814b0ac9f --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/components/listbox-group.svelte @@ -0,0 +1,33 @@ + + +{#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 new file mode 100644 index 000000000..b7d12be33 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/components/listbox-item.svelte @@ -0,0 +1,48 @@ + + +{#if child} + {@render child?.({ props: mergedProps, selected: itemState.isSelected })} +{:else} +
+ {#if children} + {@render children?.({ selected: itemState.isSelected })} + {:else if label} + {label} + {/if} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/listbox/components/listbox-label.svelte b/packages/bits-ui/src/lib/bits/listbox/components/listbox-label.svelte new file mode 100644 index 000000000..fef105644 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/components/listbox-label.svelte @@ -0,0 +1,33 @@ + + +{#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 new file mode 100644 index 000000000..bc926aa37 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/components/listbox.svelte @@ -0,0 +1,36 @@ + + +{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/listbox/index.ts b/packages/bits-ui/src/lib/bits/listbox/index.ts new file mode 100644 index 000000000..8be582d4b --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/index.ts @@ -0,0 +1,15 @@ +export { default as Root } from "./components/listbox.svelte"; +export { default as Content } from "./components/listbox-content.svelte"; +export { default as Item } from "./components/listbox-item.svelte"; +export { default as Group } from "./components/listbox-group.svelte"; +export { default as GroupLabel } from "./components/listbox-group-label.svelte"; +export { default as Label } from "./components/listbox-label.svelte"; + +export type { + ListboxRootProps as RootProps, + ListboxContentProps as ContentProps, + ListboxItemProps as ItemProps, + ListboxGroupProps as GroupProps, + ListboxGroupLabelProps as GroupLabelProps, + ListboxLabelProps as LabelProps, +} from "./types.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 new file mode 100644 index 000000000..d76107461 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/listbox.svelte.ts @@ -0,0 +1,529 @@ +import { onDestroy, onMount } from "svelte"; +import { SvelteSet } from "svelte/reactivity"; +import { focusFirst } from "../utilities/focus-scope/utils.js"; +import { + getAriaDisabled, + getAriaSelected, + getDataDisabled, + getDataOrientation, + getDataSelected, +} from "$lib/internal/attrs.js"; +import type { Box, ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; +import { createContext } from "$lib/internal/createContext.js"; +import { isHTMLElement } from "$lib/internal/is.js"; +import { kbd } from "$lib/internal/kbd.js"; +import type { WithRefProps } from "$lib/internal/types.js"; +import { useRefById } from "$lib/internal/useRefById.svelte.js"; +import { type UseRovingFocusReturn, useRovingFocus } from "$lib/internal/useRovingFocus.svelte.js"; +import { useTypeahead } from "$lib/internal/useTypeahead.svelte.js"; +import type { Orientation } from "$lib/shared/index.js"; +import { afterTick } from "$lib/internal/afterTick.js"; + +const LISTBOX_ITEM_ATTR = "data-listbox-item"; +const LISTBOX_CONTENT_ATTR = "data-listbox-content"; +export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE]; +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]; + +type ListboxRootBaseStateProps = ReadableBoxedValues<{ + loop: boolean; + orientation: Orientation; + autoFocus: boolean | "first" | "last"; +}>; + +class ListboxRootBaseState { + loop: ListboxRootBaseStateProps["loop"]; + orientation: ListboxRootBaseStateProps["orientation"]; + autoFocus: ListboxRootBaseStateProps["autoFocus"]; + labelNode = $state(null); + contentNode = $state(null); + valueOptions = new SvelteSet(); + + constructor(props: ListboxRootBaseStateProps) { + this.loop = props.loop; + this.orientation = props.orientation; + this.autoFocus = props.autoFocus; + } +} + +type ListboxRootSingleStateProps = ListboxRootBaseStateProps & + WritableBoxedValues<{ + value: string; + }>; + +export class ListboxRootSingleState extends ListboxRootBaseState { + value: ListboxRootSingleStateProps["value"]; + isMulti = false as const; + + constructor(props: ListboxRootSingleStateProps) { + super(props); + this.value = props.value; + } + + includesItem = (itemValue: string) => { + return this.value.value === itemValue; + }; + + toggleItem = (itemValue: string) => { + this.value.value = this.includesItem(itemValue) ? "" : itemValue; + }; + + createContent = (props: ListboxContentStateProps) => { + return new ListboxContentState(props, this); + }; + + createGroup = (props: ListboxGroupStateProps) => { + return new ListboxGroupState(props, this); + }; + + createLabel = (props: ListboxLabelStateProps) => { + return new ListboxLabelState(props, this); + }; +} + +type ListboxRootMultipleStateProps = ListboxRootBaseStateProps & + WritableBoxedValues<{ + value: string[]; + }>; + +export class ListboxRootMultipleState extends ListboxRootBaseState { + value: ListboxRootMultipleStateProps["value"]; + isMulti = true as const; + + constructor(props: ListboxRootMultipleStateProps) { + super(props); + this.value = props.value; + } + + includesItem = (itemValue: string) => { + return this.value.value.includes(itemValue); + }; + + toggleItem = (itemValue: string) => { + if (this.includesItem(itemValue)) { + this.value.value = this.value.value.filter((v) => v !== itemValue); + } else { + this.value.value = [...this.value.value, itemValue]; + } + }; + + selectAll = () => { + this.value.value = [...this.valueOptions]; + }; + + createContent = (props: ListboxContentStateProps) => { + return new ListboxContentState(props, this); + }; + + createGroup = (props: ListboxGroupStateProps) => { + return new ListboxGroupState(props, this); + }; + + createLabel = (props: ListboxLabelStateProps) => { + return new ListboxLabelState(props, this); + }; +} + +type ListboxRootState = ListboxRootSingleState | ListboxRootMultipleState; + +type ListboxContentStateProps = WithRefProps; + +export class ListboxContentState { + id: ListboxContentStateProps["id"]; + ref: ListboxContentStateProps["ref"]; + root: ListboxRootState; + rovingFocusGroup: UseRovingFocusReturn; + #handleTypeaheadSearch: ReturnType["handleTypeaheadSearch"]; + focusedItemId = $state(""); + + constructor(props: ListboxContentStateProps, root: ListboxRootState) { + this.id = props.id; + this.ref = props.ref; + this.root = root; + + useRefById({ + id: this.id, + ref: this.ref, + onRefChange: (node) => { + this.root.contentNode = node; + }, + }); + + this.rovingFocusGroup = useRovingFocus({ + rootNodeId: this.id, + candidateSelector: "data-listbox-item", + loop: this.root.loop, + orientation: this.root.orientation, + onCandidateFocus: (node) => { + if (node) { + this.focusedItemId = node.id; + } else { + this.focusedItemId = ""; + } + }, + }); + + this.#handleTypeaheadSearch = useTypeahead().handleTypeaheadSearch; + + onMount(() => { + if (!this.focusedItemId) { + const candidateNodes = this.getCandidateNodes(); + if (this.root.isMulti && this.root.value.value.length) { + const firstValue = this.root.value.value[0]; + if (firstValue) { + const candidateNode = candidateNodes.find( + (node) => node.dataset.value === firstValue + ); + if (candidateNode) { + this.focusedItemId = candidateNode.id; + return; + } + } + } else if (!this.root.isMulti && this.root.value.value) { + const candidateNode = candidateNodes.find( + (node) => node.dataset.value === this.root.value.value + ); + if (candidateNode) { + this.focusedItemId = candidateNode.id; + return; + } + } + const firstCandidate = candidateNodes[0]; + if (firstCandidate) [(this.focusedItemId = firstCandidate.id)]; + } + }); + } + + isFocusedItem = (id: string) => { + return this.focusedItemId === id; + }; + + getCandidateNodes = () => { + const node = this.ref.value; + if (!node) return []; + const candidates = Array.from(node.querySelectorAll(`[${LISTBOX_ITEM_ATTR}]`)); + return candidates; + }; + + #onkeydown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + const target = e.target; + const currentTarget = e.currentTarget; + if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) return; + + const isKeydownInside = target.closest(`[${LISTBOX_CONTENT_ATTR}]`)?.id === this.id.value; + + const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; + const isCharacterKey = e.key.length === 1; + + const kbdFocusedEl = this.rovingFocusGroup.handleKeydown(target, e); + if (kbdFocusedEl) return; + + // prevent space from being considered with typeahead + if (e.code === "Space") return; + + const candidateNodes = this.getCandidateNodes(); + + if (isKeydownInside) { + // listboxes do not respect the tab key + if (e.key === kbd.TAB) return; + if (!isModifierKey && isCharacterKey) { + this.#handleTypeaheadSearch(e.key, candidateNodes); + } + } + + if (e.key === "a" && (e.ctrlKey || e.metaKey) && this.root.isMulti) { + this.root.selectAll(); + e.preventDefault(); + return; + } + + if (e.key === kbd.ESCAPE) { + if (this.root.isMulti) { + this.root.value.value = []; + } else { + this.root.value.value = ""; + } + } + + // focus first/last based on key pressed; + if (target.id !== this.id.value) return; + + if (!FIRST_LAST_KEYS.includes(e.key)) return; + e.preventDefault(); + + if (LAST_KEYS.includes(e.key)) { + candidateNodes.reverse(); + } + focusFirst(candidateNodes); + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.root.orientation.value), + role: "listbox", + [LISTBOX_CONTENT_ATTR]: "", + onkeydown: this.#onkeydown, + }) as const + ); + + createItem = (props: ListboxItemStateProps) => { + return new ListboxItemState(props, this); + }; +} + +type ListboxLabelStateProps = WithRefProps; + +export class ListboxLabelState { + id: ListboxLabelStateProps["id"]; + ref: ListboxLabelStateProps["ref"]; + root: ListboxRootState; + + constructor(props: ListboxLabelStateProps, root: ListboxRootState) { + this.id = props.id; + this.ref = props.ref; + this.root = root; + + useRefById({ + id: this.id, + ref: this.ref, + onRefChange: (node) => { + this.root.labelNode = node; + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.root.orientation.value), + }) as const + ); +} + +type ListboxItemStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + label: string; + disabled: boolean; + }> +>; + +export class ListboxItemState { + id: ListboxItemStateProps["id"]; + ref: ListboxItemStateProps["ref"]; + value: ListboxItemStateProps["value"]; + label: ListboxItemStateProps["label"]; + disabled: ListboxItemStateProps["disabled"]; + content: ListboxContentState; + isSelected = $derived.by(() => this.content.root.includesItem(this.value.value)); + isTabIndexTarget = $derived.by(() => this.content.isFocusedItem(this.id.value)); + #isFocused = $state(false); + + constructor(props: ListboxItemStateProps, content: ListboxContentState) { + this.id = props.id; + this.ref = props.ref; + this.value = props.value; + this.label = props.label; + this.disabled = props.disabled; + this.content = content; + + $effect(() => { + this.content.root.valueOptions.add(this.value.value); + + return () => { + this.content.root.valueOptions.delete(this.value.value); + }; + }); + + onDestroy(() => { + this.content.root.valueOptions.delete(this.value.value); + }); + + useRefById({ + id: this.id, + ref: this.ref, + }); + } + + handleSelect = () => { + if (this.disabled.value) return; + this.content.root.toggleItem(this.value.value); + }; + + #onpointerup = () => { + this.handleSelect(); + }; + + #onkeydown = (e: KeyboardEvent) => { + if (SELECTION_KEYS.includes(e.key)) { + e.preventDefault(); + this.handleSelect(); + } + }; + + #onfocus = async (e: FocusEvent) => { + afterTick(() => { + if (e.defaultPrevented || this.disabled.value) return; + this.#isFocused = true; + }); + }; + + #onblur = async (e: FocusEvent) => { + afterTick(() => { + if (e.defaultPrevented) return; + this.#isFocused = false; + }); + }; + + #onpointermove = () => { + if (this.#isFocused) return; + this.#isFocused = true; + this.ref.value?.focus(); + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + role: "option", + "data-value": this.value.value, + "aria-disabled": getAriaDisabled(this.disabled.value), + "data-disabled": getDataDisabled(this.disabled.value), + "aria-selected": getAriaSelected(this.isSelected), + "data-selected": getDataSelected(this.isSelected), + "data-highlighted": this.#isFocused ? "" : undefined, + tabindex: this.isTabIndexTarget ? 0 : -1, + [LISTBOX_ITEM_ATTR]: "", + // + onpointerup: this.#onpointerup, + onkeydown: this.#onkeydown, + onfocus: this.#onfocus, + onblur: this.#onblur, + onpointermove: this.#onpointermove, + }) as const + ); +} + +type ListboxGroupStateProps = WithRefProps; + +export class ListboxGroupState { + id: ListboxGroupStateProps["id"]; + ref: ListboxGroupStateProps["ref"]; + root: ListboxRootState; + groupLabelNode = $state(null); + + constructor(props: ListboxGroupStateProps, root: ListboxRootState) { + this.id = props.id; + this.ref = props.ref; + this.root = root; + + useRefById({ + id: this.id, + ref: this.ref, + }); + } + + #ariaLabelledBy = $derived.by(() => { + if (!this.groupLabelNode) return undefined; + return this.groupLabelNode.id; + }); + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.root.orientation.value), + role: "group", + "aria-labelledby": this.#ariaLabelledBy, + }) as const + ); + + createGroupLabel(props: ListboxGroupLabelStateProps) { + return new ListboxGroupLabelState(props, this); + } +} + +type ListboxGroupLabelStateProps = WithRefProps; + +export class ListboxGroupLabelState { + id: ListboxGroupLabelStateProps["id"]; + ref: ListboxGroupLabelStateProps["ref"]; + + constructor( + props: ListboxGroupLabelStateProps, + readonly group: ListboxGroupState + ) { + this.id = props.id; + this.ref = props.ref; + + useRefById({ + id: this.id, + ref: this.ref, + onRefChange: (node) => { + this.group.groupLabelNode = node; + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.group.root.orientation.value), + }) as const + ); +} + +type InitListboxProps = { + type: "single" | "multiple"; + value: Box | Box; +} & ReadableBoxedValues<{ + disabled: boolean; + orientation: Orientation; + loop: boolean; + autoFocus: boolean | "first" | "last"; +}>; + +const [setListboxRootContext, getListboxRootContext] = + createContext("Listbox.Root"); + +const [setListboxContentContext, getListboxContentContext] = + createContext("Listbox.Content"); + +const [setListboxGroupContext, getListboxGroupContext] = + createContext("Listbox.Group"); + +export function useListboxRoot(props: InitListboxProps) { + const { type, ...rest } = props; + + const rootState = + type === "single" + ? new ListboxRootSingleState(rest as ListboxRootSingleStateProps) + : new ListboxRootMultipleState(rest as ListboxRootMultipleStateProps); + return setListboxRootContext(rootState); +} + +export function useListboxContent(props: ListboxContentStateProps) { + return setListboxContentContext(getListboxRootContext().createContent(props)); +} + +export function useListboxItem(props: ListboxItemStateProps) { + return getListboxContentContext().createItem(props); +} + +export function useListboxLabel(props: ListboxLabelStateProps) { + return getListboxRootContext().createLabel(props); +} + +export function useListboxGroup(props: ListboxGroupStateProps) { + return setListboxGroupContext(getListboxRootContext().createGroup(props)); +} + +export function useListboxGroupLabel(props: ListboxGroupLabelStateProps) { + return getListboxGroupContext().createGroupLabel(props); +} diff --git a/packages/bits-ui/src/lib/bits/listbox/types.ts b/packages/bits-ui/src/lib/bits/listbox/types.ts new file mode 100644 index 000000000..1dd703c85 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/listbox/types.ts @@ -0,0 +1,125 @@ +import type { + OnChangeFn, + PrimitiveDivAttributes, + WithChild, + WithChildren, + Without, +} from "$lib/internal/types.js"; +import type { Orientation } from "$lib/shared/index.js"; + +export type ListboxRootBasePropsWithoutHTML = WithChildren<{ + /** + * Whether to loop through the listbox items when reaching the end via keyboard. + * + * @defaultValue false + */ + loop?: boolean; + + /** + * The orientation of the listbox. This is how the listbox items are laid out and will + * impact how keyboard navigation works within the component. + * + * @defaultValue "vertical" + */ + orientation?: Orientation; + + /** + * Whether to autofocus the first or last listbox item when the listbox is mounted. + * This is useful when composing a custom listbox component within a popover or dialog. + * + * @defaultValue false + */ + autoFocus?: boolean | "first" | "last"; + + /** + * Whether the listbox is disabled. + */ + disabled?: boolean; +}>; + +export type ListboxSingleRootPropsWithoutHTML = ListboxRootBasePropsWithoutHTML & { + /** + * The value of the selected listbox item. + */ + value?: string; + + /** + * A callback function called when the value changes. + */ + onValueChange?: OnChangeFn; + + /** + * The selection type of the listbox. + */ + type: "single"; +}; + +export type ListboxMultipleRootPropsWithoutHTML = ListboxRootBasePropsWithoutHTML & { + /** + * The value of the selected listbox items. + */ + value?: string[]; + + /** + * A callback function called when the value changes. + */ + onValueChange?: OnChangeFn; + + /** + * The selection type of the listbox. + */ + type: "multiple"; +}; + +export type ListboxRootPropsWithoutHTML = + | ListboxSingleRootPropsWithoutHTML + | ListboxMultipleRootPropsWithoutHTML; + +export type ListboxRootProps = ListboxRootPropsWithoutHTML; + +export type ListboxContentPropsWithoutHTML = WithChild; + +export type ListboxContentProps = ListboxContentPropsWithoutHTML & + Without; + +export type ListboxItemSnippetProps = { selected: boolean }; + +export type ListboxItemPropsWithoutHTML = WithChild< + { + /** + * The value of the listbox item. This is used to populate the `value` prop of the + * `Listbox.Root` component. + */ + value: string; + + /** + * The label of the listbox item. This will be rendered as the text content of the listbox item + * by default. If a child is provided, this will only be used for typeahead purposes. + */ + label?: string; + + /** + * Whether the listbox item is disabled, which prevents users from interacting with it. + */ + disabled?: boolean; + }, + ListboxItemSnippetProps +>; + +export type ListboxItemProps = ListboxItemPropsWithoutHTML & + Without; + +export type ListboxGroupPropsWithoutHTML = WithChild; + +export type ListboxGroupProps = ListboxGroupPropsWithoutHTML & + Without; + +export type ListboxGroupLabelPropsWithoutHTML = WithChild; + +export type ListboxGroupLabelProps = ListboxGroupLabelPropsWithoutHTML & + Without; + +export type ListboxLabelPropsWithoutHTML = WithChild; + +export type ListboxLabelProps = ListboxLabelPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/bits/slider/components/slider-input.svelte b/packages/bits-ui/src/lib/bits/slider/components/slider-input.svelte deleted file mode 100644 index 791004ee1..000000000 --- a/packages/bits-ui/src/lib/bits/slider/components/slider-input.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/packages/bits-ui/src/lib/bits/slider/components/slider-range.svelte b/packages/bits-ui/src/lib/bits/slider/components/slider-range.svelte index 8965bdf71..84e5e9834 100644 --- a/packages/bits-ui/src/lib/bits/slider/components/slider-range.svelte +++ b/packages/bits-ui/src/lib/bits/slider/components/slider-range.svelte @@ -1,26 +1,32 @@ -{#if asChild} - +{#if child} + {@render child?.({ props: mergedProps })} {:else} - + + {@render children?.()} + {/if} diff --git a/packages/bits-ui/src/lib/bits/slider/components/slider-thumb.svelte b/packages/bits-ui/src/lib/bits/slider/components/slider-thumb.svelte index a94d7113e..f50bed1a7 100644 --- a/packages/bits-ui/src/lib/bits/slider/components/slider-thumb.svelte +++ b/packages/bits-ui/src/lib/bits/slider/components/slider-thumb.svelte @@ -1,27 +1,37 @@ -{#if asChild} - +{#if child} + {@render child?.({ props: mergedProps })} {:else} - + + {@render children?.()} + {/if} diff --git a/packages/bits-ui/src/lib/bits/slider/components/slider-tick.svelte b/packages/bits-ui/src/lib/bits/slider/components/slider-tick.svelte index 9fed4521b..4d29d2255 100644 --- a/packages/bits-ui/src/lib/bits/slider/components/slider-tick.svelte +++ b/packages/bits-ui/src/lib/bits/slider/components/slider-tick.svelte @@ -1,24 +1,33 @@ -{#if asChild} - +{#if child} + {@render child?.({ props: mergedProps })} {:else} - + {@render children?.()} {/if} diff --git a/packages/bits-ui/src/lib/bits/slider/components/slider.svelte b/packages/bits-ui/src/lib/bits/slider/components/slider.svelte index 87157451b..2f762249f 100644 --- a/packages/bits-ui/src/lib/bits/slider/components/slider.svelte +++ b/packages/bits-ui/src/lib/bits/slider/components/slider.svelte @@ -1,58 +1,61 @@ -{#if asChild} - +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} {:else} - - + + {@render children?.(rootState.snippetProps)} {/if} diff --git a/packages/bits-ui/src/lib/bits/slider/ctx.ts b/packages/bits-ui/src/lib/bits/slider/ctx.ts deleted file mode 100644 index 58cd99dc2..000000000 --- a/packages/bits-ui/src/lib/bits/slider/ctx.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type CreateSliderProps, createSlider } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; - -function getSliderData() { - const NAME = "slider" as const; - const PARTS = ["root", "input", "range", "thumb", "tick"] as const; - - return { - NAME, - PARTS, - }; -} - -type GetReturn = Omit, "updateOption">; - -export function setCtx(props: CreateSliderProps) { - const { NAME, PARTS } = getSliderData(); - const getAttrs = createBitAttrs(NAME, PARTS); - const slider = { ...createSlider(removeUndefined(props)), getAttrs }; - setContext(NAME, slider); - return { - ...slider, - updateOption: getOptionUpdater(slider.options), - }; -} - -export function getCtx() { - const { NAME } = getSliderData(); - return getContext(NAME); -} diff --git a/packages/bits-ui/src/lib/bits/slider/helpers.ts b/packages/bits-ui/src/lib/bits/slider/helpers.ts new file mode 100644 index 000000000..28e6abc08 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/slider/helpers.ts @@ -0,0 +1,67 @@ +import type { StyleProperties } from "$lib/shared/index.js"; + +export function getRangeStyles(direction: "lr" | "rl" | "tb" | "bt", min: number, max: number) { + const styles: StyleProperties = { + position: "absolute", + }; + if (direction === "lr") { + styles.left = `${min}%`; + styles.right = `${max}%`; + } else if (direction === "rl") { + styles.right = `${min}%`; + styles.left = `${max}%`; + } else if (direction === "bt") { + styles.bottom = `${min}%`; + styles.top = `${max}%`; + } else { + styles.top = `${min}%`; + styles.bottom = `${max}%`; + } + return styles; +} + +export function getThumbStyles(direction: "lr" | "rl" | "tb" | "bt", thumbPos: number) { + const styles: StyleProperties = { + position: "absolute", + }; + if (direction === "lr") { + styles.left = `${thumbPos}%`; + styles.translate = "-50% 0"; + } else if (direction === "rl") { + styles.right = `${thumbPos}%`; + styles.translate = "50% 0"; + } else if (direction === "bt") { + styles.bottom = `${thumbPos}%`; + styles.translate = "0 50%"; + } else { + styles.top = `${thumbPos}%`; + styles.translate = "0 -50%"; + } + return styles; +} + +export function getTickStyles( + direction: "lr" | "rl" | "tb" | "bt", + tickPosition: number, + offsetPercentage: number +) { + const style: StyleProperties = { + position: "absolute", + }; + + if (direction === "lr") { + style.left = `${tickPosition}%`; + style.translate = `${offsetPercentage}% 0`; + } else if (direction === "rl") { + style.right = `${tickPosition}%`; + style.translate = `${-offsetPercentage}% 0`; + } else if (direction === "bt") { + style.bottom = `${tickPosition}%`; + style.translate = `0 ${-offsetPercentage}%`; + } else { + style.top = `${tickPosition}%`; + style.translate = `0 ${offsetPercentage}%`; + } + + return style; +} diff --git a/packages/bits-ui/src/lib/bits/slider/index.ts b/packages/bits-ui/src/lib/bits/slider/index.ts index 0384778af..def822376 100644 --- a/packages/bits-ui/src/lib/bits/slider/index.ts +++ b/packages/bits-ui/src/lib/bits/slider/index.ts @@ -1,15 +1,11 @@ export { default as Root } from "./components/slider.svelte"; export { default as Range } from "./components/slider-range.svelte"; export { default as Thumb } from "./components/slider-thumb.svelte"; -export { default as Input } from "./components/slider-input.svelte"; export { default as Tick } from "./components/slider-tick.svelte"; export type { - SliderProps as Props, + SliderRootProps as RootProps, SliderRangeProps as RangeProps, SliderThumbProps as ThumbProps, - SliderInputProps as InputProps, SliderTickProps as TickProps, - // - SliderThumbEvents as ThumbEvents, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/slider/slider.svelte.ts b/packages/bits-ui/src/lib/bits/slider/slider.svelte.ts new file mode 100644 index 000000000..c83862cdb --- /dev/null +++ b/packages/bits-ui/src/lib/bits/slider/slider.svelte.ts @@ -0,0 +1,666 @@ +/** + * This logic is adapted from the @melt-ui/svelte slider, which was mostly written by + * Abdelrahman (https://github.com/abdel-17) + */ + +import { untrack } from "svelte"; +import { getRangeStyles, getThumbStyles, getTickStyles } from "./helpers.js"; +import { + type OnChangeFn, + type ReadableBoxedValues, + type WithRefProps, + type WritableBoxedValues, + addEventListener, + executeCallbacks, + getAriaDisabled, + getAriaOrientation, + getDataDisabled, + getDataOrientation, + isElementOrSVGElement, + isValidIndex, + kbd, + useRefById, +} from "$lib/internal/index.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; +import { createContext } from "$lib/internal/createContext.js"; +import { snapValueToStep } from "$lib/internal/math.js"; + +const SLIDER_ROOT_ATTR = "data-slider-root"; +const SLIDER_THUMB_ATTR = "data-slider-thumb"; +const SLIDER_RANGE_ATTR = "data-slider-range"; +const SLIDER_TICK_ATTR = "data-slider-tick"; + +type SliderRootStateProps = WithRefProps< + ReadableBoxedValues<{ + disabled: boolean; + orientation: Orientation; + min: number; + max: number; + step: number; + dir: Direction; + autoSort: boolean; + onValueChangeEnd: OnChangeFn; + }> & + WritableBoxedValues<{ + value: number[]; + }> +>; + +class SliderRootState { + id: SliderRootStateProps["id"]; + ref: SliderRootStateProps["ref"]; + value: SliderRootStateProps["value"]; + disabled: SliderRootStateProps["disabled"]; + orientation: SliderRootStateProps["orientation"]; + min: SliderRootStateProps["min"]; + max: SliderRootStateProps["max"]; + step: SliderRootStateProps["step"]; + dir: SliderRootStateProps["dir"]; + autoSort: SliderRootStateProps["autoSort"]; + activeThumb = $state<{ node: HTMLElement; idx: number } | null>(null); + isActive = $state(false); + currentThumbIdx = $state(0); + direction: "rl" | "lr" | "tb" | "bt" = $derived.by(() => { + if (this.orientation.value === "horizontal") { + return this.dir.value === "rtl" ? "rl" : "lr"; + } else { + return this.dir.value === "rtl" ? "tb" : "bt"; + } + }); + onValueChangeEnd: SliderRootStateProps["onValueChangeEnd"]; + + constructor(props: SliderRootStateProps) { + this.id = props.id; + this.ref = props.ref; + this.disabled = props.disabled; + this.orientation = props.orientation; + this.min = props.min; + this.max = props.max; + this.step = props.step; + this.dir = props.dir; + this.autoSort = props.autoSort; + this.value = props.value; + this.onValueChangeEnd = props.onValueChangeEnd; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + $effect(() => { + const unsub = executeCallbacks( + addEventListener(document, "pointerdown", this.handlePointerDown), + addEventListener(document, "pointerup", this.handlePointerUp), + addEventListener(document, "pointermove", this.handlePointerMove), + addEventListener(document, "pointerleave", this.handlePointerUp) + ); + + return unsub; + }); + + $effect(() => { + const step = this.step.value; + const min = this.min.value; + const max = this.max.value; + const value = this.value.value; + + const isValidValue = (v: number) => { + const snappedValue = snapValueToStep(v, min, max, step); + return snappedValue === v; + }; + + const gcv = (v: number) => { + return snapValueToStep(v, min, max, step); + }; + + if (value.some((v) => !isValidValue(v))) { + this.value.value = value.map(gcv); + } + }); + } + + applyPosition = ({ + clientXY, + activeThumbIdx, + start, + end, + }: { + clientXY: number; + activeThumbIdx: number; + start: number; + end: number; + }) => { + const min = this.min.value; + const max = this.max.value; + const percent = (clientXY - start) / (end - start); + const val = percent * (max - min) + min; + + if (val < min) { + this.updateValue(min, activeThumbIdx); + } else if (val > max) { + this.updateValue(max, activeThumbIdx); + } else { + const step = this.step.value; + + const currStep = Math.floor((val - min) / step); + const midpointOfCurrStep = min + currStep * step + step / 2; + const midpointOfNextStep = min + (currStep + 1) * step + step / 2; + const newValue = + val >= midpointOfCurrStep && val < midpointOfNextStep + ? (currStep + 1) * step + min + : currStep * step + min; + + if (newValue <= max) { + this.updateValue(newValue, activeThumbIdx); + } + } + }; + + getClosestThumb = (e: PointerEvent) => { + const thumbs = this.getAllThumbs(); + if (!thumbs.length) return; + for (const thumb of thumbs) { + thumb.blur(); + } + + const distances = thumbs.map((thumb) => { + if (this.orientation.value === "horizontal") { + const { left, right } = thumb.getBoundingClientRect(); + return Math.abs(e.clientX - (left + right) / 2); + } else { + const { top, bottom } = thumb.getBoundingClientRect(); + return Math.abs(e.clientY - (top + bottom) / 2); + } + }); + + const node = thumbs[distances.indexOf(Math.min(...distances))]!; + const idx = thumbs.indexOf(node); + return { + node, + idx, + }; + }; + + handlePointerMove = (e: PointerEvent) => { + if (!this.isActive) return; + e.preventDefault(); + e.stopPropagation(); + + const sliderNode = this.ref.value; + const activeThumb = this.activeThumb; + if (!sliderNode || !activeThumb) return; + + activeThumb.node.focus(); + + const { left, right, top, bottom } = sliderNode.getBoundingClientRect(); + + const direction = this.direction; + if (direction === "lr") { + this.applyPosition({ + clientXY: e.clientX, + activeThumbIdx: activeThumb.idx, + start: left, + end: right, + }); + } else if (direction === "rl") { + this.applyPosition({ + clientXY: e.clientX, + activeThumbIdx: activeThumb.idx, + start: right, + end: left, + }); + } else if (direction === "bt") { + this.applyPosition({ + clientXY: e.clientY, + activeThumbIdx: activeThumb.idx, + start: bottom, + end: top, + }); + } else if (direction === "tb") { + this.applyPosition({ + clientXY: e.clientY, + activeThumbIdx: activeThumb.idx, + start: top, + end: bottom, + }); + } + }; + + handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0) return; + const sliderNode = this.ref.value; + const closestThumb = this.getClosestThumb(e); + if (!closestThumb || !sliderNode) return; + + const target = e.target; + if (!isElementOrSVGElement(target) || !sliderNode.contains(target)) return; + e.preventDefault(); + + this.activeThumb = closestThumb; + closestThumb.node.focus(); + this.isActive = true; + + this.handlePointerMove(e); + }; + + handlePointerUp = () => { + if (this.isActive) { + this.onValueChangeEnd.value(untrack(() => this.value.value)); + } + this.isActive = false; + }; + + getPositionFromValue = (thumbValue: number) => { + const min = this.min.value; + const max = this.max.value; + + return ((thumbValue - min) / (max - min)) * 100; + }; + + getAllThumbs = () => { + const node = this.ref.value; + if (!node) return []; + const thumbs = Array.from(node.querySelectorAll(`[${SLIDER_THUMB_ATTR}]`)); + return thumbs; + }; + + updateValue = (thumbValue: number, idx: number) => { + const currValue = this.value.value; + if (!currValue.length) { + this.value.value.push(thumbValue); + return; + } + const valueAtIndex = currValue[idx]; + if (valueAtIndex === thumbValue) return; + + const newValue = [...currValue]; + + if (!isValidIndex(idx, newValue)) return; + + const direction = newValue[idx]! > thumbValue ? -1 : +1; + + const swap = () => { + const diffIndex = idx + direction; + newValue[idx] = newValue[diffIndex]!; + newValue[diffIndex] = thumbValue; + const thumbs = this.getAllThumbs(); + if (!thumbs.length) return; + thumbs[diffIndex]?.focus(); + this.activeThumb = { node: thumbs[diffIndex]!, idx: diffIndex }; + }; + + if ( + this.autoSort.value && + ((direction === -1 && thumbValue < newValue[idx - 1]!) || + (direction === 1 && thumbValue > newValue[idx + 1]!)) + ) { + swap(); + this.value.value = newValue; + return; + } + + const min = this.min.value; + const max = this.max.value; + const step = this.step.value; + newValue[idx] = snapValueToStep(thumbValue, min, max, step); + + this.value.value = newValue; + }; + + thumbsPropsArr = $derived.by(() => { + const currValue = this.value.value; + return Array.from({ length: currValue.length || 1 }, (_, i) => { + const currThumb = untrack(() => this.currentThumbIdx); + + if (currThumb < currValue.length) { + untrack(() => { + this.currentThumbIdx = currThumb + 1; + }); + } + + const thumbValue = currValue[i]; + const thumbPosition = this.getPositionFromValue(thumbValue ?? 0); + const style = getThumbStyles(this.direction, thumbPosition); + + return { + role: "slider", + "aria-valuemin": this.min.value, + "aria-valuemax": this.max.value, + "aria-valuenow": thumbValue, + "aria-disabled": getAriaDisabled(this.disabled.value), + "aria-orientation": getAriaOrientation(this.orientation.value), + "data-value": thumbValue, + tabindex: this.disabled.value ? -1 : 0, + style, + [SLIDER_THUMB_ATTR]: "", + } as const; + }); + }); + + thumbsRenderArr = $derived.by(() => { + return this.thumbsPropsArr.map((_, i) => i); + }); + + ticksPropsArr = $derived.by(() => { + const max = this.max.value; + const min = this.min.value; + const step = this.step.value; + const difference = max - min; + + let count = Math.ceil(difference / step); + + // eslint-disable-next-line eqeqeq + if (difference % step == 0) { + count++; + } + const currValue = this.value.value; + + return Array.from({ length: count }, (_, i) => { + const tickPosition = i * (step / difference) * 100; + + const isFirst = i === 0; + const isLast = i === count - 1; + const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; + const style = getTickStyles(this.direction, tickPosition, offsetPercentage); + const tickValue = min + i * step; + const bounded = + currValue.length === 1 + ? tickValue <= currValue[0]! + : currValue[0]! <= tickValue && tickValue <= currValue[currValue.length - 1]!; + + return { + "data-disabled": getDataDisabled(this.disabled.value), + "data-orientation": getDataOrientation(this.orientation.value), + "data-bounded": bounded ? "" : undefined, + "data-value": tickValue, + style, + [SLIDER_TICK_ATTR]: "", + } as const; + }); + }); + + ticksRenderArr = $derived.by(() => { + return this.ticksPropsArr.map((_, i) => i); + }); + + snippetProps = $derived.by( + () => + ({ + ticks: this.ticksRenderArr, + thumbs: this.thumbsRenderArr, + }) as const + ); + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.orientation.value), + "data-disabled": getDataDisabled(this.disabled.value), + style: { + touchAction: this.orientation.value === "horizontal" ? "pan-y" : "pan-x", + }, + [SLIDER_ROOT_ATTR]: "", + }) as const + ); + + createThumb = (props: SliderThumbStateProps) => { + return new SliderThumbState(props, this); + }; + + createRange = (props: SliderRangeStateProps) => { + return new SliderRangeState(props, this); + }; + + createTick = (props: SliderTickStateProps) => { + return new SliderTickState(props, this); + }; +} + +const VALID_SLIDER_KEYS = [ + kbd.ARROW_LEFT, + kbd.ARROW_RIGHT, + kbd.ARROW_UP, + kbd.ARROW_DOWN, + kbd.HOME, + kbd.END, +]; + +type SliderRangeStateProps = WithRefProps; + +class SliderRangeState { + #id: SliderRangeStateProps["id"]; + #ref: SliderRangeStateProps["ref"]; + #root: SliderRootState; + + constructor(props: SliderRangeStateProps, root: SliderRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + rangeStyles = $derived.by(() => { + const value = this.#root.value.value; + const min = value.length > 1 ? this.#root.getPositionFromValue(Math.min(...value) ?? 0) : 0; + const max = 100 - this.#root.getPositionFromValue(Math.max(...value) ?? 0); + + return { + position: "absolute", + ...getRangeStyles(this.#root.direction, min, max), + }; + }); + + props = $derived.by( + () => + ({ + id: this.#id.value, + "data-orientation": getDataOrientation(this.#root.orientation.value), + "data-disabled": getDataDisabled(this.#root.disabled.value), + style: this.rangeStyles, + [SLIDER_RANGE_ATTR]: "", + }) as const + ); +} + +type SliderThumbStateProps = WithRefProps & + ReadableBoxedValues<{ + index: number; + disabled: boolean; + }>; + +class SliderThumbState { + #id: SliderThumbStateProps["id"]; + #ref: SliderThumbStateProps["ref"]; + #index: SliderThumbStateProps["index"]; + #root: SliderRootState; + #isDisabled = $derived.by(() => this.#root.disabled.value || this.#root.disabled.value); + + constructor(props: SliderThumbStateProps, root: SliderRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#index = props.index; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + updateValue = (newValue: number) => { + this.#root.updateValue(newValue, this.#index.value); + }; + + moveValue = (thumbValue: number, increment: number) => { + const newValue = thumbValue + increment; + if (newValue >= this.#root.min.value && newValue <= this.#root.max.value) { + this.updateValue(newValue); + } + }; + + handleArrowKey = ( + thumbValue: number, + isPositiveDirection: boolean, + isHorizontal: boolean, + e: KeyboardEvent + ) => { + const orientation = this.#root.orientation.value; + const direction = this.#root.direction; + if ( + (isHorizontal && orientation !== "horizontal") || + (!isHorizontal && orientation === "horizontal") + ) + return; + + const isForwardDirection = + (isHorizontal && direction === "lr") || (!isHorizontal && direction === "bt"); + + if (e.metaKey) { + const max = this.#root.max.value; + const min = this.#root.min.value; + this.updateValue(isForwardDirection === isPositiveDirection ? max : min); + } else { + const step = this.#root.step.value; + this.moveValue(thumbValue, isForwardDirection === isPositiveDirection ? step : -step); + } + }; + + #onkeydown = (e: KeyboardEvent) => { + if (this.#isDisabled) return; + const currNode = this.#ref.value; + if (!currNode) return; + const thumbs = this.#root.getAllThumbs(); + if (!thumbs.length) return; + + const idx = thumbs.indexOf(currNode); + this.#root.currentThumbIdx = idx; + + if (!VALID_SLIDER_KEYS.includes(e.key)) return; + + e.preventDefault(); + + const min = this.#root.min.value; + const max = this.#root.max.value; + const value = this.#root.value.value; + const thumbValue = value[idx]!; + const orientation = this.#root.orientation.value; + const direction = this.#root.direction; + const step = this.#root.step.value; + + switch (e.key) { + case kbd.HOME: + this.updateValue(min); + break; + case kbd.END: + this.updateValue(max); + break; + case kbd.ARROW_LEFT: + if (orientation !== "horizontal") break; + if (e.metaKey) { + const newValue = direction === "rl" ? max : min; + this.updateValue(newValue); + } else if (direction === "rl" && thumbValue < max) { + this.updateValue(thumbValue + step); + } else if (direction === "lr" && thumbValue > min) { + this.updateValue(thumbValue - step); + } + break; + case kbd.ARROW_RIGHT: + if (orientation !== "horizontal") break; + if (e.metaKey) { + const newValue = direction === "rl" ? min : max; + this.updateValue(newValue); + } else if (direction === "rl" && thumbValue > min) { + this.updateValue(thumbValue - step); + } else if (direction === "lr" && thumbValue < max) { + this.updateValue(thumbValue + step); + } + break; + case kbd.ARROW_UP: + if (e.metaKey) { + const newValue = direction === "tb" ? min : max; + this.updateValue(newValue); + } else if (direction === "tb" && thumbValue > min) { + this.updateValue(thumbValue - step); + } else if (direction !== "tb" && thumbValue < max) { + this.updateValue(thumbValue + step); + } + break; + case kbd.ARROW_DOWN: + if (e.metaKey) { + const newValue = direction === "tb" ? max : min; + this.updateValue(newValue); + } else if (direction === "tb" && thumbValue < max) { + this.updateValue(thumbValue + step); + } else if (direction !== "tb" && thumbValue > min) { + this.updateValue(thumbValue - step); + } + break; + } + this.#root.onValueChangeEnd.value(this.#root.value.value); + }; + + props = $derived.by( + () => + ({ + ...this.#root.thumbsPropsArr[this.#index.value]!, + id: this.#id.value, + onkeydown: this.#onkeydown, + }) as const + ); +} + +type SliderTickStateProps = WithRefProps & + ReadableBoxedValues<{ + index: number; + }>; + +class SliderTickState { + #id: SliderTickStateProps["id"]; + #ref: SliderTickStateProps["ref"]; + #index: SliderTickStateProps["index"]; + #root: SliderRootState; + + constructor(props: SliderTickStateProps, root: SliderRootState) { + this.#id = props.id; + this.#ref = props.ref; + this.#root = root; + this.#index = props.index; + + useRefById({ + id: this.#id, + ref: this.#ref, + }); + } + + props = $derived.by( + () => + ({ + ...this.#root.ticksPropsArr[this.#index.value]!, + id: this.#id.value, + }) as const + ); +} + +const [setSliderRootContext, getSliderRootContext] = createContext("Slider.Root"); + +export function useSliderRoot(props: SliderRootStateProps) { + return setSliderRootContext(new SliderRootState(props)); +} + +export function useSliderRange(props: SliderRangeStateProps) { + return getSliderRootContext().createRange(props); +} + +export function useSliderThumb(props: SliderThumbStateProps) { + return getSliderRootContext().createThumb(props); +} + +export function useSliderTick(props: SliderTickStateProps) { + return getSliderRootContext().createTick(props); +} diff --git a/packages/bits-ui/src/lib/bits/slider/types.ts b/packages/bits-ui/src/lib/bits/slider/types.ts index 3ac18d77e..d74390b8d 100644 --- a/packages/bits-ui/src/lib/bits/slider/types.ts +++ b/packages/bits-ui/src/lib/bits/slider/types.ts @@ -1,21 +1,21 @@ -import type { HTMLInputAttributes } from "svelte/elements"; -import type { Slider as MeltSlider, CreateSliderProps as MeltSliderProps } from "@melt-ui/svelte"; -import type { StoresValues } from "svelte/store"; import type { - DOMEl, - DOMElement, - Expand, - HTMLSpanAttributes, - OmitValue, OnChangeFn, -} from "$lib/internal/index.js"; -import type { CustomEventHandler } from "$lib/index.js"; + PrimitiveSpanAttributes, + WithChild, + Without, +} from "$lib/internal/types.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; -export type SliderPropsWithoutHTML = Expand< - OmitValue & { +export type SliderRootSnippetProps = { + ticks: number[]; + thumbs: number[]; +}; + +export type SliderRootPropsWithoutHTML = WithChild< + { /** * The value of the slider. - * You can bind this to a number value to programmatically control the value. + * @bindable */ value?: number[]; @@ -23,42 +23,102 @@ export type SliderPropsWithoutHTML = Expand< * A callback function called when the value changes. */ onValueChange?: OnChangeFn; - } & DOMElement ->; -export type SliderRangePropsWithoutHTML = DOMElement; + /** + * A callback function called when the user stops dragging the thumb, + * which is useful for knowing when the user has finished interacting with the slider. + */ + onValueChangeEnd?: OnChangeFn; -export type SliderThumbPropsWithoutHTML = DOMElement & { - /** - * An individual thumb builder from the `thumbs` slot prop - * provided by the `Slider.Root` component. - */ - thumb: Thumb; -}; + /** + * Whether to automatically sort the values in the array when moving thumbs past + * one another. + * + * @defaultValue true + */ + autoSort?: boolean; + /** + * The minimum value of the slider. + * + * @defaultValue 0 + */ + min?: number; -export type SliderTickPropsWithoutHTML = DOMElement & { - /** - * An individual tick builder from the `ticks` slot prop - * provided by the `Slider.Root` component. - */ - tick: Tick; -}; + /** + * The maximum value of the slider. + * + * @defaultValue 100 + */ + max?: number; -type Tick = StoresValues[number]; -type Thumb = StoresValues[number]; + /** + * The amount to increment the value by when the user presses the arrow keys. + * + * @defayltValue 1 + */ + step?: number; -// + /** + * The direction of the slider. + * + * For vertical sliders, setting `dir` to `'rtl'` will caus the slider to start + * from the top and move downwards. For horizontal sliders, setting `dir` to `'rtl'` + * will cause the slider to start from the left and move rightwards. + * + * @defaultValue 'ltr' + */ + dir?: Direction; -export type SliderProps = SliderPropsWithoutHTML & HTMLSpanAttributes; + /** + * The orientation of the slider. + * + * @defaultValue "horizontal" + */ + orientation?: Orientation; -export type SliderRangeProps = SliderRangePropsWithoutHTML & HTMLSpanAttributes; + /** + * Whether the slider is disabled or not. + * + * @defaultValue false + */ + disabled?: boolean; + }, + SliderRootSnippetProps +>; -export type SliderThumbProps = SliderThumbPropsWithoutHTML & HTMLSpanAttributes; +export type SliderRootProps = SliderRootPropsWithoutHTML & + Without; -export type SliderTickProps = SliderTickPropsWithoutHTML & HTMLSpanAttributes; +export type SliderRangePropsWithoutHTML = WithChild; -export type SliderInputProps = Omit & DOMEl; +export type SliderRangeProps = SliderRangePropsWithoutHTML & + Without; -export type SliderThumbEvents = { - keydown: CustomEventHandler; -}; +export type SliderThumbPropsWithoutHTML = WithChild<{ + /** + * Whether the thumb is disabled or not. + * + * @defaultValue false + */ + disabled?: boolean; + + /** + * The index of the thumb in the array of thumbs provided by the `children` snippet prop of the + * `Slider.Root` component. + */ + index: number; +}>; + +export type SliderThumbProps = SliderThumbPropsWithoutHTML & + Without; + +export type SliderTickPropsWithoutHTML = WithChild<{ + /** + * The index of the tick in the array of ticks provided by the `children` snippet prop of the + * `Slider.Root` component. + */ + index: number; +}>; + +export type SliderTickProps = SliderTickPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/bits/toggle-group/types.ts b/packages/bits-ui/src/lib/bits/toggle-group/types.ts index 2c28fbd8e..a0dc32b1a 100644 --- a/packages/bits-ui/src/lib/bits/toggle-group/types.ts +++ b/packages/bits-ui/src/lib/bits/toggle-group/types.ts @@ -7,7 +7,7 @@ import type { } from "$lib/internal/index.js"; import type { Orientation } from "$lib/index.js"; -type BaseToggleGroupProps = { +export type BaseToggleGroupRootProps = { /** * Whether the toggle group is disabled or not. * @@ -39,29 +39,29 @@ type BaseToggleGroupProps = { rovingFocus?: boolean; }; -export type SingleToggleGroupPropsWithoutHTML = WithChild< - BaseToggleGroupProps & { +export type SingleToggleGroupRootPropsWithoutHTML = WithChild< + BaseToggleGroupRootProps & { type: "single"; value?: string; onValueChange?: OnChangeFn; } >; -export type SingleToggleGroupRootProps = SingleToggleGroupPropsWithoutHTML & - Without; +export type SingleToggleGroupRootProps = SingleToggleGroupRootPropsWithoutHTML & + Without; -export type MultipleToggleGroupPropsWithoutHTML = WithChild & { +export type MultipleToggleGroupRootPropsWithoutHTML = WithChild & { type: "multiple"; value?: string[]; onValueChange?: OnChangeFn; }; -export type MultipleToggleGroupRootProps = MultipleToggleGroupPropsWithoutHTML & - Without; +export type MultipleToggleGroupRootProps = MultipleToggleGroupRootPropsWithoutHTML & + Without; export type ToggleGroupRootPropsWithoutHTML = - | SingleToggleGroupPropsWithoutHTML - | MultipleToggleGroupPropsWithoutHTML; + | SingleToggleGroupRootPropsWithoutHTML + | MultipleToggleGroupRootPropsWithoutHTML; export type ToggleGroupRootProps = ToggleGroupRootPropsWithoutHTML & Without; diff --git a/packages/bits-ui/src/lib/internal/math.ts b/packages/bits-ui/src/lib/internal/math.ts new file mode 100644 index 000000000..256c7e264 --- /dev/null +++ b/packages/bits-ui/src/lib/internal/math.ts @@ -0,0 +1,40 @@ +/** + * From https://github.com/melt-ui/melt-ui/blob/main/packages/svelte/src/lib/internal/math.ts + */ +export function snapValueToStep(value: number, min: number, max: number, step: number): number { + const remainder = (value - (Number.isNaN(min) ? 0 : min)) % step; + let snappedValue = + Math.abs(remainder) * 2 >= step + ? value + Math.sign(remainder) * (step - Math.abs(remainder)) + : value - remainder; + + if (!Number.isNaN(min)) { + if (snappedValue < min) { + snappedValue = min; + } else if (!Number.isNaN(max) && snappedValue > max) { + snappedValue = min + Math.floor((max - min) / step) * step; + } + } else if (!Number.isNaN(max) && snappedValue > max) { + snappedValue = Math.floor(max / step) * step; + } + + const string = step.toString(); + const index = string.indexOf("."); + const precision = index >= 0 ? string.length - index : 0; + + if (precision > 0) { + const pow = 10 ** precision; + snappedValue = Math.round(snappedValue * pow) / pow; + } + + return snappedValue; +} + +export function roundValue(value: number, decimalCount: number) { + const rounder = 10 ** decimalCount; + return Math.round(value * rounder) / rounder; +} + +export function getDecimalCount(value: number) { + return (String(value).split(".")[1] || "").length; +} diff --git a/packages/bits-ui/src/lib/internal/mergeProps.ts b/packages/bits-ui/src/lib/internal/mergeProps.ts index 6ae228364..9b7ec274e 100644 --- a/packages/bits-ui/src/lib/internal/mergeProps.ts +++ b/packages/bits-ui/src/lib/internal/mergeProps.ts @@ -1,3 +1,6 @@ +/** + * Modified from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/utils/src/mergeProps.ts (see NOTICE.txt for source) + */ import { clsx } from "clsx"; import { type EventCallback, composeHandlers } from "./events.js"; import { styleToString } from "./style.js"; @@ -14,6 +17,7 @@ type TupleTypes = { [P in keyof T]: T[P] } extends { [key: number]: infer V } ? NullToObject : never; type NullToObject = T extends null | undefined ? {} : T; +// eslint-disable-next-line ts/no-explicit-any type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; @@ -27,7 +31,6 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( * - Handles a bug with Svelte where setting the `hidden` attribute to `false` doesn't remove it * - Overrides other values with the last one * - * Modified from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/utils/src/mergeProps.ts */ export function mergeProps( ...args: T diff --git a/packages/bits-ui/src/lib/internal/useRovingFocus.svelte.ts b/packages/bits-ui/src/lib/internal/useRovingFocus.svelte.ts index 89539435e..9671777fe 100644 --- a/packages/bits-ui/src/lib/internal/useRovingFocus.svelte.ts +++ b/packages/bits-ui/src/lib/internal/useRovingFocus.svelte.ts @@ -118,5 +118,6 @@ export function useRovingFocus(props: UseRovingFocusProps) { getTabIndex, handleKeydown, focusFirstCandidate, + currentTabStopId, }; } diff --git a/packages/bits-ui/src/tests/slider/Slider.spec.ts b/packages/bits-ui/src/tests/slider/Slider.spec.ts index 8811b7e31..c59251946 100644 --- a/packages/bits-ui/src/tests/slider/Slider.spec.ts +++ b/packages/bits-ui/src/tests/slider/Slider.spec.ts @@ -1,24 +1,22 @@ // Credit to @paoloricciuti for this code via melt :) import { render } from "@testing-library/svelte"; -import { userEvent } from "@testing-library/user-event"; import { axe } from "jest-axe"; import { describe, it } from "vitest"; -import { getTestKbd } from "../utils.js"; -import SliderTest from "./SliderTest.svelte"; -import SliderRangeTest from "./SliderRangeTest.svelte"; -import type { Slider } from "$lib/index.js"; +import { getTestKbd, setupUserEvents } from "../utils.js"; +import SliderTest, { type SliderTestProps } from "./SliderTest.svelte"; +import SliderRangeTest, { type SliderRangeTestProps } from "./SliderRangeTest.svelte"; const kbd = getTestKbd(); -function renderSlider(props: Slider.Props = {}) { +function renderSlider(props: SliderTestProps = {}) { return render(SliderTest, { ...props }); } -function renderSliderRange(props: Slider.Props = {}) { +function renderSliderRange(props: SliderRangeTestProps = {}) { return render(SliderRangeTest, { ...props }); } -function setup(props: Slider.Props = {}, kind: "default" | "range" = "default") { - const user = userEvent.setup(); +function setup(props: SliderTestProps = {}, kind: "default" | "range" = "default") { + const user = setupUserEvents(); // eslint-disable-next-line ts/no-explicit-any let returned: any; if (kind === "default") { @@ -57,8 +55,7 @@ describe("slider (default)", () => { }); it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])("change by 1% when pressing %s", async (key) => { - const { getByTestId } = setup(); - const user = userEvent.setup(); + const { getByTestId, user } = setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -70,8 +67,7 @@ describe("slider (default)", () => { }); it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])("change by 1% when pressing %s", async (key) => { - const { getByTestId } = setup(); - const user = userEvent.setup(); + const { getByTestId, user } = setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -83,8 +79,7 @@ describe("slider (default)", () => { }); it("goes to minimum when pressing Home", async () => { - const { getByTestId } = setup(); - const user = userEvent.setup(); + const { getByTestId, user } = setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -96,8 +91,7 @@ describe("slider (default)", () => { }); it("goes to maximum when pressing End", async () => { - const { getByTestId } = setup(); - const user = userEvent.setup(); + const { getByTestId, user } = setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -139,8 +133,7 @@ describe("slider (range)", () => { it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])( "change by 1% when pressing %s (pressing on the first thumb)", async (key) => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -156,8 +149,7 @@ describe("slider (range)", () => { it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])( "change by 1% when pressing %s (pressing on the last thumb)", async (key) => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -173,8 +165,7 @@ describe("slider (range)", () => { it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])( "change by 1% when pressing %s (pressing on the first thumb)", async (key) => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -190,8 +181,7 @@ describe("slider (range)", () => { it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])( "change by 1% when pressing %s (pressing on the last thumb)", async (key) => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -207,13 +197,12 @@ describe("slider (range)", () => { it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])( "the handlers swap places when they overlap pressing %s (going up)", async (key) => { - const { getByTestId } = setup( + const { getByTestId, user } = setup( { value: [49, 51], }, "range" ); - const user = userEvent.setup(); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -232,13 +221,12 @@ describe("slider (range)", () => { it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])( "the handlers swap places when they overlap pressing %s (going down)", async (key) => { - const { getByTestId } = setup( + const { getByTestId, user } = setup( { value: [49, 51], }, "range" ); - const user = userEvent.setup(); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -255,8 +243,7 @@ describe("slider (range)", () => { ); it("thumb 0 goes to minimum when pressing Home", async () => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -269,8 +256,7 @@ describe("slider (range)", () => { }); it("thumb 1 goes to maximum when pressing End", async () => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -283,8 +269,7 @@ describe("slider (range)", () => { }); it("thumb 1 goes to minimum when pressing Home (thumbs swap places)", async () => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -298,8 +283,7 @@ describe("slider (range)", () => { }); it("thumb 0 goes to maximum when pressing End (thumbs swap places)", async () => { - const { getByTestId } = setup({}, "range"); - const user = userEvent.setup(); + const { getByTestId, user } = setup({}, "range"); const thumb0 = getByTestId("thumb-0"); const thumb1 = getByTestId("thumb-1"); @@ -329,15 +313,13 @@ describe("slider (small min, max, step)", () => { }); it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])("change by 1% when pressing %s", async (key) => { - const { getByTestId } = setup({ + const { getByTestId, user } = setup({ value: [0.5], min: 0, max: 1, step: 0.01, }); - const user = userEvent.setup(); - const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -348,13 +330,12 @@ describe("slider (small min, max, step)", () => { }); it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])("change by 10% when pressing %s", async (key) => { - const { getByTestId } = setup({ + const { getByTestId, user } = setup({ value: [0.5], min: 0, max: 1, step: 0.01, }); - const user = userEvent.setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -382,15 +363,13 @@ describe("slider (negative min)", () => { }); it.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])("change by 1% when pressing %s", async (key) => { - const { getByTestId } = setup({ + const { getByTestId, user } = setup({ value: [0], min: -50, max: 50, step: 1, }); - const user = userEvent.setup(); - const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -401,13 +380,12 @@ describe("slider (negative min)", () => { }); it.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])("change by 10% when pressing %s", async (key) => { - const { getByTestId } = setup({ + const { getByTestId, user } = setup({ value: [0], min: -50, max: 50, step: 1, }); - const user = userEvent.setup(); const thumb = getByTestId("thumb"); const range = getByTestId("range"); @@ -596,5 +574,7 @@ function expectPercentages({ expect(isCloseEnough(percentage, thumb.style.left)).toBeTruthy(); } expect(isCloseEnough(lesserPercentage, range.style.left)).toBeTruthy(); + console.log("higherPercentage", higherPercentage); + console.log("rangeStyleRight", range.style.right); expect(isCloseEnough(100 - higherPercentage, range.style.right)).toBeTruthy(); } diff --git a/packages/bits-ui/src/tests/slider/SliderRangeTest.svelte b/packages/bits-ui/src/tests/slider/SliderRangeTest.svelte index 33d451e0e..ee8b3b8cb 100644 --- a/packages/bits-ui/src/tests/slider/SliderRangeTest.svelte +++ b/packages/bits-ui/src/tests/slider/SliderRangeTest.svelte @@ -1,26 +1,31 @@ - + +
- - - - - {#each thumbs as thumb, i} - - {/each} + + {#snippet children({ thumbs, ticks })} + + + + {#each thumbs as thumb, i} + + {/each} - {#each ticks as tick} - - {/each} + {#each ticks as tick} + + {/each} + {/snippet}
diff --git a/packages/bits-ui/src/tests/slider/SliderTest.svelte b/packages/bits-ui/src/tests/slider/SliderTest.svelte index 4c329c481..37437264d 100644 --- a/packages/bits-ui/src/tests/slider/SliderTest.svelte +++ b/packages/bits-ui/src/tests/slider/SliderTest.svelte @@ -1,53 +1,60 @@ - + +
- - - - - {#each thumbs as thumb} - - {/each} + + {#snippet children({ thumbs, ticks })} + + + + {#each thumbs as thumb} + + {/each} - {#each ticks as tick} - - {/each} + {#each ticks as tick} + + {/each} + {/snippet}
diff --git a/sites/docs/content/components/listbox.md b/sites/docs/content/components/listbox.md new file mode 100644 index 000000000..9995c9ba5 --- /dev/null +++ b/sites/docs/content/components/listbox.md @@ -0,0 +1,33 @@ +--- +title: Listbox +description: A list of options that can be selected by the user. +--- + + + + + + + + + +## Structure + +```svelte + + + + + + + + + + + +``` diff --git a/sites/docs/src/lib/components/demos/index.ts b/sites/docs/src/lib/components/demos/index.ts index 2d35e6c1e..d18c97482 100644 --- a/sites/docs/src/lib/components/demos/index.ts +++ b/sites/docs/src/lib/components/demos/index.ts @@ -16,6 +16,7 @@ export { default as DialogDemo } from "./dialog-demo.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 MenubarDemo } from "./menubar-demo.svelte"; export { default as NavigationMenuDemo } from "./navigation-menu-demo.svelte"; export { default as PaginationDemo } from "./pagination-demo.svelte"; diff --git a/sites/docs/src/lib/components/demos/listbox-demo.svelte b/sites/docs/src/lib/components/demos/listbox-demo.svelte new file mode 100644 index 000000000..8f57a0702 --- /dev/null +++ b/sites/docs/src/lib/components/demos/listbox-demo.svelte @@ -0,0 +1,58 @@ + + +
+ + + {#each themes as item} + + {#snippet children({ selected })} + {item.label} + {#if selected} + + + + {/if} + {/snippet} + + {/each} + + + +
+ Selected: {value} +
+
diff --git a/sites/docs/src/lib/components/demos/slider-demo.svelte b/sites/docs/src/lib/components/demos/slider-demo.svelte index e20c3f822..2fee9a17f 100644 --- a/sites/docs/src/lib/components/demos/slider-demo.svelte +++ b/sites/docs/src/lib/components/demos/slider-demo.svelte @@ -1,23 +1,28 @@
- - - - - {#each thumbs as thumb} - - {/each} + + {#snippet children({ thumbs, ticks })} + + + + {#each thumbs as index} + + {/each} + {#each ticks as index} + + {/each} + {/snippet}
diff --git a/sites/docs/src/lib/components/icons/switch-off.svelte b/sites/docs/src/lib/components/icons/switch-off.svelte index 38c9ae3d0..2bf314821 100644 --- a/sites/docs/src/lib/components/icons/switch-off.svelte +++ b/sites/docs/src/lib/components/icons/switch-off.svelte @@ -3,5 +3,5 @@ > + > diff --git a/sites/docs/src/lib/content/api-reference/index.ts b/sites/docs/src/lib/content/api-reference/index.ts index ee4181ab5..ebb16fff5 100644 --- a/sites/docs/src/lib/content/api-reference/index.ts +++ b/sites/docs/src/lib/content/api-reference/index.ts @@ -53,6 +53,7 @@ export const bits = [ "dropdown-menu", "label", "link-preview", + "listbox", "menubar", "navigation-menu", "pagination", @@ -100,6 +101,7 @@ export const apiSchemas: Record = { "dropdown-menu": dropdownMenu, label, "link-preview": linkPreview, + listbox: linkPreview, pagination, "pin-input": pinInput, popover,