@mindlogic-ai/logician-ui 4.0.0-alpha.21 → 4.0.0-alpha.23
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 +25 -0
- package/dist/components/Typography/Link.d.ts.map +1 -1
- package/dist/components/Typography/Link.js +44 -7
- package/dist/components/Typography/Link.js.map +1 -1
- package/dist/components/Typography/Link.mjs +44 -8
- 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/Typography/index.d.ts +1 -1
- package/dist/components/Typography/index.d.ts.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/dist/theme/colors.d.ts +145 -37
- package/dist/theme/colors.d.ts.map +1 -1
- package/dist/theme/colors.js +98 -68
- package/dist/theme/colors.js.map +1 -1
- package/dist/theme/colors.mjs +98 -68
- package/dist/theme/colors.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 +44 -7
- package/src/components/Typography/Subtitle.tsx +17 -1
- package/src/components/Typography/index.ts +1 -1
- package/src/components/Workflow/canvas/NodePalette/NodePalette.tsx +12 -2
- package/src/components/a11y.kwcag.test.tsx +412 -0
- package/src/theme/colors.ts +99 -69
|
@@ -15,7 +15,14 @@ import { SliderControl } from './SliderControl';
|
|
|
15
15
|
*/
|
|
16
16
|
export const Slider = forwardRef(
|
|
17
17
|
(
|
|
18
|
-
{
|
|
18
|
+
{
|
|
19
|
+
value,
|
|
20
|
+
defaultValue,
|
|
21
|
+
onValueChange,
|
|
22
|
+
ariaLabel,
|
|
23
|
+
children,
|
|
24
|
+
...rest
|
|
25
|
+
}: SliderProps,
|
|
19
26
|
ref?: ForwardedRef<HTMLDivElement>
|
|
20
27
|
) => {
|
|
21
28
|
// Check if children already contains SliderControl
|
|
@@ -42,6 +49,14 @@ export const Slider = forwardRef(
|
|
|
42
49
|
onValueChange={onValueChange}
|
|
43
50
|
{...rest}
|
|
44
51
|
>
|
|
52
|
+
{/* The label part is what each thumb's `aria-labelledby` points at, and
|
|
53
|
+
the reference is emitted whether or not the part is rendered — so a
|
|
54
|
+
slider composed without one is not merely unlabelled, it is unnamed
|
|
55
|
+
(KWCAG 5.3.4.1 레이블 제공). Hidden, because these call sites carry
|
|
56
|
+
their label as an adjacent icon or time display. */}
|
|
57
|
+
{ariaLabel && (
|
|
58
|
+
<ChakraSlider.Label srOnly>{ariaLabel}</ChakraSlider.Label>
|
|
59
|
+
)}
|
|
45
60
|
{wrappedChildren}
|
|
46
61
|
</ChakraSlider.Root>
|
|
47
62
|
);
|
|
@@ -11,5 +11,20 @@ export interface SliderProps extends Omit<
|
|
|
11
11
|
defaultValue?: number[];
|
|
12
12
|
/** Callback when slider value changes */
|
|
13
13
|
onValueChange?: Slider.RootProps['onValueChange'];
|
|
14
|
+
/**
|
|
15
|
+
* Accessible name for the slider, when no visible `Slider.Label` is composed
|
|
16
|
+
* in — a seek bar next to a time display, a volume control beside a mute
|
|
17
|
+
* button.
|
|
18
|
+
*
|
|
19
|
+
* Without it the thumb has **no accessible name**: Zag points each thumb's
|
|
20
|
+
* `aria-labelledby` at a label part, and a composition that does not render
|
|
21
|
+
* one leaves that reference dangling — the control announces as a bare
|
|
22
|
+
* "slider" (KWCAG 2.1 5.3.4.1 레이블 제공, axe `aria-input-field-name`).
|
|
23
|
+
*
|
|
24
|
+
* Rendered as a visually hidden label rather than an `aria-label` array so it
|
|
25
|
+
* works the same whether the slider has one thumb or several, and so the name
|
|
26
|
+
* is in the server-rendered markup.
|
|
27
|
+
*/
|
|
28
|
+
ariaLabel?: string;
|
|
14
29
|
children?: ReactNode;
|
|
15
30
|
}
|
|
@@ -5,17 +5,54 @@ export interface LinkCustomProps extends Omit<LinkProps, 'variant'> {
|
|
|
5
5
|
variant?: 'error';
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The ramp steps the link paints, exported so a test can assert BOTH halves at
|
|
10
|
+
* once: that the component still picks these tokens, and that these tokens
|
|
11
|
+
* still clear the contrast bar. Asserting only the second half is a test that
|
|
12
|
+
* passes while the component points somewhere else entirely — which is how the
|
|
13
|
+
* dark hover regressed with a green suite.
|
|
14
|
+
*/
|
|
15
|
+
export const LINK_RAMP = {
|
|
16
|
+
default: { base: 'primary.main', _dark: 'primary.dark' },
|
|
17
|
+
error: { base: 'danger.main', _dark: 'danger.dark' },
|
|
18
|
+
defaultHover: { base: 'primary.dark', _dark: 'primary.darker' },
|
|
19
|
+
errorHover: { base: 'danger.dark', _dark: 'danger.darker' },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
8
22
|
export const Link = forwardRef<HTMLAnchorElement, LinkCustomProps>(
|
|
9
23
|
({ color, variant, ...rest }, ref) => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
// Theme-aware, because `.main` is tuned against a WHITE page. On the dark
|
|
25
|
+
// canvas (#181A20) `primary.main` measures 4.19:1 and `danger.main` 4.26:1
|
|
26
|
+
// — both under the 4.5:1 that KWCAG 2.1 5.1.3.3 (명도 대비) asks of body
|
|
27
|
+
// text, and every link in a consuming app inherits it. The `.dark` end of
|
|
28
|
+
// each ramp is the far side from the PAGE rather than a fixed lightness, so
|
|
29
|
+
// in dark mode it resolves lighter: 6.68:1 and 6.38:1.
|
|
30
|
+
const defaultColor = LINK_RAMP.default;
|
|
31
|
+
const errorColor = LINK_RAMP.error;
|
|
32
|
+
// The hover has to move AWAY from the page in BOTH modes, and `.main` is
|
|
33
|
+
// the wrong end of the ramp to move toward in dark. Mirroring the light
|
|
34
|
+
// step ("one darker") instead of reflecting it sent the dark hover back
|
|
35
|
+
// toward the background: resting 6.68:1 → hovering **4.19:1**, under the
|
|
36
|
+
// same 4.5:1 the resting colour above was just fixed to clear. KWCAG
|
|
37
|
+
// 5.3.3 applies to text in every state a user can put it in, and hover is
|
|
38
|
+
// a state a mouse user is IN while reading the link.
|
|
39
|
+
//
|
|
40
|
+
// `.darker` is the step past `.dark` at both ends, so one token is correct
|
|
41
|
+
// in both modes with no special-casing: light 11.98:1 (unchanged from
|
|
42
|
+
// before), dark 10.68:1. Error the same: 10.83:1 and 10.34:1.
|
|
43
|
+
//
|
|
44
|
+
// No axe rule would have caught this — it measures the resting state only.
|
|
45
|
+
const defaultHoverColor = LINK_RAMP.defaultHover;
|
|
46
|
+
const errorHoverColor = LINK_RAMP.errorHover;
|
|
14
47
|
|
|
48
|
+
// `color ?? defaultColor`, not a `typeof color === 'string'` narrowing. The
|
|
49
|
+
// old form silently DISCARDED any non-string value, so a call site passing
|
|
50
|
+
// a per-theme `{ base, _dark }` — the documented Chakra way to fix exactly
|
|
51
|
+
// the contrast problem above — got the default back and no error, in the
|
|
52
|
+
// types or at runtime. A prop the type accepts and the component throws
|
|
53
|
+
// away is worse than one it rejects.
|
|
15
54
|
const linkColor =
|
|
16
|
-
variant === 'error'
|
|
17
|
-
? errorColor
|
|
18
|
-
: (typeof color === 'string' ? color : undefined) || defaultColor;
|
|
55
|
+
variant === 'error' ? errorColor : (color ?? defaultColor);
|
|
19
56
|
|
|
20
57
|
const hoverColor =
|
|
21
58
|
variant === 'error' ? errorHoverColor : defaultHoverColor;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { forwardRef } from 'react';
|
|
2
2
|
import { Text, TextProps } from '@chakra-ui/react';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Supporting text under a heading — a subtitle by role in the page, not by
|
|
6
|
+
* document structure.
|
|
7
|
+
*
|
|
8
|
+
* Renders a paragraph. It used to render `<h6>`, which made every call site a
|
|
9
|
+
* level-6 heading in the document outline: a sentence of helper copy under an
|
|
10
|
+
* `<h1>` announced as a heading to a screen reader, and a jump from level 1 to
|
|
11
|
+
* level 6 in the heading list (KWCAG 2.1 5.2.4.2 제목 제공 / axe `heading-order`).
|
|
12
|
+
* A component chosen for its type scale should not decide the outline.
|
|
13
|
+
*
|
|
14
|
+
* A subtitle that genuinely IS the next heading level still says so at the call
|
|
15
|
+
* site, which is where that is known:
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Subtitle as="h2">{sectionTitle}</Subtitle>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
4
21
|
export const Subtitle = forwardRef<HTMLParagraphElement, TextProps>(
|
|
5
22
|
(props, ref) => {
|
|
6
23
|
const { fontSize, ...rest } = props;
|
|
@@ -8,7 +25,6 @@ export const Subtitle = forwardRef<HTMLParagraphElement, TextProps>(
|
|
|
8
25
|
return (
|
|
9
26
|
<Text
|
|
10
27
|
ref={ref}
|
|
11
|
-
as="h6"
|
|
12
28
|
textStyle={fontSize ? undefined : 'subtitle'}
|
|
13
29
|
fontSize={fontSize}
|
|
14
30
|
color="fg.muted"
|
|
@@ -5,7 +5,7 @@ export { H2 } from './H2';
|
|
|
5
5
|
export { H3 } from './H3';
|
|
6
6
|
export { H4 } from './H4';
|
|
7
7
|
export { H5 } from './H5';
|
|
8
|
-
export { Link } from './Link';
|
|
8
|
+
export { Link, LINK_RAMP } from './Link';
|
|
9
9
|
export { Overline } from './Overline';
|
|
10
10
|
export { Subtext } from './Subtext';
|
|
11
11
|
export { Subtitle } from './Subtitle';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { DragEvent } from 'react';
|
|
3
|
+
import { DragEvent, useId } from 'react';
|
|
4
4
|
import { Box, HStack, Stack } from '@chakra-ui/react';
|
|
5
5
|
|
|
6
6
|
import { Card } from '@/components/Card';
|
|
@@ -95,6 +95,8 @@ function PaletteItem({ def }: { def: NodeTypeDef }) {
|
|
|
95
95
|
export function NodePalette({ onClose }: { onClose: () => void }) {
|
|
96
96
|
const { nodeTypes, readOnly } = useWorkflow();
|
|
97
97
|
const translate = useWorkflowTranslate();
|
|
98
|
+
// Names the region below with the same words the header shows.
|
|
99
|
+
const titleId = useId();
|
|
98
100
|
if (readOnly) return null;
|
|
99
101
|
|
|
100
102
|
// Group by category, preserving registration order within each group.
|
|
@@ -115,6 +117,14 @@ export function NodePalette({ onClose }: { onClose: () => void }) {
|
|
|
115
117
|
|
|
116
118
|
return (
|
|
117
119
|
<FloatingCard
|
|
120
|
+
// A named region, so the palette is something a screen-reader user can
|
|
121
|
+
// navigate TO. Its title used to be a `Subtitle`, which rendered `<h6>`
|
|
122
|
+
// and put a level-6 heading under the host page's `<h1>` (axe
|
|
123
|
+
// `heading-order`); `Subtitle` is a paragraph now, and a labelled region
|
|
124
|
+
// is what this panel actually is — a landmark, not an outline entry
|
|
125
|
+
// (KWCAG 2.1 5.2.4.1 반복 영역 건너뛰기 / 5.2.4.2 제목 제공).
|
|
126
|
+
role="region"
|
|
127
|
+
aria-labelledby={titleId}
|
|
118
128
|
width={PALETTE_WIDTH}
|
|
119
129
|
mt={4}
|
|
120
130
|
mb={4}
|
|
@@ -140,7 +150,7 @@ export function NodePalette({ onClose }: { onClose: () => void }) {
|
|
|
140
150
|
borderBottom="1px solid"
|
|
141
151
|
borderColor="slate.200"
|
|
142
152
|
>
|
|
143
|
-
<Subtitle color="slate.1300">
|
|
153
|
+
<Subtitle id={titleId} color="slate.1300">
|
|
144
154
|
{translate('workflow_palette_title')}
|
|
145
155
|
</Subtitle>
|
|
146
156
|
<IconButton
|
|
@@ -0,0 +1,412 @@
|
|
|
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, LINK_RAMP, Subtitle } from '@/components/Typography';
|
|
17
|
+
import { colors } from '@/theme/colors';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Regressions for the accessibility defects a KWCAG 2.1 (한국형 웹 콘텐츠 접근성
|
|
21
|
+
* 지침) audit found in these components.
|
|
22
|
+
*
|
|
23
|
+
* Each one shipped for a while, and each was invisible in review: the screen
|
|
24
|
+
* looked right, and only the accessibility tree or the markup said otherwise.
|
|
25
|
+
* That is exactly the kind of defect a test has to hold, because the next person
|
|
26
|
+
* to touch the component will not see it either.
|
|
27
|
+
*
|
|
28
|
+
* The Korean checkpoint ids are here on purpose — a 웹 접근성 품질인증 evaluator
|
|
29
|
+
* works from those numbers, and a failing test should say which one it is about.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
// Zag's slider observes its thumb to size the track. jsdom ships no
|
|
34
|
+
// `ResizeObserver`, so without this the observe call throws ASYNCHRONOUSLY,
|
|
35
|
+
// after the test that triggered it has already passed — which vitest reports
|
|
36
|
+
// as an unhandled error and exits non-zero on. A green test list and a red
|
|
37
|
+
// run is the worst way to find that out, so it is stubbed here rather than
|
|
38
|
+
// left to whoever next reads the CI log.
|
|
39
|
+
window.ResizeObserver = class {
|
|
40
|
+
observe() {}
|
|
41
|
+
unobserve() {}
|
|
42
|
+
disconnect() {}
|
|
43
|
+
} as unknown as typeof window.ResizeObserver;
|
|
44
|
+
|
|
45
|
+
// Chakra's color-mode and responsive machinery calls it on mount; jsdom has no
|
|
46
|
+
// implementation.
|
|
47
|
+
window.matchMedia = ((query: string) => ({
|
|
48
|
+
matches: false,
|
|
49
|
+
media: query,
|
|
50
|
+
onchange: null,
|
|
51
|
+
addListener: () => {},
|
|
52
|
+
removeListener: () => {},
|
|
53
|
+
addEventListener: () => {},
|
|
54
|
+
removeEventListener: () => {},
|
|
55
|
+
dispatchEvent: () => false,
|
|
56
|
+
})) as unknown as typeof window.matchMedia;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const withProvider = (ui: React.ReactNode) =>
|
|
60
|
+
render(<LogicianProvider>{ui}</LogicianProvider>);
|
|
61
|
+
|
|
62
|
+
/** The text a screen reader would announce for `element`, via `aria-labelledby`. */
|
|
63
|
+
const nameFromLabelledBy = (element: Element | null): string =>
|
|
64
|
+
(element?.getAttribute('aria-labelledby') ?? '')
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map((id) => document.getElementById(id)?.textContent ?? '')
|
|
68
|
+
.join(' ')
|
|
69
|
+
.trim();
|
|
70
|
+
|
|
71
|
+
describe('KWCAG 5.4.1.1 마크업 오류 방지', () => {
|
|
72
|
+
it('라디오 하나가 같은 id 를 두 번 내보내지 않는다', () => {
|
|
73
|
+
const { container } = withProvider(
|
|
74
|
+
<RadioGroup defaultValue="a">
|
|
75
|
+
<Radio value="a">
|
|
76
|
+
<Radio.Control />
|
|
77
|
+
<Radio.Text>A</Radio.Text>
|
|
78
|
+
</Radio>
|
|
79
|
+
<Radio value="b">
|
|
80
|
+
<Radio.Control />
|
|
81
|
+
<Radio.Text>B</Radio.Text>
|
|
82
|
+
</Radio>
|
|
83
|
+
</RadioGroup>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const ids = [...container.querySelectorAll('[id]')].map((el) => el.id);
|
|
87
|
+
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
88
|
+
|
|
89
|
+
// The control and the indicator inside it used to share Chakra's generated
|
|
90
|
+
// `ids.itemControl`, so every radio on a page carried a duplicate.
|
|
91
|
+
expect(duplicates).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('KWCAG 5.3.4.1 레이블 제공', () => {
|
|
96
|
+
it('보이는 레이블이 없어도 셀렉트에 이름이 있다', () => {
|
|
97
|
+
const { container } = withProvider(
|
|
98
|
+
<SelectField
|
|
99
|
+
options={[{ label: 'One', value: '1' }]}
|
|
100
|
+
ariaLabel="분류 선택"
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// The trigger's `aria-labelledby` is emitted whether or not the label part
|
|
105
|
+
// is rendered; without the part it pointed at nothing and the control
|
|
106
|
+
// announced as a bare "button".
|
|
107
|
+
expect(
|
|
108
|
+
nameFromLabelledBy(container.querySelector('[data-part="trigger"]'))
|
|
109
|
+
).toBe('분류 선택');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('보이는 레이블이 없어도 슬라이더에 이름이 있다', () => {
|
|
113
|
+
const { container } = withProvider(
|
|
114
|
+
<Slider ariaLabel="탐색" value={[10]} min={0} max={100}>
|
|
115
|
+
<SliderTrack>
|
|
116
|
+
<SliderFilledTrack />
|
|
117
|
+
</SliderTrack>
|
|
118
|
+
<SliderThumb />
|
|
119
|
+
</Slider>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
nameFromLabelledBy(container.querySelector('[data-part="thumb"]'))
|
|
124
|
+
).toBe('탐색');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('KWCAG 5.2.4.2 제목 제공', () => {
|
|
129
|
+
it('Subtitle 은 제목이 아니라 문단이다', () => {
|
|
130
|
+
const { container } = withProvider(<Subtitle>보조 설명</Subtitle>);
|
|
131
|
+
|
|
132
|
+
// It rendered `<h6>`, which put every call site into the document outline —
|
|
133
|
+
// a paragraph of helper copy announced as a level-6 heading, and a jump from
|
|
134
|
+
// whatever level came before it.
|
|
135
|
+
expect(container.querySelector('h6')).toBeNull();
|
|
136
|
+
expect(container.querySelector('p')?.textContent).toBe('보조 설명');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('제목이 필요하면 호출부가 말할 수 있다', () => {
|
|
140
|
+
const { container } = withProvider(<Subtitle as="h2">절 제목</Subtitle>);
|
|
141
|
+
|
|
142
|
+
expect(container.querySelector('h2')?.textContent).toBe('절 제목');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('마크다운의 ###### 은 여전히 제목이다', () => {
|
|
146
|
+
// `Subtitle` 의 기본 태그를 문단으로 바꾸면서 같이 끌려간 자리. 마크다운
|
|
147
|
+
// 매퍼의 `h6` 는 **글쓴이가 실제로 쓴 제목**이므로, 여기까지 문단이 되면
|
|
148
|
+
// 고치려던 결함이 방향만 바꿔 다시 생긴다 — 제목 목록에서 사라지는 쪽으로.
|
|
149
|
+
const H6 = baseMarkdownComponents.h6 as React.ComponentType<{
|
|
150
|
+
children: React.ReactNode;
|
|
151
|
+
}>;
|
|
152
|
+
const { container } = withProvider(<H6>여섯 번째 제목</H6>);
|
|
153
|
+
|
|
154
|
+
expect(container.querySelector('h6')?.textContent).toBe('여섯 번째 제목');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('KWCAG 5.4.2.1 웹 애플리케이션 접근성 준수', () => {
|
|
159
|
+
it('파일 입력이 버튼 안에 컨트롤을 넣지 않는다', () => {
|
|
160
|
+
const { container } = withProvider(<FileInput onChange={() => {}} />);
|
|
161
|
+
|
|
162
|
+
// `role="button"` on the wrapper made the real `<input>` an interactive
|
|
163
|
+
// element inside another (axe `nested-interactive`), and `role="group"` on a
|
|
164
|
+
// `<label>` is not an allowed pairing at all (`aria-allowed-role`).
|
|
165
|
+
expect(container.querySelector('[role="button"]')).toBeNull();
|
|
166
|
+
expect(container.querySelector('label[role]')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('호버 시 드러나는 오버레이는 group 클래스로 동작한다', () => {
|
|
170
|
+
const { container } = withProvider(<FileInput onChange={() => {}} />);
|
|
171
|
+
|
|
172
|
+
// `_groupHover` compiles to `.group:hover &`, so the CLASS is what makes it
|
|
173
|
+
// fire — the old `role="group"` did nothing for hover or for assistive tech.
|
|
174
|
+
expect(container.querySelector('label')?.className).toContain('group');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An axe pass over the components above, so the NEXT defect of this shape does
|
|
180
|
+
* not need an audit downstream to find it.
|
|
181
|
+
*
|
|
182
|
+
* Everything in this file until now is a regression test: each one names a
|
|
183
|
+
* defect that already shipped. This runs the same engine the certification
|
|
184
|
+
* audit runs, in the design system, on the compositions most likely to be
|
|
185
|
+
* wrong — so a component that acquires a nameless control, a role that is not
|
|
186
|
+
* allowed on its element, or an interactive element inside another fails here
|
|
187
|
+
* rather than in an app that consumes it.
|
|
188
|
+
*
|
|
189
|
+
* **jsdom has no layout**, so this is deliberately not the whole rule set:
|
|
190
|
+
* `color-contrast` needs painted pixels and reports nothing here, and rules
|
|
191
|
+
* about visibility or size have the same problem. What jsdom does carry
|
|
192
|
+
* perfectly is structure and ARIA — which is precisely where all nine defects
|
|
193
|
+
* in this branch lived. The contrast half belongs to a browser, and FactChat's
|
|
194
|
+
* `storybook-a11y.yml` runs it there in both colour modes.
|
|
195
|
+
*/
|
|
196
|
+
const STRUCTURAL_AXE_RULES = [
|
|
197
|
+
'aria-allowed-attr',
|
|
198
|
+
'aria-allowed-role',
|
|
199
|
+
'aria-required-attr',
|
|
200
|
+
'aria-required-children',
|
|
201
|
+
'aria-required-parent',
|
|
202
|
+
'aria-valid-attr-value',
|
|
203
|
+
'button-name',
|
|
204
|
+
'duplicate-id',
|
|
205
|
+
'label',
|
|
206
|
+
'link-name',
|
|
207
|
+
'nested-interactive',
|
|
208
|
+
'aria-input-field-name',
|
|
209
|
+
'aria-toggle-field-name',
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/** Runs axe over a container and returns one line per violating node. */
|
|
213
|
+
const axeViolations = async (container: HTMLElement): Promise<string[]> => {
|
|
214
|
+
const results = await axe.run(container, {
|
|
215
|
+
runOnly: { type: 'rule', values: STRUCTURAL_AXE_RULES },
|
|
216
|
+
// The default reporter walks the whole document for a summary this does
|
|
217
|
+
// not use, and jsdom makes that slow for no gain.
|
|
218
|
+
resultTypes: ['violations'],
|
|
219
|
+
});
|
|
220
|
+
return results.violations.flatMap((v) =>
|
|
221
|
+
v.nodes.map((n) => `${v.id}: ${n.html.replace(/\s+/g, ' ').slice(0, 120)}`)
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
describe('KWCAG 5.1.3.3 텍스트 콘텐츠의 명도 대비', () => {
|
|
226
|
+
it('링크 색은 테마에 따라 갈린다', () => {
|
|
227
|
+
// `.main` is tuned against a white page: on the dark canvas `primary.main`
|
|
228
|
+
// is 4.19:1, under the 4.5:1 body text needs. Every link in a consuming app
|
|
229
|
+
// inherits this default, so it is the single highest-reach instance of that
|
|
230
|
+
// defect in the package.
|
|
231
|
+
const { container } = withProvider(<Link href="#">링크</Link>);
|
|
232
|
+
const cls = container.querySelector('a')?.className ?? '';
|
|
233
|
+
|
|
234
|
+
// Emotion compiles the conditional into a class; a flat string value would
|
|
235
|
+
// not produce a `_dark` variant at all.
|
|
236
|
+
expect(cls).not.toBe('');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('호출부가 준 색을 버리지 않는다', () => {
|
|
240
|
+
// The old implementation narrowed with `typeof color === 'string'` and
|
|
241
|
+
// dropped anything else — so a per-theme `{ base, _dark }`, which is the
|
|
242
|
+
// documented way to fix a contrast problem, was silently replaced by the
|
|
243
|
+
// default. Accepted by the type, discarded at runtime, no error either way.
|
|
244
|
+
const custom = { base: 'danger.main', _dark: 'danger.dark' };
|
|
245
|
+
const { container } = withProvider(
|
|
246
|
+
<Link href="#" color={custom}>
|
|
247
|
+
링크
|
|
248
|
+
</Link>
|
|
249
|
+
);
|
|
250
|
+
const withCustom = container.querySelector('a')?.className ?? '';
|
|
251
|
+
|
|
252
|
+
const { container: plain } = withProvider(<Link href="#">링크</Link>);
|
|
253
|
+
const withDefault = plain.querySelector('a')?.className ?? '';
|
|
254
|
+
|
|
255
|
+
expect(withCustom).not.toBe(withDefault);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('axe — 구조·ARIA 규칙', () => {
|
|
260
|
+
it.each([
|
|
261
|
+
[
|
|
262
|
+
'이름이 있는 셀렉트',
|
|
263
|
+
<SelectField
|
|
264
|
+
key="select"
|
|
265
|
+
options={[{ label: 'One', value: '1' }]}
|
|
266
|
+
ariaLabel="분류 선택"
|
|
267
|
+
/>,
|
|
268
|
+
],
|
|
269
|
+
[
|
|
270
|
+
'이름이 있는 슬라이더',
|
|
271
|
+
<Slider key="slider" ariaLabel="탐색" value={[10]} min={0} max={100}>
|
|
272
|
+
<SliderTrack>
|
|
273
|
+
<SliderFilledTrack />
|
|
274
|
+
</SliderTrack>
|
|
275
|
+
<SliderThumb />
|
|
276
|
+
</Slider>,
|
|
277
|
+
],
|
|
278
|
+
[
|
|
279
|
+
'라디오 그룹',
|
|
280
|
+
<RadioGroup key="radio" defaultValue="a">
|
|
281
|
+
<Radio value="a">
|
|
282
|
+
<Radio.Control />
|
|
283
|
+
<Radio.Text>A</Radio.Text>
|
|
284
|
+
</Radio>
|
|
285
|
+
<Radio value="b">
|
|
286
|
+
<Radio.Control />
|
|
287
|
+
<Radio.Text>B</Radio.Text>
|
|
288
|
+
</Radio>
|
|
289
|
+
</RadioGroup>,
|
|
290
|
+
],
|
|
291
|
+
['파일 입력', <FileInput key="file" onChange={() => {}} />],
|
|
292
|
+
])('%s', async (_name, ui) => {
|
|
293
|
+
const { container } = withProvider(ui);
|
|
294
|
+
expect(await axeViolations(container)).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The link ramp's HOVER states, measured rather than rendered.
|
|
300
|
+
*
|
|
301
|
+
* The resting colour has a test above; the hover did not, and that is exactly
|
|
302
|
+
* where the defect got in. `defaultHoverColor` was written as "one step darker"
|
|
303
|
+
* — correct against a white page, and backwards on a dark one, where darker
|
|
304
|
+
* means *toward* the background. The dark hover landed on 4.19:1 while the
|
|
305
|
+
* resting colour it replaced had just been fixed to clear 4.5:1.
|
|
306
|
+
*
|
|
307
|
+
* No scanner catches this: axe measures the resting state only, and jsdom
|
|
308
|
+
* cannot resolve an emotion conditional into a colour. So this asserts the
|
|
309
|
+
* palette arithmetic directly, against the raw scale — which also holds if
|
|
310
|
+
* someone re-pegs `blue.100` or `rose.100` later for an unrelated reason.
|
|
311
|
+
*
|
|
312
|
+
* KWCAG 2.2 5.3.3 텍스트 콘텐츠의 명도 대비 applies to text in every state a
|
|
313
|
+
* user can put it in, and hover is a state a mouse user is IN while reading.
|
|
314
|
+
*/
|
|
315
|
+
describe('Link 색 대비 — 링크 램프의 hover 상태', () => {
|
|
316
|
+
const relativeLuminance = (hex: string) => {
|
|
317
|
+
const n = hex.replace('#', '');
|
|
318
|
+
const channels = [0, 2, 4].map(
|
|
319
|
+
(i) => parseInt(n.slice(i, i + 2), 16) / 255
|
|
320
|
+
);
|
|
321
|
+
const [r, g, b] = channels.map((c) =>
|
|
322
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
323
|
+
);
|
|
324
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
325
|
+
};
|
|
326
|
+
const contrast = (a: string, b: string) => {
|
|
327
|
+
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
|
|
328
|
+
(x, y) => y - x
|
|
329
|
+
);
|
|
330
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** The canvas each mode's text is read against (`bg.surface`). */
|
|
334
|
+
const LIGHT_CANVAS = '#FFFFFF';
|
|
335
|
+
const DARK_CANVAS = '#181A20';
|
|
336
|
+
const AA_BODY_TEXT = 4.5;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Semantic token → raw hex, per mode. Only the steps this ramp uses; kept
|
|
340
|
+
* literal so the test breaks loudly if the semantic layer is re-pointed
|
|
341
|
+
* rather than silently measuring the wrong colour.
|
|
342
|
+
*/
|
|
343
|
+
const RESOLVED: Record<string, { base: string; _dark: string }> = {
|
|
344
|
+
'primary.main': {
|
|
345
|
+
base: colors.blue[500].value,
|
|
346
|
+
_dark: colors.blue[300].value,
|
|
347
|
+
},
|
|
348
|
+
'primary.dark': {
|
|
349
|
+
base: colors.blue[700].value,
|
|
350
|
+
_dark: colors.blue[200].value,
|
|
351
|
+
},
|
|
352
|
+
'primary.darker': {
|
|
353
|
+
base: colors.blue[900].value,
|
|
354
|
+
_dark: colors.blue[100].value,
|
|
355
|
+
},
|
|
356
|
+
'danger.main': {
|
|
357
|
+
base: colors.rose[500].value,
|
|
358
|
+
_dark: colors.rose[300].value,
|
|
359
|
+
},
|
|
360
|
+
'danger.dark': {
|
|
361
|
+
base: colors.rose[700].value,
|
|
362
|
+
_dark: colors.rose[200].value,
|
|
363
|
+
},
|
|
364
|
+
'danger.darker': {
|
|
365
|
+
base: colors.rose[900].value,
|
|
366
|
+
_dark: colors.rose[100].value,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const resolve = (token: string, mode: 'base' | '_dark') => {
|
|
371
|
+
const entry = RESOLVED[token];
|
|
372
|
+
if (!entry) throw new Error(`토큰 ${token} 의 원색이 이 표에 없다`);
|
|
373
|
+
return entry[mode];
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
it.each([
|
|
377
|
+
['기본 링크', LINK_RAMP.default],
|
|
378
|
+
['기본 링크 hover', LINK_RAMP.defaultHover],
|
|
379
|
+
['오류 링크', LINK_RAMP.error],
|
|
380
|
+
['오류 링크 hover', LINK_RAMP.errorHover],
|
|
381
|
+
])('%s 가 라이트·다크 양쪽에서 4.5:1 을 넘는다', (_what, ramp) => {
|
|
382
|
+
// Reads the tokens the COMPONENT actually uses. A version of this test that
|
|
383
|
+
// hardcoded the expected steps passed just fine with the hover pointing at
|
|
384
|
+
// `primary.main` — measuring the right arithmetic about the wrong colour.
|
|
385
|
+
expect(
|
|
386
|
+
contrast(resolve(ramp.base, 'base'), LIGHT_CANVAS)
|
|
387
|
+
).toBeGreaterThanOrEqual(AA_BODY_TEXT);
|
|
388
|
+
expect(
|
|
389
|
+
contrast(resolve(ramp._dark, '_dark'), DARK_CANVAS)
|
|
390
|
+
).toBeGreaterThanOrEqual(AA_BODY_TEXT);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('hover 가 배경 반대쪽으로 간다 — 양쪽 모드에서', () => {
|
|
394
|
+
// The invariant the defect broke: hovering must make the link stand out
|
|
395
|
+
// MORE. "One step darker" is correct on a white page and backwards on a
|
|
396
|
+
// dark one, where darker means toward the background.
|
|
397
|
+
for (const [mode, canvas] of [
|
|
398
|
+
['base', LIGHT_CANVAS],
|
|
399
|
+
['_dark', DARK_CANVAS],
|
|
400
|
+
] as const) {
|
|
401
|
+
const resting = contrast(resolve(LINK_RAMP.default[mode], mode), canvas);
|
|
402
|
+
const hover = contrast(
|
|
403
|
+
resolve(LINK_RAMP.defaultHover[mode], mode),
|
|
404
|
+
canvas
|
|
405
|
+
);
|
|
406
|
+
expect(
|
|
407
|
+
hover,
|
|
408
|
+
`${mode} 에서 hover 가 resting 보다 흐리다`
|
|
409
|
+
).toBeGreaterThan(resting);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|