@nucel/ui 0.11.0 → 0.12.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.
package/README.md
CHANGED
|
@@ -217,9 +217,21 @@ bun run storybook
|
|
|
217
217
|
# Install dependencies
|
|
218
218
|
bun install
|
|
219
219
|
|
|
220
|
-
#
|
|
220
|
+
# Build = export-completeness guard + svelte-check (MUST pass before commit/publish)
|
|
221
|
+
bun run build
|
|
222
|
+
|
|
223
|
+
# Type-check only
|
|
221
224
|
bun run check
|
|
222
225
|
|
|
226
|
+
# Export-completeness guard standalone — fails if any component under
|
|
227
|
+
# src/lib/components is not reachable from the barrel (src/lib/index.ts).
|
|
228
|
+
# This prevents the regression class where a component silently drops out
|
|
229
|
+
# of the public surface and breaks the consuming app's build.
|
|
230
|
+
bun run check:exports
|
|
231
|
+
|
|
232
|
+
# Run the unit/component test suite (Vitest + Testing Library)
|
|
233
|
+
bun run test
|
|
234
|
+
|
|
223
235
|
# Run linter
|
|
224
236
|
bun run lint
|
|
225
237
|
|
|
@@ -230,6 +242,14 @@ bun run format
|
|
|
230
242
|
bun run build-storybook
|
|
231
243
|
```
|
|
232
244
|
|
|
245
|
+
### Adding a component
|
|
246
|
+
|
|
247
|
+
When you add a `.svelte` component under `src/lib/components`, you **must** also
|
|
248
|
+
export it from `src/lib/index.ts`. `bun run build` (and CI / `prepublishOnly`)
|
|
249
|
+
run `check:exports`, which fails the build if any component file is not
|
|
250
|
+
reachable from the barrel. If a file is intentionally internal, list it in the
|
|
251
|
+
`IGNORE` set in `scripts/check-exports.mjs`.
|
|
252
|
+
|
|
233
253
|
## License
|
|
234
254
|
|
|
235
255
|
MIT © Nucel Team
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nucel/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "A comprehensive Svelte 5 UI component library for Nucel projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./src/lib/index.ts",
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src/lib",
|
|
16
|
-
"src/styles.css"
|
|
16
|
+
"src/styles.css",
|
|
17
|
+
"!src/lib/**/*.test.ts",
|
|
18
|
+
"!src/lib/**/*.test.js"
|
|
17
19
|
],
|
|
18
20
|
"scripts": {
|
|
19
21
|
"dev": "vite",
|
|
20
|
-
"build": "svelte-check --tsconfig ./tsconfig.json",
|
|
22
|
+
"build": "node scripts/check-exports.mjs && svelte-check --tsconfig ./tsconfig.json",
|
|
21
23
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
24
|
+
"check:exports": "node scripts/check-exports.mjs",
|
|
22
25
|
"lint": "eslint .",
|
|
23
26
|
"format": "prettier --write .",
|
|
24
27
|
"format:check": "prettier --check .",
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
"test:watch": "vitest",
|
|
27
30
|
"storybook": "storybook dev -p 6006",
|
|
28
31
|
"build-storybook": "storybook build",
|
|
29
|
-
"prepublishOnly": "echo 'publishing @nucel/ui'"
|
|
32
|
+
"prepublishOnly": "node scripts/check-exports.mjs && echo 'publishing @nucel/ui'"
|
|
30
33
|
},
|
|
31
34
|
"repository": {
|
|
32
35
|
"type": "git",
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/svelte';
|
|
3
|
-
import { tick } from 'svelte';
|
|
4
|
-
import ColorInput from './ColorInput.svelte';
|
|
5
|
-
|
|
6
|
-
/** Grab the native color input from a rendered ColorInput. */
|
|
7
|
-
function getColorInput(container: HTMLElement): HTMLInputElement {
|
|
8
|
-
return container.querySelector('input[type="color"]') as HTMLInputElement;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('ColorInput', () => {
|
|
12
|
-
it('renders a native <input type="color"> inside the color-input slot', () => {
|
|
13
|
-
const { container } = render(ColorInput, { props: { value: '#3b82f6' } });
|
|
14
|
-
const wrapper = container.querySelector('[data-slot="color-input"]');
|
|
15
|
-
expect(wrapper).toBeInTheDocument();
|
|
16
|
-
const input = getColorInput(container);
|
|
17
|
-
expect(input).toBeInTheDocument();
|
|
18
|
-
expect(input.type).toBe('color');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('reflects the initial value on the input', () => {
|
|
22
|
-
const { container } = render(ColorInput, { props: { value: '#22c55e' } });
|
|
23
|
-
expect(getColorInput(container).value).toBe('#22c55e');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('defaults to #000000 when no value is supplied', () => {
|
|
27
|
-
const { container } = render(ColorInput, { props: {} });
|
|
28
|
-
expect(getColorInput(container).value).toBe('#000000');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('does not show the hex value by default', () => {
|
|
32
|
-
const { container } = render(ColorInput, { props: { value: '#abcdef' } });
|
|
33
|
-
const wrapper = container.querySelector('[data-slot="color-input"]')!;
|
|
34
|
-
// Only the <input> child, no trailing text span.
|
|
35
|
-
expect(wrapper.querySelectorAll('span').length).toBe(0);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('shows the hex value (uppercased via class) when showValue is set', () => {
|
|
39
|
-
const { container } = render(ColorInput, {
|
|
40
|
-
props: { value: '#abcdef', showValue: true },
|
|
41
|
-
});
|
|
42
|
-
const wrapper = container.querySelector('[data-slot="color-input"]')!;
|
|
43
|
-
const valueSpan = wrapper.querySelector('span');
|
|
44
|
-
expect(valueSpan).toBeInTheDocument();
|
|
45
|
-
expect(valueSpan).toHaveTextContent('#abcdef');
|
|
46
|
-
expect(valueSpan?.className).toContain('uppercase');
|
|
47
|
-
expect(valueSpan?.className).toContain('font-mono');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('updates the displayed hex value when the input changes', async () => {
|
|
51
|
-
const { container } = render(ColorInput, {
|
|
52
|
-
props: { value: '#000000', showValue: true },
|
|
53
|
-
});
|
|
54
|
-
const input = getColorInput(container);
|
|
55
|
-
|
|
56
|
-
input.value = '#ff8800';
|
|
57
|
-
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
58
|
-
await tick();
|
|
59
|
-
|
|
60
|
-
const valueSpan = container.querySelector('[data-slot="color-input"] span');
|
|
61
|
-
expect(valueSpan).toHaveTextContent('#ff8800');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('propagates an input change through the two-way bound value', async () => {
|
|
65
|
-
// `bind:value` drives the `showValue` span; if the bound value updates
|
|
66
|
-
// on input, the rendered hex text reflects the new colour.
|
|
67
|
-
const { container } = render(ColorInput, {
|
|
68
|
-
props: { value: '#111111', showValue: true },
|
|
69
|
-
});
|
|
70
|
-
const input = getColorInput(container);
|
|
71
|
-
|
|
72
|
-
input.value = '#00ff00';
|
|
73
|
-
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
74
|
-
await tick();
|
|
75
|
-
|
|
76
|
-
expect(input.value).toBe('#00ff00');
|
|
77
|
-
expect(container.querySelector('[data-slot="color-input"] span')).toHaveTextContent(
|
|
78
|
-
'#00ff00',
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('emits a change event consumers can listen to', async () => {
|
|
83
|
-
const onchange = vi.fn();
|
|
84
|
-
const { container } = render(ColorInput, {
|
|
85
|
-
props: { value: '#000000', onchange },
|
|
86
|
-
});
|
|
87
|
-
const input = getColorInput(container);
|
|
88
|
-
|
|
89
|
-
input.value = '#123456';
|
|
90
|
-
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
91
|
-
await tick();
|
|
92
|
-
|
|
93
|
-
expect(onchange).toHaveBeenCalledOnce();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('forwards the disabled attribute to the native input', () => {
|
|
97
|
-
const { container } = render(ColorInput, {
|
|
98
|
-
props: { value: '#ef4444', disabled: true },
|
|
99
|
-
});
|
|
100
|
-
expect(getColorInput(container)).toBeDisabled();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('merges a custom class onto the input', () => {
|
|
104
|
-
const { container } = render(ColorInput, {
|
|
105
|
-
props: { value: '#000000', class: 'my-swatch' },
|
|
106
|
-
});
|
|
107
|
-
const input = getColorInput(container);
|
|
108
|
-
expect(input.className).toContain('my-swatch');
|
|
109
|
-
// Base styling is preserved.
|
|
110
|
-
expect(input.className).toContain('rounded-md');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('honours a custom data-slot', () => {
|
|
114
|
-
const { container } = render(ColorInput, {
|
|
115
|
-
props: { value: '#000000', 'data-slot': 'theme-color' },
|
|
116
|
-
});
|
|
117
|
-
expect(container.querySelector('[data-slot="theme-color"]')).toBeInTheDocument();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('forwards arbitrary props (e.g. aria-label) to the input', () => {
|
|
121
|
-
const { getByLabelText } = render(ColorInput, {
|
|
122
|
-
props: { value: '#000000', 'aria-label': 'Pick a label colour' },
|
|
123
|
-
});
|
|
124
|
-
expect(getByLabelText('Pick a label colour')).toBeInTheDocument();
|
|
125
|
-
});
|
|
126
|
-
});
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/svelte';
|
|
3
|
-
import { tick } from 'svelte';
|
|
4
|
-
import CopyButton from './CopyButton.svelte';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Install a mock `navigator.clipboard.writeText`. jsdom does not provide a
|
|
8
|
-
* working clipboard, so we define it ourselves. Returns the spy.
|
|
9
|
-
*/
|
|
10
|
-
function mockClipboard(impl?: (text: string) => Promise<void>) {
|
|
11
|
-
const writeText = vi.fn(impl ?? (() => Promise.resolve()));
|
|
12
|
-
Object.defineProperty(navigator, 'clipboard', {
|
|
13
|
-
value: { writeText },
|
|
14
|
-
configurable: true,
|
|
15
|
-
writable: true,
|
|
16
|
-
});
|
|
17
|
-
return writeText;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('CopyButton', () => {
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
vi.useFakeTimers();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
vi.runOnlyPendingTimers();
|
|
27
|
-
vi.useRealTimers();
|
|
28
|
-
vi.restoreAllMocks();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('writes the value to the clipboard on click', async () => {
|
|
32
|
-
const writeText = mockClipboard();
|
|
33
|
-
const { getByRole } = render(CopyButton, { props: { value: 'hello-world' } });
|
|
34
|
-
|
|
35
|
-
await getByRole('button').click();
|
|
36
|
-
await tick();
|
|
37
|
-
|
|
38
|
-
expect(writeText).toHaveBeenCalledOnce();
|
|
39
|
-
expect(writeText).toHaveBeenCalledWith('hello-world');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('shows the copied state after a successful copy and reverts after timeout', async () => {
|
|
43
|
-
mockClipboard();
|
|
44
|
-
const { getByRole } = render(CopyButton, {
|
|
45
|
-
props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
|
|
46
|
-
});
|
|
47
|
-
const btn = getByRole('button');
|
|
48
|
-
|
|
49
|
-
expect(btn).toHaveTextContent('Copy');
|
|
50
|
-
expect(btn).not.toHaveAttribute('data-copied');
|
|
51
|
-
|
|
52
|
-
await btn.click();
|
|
53
|
-
await tick();
|
|
54
|
-
|
|
55
|
-
expect(btn).toHaveTextContent('Copied');
|
|
56
|
-
expect(btn).toHaveAttribute('data-copied');
|
|
57
|
-
|
|
58
|
-
// Advance past the timeout — state reverts.
|
|
59
|
-
vi.advanceTimersByTime(2000);
|
|
60
|
-
await tick();
|
|
61
|
-
|
|
62
|
-
expect(btn).toHaveTextContent('Copy');
|
|
63
|
-
expect(btn).not.toHaveAttribute('data-copied');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('respects a custom timeout before reverting', async () => {
|
|
67
|
-
mockClipboard();
|
|
68
|
-
const { getByRole } = render(CopyButton, {
|
|
69
|
-
props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 5000 },
|
|
70
|
-
});
|
|
71
|
-
const btn = getByRole('button');
|
|
72
|
-
|
|
73
|
-
await btn.click();
|
|
74
|
-
await tick();
|
|
75
|
-
expect(btn).toHaveTextContent('Copied');
|
|
76
|
-
|
|
77
|
-
// Not yet elapsed at 2s.
|
|
78
|
-
vi.advanceTimersByTime(2000);
|
|
79
|
-
await tick();
|
|
80
|
-
expect(btn).toHaveTextContent('Copied');
|
|
81
|
-
|
|
82
|
-
// Elapsed at 5s total.
|
|
83
|
-
vi.advanceTimersByTime(3000);
|
|
84
|
-
await tick();
|
|
85
|
-
expect(btn).toHaveTextContent('Copy');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('renders a visible label by default', () => {
|
|
89
|
-
mockClipboard();
|
|
90
|
-
const { getByRole } = render(CopyButton, { props: { value: 'x' } });
|
|
91
|
-
expect(getByRole('button')).toHaveTextContent('Copy');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('renders icon-only (no text) when label is false, exposing an aria-label', () => {
|
|
95
|
-
mockClipboard();
|
|
96
|
-
const { getByRole } = render(CopyButton, { props: { value: 'x', label: false } });
|
|
97
|
-
const btn = getByRole('button');
|
|
98
|
-
// No visible "Copy" text node; accessible name comes from aria-label.
|
|
99
|
-
expect(btn).toHaveAttribute('aria-label', 'Copy');
|
|
100
|
-
expect(btn.querySelector('span')).toBeNull();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('updates the icon-only aria-label to the copied label after copying', async () => {
|
|
104
|
-
mockClipboard();
|
|
105
|
-
const { getByRole } = render(CopyButton, {
|
|
106
|
-
props: { value: 'x', label: false, copiedLabel: 'Copied' },
|
|
107
|
-
});
|
|
108
|
-
const btn = getByRole('button');
|
|
109
|
-
|
|
110
|
-
await btn.click();
|
|
111
|
-
await tick();
|
|
112
|
-
|
|
113
|
-
expect(btn).toHaveAttribute('aria-label', 'Copied');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('fires the onCopy callback with the value after a successful copy', async () => {
|
|
117
|
-
mockClipboard();
|
|
118
|
-
const onCopy = vi.fn();
|
|
119
|
-
const { getByRole } = render(CopyButton, { props: { value: 'token-123', onCopy } });
|
|
120
|
-
|
|
121
|
-
await getByRole('button').click();
|
|
122
|
-
await tick();
|
|
123
|
-
|
|
124
|
-
expect(onCopy).toHaveBeenCalledOnce();
|
|
125
|
-
expect(onCopy).toHaveBeenCalledWith('token-123');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('fires onError and does NOT enter copied state when the write rejects', async () => {
|
|
129
|
-
const boom = new Error('permission denied');
|
|
130
|
-
mockClipboard(() => Promise.reject(boom));
|
|
131
|
-
const onError = vi.fn();
|
|
132
|
-
const onCopy = vi.fn();
|
|
133
|
-
const { getByRole } = render(CopyButton, {
|
|
134
|
-
props: { value: 'x', onError, onCopy },
|
|
135
|
-
});
|
|
136
|
-
const btn = getByRole('button');
|
|
137
|
-
|
|
138
|
-
await btn.click();
|
|
139
|
-
await tick();
|
|
140
|
-
|
|
141
|
-
expect(onError).toHaveBeenCalledOnce();
|
|
142
|
-
expect(onError).toHaveBeenCalledWith(boom);
|
|
143
|
-
expect(onCopy).not.toHaveBeenCalled();
|
|
144
|
-
expect(btn).not.toHaveAttribute('data-copied');
|
|
145
|
-
expect(btn).toHaveTextContent('Copy');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('hides the icon when hideIcon is set', () => {
|
|
149
|
-
mockClipboard();
|
|
150
|
-
const { getByRole } = render(CopyButton, {
|
|
151
|
-
props: { value: 'x', hideIcon: true },
|
|
152
|
-
});
|
|
153
|
-
const btn = getByRole('button');
|
|
154
|
-
expect(btn.querySelector('svg')).toBeNull();
|
|
155
|
-
expect(btn).toHaveTextContent('Copy');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('renders custom label and copiedLabel text', async () => {
|
|
159
|
-
mockClipboard();
|
|
160
|
-
const { getByRole } = render(CopyButton, {
|
|
161
|
-
props: { value: 'x', label: 'Copy URL', copiedLabel: 'Copied URL!' },
|
|
162
|
-
});
|
|
163
|
-
const btn = getByRole('button');
|
|
164
|
-
expect(btn).toHaveTextContent('Copy URL');
|
|
165
|
-
|
|
166
|
-
await btn.click();
|
|
167
|
-
await tick();
|
|
168
|
-
expect(btn).toHaveTextContent('Copied URL!');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('does not copy or change state when disabled', async () => {
|
|
172
|
-
const writeText = mockClipboard();
|
|
173
|
-
const { getByRole } = render(CopyButton, {
|
|
174
|
-
props: { value: 'x', disabled: true },
|
|
175
|
-
});
|
|
176
|
-
const btn = getByRole('button');
|
|
177
|
-
expect(btn).toBeDisabled();
|
|
178
|
-
|
|
179
|
-
await btn.click();
|
|
180
|
-
await tick();
|
|
181
|
-
|
|
182
|
-
expect(writeText).not.toHaveBeenCalled();
|
|
183
|
-
expect(btn).not.toHaveAttribute('data-copied');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('restarts the timer on a second click (debounced revert)', async () => {
|
|
187
|
-
mockClipboard();
|
|
188
|
-
const { getByRole } = render(CopyButton, {
|
|
189
|
-
props: { value: 'x', label: 'Copy', copiedLabel: 'Copied', timeout: 2000 },
|
|
190
|
-
});
|
|
191
|
-
const btn = getByRole('button');
|
|
192
|
-
|
|
193
|
-
await btn.click();
|
|
194
|
-
await tick();
|
|
195
|
-
expect(btn).toHaveTextContent('Copied');
|
|
196
|
-
|
|
197
|
-
// 1.5s in, click again — timer should reset.
|
|
198
|
-
vi.advanceTimersByTime(1500);
|
|
199
|
-
await btn.click();
|
|
200
|
-
await tick();
|
|
201
|
-
expect(btn).toHaveTextContent('Copied');
|
|
202
|
-
|
|
203
|
-
// 1s more (2.5s since first click, 1s since second) — still copied.
|
|
204
|
-
vi.advanceTimersByTime(1000);
|
|
205
|
-
await tick();
|
|
206
|
-
expect(btn).toHaveTextContent('Copied');
|
|
207
|
-
|
|
208
|
-
// Reach 2s since the second click — now reverts.
|
|
209
|
-
vi.advanceTimersByTime(1000);
|
|
210
|
-
await tick();
|
|
211
|
-
expect(btn).toHaveTextContent('Copy');
|
|
212
|
-
});
|
|
213
|
-
});
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { render } from '@testing-library/svelte';
|
|
3
|
-
import { createRawSnippet } from 'svelte';
|
|
4
|
-
import IconButton, { iconButtonVariants } from './IconButton.svelte';
|
|
5
|
-
|
|
6
|
-
/** A trivial snippet to pass as the icon child. */
|
|
7
|
-
const icon = createRawSnippet(() => ({
|
|
8
|
-
render: () => `<svg data-testid="icon"></svg>`,
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
describe('IconButton', () => {
|
|
12
|
-
it('renders as a <button> by default', () => {
|
|
13
|
-
const { container } = render(IconButton, {
|
|
14
|
-
props: { 'aria-label': 'Notifications', children: icon },
|
|
15
|
-
});
|
|
16
|
-
const el = container.querySelector('[data-slot="icon-button"]');
|
|
17
|
-
expect(el).toBeInTheDocument();
|
|
18
|
-
expect(el?.tagName).toBe('BUTTON');
|
|
19
|
-
expect(el).toHaveAttribute('type', 'button');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('renders as an <a> when href is set', () => {
|
|
23
|
-
const { container } = render(IconButton, {
|
|
24
|
-
props: { 'aria-label': 'Go home', href: '/home', children: icon },
|
|
25
|
-
});
|
|
26
|
-
const el = container.querySelector('[data-slot="icon-button"]');
|
|
27
|
-
expect(el?.tagName).toBe('A');
|
|
28
|
-
expect(el).toHaveAttribute('href', '/home');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('exposes the required aria-label on the control', () => {
|
|
32
|
-
const { getByLabelText } = render(IconButton, {
|
|
33
|
-
props: { 'aria-label': 'Search', children: icon },
|
|
34
|
-
});
|
|
35
|
-
expect(getByLabelText('Search')).toBeInTheDocument();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('renders the icon child', () => {
|
|
39
|
-
const { getByTestId } = render(IconButton, {
|
|
40
|
-
props: { 'aria-label': 'Add', children: icon },
|
|
41
|
-
});
|
|
42
|
-
expect(getByTestId('icon')).toBeInTheDocument();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('applies the default variant (ghost) and default size classes', () => {
|
|
46
|
-
const { container } = render(IconButton, {
|
|
47
|
-
props: { 'aria-label': 'Default', children: icon },
|
|
48
|
-
});
|
|
49
|
-
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
50
|
-
// ghost variant => muted-foreground; default size => size-9
|
|
51
|
-
expect(el.className).toContain('text-muted-foreground');
|
|
52
|
-
expect(el.className).toContain('size-9');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('applies the tap size (44px / size-11) for mobile hit targets', () => {
|
|
56
|
-
const { container } = render(IconButton, {
|
|
57
|
-
props: { 'aria-label': 'Tap', size: 'tap', children: icon },
|
|
58
|
-
});
|
|
59
|
-
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
60
|
-
expect(el.className).toContain('size-11');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it.each([
|
|
64
|
-
['sm', 'size-8'],
|
|
65
|
-
['default', 'size-9'],
|
|
66
|
-
['lg', 'size-10'],
|
|
67
|
-
['tap', 'size-11'],
|
|
68
|
-
] as const)('applies the %s size class %s', (size, expectedClass) => {
|
|
69
|
-
const { container } = render(IconButton, {
|
|
70
|
-
props: { 'aria-label': 'Sized', size, children: icon },
|
|
71
|
-
});
|
|
72
|
-
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
73
|
-
expect(el.className).toContain(expectedClass);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it.each([
|
|
77
|
-
['ghost', 'text-muted-foreground'],
|
|
78
|
-
['outline', 'border'],
|
|
79
|
-
['default', 'bg-primary'],
|
|
80
|
-
['secondary', 'bg-secondary'],
|
|
81
|
-
['destructive', 'text-destructive'],
|
|
82
|
-
] as const)('applies the %s variant classes', (variant, expectedClass) => {
|
|
83
|
-
const { container } = render(IconButton, {
|
|
84
|
-
props: { 'aria-label': 'Variant', variant, children: icon },
|
|
85
|
-
});
|
|
86
|
-
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
87
|
-
expect(el.className).toContain(expectedClass);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('disables the native button when disabled', () => {
|
|
91
|
-
const { container } = render(IconButton, {
|
|
92
|
-
props: { 'aria-label': 'Disabled', disabled: true, children: icon },
|
|
93
|
-
});
|
|
94
|
-
const el = container.querySelector('button')!;
|
|
95
|
-
expect(el).toBeDisabled();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('disables the anchor variant via aria-disabled and drops href', () => {
|
|
99
|
-
const { container } = render(IconButton, {
|
|
100
|
-
props: { 'aria-label': 'Disabled link', href: '/x', disabled: true, children: icon },
|
|
101
|
-
});
|
|
102
|
-
const el = container.querySelector('a')!;
|
|
103
|
-
expect(el).toHaveAttribute('aria-disabled', 'true');
|
|
104
|
-
expect(el).not.toHaveAttribute('href');
|
|
105
|
-
expect(el).toHaveAttribute('role', 'link');
|
|
106
|
-
expect(el).toHaveAttribute('tabindex', '-1');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('merges a custom class with the variant classes', () => {
|
|
110
|
-
const { container } = render(IconButton, {
|
|
111
|
-
props: { 'aria-label': 'Custom', class: 'my-custom-class', children: icon },
|
|
112
|
-
});
|
|
113
|
-
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
114
|
-
expect(el.className).toContain('my-custom-class');
|
|
115
|
-
expect(el.className).toContain('size-9');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('forwards arbitrary props (e.g. data-testid) to the element', () => {
|
|
119
|
-
const { getByTestId } = render(IconButton, {
|
|
120
|
-
props: { 'aria-label': 'Forwarded', 'data-testid': 'fwd', children: icon },
|
|
121
|
-
});
|
|
122
|
-
expect(getByTestId('fwd').tagName).toBe('BUTTON');
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('iconButtonVariants', () => {
|
|
127
|
-
it('returns base classes plus default variant/size', () => {
|
|
128
|
-
const cls = iconButtonVariants();
|
|
129
|
-
expect(cls).toContain('inline-grid');
|
|
130
|
-
expect(cls).toContain('text-muted-foreground'); // ghost default
|
|
131
|
-
expect(cls).toContain('size-9'); // default size
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('honours explicit variant + size args', () => {
|
|
135
|
-
const cls = iconButtonVariants({ variant: 'destructive', size: 'tap' });
|
|
136
|
-
expect(cls).toContain('text-destructive');
|
|
137
|
-
expect(cls).toContain('size-11');
|
|
138
|
-
});
|
|
139
|
-
});
|