@boostdev/design-system-components 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -0,0 +1,59 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './Collapsible.stories';
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # Collapsible
7
+
8
+ Disclosure widget that shows or hides content. Built on the native `<details>`/`<summary>` HTML elements — no JavaScript required for basic toggle behaviour.
9
+
10
+ ## When to use
11
+ - A single section of content that users may or may not need (FAQs, help text, optional details)
12
+ - When you want progressive disclosure without routing to a new page
13
+
14
+ ## When not to use
15
+ - Multiple interrelated sections with complex state — use `Accordion` instead
16
+ - Navigation or tabbed views — use `Tabs`
17
+
18
+ ## Examples
19
+
20
+ ### Default
21
+ <Canvas of={Stories.Default} />
22
+
23
+ ### Starts open
24
+ <Canvas of={Stories.DefaultOpen} />
25
+
26
+ ### Grouped (accordion behaviour)
27
+ Use the `name` prop to link multiple Collapsibles so only one can be open at a time — this uses the native `<details name="…">` attribute.
28
+ <Canvas of={Stories.Group} />
29
+
30
+ ### Rich summary content
31
+ <Canvas of={Stories.RichSummary} />
32
+
33
+ ## Props
34
+
35
+ <ArgTypes of={Stories} />
36
+
37
+ ## CSS variables
38
+
39
+ <table>
40
+ <thead>
41
+ <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
42
+ </thead>
43
+ <tbody>
44
+ <tr><td>`--collapsible_border-color`</td><td>`var(--color_bg--subtle)`</td><td>Border and separator colour</td></tr>
45
+ <tr><td>`--collapsible_border-width`</td><td>`1px`</td><td>Border and separator width</td></tr>
46
+ <tr><td>`--collapsible_border-radius`</td><td>`var(--border_radius--m)`</td><td>Corner radius</td></tr>
47
+ <tr><td>`--collapsible_bg`</td><td>`var(--color_bg)`</td><td>Summary background</td></tr>
48
+ <tr><td>`--collapsible_bg--hover`</td><td>`var(--color_bg--subtle)`</td><td>Summary background on hover</td></tr>
49
+ <tr><td>`--collapsible_color`</td><td>`var(--color_on-bg)`</td><td>Summary text colour</td></tr>
50
+ <tr><td>`--collapsible_on-color`</td><td>`var(--color_on-bg)`</td><td>Content text colour</td></tr>
51
+ </tbody>
52
+ </table>
53
+
54
+ ## Accessibility
55
+
56
+ - Uses semantic `<details>`/`<summary>` — the browser applies `role="group"` implicitly
57
+ - Keyboard: `Enter` or `Space` on the focused summary toggles the panel
58
+ - Arrow keys do **not** move between grouped Collapsibles (unlike `Accordion`); use `Tab` to navigate
59
+ - No `aria-expanded` needed — the native element communicates state to screen readers
@@ -0,0 +1,76 @@
1
+ @layer component {
2
+ .collapsible {
3
+ border: var(--collapsible_border-width, 1px) solid var(--collapsible_border-color, var(--color_bg--subtle));
4
+ border-radius: var(--collapsible_border-radius, var(--border_radius--m));
5
+ overflow: hidden;
6
+ }
7
+
8
+ .summary {
9
+ list-style: none; /* removes native disclosure triangle */
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: var(--space_m);
14
+ padding: var(--space_m);
15
+ cursor: pointer;
16
+ font-weight: var(--font_weight--semibold);
17
+ font-size: var(--font_size--body);
18
+ color: var(--collapsible_color, var(--color_on-bg));
19
+ background-color: var(--collapsible_bg, var(--color_bg));
20
+ user-select: none;
21
+ transition: var(--animation_transition);
22
+ }
23
+
24
+ /* Remove WebKit disclosure triangle */
25
+ .summary::-webkit-details-marker {
26
+ display: none;
27
+ }
28
+
29
+ .summary:focus-visible {
30
+ outline: var(--outline_default);
31
+ outline-offset: calc(var(--outline_offset) * -1);
32
+ }
33
+
34
+ @media (hover: hover) and (pointer: fine) {
35
+ .summary:hover {
36
+ background-color: var(--collapsible_bg--hover, var(--color_bg--subtle));
37
+ }
38
+ }
39
+
40
+ /* Separator when open */
41
+ .collapsible[open] > .summary {
42
+ border-block-end: var(--collapsible_border-width, 1px) solid var(--collapsible_border-color, var(--color_bg--subtle));
43
+ }
44
+
45
+ .summaryContent {
46
+ flex: 1;
47
+ }
48
+
49
+ /* Chevron drawn with CSS borders */
50
+ .icon {
51
+ width: 0.5rem;
52
+ height: 0.5rem;
53
+ border-right: 2px solid currentcolor;
54
+ border-bottom: 2px solid currentcolor;
55
+ transform: rotate(45deg);
56
+ flex-shrink: 0;
57
+ transition: transform var(--animation_transition-duration) var(--animation_easing);
58
+ }
59
+
60
+ .collapsible[open] > .summary .icon {
61
+ transform: rotate(-135deg);
62
+ }
63
+
64
+ .content {
65
+ padding: var(--space_m);
66
+ color: var(--collapsible_on-color, var(--color_on-bg));
67
+ font-size: var(--font_size--body);
68
+ line-height: var(--font_line-height--body);
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .icon {
73
+ transition: none;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,75 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { Collapsible } from './Collapsible';
4
+
5
+ describe('Collapsible', () => {
6
+ it('renders summary text', () => {
7
+ render(<Collapsible summary="Toggle me">Content</Collapsible>);
8
+ expect(screen.getByText('Toggle me')).toBeInTheDocument();
9
+ });
10
+
11
+ it('hides content by default', () => {
12
+ render(<Collapsible summary="Toggle">Hidden content</Collapsible>);
13
+ expect(screen.getByRole('group')).not.toHaveAttribute('open');
14
+ });
15
+
16
+ it('shows content when defaultOpen is true', () => {
17
+ render(<Collapsible summary="Toggle" defaultOpen>Visible content</Collapsible>);
18
+ expect(screen.getByRole('group')).toHaveAttribute('open');
19
+ });
20
+
21
+ it('expands on summary click', async () => {
22
+ const user = userEvent.setup();
23
+ render(<Collapsible summary="Toggle">Content</Collapsible>);
24
+ const details = screen.getByRole('group');
25
+ expect(details).not.toHaveAttribute('open');
26
+ await user.click(screen.getByText('Toggle'));
27
+ expect(details).toHaveAttribute('open');
28
+ });
29
+
30
+ it('collapses on second click', async () => {
31
+ const user = userEvent.setup();
32
+ render(<Collapsible summary="Toggle" defaultOpen>Content</Collapsible>);
33
+ const details = screen.getByRole('group');
34
+ await user.click(screen.getByText('Toggle'));
35
+ expect(details).not.toHaveAttribute('open');
36
+ });
37
+
38
+ it('calls onToggle with new open state when expanded', async () => {
39
+ const user = userEvent.setup();
40
+ const onToggle = vi.fn();
41
+ render(<Collapsible summary="Toggle" onToggle={onToggle}>Content</Collapsible>);
42
+ await user.click(screen.getByText('Toggle'));
43
+ expect(onToggle).toHaveBeenCalledWith(true);
44
+ });
45
+
46
+ it('calls onToggle with false when collapsed', async () => {
47
+ const user = userEvent.setup();
48
+ const onToggle = vi.fn();
49
+ render(<Collapsible summary="Toggle" defaultOpen onToggle={onToggle}>Content</Collapsible>);
50
+ await user.click(screen.getByText('Toggle'));
51
+ expect(onToggle).toHaveBeenCalledWith(false);
52
+ });
53
+
54
+ it('respects controlled open prop', () => {
55
+ render(<Collapsible summary="Toggle" open>Content</Collapsible>);
56
+ expect(screen.getByRole('group')).toHaveAttribute('open');
57
+ });
58
+
59
+ it('renders with name attribute for grouping', () => {
60
+ render(
61
+ <>
62
+ <Collapsible summary="A" name="group">Content A</Collapsible>
63
+ <Collapsible summary="B" name="group">Content B</Collapsible>
64
+ </>,
65
+ );
66
+ const groups = screen.getAllByRole('group');
67
+ expect(groups[0]).toHaveAttribute('name', 'group');
68
+ expect(groups[1]).toHaveAttribute('name', 'group');
69
+ });
70
+
71
+ it('accepts className', () => {
72
+ render(<Collapsible summary="Toggle" className="custom">Content</Collapsible>);
73
+ expect(screen.getByRole('group')).toHaveClass('custom');
74
+ });
75
+ });
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Collapsible } from './Collapsible';
3
+
4
+ const meta = {
5
+ title: 'UI/Collapsible',
6
+ component: Collapsible,
7
+ } satisfies Meta<typeof Collapsible>;
8
+
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+
12
+ export const Default: Story = {
13
+ args: {
14
+ summary: 'What is the return policy?',
15
+ children: 'You can return any item within 30 days of purchase for a full refund.',
16
+ },
17
+ };
18
+
19
+ export const DefaultOpen: Story = {
20
+ args: {
21
+ summary: 'Expanded by default',
22
+ children: 'This panel starts open. Click the summary to collapse it.',
23
+ defaultOpen: true,
24
+ },
25
+ };
26
+
27
+ export const Group: Story = {
28
+ render: () => (
29
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: '480px' }}>
30
+ <Collapsible name="faq" summary="What is your return policy?">
31
+ You can return any item within 30 days of purchase for a full refund.
32
+ </Collapsible>
33
+ <Collapsible name="faq" summary="How long does shipping take?" defaultOpen>
34
+ Standard shipping takes 3–5 business days. Express shipping is available at checkout.
35
+ </Collapsible>
36
+ <Collapsible name="faq" summary="Do you ship internationally?">
37
+ Yes, we ship to over 50 countries. International shipping times vary by destination.
38
+ </Collapsible>
39
+ </div>
40
+ ),
41
+ };
42
+
43
+ export const RichSummary: Story = {
44
+ args: {
45
+ summary: (
46
+ <span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
47
+ <strong>Advanced settings</strong>
48
+ <span style={{ fontSize: '0.75rem', opacity: 0.6 }}>optional</span>
49
+ </span>
50
+ ),
51
+ children: (
52
+ <p style={{ margin: 0 }}>
53
+ Configure advanced options here. These settings are optional and have sensible defaults.
54
+ </p>
55
+ ),
56
+ },
57
+ };
@@ -0,0 +1,55 @@
1
+ import { HTMLAttributes, ReactNode, SyntheticEvent } from 'react';
2
+ import css from './Collapsible.module.css';
3
+ import { cn } from '@boostdev/design-system-foundation';
4
+
5
+ export interface CollapsibleProps extends Omit<HTMLAttributes<HTMLDetailsElement>, 'onToggle'> {
6
+ /** The trigger label — always visible */
7
+ summary: ReactNode;
8
+ /** The content revealed when expanded */
9
+ children: ReactNode;
10
+ /** Controlled open state */
11
+ open?: boolean;
12
+ /** Uncontrolled initial open state */
13
+ defaultOpen?: boolean;
14
+ /** Called after each toggle with the new open state */
15
+ onToggle?: (open: boolean) => void;
16
+ /**
17
+ * Groups multiple Collapsible elements so only one is open at a time
18
+ * (native <details name="…"> behaviour).
19
+ */
20
+ name?: string;
21
+ className?: string;
22
+ }
23
+
24
+ export function Collapsible({
25
+ summary,
26
+ children,
27
+ open,
28
+ defaultOpen,
29
+ onToggle,
30
+ name,
31
+ className,
32
+ ...rest
33
+ }: Readonly<CollapsibleProps>) {
34
+ const handleToggle = (e: SyntheticEvent<HTMLDetailsElement>) => {
35
+ onToggle?.(e.currentTarget.open);
36
+ };
37
+
38
+ return (
39
+ <details
40
+ className={cn(css.collapsible, className)}
41
+ open={open ?? defaultOpen}
42
+ name={name}
43
+ onToggle={handleToggle}
44
+ {...rest}
45
+ >
46
+ <summary className={css.summary}>
47
+ <span className={css.summaryContent}>{summary}</span>
48
+ <span className={css.icon} aria-hidden="true" />
49
+ </summary>
50
+ <div className={css.content}>
51
+ {children}
52
+ </div>
53
+ </details>
54
+ );
55
+ }
@@ -0,0 +1,2 @@
1
+ export { Collapsible } from './Collapsible';
2
+ export type { CollapsibleProps } from './Collapsible';
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export { Avatar } from './components/ui/Avatar';
6
6
  export { Badge } from './components/ui/Badge';
