paris 0.18.1 → 0.20.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +31 -2
  3. package/src/stories/accordion/Accordion.test.tsx +140 -0
  4. package/src/stories/accordionselect/AccordionSelect.test.tsx +252 -0
  5. package/src/stories/avatar/Avatar.test.tsx +77 -0
  6. package/src/stories/button/Button.test.tsx +266 -0
  7. package/src/stories/callout/Callout.test.tsx +79 -0
  8. package/src/stories/card/Card.test.tsx +81 -0
  9. package/src/stories/cardbutton/CardButton.test.tsx +174 -0
  10. package/src/stories/checkbox/Checkbox.test.tsx +531 -0
  11. package/src/stories/combobox/Combobox.test.tsx +164 -0
  12. package/src/stories/dialog/Dialog.module.scss +2 -2
  13. package/src/stories/dialog/Dialog.test.tsx +244 -0
  14. package/src/stories/drawer/Drawer.module.scss +2 -2
  15. package/src/stories/drawer/Drawer.test.tsx +259 -0
  16. package/src/stories/field/Field.test.tsx +146 -0
  17. package/src/stories/icon/Icon.test.tsx +59 -0
  18. package/src/stories/informationaltooltip/InformationalTooltip.test.tsx +178 -0
  19. package/src/stories/input/Input.test.tsx +174 -0
  20. package/src/stories/markdown/Markdown.test.tsx +228 -0
  21. package/src/stories/markdowneditor/FixedToolbar.module.scss +24 -0
  22. package/src/stories/markdowneditor/FixedToolbar.tsx +274 -0
  23. package/src/stories/markdowneditor/FloatingToolbar.module.scss +21 -0
  24. package/src/stories/markdowneditor/FloatingToolbar.tsx +94 -0
  25. package/src/stories/markdowneditor/LinkPopover.module.scss +124 -0
  26. package/src/stories/markdowneditor/LinkPopover.tsx +135 -0
  27. package/src/stories/markdowneditor/MarkdownEditor.module.scss +405 -0
  28. package/src/stories/markdowneditor/MarkdownEditor.stories.tsx +226 -0
  29. package/src/stories/markdowneditor/MarkdownEditor.test.tsx +115 -0
  30. package/src/stories/markdowneditor/MarkdownEditor.tsx +133 -0
  31. package/src/stories/markdowneditor/MarkdownEditorContext.tsx +20 -0
  32. package/src/stories/markdowneditor/ToolbarButton.module.scss +35 -0
  33. package/src/stories/markdowneditor/ToolbarButton.tsx +52 -0
  34. package/src/stories/markdowneditor/features.ts +92 -0
  35. package/src/stories/markdowneditor/index.ts +12 -0
  36. package/src/stories/markdowneditor/useMarkdownEditor.ts +75 -0
  37. package/src/stories/menu/Menu.module.scss +1 -1
  38. package/src/stories/menu/Menu.test.tsx +211 -0
  39. package/src/stories/pagination/usePagination.test.ts +259 -0
  40. package/src/stories/popover/Popover.test.tsx +152 -0
  41. package/src/stories/select/Select.module.scss +2 -1
  42. package/src/stories/select/Select.test.tsx +233 -0
  43. package/src/stories/styledlink/StyledLink.test.tsx +59 -0
  44. package/src/stories/table/Table.test.tsx +156 -0
  45. package/src/stories/tabs/Tabs.module.scss +1 -3
  46. package/src/stories/tabs/Tabs.test.tsx +167 -0
  47. package/src/stories/tabs/Tabs.tsx +9 -0
  48. package/src/stories/tag/Tag.test.tsx +90 -0
  49. package/src/stories/text/Text.test.tsx +81 -0
  50. package/src/stories/textarea/TextArea.test.tsx +147 -0
  51. package/src/stories/theme/themes.ts +16 -0
  52. package/src/stories/tilt/Tilt.test.tsx +203 -0
  53. package/src/stories/toast/Toast.test.tsx +86 -0
  54. package/src/stories/utility/Dropdown.module.scss +1 -1
  55. package/src/stories/utility/Utility.test.tsx +96 -0
  56. package/src/test/render.tsx +20 -0
  57. package/src/test/setup.ts +32 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # paris
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f40892e: Add semantic z-index layer tokens (`Theme.new.layers`) with six named layers: below, sticky, dropdown, overlay, popover, menu. Migrate all hardcoded z-index values to use CSS variable tokens. Fix stacking conflict where SegmentedControl labels bled through GlassTabs sticky bar.
8
+
9
+ ### Patch Changes
10
+
11
+ - 680364a: MarkdownEditor: fix task list checkbox alignment, fix duplicate link extension, fix link popover not appearing, add handleImageUpload prop for file-based image insertion, match checkbox styling to Paris Checkbox component, use Lucide icons for toolbar, and update documentation.
12
+
13
+ ## 0.19.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 4a511cc: Add MarkdownEditor component — a WYSIWYG rich text editor built on Tiptap that outputs markdown. Features composable toolbars (FixedToolbar, FloatingToolbar), controlled value/onChange with markdown strings, feature gating via `features` prop, and Paris-styled content rendering. Adds lucide-react for toolbar icons.
18
+
3
19
  ## 0.18.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "paris",
