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 b6885d227..43ae8dcc8 100644 --- a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts +++ b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts @@ -1,4 +1,4 @@ -import { getContext, onMount, setContext, tick, untrack } from "svelte"; +import { getContext, setContext, tick, untrack } from "svelte"; import { type Box, type BoxedValues, @@ -13,7 +13,6 @@ import { getDataOpenClosed, kbd, readonlyBox, - styleToString, verifyContextDeps, } from "$lib/internal/index.js"; import type { StyleProperties } from "$lib/shared/index.js"; @@ -26,15 +25,10 @@ type AccordionBaseStateProps = ReadonlyBoxedValues<{ disabled: boolean; }>; -interface AccordionRootAttrs { - id: string; - "data-accordion-root": string; -} - class AccordionBaseState { id = undefined as unknown as ReadonlyBox; disabled: ReadonlyBox; - #attrs: AccordionRootAttrs = $derived({ + #attrs = $derived({ id: this.id.value, "data-accordion-root": "", } as const); @@ -76,9 +70,8 @@ export class AccordionSingleState extends AccordionBaseState { /** * MULTIPLE */ -interface AccordionMultiStateProps extends AccordionBaseStateProps { - value: Box; -} + +type AccordionMultiStateProps = AccordionBaseStateProps & BoxedValues<{ value: string[] }>; export class AccordionMultiState extends AccordionBaseState { #value: Box; @@ -116,7 +109,7 @@ type AccordionItemStateProps = ReadonlyBoxedValues<{ export class AccordionItemState { #value: ReadonlyBox; disabled = undefined as unknown as ReadonlyBox; - root: AccordionState = undefined as unknown as AccordionState; + root = undefined as unknown as AccordionState; isSelected = $derived(this.root.includesItem(this.value)); isDisabled = $derived(this.disabled.value || this.root.disabled.value); #attrs = $derived({ @@ -164,60 +157,60 @@ type AccordionTriggerStateProps = ReadonlyBoxedValues<{ }>; class AccordionTriggerState { - disabled = undefined as unknown as ReadonlyBox; - id = undefined as unknown as ReadonlyBox; - root = undefined as unknown as AccordionState; - itemState = undefined as unknown as AccordionItemState; - onclickProp = boxedState(readonlyBox(() => () => {})); - onkeydownProp = boxedState( + #disabled = undefined as unknown as ReadonlyBox; + #id = undefined as unknown as ReadonlyBox; + #root = undefined as unknown as AccordionState; + #itemState = undefined as unknown as AccordionItemState; + #onclickProp = boxedState(readonlyBox(() => () => {})); + #onkeydownProp = boxedState( readonlyBox(() => () => {}) ); // Disabled if the trigger itself, the item it belongs to, or the root is disabled - isDisabled = $derived( - this.disabled.value || this.itemState.disabled.value || this.root.disabled.value + #isDisabled = $derived( + this.#disabled.value || this.#itemState.disabled.value || this.#root.disabled.value ); - #attrs: Record = $derived({ - id: this.id.value, - disabled: this.isDisabled, - "aria-expanded": getAriaExpanded(this.itemState.isSelected), - "aria-disabled": getAriaDisabled(this.isDisabled), - "data-disabled": getDataDisabled(this.isDisabled), - "data-value": this.itemState.value, - "data-state": getDataOpenClosed(this.itemState.isSelected), + #attrs = $derived({ + id: this.#id.value, + disabled: this.#isDisabled, + "aria-expanded": getAriaExpanded(this.#itemState.isSelected), + "aria-disabled": getAriaDisabled(this.#isDisabled), + "data-disabled": getDataDisabled(this.#isDisabled), + "data-value": this.#itemState.value, + "data-state": getDataOpenClosed(this.#itemState.isSelected), "data-accordion-trigger": "", } as const); constructor(props: AccordionTriggerStateProps, itemState: AccordionItemState) { - this.disabled = props.disabled; - this.itemState = itemState; - this.root = itemState.root; - this.onclickProp.value = props.onclick; - this.onkeydownProp.value = props.onkeydown; - this.id = props.id; + this.#disabled = props.disabled; + this.#itemState = itemState; + this.#root = itemState.root; + this.#onclickProp.value = props.onclick; + this.#onkeydownProp.value = props.onkeydown; + this.#id = props.id; } - onclick = composeHandlers(this.onclickProp, () => { - if (this.isDisabled) return; - this.itemState.updateValue(); + #onclick = composeHandlers(this.#onclickProp, () => { + if (this.#isDisabled) return; + this.#itemState.updateValue(); }); - onkeydown = composeHandlers(this.onkeydownProp, (e: KeyboardEvent) => { + #onkeydown = composeHandlers(this.#onkeydownProp, (e: KeyboardEvent) => { const handledKeys = [kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.HOME, kbd.END, kbd.SPACE, kbd.ENTER]; - if (this.isDisabled || !handledKeys.includes(e.key)) return; + if (this.#isDisabled || !handledKeys.includes(e.key)) return; e.preventDefault(); if (e.key === kbd.SPACE || e.key === kbd.ENTER) { - this.itemState.updateValue(); + this.#itemState.updateValue(); return; } - if (!this.root.id.value || !this.id.value) return; + if (!this.#root.id.value || !this.#id.value) return; - const rootEl = document.getElementById(this.root.id.value); + const rootEl = document.getElementById(this.#root.id.value); if (!rootEl) return; - const itemEl = document.getElementById(this.id.value); + const itemEl = document.getElementById(this.#id.value); if (!itemEl) return; const items = Array.from(rootEl.querySelectorAll("[data-accordion-trigger]")); @@ -241,8 +234,8 @@ class AccordionTriggerState { get props() { return { ...this.#attrs, - onclick: this.onclick, - onkeydown: this.onkeydown, + onclick: this.#onclick, + onkeydown: this.#onkeydown, }; } } @@ -260,16 +253,14 @@ type AccordionContentStateProps = BoxedValues<{ class AccordionContentState { item = undefined as unknown as AccordionItemState; - originalStyles = boxedState<{ transitionDuration: string; animationName: string } | undefined>( - undefined - ); - isMountAnimationPrevented = $state(false); + originalStyles: { transitionDuration: string; animationName: string } | undefined = undefined; + isMountAnimationPrevented = false; width = boxedState(0); height = boxedState(0); - presentEl: Box = boxedState(undefined); + presentEl = boxedState(undefined); forceMount = undefined as unknown as ReadonlyBox; present = $derived(this.item.isSelected); - #attrs: Record = $derived({ + #attrs = $derived({ "data-state": getDataOpenClosed(this.item.isSelected), "data-disabled": getDataDisabled(this.item.isDisabled), "data-value": this.item.value, @@ -305,7 +296,7 @@ class AccordionContentState { tick().then(() => { // get the dimensions of the element - this.originalStyles.value = this.originalStyles.value || { + this.originalStyles = this.originalStyles || { transitionDuration: node.style.transitionDuration, animationName: node.style.animationName, }; @@ -319,8 +310,8 @@ class AccordionContentState { this.width.value = rect.width; // unblock any animations/transitions that were originally set if not the initial render - if (!untrack(() => this.isMountAnimationPrevented)) { - const { animationName, transitionDuration } = this.originalStyles.value; + if (!this.isMountAnimationPrevented) { + const { animationName, transitionDuration } = this.originalStyles; node.style.transitionDuration = transitionDuration; node.style.animationName = animationName; } @@ -337,8 +328,8 @@ class AccordionContentState { * CONTEXT METHODS */ -export const ACCORDION_ROOT_KEY = "Accordion.Root"; -export const ACCORDION_ITEM_KEY = "Accordion.Item"; +export const ACCORDION_ROOT_KEY = Symbol("Accordion.Root"); +export const ACCORDION_ITEM_KEY = Symbol("Accordion.Item"); type AccordionState = AccordionSingleState | AccordionMultiState; @@ -350,23 +341,16 @@ type InitAccordionProps = { }; export function setAccordionRootState(props: InitAccordionProps) { - if (props.type === "single") { - const { value, type, ...rest } = props; - return setContext( - ACCORDION_ROOT_KEY, - new AccordionSingleState({ ...rest, value: value as Box }) - ); - } else { - const { value, type, ...rest } = props; - return setContext( - ACCORDION_ROOT_KEY, - new AccordionMultiState({ ...rest, value: value as Box }) - ); - } + const { type, ...rest } = props; + const rootState = + type === "single" + ? new AccordionSingleState(rest as AccordionSingleStateProps) + : new AccordionMultiState(rest as AccordionMultiStateProps); + return setContext(ACCORDION_ROOT_KEY, rootState); } -export function getAccordionRootState(): AccordionState { - return getContext(ACCORDION_ROOT_KEY); +export function getAccordionRootState() { + return getContext(ACCORDION_ROOT_KEY); } export function setAccordionItemState(props: Omit) { @@ -377,8 +361,8 @@ export function setAccordionItemState(props: Omit(ACCORDION_ITEM_KEY); } export function getAccordionTriggerState(props: AccordionTriggerStateProps): AccordionTriggerState { diff --git a/packages/bits-ui/src/lib/bits/avatar/avatar.svelte.ts b/packages/bits-ui/src/lib/bits/avatar/avatar.svelte.ts index 314ae49c4..054d29135 100644 --- a/packages/bits-ui/src/lib/bits/avatar/avatar.svelte.ts +++ b/packages/bits-ui/src/lib/bits/avatar/avatar.svelte.ts @@ -19,11 +19,6 @@ type AvatarRootStateProps = { style: ReadonlyBox; }; -interface AvatarRootAttrs { - "data-avatar-root": string; - "data-status": ImageLoadingStatus; -} - type AvatarImageSrc = string | null | undefined; class AvatarRootState { @@ -31,13 +26,11 @@ class AvatarRootState { delayMs: ReadonlyBox; loadingStatus = undefined as unknown as Box; styleProp = undefined as unknown as ReadonlyBox; - #attrs: AvatarRootAttrs = $derived({ + #attrs = $derived({ "data-avatar-root": "", "data-status": this.loadingStatus.value, style: styleToString(this.styleProp.value), - }); - - #imageTimerId: NodeJS.Timeout | undefined = undefined; + } as const); constructor(props: AvatarRootStateProps) { this.delayMs = props.delayMs; @@ -45,28 +38,24 @@ class AvatarRootState { $effect.pre(() => { if (!this.src.value) return; - this.#loadImage(this.src.value); + return this.#loadImage(this.src.value); }); } #loadImage(src: string) { - // clear any existing timers before creating a new one - clearTimeout(this.#imageTimerId); + let imageTimerId: NodeJS.Timeout; const image = new Image(); image.src = src; + this.loadingStatus.value = "loading"; image.onload = () => { - // if its 0 then we don't need to add a delay - if (this.delayMs.value !== 0) { - this.#imageTimerId = setTimeout(() => { - this.loadingStatus.value = "loaded"; - }, this.delayMs.value); - } else { + imageTimerId = setTimeout(() => { this.loadingStatus.value = "loaded"; - } + }, this.delayMs.value); }; image.onerror = () => { this.loadingStatus.value = "error"; }; + return () => clearTimeout(imageTimerId); } createImage(props: AvatarImageStateProps) { @@ -86,12 +75,6 @@ class AvatarRootState { * IMAGE */ -interface AvatarImageAttrs { - style: string; - src: AvatarImageSrc; - "data-avatar-image": string; -} - type AvatarImageStateProps = ReadonlyBoxedValues<{ src: AvatarImageSrc; style: StyleProperties; @@ -100,7 +83,7 @@ type AvatarImageStateProps = ReadonlyBoxedValues<{ class AvatarImageState { root = undefined as unknown as AvatarRootState; styleProp = undefined as unknown as ReadonlyBox; - #attrs: AvatarImageAttrs = $derived({ + #attrs = $derived({ style: styleToString({ ...this.styleProp.value, display: this.root.loadingStatus.value === "loaded" ? "block" : "none", @@ -124,11 +107,6 @@ class AvatarImageState { * FALLBACK */ -interface AvatarFallbackAttrs { - style: string; - "data-avatar-fallback": string; -} - type AvatarFallbackStateProps = ReadonlyBoxedValues<{ style: StyleProperties; }>; @@ -136,7 +114,7 @@ type AvatarFallbackStateProps = ReadonlyBoxedValues<{ class AvatarFallbackState { root = undefined as unknown as AvatarRootState; styleProp = undefined as unknown as ReadonlyBox; - #attrs: AvatarFallbackAttrs = $derived({ + #attrs = $derived({ style: styleToString({ ...this.styleProp.value, display: this.root.loadingStatus.value === "loaded" ? "none" : "block", diff --git a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts index 189d44eef..a99ce0bde 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts @@ -14,7 +14,6 @@ import { } from "$lib/internal/box.svelte.js"; import { type EventCallback, composeHandlers } from "$lib/internal/events.js"; import { kbd } from "$lib/internal/kbd.js"; -import type { StyleProperties } from "$lib/shared/index.js"; type CheckboxRootStateProps = ReadonlyBoxedValues<{ disabled: boolean; @@ -41,8 +40,8 @@ class CheckboxRootState { required = undefined as unknown as ReadonlyBox; name: ReadonlyBox; value: ReadonlyBox; - onclickProp = boxedState(readonlyBox(() => () => {})); - onkeydownProp = boxedState(readonlyBox(() => () => {})); + #onclickProp = boxedState(readonlyBox(() => () => {})); + #onkeydownProp = boxedState(readonlyBox(() => () => {})); #attrs = $derived({ "data-disabled": getDataDisabled(this.disabled.value), "data-state": getCheckboxDataState(this.checked.value), @@ -60,15 +59,15 @@ class CheckboxRootState { this.required = props.required; this.name = props.name; this.value = props.value; - this.onclickProp.value = props.onclick; - this.onkeydownProp.value = props.onkeydown; + this.#onclickProp.value = props.onclick; + this.#onkeydownProp.value = props.onkeydown; } - onkeydown = composeHandlers(this.onkeydownProp, (e) => { + #onkeydown = composeHandlers(this.#onkeydownProp, (e) => { if (e.key === kbd.ENTER) e.preventDefault(); }); - onclick = composeHandlers(this.onclickProp, () => { + #onclick = composeHandlers(this.#onclickProp, () => { if (this.disabled.value) return; if (this.checked.value === "indeterminate") { this.checked.value = true; @@ -88,8 +87,8 @@ class CheckboxRootState { get props() { return { ...this.#attrs, - onclick: this.onclick, - onkeydown: this.onkeydown, + onclick: this.#onclick, + onkeydown: this.#onkeydown, }; } } @@ -145,7 +144,7 @@ class CheckboxInputState { * CONTEXT METHODS */ -export const CHECKBOX_ROOT_KEY = "Checkbox.Root"; +export const CHECKBOX_ROOT_KEY = Symbol("Checkbox.Root"); export function setCheckboxRootState(props: CheckboxRootStateProps) { return setContext(CHECKBOX_ROOT_KEY, new CheckboxRootState(props)); 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 4ed8fbeaa..a583844a1 100644 --- a/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts +++ b/packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts @@ -63,14 +63,12 @@ type CollapsibleContentStateProps = BoxedValues<{ class CollapsibleContentState { root = undefined as unknown as CollapsibleRootState; - currentStyle = boxedState<{ transitionDuration: string; animationName: string } | undefined>( - undefined - ); + currentStyle = $state<{ transitionDuration: string; animationName: string }>(); styleProp = undefined as unknown as ReadonlyBox; - isMountAnimationPrevented = $state(false); - width = boxedState(0); - height = boxedState(0); - presentEl: Box = boxedState(undefined); + #isMountAnimationPrevented = $state(false); + #width = $state(0); + #height = $state(0); + #presentEl: Box; present = $derived(this.root.open); #attrs = $derived({ id: this.root.contentId.value, @@ -79,35 +77,31 @@ class CollapsibleContentState { "data-collapsible-content": "", style: styleToString({ ...this.styleProp.value, - "--bits-collapsible-content-height": this.height.value - ? `${this.height.value}px` - : undefined, - "--bits-collapsible-content-width": this.width.value - ? `${this.width.value}px` - : undefined, + "--bits-collapsible-content-height": this.#height ? `${this.#height}px` : undefined, + "--bits-collapsible-content-width": this.#width ? `${this.#width}px` : undefined, }), } as const); constructor(props: CollapsibleContentStateProps, root: CollapsibleRootState) { this.root = root; - this.isMountAnimationPrevented = root.open.value; - this.presentEl = props.presentEl; + this.#isMountAnimationPrevented = root.open.value; + this.#presentEl = props.presentEl; this.root.contentId = props.id; this.styleProp = props.style; onMount(() => { requestAnimationFrame(() => { - this.isMountAnimationPrevented = false; + this.#isMountAnimationPrevented = false; }); }); $effect.pre(() => { // eslint-disable-next-line no-unused-expressions this.root.open.value; - const node = this.presentEl.value; + const node = this.#presentEl.value; if (!node) return; - this.currentStyle.value = this.currentStyle.value || { + this.currentStyle = this.currentStyle || { transitionDuration: node.style.transitionDuration, animationName: node.style.animationName, }; @@ -118,12 +112,12 @@ class CollapsibleContentState { // get the dimensions of the element const rect = node.getBoundingClientRect(); - this.height.value = rect.height; - this.width.value = rect.width; + this.#height = rect.height; + this.#width = rect.width; // unblock any animations/transitions that were originally set if not the initial render - if (!this.isMountAnimationPrevented) { - const { animationName, transitionDuration } = this.currentStyle.value; + if (!this.#isMountAnimationPrevented) { + const { animationName, transitionDuration } = this.currentStyle; node.style.transitionDuration = transitionDuration; node.style.animationName = animationName; } @@ -140,44 +134,44 @@ type CollapsibleTriggerStateProps = ReadonlyBoxedValues<{ }>; class CollapsibleTriggerState { - root = undefined as unknown as CollapsibleRootState; - onclickProp = boxedState(readonlyBox(() => () => {})); + #root = undefined as unknown as CollapsibleRootState; + #onclickProp = boxedState(readonlyBox(() => () => {})); #attrs = $derived({ type: "button", - "aria-controls": this.root.contentId.value, - "aria-expanded": getAriaExpanded(this.root.open.value), - "data-state": getDataOpenClosed(this.root.open.value), - "data-disabled": getDataDisabled(this.root.disabled.value), - disabled: this.root.disabled.value, + "aria-controls": this.#root.contentId.value, + "aria-expanded": getAriaExpanded(this.#root.open.value), + "data-state": getDataOpenClosed(this.#root.open.value), + "data-disabled": getDataDisabled(this.#root.disabled.value), + disabled: this.#root.disabled.value, "data-collapsible-trigger": "", } as const); constructor(props: CollapsibleTriggerStateProps, root: CollapsibleRootState) { - this.root = root; - this.onclickProp.value = props.onclick; + this.#root = root; + this.#onclickProp.value = props.onclick; } - onclick = composeHandlers(this.onclickProp, () => { - this.root.toggleOpen(); + #onclick = composeHandlers(this.#onclickProp, () => { + this.#root.toggleOpen(); }); get props() { return { ...this.#attrs, - onclick: this.onclick, + onclick: this.#onclick, }; } } -export const COLLAPSIBLE_ROOT_KEY = "Collapsible.Root"; +export const COLLAPSIBLE_ROOT_KEY = Symbol("Collapsible.Root"); export function setCollapsibleRootState(props: CollapsibleRootStateProps) { return setContext(COLLAPSIBLE_ROOT_KEY, new CollapsibleRootState(props)); } -export function getCollapsibleRootState(): CollapsibleRootState { - return getContext(COLLAPSIBLE_ROOT_KEY); +export function getCollapsibleRootState() { + return getContext(COLLAPSIBLE_ROOT_KEY); } export function getCollapsibleTriggerState( diff --git a/packages/bits-ui/src/lib/internal/context.ts b/packages/bits-ui/src/lib/internal/context.ts index 6d84590f7..40eacc405 100644 --- a/packages/bits-ui/src/lib/internal/context.ts +++ b/packages/bits-ui/src/lib/internal/context.ts @@ -1,12 +1,15 @@ -import { getAllContexts } from "svelte"; +import { hasContext } from "svelte"; import { DEV } from "esm-env"; -export function verifyContextDeps(...deps: string[]) { +export function verifyContextDeps(...deps: Array) { if (DEV) { - const ctx = getAllContexts(); - const missing = deps.filter((dep) => !ctx.has(dep)); - if (missing.length > 0) { - // TODO: symbols break our ability to show the name of the missing context. :/ + const missing: string[] = []; + for (const dep of deps) { + if (hasContext(dep)) continue; + const depLabel = typeof dep === "symbol" ? dep.description : dep; + missing.push(depLabel!); + } + if (missing.length) { throw new Error(`Missing context dependencies: ${missing.join(", ")}`); } }