@mindlogic-ai/logician-ui 5.0.0-alpha.11 → 5.0.0-alpha.13
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 +1 -0
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.js +4 -0
- package/dist/components/Input/Input.js.map +1 -1
- package/dist/components/Input/Input.mjs +4 -0
- package/dist/components/Input/Input.mjs.map +1 -1
- package/dist/components/NavList/NavList.d.ts +34 -0
- package/dist/components/NavList/NavList.d.ts.map +1 -0
- package/dist/components/NavList/NavList.js +113 -0
- package/dist/components/NavList/NavList.js.map +1 -0
- package/dist/components/NavList/NavList.mjs +111 -0
- package/dist/components/NavList/NavList.mjs.map +1 -0
- package/dist/components/NavList/NavList.styles.d.ts +90 -0
- package/dist/components/NavList/NavList.styles.d.ts.map +1 -0
- package/dist/components/NavList/NavList.styles.js +104 -0
- package/dist/components/NavList/NavList.styles.js.map +1 -0
- package/dist/components/NavList/NavList.styles.mjs +97 -0
- package/dist/components/NavList/NavList.styles.mjs.map +1 -0
- package/dist/components/NavList/NavList.translations.json.js +19 -0
- package/dist/components/NavList/NavList.translations.json.js.map +1 -0
- package/dist/components/NavList/NavList.translations.json.mjs +14 -0
- package/dist/components/NavList/NavList.translations.json.mjs.map +1 -0
- package/dist/components/NavList/NavList.types.d.ts +131 -0
- package/dist/components/NavList/NavList.types.d.ts.map +1 -0
- package/dist/components/NavList/NavListContext.d.ts +30 -0
- package/dist/components/NavList/NavListContext.d.ts.map +1 -0
- package/dist/components/NavList/NavListContext.js +16 -0
- package/dist/components/NavList/NavListContext.js.map +1 -0
- package/dist/components/NavList/NavListContext.mjs +13 -0
- package/dist/components/NavList/NavListContext.mjs.map +1 -0
- package/dist/components/NavList/NavListGroup.d.ts +31 -0
- package/dist/components/NavList/NavListGroup.d.ts.map +1 -0
- package/dist/components/NavList/NavListGroup.js +115 -0
- package/dist/components/NavList/NavListGroup.js.map +1 -0
- package/dist/components/NavList/NavListGroup.mjs +113 -0
- package/dist/components/NavList/NavListGroup.mjs.map +1 -0
- package/dist/components/NavList/NavListItem.d.ts +15 -0
- package/dist/components/NavList/NavListItem.d.ts.map +1 -0
- package/dist/components/NavList/NavListItem.js +269 -0
- package/dist/components/NavList/NavListItem.js.map +1 -0
- package/dist/components/NavList/NavListItem.mjs +267 -0
- package/dist/components/NavList/NavListItem.mjs.map +1 -0
- package/dist/components/NavList/NavListSkeleton.d.ts +17 -0
- package/dist/components/NavList/NavListSkeleton.d.ts.map +1 -0
- package/dist/components/NavList/NavListSkeleton.js +33 -0
- package/dist/components/NavList/NavListSkeleton.js.map +1 -0
- package/dist/components/NavList/NavListSkeleton.mjs +31 -0
- package/dist/components/NavList/NavListSkeleton.mjs.map +1 -0
- package/dist/components/NavList/index.d.ts +7 -0
- package/dist/components/NavList/index.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.js +4 -0
- package/dist/components/Textarea/Textarea.js.map +1 -1
- package/dist/components/Textarea/Textarea.mjs +4 -0
- package/dist/components/Textarea/Textarea.mjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -1
- package/dist/index.mjs.map +1 -1
- package/dist/utils/focusRing.d.ts +22 -0
- package/dist/utils/focusRing.d.ts.map +1 -1
- package/dist/utils/focusRing.js +22 -0
- package/dist/utils/focusRing.js.map +1 -1
- package/dist/utils/focusRing.mjs +21 -1
- package/dist/utils/focusRing.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/Input/Input.tsx +4 -0
- package/src/components/Menu/Menu.usage.mdx +8 -0
- package/src/components/NavList/NavList.stories.tsx +895 -0
- package/src/components/NavList/NavList.styles.ts +114 -0
- package/src/components/NavList/NavList.test.tsx +1254 -0
- package/src/components/NavList/NavList.translations.json +9 -0
- package/src/components/NavList/NavList.tsx +160 -0
- package/src/components/NavList/NavList.types.ts +145 -0
- package/src/components/NavList/NavList.usage.mdx +540 -0
- package/src/components/NavList/NavListContext.ts +25 -0
- package/src/components/NavList/NavListGroup.tsx +243 -0
- package/src/components/NavList/NavListItem.tsx +525 -0
- package/src/components/NavList/NavListSkeleton.tsx +57 -0
- package/src/components/NavList/index.ts +14 -0
- package/src/components/Tabs/Tabs.usage.mdx +58 -14
- package/src/components/Textarea/Textarea.tsx +4 -0
- package/src/components/Tooltip/Tooltip.usage.mdx +8 -0
- package/src/components/a11y.kwcag.test.tsx +66 -0
- package/src/components/claude.md +1 -1
- package/src/index.ts +7 -1
- package/src/utils/focusRing.ts +23 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
import { forwardRef, useId } from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { ChakraProvider } from '@chakra-ui/react';
|
|
4
|
+
import {
|
|
5
|
+
act,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { LogicianProvider } from '@/components/LogicianProvider';
|
|
15
|
+
import { Menu } from '@/components/Menu';
|
|
16
|
+
import { Tooltip } from '@/components/Tooltip';
|
|
17
|
+
|
|
18
|
+
import { system } from '../../theme';
|
|
19
|
+
import { SCHEMES } from '../../theme/palette.generated';
|
|
20
|
+
import { NavList } from './NavList';
|
|
21
|
+
import { NAV_LIST_RAMP } from './NavList.styles';
|
|
22
|
+
|
|
23
|
+
const renderUi = (ui: React.ReactNode) =>
|
|
24
|
+
render(<ChakraProvider value={system}>{ui}</ChakraProvider>);
|
|
25
|
+
|
|
26
|
+
/** Stand-in for `next/link`: a component that renders an `<a>` and forwards its ref. */
|
|
27
|
+
const RouterLink = forwardRef<
|
|
28
|
+
HTMLAnchorElement,
|
|
29
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
30
|
+
>((props, ref) => <a ref={ref} data-router-link="" {...props} />);
|
|
31
|
+
RouterLink.displayName = 'RouterLink';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every emitted rule whose selector names one of `el`'s classes, as
|
|
35
|
+
* `[at-rule, selector, declarations]`. jsdom runs no transitions and no
|
|
36
|
+
* `:active`, so what is checked is which declarations reach the element —
|
|
37
|
+
* the same approach as `Collapsible.presence.test.tsx`.
|
|
38
|
+
*/
|
|
39
|
+
const rulesFor = (el: Element) => {
|
|
40
|
+
const css = Array.from(document.querySelectorAll('style'))
|
|
41
|
+
.map((s) => s.textContent ?? '')
|
|
42
|
+
.join('\n');
|
|
43
|
+
// Split into [at-rule, block] runs: top level, and each `@media` block.
|
|
44
|
+
const blocks: [string, string][] = [];
|
|
45
|
+
const top: string[] = [];
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < css.length) {
|
|
48
|
+
const at = css.indexOf('@media', i);
|
|
49
|
+
if (at === -1) {
|
|
50
|
+
top.push(css.slice(i));
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
top.push(css.slice(i, at));
|
|
54
|
+
const open = css.indexOf('{', at);
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let j = open + 1;
|
|
57
|
+
const brace = /[{}]/g;
|
|
58
|
+
while (depth > 0) {
|
|
59
|
+
brace.lastIndex = j;
|
|
60
|
+
const next = brace.exec(css);
|
|
61
|
+
if (!next) break;
|
|
62
|
+
depth += next[0] === '{' ? 1 : -1;
|
|
63
|
+
j = next.index + 1;
|
|
64
|
+
}
|
|
65
|
+
blocks.push([css.slice(at, open), css.slice(open + 1, j - 1)]);
|
|
66
|
+
i = j;
|
|
67
|
+
}
|
|
68
|
+
blocks.push(['', top.join('')]);
|
|
69
|
+
const classes = el.className
|
|
70
|
+
.toString()
|
|
71
|
+
.split(' ')
|
|
72
|
+
.filter((c) => c.startsWith('css-'));
|
|
73
|
+
const rules = blocks.flatMap(([media, text]) =>
|
|
74
|
+
text.split('}').flatMap((piece) => {
|
|
75
|
+
const open = piece.indexOf('{');
|
|
76
|
+
return open === -1
|
|
77
|
+
? []
|
|
78
|
+
: [
|
|
79
|
+
[
|
|
80
|
+
media,
|
|
81
|
+
piece.slice(0, open).trim(),
|
|
82
|
+
piece.slice(open + 1),
|
|
83
|
+
] as const,
|
|
84
|
+
];
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
return rules.filter(([, selector]) =>
|
|
88
|
+
classes.some((c) => new RegExp(`\\.${c}(?![\\w-])`).test(selector))
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
/** The declarations on the element itself — no condition, no descendant. */
|
|
92
|
+
const ownDeclarations = (el: Element) =>
|
|
93
|
+
rulesFor(el)
|
|
94
|
+
.filter(([media, selector]) => !media && /^\.css-[\w-]+$/.test(selector))
|
|
95
|
+
.map(([, , body]) => body)
|
|
96
|
+
.join(';');
|
|
97
|
+
/** WCAG relative-luminance contrast of two `#rrggbb` colours. */
|
|
98
|
+
const contrast = (a: string, b: string) => {
|
|
99
|
+
const lum = (hex: string) => {
|
|
100
|
+
const [r, g, bl] = [1, 3, 5].map((i) => {
|
|
101
|
+
const v = parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
102
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
103
|
+
});
|
|
104
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
|
|
105
|
+
};
|
|
106
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
107
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
beforeAll(() => {
|
|
111
|
+
// LogicianProvider's color-mode machinery calls it on mount; jsdom has none.
|
|
112
|
+
window.matchMedia = ((query: string) => ({
|
|
113
|
+
matches: false,
|
|
114
|
+
media: query,
|
|
115
|
+
onchange: null,
|
|
116
|
+
addListener: () => {},
|
|
117
|
+
removeListener: () => {},
|
|
118
|
+
addEventListener: () => {},
|
|
119
|
+
removeEventListener: () => {},
|
|
120
|
+
dispatchEvent: () => false,
|
|
121
|
+
})) as unknown as typeof window.matchMedia;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('NavList', () => {
|
|
125
|
+
describe('structure', () => {
|
|
126
|
+
it('is a named nav landmark around a list when given aria-label', () => {
|
|
127
|
+
renderUi(
|
|
128
|
+
<NavList aria-label="설정">
|
|
129
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
130
|
+
</NavList>
|
|
131
|
+
);
|
|
132
|
+
const nav = screen.getByRole('navigation', { name: '설정' });
|
|
133
|
+
expect(nav.querySelector(':scope > ul')).not.toBeNull();
|
|
134
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('is only a list without a name — no anonymous landmark', () => {
|
|
138
|
+
renderUi(
|
|
139
|
+
<NavList>
|
|
140
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
141
|
+
</NavList>
|
|
142
|
+
);
|
|
143
|
+
expect(screen.queryByRole('navigation')).toBeNull();
|
|
144
|
+
expect(screen.getByRole('list')).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('is not tabs: no tablist, tab or aria-selected anywhere', () => {
|
|
148
|
+
const { container } = renderUi(
|
|
149
|
+
<NavList aria-label="필터">
|
|
150
|
+
<NavList.Item isCurrent onClick={() => {}}>
|
|
151
|
+
전체
|
|
152
|
+
</NavList.Item>
|
|
153
|
+
<NavList.Item onClick={() => {}}>이미지</NavList.Item>
|
|
154
|
+
</NavList>
|
|
155
|
+
);
|
|
156
|
+
expect(screen.queryByRole('tablist')).toBeNull();
|
|
157
|
+
expect(screen.queryByRole('tab')).toBeNull();
|
|
158
|
+
expect(container.querySelector('[aria-selected]')).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('labels a group list with its title, which is not a heading', () => {
|
|
162
|
+
renderUi(
|
|
163
|
+
<NavList aria-label="드라이브">
|
|
164
|
+
<NavList.Group title="공유 드라이브">
|
|
165
|
+
<NavList.Item onClick={() => {}}>마케팅</NavList.Item>
|
|
166
|
+
</NavList.Group>
|
|
167
|
+
</NavList>
|
|
168
|
+
);
|
|
169
|
+
expect(
|
|
170
|
+
screen.getByRole('list', { name: '공유 드라이브' })
|
|
171
|
+
).toBeInTheDocument();
|
|
172
|
+
expect(screen.queryByRole('heading')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('puts the trailing action beside the row, not inside it', () => {
|
|
176
|
+
renderUi(
|
|
177
|
+
<NavList aria-label="세션">
|
|
178
|
+
<NavList.Item
|
|
179
|
+
href="/s/1"
|
|
180
|
+
trailingAction={<button type="button">더 보기</button>}
|
|
181
|
+
>
|
|
182
|
+
세션 1
|
|
183
|
+
</NavList.Item>
|
|
184
|
+
</NavList>
|
|
185
|
+
);
|
|
186
|
+
const link = screen.getByRole('link', { name: '세션 1' });
|
|
187
|
+
const action = screen.getByRole('button', { name: '더 보기' });
|
|
188
|
+
expect(link.contains(action)).toBe(false);
|
|
189
|
+
expect(link.closest('li')).toBe(action.closest('li'));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('reads the trailing count as part of the row name and hides the icon', () => {
|
|
193
|
+
renderUi(
|
|
194
|
+
<NavList aria-label="필터">
|
|
195
|
+
<NavList.Item
|
|
196
|
+
onClick={() => {}}
|
|
197
|
+
leadingVisual={<svg data-testid="icon" />}
|
|
198
|
+
trailingVisual="12"
|
|
199
|
+
>
|
|
200
|
+
이미지
|
|
201
|
+
</NavList.Item>
|
|
202
|
+
</NavList>
|
|
203
|
+
);
|
|
204
|
+
expect(screen.getByRole('button', { name: '이미지 12' })).toBeVisible();
|
|
205
|
+
expect(
|
|
206
|
+
screen.getByTestId('icon').closest('[aria-hidden]')
|
|
207
|
+
).not.toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('button vs anchor', () => {
|
|
212
|
+
it('renders an <a> with href', () => {
|
|
213
|
+
renderUi(
|
|
214
|
+
<NavList>
|
|
215
|
+
<NavList.Item href="/images">이미지</NavList.Item>
|
|
216
|
+
</NavList>
|
|
217
|
+
);
|
|
218
|
+
const el = screen.getByRole('link', { name: '이미지' });
|
|
219
|
+
expect(el.tagName).toBe('A');
|
|
220
|
+
expect(el).toHaveAttribute('href', '/images');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('renders <button type="button"> with only onClick', () => {
|
|
224
|
+
renderUi(
|
|
225
|
+
<NavList>
|
|
226
|
+
<NavList.Item onClick={() => {}}>이미지</NavList.Item>
|
|
227
|
+
</NavList>
|
|
228
|
+
);
|
|
229
|
+
const el = screen.getByRole('button', { name: '이미지' });
|
|
230
|
+
expect(el.tagName).toBe('BUTTON');
|
|
231
|
+
expect(el).toHaveAttribute('type', 'button');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('merges onto a router link with asChild, keeping the label and visuals', () => {
|
|
235
|
+
renderUi(
|
|
236
|
+
<NavList>
|
|
237
|
+
<NavList.Item asChild isCurrent trailingVisual="3">
|
|
238
|
+
<RouterLink href="/videos">영상</RouterLink>
|
|
239
|
+
</NavList.Item>
|
|
240
|
+
</NavList>
|
|
241
|
+
);
|
|
242
|
+
const el = screen.getByRole('link', { name: '영상 3' });
|
|
243
|
+
expect(el).toHaveAttribute('data-router-link');
|
|
244
|
+
expect(el).toHaveAttribute('href', '/videos');
|
|
245
|
+
expect(el).toHaveAttribute('aria-current', 'page');
|
|
246
|
+
// One link, not a link inside a link.
|
|
247
|
+
expect(screen.getAllByRole('link')).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('aria-current', () => {
|
|
252
|
+
it('is "page" on the current link only', () => {
|
|
253
|
+
renderUi(
|
|
254
|
+
<NavList aria-label="메뉴">
|
|
255
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
256
|
+
<NavList.Item href="/b" isCurrent>
|
|
257
|
+
B
|
|
258
|
+
</NavList.Item>
|
|
259
|
+
<NavList.Item href="/c">C</NavList.Item>
|
|
260
|
+
</NavList>
|
|
261
|
+
);
|
|
262
|
+
const current = screen
|
|
263
|
+
.getAllByRole('link')
|
|
264
|
+
.filter((l) => l.hasAttribute('aria-current'));
|
|
265
|
+
expect(current).toHaveLength(1);
|
|
266
|
+
expect(current[0]).toHaveTextContent('B');
|
|
267
|
+
expect(current[0]).toHaveAttribute('aria-current', 'page');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('is "true" on the current button only', () => {
|
|
271
|
+
renderUi(
|
|
272
|
+
<NavList aria-label="필터">
|
|
273
|
+
<NavList.Item onClick={() => {}} isCurrent>
|
|
274
|
+
전체
|
|
275
|
+
</NavList.Item>
|
|
276
|
+
<NavList.Item onClick={() => {}}>이미지</NavList.Item>
|
|
277
|
+
</NavList>
|
|
278
|
+
);
|
|
279
|
+
expect(screen.getByRole('button', { name: '전체' })).toHaveAttribute(
|
|
280
|
+
'aria-current',
|
|
281
|
+
'true'
|
|
282
|
+
);
|
|
283
|
+
expect(
|
|
284
|
+
screen.getByRole('button', { name: '이미지' })
|
|
285
|
+
).not.toHaveAttribute('aria-current');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('keyboard', () => {
|
|
290
|
+
it('Tab visits every row and trailing action in order; arrows do nothing', async () => {
|
|
291
|
+
const user = userEvent.setup();
|
|
292
|
+
renderUi(
|
|
293
|
+
<NavList aria-label="메뉴">
|
|
294
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
295
|
+
<NavList.Item
|
|
296
|
+
href="/b"
|
|
297
|
+
isCurrent
|
|
298
|
+
trailingAction={<button type="button">B 옵션</button>}
|
|
299
|
+
>
|
|
300
|
+
B
|
|
301
|
+
</NavList.Item>
|
|
302
|
+
<NavList.Item onClick={() => {}}>C</NavList.Item>
|
|
303
|
+
</NavList>
|
|
304
|
+
);
|
|
305
|
+
await user.tab();
|
|
306
|
+
expect(screen.getByRole('link', { name: 'A' })).toHaveFocus();
|
|
307
|
+
await user.tab();
|
|
308
|
+
expect(screen.getByRole('link', { name: 'B' })).toHaveFocus();
|
|
309
|
+
await user.tab();
|
|
310
|
+
expect(screen.getByRole('button', { name: 'B 옵션' })).toHaveFocus();
|
|
311
|
+
await user.tab();
|
|
312
|
+
expect(screen.getByRole('button', { name: 'C' })).toHaveFocus();
|
|
313
|
+
|
|
314
|
+
// Not a roving-tabindex widget: arrow keys leave focus where it is.
|
|
315
|
+
await user.keyboard('{ArrowUp}');
|
|
316
|
+
expect(screen.getByRole('button', { name: 'C' })).toHaveFocus();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('Enter and Space activate a button row', async () => {
|
|
320
|
+
const user = userEvent.setup();
|
|
321
|
+
const onClick = vi.fn();
|
|
322
|
+
renderUi(
|
|
323
|
+
<NavList aria-label="필터">
|
|
324
|
+
<NavList.Item onClick={onClick}>이미지</NavList.Item>
|
|
325
|
+
</NavList>
|
|
326
|
+
);
|
|
327
|
+
await user.tab();
|
|
328
|
+
await user.keyboard('{Enter}');
|
|
329
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
330
|
+
await user.keyboard(' ');
|
|
331
|
+
expect(onClick).toHaveBeenCalledTimes(2);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('Enter activates a link row', async () => {
|
|
335
|
+
const user = userEvent.setup();
|
|
336
|
+
const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
|
|
337
|
+
renderUi(
|
|
338
|
+
<NavList aria-label="메뉴">
|
|
339
|
+
<NavList.Item href="/a" onClick={onClick}>
|
|
340
|
+
A
|
|
341
|
+
</NavList.Item>
|
|
342
|
+
</NavList>
|
|
343
|
+
);
|
|
344
|
+
await user.tab();
|
|
345
|
+
await user.keyboard('{Enter}');
|
|
346
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('disabled', () => {
|
|
351
|
+
it('a disabled button is disabled and skipped by Tab', async () => {
|
|
352
|
+
const user = userEvent.setup();
|
|
353
|
+
const onClick = vi.fn();
|
|
354
|
+
renderUi(
|
|
355
|
+
<NavList aria-label="필터">
|
|
356
|
+
<NavList.Item onClick={onClick} isDisabled>
|
|
357
|
+
이미지
|
|
358
|
+
</NavList.Item>
|
|
359
|
+
<NavList.Item onClick={() => {}}>영상</NavList.Item>
|
|
360
|
+
</NavList>
|
|
361
|
+
);
|
|
362
|
+
expect(screen.getByRole('button', { name: '이미지' })).toBeDisabled();
|
|
363
|
+
await user.tab();
|
|
364
|
+
expect(screen.getByRole('button', { name: '영상' })).toHaveFocus();
|
|
365
|
+
await user.click(screen.getByRole('button', { name: '이미지' }));
|
|
366
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('a disabled link is still a link, with aria-disabled, no href, skipped by Tab', async () => {
|
|
370
|
+
const user = userEvent.setup();
|
|
371
|
+
renderUi(
|
|
372
|
+
<NavList aria-label="메뉴">
|
|
373
|
+
<NavList.Item href="/a" isDisabled>
|
|
374
|
+
A
|
|
375
|
+
</NavList.Item>
|
|
376
|
+
<NavList.Item href="/b">B</NavList.Item>
|
|
377
|
+
</NavList>
|
|
378
|
+
);
|
|
379
|
+
const disabled = screen.getByRole('link', { name: 'A' });
|
|
380
|
+
expect(disabled.tagName).toBe('A');
|
|
381
|
+
expect(disabled).toHaveAttribute('aria-disabled', 'true');
|
|
382
|
+
expect(disabled).not.toHaveAttribute('href');
|
|
383
|
+
await user.tab();
|
|
384
|
+
expect(screen.getByRole('link', { name: 'B' })).toHaveFocus();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('a disabled asChild link drops the router link and its handlers entirely', async () => {
|
|
388
|
+
const user = userEvent.setup();
|
|
389
|
+
const onClick = vi.fn();
|
|
390
|
+
const onChildClick = vi.fn();
|
|
391
|
+
const { container } = renderUi(
|
|
392
|
+
<NavList>
|
|
393
|
+
<NavList.Item asChild isDisabled onClick={onClick}>
|
|
394
|
+
<RouterLink href="/a" onClick={onChildClick}>
|
|
395
|
+
A
|
|
396
|
+
</RouterLink>
|
|
397
|
+
</NavList.Item>
|
|
398
|
+
</NavList>
|
|
399
|
+
);
|
|
400
|
+
const link = screen.getByRole('link', { name: 'A' });
|
|
401
|
+
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
402
|
+
expect(link).not.toHaveAttribute('href');
|
|
403
|
+
expect(container.querySelector('[data-router-link]')).toBeNull();
|
|
404
|
+
await user.click(link);
|
|
405
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
406
|
+
expect(onChildClick).not.toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("isCurrent wins over a router link's own aria-current", () => {
|
|
410
|
+
renderUi(
|
|
411
|
+
<NavList>
|
|
412
|
+
<NavList.Item asChild>
|
|
413
|
+
<RouterLink href="/a" aria-current="page">
|
|
414
|
+
A
|
|
415
|
+
</RouterLink>
|
|
416
|
+
</NavList.Item>
|
|
417
|
+
</NavList>
|
|
418
|
+
);
|
|
419
|
+
expect(screen.getByRole('link', { name: 'A' })).not.toHaveAttribute(
|
|
420
|
+
'aria-current'
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("runs both the item's and the router link's click handlers", async () => {
|
|
425
|
+
const user = userEvent.setup();
|
|
426
|
+
const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
|
|
427
|
+
const onChildClick = vi.fn();
|
|
428
|
+
renderUi(
|
|
429
|
+
<NavList>
|
|
430
|
+
<NavList.Item asChild onClick={onClick}>
|
|
431
|
+
<RouterLink href="/a" onClick={onChildClick}>
|
|
432
|
+
A
|
|
433
|
+
</RouterLink>
|
|
434
|
+
</NavList.Item>
|
|
435
|
+
</NavList>
|
|
436
|
+
);
|
|
437
|
+
await user.click(screen.getByRole('link', { name: 'A' }));
|
|
438
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
439
|
+
expect(onChildClick).toHaveBeenCalledTimes(1);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('data-current', () => {
|
|
444
|
+
it('marks the current link, button and router link — and nothing else', () => {
|
|
445
|
+
const { container } = renderUi(
|
|
446
|
+
<NavList aria-label="메뉴">
|
|
447
|
+
<NavList.Item href="/a" isCurrent>
|
|
448
|
+
A
|
|
449
|
+
</NavList.Item>
|
|
450
|
+
<NavList.Item onClick={() => {}} isCurrent>
|
|
451
|
+
B
|
|
452
|
+
</NavList.Item>
|
|
453
|
+
<NavList.Item asChild isCurrent>
|
|
454
|
+
<RouterLink href="/c">C</RouterLink>
|
|
455
|
+
</NavList.Item>
|
|
456
|
+
<NavList.Item href="/d">D</NavList.Item>
|
|
457
|
+
</NavList>
|
|
458
|
+
);
|
|
459
|
+
const marked = container.querySelectorAll('[data-current]');
|
|
460
|
+
expect([...marked].map((el) => el.textContent)).toEqual(['A', 'B', 'C']);
|
|
461
|
+
// On the element that carries the role, so `[data-current]` is the row.
|
|
462
|
+
marked.forEach((el) => expect(el).toHaveAttribute('aria-current'));
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe('new window', () => {
|
|
467
|
+
it('says so in the name of a target=_blank link', () => {
|
|
468
|
+
renderUi(
|
|
469
|
+
<NavList aria-label="메뉴">
|
|
470
|
+
<NavList.Item href="https://docs.example" target="_blank">
|
|
471
|
+
문서
|
|
472
|
+
</NavList.Item>
|
|
473
|
+
<NavList.Item href="/a">설정</NavList.Item>
|
|
474
|
+
</NavList>
|
|
475
|
+
);
|
|
476
|
+
expect(
|
|
477
|
+
screen.getByRole('link', { name: '문서 (새 창 열림)' })
|
|
478
|
+
).toHaveAttribute('target', '_blank');
|
|
479
|
+
expect(screen.getByRole('link', { name: '설정' })).toBeInTheDocument();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('reads target from an asChild router link, in the provider language', () => {
|
|
483
|
+
render(
|
|
484
|
+
<LogicianProvider language="en" loadFonts={false}>
|
|
485
|
+
<NavList aria-label="Menu">
|
|
486
|
+
<NavList.Item asChild>
|
|
487
|
+
<RouterLink href="https://docs.example" target="_blank">
|
|
488
|
+
Docs
|
|
489
|
+
</RouterLink>
|
|
490
|
+
</NavList.Item>
|
|
491
|
+
</NavList>
|
|
492
|
+
</LogicianProvider>
|
|
493
|
+
);
|
|
494
|
+
expect(
|
|
495
|
+
screen.getByRole('link', { name: 'Docs (opens in a new window)' })
|
|
496
|
+
).toBeInTheDocument();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('a disabled link opens nothing, so it neither says so nor keeps target', () => {
|
|
500
|
+
renderUi(
|
|
501
|
+
<NavList aria-label="메뉴">
|
|
502
|
+
<NavList.Item href="https://x.example" target="_blank" isDisabled>
|
|
503
|
+
문서
|
|
504
|
+
</NavList.Item>
|
|
505
|
+
</NavList>
|
|
506
|
+
);
|
|
507
|
+
const link = screen.getByRole('link', { name: '문서' });
|
|
508
|
+
expect(link).not.toHaveAttribute('target');
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe('tooltip', () => {
|
|
513
|
+
const hover = async (el: HTMLElement) => {
|
|
514
|
+
fireEvent.pointerMove(el, { pointerType: 'mouse' });
|
|
515
|
+
await act(() => new Promise((r) => setTimeout(r, 400)));
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
it('`tooltip` is the row DESCRIPTION, opens on hover and closes on Escape; the click still lands', async () => {
|
|
519
|
+
const user = userEvent.setup();
|
|
520
|
+
const onClick = vi.fn();
|
|
521
|
+
renderUi(
|
|
522
|
+
<NavList aria-label="메뉴">
|
|
523
|
+
<NavList.Item onClick={onClick} tooltip="팀 전체가 쓰는 공간">
|
|
524
|
+
공유 드라이브
|
|
525
|
+
</NavList.Item>
|
|
526
|
+
</NavList>
|
|
527
|
+
);
|
|
528
|
+
const row = screen.getByRole('button', { name: '공유 드라이브' });
|
|
529
|
+
await hover(row);
|
|
530
|
+
const openTip = () =>
|
|
531
|
+
document.querySelector('[data-part="content"][data-state="open"]');
|
|
532
|
+
await waitFor(() =>
|
|
533
|
+
expect(openTip()).toHaveTextContent('팀 전체가 쓰는 공간')
|
|
534
|
+
);
|
|
535
|
+
expect(row).toHaveAccessibleDescription('팀 전체가 쓰는 공간');
|
|
536
|
+
expect(row).toHaveAccessibleName('공유 드라이브');
|
|
537
|
+
await user.keyboard('{Escape}');
|
|
538
|
+
await waitFor(() => expect(openTip()).toBeNull());
|
|
539
|
+
await user.click(row);
|
|
540
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('a disabled row still carries its tooltip as a description, closed', () => {
|
|
544
|
+
renderUi(
|
|
545
|
+
<NavList aria-label="메뉴">
|
|
546
|
+
<NavList.Item
|
|
547
|
+
onClick={() => {}}
|
|
548
|
+
isDisabled
|
|
549
|
+
tooltip="엔터프라이즈 요금제에서 쓸 수 있습니다"
|
|
550
|
+
>
|
|
551
|
+
SSO
|
|
552
|
+
</NavList.Item>
|
|
553
|
+
<NavList.Item href="/a" isDisabled tooltip="권한이 없습니다">
|
|
554
|
+
감사 로그
|
|
555
|
+
</NavList.Item>
|
|
556
|
+
</NavList>
|
|
557
|
+
);
|
|
558
|
+
expect(
|
|
559
|
+
screen.getByRole('button', { name: 'SSO' })
|
|
560
|
+
).toHaveAccessibleDescription('엔터프라이즈 요금제에서 쓸 수 있습니다');
|
|
561
|
+
expect(
|
|
562
|
+
screen.getByRole('link', { name: '감사 로그' })
|
|
563
|
+
).toHaveAccessibleDescription('권한이 없습니다');
|
|
564
|
+
// Not read a second time as text in the list item.
|
|
565
|
+
expect(screen.queryByText('권한이 없습니다')).not.toBeVisible();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("keeps the caller's own aria-describedby beside the tooltip's", () => {
|
|
569
|
+
renderUi(
|
|
570
|
+
<>
|
|
571
|
+
<p id="hint">새 기능</p>
|
|
572
|
+
<NavList aria-label="메뉴">
|
|
573
|
+
<NavList.Item href="/a" aria-describedby="hint" tooltip="설명">
|
|
574
|
+
A
|
|
575
|
+
</NavList.Item>
|
|
576
|
+
</NavList>
|
|
577
|
+
</>
|
|
578
|
+
);
|
|
579
|
+
expect(
|
|
580
|
+
screen.getByRole('link', { name: 'A' })
|
|
581
|
+
).toHaveAccessibleDescription('새 기능 설명');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('opens on keyboard focus', async () => {
|
|
585
|
+
const user = userEvent.setup();
|
|
586
|
+
renderUi(
|
|
587
|
+
<NavList aria-label="메뉴">
|
|
588
|
+
<NavList.Item href="/a" tooltip="설명">
|
|
589
|
+
A
|
|
590
|
+
</NavList.Item>
|
|
591
|
+
</NavList>
|
|
592
|
+
);
|
|
593
|
+
await user.tab();
|
|
594
|
+
await waitFor(() =>
|
|
595
|
+
expect(
|
|
596
|
+
document.querySelector('[data-part="content"][data-state="open"]')
|
|
597
|
+
).toHaveTextContent('설명')
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe('overflow label', () => {
|
|
602
|
+
afterEach(() => vi.restoreAllMocks());
|
|
603
|
+
const widths = (scroll: number, client: number) => {
|
|
604
|
+
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(
|
|
605
|
+
scroll
|
|
606
|
+
);
|
|
607
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(
|
|
608
|
+
client
|
|
609
|
+
);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
it('shows the full label only when the ellipsis cuts it off, and not as a description', async () => {
|
|
613
|
+
widths(300, 120);
|
|
614
|
+
renderUi(
|
|
615
|
+
<NavList aria-label="메뉴">
|
|
616
|
+
<NavList.Item href="/a">
|
|
617
|
+
영업팀 — 2026년 하반기 제안서와 계약서 보관함
|
|
618
|
+
</NavList.Item>
|
|
619
|
+
</NavList>
|
|
620
|
+
);
|
|
621
|
+
const row = screen.getByRole('link');
|
|
622
|
+
await hover(row);
|
|
623
|
+
await waitFor(() =>
|
|
624
|
+
expect(
|
|
625
|
+
document.querySelector('[data-part="content"][data-state="open"]')
|
|
626
|
+
).toHaveTextContent('영업팀 — 2026년 하반기 제안서와 계약서 보관함')
|
|
627
|
+
);
|
|
628
|
+
// The label is already the name; reading it again as a description is noise.
|
|
629
|
+
expect(row).toHaveAccessibleDescription('');
|
|
630
|
+
expect(row).toHaveAccessibleName(
|
|
631
|
+
'영업팀 — 2026년 하반기 제안서와 계약서 보관함'
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('stays closed when the label fits', async () => {
|
|
636
|
+
widths(80, 120);
|
|
637
|
+
renderUi(
|
|
638
|
+
<NavList aria-label="메뉴">
|
|
639
|
+
<NavList.Item href="/a">짧음</NavList.Item>
|
|
640
|
+
</NavList>
|
|
641
|
+
);
|
|
642
|
+
await hover(screen.getByRole('link'));
|
|
643
|
+
expect(
|
|
644
|
+
document.querySelector('[data-part="content"][data-state="open"]')
|
|
645
|
+
).toBeNull();
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe('isCollapsed', () => {
|
|
651
|
+
it('keeps the label as the name, drops trailing visuals and actions, and tooltips the label', async () => {
|
|
652
|
+
renderUi(
|
|
653
|
+
<NavList aria-label="메뉴" isCollapsed>
|
|
654
|
+
<NavList.Item
|
|
655
|
+
href="/a"
|
|
656
|
+
isCurrent
|
|
657
|
+
leadingVisual={<svg data-testid="icon" />}
|
|
658
|
+
trailingVisual="12"
|
|
659
|
+
trailingAction={<button type="button">옵션</button>}
|
|
660
|
+
>
|
|
661
|
+
멤버 관리
|
|
662
|
+
</NavList.Item>
|
|
663
|
+
</NavList>
|
|
664
|
+
);
|
|
665
|
+
const row = screen.getByRole('link', { name: '멤버 관리' });
|
|
666
|
+
expect(row).toHaveAttribute('aria-current', 'page');
|
|
667
|
+
// The count stays in the DOM so it can fade with the label, but it is
|
|
668
|
+
// hidden — out of the name (asserted above) and out of sight.
|
|
669
|
+
expect(screen.getByText('12')).not.toBeVisible();
|
|
670
|
+
expect(screen.queryByRole('button', { name: '옵션' })).toBeNull();
|
|
671
|
+
fireEvent.pointerMove(row, { pointerType: 'mouse' });
|
|
672
|
+
await act(() => new Promise((r) => setTimeout(r, 400)));
|
|
673
|
+
await waitFor(() =>
|
|
674
|
+
expect(
|
|
675
|
+
document.querySelector('[data-part="content"][data-state="open"]')
|
|
676
|
+
).toHaveTextContent('멤버 관리')
|
|
677
|
+
);
|
|
678
|
+
expect(row).toHaveAccessibleDescription('');
|
|
679
|
+
// While open, no reference to a tooltip id that does not exist.
|
|
680
|
+
const ids = (row.getAttribute('aria-describedby') ?? '')
|
|
681
|
+
.split(' ')
|
|
682
|
+
.filter(Boolean);
|
|
683
|
+
ids.forEach((id) => expect(document.getElementById(id)).not.toBeNull());
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('shows a description under the label and keeps it as the description', async () => {
|
|
687
|
+
renderUi(
|
|
688
|
+
<NavList aria-label="메뉴" isCollapsed>
|
|
689
|
+
<NavList.Item
|
|
690
|
+
href="/a"
|
|
691
|
+
leadingVisual={<svg />}
|
|
692
|
+
tooltip="멤버 초대와 권한"
|
|
693
|
+
>
|
|
694
|
+
멤버 관리
|
|
695
|
+
</NavList.Item>
|
|
696
|
+
</NavList>
|
|
697
|
+
);
|
|
698
|
+
const row = screen.getByRole('link', { name: '멤버 관리' });
|
|
699
|
+
expect(row).toHaveAccessibleDescription('멤버 초대와 권한');
|
|
700
|
+
fireEvent.pointerMove(row, { pointerType: 'mouse' });
|
|
701
|
+
await act(() => new Promise((r) => setTimeout(r, 400)));
|
|
702
|
+
await waitFor(() =>
|
|
703
|
+
expect(
|
|
704
|
+
document.querySelector('[data-part="content"][data-state="open"]')
|
|
705
|
+
).toHaveTextContent('멤버 관리멤버 초대와 권한')
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('still names group lists, and shows a collapsible group’s rows', () => {
|
|
710
|
+
renderUi(
|
|
711
|
+
<NavList aria-label="메뉴" isCollapsed>
|
|
712
|
+
<NavList.Group title="관리" isCollapsible defaultOpen={false}>
|
|
713
|
+
<NavList.Item href="/a" leadingVisual={<svg />}>
|
|
714
|
+
A
|
|
715
|
+
</NavList.Item>
|
|
716
|
+
</NavList.Group>
|
|
717
|
+
</NavList>
|
|
718
|
+
);
|
|
719
|
+
expect(screen.getByRole('list', { name: '관리' })).toBeVisible();
|
|
720
|
+
expect(screen.queryByRole('button', { name: '관리' })).toBeNull();
|
|
721
|
+
expect(screen.getByRole('link', { name: 'A' })).toBeVisible();
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe('trailingActionVisibility="hover"', () => {
|
|
726
|
+
it('keeps the action in the DOM and in the tab order', async () => {
|
|
727
|
+
const user = userEvent.setup();
|
|
728
|
+
renderUi(
|
|
729
|
+
<NavList aria-label="세션">
|
|
730
|
+
<NavList.Item
|
|
731
|
+
href="/s/1"
|
|
732
|
+
trailingActionVisibility="hover"
|
|
733
|
+
trailingAction={<button type="button">세션 1 더 보기</button>}
|
|
734
|
+
>
|
|
735
|
+
세션 1
|
|
736
|
+
</NavList.Item>
|
|
737
|
+
</NavList>
|
|
738
|
+
);
|
|
739
|
+
await user.tab();
|
|
740
|
+
await user.tab();
|
|
741
|
+
expect(
|
|
742
|
+
screen.getByRole('button', { name: '세션 1 더 보기' })
|
|
743
|
+
).toHaveFocus();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
describe('collapsible group', () => {
|
|
748
|
+
it('is a disclosure: aria-expanded, aria-controls, rows hidden when closed', async () => {
|
|
749
|
+
const user = userEvent.setup();
|
|
750
|
+
const onOpenChange = vi.fn();
|
|
751
|
+
renderUi(
|
|
752
|
+
<NavList aria-label="메뉴">
|
|
753
|
+
<NavList.Group title="관리" isCollapsible onOpenChange={onOpenChange}>
|
|
754
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
755
|
+
</NavList.Group>
|
|
756
|
+
</NavList>
|
|
757
|
+
);
|
|
758
|
+
const trigger = screen.getByRole('button', { name: '관리' });
|
|
759
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
760
|
+
const list = screen.getByRole('list', { name: '관리' });
|
|
761
|
+
expect(trigger).toHaveAttribute('aria-controls', list.id);
|
|
762
|
+
await user.click(trigger);
|
|
763
|
+
// On the click, not when the closing animation ends.
|
|
764
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
765
|
+
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
|
766
|
+
// Out of reach while it is still leaving…
|
|
767
|
+
expect(list.inert).toBe(true);
|
|
768
|
+
// …and hidden once it has left.
|
|
769
|
+
await waitFor(() =>
|
|
770
|
+
expect(screen.queryByRole('link', { name: 'A' })).toBeNull()
|
|
771
|
+
);
|
|
772
|
+
expect(list).toHaveAttribute('hidden');
|
|
773
|
+
await user.click(trigger);
|
|
774
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
775
|
+
expect(list.inert).toBe(false);
|
|
776
|
+
expect(screen.getByRole('link', { name: 'A' })).toBeVisible();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("animates on Collapsible's own motion: its keyframes, the presence clock, a fade under reduced motion", () => {
|
|
780
|
+
renderUi(
|
|
781
|
+
<NavList aria-label="메뉴">
|
|
782
|
+
<NavList.Group title="관리" isCollapsible>
|
|
783
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
784
|
+
</NavList.Group>
|
|
785
|
+
</NavList>
|
|
786
|
+
);
|
|
787
|
+
const list = screen.getByRole('list', { name: '관리' });
|
|
788
|
+
expect(list).toHaveAttribute('data-scope', 'collapsible');
|
|
789
|
+
const rules = rulesFor(list);
|
|
790
|
+
const all = rules.map(([, , body]) => body).join(';');
|
|
791
|
+
// The recipe's height keyframes, not new ones.
|
|
792
|
+
expect(all).toContain('animation-name:expand-height');
|
|
793
|
+
expect(all).toContain('animation-name:collapse-height');
|
|
794
|
+
// The house clock: 300ms in, 150ms out.
|
|
795
|
+
expect(all).toContain(
|
|
796
|
+
'animation-duration:var(--chakra-durations-motion-base)'
|
|
797
|
+
);
|
|
798
|
+
expect(all).toContain('animation-duration:var(--chakra-durations-fast)');
|
|
799
|
+
const reduced = rules
|
|
800
|
+
.filter(([media]) => media.includes('prefers-reduced-motion'))
|
|
801
|
+
.map(([, , body]) => body)
|
|
802
|
+
.join(';');
|
|
803
|
+
expect(reduced).toContain('animation-name:fade-in');
|
|
804
|
+
expect(reduced).toContain('animation-name:fade-out');
|
|
805
|
+
expect(reduced).not.toContain('height');
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('a closed group starts hidden and inert', () => {
|
|
809
|
+
renderUi(
|
|
810
|
+
<NavList aria-label="메뉴">
|
|
811
|
+
<NavList.Group title="관리" isCollapsible defaultOpen={false}>
|
|
812
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
813
|
+
</NavList.Group>
|
|
814
|
+
</NavList>
|
|
815
|
+
);
|
|
816
|
+
const list = document.getElementById(
|
|
817
|
+
screen
|
|
818
|
+
.getByRole('button', { name: '관리' })
|
|
819
|
+
.getAttribute('aria-controls')!
|
|
820
|
+
)!;
|
|
821
|
+
expect(list).toHaveAttribute('hidden');
|
|
822
|
+
expect(list.inert).toBe(true);
|
|
823
|
+
expect(screen.queryByRole('link', { name: 'A' })).toBeNull();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('collapsing the rail opens the group without remounting its rows', () => {
|
|
827
|
+
const ui = (isCollapsed: boolean) => (
|
|
828
|
+
<ChakraProvider value={system}>
|
|
829
|
+
<NavList aria-label="메뉴" isCollapsed={isCollapsed}>
|
|
830
|
+
<NavList.Group title="관리" isCollapsible defaultOpen={false}>
|
|
831
|
+
<NavList.Item href="/a" leadingVisual={<svg />}>
|
|
832
|
+
A
|
|
833
|
+
</NavList.Item>
|
|
834
|
+
</NavList.Group>
|
|
835
|
+
</NavList>
|
|
836
|
+
</ChakraProvider>
|
|
837
|
+
);
|
|
838
|
+
const { rerender } = render(ui(true));
|
|
839
|
+
const row = screen.getByRole('link', { name: 'A' });
|
|
840
|
+
rerender(ui(false));
|
|
841
|
+
rerender(ui(true));
|
|
842
|
+
expect(screen.getByRole('link', { name: 'A' })).toBe(row);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('follows a controlled isOpen and only reports clicks', async () => {
|
|
846
|
+
const user = userEvent.setup();
|
|
847
|
+
const onOpenChange = vi.fn();
|
|
848
|
+
renderUi(
|
|
849
|
+
<NavList aria-label="메뉴">
|
|
850
|
+
<NavList.Group
|
|
851
|
+
title="관리"
|
|
852
|
+
isCollapsible
|
|
853
|
+
isOpen={false}
|
|
854
|
+
onOpenChange={onOpenChange}
|
|
855
|
+
>
|
|
856
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
857
|
+
</NavList.Group>
|
|
858
|
+
</NavList>
|
|
859
|
+
);
|
|
860
|
+
const trigger = screen.getByRole('button', { name: '관리' });
|
|
861
|
+
await user.click(trigger);
|
|
862
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
863
|
+
// Still closed: the caller owns the state.
|
|
864
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe('pressed', () => {
|
|
869
|
+
const PRESS = ':has([data-nav-list-row]:active)';
|
|
870
|
+
const liOf = (name: string) =>
|
|
871
|
+
screen.getByRole('link', { name }).closest('li')!;
|
|
872
|
+
|
|
873
|
+
it('fills the row one step past hover while the ROW is pressed, current rows included', () => {
|
|
874
|
+
renderUi(
|
|
875
|
+
<NavList aria-label="메뉴">
|
|
876
|
+
<NavList.Item href="/a">A</NavList.Item>
|
|
877
|
+
<NavList.Item href="/b" isCurrent>
|
|
878
|
+
B
|
|
879
|
+
</NavList.Item>
|
|
880
|
+
</NavList>
|
|
881
|
+
);
|
|
882
|
+
['A', 'B'].forEach((name) => {
|
|
883
|
+
const rules = rulesFor(liOf(name));
|
|
884
|
+
const pressed = rules.filter(([, sel]) => sel.includes(PRESS));
|
|
885
|
+
expect(pressed.find(([media]) => !media)?.[2], name).toContain(
|
|
886
|
+
'background:var(--chakra-colors-border-default)'
|
|
887
|
+
);
|
|
888
|
+
// High contrast turns `border.default` into a stroke grey, so it
|
|
889
|
+
// takes the token high contrast leaves alone.
|
|
890
|
+
expect(
|
|
891
|
+
pressed.find(([media]) =>
|
|
892
|
+
media.includes('prefers-contrast: more')
|
|
893
|
+
)?.[2],
|
|
894
|
+
name
|
|
895
|
+
).toContain('background:var(--chakra-colors-border-strong)');
|
|
896
|
+
// And again inside hover, or a pointer press loses to the hover
|
|
897
|
+
// rule, which is just as specific and comes later.
|
|
898
|
+
expect(
|
|
899
|
+
pressed.some(
|
|
900
|
+
([media, sel, body]) =>
|
|
901
|
+
media.includes('hover: hover') &&
|
|
902
|
+
sel.includes(':hover') &&
|
|
903
|
+
body.includes('var(--chakra-colors-border-default)')
|
|
904
|
+
),
|
|
905
|
+
name
|
|
906
|
+
).toBe(true);
|
|
907
|
+
// Distinct from hover, which is `bg.emphasized`.
|
|
908
|
+
const hover = rules.find(([, sel]) => sel.includes(':hover'))?.[2];
|
|
909
|
+
expect(hover).toContain('var(--chakra-colors-bg-emphasized)');
|
|
910
|
+
});
|
|
911
|
+
// The row carries the hook the selector names.
|
|
912
|
+
expect(screen.getByRole('link', { name: 'A' })).toHaveAttribute(
|
|
913
|
+
'data-nav-list-row'
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('does not press a disabled row', () => {
|
|
918
|
+
renderUi(
|
|
919
|
+
<NavList aria-label="메뉴">
|
|
920
|
+
<NavList.Item href="/a" isDisabled>
|
|
921
|
+
A
|
|
922
|
+
</NavList.Item>
|
|
923
|
+
</NavList>
|
|
924
|
+
);
|
|
925
|
+
const li = screen.getByRole('link', { name: 'A' }).closest('li')!;
|
|
926
|
+
expect(rulesFor(li).some(([, sel]) => sel.includes(PRESS))).toBe(false);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it('keeps the label and the count AA on the pressed fill, in every scheme and mode', () => {
|
|
930
|
+
// Light and dark press onto `border.default`; high contrast onto
|
|
931
|
+
// `border.strong` (see NAV_LIST_RAMP). Text and counts need 4.5:1; the
|
|
932
|
+
// fill has to read as a step from hover (the audit's 1.10:1 for
|
|
933
|
+
// neighbouring layers).
|
|
934
|
+
const FILL = {
|
|
935
|
+
light: 'border-default',
|
|
936
|
+
dark: 'border-default',
|
|
937
|
+
hcLight: 'border-strong',
|
|
938
|
+
hcDark: 'border-strong',
|
|
939
|
+
} as const;
|
|
940
|
+
expect(NAV_LIST_RAMP.pressedFill).toBe('border.default');
|
|
941
|
+
expect(NAV_LIST_RAMP.pressedFillMoreContrast).toBe('border.strong');
|
|
942
|
+
Object.entries(SCHEMES).forEach(([scheme, modes]) =>
|
|
943
|
+
Object.entries(FILL).forEach(([mode, fill]) => {
|
|
944
|
+
const t = (modes as Record<string, Record<string, string>>)[mode];
|
|
945
|
+
const where = `${scheme} ${mode}`;
|
|
946
|
+
expect(contrast(t['fg-default'], t[fill]), where).toBeGreaterThan(
|
|
947
|
+
4.5
|
|
948
|
+
);
|
|
949
|
+
expect(contrast(t['fg-muted'], t[fill]), where).toBeGreaterThan(4.5);
|
|
950
|
+
expect(
|
|
951
|
+
contrast(t[fill], t['layer-1-active']),
|
|
952
|
+
where
|
|
953
|
+
).toBeGreaterThanOrEqual(1.1);
|
|
954
|
+
})
|
|
955
|
+
);
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
describe('hover-revealed action layout', () => {
|
|
960
|
+
const setup = (
|
|
961
|
+
visibility: 'hover' | 'always',
|
|
962
|
+
orientation: 'vertical' | 'horizontal' = 'vertical'
|
|
963
|
+
) => {
|
|
964
|
+
renderUi(
|
|
965
|
+
<NavList aria-label="세션" orientation={orientation}>
|
|
966
|
+
<NavList.Item
|
|
967
|
+
href="/s/1"
|
|
968
|
+
trailingActionVisibility={visibility}
|
|
969
|
+
trailingAction={<button type="button">더 보기</button>}
|
|
970
|
+
>
|
|
971
|
+
아주 긴 세션 제목
|
|
972
|
+
</NavList.Item>
|
|
973
|
+
</NavList>
|
|
974
|
+
);
|
|
975
|
+
const row = screen.getByRole('link', { name: '아주 긴 세션 제목' });
|
|
976
|
+
return { row, li: row.closest('li')! };
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
it('hover: the label has the whole row at rest and makes room only while the action shows', () => {
|
|
980
|
+
const { row, li } = setup('hover');
|
|
981
|
+
expect(ownDeclarations(li)).toContain('display:grid');
|
|
982
|
+
expect(ownDeclarations(row)).toContain('grid-column:1/-1');
|
|
983
|
+
const rules = rulesFor(li);
|
|
984
|
+
const giveBack = (selector: string) =>
|
|
985
|
+
rules.some(
|
|
986
|
+
([media, sel, body]) =>
|
|
987
|
+
!media &&
|
|
988
|
+
sel.includes(selector) &&
|
|
989
|
+
sel.includes('[data-nav-list-row]') &&
|
|
990
|
+
body.includes('grid-column:1')
|
|
991
|
+
);
|
|
992
|
+
expect(giveBack(':hover')).toBe(true);
|
|
993
|
+
expect(giveBack(':focus-within')).toBe(true);
|
|
994
|
+
expect(giveBack('[aria-expanded="true"]')).toBe(true);
|
|
995
|
+
// Touch: shown, with its space kept.
|
|
996
|
+
const touch = rules.filter(([media]) =>
|
|
997
|
+
/hover: ?none\), ?\(pointer: ?coarse/.test(media)
|
|
998
|
+
);
|
|
999
|
+
expect(
|
|
1000
|
+
touch.some(
|
|
1001
|
+
([, sel, body]) =>
|
|
1002
|
+
sel.includes('[data-nav-list-row]') &&
|
|
1003
|
+
body.includes('grid-column:1')
|
|
1004
|
+
)
|
|
1005
|
+
).toBe(true);
|
|
1006
|
+
expect(
|
|
1007
|
+
touch.some(
|
|
1008
|
+
([, sel, body]) =>
|
|
1009
|
+
sel.includes('trailing-action') && body.includes('opacity:1')
|
|
1010
|
+
)
|
|
1011
|
+
).toBe(true);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it('always: the action keeps its own space, as before', () => {
|
|
1015
|
+
const { row, li } = setup('always');
|
|
1016
|
+
expect(ownDeclarations(li)).toContain('display:flex');
|
|
1017
|
+
expect(ownDeclarations(row)).not.toContain('grid-column');
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('a horizontal strip keeps the space: a row there is only as wide as its label', () => {
|
|
1021
|
+
const { li } = setup('hover', 'horizontal');
|
|
1022
|
+
expect(ownDeclarations(li)).toContain('display:flex');
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
describe('right to left', () => {
|
|
1027
|
+
it('lays out from CSS: every element zag stamps `dir` on inherits `direction` instead', () => {
|
|
1028
|
+
renderUi(
|
|
1029
|
+
<div dir="rtl">
|
|
1030
|
+
<NavList aria-label="القائمة">
|
|
1031
|
+
<NavList.Item href="/1">الرئيسية</NavList.Item>
|
|
1032
|
+
<NavList.Group title="الإدارة" isCollapsible>
|
|
1033
|
+
<NavList.Item href="/2">الإعدادات</NavList.Item>
|
|
1034
|
+
</NavList.Group>
|
|
1035
|
+
</NavList>
|
|
1036
|
+
</div>
|
|
1037
|
+
);
|
|
1038
|
+
const stamped = [
|
|
1039
|
+
screen.getByRole('link', { name: 'الرئيسية' }),
|
|
1040
|
+
screen.getByRole('link', { name: 'الإعدادات' }),
|
|
1041
|
+
screen.getByRole('list', { name: 'الإدارة' }),
|
|
1042
|
+
screen.getByRole('list', { name: 'الإدارة' }).closest('li')!,
|
|
1043
|
+
];
|
|
1044
|
+
stamped.forEach((el) => {
|
|
1045
|
+
expect(el).toHaveAttribute('dir');
|
|
1046
|
+
expect(ownDeclarations(el)).toContain('direction:inherit');
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('the server markup carries no layout direction of its own beyond that', () => {
|
|
1051
|
+
// What the browser paints before hydration: zag's `dir="ltr"` stamps
|
|
1052
|
+
// are in the HTML, and only the CSS above stops them from deciding
|
|
1053
|
+
// the layout. Nothing in the markup depends on an effect.
|
|
1054
|
+
const html = renderToString(
|
|
1055
|
+
<ChakraProvider value={system}>
|
|
1056
|
+
<div dir="rtl">
|
|
1057
|
+
<NavList aria-label="القائمة">
|
|
1058
|
+
<NavList.Item href="/1">الرئيسية</NavList.Item>
|
|
1059
|
+
</NavList>
|
|
1060
|
+
</div>
|
|
1061
|
+
</ChakraProvider>
|
|
1062
|
+
);
|
|
1063
|
+
const host = document.createElement('div');
|
|
1064
|
+
host.innerHTML = html;
|
|
1065
|
+
const row = host.querySelector('a')!;
|
|
1066
|
+
expect(row.getAttribute('dir')).toBe('ltr');
|
|
1067
|
+
expect(row).toHaveAttribute('data-nav-list-row');
|
|
1068
|
+
expect(row.querySelector('[style*="direction"]')).toBeNull();
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
describe('rail toggle', () => {
|
|
1073
|
+
const items = (isCollapsed: boolean) => (
|
|
1074
|
+
<ChakraProvider value={system}>
|
|
1075
|
+
<NavList aria-label="메뉴" isCollapsed={isCollapsed}>
|
|
1076
|
+
<NavList.Item
|
|
1077
|
+
href="/a"
|
|
1078
|
+
isCurrent
|
|
1079
|
+
leadingVisual={<svg />}
|
|
1080
|
+
trailingVisual="12"
|
|
1081
|
+
>
|
|
1082
|
+
멤버 관리
|
|
1083
|
+
</NavList.Item>
|
|
1084
|
+
<NavList.Group title="도움말">
|
|
1085
|
+
<NavList.Item href="/b" leadingVisual={<svg />}>
|
|
1086
|
+
사용 가이드
|
|
1087
|
+
</NavList.Item>
|
|
1088
|
+
</NavList.Group>
|
|
1089
|
+
</NavList>
|
|
1090
|
+
</ChakraProvider>
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
it('keeps the same row, label element and current state across the toggle', () => {
|
|
1094
|
+
const { rerender } = render(items(false));
|
|
1095
|
+
const row = screen.getByRole('link', { name: '멤버 관리 12' });
|
|
1096
|
+
const label = row.querySelector('[data-part="label"]');
|
|
1097
|
+
rerender(items(true));
|
|
1098
|
+
const collapsed = screen.getByRole('link', { name: '멤버 관리' });
|
|
1099
|
+
expect(collapsed).toBe(row);
|
|
1100
|
+
expect(collapsed.querySelector('[data-part="label"]')).toBe(label);
|
|
1101
|
+
expect(collapsed).toHaveAttribute('aria-current', 'page');
|
|
1102
|
+
expect(collapsed).toHaveAttribute('data-current');
|
|
1103
|
+
// The group list keeps its name from the folded title.
|
|
1104
|
+
expect(screen.getByRole('list', { name: '도움말' })).toBeInTheDocument();
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('fades the label and count on the feedback clock, which reduced motion zeroes', () => {
|
|
1108
|
+
render(items(true));
|
|
1109
|
+
const row = screen.getByRole('link', { name: '멤버 관리' });
|
|
1110
|
+
const label = row.querySelector('[data-part="label"]')!;
|
|
1111
|
+
const count = screen.getByText('12');
|
|
1112
|
+
expect(ownDeclarations(label)).toContain('opacity:0');
|
|
1113
|
+
expect(ownDeclarations(label)).toContain('transition-property:opacity');
|
|
1114
|
+
expect(ownDeclarations(count)).toMatch(
|
|
1115
|
+
/transition-property:opacity, ?visibility/
|
|
1116
|
+
);
|
|
1117
|
+
[label, count].forEach((el) => {
|
|
1118
|
+
expect(ownDeclarations(el)).toContain(
|
|
1119
|
+
'transition-duration:var(--chakra-durations-fast)'
|
|
1120
|
+
);
|
|
1121
|
+
expect(
|
|
1122
|
+
rulesFor(el).some(
|
|
1123
|
+
([media, , body]) =>
|
|
1124
|
+
media.includes('prefers-reduced-motion') &&
|
|
1125
|
+
body.includes(
|
|
1126
|
+
'transition-duration:var(--chakra-durations-motion-instant)'
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
).toBe(true);
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('keeps the row padding in both modes, so the icon does not move', () => {
|
|
1134
|
+
const { rerender } = render(items(false));
|
|
1135
|
+
const row = screen.getByRole('link', { name: '멤버 관리 12' });
|
|
1136
|
+
const padding = (decl: string) =>
|
|
1137
|
+
decl.match(/padding-inline:[^;]+/)?.[0] ??
|
|
1138
|
+
decl.match(/padding-inline-start:[^;]+/)?.[0];
|
|
1139
|
+
const expanded = padding(ownDeclarations(row));
|
|
1140
|
+
rerender(items(true));
|
|
1141
|
+
expect(padding(ownDeclarations(row))).toBe(expanded);
|
|
1142
|
+
expect(ownDeclarations(row)).not.toContain('justify-content:center');
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
describe('Tooltip + Menu on one trailing-action trigger', () => {
|
|
1147
|
+
it('both wrappers take `ids.trigger`, so the menu anchors to the button', async () => {
|
|
1148
|
+
const user = userEvent.setup();
|
|
1149
|
+
const RowMenu = () => {
|
|
1150
|
+
const triggerId = useId();
|
|
1151
|
+
return (
|
|
1152
|
+
<Menu ids={{ trigger: triggerId }}>
|
|
1153
|
+
<Tooltip ids={{ trigger: triggerId }} content="더 보기">
|
|
1154
|
+
<Menu.Trigger asChild>
|
|
1155
|
+
<button type="button">세션 1 더 보기</button>
|
|
1156
|
+
</Menu.Trigger>
|
|
1157
|
+
</Tooltip>
|
|
1158
|
+
<Menu.List>
|
|
1159
|
+
<Menu.Item value="rename">이름 바꾸기</Menu.Item>
|
|
1160
|
+
</Menu.List>
|
|
1161
|
+
</Menu>
|
|
1162
|
+
);
|
|
1163
|
+
};
|
|
1164
|
+
renderUi(
|
|
1165
|
+
<NavList aria-label="세션">
|
|
1166
|
+
<NavList.Item
|
|
1167
|
+
href="/s/1"
|
|
1168
|
+
trailingActionVisibility="hover"
|
|
1169
|
+
trailingAction={<RowMenu />}
|
|
1170
|
+
>
|
|
1171
|
+
세션 1
|
|
1172
|
+
</NavList.Item>
|
|
1173
|
+
</NavList>
|
|
1174
|
+
);
|
|
1175
|
+
const button = screen.getByRole('button', { name: '세션 1 더 보기' });
|
|
1176
|
+
await user.click(button);
|
|
1177
|
+
const menu = await screen.findByRole('menu');
|
|
1178
|
+
// One id on the button, and the menu points at it — the reference
|
|
1179
|
+
// zag positions the menu from.
|
|
1180
|
+
expect(menu).toHaveAttribute('aria-labelledby', button.id);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('without the shared id the two triggers disagree — the bug it prevents', async () => {
|
|
1184
|
+
const user = userEvent.setup();
|
|
1185
|
+
renderUi(
|
|
1186
|
+
<Menu>
|
|
1187
|
+
<Tooltip content="더 보기">
|
|
1188
|
+
<Menu.Trigger asChild>
|
|
1189
|
+
<button type="button">더 보기</button>
|
|
1190
|
+
</Menu.Trigger>
|
|
1191
|
+
</Tooltip>
|
|
1192
|
+
<Menu.List>
|
|
1193
|
+
<Menu.Item value="rename">이름 바꾸기</Menu.Item>
|
|
1194
|
+
</Menu.List>
|
|
1195
|
+
</Menu>
|
|
1196
|
+
);
|
|
1197
|
+
const button = screen.getByRole('button', { name: '더 보기' });
|
|
1198
|
+
await user.click(button);
|
|
1199
|
+
const menu = await screen.findByRole('menu');
|
|
1200
|
+
expect(menu.getAttribute('aria-labelledby')).not.toBe(button.id);
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
describe('Skeleton', () => {
|
|
1205
|
+
it('renders the requested rows, hidden from assistive tech', () => {
|
|
1206
|
+
const { container } = renderUi(<NavList.Skeleton count={3} />);
|
|
1207
|
+
const ul = container.querySelector('ul');
|
|
1208
|
+
expect(ul).toHaveAttribute('aria-hidden', 'true');
|
|
1209
|
+
expect(ul?.querySelectorAll('li')).toHaveLength(3);
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
describe('router links', () => {
|
|
1214
|
+
it('onClick + preventDefault reaches the router link as defaultPrevented', async () => {
|
|
1215
|
+
const user = userEvent.setup();
|
|
1216
|
+
let seen: boolean | undefined;
|
|
1217
|
+
renderUi(
|
|
1218
|
+
<NavList>
|
|
1219
|
+
<NavList.Item asChild onClick={(e) => e.preventDefault()}>
|
|
1220
|
+
<RouterLink
|
|
1221
|
+
href="/a"
|
|
1222
|
+
onClick={(e) => {
|
|
1223
|
+
seen = e.defaultPrevented;
|
|
1224
|
+
}}
|
|
1225
|
+
>
|
|
1226
|
+
A
|
|
1227
|
+
</RouterLink>
|
|
1228
|
+
</NavList.Item>
|
|
1229
|
+
</NavList>
|
|
1230
|
+
);
|
|
1231
|
+
await user.click(screen.getByRole('link', { name: 'A' }));
|
|
1232
|
+
expect(seen).toBe(true);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it('leaves modifier clicks alone, so cmd/ctrl-click can open a new tab', () => {
|
|
1236
|
+
const onClick = vi.fn();
|
|
1237
|
+
renderUi(
|
|
1238
|
+
<NavList>
|
|
1239
|
+
<NavList.Item href="/a" onClick={onClick}>
|
|
1240
|
+
A
|
|
1241
|
+
</NavList.Item>
|
|
1242
|
+
</NavList>
|
|
1243
|
+
);
|
|
1244
|
+
const event = new MouseEvent('click', {
|
|
1245
|
+
bubbles: true,
|
|
1246
|
+
cancelable: true,
|
|
1247
|
+
metaKey: true,
|
|
1248
|
+
});
|
|
1249
|
+
screen.getByRole('link', { name: 'A' }).dispatchEvent(event);
|
|
1250
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
1251
|
+
expect(event.defaultPrevented).toBe(false);
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
});
|