3
3
  "author": "Sanil Chawla <sanil@slingshot.fm> (https://sanil.co)",
4
4
  "description": "Paris is Slingshot's React design system. It's a collection of reusable components, design tokens, and guidelines that help us build consistent, accessible, and performant user interfaces.",
5
- "version": "0.18.1",
5
+ "version": "0.20.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -36,6 +36,12 @@
36
36
  "generate:text": "ts-node --esm ./scripts/text.ts",
37
37
  "release": "bun run generate:exports && bunx changeset publish",
38
38
  "prepare": "test -n \"$CI\" || lefthook install",
39
+ "test": "vitest run --project unit",
40
+ "test:watch": "vitest --project unit",
41
+ "test:coverage": "vitest run --project unit --coverage",
42
+ "test:storybook": "vitest run --project storybook",
43
+ "test:all": "vitest run",
44
+ "test:coverage-check": "node scripts/checkTestCoverage.js",
39
45
  "typecheck": "tsc --noEmit",
40
46
  "lint": "biome check .",
41
47
  "lint:fix": "biome check --write .",
@@ -62,6 +68,7 @@
62
68
  "./informationaltooltip": "./src/stories/informationaltooltip/index.ts",
63
69
  "./input": "./src/stories/input/index.ts",
64
70
  "./markdown": "./src/stories/markdown/index.ts",
71
+ "./markdowneditor": "./src/stories/markdowneditor/index.ts",
65
72
  "./menu": "./src/stories/menu/index.ts",
66
73
  "./pagination": "./src/stories/pagination/index.ts",
67
74
  "./popover": "./src/stories/popover/index.ts",
@@ -87,9 +94,22 @@
87
94
  "@headlessui/react": "^2.2.4",
88
95
  "@radix-ui/react-checkbox": "^1.3.3",
89
96
  "@radix-ui/react-tooltip": "^1.2.8",
97
+ "@tiptap/extension-image": "^3.22.2",
98
+ "@tiptap/extension-link": "^3.22.2",
99
+ "@tiptap/extension-placeholder": "^3.22.2",
100
+ "@tiptap/extension-table": "^3.22.2",
101
+ "@tiptap/extension-table-cell": "^3.22.2",
102
+ "@tiptap/extension-table-header": "^3.22.2",
103
+ "@tiptap/extension-table-row": "^3.22.2",
104
+ "@tiptap/extension-task-item": "^3.22.2",
105
+ "@tiptap/extension-task-list": "^3.22.2",
106
+ "@tiptap/markdown": "^3.22.2",
107
+ "@tiptap/react": "^3.22.2",
108
+ "@tiptap/starter-kit": "^3.22.2",
90
109
  "clsx": "^1.2.1",
91
110
  "font-color-contrast": "^11.1.0",
92
111
  "framer-motion": "^12.24.10",
112
+ "lucide-react": "^1.7.0",
93
113
  "pte": "^0.5.0",
