- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
index 53bba18af..b30075674 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
@@ -7,7 +7,7 @@
value,
children,
child,
- el,
+ el = $bindable(),
...restProps
}: AccordionItemProps = $props();
@@ -25,17 +25,16 @@
const mergedProps = $derived({
...restProps,
+ ...item.props,
"data-state": item.isSelected ? "open" : "closed",
"data-disabled": isDisabled ? "" : undefined,
});
-{#if asChild && child}
- {@render child(mergedProps)}
+{#if asChild}
+ {@render child?.(mergedProps)}
{:else}
- {#if children}
- {@render children()}
- {/if}
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
index 8832b41fc..7de657eca 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
@@ -6,6 +6,7 @@
disabled = false,
asChild,
el,
+ id,
onkeydown = undefined,
onclick = undefined,
children,
@@ -23,7 +24,9 @@
trigger.disabled = disabled;
});
$effect.pre(() => {
- trigger.el = el;
+ if (id) {
+ trigger.id = id;
+ }
});
$effect.pre(() => {
trigger.handlers.click = onclick;
@@ -38,12 +41,10 @@
});
-{#if asChild && child}
- {@render child(mergedProps)}
+{#if asChild}
+ {@render child?.(mergedProps)}
{:else}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte
index e15bb9b6a..6743ffc98 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte
@@ -10,11 +10,13 @@
child,
type,
value = $bindable(),
- el,
+ el = $bindable(),
+ id,
+ onValueChange,
...restProps
}: AccordionRootProps = $props();
- const rootState = setAccordionRootState({ type, value });
+ const rootState = setAccordionRootState({ type, value, id, onValueChange });
$effect.pre(() => {
if (value !== undefined) {
@@ -25,7 +27,9 @@
value = rootState.value;
});
$effect.pre(() => {
- rootState.el = el;
+ if (id) {
+ rootState.id = id;
+ }
});
$effect.pre(() => {
rootState.disabled = disabled;
@@ -35,12 +39,10 @@
});
-{#if asChild && child}
- {@render child(restProps)}
+{#if asChild}
+ {@render child?.(restProps)}
{:else}
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/accordion/ctx.ts b/packages/bits-ui/src/lib/bits/accordion/ctx.ts
deleted file mode 100644
index bc1cdca31..000000000
--- a/packages/bits-ui/src/lib/bits/accordion/ctx.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { type CreateAccordionProps, createAccordion } from "@melt-ui/svelte";
-import { getContext, setContext } from "svelte";
-import type { ItemProps } from "./index.js";
-import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js";
-
-function getAccordionData() {
- const NAME = "accordion" as const;
- const ITEM_NAME = "accordion-item";
- const PARTS = ["root", "content", "header", "item", "trigger"] as const;
-
- return { NAME, ITEM_NAME, PARTS };
-}
-
-export function setCtx
(props: CreateAccordionProps) {
- const initAccordion = createAccordion(removeUndefined(props));
- const { NAME, PARTS } = getAccordionData();
- const getAttrs = createBitAttrs(NAME, PARTS);
- const accordion = {
- ...initAccordion,
- getAttrs,
- updateOption: getOptionUpdater(initAccordion.options),
- };
-
- setContext(NAME, accordion);
- return accordion;
-}
-
-export function getCtx() {
- const { NAME } = getAccordionData();
- return getContext>(NAME);
-}
-
-export function setItem(props: ItemProps) {
- const { ITEM_NAME } = getAccordionData();
- setContext(ITEM_NAME, { ...props });
- const ctx = getCtx();
- return { ...ctx, props };
-}
-
-export function getItemProps() {
- const { ITEM_NAME } = getAccordionData();
- return getContext(ITEM_NAME);
-}
-
-export function getContent() {
- const ctx = getCtx();
- const { value: props } = getItemProps();
- return {
- ...ctx,
- props,
- };
-}
-
-export function getTrigger() {
- const ctx = getCtx();
- const { value, disabled } = getItemProps();
- return {
- ...ctx,
- props: { value, disabled },
- };
-}
diff --git a/packages/bits-ui/src/lib/bits/accordion/state.svelte.ts b/packages/bits-ui/src/lib/bits/accordion/state.svelte.ts
index 07b60a239..9fcdfff96 100644
--- a/packages/bits-ui/src/lib/bits/accordion/state.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/accordion/state.svelte.ts
@@ -4,6 +4,7 @@ import {
type OnChangeFn,
composeHandlers,
dataDisabledAttrs,
+ generateId,
kbd,
openClosedAttrs,
verifyContextDeps,
@@ -13,21 +14,34 @@ import {
* BASE
*/
interface AccordionBaseStateProps {
- el?: HTMLElement | null;
+ id?: string | null;
disabled?: boolean;
forceVisible?: boolean;
}
+interface AccordionRootAttrs {
+ id: string;
+ "data-accordion-root": string;
+}
+
class AccordionBaseState {
- el: HTMLElement | null | undefined = $state(null);
+ id: string = $state(generateId());
disabled: boolean = $state(false);
forceVisible: boolean = $state(false);
+ attrs: AccordionRootAttrs = $derived({
+ id: this.id,
+ "data-accordion-root": "",
+ });
constructor(props: AccordionBaseStateProps) {
- this.el = props.el ?? this.el;
+ this.id = props.id ?? this.id;
this.disabled = props.disabled ?? this.disabled;
this.forceVisible = props.forceVisible ?? this.forceVisible;
}
+
+ get props() {
+ return this.attrs;
+ }
}
/**
@@ -37,6 +51,7 @@ class AccordionBaseState {
interface AccordionSingleStateProps extends AccordionBaseStateProps {
value?: string;
onValueChange?: OnChangeFn;
+ id?: string | null;
}
export class AccordionSingleState extends AccordionBaseState {
@@ -91,11 +106,18 @@ type AccordionItemStateProps = {
rootState: AccordionState;
};
+interface AccordionItemAttrs {
+ "data-accordion-item": string;
+}
+
export class AccordionItemState {
value: string = $state("");
disabled: boolean = $state(false);
isSelected: boolean = $state(false);
root: AccordionState;
+ attrs: AccordionItemAttrs = {
+ "data-accordion-item": "",
+ };
constructor(props: AccordionItemStateProps) {
this.value = props.value;
@@ -127,6 +149,10 @@ export class AccordionItemState {
}
}
+ get props() {
+ return this.attrs;
+ }
+
createTrigger(props: AccordionTriggerStateProps) {
return new AccordionTriggerState(props, this);
}
@@ -143,29 +169,29 @@ export class AccordionItemState {
type AccordionTriggerStateProps = {
onclick?: (e: MouseEvent) => void;
onkeydown?: (e: KeyboardEvent) => void;
- disabled: boolean;
+ disabled?: boolean;
+ id?: string;
};
-const defaultAccordionTriggerProps = {
- disabled: false,
- el: null,
- handlers: {
- click: undefined,
- keydown: undefined,
- },
+interface AccordionTriggerHandlers {
+ click?: EventCallback;
+ keydown?: EventCallback;
+}
+
+const defaultAccordionTriggerHandlers: AccordionTriggerHandlers = {
+ click: () => {},
+ keydown: () => {},
};
class AccordionTriggerState {
disabled: boolean = $state(false);
- el: HTMLElement | null | undefined = $state();
+ id: string = $state(generateId());
root: AccordionState = undefined as unknown as AccordionState;
itemState: AccordionItemState = undefined as unknown as AccordionItemState;
- handlers: {
- click: EventCallback | undefined;
- keydown: EventCallback | undefined;
- } = $state(defaultAccordionTriggerProps.handlers);
+ handlers: AccordionTriggerHandlers = $state(defaultAccordionTriggerHandlers);
isDisabled: boolean = $state(false);
attrs: Record = $derived({
+ id: this.id,
disabled: this.disabled,
"aria-expanded": this.itemState.isSelected ? "true" : "false",
"aria-disabled": this.isDisabled ? "true" : "false",
@@ -179,8 +205,9 @@ class AccordionTriggerState {
this.disabled = props.disabled || itemState.disabled || itemState.root.disabled;
this.itemState = itemState;
this.root = itemState.root;
- this.handlers.click = props.onclick;
- this.handlers.keydown = props.onkeydown;
+ this.handlers.click = props.onclick ?? this.onclick;
+ this.handlers.keydown = props.onkeydown ?? this.onkeydown;
+ this.id = props.id ?? this.id;
$effect(() => {
this.isDisabled = this.disabled || this.itemState.disabled || this.root.disabled;
@@ -198,22 +225,25 @@ class AccordionTriggerState {
e.preventDefault();
- if ([kbd.SPACE, kbd.ENTER].includes(e.key)) {
+ if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
this.itemState.updateValue();
return;
}
- if (!this.root.el || !this.el) return;
+ if (!this.root.id || !this.id) return;
+
+ const rootEl = document.getElementById(this.root.id);
+ if (!rootEl) return;
+ const itemEl = document.getElementById(this.id);
+ if (!itemEl) return;
- const items = Array.from(
- this.root.el.querySelectorAll("[data-accordion-trigger]")
- );
+ const items = Array.from(rootEl.querySelectorAll("[data-accordion-trigger]"));
if (!items.length) return;
const candidateItems = items.filter((item) => !item.dataset.disabled);
if (!candidateItems.length) return;
- const currentIndex = candidateItems.indexOf(this.el);
+ const currentIndex = candidateItems.indexOf(itemEl);
switch (e.key) {
case kbd.ARROW_DOWN:
@@ -275,13 +305,23 @@ type AccordionState = AccordionSingleState | AccordionMultiState;
type InitAccordionProps = {
type: "single" | "multiple";
value?: string | string[];
+ id?: string | null;
+ onValueChange?: OnChangeFn | OnChangeFn;
};
export function setAccordionRootState(props: InitAccordionProps) {
const rootState =
props.type === "single"
- ? new AccordionSingleState({ value: props.value as string })
- : new AccordionMultiState({ value: props.value as string[] });
+ ? new AccordionSingleState({
+ value: props.value as string,
+ id: props.id,
+ onValueChange: props.onValueChange as OnChangeFn,
+ })
+ : new AccordionMultiState({
+ value: props.value as string[],
+ id: props.id,
+ onValueChange: props.onValueChange as OnChangeFn,
+ });
setContext(ACCORDION_ROOT_KEY, rootState);
return rootState;
}
diff --git a/packages/bits-ui/src/lib/bits/avatar/components/avatar-fallback.svelte b/packages/bits-ui/src/lib/bits/avatar/components/avatar-fallback.svelte
index 3f6100e50..3db643a3f 100644
--- a/packages/bits-ui/src/lib/bits/avatar/components/avatar-fallback.svelte
+++ b/packages/bits-ui/src/lib/bits/avatar/components/avatar-fallback.svelte
@@ -1,27 +1,16 @@
{#if asChild}
-
+ {@render child?.(restProps)}
{:else}
-
-
+
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/avatar/components/avatar-image.svelte b/packages/bits-ui/src/lib/bits/avatar/components/avatar-image.svelte
index 63c83ce52..e77f31d4f 100644
--- a/packages/bits-ui/src/lib/bits/avatar/components/avatar-image.svelte
+++ b/packages/bits-ui/src/lib/bits/avatar/components/avatar-image.svelte
@@ -1,27 +1,14 @@
{#if asChild}
-
+ {@render child?.({ src, alt, ...restProps })}
{:else}
-
+
{/if}
diff --git a/packages/bits-ui/src/lib/bits/avatar/components/avatar.svelte b/packages/bits-ui/src/lib/bits/avatar/components/avatar.svelte
index 75e92ae7b..f9c252acf 100644
--- a/packages/bits-ui/src/lib/bits/avatar/components/avatar.svelte
+++ b/packages/bits-ui/src/lib/bits/avatar/components/avatar.svelte
@@ -1,38 +1,37 @@
{#if asChild}
-
+ {@render child?.(restProps)}
{:else}
-
-
+
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/bits/avatar/ctx.ts b/packages/bits-ui/src/lib/bits/avatar/ctx.ts
deleted file mode 100644
index 651450afd..000000000
--- a/packages/bits-ui/src/lib/bits/avatar/ctx.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { type Avatar as AvatarReturn, type CreateAvatarProps, createAvatar } from "@melt-ui/svelte";
-import { getContext, setContext } from "svelte";
-import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js";
-
-export function getAvatarData() {
- const NAME = "avatar" as const;
- const PARTS = ["root", "image", "fallback"] as const;
-
- return {
- NAME,
- PARTS,
- };
-}
-
-type GetReturn = Omit
, "updateOption">;
-
-export function setCtx(props: CreateAvatarProps) {
- const { NAME, PARTS } = getAvatarData();
- const getAttrs = createBitAttrs(NAME, PARTS);
- const avatar = { ...createAvatar(removeUndefined(props)), getAttrs };
-
- setContext(NAME, avatar);
- return {
- ...avatar,
- updateOption: getOptionUpdater(avatar.options),
- };
-}
-
-export function getImage(src: string | undefined | null = "") {
- const { NAME } = getAvatarData();
- const avatar = getContext(NAME);
- if (!src) {
- avatar.options.src.set("");
- } else {
- avatar.options.src.set(src);
- }
- return avatar;
-}
-
-export function getCtx() {
- const { NAME } = getAvatarData();
- return getContext(NAME);
-}
diff --git a/packages/bits-ui/src/lib/bits/avatar/state.svelte.ts b/packages/bits-ui/src/lib/bits/avatar/state.svelte.ts
index e69de29bb..e90376acc 100644
--- a/packages/bits-ui/src/lib/bits/avatar/state.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/avatar/state.svelte.ts
@@ -0,0 +1,140 @@
+import { getContext, setContext } from "svelte";
+import type { AvatarImageLoadingStatus } from "./types.js";
+import type { OnChangeFn } from "$lib/internal/types.js";
+import { styleToString } from "$lib/internal/style.js";
+
+/**
+ * ROOT
+ */
+interface AvatarStateProps {
+ delayMs?: number;
+ loadingStatus?: AvatarImageLoadingStatus;
+ onLoadingStatusChange?: OnChangeFn;
+}
+
+interface AvatarRootAttrs {
+ "data-avatar-root": string;
+}
+
+type AvatarImageSrc = string | null | undefined;
+
+class AvatarRootState {
+ src: AvatarImageSrc = $state(null);
+ delayMs: number = $state(0);
+ loadingStatus: AvatarImageLoadingStatus = $state("loading");
+ onLoadingStatusChange: AvatarStateProps["onLoadingStatusChange"] = $state(() => {});
+ attrs: AvatarRootAttrs = {
+ "data-avatar-root": "",
+ };
+
+ #imageTimerId: number = 0;
+
+ constructor(props: AvatarStateProps) {
+ this.delayMs = props.delayMs ?? this.delayMs;
+ this.onLoadingStatusChange = props.onLoadingStatusChange ?? this.onLoadingStatusChange;
+
+ $effect.pre(() => {
+ if (!this.src) return;
+ this.#loadImage(this.src);
+ });
+ }
+
+ #loadImage(src: string) {
+ // clear any existing timers before creating a new one
+ window.clearTimeout(this.#imageTimerId);
+ const image = new Image();
+ image.src = src;
+ image.onload = () => {
+ // if its 0 then we don't need to add a delay
+ if (this.delayMs !== 0) {
+ this.#imageTimerId = window.setTimeout(() => {
+ this.loadingStatus = "loaded";
+ }, this.delayMs);
+ } else {
+ this.loadingStatus = "loaded";
+ }
+ };
+ image.onerror = () => {
+ this.loadingStatus = "error";
+ };
+ }
+
+ createImage(src: AvatarImageSrc) {
+ return new AvatarImageState(src, this);
+ }
+
+ createFallback() {
+ return new AvatarFallbackState(this);
+ }
+}
+
+/**
+ * IMAGE
+ */
+
+interface AvatarImageAttrs {
+ style: string;
+ "data-avatar-image": string;
+}
+
+class AvatarImageState {
+ root: AvatarRootState = undefined as unknown as AvatarRootState;
+ attrs: AvatarImageAttrs = $derived({
+ style: styleToString({
+ display: this.root.loadingStatus === "loaded" ? "block" : "none",
+ }),
+ "data-avatar-image": "",
+ });
+
+ constructor(src: AvatarImageSrc, root: AvatarRootState) {
+ this.root = root;
+ root.src = src;
+ }
+}
+
+/**
+ * FALLBACK
+ */
+
+interface AvatarFallbackAttrs {
+ style: string;
+ "data-avatar-fallback": string;
+}
+
+class AvatarFallbackState {
+ root: AvatarRootState = undefined as unknown as AvatarRootState;
+ attrs: AvatarFallbackAttrs = $derived({
+ style: styleToString({
+ display: this.root.loadingStatus === "loaded" ? "none" : "block",
+ }),
+ "data-avatar-fallback": "",
+ });
+
+ constructor(root: AvatarRootState) {
+ this.root = root;
+ }
+}
+
+/**
+ * CONTEXT METHODS
+ */
+
+export const AVATAR_ROOT_KEY = Symbol("Avatar.Root");
+
+export function setAvatarRootState(props: AvatarStateProps) {
+ const rootState = new AvatarRootState(props);
+ setContext(AVATAR_ROOT_KEY, rootState);
+ return rootState;
+}
+
+export function getAvatarRootState(): AvatarRootState {
+ return getContext(AVATAR_ROOT_KEY);
+}
+
+export function getAvatarImageState(src: AvatarImageSrc) {
+ return getAvatarRootState().createImage(src);
+}
+
+export function getAvatarFallbackState() {
+ return getAvatarRootState().createFallback();
+}
diff --git a/packages/bits-ui/src/lib/bits/avatar/types.ts b/packages/bits-ui/src/lib/bits/avatar/types.ts
index 91e408894..0b4a07369 100644
--- a/packages/bits-ui/src/lib/bits/avatar/types.ts
+++ b/packages/bits-ui/src/lib/bits/avatar/types.ts
@@ -10,12 +10,6 @@ import type {
export type AvatarImageLoadingStatus = "loading" | "loaded" | "error";
export type AvatarRootPropsWithoutHTML = WithAsChild<{
- /**
- * The source of the image. If the image fails to load,
- * the `Avatar.Fallback` component will be rendered instead.
- */
- src: string;
-
/**
* The delay in milliseconds to wait before showing the avatar once
* the image has loaded. This can be used to prevent sudden flickering
diff --git a/packages/bits-ui/src/lib/bits/utilities/with-transition.svelte b/packages/bits-ui/src/lib/bits/utilities/with-transition.svelte
index 4c1890454..8ccc80ccd 100644
--- a/packages/bits-ui/src/lib/bits/utilities/with-transition.svelte
+++ b/packages/bits-ui/src/lib/bits/utilities/with-transition.svelte
@@ -12,6 +12,7 @@
outTransitionConfig?: TransitionConfig;
condition?: boolean;
children?: Snippet;
+ el?: HTMLElement;
};
let {
@@ -23,42 +24,34 @@
outTransitionConfig,
children,
condition,
+ el = $bindable(),
...restProps
}: Props = $props();
{#if transition && condition}
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{:else if inTransition && outTransition && condition}
- {#if children}
- {@render children()}
- {/if}
+ {@render children?.()}
{:else if inTransition && condition}
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{:else if outTransition && condition}
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{:else if condition}
-
- {#if children}
- {@render children()}
- {/if}
+
+ {@render children?.()}
{/if}
diff --git a/packages/bits-ui/src/lib/internal/attrs.ts b/packages/bits-ui/src/lib/internal/attrs.ts
index d92d5bbfd..e53a1e89b 100644
--- a/packages/bits-ui/src/lib/internal/attrs.ts
+++ b/packages/bits-ui/src/lib/internal/attrs.ts
@@ -56,8 +56,8 @@ export function disabledAttrs(disabled: boolean | undefined | null) {
: { "aria-disabled": undefined, "data-disabled": undefined };
}
-export function openClosedAttrs(condition: boolean): "true" | "false" {
- return condition ? "true" : "false";
+export function openClosedAttrs(condition: boolean): "open" | "closed" {
+ return condition ? "open" : "closed";
}
export function dataDisabledAttrs(condition: boolean): "" | undefined {
diff --git a/sites/docs/src/lib/components/demos/avatar-demo.svelte b/sites/docs/src/lib/components/demos/avatar-demo.svelte
index 332085f03..a21e3dcab 100644
--- a/sites/docs/src/lib/components/demos/avatar-demo.svelte
+++ b/sites/docs/src/lib/components/demos/avatar-demo.svelte
@@ -1,6 +1,6 @@
Date: Mon, 15 Apr 2024 19:48:47 -0400
Subject: [PATCH 003/322] next: Box helpers (#457)
Co-authored-by: Anatol Zakrividoroga <53095479+anatolzak@users.noreply.github.com>
fix accordion presence transition (#463)
---
.vscode/settings.json | 2 +-
package.json | 2 +-
packages/bits-ui/other/setupTest.ts | 2 +
packages/bits-ui/package.json | 16 +-
.../lib/bits/accordion/accordion.svelte.ts | 394 ++++++++++++++++++
.../components/accordion-content.svelte | 55 ++-
.../components/accordion-header.svelte | 5 +-
.../components/accordion-item.svelte | 25 +-
.../components/accordion-trigger.svelte | 38 +-
.../accordion/components/accordion.svelte | 54 +--
.../src/lib/bits/accordion/state.svelte.ts | 355 ----------------
.../bits-ui/src/lib/bits/accordion/types.ts | 12 +-
.../components/aspect-ratio.svelte | 4 +-
.../src/lib/bits/avatar/avatar.svelte.ts | 177 ++++++++
.../avatar/components/avatar-fallback.svelte | 25 +-
.../avatar/components/avatar-image.svelte | 26 +-
.../lib/bits/avatar/components/avatar.svelte | 41 +-
.../src/lib/bits/avatar/state.svelte.ts | 140 -------
packages/bits-ui/src/lib/bits/avatar/types.ts | 13 +-
.../src/lib/bits/checkbox/checkbox.svelte.ts | 164 ++++++++
.../components/checkbox-indicator.svelte | 34 --
.../checkbox/components/checkbox-input.svelte | 16 +-
.../bits/checkbox/components/checkbox.svelte | 101 +++--
packages/bits-ui/src/lib/bits/checkbox/ctx.ts | 32 --
.../bits-ui/src/lib/bits/checkbox/index.ts | 9 +-
.../bits-ui/src/lib/bits/checkbox/types.ts | 73 +++-
.../bits/collapsible/collapsible.svelte.ts | 193 +++++++++
.../components/collapsible-content.svelte | 103 ++---
.../components/collapsible-trigger.svelte | 43 +-
.../collapsible/components/collapsible.svelte | 66 ++-
.../bits-ui/src/lib/bits/collapsible/ctx.ts | 32 --
.../bits-ui/src/lib/bits/collapsible/index.ts | 3 +-
.../bits-ui/src/lib/bits/collapsible/types.ts | 94 ++---
.../src/lib/bits/utilities/presence.svelte | 42 ++
packages/bits-ui/src/lib/internal/attrs.ts | 25 +-
.../bits-ui/src/lib/internal/box.svelte.ts | 115 +++++
packages/bits-ui/src/lib/internal/context.ts | 3 +-
packages/bits-ui/src/lib/internal/events.ts | 16 +-
packages/bits-ui/src/lib/internal/index.ts | 3 +
packages/bits-ui/src/lib/internal/is.ts | 4 +
packages/bits-ui/src/lib/internal/style.ts | 19 +-
packages/bits-ui/src/lib/internal/types.ts | 13 +-
.../src/lib/internal/use-presence.svelte.ts | 117 ++++++
.../lib/internal/use-state-machine.svelte.ts | 49 +++
packages/bits-ui/src/lib/shared/css.d.ts | 9 +
packages/bits-ui/src/lib/shared/index.ts | 3 +
.../bits-ui/src/tests/avatar/Avatar.spec.ts | 2 +-
packages/bits-ui/vite.config.ts | 2 +-
pnpm-lock.yaml | 281 +++++++------
sites/docs/package.json | 3 +-
.../components/demos/accordion-demo.svelte | 15 +-
.../lib/components/demos/avatar-demo.svelte | 2 +-
.../lib/components/demos/checkbox-demo.svelte | 22 +-
.../components/demos/collapsible-demo.svelte | 6 +-
sites/docs/svelte.config.js | 14 +-
sites/docs/tailwind.config.js | 22 +-
56 files changed, 1981 insertions(+), 1155 deletions(-)
create mode 100644 packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
delete mode 100644 packages/bits-ui/src/lib/bits/accordion/state.svelte.ts
create mode 100644 packages/bits-ui/src/lib/bits/avatar/avatar.svelte.ts
delete mode 100644 packages/bits-ui/src/lib/bits/avatar/state.svelte.ts
create mode 100644 packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts
delete mode 100644 packages/bits-ui/src/lib/bits/checkbox/components/checkbox-indicator.svelte
delete mode 100644 packages/bits-ui/src/lib/bits/checkbox/ctx.ts
create mode 100644 packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts
delete mode 100644 packages/bits-ui/src/lib/bits/collapsible/ctx.ts
create mode 100644 packages/bits-ui/src/lib/bits/utilities/presence.svelte
create mode 100644 packages/bits-ui/src/lib/internal/box.svelte.ts
create mode 100644 packages/bits-ui/src/lib/internal/use-presence.svelte.ts
create mode 100644 packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts
create mode 100644 packages/bits-ui/src/lib/shared/css.d.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 1d278a066..ec0efcf5a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,6 @@
{
// Enable the ESlint flat config support
- "eslint.experimental.useFlatConfig": true,
+ "eslint.useFlatConfig": true,
// Auto fix
"editor.codeActionsOnSave": {
diff --git a/package.json b/package.json
index 78bf9cb39..b169faf96 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"prettier-plugin-tailwindcss": "0.5.13",
- "svelte": "5.0.0-next.100",
+ "svelte": "5.0.0-next.104",
"svelte-eslint-parser": "^0.34.1",
"wrangler": "^3.44.0"
},
diff --git a/packages/bits-ui/other/setupTest.ts b/packages/bits-ui/other/setupTest.ts
index 1ea8f809a..b94c85a06 100644
--- a/packages/bits-ui/other/setupTest.ts
+++ b/packages/bits-ui/other/setupTest.ts
@@ -1,3 +1,5 @@
+import "@testing-library/svelte/vitest";
+import "@testing-library/jest-dom/vitest";
import * as matchers from "@testing-library/jest-dom/matchers";
import { expect, vi } from "vitest";
import type { Navigation, Page } from "@sveltejs/kit";
diff --git a/packages/bits-ui/package.json b/packages/bits-ui/package.json
index aaabfa1b6..a70eac69d 100644
--- a/packages/bits-ui/package.json
+++ b/packages/bits-ui/package.json
@@ -13,7 +13,7 @@
"package": "svelte-kit sync && svelte-package && publint",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest",
- "watch": "svelte-kit sync && svelte-package --watch"
+ "watch": "svelte-package --watch"
},
"exports": {
".": {
@@ -31,23 +31,24 @@
"@sveltejs/kit": "^2.5.0",
"@sveltejs/package": "^2.2.7",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
- "@testing-library/dom": "^9.3.4",
- "@testing-library/jest-dom": "^6.4.1",
- "@testing-library/svelte": "^4.1.0",
+ "@testing-library/dom": "^10.0.0",
+ "@testing-library/jest-dom": "^6.4.2",
+ "@testing-library/svelte": "^4.2.2",
"@testing-library/user-event": "^14.5.2",
"@types/jest-axe": "^3.5.9",
"@types/node": "^20.12.2",
"@types/testing-library__jest-dom": "^5.14.9",
+ "csstype": "^3.1.3",
"jest-axe": "^8.0.0",
"jsdom": "^24.0.0",
"publint": "^0.2.7",
"resize-observer-polyfill": "^1.5.1",
- "svelte": "5.0.0-next.100",
+ "svelte": "5.0.0-next.104",
"svelte-check": "^3.6.9",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.2.8",
- "vitest": "^1.2.2"
+ "vitest": "^1.5.0"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -56,7 +57,8 @@
"@internationalized/date": "^3.5.1",
"@melt-ui/svelte": "0.76.2",
"esm-env": "^1.0.0",
- "nanoid": "^5.0.5"
+ "nanoid": "^5.0.5",
+ "style-object-to-css-string": "^1.1.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
diff --git a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
new file mode 100644
index 000000000..b6885d227
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
@@ -0,0 +1,394 @@
+import { getContext, onMount, setContext, tick, untrack } from "svelte";
+import {
+ type Box,
+ type BoxedValues,
+ type EventCallback,
+ type ReadonlyBox,
+ type ReadonlyBoxedValues,
+ boxedState,
+ composeHandlers,
+ getAriaDisabled,
+ getAriaExpanded,
+ getDataDisabled,
+ getDataOpenClosed,
+ kbd,
+ readonlyBox,
+ styleToString,
+ verifyContextDeps,
+} from "$lib/internal/index.js";
+import type { StyleProperties } from "$lib/shared/index.js";
+
+/**
+ * BASE
+ */
+type AccordionBaseStateProps = ReadonlyBoxedValues<{
+ id: string;
+ disabled: boolean;
+}>;
+
+interface AccordionRootAttrs {
+ id: string;
+ "data-accordion-root": string;
+}
+
+class AccordionBaseState {
+ id = undefined as unknown as ReadonlyBox;
+ disabled: ReadonlyBox;
+ #attrs: AccordionRootAttrs = $derived({
+ id: this.id.value,
+ "data-accordion-root": "",
+ } as const);
+
+ constructor(props: AccordionBaseStateProps) {
+ this.id = props.id;
+ this.disabled = props.disabled;
+ }
+
+ get props() {
+ return this.#attrs;
+ }
+}
+
+/**
+ * SINGLE
+ */
+
+type AccordionSingleStateProps = AccordionBaseStateProps & BoxedValues<{ value: string }>;
+
+export class AccordionSingleState extends AccordionBaseState {
+ #value: Box;
+ isMulti = false as const;
+
+ constructor(props: AccordionSingleStateProps) {
+ super(props);
+ this.#value = props.value;
+ }
+
+ includesItem(item: string) {
+ return this.#value.value === item;
+ }
+
+ toggleItem(item: string) {
+ this.#value.value = this.includesItem(item) ? "" : item;
+ }
+}
+
+/**
+ * MULTIPLE
+ */
+interface AccordionMultiStateProps extends AccordionBaseStateProps {
+ value: Box;
+}
+
+export class AccordionMultiState extends AccordionBaseState {
+ #value: Box;
+ isMulti = true as const;
+
+ constructor(props: AccordionMultiStateProps) {
+ super(props);
+ this.#value = props.value;
+ }
+
+ includesItem(item: string) {
+ return this.#value.value.includes(item);
+ }
+
+ toggleItem(item: string) {
+ if (this.includesItem(item)) {
+ this.#value.value = this.#value.value.filter((v) => v !== item);
+ } else {
+ this.#value.value.push(item);
+ }
+ }
+}
+
+/**
+ * ITEM
+ */
+
+type AccordionItemStateProps = ReadonlyBoxedValues<{
+ value: string;
+ disabled: boolean;
+}> & {
+ rootState: AccordionState;
+};
+
+export class AccordionItemState {
+ #value: ReadonlyBox;
+ disabled = undefined as unknown as ReadonlyBox;
+ root: AccordionState = undefined as unknown as AccordionState;
+ isSelected = $derived(this.root.includesItem(this.value));
+ isDisabled = $derived(this.disabled.value || this.root.disabled.value);
+ #attrs = $derived({
+ "data-accordion-item": "",
+ "data-state": getDataOpenClosed(this.isSelected),
+ "data-disabled": getDataDisabled(this.isDisabled),
+ } as const);
+
+ constructor(props: AccordionItemStateProps) {
+ this.#value = props.value;
+ this.disabled = props.disabled;
+ this.root = props.rootState;
+ }
+
+ get value() {
+ return this.#value.value;
+ }
+
+ updateValue() {
+ this.root.toggleItem(this.value);
+ }
+
+ get props() {
+ return this.#attrs;
+ }
+
+ createTrigger(props: AccordionTriggerStateProps) {
+ return new AccordionTriggerState(props, this);
+ }
+
+ createContent(props: AccordionContentStateProps) {
+ return new AccordionContentState(props, this);
+ }
+}
+
+/**
+ * TRIGGER
+ */
+
+type AccordionTriggerStateProps = ReadonlyBoxedValues<{
+ onclick: EventCallback;
+ onkeydown: EventCallback;
+ disabled: boolean;
+ id: string;
+}>;
+
+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(
+ 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
+ );
+ #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),
+ "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;
+ }
+
+ onclick = composeHandlers(this.onclickProp, () => {
+ if (this.isDisabled) return;
+ this.itemState.updateValue();
+ });
+
+ 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;
+
+ e.preventDefault();
+
+ if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
+ this.itemState.updateValue();
+ 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;
+
+ const items = Array.from(rootEl.querySelectorAll("[data-accordion-trigger]"));
+ if (!items.length) return;
+
+ const candidateItems = items.filter((item) => !item.dataset.disabled);
+ if (!candidateItems.length) return;
+
+ const currentIndex = candidateItems.indexOf(itemEl);
+
+ const keyToIndex = {
+ [kbd.ARROW_DOWN]: (currentIndex + 1) % candidateItems.length,
+ [kbd.ARROW_UP]: (currentIndex - 1 + candidateItems.length) % candidateItems.length,
+ [kbd.HOME]: 0,
+ [kbd.END]: candidateItems.length - 1,
+ };
+
+ candidateItems[keyToIndex[e.key]!]?.focus();
+ });
+
+ get props() {
+ return {
+ ...this.#attrs,
+ onclick: this.onclick,
+ onkeydown: this.onkeydown,
+ };
+ }
+}
+
+/**
+ * CONTENT
+ */
+
+type AccordionContentStateProps = BoxedValues<{
+ presentEl: HTMLElement | undefined;
+}> &
+ ReadonlyBoxedValues<{
+ forceMount: boolean;
+ }>;
+
+class AccordionContentState {
+ item = undefined as unknown as AccordionItemState;
+ originalStyles = boxedState<{ transitionDuration: string; animationName: string } | undefined>(
+ undefined
+ );
+ isMountAnimationPrevented = $state(false);
+ width = boxedState(0);
+ height = boxedState(0);
+ presentEl: Box = boxedState(undefined);
+ forceMount = undefined as unknown as ReadonlyBox;
+ present = $derived(this.item.isSelected);
+ #attrs: Record = $derived({
+ "data-state": getDataOpenClosed(this.item.isSelected),
+ "data-disabled": getDataDisabled(this.item.isDisabled),
+ "data-value": this.item.value,
+ "data-accordion-content": "",
+ } as const);
+
+ style: StyleProperties = $derived({
+ "--bits-accordion-content-height": `${this.height.value}px`,
+ "--bits-accordion-content-width": `${this.width.value}px`,
+ });
+
+ constructor(props: AccordionContentStateProps, item: AccordionItemState) {
+ this.item = item;
+ this.forceMount = props.forceMount;
+ this.isMountAnimationPrevented = this.item.isSelected;
+ this.presentEl = props.presentEl;
+
+ $effect.pre(() => {
+ const rAF = requestAnimationFrame(() => {
+ this.isMountAnimationPrevented = false;
+ });
+
+ return () => {
+ cancelAnimationFrame(rAF);
+ };
+ });
+
+ $effect(() => {
+ // eslint-disable-next-line no-unused-expressions
+ this.item.isSelected;
+ const node = untrack(() => this.presentEl.value);
+ if (!node) return;
+
+ tick().then(() => {
+ // get the dimensions of the element
+ this.originalStyles.value = this.originalStyles.value || {
+ transitionDuration: node.style.transitionDuration,
+ animationName: node.style.animationName,
+ };
+
+ // block any animations/transitions so the element renders at full dimensions
+ node.style.transitionDuration = "0s";
+ node.style.animationName = "none";
+
+ const rect = node.getBoundingClientRect();
+ this.height.value = rect.height;
+ 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;
+ node.style.transitionDuration = transitionDuration;
+ node.style.animationName = animationName;
+ }
+ });
+ });
+ }
+
+ get props() {
+ return this.#attrs;
+ }
+}
+
+/**
+ * CONTEXT METHODS
+ */
+
+export const ACCORDION_ROOT_KEY = "Accordion.Root";
+export const ACCORDION_ITEM_KEY = "Accordion.Item";
+
+type AccordionState = AccordionSingleState | AccordionMultiState;
+
+type InitAccordionProps = {
+ type: "single" | "multiple";
+ value: Box | Box;
+ id: ReadonlyBox;
+ disabled: ReadonlyBox;
+};
+
+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 })
+ );
+ }
+}
+
+export function getAccordionRootState(): AccordionState {
+ return getContext(ACCORDION_ROOT_KEY);
+}
+
+export function setAccordionItemState(props: Omit) {
+ verifyContextDeps(ACCORDION_ROOT_KEY);
+ const rootState = getAccordionRootState();
+ const itemState = new AccordionItemState({ ...props, rootState });
+ setContext(ACCORDION_ITEM_KEY, itemState);
+ return itemState;
+}
+
+export function getAccordionItemState(): AccordionItemState {
+ return getContext(ACCORDION_ITEM_KEY);
+}
+
+export function getAccordionTriggerState(props: AccordionTriggerStateProps): AccordionTriggerState {
+ verifyContextDeps(ACCORDION_ITEM_KEY);
+ const itemState = getAccordionItemState();
+ return itemState.createTrigger(props);
+}
+
+export function getAccordionContentState(props: AccordionContentStateProps): AccordionContentState {
+ verifyContextDeps(ACCORDION_ITEM_KEY);
+ const itemState = getAccordionItemState();
+ return itemState.createContent(props);
+}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-content.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-content.svelte
index 9e095b7b3..7defac2fb 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-content.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-content.svelte
@@ -1,30 +1,45 @@
-
-
-
-{#if asChild && content.item.isSelected}
- {@render child?.(mergedProps)}
-{:else}
-
-{/if}
+
+ {#snippet presence({ node, present })}
+ {@const mergedProps = {
+ ...restProps,
+ ...content.props,
+ style: styleToString({
+ ...styleProp,
+ ...content.style,
+ }),
+ }}
+ {#if asChild}
+ {@render child?.({ props: mergedProps })}
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
+ {/snippet}
+
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-header.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-header.svelte
index 56c6a11ce..a1db438e0 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-header.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-header.svelte
@@ -1,5 +1,6 @@
{#if asChild}
- {@render child?.(mergedProps)}
+ {@render child?.({ props: mergedProps })}
{:else}
{@render children?.()}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
index b30075674..f96ae2a0c 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-item.svelte
@@ -1,38 +1,37 @@
{#if asChild}
- {@render child?.(mergedProps)}
+ {@render child?.({ props: mergedProps })}
{:else}
{@render children?.()}
diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
index 7de657eca..6c9e8f80a 100644
--- a/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
+++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion-trigger.svelte
@@ -1,48 +1,44 @@
{#if asChild}
- {@render child?.(mergedProps)}
+ {@render child?.({ props: mergedProps })}
{:else}