@mindlogic-ai/logician-ui 4.0.0-alpha.21 → 4.0.0-alpha.22

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 (77) hide show
  1. package/dist/components/ErrorFallback/ErrorFallback.d.ts.map +1 -1
  2. package/dist/components/ErrorFallback/ErrorFallback.js +13 -2
  3. package/dist/components/ErrorFallback/ErrorFallback.js.map +1 -1
  4. package/dist/components/ErrorFallback/ErrorFallback.mjs +13 -2
  5. package/dist/components/ErrorFallback/ErrorFallback.mjs.map +1 -1
  6. package/dist/components/FileInput/FileInput.d.ts.map +1 -1
  7. package/dist/components/FileInput/FileInput.js +7 -1
  8. package/dist/components/FileInput/FileInput.js.map +1 -1
  9. package/dist/components/FileInput/FileInput.mjs +7 -1
  10. package/dist/components/FileInput/FileInput.mjs.map +1 -1
  11. package/dist/components/Markdown/Markdown.d.ts.map +1 -1
  12. package/dist/components/Markdown/Markdown.js +6 -1
  13. package/dist/components/Markdown/Markdown.js.map +1 -1
  14. package/dist/components/Markdown/Markdown.mjs +6 -1
  15. package/dist/components/Markdown/Markdown.mjs.map +1 -1
  16. package/dist/components/Pagination/Pagination.d.ts.map +1 -1
  17. package/dist/components/Pagination/Pagination.js +6 -1
  18. package/dist/components/Pagination/Pagination.js.map +1 -1
  19. package/dist/components/Pagination/Pagination.mjs +6 -1
  20. package/dist/components/Pagination/Pagination.mjs.map +1 -1
  21. package/dist/components/Radio/RadioControl.d.ts.map +1 -1
  22. package/dist/components/Radio/RadioControl.js +14 -1
  23. package/dist/components/Radio/RadioControl.js.map +1 -1
  24. package/dist/components/Radio/RadioControl.mjs +15 -2
  25. package/dist/components/Radio/RadioControl.mjs.map +1 -1
  26. package/dist/components/Select/ComboboxField.d.ts +1 -1
  27. package/dist/components/Select/ComboboxField.d.ts.map +1 -1
  28. package/dist/components/Select/ComboboxField.js +2 -2
  29. package/dist/components/Select/ComboboxField.js.map +1 -1
  30. package/dist/components/Select/ComboboxField.mjs +2 -2
  31. package/dist/components/Select/ComboboxField.mjs.map +1 -1
  32. package/dist/components/Select/Select.types.d.ts +16 -0
  33. package/dist/components/Select/Select.types.d.ts.map +1 -1
  34. package/dist/components/Select/SelectField.d.ts +1 -1
  35. package/dist/components/Select/SelectField.d.ts.map +1 -1
  36. package/dist/components/Select/SelectField.js +2 -2
  37. package/dist/components/Select/SelectField.js.map +1 -1
  38. package/dist/components/Select/SelectField.mjs +2 -2
  39. package/dist/components/Select/SelectField.mjs.map +1 -1
  40. package/dist/components/Slider/Slider.d.ts.map +1 -1
  41. package/dist/components/Slider/Slider.js +2 -2
  42. package/dist/components/Slider/Slider.js.map +1 -1
  43. package/dist/components/Slider/Slider.mjs +3 -3
  44. package/dist/components/Slider/Slider.mjs.map +1 -1
  45. package/dist/components/Slider/Slider.types.d.ts +15 -0
  46. package/dist/components/Slider/Slider.types.d.ts.map +1 -1
  47. package/dist/components/Typography/Link.d.ts.map +1 -1
  48. package/dist/components/Typography/Link.js +17 -7
  49. package/dist/components/Typography/Link.js.map +1 -1
  50. package/dist/components/Typography/Link.mjs +17 -7
  51. package/dist/components/Typography/Link.mjs.map +1 -1
  52. package/dist/components/Typography/Subtitle.d.ts +17 -0
  53. package/dist/components/Typography/Subtitle.d.ts.map +1 -1
  54. package/dist/components/Typography/Subtitle.js +18 -1
  55. package/dist/components/Typography/Subtitle.js.map +1 -1
  56. package/dist/components/Typography/Subtitle.mjs +18 -1
  57. package/dist/components/Typography/Subtitle.mjs.map +1 -1
  58. package/dist/components/Workflow/canvas/NodePalette/NodePalette.d.ts.map +1 -1
  59. package/dist/components/Workflow/canvas/NodePalette/NodePalette.js +19 -2
  60. package/dist/components/Workflow/canvas/NodePalette/NodePalette.js.map +1 -1
  61. package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs +19 -2
  62. package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs.map +1 -1
  63. package/package.json +2 -1
  64. package/src/components/ErrorFallback/ErrorFallback.tsx +30 -3
  65. package/src/components/FileInput/FileInput.tsx +11 -2
  66. package/src/components/Markdown/Markdown.tsx +6 -1
  67. package/src/components/Pagination/Pagination.tsx +5 -0
  68. package/src/components/Radio/RadioControl.tsx +20 -6
  69. package/src/components/Select/ComboboxField.tsx +11 -1
  70. package/src/components/Select/Select.types.ts +16 -0
  71. package/src/components/Select/SelectField.tsx +14 -1
  72. package/src/components/Slider/Slider.tsx +16 -1
  73. package/src/components/Slider/Slider.types.ts +15 -0
  74. package/src/components/Typography/Link.tsx +17 -7
  75. package/src/components/Typography/Subtitle.tsx +17 -1
  76. package/src/components/Workflow/canvas/NodePalette/NodePalette.tsx +12 -2
  77. package/src/components/a11y.kwcag.test.tsx +295 -0
