@godxjp/ui 27.10.0 → 27.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/data-display/card.d.ts +46 -4
- package/dist/components/data-display/card.js +17 -6
- package/dist/components/ui/toggle-group.d.ts +17 -0
- package/dist/components/ui/toggle-group.js +2 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/props/registry.d.ts +10 -1
- package/dist/props/registry.js +19 -1
- package/dist/styles/card-layout.css +4 -0
- package/dist/styles/control.css +5 -0
- package/dist/styles/focus-ring.css +5 -0
- package/docs/data-display/card/index.tsx +41 -0
- package/docs/data-entry/toggle-group.tsx +83 -0
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +134 -8
|
@@ -40,10 +40,31 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
|
40
40
|
*
|
|
41
41
|
* This is a PRESENTATION flag, not an interaction: it announces nothing and binds no handler.
|
|
42
42
|
* A card that looks clickable has to BE clickable for everyone, so pair it with a real control
|
|
43
|
-
* — a `Link`/`Button` in the header or footer, or the whole card rendered as one
|
|
44
|
-
* bare `onClick` on this div, which a keyboard or
|
|
43
|
+
* — a `Link`/`Button` in the header or footer, or the whole card rendered as one via
|
|
44
|
+
* {@link CardProps.asChild} — never with a bare `onClick` on this div, which a keyboard or
|
|
45
|
+
* screen-reader user cannot reach.
|
|
45
46
|
*/
|
|
46
47
|
hoverable?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Borrow the child's element for the card box instead of rendering a `div` — the package's
|
|
50
|
+
* standard Slot passthrough (`Button`, `AspectRatio`, `ListRow`). The card's chrome and every
|
|
51
|
+
* `data-*` flag move onto that element, so a card rendered as an `<a>` or a `<button>` measures
|
|
52
|
+
* byte for byte like the `div` it replaces and stays a SINGLE tab stop.
|
|
53
|
+
*
|
|
54
|
+
* This is the other half of `hoverable`: `<Card asChild hoverable><a href="…">` is "the whole
|
|
55
|
+
* card rendered as one control", reachable by keyboard and announced as one link.
|
|
56
|
+
*
|
|
57
|
+
* NESTING CAVEAT — the card is now ONE control, so it may not contain another. An
|
|
58
|
+
* `<a>`/`<button>` (a `Link`, a `Button`, a menu trigger) inside a card-as-link or
|
|
59
|
+
* card-as-button is invalid HTML: the inner control is unreachable or mis-announced, and
|
|
60
|
+
* `tabList` — a strip of `button` triggers — is for the same reason not drawn under `asChild`.
|
|
61
|
+
* A card that needs interactive children is not one control: drop `asChild` and put the
|
|
62
|
+
* `Link`/`Button` inside it instead, as `hoverable` also allows.
|
|
63
|
+
*
|
|
64
|
+
* Exactly one child, as everywhere else in this package: two children have no element to
|
|
65
|
+
* borrow, so `Slot`'s `React.Children.only` throws — the same error `Button asChild` throws.
|
|
66
|
+
*/
|
|
67
|
+
asChild?: boolean;
|
|
47
68
|
/**
|
|
48
69
|
* Ant Design `tabList` — the tab strip that lives IN THE CARD'S HEAD, under the title, inside
|
|
49
70
|
* the same border and on the same surface, so the card and its tabs read as one object. The
|
|
@@ -102,10 +123,31 @@ export declare const Card: React.ForwardRefExoticComponent<React.HTMLAttributes<
|
|
|
102
123
|
*
|
|
103
124
|
* This is a PRESENTATION flag, not an interaction: it announces nothing and binds no handler.
|
|
104
125
|
* A card that looks clickable has to BE clickable for everyone, so pair it with a real control
|
|
105
|
-
* — a `Link`/`Button` in the header or footer, or the whole card rendered as one
|
|
106
|
-
* bare `onClick` on this div, which a keyboard or
|
|
126
|
+
* — a `Link`/`Button` in the header or footer, or the whole card rendered as one via
|
|
127
|
+
* {@link CardProps.asChild} — never with a bare `onClick` on this div, which a keyboard or
|
|
128
|
+
* screen-reader user cannot reach.
|
|
107
129
|
*/
|
|
108
130
|
hoverable?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Borrow the child's element for the card box instead of rendering a `div` — the package's
|
|
133
|
+
* standard Slot passthrough (`Button`, `AspectRatio`, `ListRow`). The card's chrome and every
|
|
134
|
+
* `data-*` flag move onto that element, so a card rendered as an `<a>` or a `<button>` measures
|
|
135
|
+
* byte for byte like the `div` it replaces and stays a SINGLE tab stop.
|
|
136
|
+
*
|
|
137
|
+
* This is the other half of `hoverable`: `<Card asChild hoverable><a href="…">` is "the whole
|
|
138
|
+
* card rendered as one control", reachable by keyboard and announced as one link.
|
|
139
|
+
*
|
|
140
|
+
* NESTING CAVEAT — the card is now ONE control, so it may not contain another. An
|
|
141
|
+
* `<a>`/`<button>` (a `Link`, a `Button`, a menu trigger) inside a card-as-link or
|
|
142
|
+
* card-as-button is invalid HTML: the inner control is unreachable or mis-announced, and
|
|
143
|
+
* `tabList` — a strip of `button` triggers — is for the same reason not drawn under `asChild`.
|
|
144
|
+
* A card that needs interactive children is not one control: drop `asChild` and put the
|
|
145
|
+
* `Link`/`Button` inside it instead, as `hoverable` also allows.
|
|
146
|
+
*
|
|
147
|
+
* Exactly one child, as everywhere else in this package: two children have no element to
|
|
148
|
+
* borrow, so `Slot`'s `React.Children.only` throws — the same error `Button asChild` throws.
|
|
149
|
+
*/
|
|
150
|
+
asChild?: boolean;
|
|
109
151
|
/**
|
|
110
152
|
* Ant Design `tabList` — the tab strip that lives IN THE CARD'S HEAD, under the title, inside
|
|
111
153
|
* the same border and on the same surface, so the card and its tabs read as one object. The
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { padStyle, padStepToken } from "../../lib/variants.js";
|
|
4
4
|
import * as React from "react";
|
|
5
|
+
import { isDevelopment } from "../../lib/dev.js";
|
|
6
|
+
import { Slot } from "../../lib/slot.js";
|
|
5
7
|
import { cn } from "../../lib/utils.js";
|
|
6
8
|
import { Tabs } from "../navigation/tabs.js";
|
|
7
9
|
function resolveActiveCardTabKey(tabList, requested) {
|
|
@@ -37,11 +39,20 @@ const Card = React.forwardRef(
|
|
|
37
39
|
onTabChange,
|
|
38
40
|
extra,
|
|
39
41
|
tabProps,
|
|
42
|
+
asChild = false,
|
|
40
43
|
children,
|
|
41
44
|
...props
|
|
42
45
|
}, ref) => {
|
|
46
|
+
const slotted = asChild && Boolean(tabList?.length);
|
|
47
|
+
if (isDevelopment() && slotted) {
|
|
48
|
+
console.warn(
|
|
49
|
+
"Card: `asChild` renders the whole card as ONE control, so `tabList` is ignored and the tab strip is NOT drawn \u2014 a strip of `button` triggers inside a link/button is invalid HTML. Drop `asChild` and put a `Link`/`Button` inside the card instead."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const tabs = slotted ? void 0 : tabList;
|
|
53
|
+
const Comp = asChild ? Slot : "div";
|
|
43
54
|
const [mirroredKey, setMirroredKey] = React.useState(void 0);
|
|
44
|
-
const activeKey =
|
|
55
|
+
const activeKey = tabs?.length ? resolveActiveCardTabKey(tabs, activeTabKey ?? mirroredKey ?? defaultActiveTabKey) : void 0;
|
|
45
56
|
const handleTabChange = React.useCallback(
|
|
46
57
|
(key) => {
|
|
47
58
|
setMirroredKey(key);
|
|
@@ -49,9 +60,9 @@ const Card = React.forwardRef(
|
|
|
49
60
|
},
|
|
50
61
|
[onTabChange]
|
|
51
62
|
);
|
|
52
|
-
const { head, body, foot } =
|
|
63
|
+
const { head, body, foot } = tabs?.length ? splitCardBands(children) : { head: void 0, body: void 0, foot: void 0 };
|
|
53
64
|
return /* @__PURE__ */ jsx(
|
|
54
|
-
|
|
65
|
+
Comp,
|
|
55
66
|
{
|
|
56
67
|
ref,
|
|
57
68
|
className: cn("group/card", className),
|
|
@@ -61,16 +72,16 @@ const Card = React.forwardRef(
|
|
|
61
72
|
"data-accent-placement": accentPlacement === "perimeter" ? "perimeter" : void 0,
|
|
62
73
|
"data-variant": variant && variant !== "default" ? variant : void 0,
|
|
63
74
|
"data-density": density,
|
|
64
|
-
"data-tab-list":
|
|
75
|
+
"data-tab-list": tabs?.length ? "" : void 0,
|
|
65
76
|
...props,
|
|
66
|
-
children:
|
|
77
|
+
children: tabs?.length ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
67
78
|
head,
|
|
68
79
|
/* @__PURE__ */ jsx(
|
|
69
80
|
Tabs,
|
|
70
81
|
{
|
|
71
82
|
variant: "line",
|
|
72
83
|
...tabProps,
|
|
73
|
-
items:
|
|
84
|
+
items: tabs.map((item) => ({
|
|
74
85
|
value: item.key,
|
|
75
86
|
label: item.tab,
|
|
76
87
|
disabled: item.disabled,
|
|
@@ -11,6 +11,23 @@ type ToggleGroupBaseProp = Omit<React.ComponentPropsWithoutRef<"div">, "defaultV
|
|
|
11
11
|
disabled?: boolean;
|
|
12
12
|
/** Which way the arrow keys walk the group. */
|
|
13
13
|
orientation?: "horizontal" | "vertical";
|
|
14
|
+
/**
|
|
15
|
+
* Let the row break onto further lines instead of running past its rail (gh#741). Same name,
|
|
16
|
+
* same boolean shape and same `data-wrap` attribute as `Flex` — one spelling answers "what does
|
|
17
|
+
* this row do when it does not fit" across the library.
|
|
18
|
+
*
|
|
19
|
+
* OPT-IN (`false`), and deliberately the SAME default for every `variant`. `Flex`'s `wrap` is
|
|
20
|
+
* `false`, and one word must not mean two defaults. Measured in Chromium at a 320px rail:
|
|
21
|
+
* forcing `flex-wrap: wrap` on all 18 groups of the docs page left the 17 that FIT untouched
|
|
22
|
+
* (same line count, same height, at a 320px and a 1358px rail) — including every `soft` one, so
|
|
23
|
+
* paint is not the axis that decides. The only row it moves is the one that overflows, from
|
|
24
|
+
* 171.5px to 108px: a default of `true` reflows exactly those rows, silently, on an upgrade
|
|
25
|
+
* whose call sites did not change.
|
|
26
|
+
*
|
|
27
|
+
* Wrapping keeps the group's single `gap`, so the two axes stay equal (4px × 4px measured);
|
|
28
|
+
* `flex-wrap` reorders nothing, so arrow keys still walk the items in DOM order across lines.
|
|
29
|
+
*/
|
|
30
|
+
wrap?: boolean;
|
|
14
31
|
/**
|
|
15
32
|
* Writing direction. It still lands on the DOM node, but React Aria reads the direction it
|
|
16
33
|
* navigates by from the ambient locale (`I18nProvider`), NOT from this attribute — where Radix
|
|
@@ -32,6 +32,7 @@ const ToggleGroup = React.forwardRef(
|
|
|
32
32
|
variant,
|
|
33
33
|
size,
|
|
34
34
|
shape,
|
|
35
|
+
wrap,
|
|
35
36
|
children,
|
|
36
37
|
type,
|
|
37
38
|
value,
|
|
@@ -60,6 +61,7 @@ const ToggleGroup = React.forwardRef(
|
|
|
60
61
|
"data-variant": variant,
|
|
61
62
|
"data-size": size,
|
|
62
63
|
"data-shape": shape,
|
|
64
|
+
"data-wrap": wrap ? "true" : void 0,
|
|
63
65
|
className: cn("ui-toggle-group", className),
|
|
64
66
|
...props,
|
|
65
67
|
selectionMode: type,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.12.0",
|
|
4
4
|
"targetSize": {
|
|
5
5
|
"standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
|
|
6
6
|
"min": 24,
|
package/dist/props/registry.d.ts
CHANGED
|
@@ -2464,6 +2464,15 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
2464
2464
|
readonly reason: "Localized unit folded into the accessible name so a counted icon/emoji chip never announces as a bare number.";
|
|
2465
2465
|
}];
|
|
2466
2466
|
};
|
|
2467
|
+
readonly ToggleGroupProp: {
|
|
2468
|
+
readonly group: "data-entry";
|
|
2469
|
+
readonly file: "components/ui/toggle-group.tsx";
|
|
2470
|
+
readonly vocabulary: readonly ["ValueProp", "DefaultValueProp", "OnValueChangeProp", "DisabledProp", "SizeProp", "ShapeProp", "ClassNameProp", {
|
|
2471
|
+
readonly field: "wrap";
|
|
2472
|
+
readonly local: true;
|
|
2473
|
+
readonly reason: "The SAME boolean shorthand for flex-wrap that FlexProp carries, on the row that owns a chip set — one spelling for 'what does this row do when it does not fit' (gh#741). Opt-in, and the same default for every variant: paint must not decide geometry.";
|
|
2474
|
+
}];
|
|
2475
|
+
};
|
|
2467
2476
|
readonly ToggleGroupItemProp: {
|
|
2468
2477
|
readonly group: "data-entry";
|
|
2469
2478
|
readonly file: "components/ui/toggle-group.tsx";
|
|
@@ -2842,7 +2851,7 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
2842
2851
|
readonly CardProp: {
|
|
2843
2852
|
readonly group: "data-display";
|
|
2844
2853
|
readonly file: "components/data-display/card.tsx";
|
|
2845
|
-
readonly vocabulary: readonly ["ToneProp", "ClassNameProp", "ChildrenProp"];
|
|
2854
|
+
readonly vocabulary: readonly ["ToneProp", "ClassNameProp", "ChildrenProp", "AsChildProp"];
|
|
2846
2855
|
};
|
|
2847
2856
|
readonly CardTabItemProp: {
|
|
2848
2857
|
readonly group: "data-display";
|
package/dist/props/registry.js
CHANGED
|
@@ -2863,6 +2863,24 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
2863
2863
|
}
|
|
2864
2864
|
]
|
|
2865
2865
|
},
|
|
2866
|
+
ToggleGroupProp: {
|
|
2867
|
+
group: "data-entry",
|
|
2868
|
+
file: "components/ui/toggle-group.tsx",
|
|
2869
|
+
vocabulary: [
|
|
2870
|
+
"ValueProp",
|
|
2871
|
+
"DefaultValueProp",
|
|
2872
|
+
"OnValueChangeProp",
|
|
2873
|
+
"DisabledProp",
|
|
2874
|
+
"SizeProp",
|
|
2875
|
+
"ShapeProp",
|
|
2876
|
+
"ClassNameProp",
|
|
2877
|
+
{
|
|
2878
|
+
field: "wrap",
|
|
2879
|
+
local: true,
|
|
2880
|
+
reason: "The SAME boolean shorthand for flex-wrap that FlexProp carries, on the row that owns a chip set \u2014 one spelling for 'what does this row do when it does not fit' (gh#741). Opt-in, and the same default for every variant: paint must not decide geometry."
|
|
2881
|
+
}
|
|
2882
|
+
]
|
|
2883
|
+
},
|
|
2866
2884
|
ToggleGroupItemProp: {
|
|
2867
2885
|
group: "data-entry",
|
|
2868
2886
|
file: "components/ui/toggle-group.tsx",
|
|
@@ -3348,7 +3366,7 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
3348
3366
|
CardProp: {
|
|
3349
3367
|
group: "data-display",
|
|
3350
3368
|
file: "components/data-display/card.tsx",
|
|
3351
|
-
vocabulary: ["ToneProp", "ClassNameProp", "ChildrenProp"]
|
|
3369
|
+
vocabulary: ["ToneProp", "ClassNameProp", "ChildrenProp", "AsChildProp"]
|
|
3352
3370
|
},
|
|
3353
3371
|
CardTabItemProp: {
|
|
3354
3372
|
group: "data-display",
|
package/dist/styles/control.css
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
.ui-carousel-dot,
|
|
36
36
|
.ui-rating-star,
|
|
37
37
|
a.ui-list-row,
|
|
38
|
+
|
|
39
|
+
a[data-slot="card"],
|
|
40
|
+
button[data-slot="card"],
|
|
38
41
|
.ui-text[data-link],
|
|
39
42
|
.sb-nav-item,
|
|
40
43
|
.sb-user,
|
|
@@ -96,6 +99,8 @@
|
|
|
96
99
|
.ui-carousel-dot,
|
|
97
100
|
.ui-rating-star,
|
|
98
101
|
a.ui-list-row,
|
|
102
|
+
a[data-slot="card"],
|
|
103
|
+
button[data-slot="card"],
|
|
99
104
|
.ui-text[data-link],
|
|
100
105
|
.sb-nav-item,
|
|
101
106
|
.sb-user,
|
|
@@ -160,6 +160,47 @@ export default function Demo() {
|
|
|
160
160
|
</ResponsiveGrid>
|
|
161
161
|
</Flex>
|
|
162
162
|
|
|
163
|
+
<Flex direction="col" gap="sm">
|
|
164
|
+
<Text as="div" weight="medium">
|
|
165
|
+
asChild · カード全体をひとつのコントロールにする
|
|
166
|
+
</Text>
|
|
167
|
+
<ResponsiveGrid columns={{ sm: 1, md: 2 }}>
|
|
168
|
+
{/*
|
|
169
|
+
* `hoverable` が約束する「カード全体をひとつのコントロールとして描く」側。
|
|
170
|
+
* `asChild` はカードの箱を子の要素に譲るので、リンクそのものがカードになり、
|
|
171
|
+
* Tab は一度しか止まらず、スクリーンリーダーはリンクひとつとして読み上げます。
|
|
172
|
+
* 入れ子の禁則:これはもうコントロールなので、中に <a>/<Button> は置けません
|
|
173
|
+
* (HTML として不正)。中にボタンが要るカードは asChild を外し、hoverable +
|
|
174
|
+
* CardFooter のボタン(上の例)にします。
|
|
175
|
+
*/}
|
|
176
|
+
<Card asChild hoverable>
|
|
177
|
+
<a href="#card-as-child">
|
|
178
|
+
<CardHeader>
|
|
179
|
+
<CardTitle level={2}>カード全体がリンク</CardTitle>
|
|
180
|
+
<CardDescription>
|
|
181
|
+
タブ 1 回で届き、フォーカスリングはカードの箱に出ます
|
|
182
|
+
</CardDescription>
|
|
183
|
+
</CardHeader>
|
|
184
|
+
<CardContent>
|
|
185
|
+
詳細画面へ遷移します。onClick しか無い場合もこの形(router の Link
|
|
186
|
+
コンポーネントを子に渡す)にします。
|
|
187
|
+
</CardContent>
|
|
188
|
+
</a>
|
|
189
|
+
</Card>
|
|
190
|
+
<Card hoverable>
|
|
191
|
+
<CardHeader>
|
|
192
|
+
<CardTitle level={2}>中にコントロールがある場合</CardTitle>
|
|
193
|
+
<CardDescription>
|
|
194
|
+
asChild は使わず、本物のコントロールを内側に置きます
|
|
195
|
+
</CardDescription>
|
|
196
|
+
</CardHeader>
|
|
197
|
+
<CardFooter separated>
|
|
198
|
+
<Button variant="outline">詳細</Button>
|
|
199
|
+
</CardFooter>
|
|
200
|
+
</Card>
|
|
201
|
+
</ResponsiveGrid>
|
|
202
|
+
</Flex>
|
|
203
|
+
|
|
163
204
|
<Flex direction="col" gap="sm">
|
|
164
205
|
<Text as="div" weight="medium">
|
|
165
206
|
Accent · accentPlacement="edge" (既定 · 先頭エッジのレール)
|
|
@@ -5,6 +5,22 @@ import { FormField, ToggleGroup, ToggleGroupItem } from "@godxjp/ui/data-entry";
|
|
|
5
5
|
import { Text } from "@godxjp/ui/general";
|
|
6
6
|
import { Flex, PageContainer } from "@godxjp/ui/layout";
|
|
7
7
|
|
|
8
|
+
/** A folder's tag facets — more chips than any rail is wide, which is the `wrap` case (gh#741). */
|
|
9
|
+
const folderTags = [
|
|
10
|
+
{ value: "design", label: "設計", count: 12 },
|
|
11
|
+
{ value: "runbook", label: "運用手順", count: 148 },
|
|
12
|
+
{ value: "security", label: "セキュリティ", count: 37 },
|
|
13
|
+
{ value: "onboarding", label: "オンボーディング", count: 9 },
|
|
14
|
+
{ value: "incident", label: "障害報告", count: 64 },
|
|
15
|
+
{ value: "contract", label: "契約", count: 21 },
|
|
16
|
+
{ value: "billing", label: "請求", count: 83 },
|
|
17
|
+
{ value: "release", label: "リリース", count: 156 },
|
|
18
|
+
{ value: "meeting", label: "議事録", count: 402 },
|
|
19
|
+
{ value: "hr", label: "人事", count: 18 },
|
|
20
|
+
{ value: "legal", label: "法務", count: 6 },
|
|
21
|
+
{ value: "archive", label: "アーカイブ", count: 311 },
|
|
22
|
+
];
|
|
23
|
+
|
|
8
24
|
/**
|
|
9
25
|
* ToggleGroup — single or multi-select toggle set. type='single' for mutually
|
|
10
26
|
* exclusive modes; type='multiple' for independent selections. Never raw radio
|
|
@@ -14,6 +30,7 @@ export default function Demo() {
|
|
|
14
30
|
const [period, setPeriod] = useState<string>("monthly");
|
|
15
31
|
const [formats, setFormats] = useState<string[]>(["pdf"]);
|
|
16
32
|
const [view, setView] = useState<string>("list");
|
|
33
|
+
const [tags, setTags] = useState<string[]>(["design", "runbook"]);
|
|
17
34
|
|
|
18
35
|
return (
|
|
19
36
|
<PageContainer
|
|
@@ -165,6 +182,72 @@ export default function Demo() {
|
|
|
165
182
|
</CardContent>
|
|
166
183
|
</Card>
|
|
167
184
|
|
|
185
|
+
<Card>
|
|
186
|
+
<CardHeader>
|
|
187
|
+
<CardTitle level={2}>wrap · 折り返すタグ絞り込みの行</CardTitle>
|
|
188
|
+
<CardDescription>
|
|
189
|
+
wrap
|
|
190
|
+
を付けた行は、レールに収まらなくなった時点で次の行へ折り返す(gh#741)。名前も真偽値も
|
|
191
|
+
data-wrap 属性も Flex と同じ。既定は false で、variant
|
|
192
|
+
によって既定を変えることはしない。320px のレールで測ると 4 項目のセグメントは wrap
|
|
193
|
+
の有無に関わらず 1 行 32px
|
|
194
|
+
のままで、変わるのは元から溢れていた行だけだからである。折り返しても gap は 1
|
|
195
|
+
つなので、行間と列間は同じ 4px になる。
|
|
196
|
+
</CardDescription>
|
|
197
|
+
</CardHeader>
|
|
198
|
+
<CardContent>
|
|
199
|
+
<Flex direction="col" gap="md">
|
|
200
|
+
<Flex direction="col" gap="sm">
|
|
201
|
+
<Text as="p" size="sm" tone="muted">
|
|
202
|
+
wrap · variant="soft" shape="pill" size="xs" ·
|
|
203
|
+
タグが 12 個あっても ToggleGroup は 1 つのまま(value / onValueChange も 1
|
|
204
|
+
つ、矢印キーは折り返した行をまたいで DOM 順に進む)
|
|
205
|
+
</Text>
|
|
206
|
+
<ToggleGroup
|
|
207
|
+
type="multiple"
|
|
208
|
+
variant="soft"
|
|
209
|
+
shape="pill"
|
|
210
|
+
size="xs"
|
|
211
|
+
wrap
|
|
212
|
+
value={tags}
|
|
213
|
+
onValueChange={(v) => setTags(v as string[])}
|
|
214
|
+
aria-label="タグで絞り込み"
|
|
215
|
+
>
|
|
216
|
+
{folderTags.map((tag) => (
|
|
217
|
+
<ToggleGroupItem
|
|
218
|
+
key={tag.value}
|
|
219
|
+
value={tag.value}
|
|
220
|
+
count={tag.count}
|
|
221
|
+
countLabel="ページ"
|
|
222
|
+
>
|
|
223
|
+
{tag.label}
|
|
224
|
+
</ToggleGroupItem>
|
|
225
|
+
))}
|
|
226
|
+
</ToggleGroup>
|
|
227
|
+
<Text as="p" size="sm" tone="muted">
|
|
228
|
+
選択中: {tags.length === 0 ? "なし" : tags.join(" / ")}
|
|
229
|
+
</Text>
|
|
230
|
+
</Flex>
|
|
231
|
+
<Flex direction="col" gap="sm">
|
|
232
|
+
<Text as="p" size="sm" tone="muted">
|
|
233
|
+
wrap なし(既定)· セグメントは 1 行 32px のまま、何も変わらない
|
|
234
|
+
</Text>
|
|
235
|
+
<ToggleGroup
|
|
236
|
+
type="single"
|
|
237
|
+
variant="outline"
|
|
238
|
+
defaultValue="daily"
|
|
239
|
+
aria-label="集計単位"
|
|
240
|
+
>
|
|
241
|
+
<ToggleGroupItem value="daily">日次</ToggleGroupItem>
|
|
242
|
+
<ToggleGroupItem value="monthly">月次</ToggleGroupItem>
|
|
243
|
+
<ToggleGroupItem value="quarterly">四半期</ToggleGroupItem>
|
|
244
|
+
<ToggleGroupItem value="yearly">年次</ToggleGroupItem>
|
|
245
|
+
</ToggleGroup>
|
|
246
|
+
</Flex>
|
|
247
|
+
</Flex>
|
|
248
|
+
</CardContent>
|
|
249
|
+
</Card>
|
|
250
|
+
|
|
168
251
|
<Card>
|
|
169
252
|
<CardHeader>
|
|
170
253
|
<CardTitle level={2}>size · xs / sm / md / lg</CardTitle>
|
package/package.json
CHANGED
package/scripts/ui-audit.mjs
CHANGED
|
@@ -498,15 +498,12 @@ const RULES = [
|
|
|
498
498
|
id: "icon-button-needs-name",
|
|
499
499
|
severity: "warn",
|
|
500
500
|
spansElement: true,
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
"g",
|
|
506
|
-
),
|
|
507
|
-
standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2",
|
|
501
|
+
// A `<Button size="icon">` with no accessible name AT ALL — neither from the author
|
|
502
|
+
// (aria-label / aria-labelledby / title) nor from its CONTENT. See `iconButtonMatches`.
|
|
503
|
+
matches: iconButtonMatches,
|
|
504
|
+
standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2 · Accessible Name Computation 1.2",
|
|
508
505
|
message:
|
|
509
|
-
"Icon-only <Button size=\"icon\">
|
|
506
|
+
"Icon-only <Button size=\"icon\"> has no accessible name — neither aria-label={t('…')} nor any content that names it. A visually-hidden child names it too: <VisuallyHidden>{t('…')}</VisuallyHidden> (or className=\"sr-only\") beside the aria-hidden glyph. A glyph alone, or text inside an aria-hidden subtree, names nothing.",
|
|
510
507
|
},
|
|
511
508
|
{
|
|
512
509
|
id: "img-needs-alt",
|
|
@@ -1074,6 +1071,135 @@ function* lucideGlyphMatches(source) {
|
|
|
1074
1071
|
}
|
|
1075
1072
|
}
|
|
1076
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* Is this subtree hidden from the accessibility tree? `aria-hidden` with anything but a literal
|
|
1076
|
+
* `false` — an author who writes `aria-hidden={busy}` is saying the subtree MAY be hidden, and a
|
|
1077
|
+
* name that may not be there is not a name.
|
|
1078
|
+
*/
|
|
1079
|
+
const ariaHiddenTag = (openTag) =>
|
|
1080
|
+
!/\baria-hidden\s*=\s*(?:\{\s*false\s*\}|["']false["'])/.test(openTag) &&
|
|
1081
|
+
/\baria-hidden(?=[\s=/>])/.test(openTag);
|
|
1082
|
+
|
|
1083
|
+
/** The three attributes accname takes from the AUTHOR, on one opening tag. */
|
|
1084
|
+
const authoredNameTag = (openTag) => /\b(?:aria-label|aria-labelledby|title)\s*=/.test(openTag);
|
|
1085
|
+
|
|
1086
|
+
/** Where the element whose opening tag ended at `from` closes, counting nesting of the same tag. */
|
|
1087
|
+
function closingTagRange(source, tagName, from) {
|
|
1088
|
+
const tag = new RegExp(`<(/?)${tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=[\\s/>])`, "g");
|
|
1089
|
+
tag.lastIndex = from;
|
|
1090
|
+
let depth = 0;
|
|
1091
|
+
for (let m; (m = tag.exec(source));) {
|
|
1092
|
+
if (m[1]) {
|
|
1093
|
+
if (depth === 0) {
|
|
1094
|
+
const gt = source.indexOf(">", m.index);
|
|
1095
|
+
return { start: m.index, end: gt < 0 ? source.length : gt + 1 };
|
|
1096
|
+
}
|
|
1097
|
+
depth -= 1;
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const end = jsxOpeningEnd(source, m.index);
|
|
1101
|
+
if (!/\/\s*$/.test(source.slice(m.index, end))) depth += 1;
|
|
1102
|
+
tag.lastIndex = end + 1;
|
|
1103
|
+
}
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* "Name from content" (Accessible Name and Description Computation §2F) over JSX SOURCE: does
|
|
1109
|
+
* anything in these children reach the accessibility tree as text?
|
|
1110
|
+
*
|
|
1111
|
+
* - an `aria-hidden` subtree contributes NOTHING, however much text it holds;
|
|
1112
|
+
* - a visually-hidden child does — `sr-only` (and `VisuallyHidden`, which renders `span.sr-only`)
|
|
1113
|
+
* clips the box, it does not `display:none` the node, so the text is still named;
|
|
1114
|
+
* - a non-hidden child carrying its own `aria-label`/`title` contributes that name (`asChild`);
|
|
1115
|
+
* - an `{expression}` renders text, EXCEPT when it holds JSX — then what renders is those
|
|
1116
|
+
* elements, and `open ? <ChevronUp /> : <ChevronDown />` is still two unnamed glyphs.
|
|
1117
|
+
*
|
|
1118
|
+
* Limits, both deliberate: a bare `{icon}` identifier is read as text (source alone cannot say
|
|
1119
|
+
* what it holds, and guessing "glyph" would re-create the false positive this replaces), and
|
|
1120
|
+
* visual hiding is only recognised as `aria-hidden` — a `hidden`/`display:none` class is not read.
|
|
1121
|
+
*/
|
|
1122
|
+
function contributesName(source, textCounts = true) {
|
|
1123
|
+
let i = 0;
|
|
1124
|
+
while (i < source.length) {
|
|
1125
|
+
const char = source[i];
|
|
1126
|
+
if (char === "<") {
|
|
1127
|
+
if (source[i + 1] === "/") {
|
|
1128
|
+
const gt = source.indexOf(">", i);
|
|
1129
|
+
i = gt < 0 ? source.length : gt + 1;
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const tagName = /^<([A-Za-z][\w.:-]*)/.exec(source.slice(i))?.[1];
|
|
1133
|
+
if (!tagName) {
|
|
1134
|
+
i += 1;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const end = jsxOpeningEnd(source, i);
|
|
1138
|
+
const openTag = source.slice(i, end + 1);
|
|
1139
|
+
const hidden = ariaHiddenTag(openTag);
|
|
1140
|
+
if (!hidden && authoredNameTag(openTag)) return true;
|
|
1141
|
+
if (/\/\s*>$/.test(openTag)) {
|
|
1142
|
+
i = end + 1;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
const close = closingTagRange(source, tagName, end + 1);
|
|
1146
|
+
const childrenEnd = close ? close.start : source.length;
|
|
1147
|
+
if (!hidden && contributesName(source.slice(end + 1, childrenEnd))) return true;
|
|
1148
|
+
i = close ? close.end : source.length;
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (char === "{") {
|
|
1152
|
+
const close = matchBracket(source, i);
|
|
1153
|
+
const expr = source.slice(i + 1, close < 0 ? source.length : close);
|
|
1154
|
+
if (/<[A-Za-z]/.test(expr)) {
|
|
1155
|
+
if (contributesName(expr, false)) return true;
|
|
1156
|
+
} else if (
|
|
1157
|
+
/\S/.test(expr.replace(/(["'`])(?:\\.|(?!\1)[\s\S])*?\1/g, (s) => s.slice(1, -1)))
|
|
1158
|
+
) {
|
|
1159
|
+
// `{t("…")}` / `{label}` name it; prettier's `{" "}` does not.
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
i = close < 0 ? source.length : close + 1;
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
// Inside an expression the scaffolding (`cond ? … : …`, `.map(…)`) is not rendered text, but a
|
|
1166
|
+
// string literal in it is.
|
|
1167
|
+
if (!textCounts && (char === '"' || char === "'" || char === "`")) {
|
|
1168
|
+
const end = endOfString(source, i);
|
|
1169
|
+
if (/\S/.test(source.slice(i + 1, end))) return true;
|
|
1170
|
+
i = end + 1;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (textCounts && char.trim()) return true;
|
|
1174
|
+
i += 1;
|
|
1175
|
+
}
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* An icon-only `<Button size="icon">` that NOTHING names (gh#739).
|
|
1181
|
+
*
|
|
1182
|
+
* The rule was a single regex over the OPENING TAG, so the most standard naming of an icon button
|
|
1183
|
+
* — an `aria-hidden` glyph beside visually-hidden text — was reported as unnamed: 5/5 findings in
|
|
1184
|
+
* one consumer repo were that shape. Following the message literally adds an `aria-label` on TOP
|
|
1185
|
+
* of a name, and two name sources drift apart the first time one i18n key is edited. So read the
|
|
1186
|
+
* children the way accname does, via `contributesName`.
|
|
1187
|
+
*/
|
|
1188
|
+
function* iconButtonMatches(source) {
|
|
1189
|
+
for (const m of source.matchAll(/<Button(?=[\s/>])/g)) {
|
|
1190
|
+
const end = jsxOpeningEnd(source, m.index);
|
|
1191
|
+
if (end >= source.length) continue;
|
|
1192
|
+
const openTag = source.slice(m.index, end + 1);
|
|
1193
|
+
if (!/\bsize\s*=\s*["']icon["']/.test(openTag)) continue;
|
|
1194
|
+
if (authoredNameTag(openTag)) continue;
|
|
1195
|
+
if (!/\/\s*>$/.test(openTag)) {
|
|
1196
|
+
const close = closingTagRange(source, "Button", end + 1);
|
|
1197
|
+
if (contributesName(source.slice(end + 1, close ? close.start : source.length))) continue;
|
|
1198
|
+
}
|
|
1199
|
+
yield { 0: openTag, index: m.index };
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1077
1203
|
/** Match real JSX text after a balanced opening tag, including props with comparisons. */
|
|
1078
1204
|
function* currencyMatches(source) {
|
|
1079
1205
|
for (const opening of source.matchAll(/<(?:[A-Za-z][\w.:]*\b|(?=>))/g)) {
|