Skip to content

Commit

Permalink
next: remove dependency on bind:this for Presence (#467)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Apr 16, 2024
1 parent 3853dbf commit 2375dfd
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 123 deletions.
69 changes: 40 additions & 29 deletions packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getDataOpenClosed,
kbd,
readonlyBox,
styleToString,
verifyContextDeps,
} from "$lib/internal/index.js";
import type { StyleProperties } from "$lib/shared/index.js";
Expand Down Expand Up @@ -244,43 +245,52 @@ class AccordionTriggerState {
* CONTENT
*/

type AccordionContentStateProps = BoxedValues<{
presentEl: HTMLElement | undefined;
}> &
ReadonlyBoxedValues<{
forceMount: boolean;
}>;
type AccordionContentStateProps = ReadonlyBoxedValues<{
forceMount: boolean;
id: string;
style: StyleProperties;
}>;

class AccordionContentState {
item = undefined as unknown as AccordionItemState;
originalStyles: { transitionDuration: string; animationName: string } | undefined = undefined;
isMountAnimationPrevented = false;
width = boxedState(0);
height = boxedState(0);
presentEl = boxedState<HTMLElement | undefined>(undefined);
forceMount = undefined as unknown as ReadonlyBox<boolean>;
present = $derived(this.item.isSelected);
node = boxedState<HTMLElement | null>(null);
#id = undefined as unknown as ReadonlyBox<string>;
#originalStyles: { transitionDuration: string; animationName: string } | undefined = undefined;
#isMountAnimationPrevented = false;
#width = boxedState(0);
#height = boxedState(0);
#forceMount = undefined as unknown as ReadonlyBox<boolean>;
present = $derived(this.#forceMount.value || this.item.isSelected);
#styleProp = undefined as unknown as ReadonlyBox<StyleProperties>;
#attrs = $derived({
id: this.#id.value,
"data-state": getDataOpenClosed(this.item.isSelected),
"data-disabled": getDataDisabled(this.item.isDisabled),
"data-value": this.item.value,
"data-accordion-content": "",
style: styleToString({
...this.#styleProp.value,
"--bits-accordion-content-height": `${this.#height.value}px`,
"--bits-accordion-content-width": `${this.#width.value}px`,
}),
} 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;
this.#forceMount = props.forceMount;
this.#isMountAnimationPrevented = this.item.isSelected;
this.#id = props.id;
this.#styleProp = props.style;

$effect.root(() => {
tick().then(() => {
this.node.value = document.getElementById(this.#id.value);
});
});

$effect.pre(() => {
const rAF = requestAnimationFrame(() => {
this.isMountAnimationPrevented = false;
this.#isMountAnimationPrevented = false;
});

return () => {
Expand All @@ -290,13 +300,14 @@ class AccordionContentState {

$effect(() => {
// eslint-disable-next-line no-unused-expressions
this.item.isSelected;
const node = untrack(() => this.presentEl.value);
this.present;
const node = this.node.value;
if (!node) return;

tick().then(() => {
if (!this.node) return;
// get the dimensions of the element
this.originalStyles = this.originalStyles || {
this.#originalStyles = this.#originalStyles || {
transitionDuration: node.style.transitionDuration,
animationName: node.style.animationName,
};
Expand All @@ -306,12 +317,12 @@ class AccordionContentState {
node.style.animationName = "none";

const rect = node.getBoundingClientRect();
this.height.value = rect.height;
this.width.value = rect.width;
this.#height.value = rect.height;
this.#width.value = rect.width;

// unblock any animations/transitions that were originally set if not the initial render
if (!this.isMountAnimationPrevented) {
const { animationName, transitionDuration } = this.originalStyles;
if (!this.#isMountAnimationPrevented) {
const { animationName, transitionDuration } = this.#originalStyles;
node.style.transitionDuration = transitionDuration;
node.style.animationName = animationName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,39 @@
import { getAccordionContentState } from "../accordion.svelte.js";
import type { AccordionContentProps } from "../types.js";
import Presence from "$lib/bits/utilities/presence.svelte";
import { box, readonlyBox } from "$lib/internal/box.svelte.js";
import { styleToString } from "$lib/internal/style.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { generateId } from "$lib/internal/id.js";
let {
child,
asChild,
el: elProp = $bindable(),
el = $bindable(),
id: idProp = generateId(),
forceMount: forceMountProp = false,
children,
style: styleProp = {},
...restProps
}: AccordionContentProps & { forceMount?: boolean } = $props();
const el = box(
() => elProp,
(v) => (elProp = v)
);
}: AccordionContentProps = $props();
const id = readonlyBox(() => idProp);
const style = readonlyBox(() => styleProp);
const forceMount = readonlyBox(() => forceMountProp);
const content = getAccordionContentState({ presentEl: el, forceMount });
const content = getAccordionContentState({ forceMount, id, style });
</script>

<Presence forceMount={true} present={content.present} bind:el={el.value}>
{#snippet presence({ node, present })}
<Presence forceMount={true} present={content.present} node={content.node}>
{#snippet presence({ present })}
{@const mergedProps = {
...restProps,
...content.props,
style: styleToString({
...styleProp,
...content.style,
}),
hidden: present.value ? undefined : true,
}}
{#if asChild}
{@render child?.({ props: mergedProps })}
{@render child?.({
props: mergedProps,
})}
{:else}
<div {...mergedProps} bind:this={node.value} hidden={present.value ? undefined : true}>
<div {...mergedProps} bind:this={el}>
{@render children?.()}
</div>
{/if}
Expand Down
4 changes: 3 additions & 1 deletion packages/bits-ui/src/lib/bits/accordion/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@ export type AccordionContentPropsWithoutHTML<
inTransitionConfig?: TransitionParams<In>;
outTransition?: Out;
outTransitionConfig?: TransitionParams<Out>;
forceMount?: boolean;
id?: string;
}>;

export type AccordionContentProps<
T extends Transition = Transition,
In extends Transition = Transition,
Out extends Transition = Transition,
> = AccordionContentPropsWithoutHTML<T, In, Out> & PrimitiveDivAttributes;
> = AccordionContentPropsWithoutHTML<T, In, Out> & Omit<PrimitiveDivAttributes, "id">;

export type AccordionHeaderPropsWithoutHTML = WithAsChild<{
asChild?: boolean;
Expand Down
93 changes: 52 additions & 41 deletions packages/bits-ui/src/lib/bits/collapsible/collapsible.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getContext, onMount, setContext } from "svelte";
import { getContext, onMount, setContext, tick } from "svelte";
import { getAriaExpanded, getDataDisabled, getDataOpenClosed } from "$lib/internal/attrs.js";
import {
type Box,
Expand Down Expand Up @@ -53,30 +53,29 @@ class CollapsibleRootState {
}
}

type CollapsibleContentStateProps = BoxedValues<{
presentEl: HTMLElement | undefined;
}> &
ReadonlyBoxedValues<{
id: string;
style: StyleProperties;
}>;
type CollapsibleContentStateProps = ReadonlyBoxedValues<{
id: string;
style: StyleProperties;
forceMount: boolean;
}>;

class CollapsibleContentState {
root = undefined as unknown as CollapsibleRootState;
currentStyle = $state<{ transitionDuration: string; animationName: string }>();
styleProp = undefined as unknown as ReadonlyBox<StyleProperties>;
#originalStyles: { transitionDuration: string; animationName: string } | undefined = undefined;
#styleProp = undefined as unknown as ReadonlyBox<StyleProperties>;
node = boxedState<HTMLElement | null>(null);
#isMountAnimationPrevented = $state(false);
#width = $state(0);
#height = $state(0);
#presentEl: Box<HTMLElement | undefined>;
present = $derived(this.root.open);
#forceMount = undefined as unknown as ReadonlyBox<boolean>;
present = $derived(this.#forceMount.value || this.root.open.value);
#attrs = $derived({
id: this.root.contentId.value,
"data-state": getDataOpenClosed(this.root.open.value),
"data-disabled": getDataDisabled(this.root.disabled.value),
"data-collapsible-content": "",
style: styleToString({
...this.styleProp.value,
...this.#styleProp.value,
"--bits-collapsible-content-height": this.#height ? `${this.#height}px` : undefined,
"--bits-collapsible-content-width": this.#width ? `${this.#width}px` : undefined,
}),
Expand All @@ -85,42 +84,55 @@ class CollapsibleContentState {
constructor(props: CollapsibleContentStateProps, root: CollapsibleRootState) {
this.root = root;
this.#isMountAnimationPrevented = root.open.value;
this.#presentEl = props.presentEl;
this.#forceMount = props.forceMount;
this.root.contentId = props.id;
this.styleProp = props.style;
this.#styleProp = props.style;

onMount(() => {
requestAnimationFrame(() => {
this.#isMountAnimationPrevented = false;
$effect.root(() => {
tick().then(() => {
this.node.value = document.getElementById(this.root.contentId.value);
});
});

$effect.pre(() => {
// eslint-disable-next-line no-unused-expressions
this.root.open.value;
const node = this.#presentEl.value;
if (!node) return;
const rAF = requestAnimationFrame(() => {
this.#isMountAnimationPrevented = false;
});

this.currentStyle = this.currentStyle || {
transitionDuration: node.style.transitionDuration,
animationName: node.style.animationName,
return () => {
cancelAnimationFrame(rAF);
};
});

// block any animations/transitions so the element renders at full dimensions
node.style.transitionDuration = "0s";
node.style.animationName = "none";

// get the dimensions of the element
const rect = node.getBoundingClientRect();
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;
node.style.transitionDuration = transitionDuration;
node.style.animationName = animationName;
}
$effect(() => {
// eslint-disable-next-line no-unused-expressions
this.present;
const node = this.node.value;
if (!node) return;

tick().then(() => {
if (!this.node) return;
// get the dimensions of the element
this.#originalStyles = this.#originalStyles || {
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 = 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.#originalStyles;
node.style.transitionDuration = transitionDuration;
node.style.animationName = animationName;
}
});
});
}

Expand All @@ -136,7 +148,6 @@ type CollapsibleTriggerStateProps = ReadonlyBoxedValues<{
class CollapsibleTriggerState {
#root = undefined as unknown as CollapsibleRootState;
#onclickProp = boxedState<CollapsibleTriggerStateProps["onclick"]>(readonlyBox(() => () => {}));

#attrs = $derived({
type: "button",
"aria-controls": this.#root.contentId.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,41 @@
import type { CollapsibleContentProps } from "../types.js";
import Presence from "$lib/bits/utilities/presence.svelte";
import { generateId } from "$lib/internal/id.js";
import { box, readonlyBox } from "$lib/internal/box.svelte.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
let {
child,
asChild,
el: elProp = $bindable(),
forceMount = false,
el = $bindable(),
forceMount: forceMountProp = false,
children,
id: idProp = generateId(),
style: styleProp = {},
...restProps
}: CollapsibleContentProps & { forceMount?: boolean } = $props();
const id = readonlyBox(() => idProp);
const el = box(
() => elProp,
(v) => (elProp = v)
);
const style = readonlyBox(() => styleProp);
const content = getCollapsibleContentState({ id, presentEl: el, style });
const forceMount = readonlyBox(() => forceMountProp);
const mergedProps = $derived({
...restProps,
...content.props,
});
const style = readonlyBox(() => styleProp);
const content = getCollapsibleContentState({ id, style, forceMount });
</script>

<Presence present={forceMount || content.root.open.value} bind:el={el.value}>
{#snippet presence({ node, present })}
<Presence forceMount={true} present={content.present} node={content.node}>
{#snippet presence({ present })}
{@const mergedProps = {
...restProps,
...content.props,
hidden: present.value ? undefined : true,
}}
{#if asChild}
{@render child?.({ props: { ...mergedProps, hidden: !present.value } })}
{@render child?.({
props: mergedProps,
})}
{:else}
<div {...mergedProps} hidden={!present.value} bind:this={node.value}>
<div {...mergedProps} bind:this={el}>
{@render children?.()}
</div>
{/if}
{/snippet}
</Presence>

<style>
[hidden="false"] {
display: block !important;
}
</style>
Loading

0 comments on commit 2375dfd

Please sign in to comment.