7
7
  export { Breadcrumb } from './components/ui/Breadcrumb';
8
8
  export type { BreadcrumbItem } from './components/ui/Breadcrumb';
9
+ export { Collapsible } from './components/ui/Collapsible';
10
+ export type { CollapsibleProps } from './components/ui/Collapsible';
9
11
  export { Calendar } from './components/ui/Calendar';
10
12
  export { Carousel } from './components/ui/Carousel';
11
13
  export { DescriptionList } from './components/ui/DescriptionList';
@@ -20,8 +20,8 @@ import {Card} from "../components/layout/Card/index.ts";
20
20
  Accessible, token-driven React components. Zero extra runtime.
21
21
  </p>
22
22
  <ButtonGroup variant="content">
23
- <Button variant="secondary" href="https://www.npmjs.com/package/@boostdev/foundation">DS Foundation</Button>
24
- <Button variant="secondary" href="https://www.npmjs.com/package/@boostdev/components">DS Components</Button>
23
+ <Button variant="secondary" href="https://www.npmjs.com/package/@boostdev/design-system-foundation">DS Foundation</Button>
24
+ <Button variant="secondary" href="https://www.npmjs.com/package/@boostdev/design-system-components">DS Components</Button>
25
25
  </ButtonGroup>
26
26
  </div>
27
27
  ---