Skip to content

Commit 8b7cc7d

Browse files
committed
feat(primitives): add Spoiler component
1 parent d80258a commit 8b7cc7d

File tree

19 files changed

+1159
-0
lines changed

19 files changed

+1159
-0
lines changed

packages/primitives/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * as Popover from './popover';
1111
export * as Primitive from './primitive';
1212
export * as Separator from './separator';
1313
export * as Spinner from './spinner';
14+
export * as Spoiler from './spoiler';
1415
export * as Tabs from './tabs';
1516
export * as Toggle from './toggle';
1617
export * as VisuallyHidden from './visually-hidden';
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
# Spoiler
2+
3+
An interactive component that allow hide long sections of content under a spoiler.
4+
5+
## Features
6+
7+
- Full keyboard navigation.
8+
9+
- Can be uncontrolled or controlled.
10+
11+
- Support for responsive design.
12+
13+
## Import
14+
15+
```tsx
16+
import { Spoiler } from 'qwik-primitives';
17+
```
18+
19+
## Anatomy
20+
21+
```tsx
22+
import { component$ } from '@builder.io/qwik';
23+
import { Spoiler } from 'qwik-primitives';
24+
25+
const SpoilerDemo = component$(() => {
26+
return (
27+
<Spoiler.Root>
28+
<Spoiler.Trigger />
29+
<Spoiler.Panel>
30+
<Spoiler.Content />
31+
</Spoiler.Panel>
32+
</Spoiler.Root>
33+
);
34+
});
35+
```
36+
37+
## Usage
38+
39+
Spoiler component can be uncontrolled or controlled.
40+
41+
### Uncontrolled
42+
43+
```tsx
44+
import { component$ } from '@builder.io/qwik';
45+
import { Spoiler } from '@/components';
46+
47+
const SpoilerDemo = component$(() => {
48+
return (
49+
<Spoiler.Root>
50+
<Spoiler.Trigger>Toggle content</Spoiler.Trigger>
51+
<Spoiler.Panel minHeight="60px">
52+
<Spoiler.Content>
53+
<p>
54+
Qwik Primitives is a UI toolkit for building accessible web apps and design systems with Qwik. It provides a
55+
set of low-level UI components and primitives which can be the foundation for your design system
56+
implementation.
57+
</p>
58+
<div style={{ height: '300px', backgroundColor: 'purple' }} />
59+
</Spoiler.Content>
60+
</Spoiler.Panel>
61+
</Spoiler.Root>
62+
);
63+
});
64+
```
65+
66+
### Controlled
67+
68+
```tsx
69+
import { component$, useSignal } from '@builder.io/qwik';
70+
import { Spoiler } from '@/components';
71+
72+
const SpoilerDemo = component$(() => {
73+
const isOpen = useSignal(false);
74+
75+
return (
76+
<Spoiler.Root open={isOpen} onOpenChange$={(open) => (isOpen.value = open)}>
77+
<Spoiler.Trigger>Toggle content</Spoiler.Trigger>
78+
<Spoiler.Panel minHeight="60px">
79+
<Spoiler.Content>
80+
<p>
81+
Qwik Primitives is a UI toolkit for building accessible web apps and design systems with Qwik. It provides a
82+
set of low-level UI components and primitives which can be the foundation for your design system
83+
implementation.
84+
</p>
85+
<div style={{ height: '300px', backgroundColor: 'purple' }} />
86+
</Spoiler.Content>
87+
</Spoiler.Panel>
88+
</Spoiler.Root>
89+
);
90+
});
91+
```
92+
93+
## API Reference
94+
95+
### Root
96+
97+
Contains all the parts of a spoiler. This component is based on the `div` element.
98+
99+
| Prop | Type | Default | Description |
100+
| --------------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101+
| `as` | `FunctionComponent` | `-` | Change the default rendered element for the one passed as, merging their props and behavior. Read our [Composition](https://github.com/ZAHON/qwik-primitives/blob/main/packages/primitives/docs/composition.md) guide for more details. |
102+
| `defaultOpen` | `boolean` | `-` | The open state of the spoiler when it is initially rendered. Use when you do not need to control its open state. |
103+
| `open` | `Signal` | `-` | The controlled open state of the spoiler. |
104+
| `onOpenChange$` | `QRL<(open: boolean) => void>` | `-` | Event handler called when the open state of the spoiler changes. |
105+
| `disabled` | `boolean` | `-` | When `true`, prevents the user from interacting with the spoiler. |
106+
| `style` | `CSSProperties` | `-` | The inline style for the element. |
107+
108+
| Data attribute | Values |
109+
| ----------------- | --------------------- |
110+
| `[data-scope]` | `"spoiler"` |
111+
| `[data-part]` | `"root"` |
112+
| `[data-state]` | `"open" \| "closed"` |
113+
| `[data-disabled]` | Present when disabled |
114+
115+
### Trigger
116+
117+
The button that toggles the spoiler. This component is based on the `button` element.
118+
119+
| Prop | Type | Default | Description |
120+
| ------- | ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
121+
| `as` | `FunctionComponent` | `-` | Change the default rendered element for the one passed as, merging their props and behavior. Read our [Composition](https://github.com/ZAHON/qwik-primitives/blob/main/packages/primitives/docs/composition.md) guide for more details. |
122+
| `style` | `CSSProperties` | `-` | The inline style for the element. |
123+
124+
| Data attribute | Values |
125+
| ----------------- | --------------------- |
126+
| `[data-scope]` | `"spoiler"` |
127+
| `[data-part]` | `"trigger"` |
128+
| `[data-state]` | `"open" \| "closed"` |
129+
| `[data-disabled]` | Present when disabled |
130+
131+
### Panel
132+
133+
The panel that expands/collapses. This component is based on the `div` element.
134+
135+
| Prop | Type | Default | Description |
136+
| ----------- | ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
137+
| `as` | `FunctionComponent` | `-` | Change the default rendered element for the one passed as, merging their props and behavior. Read our [Composition](https://github.com/ZAHON/qwik-primitives/blob/main/packages/primitives/docs/composition.md) guide for more details. |
138+
| `minHeight` | `string` | `0px` | The minimum height of the panel when spolier is closed. |
139+
| `onOpen$` | `QRL<() => void>` | `-` | Event handler called when the panel is fully open. If you animate the size of the panel when it opens this event handler was call after animation end. |
140+
| `onClose$` | `QRL<() => void>` | `-` | Event handler called when the panel is fully close. If you animate the size of the panel when it closes this event handler was call after animation end. |
141+
| `style` | `CSSProperties` | `-` | The inline style for the element. |
142+
143+
| Data attribute | Values |
144+
| ----------------- | --------------------- |
145+
| `[data-scope]` | `"spoiler"` |
146+
| `[data-part]` | `"panel"` |
147+
| `[data-state]` | `"open" \| "closed"` |
148+
| `[data-disabled]` | Present when disabled |
149+
150+
| CSS Variable | Description |
151+
| -------------------------------------------- | ------------------------------------------------------ |
152+
| `--qwik-primitives-spoiler-panel-min-height` | The minimum height of the panel when spolier is close. |
153+
| `--qwik-primitives-spoiler-panel-max-height` | The maximum height of the panel when spolier is open. |
154+
155+
### Content
156+
157+
The component that contains the spoiler content. Must be rendered inside `Spoiler.Panel`. This component is based on the `div` element.
158+
159+
| Prop | Type | Default | Description |
160+
| ------- | ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161+
| `as` | `FunctionComponent` | `-` | Change the default rendered element for the one passed as, merging their props and behavior. Read our [Composition](https://github.com/ZAHON/qwik-primitives/blob/main/packages/primitives/docs/composition.md) guide for more details. |
162+
| `style` | `CSSProperties` | `-` | The inline style for the element. |
163+
164+
| Data attribute | Values |
165+
| ----------------- | --------------------- |
166+
| `[data-scope]` | `"spoiler"` |
167+
| `[data-part]` | `"content"` |
168+
| `[data-state]` | `"open" \| "closed"` |
169+
| `[data-disabled]` | Present when disabled |
170+
171+
## Examples
172+
173+
### Animating panel height
174+
175+
Use the `--qwik-primitives-spoiler-panel-min-height` and `--qwik-primitives-spoiler-panel-max-height` CSS variables to animate the height of the panel when it opens/closes.
176+
177+
```tsx
178+
// index.tsx
179+
import { component$, useStyles$ } from '@builder.io/qwik';
180+
import { Spoiler } from '@/components';
181+
import styles from './styles.css?inline';
182+
183+
const SpoilerDemo = component$(() => {
184+
useStyles$(styles);
185+
186+
return (
187+
<Spoiler.Root>
188+
<Spoiler.Trigger>Toggle content</Spoiler.Trigger>
189+
<Spoiler.Panel minHeight="60px" class="spoiler-panel">
190+
<Spoiler.Content>
191+
<p>
192+
Qwik Primitives is a UI toolkit for building accessible web apps and design systems with Qwik. It provides a
193+
set of low-level UI components and primitives which can be the foundation for your design system
194+
implementation.
195+
</p>
196+
<div style={{ height: '300px', backgroundColor: 'purple' }} />
197+
</Spoiler.Content>
198+
</Spoiler.Panel>
199+
</Spoiler.Root>
200+
);
201+
});
202+
```
203+
204+
```css
205+
/* styles.css */
206+
.spoiler-panel[data-state='open'] {
207+
animation: spoiler-panel-down 300ms ease-out;
208+
}
209+
210+
.spoiler-panel[data-state='closed'] {
211+
animation: spoiler-panel-up 300ms ease-out;
212+
}
213+
214+
@keyframes spoiler-panel-down {
215+
0% {
216+
height: var(--qwik-primitives-spoiler-panel-min-height);
217+
}
218+
100% {
219+
height: var(--qwik-primitives-spoiler-panel-max-height);
220+
}
221+
}
222+
223+
@keyframes spoiler-panel-up {
224+
0% {
225+
height: var(--qwik-primitives-spoiler-panel-max-height);
226+
}
227+
100% {
228+
height: var(--qwik-primitives-spoiler-panel-min-height);
229+
}
230+
}
231+
```
232+
233+
### Responsive min height of panel
234+
235+
You can also pass a CSS variable to the `minHeight` property. This will allow you to vary the minimum panel height when the spoiler is closed, depending on the screen width. This is especially useful when the content you want to be visible when the spoiler is closed has different heights depending on the screen width.
236+
237+
```tsx
238+
// index.tsx
239+
import { component$, useStyles$ } from '@builder.io/qwik';
240+
import { Spoiler } from '@/components';
241+
import styles from './styles.css?inline';
242+
243+
const SpoilerDemo = component$(() => {
244+
useStyles$(styles);
245+
246+
return (
247+
<Spoiler.Root>
248+
<Spoiler.Trigger>Toggle content</Spoiler.Trigger>
249+
<Spoiler.Panel minHeight="var(--spoiler-panel-min-height)" class="spoiler-panel">
250+
<Spoiler.Content>
251+
<p>
252+
Qwik Primitives is a UI toolkit for building accessible web apps and design systems with Qwik. It provides a
253+
set of low-level UI components and primitives which can be the foundation for your design system
254+
implementation.
255+
</p>
256+
<div style={{ height: '300px', backgroundColor: 'purple' }} />
257+
</Spoiler.Content>
258+
</Spoiler.Panel>
259+
</Spoiler.Root>
260+
);
261+
});
262+
```
263+
264+
```css
265+
/* styles.css */
266+
.spoiler-panel {
267+
--spoiler-panel-min-height: 60px;
268+
}
269+
270+
@media screen and (min-width: 640px) {
271+
.spoiler-panel {
272+
--spoiler-panel-min-height: 120px;
273+
}
274+
}
275+
```
276+
277+
## Accessibility
278+
279+
### Differences to Collapsible component
280+
281+
At first glance, the `Spoiler` component does not differ much from the `Collapsible` component. The main difference you may notice is that the `Spoiler` component allows you to set a minimum panel height. However, in a situation where you want to hide the content and then show it your first choice should be the `Collapsible` component. This is related to accessibility in case the `Collapsible` component is closed its content is removed from the accessibility tree while when the `Spoiler` component is closed its content is still visible in the accessibility tree.
282+
283+
### Keyboard Interactions
284+
285+
| Key | Description |
286+
| ------- | ------------------------- |
287+
| `Space` | Opens/closes the spoiler. |
288+
| `Enter` | Opens/closes the spoiler. |
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type { SpoilerRootProps as RootProps } from './spoiler-root';
2+
export type { SpoilerTriggerProps as TriggerProps } from './spoiler-trigger';
3+
export type { SpoilerPanelProps as PanelProps } from './spoiler-panel';
4+
export type { SpoilerContentProps as ContentProps } from './spoiler-content';
5+
6+
export { SpoilerRoot as Root } from './spoiler-root';
7+
export { SpoilerTrigger as Trigger } from './spoiler-trigger';
8+
export { SpoilerPanel as Panel } from './spoiler-panel';
9+
export { SpoilerContent as Content } from './spoiler-content';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { SpoilerContentProps } from './spoiler-content.types';
2+
export { SpoilerContent } from './spoiler-content';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { SpoilerContentProps } from './spoiler-content.types';
2+
import { component$, useContext, useSignal, useTask$, Slot } from '@builder.io/qwik';
3+
import { isServer } from '@builder.io/qwik/build';
4+
import { composeRefs } from '@/utilities/compose-refs';
5+
import { SpoilerContext } from '../spoiler-context';
6+
7+
/**
8+
* The component that contains the spoiler content.
9+
* Must be rendered inside `Spoiler.Panel`.
10+
* This component is based on the `div` element.
11+
*/
12+
export const SpoilerContent = component$<SpoilerContentProps>((props) => {
13+
const { as, ref, ...others } = props;
14+
15+
const { isOpen, contentHeight, disabled } = useContext(SpoilerContext);
16+
17+
const contentRef = useSignal<HTMLElement | undefined>(undefined);
18+
19+
useTask$(({ track, cleanup }) => {
20+
track(() => isOpen.value);
21+
22+
if (isServer) return;
23+
24+
if (contentRef.value) {
25+
contentHeight.value = contentRef.value.offsetHeight;
26+
}
27+
28+
if (contentRef.value) {
29+
const resizeObserver = new ResizeObserver((entries) => {
30+
if (!Array.isArray(entries)) return;
31+
if (!entries.length) return;
32+
33+
const entry = entries[0];
34+
35+
let height: number | undefined = undefined;
36+
37+
if ('borderBoxSize' in entry) {
38+
const borderSizeEntry = entry['borderBoxSize'];
39+
const borderSize = Array.isArray(borderSizeEntry) ? borderSizeEntry[0] : borderSizeEntry;
40+
41+
height = borderSize['blockSize'];
42+
} else {
43+
if (contentRef.value) {
44+
height = contentRef.value.offsetHeight;
45+
}
46+
}
47+
48+
contentHeight.value = height;
49+
});
50+
51+
resizeObserver.observe(contentRef.value, { box: 'border-box' });
52+
53+
cleanup(() => {
54+
if (contentRef.value) {
55+
resizeObserver.unobserve(contentRef.value);
56+
}
57+
});
58+
}
59+
});
60+
61+
const Component = as || 'div';
62+
63+
return (
64+
<Component
65+
ref={composeRefs([ref, contentRef])}
66+
data-qwik-primitives-spoiler-content=""
67+
data-scope="spoiler"
68+
data-part="content"
69+
data-state={isOpen.value ? 'open' : 'closed'}
70+
data-disabled={disabled ? '' : undefined}
71+
{...others}
72+
>
73+
<Slot />
74+
</Component>
75+
);
76+
});

0 commit comments

Comments
 (0)