94
114
  "react-hot-toast": "^2.4.1",
95
115
  "react-markdown": "^10.1.0",
@@ -114,18 +134,26 @@
114
134
  "@ssh/csstypes": "^1.1.0",
115
135
  "@storybook/addon-docs": "10.3.4",
116
136
  "@storybook/addon-links": "10.3.4",
137
+ "@storybook/addon-vitest": "10.3.4",
117
138
  "@storybook/nextjs-vite": "10.3.4",
139
+ "@testing-library/jest-dom": "^6.9.1",
140
+ "@testing-library/react": "^16.3.2",
141
+ "@testing-library/user-event": "^14.6.1",
118
142
  "@types/node": "^22.0.0",
119
143
  "@types/react": "^19",
120
144
  "@types/react-dom": "^19",
145
+ "@vitest/browser-playwright": "4.1.2",
146
+ "@vitest/coverage-v8": "^4.1.2",
121
147
  "autoprefixer": "^10.4.14",
122
148
  "change-case": "^4.1.2",
123
149
  "csstype": "^3.1.2",
124
150
  "esbuild-sass-plugin": "^2.16.0",
151
+ "jsdom": "^29.0.1",
125
152
  "jss": "^10.10.0",
126
153
  "jss-preset-default": "^10.10.0",
127
154
  "lefthook": "^1.11.13",
128
155
  "next": "^16.2.2",
156
+ "playwright": "^1.59.1",
129
157
  "react": "^19.0.0",
130
158
  "react-dom": "^19.0.0",
131
159
  "sass": "^1.62.1",
@@ -136,6 +164,7 @@
136
164
  "tsup": "^7.2.0",
137
165
  "type-fest": "^3.10.0",
138
166
  "typescript": "^5.2.2",
139
- "vite": "^7.0.0"
167
+ "vite": "^7.0.0",
168
+ "vitest": "^4.1.2"
140
169
  }
141
170
  }