@@ -0,0 +1,295 @@
1
+ import { render } from '@testing-library/react';
2
+ import axe from 'axe-core';
3
+ import { beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ import { FileInput } from '@/components/FileInput';
6
+ import { LogicianProvider } from '@/components/LogicianProvider';
7
+ import { baseMarkdownComponents } from '@/components/Markdown/Markdown';
8
+ import { Radio, RadioGroup } from '@/components/Radio';
9
+ import { SelectField } from '@/components/Select';
10
+ import {
11
+ Slider,
12
+ SliderFilledTrack,
13
+ SliderThumb,
14
+ SliderTrack,
15
+ } from '@/components/Slider';
16
+ import { Link, Subtitle } from '@/components/Typography';
17
+
18
+ /**
19
+ * Regressions for the accessibility defects a KWCAG 2.1 (한국형 웹 콘텐츠 접근성
20
+ * 지침) audit found in these components.
21
+ *
22
+ * Each one shipped for a while, and each was invisible in review: the screen
23
+ * looked right, and only the accessibility tree or the markup said otherwise.
24
+ * That is exactly the kind of defect a test has to hold, because the next person
25
+ * to touch the component will not see it either.
26
+ *
27
+ * The Korean checkpoint ids are here on purpose — a 웹 접근성 품질인증 evaluator
28
+ * works from those numbers, and a failing test should say which one it is about.
29
+ */
30
+
31
+ beforeAll(() => {
32
+ // Zag's slider observes its thumb to size the track. jsdom ships no
33
+ // `ResizeObserver`, so without this the observe call throws ASYNCHRONOUSLY,
34
+ // after the test that triggered it has already passed — which vitest reports
35
+ // as an unhandled error and exits non-zero on. A green test list and a red
36
+ // run is the worst way to find that out, so it is stubbed here rather than
37
+ // left to whoever next reads the CI log.
38
+ window.ResizeObserver = class {
39
+ observe() {}
40
+ unobserve() {}
41
+ disconnect() {}
42
+ } as unknown as typeof window.ResizeObserver;
43
+
44
+ // Chakra's color-mode and responsive machinery calls it on mount; jsdom has no
45
+ // implementation.
46
+ window.matchMedia = ((query: string) => ({
47
+ matches: false,
48
+ media: query,
49
+ onchange: null,
50
+ addListener: () => {},
51
+ removeListener: () => {},
52
+ addEventListener: () => {},
53
+ removeEventListener: () => {},
54
+ dispatchEvent: () => false,
55
+ })) as unknown as typeof window.matchMedia;
56
+ });
57
+
58
+ const withProvider = (ui: React.ReactNode) =>
59
+ render(<LogicianProvider>{ui}</LogicianProvider>);
60
+
61
+ /** The text a screen reader would announce for `element`, via `aria-labelledby`. */
62
+ const nameFromLabelledBy = (element: Element | null): string =>
63
+ (element?.getAttribute('aria-labelledby') ?? '')
64
+ .split(/\s+/)
65
+ .filter(Boolean)
66
+ .map((id) => document.getElementById(id)?.textContent ?? '')
67
+ .join(' ')
68
+ .trim();
69
+
70
+ describe('KWCAG 5.4.1.1 마크업 오류 방지', () => {
71
+ it('라디오 하나가 같은 id 를 두 번 내보내지 않는다', () => {
72
+ const { container } = withProvider(
73
+ <RadioGroup defaultValue="a">
74
+ <Radio value="a">
75
+ <Radio.Control />
76
+ <Radio.Text>A</Radio.Text>
77
+ </Radio>
78
+ <Radio value="b">
79
+ <Radio.Control />
80
+ <Radio.Text>B</Radio.Text>
81
+ </Radio>
82
+ </RadioGroup>
83
+ );
84
+
85
+ const ids = [...container.querySelectorAll('[id]')].map((el) => el.id);
86
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
87
+
88
+ // The control and the indicator inside it used to share Chakra's generated
89
+ // `ids.itemControl`, so every radio on a page carried a duplicate.
90
+ expect(duplicates).toEqual([]);
91
+ });
92
+ });
93
+
94
+ describe('KWCAG 5.3.4.1 레이블 제공', () => {
95
+ it('보이는 레이블이 없어도 셀렉트에 이름이 있다', () => {
96
+ const { container } = withProvider(
97
+ <SelectField
98
+ options={[{ label: 'One', value: '1' }]}
99
+ ariaLabel="분류 선택"
100
+ />
101
+ );
102
+
103
+ // The trigger's `aria-labelledby` is emitted whether or not the label part
104
+ // is rendered; without the part it pointed at nothing and the control
105
+ // announced as a bare "button".
106
+ expect(
107
+ nameFromLabelledBy(container.querySelector('[data-part="trigger"]'))
108
+ ).toBe('분류 선택');
109
+ });
110
+
111
+ it('보이는 레이블이 없어도 슬라이더에 이름이 있다', () => {
112
+ const { container } = withProvider(
113
+ <Slider ariaLabel="탐색" value={[10]} min={0} max={100}>
114
+ <SliderTrack>
115
+ <SliderFilledTrack />
116
+ </SliderTrack>
117
+ <SliderThumb />
118
+ </Slider>
119
+ );
120
+
121
+ expect(
122
+ nameFromLabelledBy(container.querySelector('[data-part="thumb"]'))
123
+ ).toBe('탐색');
124
+ });
125
+ });
126
+
127
+ describe('KWCAG 5.2.4.2 제목 제공', () => {
128
+ it('Subtitle 은 제목이 아니라 문단이다', () => {
129
+ const { container } = withProvider(<Subtitle>보조 설명</Subtitle>);
130
+
131
+ // It rendered `<h6>`, which put every call site into the document outline —
132
+ // a paragraph of helper copy announced as a level-6 heading, and a jump from
133
+ // whatever level came before it.
134
+ expect(container.querySelector('h6')).toBeNull();
135
+ expect(container.querySelector('p')?.textContent).toBe('보조 설명');
136
+ });
137
+
138
+ it('제목이 필요하면 호출부가 말할 수 있다', () => {
139
+ const { container } = withProvider(<Subtitle as="h2">절 제목</Subtitle>);
140
+
141
+ expect(container.querySelector('h2')?.textContent).toBe('절 제목');
142
+ });
143
+
144
+ it('마크다운의 ###### 은 여전히 제목이다', () => {
145
+ // `Subtitle` 의 기본 태그를 문단으로 바꾸면서 같이 끌려간 자리. 마크다운
146
+ // 매퍼의 `h6` 는 **글쓴이가 실제로 쓴 제목**이므로, 여기까지 문단이 되면
147
+ // 고치려던 결함이 방향만 바꿔 다시 생긴다 — 제목 목록에서 사라지는 쪽으로.
148
+ const H6 = baseMarkdownComponents.h6 as React.ComponentType<{
149
+ children: React.ReactNode;
150
+ }>;
151
+ const { container } = withProvider(<H6>여섯 번째 제목</H6>);
152
+
153
+ expect(container.querySelector('h6')?.textContent).toBe('여섯 번째 제목');
154
+ });
155
+ });
156
+
157
+ describe('KWCAG 5.4.2.1 웹 애플리케이션 접근성 준수', () => {
158
+ it('파일 입력이 버튼 안에 컨트롤을 넣지 않는다', () => {
159
+ const { container } = withProvider(<FileInput onChange={() => {}} />);
160
+
161
+ // `role="button"` on the wrapper made the real `<input>` an interactive
162
+ // element inside another (axe `nested-interactive`), and `role="group"` on a
163
+ // `<label>` is not an allowed pairing at all (`aria-allowed-role`).
164
+ expect(container.querySelector('[role="button"]')).toBeNull();
165
+ expect(container.querySelector('label[role]')).toBeNull();
166
+ });
167
+
168
+ it('호버 시 드러나는 오버레이는 group 클래스로 동작한다', () => {
169
+ const { container } = withProvider(<FileInput onChange={() => {}} />);
170
+
171
+ // `_groupHover` compiles to `.group:hover &`, so the CLASS is what makes it
172
+ // fire — the old `role="group"` did nothing for hover or for assistive tech.
173
+ expect(container.querySelector('label')?.className).toContain('group');
174
+ });
175
+ });
176
+
177
+ /**
178
+ * An axe pass over the components above, so the NEXT defect of this shape does
179
+ * not need an audit downstream to find it.
180
+ *
181
+ * Everything in this file until now is a regression test: each one names a
182
+ * defect that already shipped. This runs the same engine the certification
183
+ * audit runs, in the design system, on the compositions most likely to be
184
+ * wrong — so a component that acquires a nameless control, a role that is not
185
+ * allowed on its element, or an interactive element inside another fails here
186
+ * rather than in an app that consumes it.
187
+ *
188
+ * **jsdom has no layout**, so this is deliberately not the whole rule set:
189
+ * `color-contrast` needs painted pixels and reports nothing here, and rules
190
+ * about visibility or size have the same problem. What jsdom does carry
191
+ * perfectly is structure and ARIA — which is precisely where all nine defects
192
+ * in this branch lived. The contrast half belongs to a browser, and FactChat's
193
+ * `storybook-a11y.yml` runs it there in both colour modes.
194
+ */
195
+ const STRUCTURAL_AXE_RULES = [
196
+ 'aria-allowed-attr',
197
+ 'aria-allowed-role',
198
+ 'aria-required-attr',
199
+ 'aria-required-children',
200
+ 'aria-required-parent',
201
+ 'aria-valid-attr-value',
202
+ 'button-name',
203
+ 'duplicate-id',
204
+ 'label',
205
+ 'link-name',
206
+ 'nested-interactive',
207
+ 'aria-input-field-name',
208
+ 'aria-toggle-field-name',
209
+ ];
210
+
211
+ /** Runs axe over a container and returns one line per violating node. */
212
+ const axeViolations = async (container: HTMLElement): Promise<string[]> => {
213
+ const results = await axe.run(container, {
214
+ runOnly: { type: 'rule', values: STRUCTURAL_AXE_RULES },
215
+ // The default reporter walks the whole document for a summary this does
216
+ // not use, and jsdom makes that slow for no gain.
217
+ resultTypes: ['violations'],
218
+ });
219
+ return results.violations.flatMap((v) =>
220
+ v.nodes.map((n) => `${v.id}: ${n.html.replace(/\s+/g, ' ').slice(0, 120)}`)
221
+ );
222
+ };
223
+
224
+ describe('KWCAG 5.1.3.3 텍스트 콘텐츠의 명도 대비', () => {
225
+ it('링크 색은 테마에 따라 갈린다', () => {
226
+ // `.main` is tuned against a white page: on the dark canvas `primary.main`
227
+ // is 4.19:1, under the 4.5:1 body text needs. Every link in a consuming app
228
+ // inherits this default, so it is the single highest-reach instance of that
229
+ // defect in the package.
230
+ const { container } = withProvider(<Link href="#">링크</Link>);
231
+ const cls = container.querySelector('a')?.className ?? '';
232
+
233
+ // Emotion compiles the conditional into a class; a flat string value would
234
+ // not produce a `_dark` variant at all.
235
+ expect(cls).not.toBe('');
236
+ });
237
+
238
+ it('호출부가 준 색을 버리지 않는다', () => {
239
+ // The old implementation narrowed with `typeof color === 'string'` and
240
+ // dropped anything else — so a per-theme `{ base, _dark }`, which is the
241
+ // documented way to fix a contrast problem, was silently replaced by the
242
+ // default. Accepted by the type, discarded at runtime, no error either way.
243
+ const custom = { base: 'danger.main', _dark: 'danger.dark' };
244
+ const { container } = withProvider(
245
+ <Link href="#" color={custom}>
246
+ 링크
247
+ </Link>
248
+ );
249
+ const withCustom = container.querySelector('a')?.className ?? '';
250
+
251
+ const { container: plain } = withProvider(<Link href="#">링크</Link>);
252
+ const withDefault = plain.querySelector('a')?.className ?? '';
253
+
254
+ expect(withCustom).not.toBe(withDefault);
255
+ });
256
+ });
257
+
258
+ describe('axe — 구조·ARIA 규칙', () => {
259
+ it.each([
260
+ [
261
+ '이름이 있는 셀렉트',
262
+ <SelectField
263
+ key="select"
264
+ options={[{ label: 'One', value: '1' }]}
265
+ ariaLabel="분류 선택"
266
+ />,
267
+ ],
268
+ [
269
+ '이름이 있는 슬라이더',
270
+ <Slider key="slider" ariaLabel="탐색" value={[10]} min={0} max={100}>
271
+ <SliderTrack>
272
+ <SliderFilledTrack />
273
+ </SliderTrack>
274
+ <SliderThumb />
275
+ </Slider>,
276
+ ],
277
+ [
278
+ '라디오 그룹',
279
+ <RadioGroup key="radio" defaultValue="a">
280
+ <Radio value="a">
281
+ <Radio.Control />
282
+ <Radio.Text>A</Radio.Text>
283
+ </Radio>
284
+ <Radio value="b">
285
+ <Radio.Control />
286
+ <Radio.Text>B</Radio.Text>
287
+ </Radio>
288
+ </RadioGroup>,
289
+ ],
290
+ ['파일 입력', <FileInput key="file" onChange={() => {}} />],
291
+ ])('%s', async (_name, ui) => {
292
+ const { container } = withProvider(ui);
293
+ expect(await axeViolations(container)).toEqual([]);
294
+ });
295
+ });