Skip to content

Commit c6c62d9

Browse files
fix: automatic animations
1 parent dc487a0 commit c6c62d9

File tree

15 files changed

+192
-180
lines changed

15 files changed

+192
-180
lines changed

apps/website/src/routes/docs/headless/collapsible/examples/animation.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { component$ } from '@builder.io/qwik';
1+
import { component$, useStyles$ } from '@builder.io/qwik';
22
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3-
import './collapsible.css';
3+
import styles from './collapsible.css?inline';
44
import SVG from './svg';
55

66
export default component$(() => {
7+
useStyles$(styles);
8+
79
return (
810
<Collapsible class="collapsible">
911
<CollapsibleTrigger class="collapsible-trigger">

apps/website/src/routes/docs/headless/collapsible/examples/collapsible.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
align-items: center;
1313
}
1414

15-
.collapsible-trigger[data-state='open'] svg {
15+
.collapsible-trigger[data-open] svg {
1616
transform: rotate(180deg);
1717
}
1818

@@ -53,10 +53,10 @@
5353
}
5454
}
5555

56-
.collapsible-animation[data-state='open'] {
56+
.collapsible-animation[data-open] {
5757
animation: 550ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-open;
5858
}
5959

60-
.collapsible-animation[data-state='closed'] {
60+
.collapsible-animation[data-closed] {
6161
animation: 350ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-closed;
6262
}

apps/website/src/routes/docs/headless/collapsible/examples/no-animation.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { component$, useStyles$, useSignal, $ } from '@builder.io/qwik';
2+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3+
import styles from './collapsible.css?inline';
4+
import SVG from './svg';
5+
6+
export default component$(() => {
7+
useStyles$(styles);
8+
const count = useSignal<number>(0);
9+
10+
const handleOpenChange$ = $((open: boolean) => {
11+
if (open) {
12+
count.value++;
13+
}
14+
});
15+
16+
return (
17+
<Collapsible class="collapsible" onOpenChange$={handleOpenChange$}>
18+
<CollapsibleTrigger class="collapsible-trigger">
19+
<span>Trigger</span>
20+
<SVG />
21+
</CollapsibleTrigger>
22+
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
23+
Content: {count.value}
24+
</CollapsibleContent>
25+
</Collapsible>
26+
);
27+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { component$, useStyles$ } from '@builder.io/qwik';
2+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3+
import styles from './collapsible.css?inline';
4+
import SVG from './svg';
5+
6+
export default component$(() => {
7+
useStyles$(styles);
8+
9+
return (
10+
<Collapsible class="collapsible" open>
11+
<CollapsibleTrigger class="collapsible-trigger">
12+
<span>Trigger</span>
13+
<SVG />
14+
</CollapsibleTrigger>
15+
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
16+
Content
17+
</CollapsibleContent>
18+
</Collapsible>
19+
);
20+
});

apps/website/src/routes/docs/headless/collapsible/examples/padding.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
1-
import { component$, useSignal } from '@builder.io/qwik';
1+
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
22
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
3-
import './collapsible.css';
3+
import styles from './collapsible.css?inline';
44

55
export default component$(() => {
6-
const openSig = useSignal<boolean>(true);
6+
useStyles$(styles);
7+
const isOpen = useSignal<boolean>(true);
78

89
return (
9-
<div>
10-
<p>state: {openSig.value ? 'true' : 'false'}</p>
11-
<Collapsible bind:isOpen={openSig}>
12-
<CollapsibleTrigger>I am trigger 1!</CollapsibleTrigger>
10+
<>
11+
<input
12+
style={{ width: '20px', height: '20px', accentColor: 'hsl(var(--primary))' }}
13+
type="checkbox"
14+
bind:checked={isOpen}
15+
/>
16+
17+
<p>
18+
is open: <strong>{isOpen.value ? 'true' : 'false'}</strong>
19+
</p>
20+
21+
<Collapsible class="collapsible" bind:open={isOpen}>
22+
<CollapsibleTrigger class="collapsible-trigger">Trigger</CollapsibleTrigger>
1323
<CollapsibleContent class="collapsible-content">
14-
I am the content 1!
24+
<p class="collapsible-content-outline">Content</p>
1525
</CollapsibleContent>
1626
</Collapsible>
17-
<button
18-
class="rounded-base bg-slate-500 px-2 py-3 text-white"
19-
onClick$={() => {
20-
openSig.value = !openSig.value;
21-
}}
22-
>
23-
Programmatic toggle
24-
</button>
25-
</div>
27+
</>
2628
);
2729
});

apps/website/src/routes/docs/headless/collapsible/index.mdx

Lines changed: 12 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ An interactive component which expands/collapses a panel.
1919
'Full keyboard navigation',
2020
'Controlled or uncontrolled',
2121
'Initial open state does not wake up the component',
22+
'Automatic animation detection',
2223
'Executes on interaction or programmatically',
2324
]}
2425
/>
@@ -69,73 +70,24 @@ As much as we love the native elements, they come with a couple of problems:
6970

7071
<Showcase name="animation" />
7172

72-
## Jack's bug watch 🔎:
73+
### Why does padding or border break the animation?
7374

74-
<strong style={{ color: 'red' }}>BUG:</strong> Refresh the page and click on programmatic toggle.
75-
The animation does not work.
75+
Padding or border applied to `CollapsibleContent` breaks height animations. This is because the content height has changed.
7676

77-
<Showcase name="programmatic" />
78-
79-
<strong style={{ color: 'red' }}>BUG:</strong> resize the window after closing and opening
80-
the collapsible. (content height does not resize)
81-
82-
<Showcase name="no-animation" />
83-
84-
<strong style={{ color: 'red' }}>BUG:</strong> watch the animation. Issue explained further
85-
below.
86-
87-
<Showcase name="padding" />
88-
89-
## Hey Qwik developers!
90-
91-
Qwik UI is already on the path to a library that gives you the features you know and love with other headless libraries (hopefully more functionality), with the performance of _just_ grabbing html on the page.
77+
To fix this, add a child element to the content, and set the padding or border on that element.
9278

93-
To work towards this, I have been working on a new primitive to rebuild the accordion from the ground up. (which currently uses visible tasks)
79+
> Rather than dealing with this under the hood, we thought it'd be appropriate to keep style management as simple as possible. Let us know if you have a better solution!
9480
95-
<Note status="warning">
96-
Unfortunately, I don't have the time right now to complete it, but we are _basically
97-
80%_ there. Would really appreciate some help on this. It still needs tests (many of
98-
which can be taken from the accordion).
99-
</Note>
81+
## Component State
10082

101-
It is a collapsible or [disclosure component](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/), similar to native html's `details & summary` tags. Unfortunately, the accessibility for these native elements is very flaky, and thus, this component is needed.
83+
### Uncontrolled / Initial value
10284

103-
Not only does it benefit this component, but also the performance of the accordion (which would be built on top of this).
104-
105-
### What I'd like to add:
106-
107-
<FeatureList
108-
features={[
109-
'An `onOpenChange$` prop function that runs when the collapsible has opened or closed',
110-
]}
111-
/>
85+
<Showcase name="open" />
11286

113-
## There are two minor bugs:
87+
### Controlled / Reactive value
11488

115-
### **BUG #1:**
116-
117-
We currently detect automatic animations with the `getComputedStyle` API. Unfortunately, this is on the client, and so we do not get an animation if:
118-
119-
- was loaded on SSR **AND** open by default
120-
121-
I was however, able to make it work when clicking the actual button's trigger with a 1ms setTimeout inside the `handleClick$`, but it is still a problem with programmatic behavior.
122-
123-
We need to figure out how to detect the animation with SSR **OR** use a different way of animating. I briefly tried using `requestAnimationFrame` as an alternative.
124-
125-
> My current thoughts are, setting it as an animated collapsible on the server, but then setting it back to false on the client when resumed if it does not have an animation (the current logic for that does not work)
126-
127-
[where the issue is](https://github.com/thejackshelton/collapsible/blob/main/src/components/collapsible/collapsible-content.tsx#L27)
128-
129-
### **BUG #2:**
130-
131-
If you close and open the collapsible again, try resizing the window, notice that the container does not adjust its height to match its content.
132-
133-
[where the issue is](https://github.com/thejackshelton/collapsible/blob/main/src/components/collapsible/collapsible-trigger.tsx#L16)
134-
135-
> You can see visuals of the problems [on discord here.](https://discord.com/channels/990511757091033108/1040763063533588562/1193628593088106496)
136-
137-
The overall component is relatively small, around 250 lines of code.
89+
<Showcase name="programmatic" />
13890

139-
### **BUG #3:**
91+
### Handling open / close
14092

141-
Padding currently breaks height animations. This is because the padding is applied to the content, and the content is animated. This causes the content to animate from the padding, which is not the desired effect. :facepalm
93+
<Showcase name="open-change" />

apps/website/src/routes/docs/headless/select/snippets/select.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
.select-trigger {
1010
width: 100%;
11-
border: 2px dashed hsla(var(--primary) / 1);
11+
border: 2px dotted hsla(var(--primary) / 1);
1212
border-radius: calc(var(--border-radius) / 2);
1313
height: 44px;
1414
}
@@ -22,7 +22,7 @@
2222
width: 100%;
2323
background-color: hsl(var(--background));
2424
padding: 0.5rem;
25-
border: 2px dashed hsla(var(--foreground) / 0.6);
25+
border: 2px dotted hsla(var(--foreground) / 0.6);
2626
border-radius: calc(var(--border-radius) / 2);
2727
max-width: var(--select-width);
2828
color: hsl(var(--foreground));
@@ -41,7 +41,7 @@
4141
}
4242

4343
[data-highlighted] {
44-
outline: 2px dashed hsla(var(--primary) / 1);
44+
outline: 2px dotted hsla(var(--primary) / 1);
4545
border-radius: calc(var(--border-radius) / 2);
4646
}
4747

packages/kit-headless/src/components/collapsible/collapsible-content.tsx

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import { collapsibleContextId } from './collapsible-context-id';
1111

1212
export type CollapsibleContentProps = PropsOf<'div'>;
1313

14+
import { isServer } from '@builder.io/qwik/build';
15+
1416
export const CollapsibleContent = component$((props: CollapsibleContentProps) => {
1517
const context = useContext(collapsibleContextId);
16-
const isHiddenSig = useSignal<boolean>(false);
17-
const isAnimatedSig = useSignal<boolean>(false);
18+
const isHiddenSig = useSignal<boolean>(!context.isOpenSig.value);
19+
// check if it's initially "animatable"
20+
const isAnimatedSig = useSignal<boolean>(true);
21+
const initialRenderSig = useSignal<boolean>(true);
1822
const contentId = `${context.itemId}-content`;
1923

2024
const hideContent$ = $(() => {
@@ -27,27 +31,35 @@ export const CollapsibleContent = component$((props: CollapsibleContentProps) =>
2731
useTask$(async function automaticAnimations({ track }) {
2832
track(() => context.isOpenSig.value);
2933

30-
if (!context.contentRef.value) return;
34+
if (isServer) {
35+
return;
36+
}
3137

3238
await context.getContentDimensions$();
3339

34-
/* check if there's a transition or animation */
35-
const { animationDuration, transitionDuration } = getComputedStyle(
36-
context.contentRef.value,
37-
);
40+
/* check if there's a transition or animation, we set a timeout for the initial render */
41+
setTimeout(() => {
42+
const { animationDuration, transitionDuration } = getComputedStyle(
43+
context.contentRef.value!,
44+
);
3845

39-
if (animationDuration !== '0s') {
40-
console.log(animationDuration);
41-
isAnimatedSig.value = true;
42-
} else if (transitionDuration !== '0s') {
43-
isAnimatedSig.value = true;
44-
}
46+
// don't animate if initially open
47+
if (
48+
animationDuration === '0s' &&
49+
transitionDuration === '0s' &&
50+
!initialRenderSig.value
51+
) {
52+
isAnimatedSig.value = false;
53+
} else {
54+
isAnimatedSig.value = true;
55+
}
56+
}, 15);
4557

4658
if (context.isOpenSig.value) {
4759
isHiddenSig.value = false;
4860
}
4961

50-
context.initialStateSig.value = false;
62+
initialRenderSig.value = false;
5163
});
5264

5365
return (
@@ -56,20 +68,13 @@ export const CollapsibleContent = component$((props: CollapsibleContentProps) =>
5668
ref={context.contentRef}
5769
id={contentId}
5870
data-collapsible-content
59-
data-state={
60-
context.initialStateSig.value
61-
? 'initial'
62-
: context.isOpenSig.value
63-
? 'open'
64-
: 'closed'
65-
}
71+
data-open={!initialRenderSig.value && context.isOpenSig.value ? '' : undefined}
72+
data-closed={!context.isOpenSig.value ? '' : undefined}
6673
onAnimationEnd$={[hideContent$, props.onAnimationEnd$]}
6774
onTransitionEnd$={[hideContent$, props.onTransitionEnd$]}
6875
hidden={isAnimatedSig.value ? isHiddenSig.value : !context.isOpenSig.value}
6976
>
70-
<div ref={context.contentChildRef}>
71-
<Slot />
72-
</div>
77+
<Slot />
7378
</div>
7479
);
7580
});

0 commit comments

Comments
 (0)