@@ -0,0 +1,140 @@
1
+ import { render, screen, waitFor } from '../../test/render';
2
+ import { Accordion } from './Accordion';
3
+
4
+ describe('Accordion', () => {
5
+ it('renders with a title', () => {
6
+ render(<Accordion title="My Accordion" />);
7
+ expect(screen.getByText('My Accordion')).toBeInTheDocument();
8
+ });
9
+
10
+ it('does not show children when collapsed', () => {
11
+ render(<Accordion title="Title">Hidden content</Accordion>);
12
+ expect(screen.queryByText('Hidden content')).not.toBeInTheDocument();
13
+ });
14
+
15
+ it('expands on click to reveal children', async () => {
16
+ const { user } = render(<Accordion title="Title">Revealed content</Accordion>);
17
+ await user.click(screen.getByRole('button'));
18
+ expect(screen.getByText('Revealed content')).toBeInTheDocument();
19
+ });
20
+
21
+ it('collapses on second click', async () => {
22
+ const { user } = render(<Accordion title="Title">Toggle content</Accordion>);
23
+ const button = screen.getByRole('button');
24
+
25
+ await user.click(button);
26
+ expect(screen.getByText('Toggle content')).toBeInTheDocument();
27
+
28
+ await user.click(button);
29
+ // AnimatePresence exit animation may keep element mounted briefly
30
+ await waitFor(() => {
31
+ expect(screen.queryByText('Toggle content')).not.toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ it('expands on Enter key', async () => {
36
+ const { user } = render(<Accordion title="Title">Keyboard content</Accordion>);
37
+ screen.getByRole('button').focus();
38
+ await user.keyboard('{Enter}');
39
+ expect(screen.getByText('Keyboard content')).toBeInTheDocument();
40
+ });
41
+
42
+ it('expands on Space key', async () => {
43
+ const { user } = render(<Accordion title="Title">Space content</Accordion>);
44
+ screen.getByRole('button').focus();
45
+ await user.keyboard(' ');
46
+ expect(screen.getByText('Space content')).toBeInTheDocument();
47
+ });
48
+
49
+ it('calls onOpenChange when toggled (uncontrolled)', async () => {
50
+ const onChange = vi.fn();
51
+ const { user } = render(
52
+ <Accordion title="Title" onOpenChange={onChange}>
53
+ Content
54
+ </Accordion>,
55
+ );
56
+ await user.click(screen.getByRole('button'));
57
+ expect(onChange).toHaveBeenCalledWith(true);
58
+
59
+ await user.click(screen.getByRole('button'));
60
+ expect(onChange).toHaveBeenCalledWith(false);
61
+ expect(onChange).toHaveBeenCalledTimes(2);
62
+ });
63
+
64
+ it('works as a controlled component', async () => {
65
+ const onChange = vi.fn();
66
+ const { rerender, user } = render(
67
+ <Accordion title="Title" isOpen={false} onOpenChange={onChange}>
68
+ Controlled content
69
+ </Accordion>,
70
+ );
71
+
72
+ expect(screen.queryByText('Controlled content')).not.toBeInTheDocument();
73
+
74
+ // Open externally
75
+ rerender(
76
+ <Accordion title="Title" isOpen={true} onOpenChange={onChange}>
77
+ Controlled content
78
+ </Accordion>,
79
+ );
80
+ expect(screen.getByText('Controlled content')).toBeInTheDocument();
81
+
82
+ // Click should call onOpenChange but not change state (controlled)
83
+ await user.click(screen.getByRole('button'));
84
+ expect(onChange).toHaveBeenCalledWith(false);
85
+ });
86
+
87
+ it('renders with kind="card"', () => {
88
+ render(
89
+ <Accordion title="Card Accordion" kind="card">
90
+ Card content
91
+ </Accordion>,
92
+ );
93
+ expect(screen.getByText('Card Accordion')).toBeInTheDocument();
94
+ });
95
+
96
+ it('renders with kind="default" by default', () => {
97
+ const { container } = render(<Accordion title="Default" />);
98
+ expect(container.firstElementChild).toHaveClass('default');
99
+ });
100
+
101
+ it('applies custom className via overrides', () => {
102
+ const { container } = render(
103
+ <Accordion title="Styled" overrides={{ container: { className: 'custom-class' } }}>
104
+ Styled content
105
+ </Accordion>,
106
+ );
107
+ expect(container.firstElementChild).toHaveClass('custom-class');
108
+ });
109
+
110
+ it('sets data-state attribute on the title container', () => {
111
+ render(<Accordion title="Title">Content</Accordion>);
112
+ const button = screen.getByRole('button');
113
+ expect(button).toHaveAttribute('data-state', 'closed');
114
+ });
115
+
116
+ it('updates data-state to open when expanded', async () => {
117
+ const { user } = render(<Accordion title="Title">Content</Accordion>);
118
+ const button = screen.getByRole('button');
119
+ await user.click(button);
120
+ expect(button).toHaveAttribute('data-state', 'open');
121
+ });
122
+
123
+ it('renders with size="large" for card kind', () => {
124
+ render(
125
+ <Accordion title="Large Card" kind="card" size="large">
126
+ Large content
127
+ </Accordion>,
128
+ );
129
+ expect(screen.getByRole('button')).toHaveClass('large');
130
+ });
131
+
132
+ it('starts open when isOpen is true initially', () => {
133
+ render(
134
+ <Accordion title="Pre-opened" isOpen={true}>
135
+ Visible from start
136
+ </Accordion>,
137
+ );
138
+ expect(screen.getByText('Visible from start')).toBeInTheDocument();
139
+ });
140
+ });
@@ -0,0 +1,252 @@
1
+ import { useState } from 'react';
2
+ import { render, screen, waitFor } from '../../test/render';
3
+ import type { AccordionSelectOption } from './AccordionSelect';
4
+ import { AccordionSelect } from './AccordionSelect';
5
+
6
+ const options: AccordionSelectOption[] = [
7
+ { id: 'champagne', node: 'In an alleyway, drinking champagne' },
8
+ { id: 'rooftop', node: 'On a rooftop, watching the sunset' },
9
+ { id: 'garden', node: 'In a garden, under the stars' },
10
+ ];
11
+
12
+ function ControlledAccordionSelect(props: Partial<React.ComponentProps<typeof AccordionSelect>>) {
13
+ const [value, setValue] = useState<string | null>((props.value as string | null) ?? null);
14
+
15
+ return (
16
+ <AccordionSelect
17
+ options={options}
18
+ value={value}
19
+ onChange={(opt) => {
20
+ setValue(opt.id);
21
+ props.onChange?.(opt);
22
+ }}
23
+ {...props}
24
+ />
25
+ );
26
+ }
27
+
28
+ describe('AccordionSelect', () => {
29
+ it('renders with placeholder when no value is selected', () => {
30
+ render(<AccordionSelect options={options} placeholder="Where were we?" />);
31
+ expect(screen.getByText('Where were we?')).toBeInTheDocument();
32
+ });
33
+
34
+ it('renders default placeholder when none is provided', () => {
35
+ render(<AccordionSelect options={options} />);
36
+ expect(screen.getByText('Select an option')).toBeInTheDocument();
37
+ });
38
+
39
+ it('displays the selected option in the header', () => {
40
+ render(<AccordionSelect options={options} value="champagne" />);
41
+ expect(screen.getByText('In an alleyway, drinking champagne')).toBeInTheDocument();
42
+ });
43
+
44
+ it('expands to show all options when header is clicked', async () => {
45
+ const { user } = render(<AccordionSelect options={options} value="champagne" />);
46
+
47
+ await user.click(screen.getByRole('button'));
48
+
49
+ await waitFor(() => {
50
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
51
+ expect(screen.getByText('In a garden, under the stars')).toBeInTheDocument();
52
+ });
53
+ });
54
+
55
+ it('calls onChange when an option is selected', async () => {
56
+ const handleChange = vi.fn();
57
+ const { user } = render(<ControlledAccordionSelect value="champagne" onChange={handleChange} />);
58
+
59
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
60
+
61
+ await waitFor(() => {
62
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
63
+ });
64
+
65
+ await user.click(screen.getByText('On a rooftop, watching the sunset'));
66
+
67
+ expect(handleChange).toHaveBeenCalledWith(
68
+ expect.objectContaining({ id: 'rooftop', node: 'On a rooftop, watching the sunset' }),
69
+ );
70
+ });
71
+
72
+ it('closes the accordion after selection when closeOnSelect is true (default)', async () => {
73
+ const { user } = render(<ControlledAccordionSelect value="champagne" />);
74
+
75
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
76
+ await waitFor(() => {
77
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
78
+ });
79
+
80
+ await user.click(screen.getByText('On a rooftop, watching the sunset'));
81
+
82
+ await waitFor(() => {
83
+ expect(screen.queryByRole('button', { name: /garden/i })).not.toBeInTheDocument();
84
+ });
85
+ });
86
+
87
+ it('keeps the accordion open after selection when closeOnSelect is false', async () => {
88
+ const { user } = render(<ControlledAccordionSelect value="champagne" closeOnSelect={false} />);
89
+
90
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
91
+ await waitFor(() => {
92
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
93
+ });
94
+
95
+ await user.click(screen.getByText('On a rooftop, watching the sunset'));
96
+
97
+ expect(screen.getByText('In a garden, under the stars')).toBeInTheDocument();
98
+ });
99
+
100
+ it('toggles open and closed on header click', async () => {
101
+ const { user } = render(<AccordionSelect options={options} value="champagne" />);
102
+
103
+ const header = screen.getByRole('button');
104
+
105
+ // Open
106
+ await user.click(header);
107
+ await waitFor(() => {
108
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
109
+ });
110
+
111
+ // Close
112
+ await user.click(header);
113
+ await waitFor(() => {
114
+ expect(screen.queryByRole('button', { name: /rooftop/i })).not.toBeInTheDocument();
115
+ });
116
+ });
117
+
118
+ it('supports keyboard interaction on the header (Enter)', async () => {
119
+ const { user } = render(<AccordionSelect options={options} value="champagne" />);
120
+
121
+ const header = screen.getByRole('button');
122
+ header.focus();
123
+ await user.keyboard('{Enter}');
124
+
125
+ await waitFor(() => {
126
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
127
+ });
128
+ });
129
+
130
+ it('supports keyboard interaction on the header (Space)', async () => {
131
+ const { user } = render(<AccordionSelect options={options} value="champagne" />);
132
+
133
+ const header = screen.getByRole('button');
134
+ header.focus();
135
+ await user.keyboard(' ');
136
+
137
+ await waitFor(() => {
138
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
139
+ });
140
+ });
141
+
142
+ it('renders disabled options as disabled buttons', async () => {
143
+ const disabledOptions = [...options, { id: 'nowhere', node: 'Nowhere, it was all a dream', disabled: true }];
144
+ const { user } = render(<AccordionSelect options={disabledOptions} value="champagne" />);
145
+
146
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
147
+
148
+ await waitFor(() => {
149
+ const disabledButton = screen.getByText('Nowhere, it was all a dream').closest('button');
150
+ expect(disabledButton).toBeDisabled();
151
+ });
152
+ });
153
+
154
+ it('calls onOpenChange when the accordion opens or closes', async () => {
155
+ const handleOpenChange = vi.fn();
156
+ const { user, container } = render(
157
+ <AccordionSelect options={options} value="champagne" onOpenChange={handleOpenChange} />,
158
+ );
159
+
160
+ // Click the header (the div[role=button][tabindex=0]) to open
161
+ const header = container.querySelector('[role="button"][tabindex="0"]') as HTMLElement;
162
+ await user.click(header);
163
+ expect(handleOpenChange).toHaveBeenCalledWith(true);
164
+
165
+ // Click the header again to close
166
+ await user.click(header);
167
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
168
+ });
169
+
170
+ it('renders custom content via renderSelected', () => {
171
+ render(
172
+ <AccordionSelect
173
+ options={options}
174
+ value="champagne"
175
+ renderSelected={(opt) => <span data-testid="custom-selected">{opt.id}</span>}
176
+ />,
177
+ );
178
+ expect(screen.getByTestId('custom-selected')).toHaveTextContent('champagne');
179
+ });
180
+
181
+ it('renders custom content via renderOption', async () => {
182
+ const { user } = render(
183
+ <AccordionSelect
184
+ options={options}
185
+ value="champagne"
186
+ renderOption={(opt, isSelected) => (
187
+ <span data-testid={`custom-opt-${opt.id}`}>
188
+ {opt.id} {isSelected ? '(selected)' : ''}
189
+ </span>
190
+ )}
191
+ />,
192
+ );
193
+
194
+ await user.click(screen.getByRole('button'));
195
+
196
+ await waitFor(() => {
197
+ expect(screen.getByTestId('custom-opt-champagne')).toHaveTextContent('champagne (selected)');
198
+ expect(screen.getByTestId('custom-opt-rooftop')).toHaveTextContent('rooftop');
199
+ });
200
+ });
201
+
202
+ it('renders an action at the bottom of the dropdown', async () => {
203
+ const { user } = render(
204
+ <AccordionSelect options={options} value="champagne" action={<button type="button">Add new</button>} />,
205
+ );
206
+
207
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
208
+
209
+ await waitFor(() => {
210
+ expect(screen.getByText('Add new')).toBeInTheDocument();
211
+ });
212
+ });
213
+
214
+ it('renders a label in the header', () => {
215
+ render(
216
+ <AccordionSelect
217
+ options={options}
218
+ value="champagne"
219
+ label={<span data-testid="header-label">Active</span>}
220
+ />,
221
+ );
222
+ expect(screen.getByTestId('header-label')).toHaveTextContent('Active');
223
+ });
224
+
225
+ it('closes on outside click when closeOnClickOutside is true (default)', async () => {
226
+ const { user } = render(
227
+ <div>
228
+ <AccordionSelect options={options} value="champagne" />
229
+ <button type="button">Outside</button>
230
+ </div>,
231
+ );
232
+
233
+ // Open
234
+ await user.click(screen.getByRole('button', { name: /champagne/i }));
235
+ await waitFor(() => {
236
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
237
+ });
238
+
239
+ // Click outside
240
+ await user.click(screen.getByText('Outside'));
241
+ await waitFor(() => {
242
+ expect(screen.queryByRole('button', { name: /rooftop/i })).not.toBeInTheDocument();
243
+ });
244
+ });
245
+
246
+ it('respects controlled isOpen prop', () => {
247
+ render(<AccordionSelect options={options} value="champagne" isOpen />);
248
+ // When isOpen=true, dropdown content should be rendered
249
+ expect(screen.getByText('On a rooftop, watching the sunset')).toBeInTheDocument();
250
+ expect(screen.getByText('In a garden, under the stars')).toBeInTheDocument();
251
+ });
252
+ });
@@ -0,0 +1,77 @@
1
+ import { render, screen } from '../../test/render';
2
+ import { Avatar } from './Avatar';
3
+
4
+ describe('Avatar', () => {
5
+ it('renders children', () => {
6
+ render(
7
+ <Avatar>
8
+ <img src="/photo.jpg" alt="User" />
9
+ </Avatar>,
10
+ );
11
+ expect(screen.getByAltText('User')).toBeInTheDocument();
12
+ });
13
+
14
+ it('renders text children (initials)', () => {
15
+ render(<Avatar>AB</Avatar>);
16
+ expect(screen.getByText('AB')).toBeInTheDocument();
17
+ });
18
+
19
+ it('applies content class', () => {
20
+ const { container } = render(<Avatar>AB</Avatar>);
21
+ expect(container.firstElementChild).toHaveClass('content');
22
+ });
23
+
24
+ it('forwards className', () => {
25
+ const { container } = render(<Avatar className="extra">AB</Avatar>);
26
+ expect(container.firstElementChild).toHaveClass('content', 'extra');
27
+ });
28
+
29
+ it('sets fit-content width by default', () => {
30
+ const { container } = render(<Avatar>AB</Avatar>);
31
+ expect(container.firstElementChild).toHaveStyle({ width: 'fit-content' });
32
+ });
33
+
34
+ it('sets numeric width in pixels', () => {
35
+ const { container } = render(<Avatar width={48}>AB</Avatar>);
36
+ expect(container.firstElementChild).toHaveStyle({ width: '48px' });
37
+ });
38
+
39
+ it('sets string width as-is', () => {
40
+ const { container } = render(<Avatar width="3rem">AB</Avatar>);
41
+ expect(container.firstElementChild).toHaveStyle({ width: '3rem' });
42
+ });
43
+
44
+ it('applies frame color as CSS variable', () => {
45
+ const { container } = render(<Avatar frameColor="red">AB</Avatar>);
46
+ const el = container.firstElementChild as HTMLElement;
47
+ expect(el.style.getPropertyValue('--frame-color')).toBe('red');
48
+ });
49
+
50
+ it('applies default frame color CSS variable', () => {
51
+ const { container } = render(<Avatar>AB</Avatar>);
52
+ const el = container.firstElementChild as HTMLElement;
53
+ // Default is pvar('new.colors.borderMedium') which produces a var() string
54
+ expect(el.style.getPropertyValue('--frame-color')).toBeTruthy();
55
+ });
56
+
57
+ it('forwards HTML div attributes', () => {
58
+ render(
59
+ <Avatar data-testid="avatar" id="avatar-1">
60
+ AB
61
+ </Avatar>,
62
+ );
63
+ const avatar = screen.getByTestId('avatar');
64
+ expect(avatar).toHaveAttribute('id', 'avatar-1');
65
+ });
66
+
67
+ it('merges custom style with computed styles', () => {
68
+ const { container } = render(
69
+ <Avatar style={{ border: '2px solid blue' }} width={64}>
70
+ AB
71
+ </Avatar>,
72
+ );
73
+ const el = container.firstElementChild as HTMLElement;
74
+ expect(el.style.border).toBe('2px solid blue');
75
+ expect(el.style.width).toBe('64px');
76
+ });
77
+ });