@godxjp/ui 27.12.0 → 28.0.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.js +12 -0
- package/dist/components/ui/toggle-group.d.ts +22 -0
- package/dist/components/ui/toggle-group.js +48 -3
- package/dist/contracts/measurement.json +1 -1
- package/dist/props/registry.d.ts +4 -0
- package/dist/props/registry.js +5 -0
- package/dist/styles/card-layout.css +3 -4
- package/docs/DESIGN-AUTHORITY.md +33 -0
- package/docs/data-entry/toggle-group.tsx +86 -6
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +43 -0
|
@@ -49,6 +49,18 @@ const Card = React.forwardRef(
|
|
|
49
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
50
|
);
|
|
51
51
|
}
|
|
52
|
+
if (isDevelopment()) {
|
|
53
|
+
const slots = React.Children.toArray(children).filter(React.isValidElement);
|
|
54
|
+
const hasHeader = slots.some((child) => child.type === CardHeader);
|
|
55
|
+
const soloBody = slots.some(
|
|
56
|
+
(child) => child.type === CardContent && child.props?.solo === true
|
|
57
|
+
);
|
|
58
|
+
if (hasHeader && soloBody) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"Card: `CardContent solo` says there is NO header above it, so it takes the card's own block padding \u2014 but this card also renders a `CardHeader`. The two cannot both hold, and the padding wins, which is the empty strip above your content. Drop `solo` to keep the header, or drop the header if the body really is alone."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
52
64
|
const tabs = slotted ? void 0 : tabList;
|
|
53
65
|
const Comp = asChild ? Slot : "div";
|
|
54
66
|
const [mirroredKey, setMirroredKey] = React.useState(void 0);
|
|
@@ -11,6 +11,28 @@ 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
|
+
* May the group end up with NOTHING selected? Omitted (`false`) it may, which is what a tag
|
|
16
|
+
* filter wants — pressing the selected chip again clears it and reports `""`. Set it and the
|
|
17
|
+
* selected item stays selected when it is pressed again.
|
|
18
|
+
*
|
|
19
|
+
* **It also decides the ARIA role of a `type="single"` group (gh#744)**, because emptiness is
|
|
20
|
+
* exactly what the two roles disagree about. ARIA has no "press again to deselect" for a radio:
|
|
21
|
+
* a radiogroup that has a selection always has exactly one checked item, so a radiogroup that
|
|
22
|
+
* the user just emptied is a state a screen reader cannot read out. Therefore:
|
|
23
|
+
*
|
|
24
|
+
* - omitted → `role="group"` + `aria-pressed` per item (a row of toggle buttons, which MAY be
|
|
25
|
+
* all-off) — this is the default, so no existing single group changes behaviour;
|
|
26
|
+
* - set → `role="radiogroup"` + `role="radio"` / `aria-checked` per item, and the arrow keys
|
|
27
|
+
* move the SELECTION as APG's radio-group pattern requires, not just the focus.
|
|
28
|
+
*
|
|
29
|
+
* The name is React Aria's own (`useToggleGroupState`, which this component is built on) —
|
|
30
|
+
* neither antd nor Radix names the capability. See `docs/DESIGN-AUTHORITY.md`.
|
|
31
|
+
*
|
|
32
|
+
* On `type="multiple"` it only keeps the last remaining item selected; the roles do not move
|
|
33
|
+
* (a row of `aria-pressed` buttons is already the right reading there).
|
|
34
|
+
*/
|
|
35
|
+
disallowEmptySelection?: boolean;
|
|
14
36
|
/**
|
|
15
37
|
* Let the row break onto further lines instead of running past its rail (gh#741). Same name,
|
|
16
38
|
* same boolean shape and same `data-wrap` attribute as `Flex` — one spelling answers "what does
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useCounterPill
|
|
14
14
|
} from "./toggle.js";
|
|
15
15
|
const ToggleGroupContext = React.createContext({});
|
|
16
|
+
const ARROW_KEYS = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
|
16
17
|
function toSelectedKeys(value) {
|
|
17
18
|
if (value == null) {
|
|
18
19
|
return void 0;
|
|
@@ -39,10 +40,16 @@ const ToggleGroup = React.forwardRef(
|
|
|
39
40
|
defaultValue,
|
|
40
41
|
onValueChange,
|
|
41
42
|
disabled,
|
|
43
|
+
disallowEmptySelection,
|
|
42
44
|
loop: _loop,
|
|
43
45
|
...props
|
|
44
46
|
}, ref) => {
|
|
45
|
-
const
|
|
47
|
+
const radioSemantics = type === "single" && disallowEmptySelection === true;
|
|
48
|
+
const arrowNav = React.useRef(false);
|
|
49
|
+
const context = React.useMemo(
|
|
50
|
+
() => ({ variant, size, shape, radioSemantics, arrowNav }),
|
|
51
|
+
[variant, size, shape, radioSemantics]
|
|
52
|
+
);
|
|
46
53
|
const selectedKeys = React.useMemo(() => toSelectedKeys(value), [value]);
|
|
47
54
|
const defaultSelectedKeys = React.useMemo(() => toSelectedKeys(defaultValue), [defaultValue]);
|
|
48
55
|
const handleSelectionChange = (keys) => {
|
|
@@ -69,7 +76,27 @@ const ToggleGroup = React.forwardRef(
|
|
|
69
76
|
defaultSelectedKeys,
|
|
70
77
|
onSelectionChange: handleSelectionChange,
|
|
71
78
|
isDisabled: disabled,
|
|
72
|
-
|
|
79
|
+
disallowEmptySelection,
|
|
80
|
+
render: (domProps) => /* @__PURE__ */ jsx(
|
|
81
|
+
"div",
|
|
82
|
+
{
|
|
83
|
+
...props,
|
|
84
|
+
...domProps,
|
|
85
|
+
...type === "single" && !radioSemantics ? { role: "group", "aria-orientation": void 0 } : null,
|
|
86
|
+
onKeyDownCapture: (event) => {
|
|
87
|
+
if (radioSemantics) {
|
|
88
|
+
arrowNav.current = ARROW_KEYS.has(event.key);
|
|
89
|
+
if (arrowNav.current) {
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
arrowNav.current = false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
domProps.onKeyDownCapture?.(event);
|
|
96
|
+
},
|
|
97
|
+
tabIndex: props.tabIndex ?? domProps.tabIndex
|
|
98
|
+
}
|
|
99
|
+
),
|
|
73
100
|
children: /* @__PURE__ */ jsx(ToggleGroupContext.Provider, { value: context, children })
|
|
74
101
|
}
|
|
75
102
|
);
|
|
@@ -105,6 +132,9 @@ const ToggleGroupItem = React.forwardRef(
|
|
|
105
132
|
});
|
|
106
133
|
const groupState = React.useContext(ToggleGroupStateContext);
|
|
107
134
|
const isPressed = groupState?.selectedKeys.has(value) ?? false;
|
|
135
|
+
const isSingle = groupState?.selectionMode === "single";
|
|
136
|
+
const radioSemantics = context.radioSemantics === true;
|
|
137
|
+
const rovingTabIndex = radioSemantics && groupState && groupState.selectedKeys.size > 0 ? isPressed ? 0 : -1 : void 0;
|
|
108
138
|
return /* @__PURE__ */ jsxs(
|
|
109
139
|
ToggleButton,
|
|
110
140
|
{
|
|
@@ -122,7 +152,22 @@ const ToggleGroupItem = React.forwardRef(
|
|
|
122
152
|
...props,
|
|
123
153
|
id: value,
|
|
124
154
|
isDisabled: disabled,
|
|
125
|
-
render: (domProps) => restoreDomProps(props,
|
|
155
|
+
render: (domProps) => restoreDomProps(props, {
|
|
156
|
+
...domProps,
|
|
157
|
+
...isSingle && !radioSemantics ? { role: void 0, "aria-checked": void 0, "aria-pressed": isPressed } : null,
|
|
158
|
+
...rovingTabIndex == null ? null : { tabIndex: rovingTabIndex },
|
|
159
|
+
// Selection follows focus, as APG requires of a radio group — but only when an arrow
|
|
160
|
+
// key is what moved it. Measured otherwise: RAC's toolbar Tab handler focuses the
|
|
161
|
+
// LAST item on the way out, which silently moved the selection to it.
|
|
162
|
+
...radioSemantics ? {
|
|
163
|
+
onFocus: (event) => {
|
|
164
|
+
if (context.arrowNav?.current && groupState && groupState.selectedKeys.size > 0 && !isPressed) {
|
|
165
|
+
groupState.setSelected(value, true);
|
|
166
|
+
}
|
|
167
|
+
domProps.onFocus?.(event);
|
|
168
|
+
}
|
|
169
|
+
} : null
|
|
170
|
+
}),
|
|
126
171
|
children: [
|
|
127
172
|
children,
|
|
128
173
|
pill
|
|
@@ -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": "
|
|
3
|
+
"version": "28.0.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
|
@@ -2471,6 +2471,10 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
2471
2471
|
readonly field: "wrap";
|
|
2472
2472
|
readonly local: true;
|
|
2473
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
|
+
readonly field: "disallowEmptySelection";
|
|
2476
|
+
readonly local: true;
|
|
2477
|
+
readonly reason: "React Aria's own name for the capability (useToggleGroupState), which this component is built on; neither antd nor Radix names it (docs/DESIGN-AUTHORITY.md). It is not just a state guard: on type=single it DECIDES the ARIA role, because emptiness is the one thing role=group + aria-pressed and role=radiogroup + aria-checked disagree about (gh#744).";
|
|
2474
2478
|
}];
|
|
2475
2479
|
};
|
|
2476
2480
|
readonly ToggleGroupItemProp: {
|
package/dist/props/registry.js
CHANGED
|
@@ -2878,6 +2878,11 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
2878
2878
|
field: "wrap",
|
|
2879
2879
|
local: true,
|
|
2880
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
|
+
field: "disallowEmptySelection",
|
|
2884
|
+
local: true,
|
|
2885
|
+
reason: "React Aria's own name for the capability (useToggleGroupState), which this component is built on; neither antd nor Radix names it (docs/DESIGN-AUTHORITY.md). It is not just a state guard: on type=single it DECIDES the ARIA role, because emptiness is the one thing role=group + aria-pressed and role=radiogroup + aria-checked disagree about (gh#744)."
|
|
2881
2886
|
}
|
|
2882
2887
|
]
|
|
2883
2888
|
},
|
|
@@ -85,15 +85,14 @@
|
|
|
85
85
|
|
|
86
86
|
[data-slot="card"][data-accent] > [data-slot="card-header"],
|
|
87
87
|
[data-slot="card"][data-accent] > [data-slot="card-content"]:not([data-flush]),
|
|
88
|
+
[data-slot="card"][data-accent] > [data-slot="card-footer"]:not([data-flush]) {
|
|
89
|
+
padding-inline-start: calc(var(--card-space-inset) - var(--card-accent-rail-width));
|
|
90
|
+
}
|
|
88
91
|
|
|
89
92
|
[data-slot="card"] > [data-slot="card-footer"] {
|
|
90
93
|
margin-block-start: auto;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
[data-slot="card"][data-accent] > [data-slot="card-footer"]:not([data-flush]) {
|
|
94
|
-
padding-inline-start: calc(var(--card-space-inset) - var(--card-accent-rail-width));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
96
|
[data-slot="card"][data-accent][data-accent-placement="perimeter"] {
|
|
98
97
|
border-width: var(--card-accent-perimeter-width);
|
|
99
98
|
border-color: var(--card-accent-color);
|
package/docs/DESIGN-AUTHORITY.md
CHANGED
|
@@ -335,6 +335,39 @@ expandable.expandedRowKeys` / `onExpandedRowsChange`. This library already mappe
|
|
|
335
335
|
role that carries a level without promising the `tree` / `treegrid` arrow-key model. A timeline
|
|
336
336
|
with no `depth > 0` keeps its pre-#724 markup byte for byte.
|
|
337
337
|
|
|
338
|
+
- **`ToggleGroup.disallowEmptySelection` — React Aria's name, and a deliberate divergence from
|
|
339
|
+
Radix's MARKUP (gh#744).** antd has no ToggleGroup at all (its nearest controls are `Segmented`
|
|
340
|
+
and `Radio.Group`, both of which are always-one-selected by construction and name nothing here),
|
|
341
|
+
and Radix's `ToggleGroup` has no such prop — so there is no antd spelling to port and no Radix
|
|
342
|
+
spelling to keep. The name is taken from the library this component is actually built on:
|
|
343
|
+
`react-stately`'s `useToggleGroupState` and `react-aria`'s `AriaToggleButtonGroupProps` both
|
|
344
|
+
call it `disallowEmptySelection`, and it is already the state hook's own switch, so a second
|
|
345
|
+
spelling would be a rename of a prop we pass straight through. It is a negative boolean, which
|
|
346
|
+
the vocabulary otherwise avoids (`allowClear`, `allowEmpty`, `allowEmptySubmit`) — `allowEmpty`
|
|
347
|
+
was rejected because `RangePicker.allowEmpty` already means `[boolean, boolean]` on the same
|
|
348
|
+
prop surface, and one word must not carry two shapes.
|
|
349
|
+
|
|
350
|
+
The divergence is not the name, it is the markup. Radix's single group emits
|
|
351
|
+
`role="radiogroup"` / `role="radio"` + `aria-checked` **and still lets the user clear the
|
|
352
|
+
selection by pressing the checked item again** — React Aria inherited exactly the same shape
|
|
353
|
+
(`useToggleButtonGroupItem` hardcodes the radio role on `selectionMode === "single"` without
|
|
354
|
+
ever reading `disallowEmptySelection`). ARIA has no press-again-to-deselect for a radio: a
|
|
355
|
+
radiogroup that has a selection always has exactly one checked item, so the state a user reaches
|
|
356
|
+
by pressing the checked chip once more — a radiogroup with nothing checked — is not expressible,
|
|
357
|
+
and a screen reader reads an empty radio group immediately after the user activated one of its
|
|
358
|
+
radios. This library's own catalog said as much in two places (`Radio.optionType` and
|
|
359
|
+
`Segmented`, both of which justify themselves by calling ToggleGroup "a row of aria-pressed
|
|
360
|
+
buttons" that "permits 'none chosen'"), so the two entries contradicted the ToggleGroup entry
|
|
361
|
+
about the same case.
|
|
362
|
+
|
|
363
|
+
So here the emptiness rule DECIDES the role. Default (`false`, today's behaviour — no existing
|
|
364
|
+
group changes): `role="group"` + `aria-pressed` per item, and no `aria-orientation`, which
|
|
365
|
+
`group` does not take. With the prop: `role="radiogroup"` + `role="radio"` / `aria-checked`,
|
|
366
|
+
the checked item is the single tab stop, and the arrow keys move the SELECTION as APG's
|
|
367
|
+
radio-group pattern requires rather than only the focus. `type="multiple"` is untouched
|
|
368
|
+
(`role="toolbar"`, `aria-pressed`); the prop there only keeps the last item selected.
|
|
369
|
+
Divergence from Radix's markup is the point of the change, not a side effect of it.
|
|
370
|
+
|
|
338
371
|
**A knob that only a fork could reach is not parity either.** antd's `components`,
|
|
339
372
|
`filterDropdown`, `classNames`/`styles` semantic maps and `prefixCls` all exist to let a consumer
|
|
340
373
|
replace the rendered markup. This library answers that layer with tokens (cardinal rule #45), so
|
|
@@ -21,6 +21,16 @@ const folderTags = [
|
|
|
21
21
|
{ value: "archive", label: "アーカイブ", count: 311 },
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
+
/** A one-of-N setting: a list always has A sort order, so this row may never be empty (gh#744). */
|
|
25
|
+
const sortOrders = [
|
|
26
|
+
{ value: "updated", label: "更新日" },
|
|
27
|
+
{ value: "created", label: "作成日" },
|
|
28
|
+
{ value: "name", label: "名前" },
|
|
29
|
+
{ value: "size", label: "サイズ" },
|
|
30
|
+
{ value: "owner", label: "所有者" },
|
|
31
|
+
{ value: "relevance", label: "関連度" },
|
|
32
|
+
];
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* ToggleGroup — single or multi-select toggle set. type='single' for mutually
|
|
26
36
|
* exclusive modes; type='multiple' for independent selections. Never raw radio
|
|
@@ -31,6 +41,8 @@ export default function Demo() {
|
|
|
31
41
|
const [formats, setFormats] = useState<string[]>(["pdf"]);
|
|
32
42
|
const [view, setView] = useState<string>("list");
|
|
33
43
|
const [tags, setTags] = useState<string[]>(["design", "runbook"]);
|
|
44
|
+
const [facet, setFacet] = useState<string>("design");
|
|
45
|
+
const [sort, setSort] = useState<string>("updated");
|
|
34
46
|
|
|
35
47
|
return (
|
|
36
48
|
<PageContainer
|
|
@@ -50,10 +62,9 @@ export default function Demo() {
|
|
|
50
62
|
<FormField id="period" label="集計期間">
|
|
51
63
|
<ToggleGroup
|
|
52
64
|
type="single"
|
|
65
|
+
disallowEmptySelection
|
|
53
66
|
value={period}
|
|
54
|
-
onValueChange={
|
|
55
|
-
if (v) setPeriod(v);
|
|
56
|
-
}}
|
|
67
|
+
onValueChange={setPeriod}
|
|
57
68
|
>
|
|
58
69
|
<ToggleGroupItem value="daily">日次</ToggleGroupItem>
|
|
59
70
|
<ToggleGroupItem value="monthly">月次</ToggleGroupItem>
|
|
@@ -64,6 +75,76 @@ export default function Demo() {
|
|
|
64
75
|
</CardContent>
|
|
65
76
|
</Card>
|
|
66
77
|
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader>
|
|
80
|
+
<CardTitle level={2}>disallowEmptySelection · 空を許すかどうかが role を決める</CardTitle>
|
|
81
|
+
<CardDescription>
|
|
82
|
+
ARIA には「もう一度押して選択を外す」という radio
|
|
83
|
+
がない。選択のある radiogroup には必ずちょうど一つ checked
|
|
84
|
+
な項目があるので、ユーザーが今押したばかりの radiogroup
|
|
85
|
+
が空になった状態はスクリーンリーダーが読み上げられない。だから空を許すかどうかが
|
|
86
|
+
role を決める(gh#744)。既定(空を許す)は role="group" + 各項目
|
|
87
|
+
aria-pressed で、同じチップをもう一度押せば選択が外れ onValueChange("")
|
|
88
|
+
が飛ぶ。disallowEmptySelection を付けると role="radiogroup" +
|
|
89
|
+
role="radio" / aria-checked になり、選択中の項目をもう一度押しても外れず、矢印キーが
|
|
90
|
+
APG の radio group どおり選択ごと動く。
|
|
91
|
+
</CardDescription>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<Flex direction="col" gap="md">
|
|
95
|
+
<Flex direction="col" gap="sm">
|
|
96
|
+
<Text as="p" size="sm" tone="muted">
|
|
97
|
+
既定(空を許す)· タグ絞り込み · role="group" +
|
|
98
|
+
aria-pressed。矢印キーはフォーカスだけを動かす
|
|
99
|
+
</Text>
|
|
100
|
+
<ToggleGroup
|
|
101
|
+
type="single"
|
|
102
|
+
variant="soft"
|
|
103
|
+
shape="pill"
|
|
104
|
+
wrap
|
|
105
|
+
value={facet}
|
|
106
|
+
onValueChange={(v) => setFacet(v as string)}
|
|
107
|
+
aria-label="ファセット絞り込み"
|
|
108
|
+
>
|
|
109
|
+
{folderTags.slice(0, 6).map((tag) => (
|
|
110
|
+
<ToggleGroupItem key={tag.value} value={tag.value}>
|
|
111
|
+
{tag.label}
|
|
112
|
+
</ToggleGroupItem>
|
|
113
|
+
))}
|
|
114
|
+
</ToggleGroup>
|
|
115
|
+
<Text as="p" size="sm" tone="muted">
|
|
116
|
+
選択中: {facet === "" ? "なし" : facet}
|
|
117
|
+
</Text>
|
|
118
|
+
</Flex>
|
|
119
|
+
<Flex direction="col" gap="sm">
|
|
120
|
+
<Text as="p" size="sm" tone="muted">
|
|
121
|
+
disallowEmptySelection · 並び替え · role="radiogroup" +
|
|
122
|
+
aria-checked。タブ位置は選択中の項目ひとつ、矢印キーが選択ごと動く
|
|
123
|
+
</Text>
|
|
124
|
+
<ToggleGroup
|
|
125
|
+
type="single"
|
|
126
|
+
variant="soft"
|
|
127
|
+
shape="pill"
|
|
128
|
+
wrap
|
|
129
|
+
disallowEmptySelection
|
|
130
|
+
value={sort}
|
|
131
|
+
onValueChange={(v) => setSort(v as string)}
|
|
132
|
+
aria-label="並び替え"
|
|
133
|
+
>
|
|
134
|
+
{sortOrders.map((order) => (
|
|
135
|
+
<ToggleGroupItem key={order.value} value={order.value}>
|
|
136
|
+
{order.label}
|
|
137
|
+
</ToggleGroupItem>
|
|
138
|
+
))}
|
|
139
|
+
</ToggleGroup>
|
|
140
|
+
<Text as="p" size="sm" tone="muted">
|
|
141
|
+
選択中: {sort === "" ? "なし" : sort}
|
|
142
|
+
</Text>
|
|
143
|
+
</Flex>
|
|
144
|
+
</Flex>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
|
|
67
148
|
<Card>
|
|
68
149
|
<CardHeader>
|
|
69
150
|
<CardTitle level={2}>Multiple selection (type=“multiple”)</CardTitle>
|
|
@@ -98,10 +179,9 @@ export default function Demo() {
|
|
|
98
179
|
<CardContent>
|
|
99
180
|
<ToggleGroup
|
|
100
181
|
type="single"
|
|
182
|
+
disallowEmptySelection
|
|
101
183
|
value={view}
|
|
102
|
-
onValueChange={
|
|
103
|
-
if (v) setView(v);
|
|
104
|
-
}}
|
|
184
|
+
onValueChange={setView}
|
|
105
185
|
>
|
|
106
186
|
<ToggleGroupItem value="list">一覧</ToggleGroupItem>
|
|
107
187
|
<ToggleGroupItem value="card">カード</ToggleGroupItem>
|
package/package.json
CHANGED
package/scripts/ui-audit.mjs
CHANGED
|
@@ -158,6 +158,42 @@ const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
|
|
|
158
158
|
*/
|
|
159
159
|
const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Is the raw tag at `index` the SLOT CHILD of an `asChild` primitive?
|
|
163
|
+
*
|
|
164
|
+
* `<Card asChild hoverable><button onClick={…}>` is not a violation — it is the shape this
|
|
165
|
+
* package PRESCRIBES. `Card.asChild`'s own docblock says so in as many words:
|
|
166
|
+
*
|
|
167
|
+
* "a card rendered as an `<a>` or a `<button>` measures byte for byte like the `div` it
|
|
168
|
+
* replaces and stays a SINGLE tab stop"
|
|
169
|
+
*
|
|
170
|
+
* and `hoverable`'s docblock sends the reader there: "the whole card rendered as one via
|
|
171
|
+
* {@link CardProps.asChild}".
|
|
172
|
+
*
|
|
173
|
+
* Reported as gh#740 and closed by shipping `Card asChild` — but the audit was never taught about
|
|
174
|
+
* it, so a consumer following the fix still got `no-raw-button: error` on the documented answer
|
|
175
|
+
* (godx-jp/shoots-gemba#8). A package that forbids its own prescription leaves the consumer no
|
|
176
|
+
* legal move at all, which is exactly what #740 was opened about.
|
|
177
|
+
*
|
|
178
|
+
* Walks BACKWARDS to the nearest preceding `>` and asks whether the tag it closes is a
|
|
179
|
+
* capitalised component carrying `asChild`. That covers both spellings prettier produces —
|
|
180
|
+
* `<Card asChild><button` on one line, and the attribute wrapped onto its own line — while
|
|
181
|
+
* `</Card>` or `<div>` before the tag exempts nothing, because neither starts a component.
|
|
182
|
+
*/
|
|
183
|
+
const isAsChildSlot = (content, index) => {
|
|
184
|
+
const before = content.slice(0, index).replace(/\s+$/, "");
|
|
185
|
+
if (!before.endsWith(">")) return false;
|
|
186
|
+
const openStart = before.lastIndexOf("<", before.length - 1);
|
|
187
|
+
if (openStart === -1) return false;
|
|
188
|
+
const openTag = before.slice(openStart);
|
|
189
|
+
// A component, not `</Card>` and not a lowercase host element.
|
|
190
|
+
return /^<[A-Z][\w.]*\b/.test(openTag) && /\basChild\b/.test(openTag);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** `rawTag`, minus the matches that are an `asChild` primitive's borrowed element. */
|
|
194
|
+
const rawTagOutsideAsChildSlot = (tag) => (content) =>
|
|
195
|
+
[...content.matchAll(rawTag(tag))].filter((match) => !isAsChildSlot(content, match.index));
|
|
196
|
+
|
|
161
197
|
/**
|
|
162
198
|
* @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
|
|
163
199
|
* exempt?:RegExp, classOnly?:boolean}[]}
|
|
@@ -409,6 +445,12 @@ const RULES = [
|
|
|
409
445
|
severity: "error",
|
|
410
446
|
spansElement: true,
|
|
411
447
|
test: new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
|
|
448
|
+
matches: (content) =>
|
|
449
|
+
[
|
|
450
|
+
...content.matchAll(
|
|
451
|
+
new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
|
|
452
|
+
),
|
|
453
|
+
].filter((match) => !isAsChildSlot(content, match.index)),
|
|
412
454
|
message: "Use <Input> from @godxjp/ui, not a raw <input> (rules §3).",
|
|
413
455
|
},
|
|
414
456
|
{
|
|
@@ -418,6 +460,7 @@ const RULES = [
|
|
|
418
460
|
severity: "error",
|
|
419
461
|
spansElement: true,
|
|
420
462
|
test: rawTag("button"),
|
|
463
|
+
matches: rawTagOutsideAsChildSlot("button"),
|
|
421
464
|
message: "Use <Button> from @godxjp/ui, not a raw <button> (rules §3).",
|
|
422
465
|
},
|
|
423
466
|
{
|