@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.
- package/dist/components/ErrorFallback/ErrorFallback.d.ts.map +1 -1
- package/dist/components/ErrorFallback/ErrorFallback.js +13 -2
- package/dist/components/ErrorFallback/ErrorFallback.js.map +1 -1
- package/dist/components/ErrorFallback/ErrorFallback.mjs +13 -2
- package/dist/components/ErrorFallback/ErrorFallback.mjs.map +1 -1
- package/dist/components/FileInput/FileInput.d.ts.map +1 -1
- package/dist/components/FileInput/FileInput.js +7 -1
- package/dist/components/FileInput/FileInput.js.map +1 -1
- package/dist/components/FileInput/FileInput.mjs +7 -1
- package/dist/components/FileInput/FileInput.mjs.map +1 -1
- package/dist/components/Markdown/Markdown.d.ts.map +1 -1
- package/dist/components/Markdown/Markdown.js +6 -1
- package/dist/components/Markdown/Markdown.js.map +1 -1
- package/dist/components/Markdown/Markdown.mjs +6 -1
- package/dist/components/Markdown/Markdown.mjs.map +1 -1
- package/dist/components/Pagination/Pagination.d.ts.map +1 -1
- package/dist/components/Pagination/Pagination.js +6 -1
- package/dist/components/Pagination/Pagination.js.map +1 -1
- package/dist/components/Pagination/Pagination.mjs +6 -1
- package/dist/components/Pagination/Pagination.mjs.map +1 -1
- package/dist/components/Radio/RadioControl.d.ts.map +1 -1
- package/dist/components/Radio/RadioControl.js +14 -1
- package/dist/components/Radio/RadioControl.js.map +1 -1
- package/dist/components/Radio/RadioControl.mjs +15 -2
- package/dist/components/Radio/RadioControl.mjs.map +1 -1
- package/dist/components/Select/ComboboxField.d.ts +1 -1
- package/dist/components/Select/ComboboxField.d.ts.map +1 -1
- package/dist/components/Select/ComboboxField.js +2 -2
- package/dist/components/Select/ComboboxField.js.map +1 -1
- package/dist/components/Select/ComboboxField.mjs +2 -2
- package/dist/components/Select/ComboboxField.mjs.map +1 -1
- package/dist/components/Select/Select.types.d.ts +16 -0
- package/dist/components/Select/Select.types.d.ts.map +1 -1
- package/dist/components/Select/SelectField.d.ts +1 -1
- package/dist/components/Select/SelectField.d.ts.map +1 -1
- package/dist/components/Select/SelectField.js +2 -2
- package/dist/components/Select/SelectField.js.map +1 -1
- package/dist/components/Select/SelectField.mjs +2 -2
- package/dist/components/Select/SelectField.mjs.map +1 -1
- package/dist/components/Slider/Slider.d.ts.map +1 -1
- package/dist/components/Slider/Slider.js +2 -2
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/Slider/Slider.mjs +3 -3
- package/dist/components/Slider/Slider.mjs.map +1 -1
- package/dist/components/Slider/Slider.types.d.ts +15 -0
- package/dist/components/Slider/Slider.types.d.ts.map +1 -1
- package/dist/components/Typography/Link.d.ts.map +1 -1
- package/dist/components/Typography/Link.js +17 -7
- package/dist/components/Typography/Link.js.map +1 -1
- package/dist/components/Typography/Link.mjs +17 -7
- package/dist/components/Typography/Link.mjs.map +1 -1
- package/dist/components/Typography/Subtitle.d.ts +17 -0
- package/dist/components/Typography/Subtitle.d.ts.map +1 -1
- package/dist/components/Typography/Subtitle.js +18 -1
- package/dist/components/Typography/Subtitle.js.map +1 -1
- package/dist/components/Typography/Subtitle.mjs +18 -1
- package/dist/components/Typography/Subtitle.mjs.map +1 -1
- package/dist/components/Workflow/canvas/NodePalette/NodePalette.d.ts.map +1 -1
- package/dist/components/Workflow/canvas/NodePalette/NodePalette.js +19 -2
- package/dist/components/Workflow/canvas/NodePalette/NodePalette.js.map +1 -1
- package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs +19 -2
- package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/ErrorFallback/ErrorFallback.tsx +30 -3
- package/src/components/FileInput/FileInput.tsx +11 -2
- package/src/components/Markdown/Markdown.tsx +6 -1
- package/src/components/Pagination/Pagination.tsx +5 -0
- package/src/components/Radio/RadioControl.tsx +20 -6
- package/src/components/Select/ComboboxField.tsx +11 -1
- package/src/components/Select/Select.types.ts +16 -0
- package/src/components/Select/SelectField.tsx +14 -1
- package/src/components/Slider/Slider.tsx +16 -1
- package/src/components/Slider/Slider.types.ts +15 -0
- package/src/components/Typography/Link.tsx +17 -7
- package/src/components/Typography/Subtitle.tsx +17 -1
- package/src/components/Workflow/canvas/NodePalette/NodePalette.tsx +12 -2
- 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
|
+
});
|