From a6f6685c388b2874d89190a971af631e82ee435a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 16 Apr 2024 21:19:23 -0400 Subject: [PATCH 1/2] svelte 5 progress --- .../bits/progress/components/progress.svelte | 58 ++++++++----------- packages/bits-ui/src/lib/bits/progress/ctx.ts | 23 -------- .../bits-ui/src/lib/bits/progress/index.ts | 2 +- .../src/lib/bits/progress/progress.svelte.ts | 53 +++++++++++++++++ .../bits-ui/src/lib/bits/progress/types.ts | 37 +++++------- 5 files changed, 93 insertions(+), 80 deletions(-) delete mode 100644 packages/bits-ui/src/lib/bits/progress/ctx.ts create mode 100644 packages/bits-ui/src/lib/bits/progress/progress.svelte.ts diff --git a/packages/bits-ui/src/lib/bits/progress/components/progress.svelte b/packages/bits-ui/src/lib/bits/progress/components/progress.svelte index b8fa0507f..b90534a78 100644 --- a/packages/bits-ui/src/lib/bits/progress/components/progress.svelte +++ b/packages/bits-ui/src/lib/bits/progress/components/progress.svelte @@ -1,44 +1,36 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} -
- +
+ {@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/progress/ctx.ts b/packages/bits-ui/src/lib/bits/progress/ctx.ts deleted file mode 100644 index 110a6b60e..000000000 --- a/packages/bits-ui/src/lib/bits/progress/ctx.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type CreateProgressProps, createProgress } from "@melt-ui/svelte"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; - -function getProgressData() { - const NAME = "progress" as const; - const PARTS = ["root"] as const; - - return { - NAME, - PARTS, - }; -} - -export function setCtx(props: CreateProgressProps) { - const { NAME, PARTS } = getProgressData(); - const getAttrs = createBitAttrs(NAME, PARTS); - const progress = { ...createProgress(removeUndefined(props)), getAttrs }; - - return { - ...progress, - updateOption: getOptionUpdater(progress.options), - }; -} diff --git a/packages/bits-ui/src/lib/bits/progress/index.ts b/packages/bits-ui/src/lib/bits/progress/index.ts index f1d2a1a2a..bff7412a1 100644 --- a/packages/bits-ui/src/lib/bits/progress/index.ts +++ b/packages/bits-ui/src/lib/bits/progress/index.ts @@ -1,3 +1,3 @@ export { default as Root } from "./components/progress.svelte"; -export type { ProgressProps as Props } from "./types.js"; +export type { ProgressRootProps as RootProps } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/progress/progress.svelte.ts b/packages/bits-ui/src/lib/bits/progress/progress.svelte.ts new file mode 100644 index 000000000..d968bc95f --- /dev/null +++ b/packages/bits-ui/src/lib/bits/progress/progress.svelte.ts @@ -0,0 +1,53 @@ +import type { BoxedValues, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js"; + +type ProgressRootStateProps = ReadonlyBoxedValues<{ + value: number | null; + max: number; +}>; + +class ProgressRootState { + #value = undefined as unknown as ProgressRootStateProps["value"]; + #max = undefined as unknown as ProgressRootStateProps["max"]; + + #attrs = $derived({ + role: "meter", + value: this.#value.value, + max: this.#max.value, + "aria-valuemin": 0, + "aria-valuemax": this.#max.value, + "aria-valuenow": this.#value.value, + "data-value": this.#value.value, + "data-state": getProgressDataState(this.#value.value, this.#max.value), + "data-max": this.#max.value, + "data-bits-progress-root": "", + } as const); + + constructor(props: ProgressRootStateProps) { + this.#value = props.value; + this.#max = props.max; + } + + get props() { + return this.#attrs; + } +} + +// +// HELPERS +// + +function getProgressDataState( + value: number | null, + max: number +): "indeterminate" | "loaded" | "loading" { + if (value === null) return "indeterminate"; + return value === max ? "loaded" : "loading"; +} + +// +// STATE PROVIDERS +// + +export function setProgressRootState(props: ProgressRootStateProps) { + return new ProgressRootState(props); +} diff --git a/packages/bits-ui/src/lib/bits/progress/types.ts b/packages/bits-ui/src/lib/bits/progress/types.ts index aecd283dc..e0b5d82a5 100644 --- a/packages/bits-ui/src/lib/bits/progress/types.ts +++ b/packages/bits-ui/src/lib/bits/progress/types.ts @@ -1,26 +1,17 @@ -import type { CreateProgressProps as MeltProgressProps } from "@melt-ui/svelte"; -import type { - DOMElement, - Expand, - HTMLDivAttributes, - OmitValue, - OnChangeFn, -} from "$lib/internal/index.js"; +import type { PrimitiveDivAttributes, WithAsChild } from "$lib/internal/index.js"; -export type ProgressPropsWithoutHTML = Expand< - OmitValue & { - /** - * The value of the progress bar. - * You can bind this to a number value to programmatically control the value. - */ - value?: MeltProgressProps["defaultValue"]; +export type ProgressRootPropsWithoutHTML = WithAsChild<{ + /** + * The current value of the progress bar. + * If `null`, the progress bar will be in an indeterminate state. + */ + value?: number | null; - /** - * A callback function called when the value changes. - */ - onValueChange?: OnChangeFn; - } & DOMElement ->; -// + /** + * The maximum value of the progress bar. Used to calculate the percentage + * of the progress bar along with the `value` prop. + */ + max?: number; +}>; -export type ProgressProps = ProgressPropsWithoutHTML & HTMLDivAttributes; +export type ProgressRootProps = ProgressRootPropsWithoutHTML & PrimitiveDivAttributes; From 8e7727149cb19c32b6a5a6451b5396942ad39ddb Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 16 Apr 2024 22:48:45 -0400 Subject: [PATCH 2/2] radio group --- .../lib/bits/accordion/accordion.svelte.ts | 43 ++-- .../bits/checkbox/components/checkbox.svelte | 2 + .../bits/collapsible/collapsible.svelte.ts | 12 +- .../components/radio-group-input.svelte | 24 -- .../radio-group-item-indicator.svelte | 28 --- .../components/radio-group-item.svelte | 62 ++--- .../radio-group/components/radio-group.svelte | 86 +++---- .../bits-ui/src/lib/bits/radio-group/index.ts | 6 +- .../bits/radio-group/radio-group.svelte.ts | 211 ++++++++++++++++++ .../bits-ui/src/lib/bits/radio-group/types.ts | 118 ++++++---- .../src/lib/internal/elements.svelte.ts | 20 ++ packages/bits-ui/src/lib/internal/kbd.ts | 31 +++ packages/bits-ui/src/lib/internal/locale.ts | 13 ++ packages/bits-ui/src/lib/internal/types.ts | 7 +- .../bits-ui/src/lib/internal/with-tick.ts | 5 + packages/bits-ui/src/lib/shared/index.ts | 3 + 16 files changed, 467 insertions(+), 204 deletions(-) delete mode 100644 packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item-indicator.svelte create mode 100644 packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts create mode 100644 packages/bits-ui/src/lib/internal/elements.svelte.ts create mode 100644 packages/bits-ui/src/lib/internal/locale.ts create mode 100644 packages/bits-ui/src/lib/internal/with-tick.ts 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 7a32e7eac..eb8561ee3 100644 --- a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts +++ b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts @@ -17,6 +17,8 @@ import { verifyContextDeps, } from "$lib/internal/index.js"; import type { StyleProperties } from "$lib/shared/index.js"; +import { withTick } from "$lib/internal/with-tick.js"; +import { useNodeById } from "$lib/internal/elements.svelte.js"; /** * BASE @@ -28,6 +30,7 @@ type AccordionBaseStateProps = ReadonlyBoxedValues<{ class AccordionBaseState { id = undefined as unknown as ReadonlyBox; + node = boxedState(null); disabled: ReadonlyBox; #attrs = $derived({ id: this.id.value, @@ -37,6 +40,15 @@ class AccordionBaseState { constructor(props: AccordionBaseStateProps) { this.id = props.id; this.disabled = props.disabled; + + useNodeById(this.id, this.node); + } + + getTriggerNodes() { + if (!this.node.value) return []; + return Array.from( + this.node.value.querySelectorAll("[data-accordion-trigger]") + ).filter((el) => !el.dataset.disabled); } get props() { @@ -160,6 +172,7 @@ type AccordionTriggerStateProps = ReadonlyBoxedValues<{ class AccordionTriggerState { #disabled = undefined as unknown as ReadonlyBox; #id = undefined as unknown as ReadonlyBox; + #node = boxedState(null); #root = undefined as unknown as AccordionState; #itemState = undefined as unknown as AccordionItemState; #onclickProp = boxedState(readonlyBox(() => () => {})); @@ -189,6 +202,8 @@ class AccordionTriggerState { this.#onclickProp.value = props.onclick; this.#onkeydownProp.value = props.onkeydown; this.#id = props.id; + + useNodeById(this.#id, this.#node); } #onclick = composeHandlers(this.#onclickProp, () => { @@ -207,20 +222,12 @@ class AccordionTriggerState { return; } - if (!this.#root.id.value || !this.#id.value) return; - - const rootEl = document.getElementById(this.#root.id.value); - if (!rootEl) return; - const itemEl = document.getElementById(this.#id.value); - if (!itemEl) return; + if (!this.#root.node.value || !this.#node.value) return; - const items = Array.from(rootEl.querySelectorAll("[data-accordion-trigger]")); - if (!items.length) return; - - const candidateItems = items.filter((item) => !item.dataset.disabled); + const candidateItems = this.#root.getTriggerNodes(); if (!candidateItems.length) return; - const currentIndex = candidateItems.indexOf(itemEl); + const currentIndex = candidateItems.indexOf(this.#node.value); const keyToIndex = { [kbd.ARROW_DOWN]: (currentIndex + 1) % candidateItems.length, @@ -282,11 +289,7 @@ class AccordionContentState { this.#id = props.id; this.#styleProp = props.style; - $effect.root(() => { - tick().then(() => { - this.node.value = document.getElementById(this.#id.value); - }); - }); + useNodeById(this.#id, this.node); $effect.pre(() => { const rAF = requestAnimationFrame(() => { @@ -304,7 +307,7 @@ class AccordionContentState { const node = this.node.value; if (!node) return; - tick().then(() => { + withTick(() => { if (!this.node) return; // get the dimensions of the element this.#originalStyles = this.#originalStyles || { @@ -335,9 +338,9 @@ class AccordionContentState { } } -/** - * CONTEXT METHODS - */ +// +// CONTEXT METHODS +// export const ACCORDION_ROOT_KEY = Symbol("Accordion.Root"); export const ACCORDION_ITEM_KEY = Symbol("Accordion.Item"); diff --git a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte index 468c065fa..2a189f359 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte +++ b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte @@ -8,6 +8,7 @@ let { checked: checkedProp = $bindable(false), onCheckedChange, + children, disabled: disabledProp = false, required: requiredProp = false, name: nameProp, @@ -58,6 +59,7 @@ {:else} {/if} diff --git a/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts b/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts index 1064b6642..525885451 100644 --- a/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts +++ b/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts @@ -13,6 +13,9 @@ import { generateId } from "$lib/internal/id.js"; import { styleToString } from "$lib/internal/style.js"; import { type EventCallback, composeHandlers } from "$lib/internal/events.js"; import type { StyleProperties } from "$lib/shared/index.js"; +import { withTick } from "$lib/internal/with-tick.js"; +import { useNodeById } from "$lib/internal/elements.svelte.js"; +import { verifyContextDeps } from "$lib/internal/context.js"; type CollapsibleRootStateProps = BoxedValues<{ open: boolean; @@ -88,11 +91,7 @@ class CollapsibleContentState { this.root.contentId = props.id; this.#styleProp = props.style; - $effect.root(() => { - tick().then(() => { - this.node.value = document.getElementById(this.root.contentId.value); - }); - }); + useNodeById(this.root.contentId, this.node); $effect.pre(() => { const rAF = requestAnimationFrame(() => { @@ -110,7 +109,7 @@ class CollapsibleContentState { const node = this.node.value; if (!node) return; - tick().then(() => { + withTick(() => { if (!this.node) return; // get the dimensions of the element this.#originalStyles = this.#originalStyles || { @@ -182,6 +181,7 @@ export function setCollapsibleRootState(props: CollapsibleRootStateProps) { } export function getCollapsibleRootState() { + verifyContextDeps(COLLAPSIBLE_ROOT_KEY); return getContext(COLLAPSIBLE_ROOT_KEY); } diff --git a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-input.svelte b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-input.svelte index 4bf6a6373..0fbba9978 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-input.svelte +++ b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-input.svelte @@ -1,26 +1,2 @@ - -{#if asChild} - -{:else} - -{/if} diff --git a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item-indicator.svelte b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item-indicator.svelte deleted file mode 100644 index f4816ff14..000000000 --- a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item-indicator.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if asChild} - -{:else} -
- {#if checked} - - {/if} -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item.svelte b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item.svelte index 9875d83a9..7f2280bf8 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item.svelte +++ b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group-item.svelte @@ -1,41 +1,43 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group.svelte b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group.svelte index d7ca96647..ed79f4681 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/components/radio-group.svelte +++ b/packages/bits-ui/src/lib/bits/radio-group/components/radio-group.svelte @@ -1,54 +1,54 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} -
- +
+ {@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/radio-group/index.ts b/packages/bits-ui/src/lib/bits/radio-group/index.ts index e52eb1542..5311f6065 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/index.ts +++ b/packages/bits-ui/src/lib/bits/radio-group/index.ts @@ -1,12 +1,8 @@ export { default as Root } from "./components/radio-group.svelte"; export { default as Input } from "./components/radio-group-input.svelte"; export { default as Item } from "./components/radio-group-item.svelte"; -export { default as ItemIndicator } from "./components/radio-group-item-indicator.svelte"; export type { - RadioGroupProps as Props, - RadioGroupInputProps as InputProps, + RadioGroupRootProps as RootProps, RadioGroupItemProps as ItemProps, - RadioGroupItemIndicatorProps as ItemIndicatorProps, - RadioGroupItemEvents as ItemEvents, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts b/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts new file mode 100644 index 000000000..9e1bf6371 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts @@ -0,0 +1,211 @@ +import { getContext, setContext } from "svelte"; +import { getAriaChecked, getAriaRequired, getDataDisabled } from "$lib/internal/attrs.js"; +import { + type BoxedValues, + type ReadonlyBoxedValues, + boxedState, + readonlyBox, +} from "$lib/internal/box.svelte.js"; +import { useNodeById } from "$lib/internal/elements.svelte.js"; +import { type EventCallback, composeHandlers } from "$lib/internal/events.js"; +import { getDirectionalKeys, kbd } from "$lib/internal/kbd.js"; +import { getElemDirection } from "$lib/internal/locale.js"; +import { withTick } from "$lib/internal/with-tick.js"; +import type { Orientation } from "$lib/shared/index.js"; +import { verifyContextDeps } from "$lib/internal/context.js"; + +type RadioGroupRootStateProps = ReadonlyBoxedValues<{ + id: string; + disabled: boolean; + required: boolean; + loop: boolean; + orientation: Orientation; + name: string | undefined; +}> & + BoxedValues<{ value: string }>; + +class RadioGroupRootState { + id = undefined as unknown as RadioGroupRootStateProps["id"]; + node = boxedState(null); + disabled = undefined as unknown as RadioGroupRootStateProps["disabled"]; + required = undefined as unknown as RadioGroupRootStateProps["required"]; + loop = undefined as unknown as RadioGroupRootStateProps["loop"]; + orientation = undefined as unknown as RadioGroupRootStateProps["orientation"]; + name = undefined as unknown as RadioGroupRootStateProps["name"]; + value = undefined as unknown as RadioGroupRootStateProps["value"]; + #attrs = $derived({ + role: "radiogroup", + "aria-required": getAriaRequired(this.required.value), + "data-disabled": getDataDisabled(this.disabled.value), + "data-orientation": this.orientation.value, + "data-bits-radio-group": "", + } as const); + + constructor(props: RadioGroupRootStateProps) { + this.id = props.id; + this.disabled = props.disabled; + this.required = props.required; + this.loop = props.loop; + this.orientation = props.orientation; + this.name = props.name; + this.value = props.value; + + $effect.pre(() => { + // eslint-disable-next-line no-unused-expressions + this.id.value; + + withTick(() => { + this.node.value = document.getElementById(this.id.value); + }); + }); + } + + isChecked(value: string) { + return this.value.value === value; + } + + selectValue(value: string) { + this.value.value = value; + } + + getRadioItemNodes() { + if (!this.node.value) return []; + return Array.from(this.node.value.querySelectorAll("[data-bits-radio-group-item]")).filter( + (el): el is HTMLElement => el instanceof HTMLElement && !el.dataset.disabled + ); + } + + createItem(props: RadioGroupItemStateProps) { + return new RadioGroupItemState(props, this); + } + + get props() { + return this.#attrs; + } +} + +// +// RADIO GROUP ITEM +// + +type RadioGroupItemStateProps = ReadonlyBoxedValues<{ + disabled: boolean; + value: string; + onclick: EventCallback; + onkeydown: EventCallback; + id: string; +}>; + +class RadioGroupItemState { + #id = undefined as unknown as RadioGroupItemStateProps["id"]; + #node = boxedState(null); + #root = undefined as unknown as RadioGroupRootState; + #disabled = undefined as unknown as RadioGroupItemStateProps["disabled"]; + #value = undefined as unknown as RadioGroupItemStateProps["value"]; + #isDisabled = $derived(this.#disabled.value || this.#root.disabled.value); + #isChecked = $derived(this.#root.isChecked(this.#value.value)); + #onclickProp = boxedState(readonlyBox(() => () => {})); + #onkeydownProp = boxedState(readonlyBox(() => () => {})); + + #attrs = $derived({ + disabled: this.#isDisabled ? true : undefined, + "data-value": this.#value.value, + "data-orientation": this.#root.orientation.value, + "data-disabled": getDataDisabled(this.#isDisabled), + "data-state": this.#isChecked ? "checked" : "unchecked", + "aria-checked": getAriaChecked(this.#isChecked), + "data-bits-radio-group-item": "", + type: "button", + role: "radio", + } as const); + + constructor(props: RadioGroupItemStateProps, root: RadioGroupRootState) { + this.#disabled = props.disabled; + this.#value = props.value; + this.#root = root; + this.#id = props.id; + + useNodeById(this.#id, this.#node); + } + + onclick = composeHandlers(this.#onclickProp, () => { + this.#root.selectValue(this.#value.value); + }); + + onkeydown = composeHandlers(this.#onkeydownProp, (e) => { + if (!this.#root.node.value || !this.#node.value) return; + const items = this.#root.getRadioItemNodes(); + if (!items.length) return; + + const currentIndex = items.indexOf(this.#node.value); + + const dir = getElemDirection(this.#root.node.value); + const { nextKey, prevKey } = getDirectionalKeys(dir, this.#root.orientation.value); + + let itemToFocus: HTMLElement | undefined; + const loop = this.#root.loop.value; + + const keyMap = { + [nextKey]: () => { + e.preventDefault(); + const nextIndex = currentIndex + 1; + if (nextIndex >= items.length && loop) { + itemToFocus = items[0]; + } else { + itemToFocus = items[nextIndex]; + } + }, + [prevKey]: () => { + e.preventDefault(); + const prevIndex = currentIndex - 1; + if (prevIndex < 0 && loop) { + itemToFocus = items[items.length - 1]; + } else { + itemToFocus = items[prevIndex]; + } + }, + [kbd.HOME]: () => { + e.preventDefault(); + itemToFocus = items[0]; + }, + [kbd.END]: () => { + e.preventDefault(); + itemToFocus = items[items.length - 1]; + }, + }; + + keyMap[e.key]?.(); + + if (itemToFocus) { + itemToFocus.focus(); + this.#root.selectValue(itemToFocus.dataset.value as string); + } + }); + + get props() { + return { + ...this.#attrs, + onclick, + onkeydown, + }; + } +} + +// +// CONTEXT METHODS +// + +const RADIO_GROUP_ROOT_KEY = Symbol("RadioGroup.Root"); + +export function setRadioGroupRootState(props: RadioGroupRootStateProps) { + return setContext(RADIO_GROUP_ROOT_KEY, new RadioGroupRootState(props)); +} + +export function getRadioGroupRootState() { + verifyContextDeps(RADIO_GROUP_ROOT_KEY); + return getContext(RADIO_GROUP_ROOT_KEY); +} + +export function setRadioGroupItemState(props: RadioGroupItemStateProps) { + return getRadioGroupRootState().createItem(props); +} diff --git a/packages/bits-ui/src/lib/bits/radio-group/types.ts b/packages/bits-ui/src/lib/bits/radio-group/types.ts index f5f39aacc..e92fa47b3 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/types.ts +++ b/packages/bits-ui/src/lib/bits/radio-group/types.ts @@ -1,58 +1,92 @@ -import type { HTMLButtonAttributes, HTMLInputAttributes } from "svelte/elements"; +import type { Snippet } from "svelte"; import type { - RadioGroupItemProps as MeltRadioGroupItemProps, - CreateRadioGroupProps as MeltRadioGroupProps, -} from "@melt-ui/svelte"; -import type { - DOMElement, - Expand, - HTMLDivAttributes, - ObjectVariation, - OmitValue, + EventCallback, OnChangeFn, + PrimitiveButtonAttributes, + PrimitiveDivAttributes, + WithAsChild, } from "$lib/internal/index.js"; -import type { CustomEventHandler } from "$lib/index.js"; +import type { Orientation } from "$lib/index.js"; + +export type RadioGroupRootPropsWithoutHTML = WithAsChild<{ + /** + * The orientation of the radio group. Used to determine + * how keyboard navigation should work. + * + * @defaultValue "vertical" + */ + orientation?: Orientation; -export type RadioGroupPropsWithoutHTML = Expand< - OmitValue & { - /** - * The value of the radio group. - * You can bind this to a value to programmatically control the value. - * - * @defaultValue undefined - */ + /** + * Whether to loop around the radio items when navigating + * with the keyboard. + * + * @defaultValue true + */ + loop?: boolean; - value?: MeltRadioGroupProps["defaultValue"] & {}; + /** + * The value of the selected radio item. + * + * @defaultValue "" + */ + value?: string; - /** - * A callback function called when the value changes. - */ + /** + * The callback to call when the selected radio item changes. + */ + onValueChange?: OnChangeFn; - onValueChange?: OnChangeFn; - } & DOMElement ->; + /** + * The name to apply to the radio group's input element for + * form submission. If not provided, a hidden input will not + * be rendered and the radio group will not be part of a form. + * + * @defaultValue undefined + */ + name?: string; -export type RadioGroupInputPropsWithoutHTML = DOMElement; + /** + * Whether the radio group is disabled. + * + * @defaultValue false + */ + disabled?: boolean; -export type RadioGroupItemPropsWithoutHTML = Expand< - ObjectVariation & DOMElement ->; + /** + * Whether the radio group is required for form submission. + * If `true`, ensure you provide a `name` prop so the hidden + * input is rendered. + */ + required?: boolean; +}>; -export type RadioGroupItemIndicatorPropsWithoutHTML = DOMElement; +export type RadioGroupRootProps = RadioGroupRootPropsWithoutHTML & PrimitiveDivAttributes; -// +export type RadioGroupItemPropsWithoutHTML = WithAsChild<{ + /** + * The value of the radio item. + */ + value: string; -export type RadioGroupProps = RadioGroupPropsWithoutHTML & HTMLDivAttributes; + /** + * Whether the radio item is disabled. + * + * @defaultValue false + */ + disabled?: boolean; -export type RadioGroupInputProps = RadioGroupInputPropsWithoutHTML & HTMLInputAttributes; + onclick: EventCallback; -export type RadioGroupItemProps = RadioGroupItemPropsWithoutHTML & HTMLButtonAttributes; + onkeydown: EventCallback; -export type RadioGroupItemIndicatorProps = RadioGroupItemIndicatorPropsWithoutHTML & - HTMLDivAttributes; + /** + * A snippet to render the radio item's indicator. + * It receives the item's `checked` state as a prop + * for conditional styling. + */ + indicator?: Snippet<[{ checked: boolean }]>; +}>; -export type RadioGroupItemEvents = { - click: CustomEventHandler; - keydown: CustomEventHandler; - focus: CustomEventHandler; -}; +export type RadioGroupItemProps = RadioGroupItemPropsWithoutHTML & + Omit; diff --git a/packages/bits-ui/src/lib/internal/elements.svelte.ts b/packages/bits-ui/src/lib/internal/elements.svelte.ts new file mode 100644 index 000000000..f61ecc32f --- /dev/null +++ b/packages/bits-ui/src/lib/internal/elements.svelte.ts @@ -0,0 +1,20 @@ +import type { Box, ReadonlyBox } from "./box.svelte.js"; +import { withTick } from "./with-tick.js"; + +/** + * Given a boxed ID of a node, finds the node with that ID and sets it to the boxed node. + * Reactive using `$effect.pre` to ensure when the ID changes, an update is triggered and + * new node is found. + * + * @param id The boxed ID of the node to find. + * @param node The boxed node to set the found node to. + */ +export function useNodeById(id: ReadonlyBox | Box, node: Box) { + $effect.pre(() => { + // eslint-disable-next-line no-unused-expressions + id.value; + withTick(() => { + node.value = document.getElementById(id.value); + }); + }); +} diff --git a/packages/bits-ui/src/lib/internal/kbd.ts b/packages/bits-ui/src/lib/internal/kbd.ts index b3676de55..37df06861 100644 --- a/packages/bits-ui/src/lib/internal/kbd.ts +++ b/packages/bits-ui/src/lib/internal/kbd.ts @@ -1,3 +1,5 @@ +import type { Orientation, TextDirection } from "$lib/shared/index.js"; + export function getKbd() { return { ALT: "Alt", @@ -71,3 +73,32 @@ export const kbd = { CTRL: "Control", ASTERISK: "*", }; + +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.SPACE, kbd.ENTER]; + +export function getNextKey(dir: TextDirection = "ltr", orientation: Orientation = "horizontal") { + return { + horizontal: dir === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT, + vertical: kbd.ARROW_DOWN, + }[orientation]; +} + +export function getPrevKey(dir: TextDirection = "ltr", orientation: Orientation = "horizontal") { + return { + horizontal: dir === "rtl" ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT, + vertical: kbd.ARROW_UP, + }[orientation]; +} + +export function getDirectionalKeys( + dir: TextDirection = "ltr", + orientation: Orientation = "horizontal" +) { + return { + nextKey: getNextKey(dir, orientation), + prevKey: getPrevKey(dir, orientation), + }; +} diff --git a/packages/bits-ui/src/lib/internal/locale.ts b/packages/bits-ui/src/lib/internal/locale.ts new file mode 100644 index 000000000..7045bd97b --- /dev/null +++ b/packages/bits-ui/src/lib/internal/locale.ts @@ -0,0 +1,13 @@ +// https://github.com/melt-ui/melt-ui +import type { TextDirection } from "$lib/shared/index.js"; + +/** + * Detects the text direction in the element. + * @returns {TextDirection} The text direction ('ltr' for left-to-right or 'rtl' for right-to-left). + */ +export function getElemDirection(elem: HTMLElement): TextDirection { + const style = window.getComputedStyle(elem); + const direction = style.getPropertyValue("direction"); + + return direction as TextDirection; +} diff --git a/packages/bits-ui/src/lib/internal/types.ts b/packages/bits-ui/src/lib/internal/types.ts index fe9a3b0b7..53c3c40fc 100644 --- a/packages/bits-ui/src/lib/internal/types.ts +++ b/packages/bits-ui/src/lib/internal/types.ts @@ -121,8 +121,7 @@ export type TransitionProps< outTransitionConfig?: TransitionParams; }>; -export type Primitive = Omit; - +type Primitive = Omit & { id?: string }; export type PrimitiveButtonAttributes = Primitive; export type PrimitiveDivAttributes = Primitive; export type PrimitiveInputAttributes = Primitive; @@ -163,7 +162,3 @@ export type WithAsChild = {}> = * // Result type will be { a: number; } */ export type Without = Omit; - -export type ElementRef = { value?: HTMLElement | null | undefined }; - -export type Ref = { value: T }; diff --git a/packages/bits-ui/src/lib/internal/with-tick.ts b/packages/bits-ui/src/lib/internal/with-tick.ts new file mode 100644 index 000000000..6b0143674 --- /dev/null +++ b/packages/bits-ui/src/lib/internal/with-tick.ts @@ -0,0 +1,5 @@ +import { tick } from "svelte"; + +export function withTick(cb: () => void) { + tick().then(cb); +} diff --git a/packages/bits-ui/src/lib/shared/index.ts b/packages/bits-ui/src/lib/shared/index.ts index 5c3ead93b..8d9b5facb 100644 --- a/packages/bits-ui/src/lib/shared/index.ts +++ b/packages/bits-ui/src/lib/shared/index.ts @@ -28,4 +28,7 @@ export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement | null) => Focus export type StyleProperties = CSS.PropertiesHyphen; +export type Orientation = "horizontal" | "vertical"; +export type TextDirection = "ltr" | "rtl"; + export type { Month, Page, PageItem, Ellipsis, EditableSegmentPart };