@godxjp/ui 27.12.1 → 28.1.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 +5 -0
- package/dist/components/navigation/tabs.d.ts +1 -1
- package/dist/components/navigation/tabs.js +54 -6
- package/dist/components/ui/toggle-group.d.ts +22 -0
- package/dist/components/ui/toggle-group.js +59 -3
- package/dist/components/ui/toggle.d.ts +11 -2
- package/dist/components/ui/toggle.js +3 -2
- package/dist/contracts/measurement.json +1 -1
- package/dist/props/components/data-display.prop.d.ts +15 -0
- package/dist/props/components/navigation.prop.d.ts +50 -0
- package/dist/props/registry.d.ts +18 -2
- package/dist/props/registry.js +28 -2
- package/dist/styles/card-layout.css +3 -4
- package/dist/styles/navigation-layout.css +109 -0
- package/dist/tokens/components/navigation.css +18 -0
- package/docs/DESIGN-AUTHORITY.md +33 -0
- package/docs/data-entry/toggle-group.tsx +86 -6
- package/docs/navigation/card-tabs.tsx +344 -0
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +120 -4
|
@@ -245,6 +245,115 @@
|
|
|
245
245
|
box-shadow: inset calc(-1 * var(--tabs-card-rail-border-width)) 0 0 hsl(var(--border));
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
[data-slot="tabs"][data-bodied="true"] {
|
|
249
|
+
--tabs-root-gap: 0px;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
[data-slot="tabs"][data-bodied="true"][data-variant="card"] [data-slot="tabs-list"],
|
|
253
|
+
[data-slot="tabs"][data-bodied="true"][data-variant="editable-card"] [data-slot="tabs-list"],
|
|
254
|
+
[dir="rtl"] [data-slot="tabs"][data-bodied="true"][data-variant="card"] [data-slot="tabs-list"],
|
|
255
|
+
[dir="rtl"]
|
|
256
|
+
[data-slot="tabs"][data-bodied="true"][data-variant="editable-card"]
|
|
257
|
+
[data-slot="tabs-list"] {
|
|
258
|
+
box-shadow: none;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="top"] .ui-tabs-bar {
|
|
262
|
+
align-items: flex-end;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="bottom"] .ui-tabs-bar {
|
|
266
|
+
align-items: flex-start;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-panel"],
|
|
270
|
+
[data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-content"] {
|
|
271
|
+
border: var(--tabs-panel-border-width) solid hsl(var(--border));
|
|
272
|
+
border-radius: var(--tabs-panel-radius);
|
|
273
|
+
background: hsl(var(--tabs-panel-background, var(--background)));
|
|
274
|
+
padding: var(--tabs-panel-space-inset);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="top"] > [data-slot="tabs-panel"],
|
|
278
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="top"] > [data-slot="tabs-content"] {
|
|
279
|
+
margin-block-start: calc(-1 * var(--tabs-panel-border-width));
|
|
280
|
+
border-start-start-radius: 0;
|
|
281
|
+
border-start-end-radius: 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="bottom"] > [data-slot="tabs-panel"],
|
|
285
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="bottom"] > [data-slot="tabs-content"] {
|
|
286
|
+
margin-block-end: calc(-1 * var(--tabs-panel-border-width));
|
|
287
|
+
border-end-start-radius: 0;
|
|
288
|
+
border-end-end-radius: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="start"] > [data-slot="tabs-panel"],
|
|
292
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="start"] > [data-slot="tabs-content"] {
|
|
293
|
+
margin-inline-start: calc(-1 * var(--tabs-panel-border-width));
|
|
294
|
+
border-start-start-radius: 0;
|
|
295
|
+
border-end-start-radius: 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="end"] > [data-slot="tabs-panel"],
|
|
299
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="end"] > [data-slot="tabs-content"] {
|
|
300
|
+
margin-inline-end: calc(-1 * var(--tabs-panel-border-width));
|
|
301
|
+
border-start-end-radius: 0;
|
|
302
|
+
border-end-end-radius: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@media (forced-colors: active) {
|
|
306
|
+
[data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-panel"],
|
|
307
|
+
[data-slot="tabs"][data-bodied="true"] > [data-slot="tabs-content"] {
|
|
308
|
+
border-color: CanvasText;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="top"]
|
|
312
|
+
[data-slot="tabs-trigger"][data-state="active"] {
|
|
313
|
+
border-block-end-style: none;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
[data-slot="tabs"][data-bodied="true"][data-placement="bottom"]
|
|
317
|
+
[data-slot="tabs-trigger"][data-state="active"] {
|
|
318
|
+
border-block-start-style: none;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.ui-tabs-count {
|
|
323
|
+
display: inline-flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
justify-content: center;
|
|
326
|
+
min-inline-size: var(--tabs-count-min-width);
|
|
327
|
+
margin-inline-start: var(--tabs-count-gap);
|
|
328
|
+
border-radius: var(--tabs-count-radius);
|
|
329
|
+
padding-inline: var(--tabs-count-space-inline);
|
|
330
|
+
background: var(--tabs-count-background, hsl(var(--muted)));
|
|
331
|
+
color: var(--tabs-count-color, hsl(var(--muted-foreground)));
|
|
332
|
+
font-size: var(--tabs-count-font-size);
|
|
333
|
+
line-height: 1;
|
|
334
|
+
font-variant-numeric: tabular-nums;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
[data-slot="tabs"][data-variant="card"]
|
|
338
|
+
[data-slot="tabs-trigger"]:not([data-state="active"])
|
|
339
|
+
.ui-tabs-count,
|
|
340
|
+
[data-slot="tabs"][data-variant="editable-card"]
|
|
341
|
+
[data-slot="tabs-trigger"]:not([data-state="active"])
|
|
342
|
+
.ui-tabs-count {
|
|
343
|
+
background: var(--tabs-count-card-background, hsl(var(--background)));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
[data-slot="tabs-trigger"][data-state="active"] .ui-tabs-count {
|
|
347
|
+
background: var(--tabs-count-active-background, hsl(var(--primary)));
|
|
348
|
+
color: var(--tabs-count-active-color, hsl(var(--primary-foreground)));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
@media (forced-colors: active) {
|
|
352
|
+
[data-slot="tabs-trigger"][data-state="active"] .ui-tabs-count {
|
|
353
|
+
outline: var(--tabs-count-forced-outline-width) solid currentColor;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
248
357
|
.ui-tabs-tab-remove {
|
|
249
358
|
display: inline-flex;
|
|
250
359
|
flex: 0 0 auto;
|
|
@@ -78,6 +78,24 @@
|
|
|
78
78
|
--tabs-card-rail-border-width: var(--stroke-hairline);
|
|
79
79
|
--tabs-card-background: initial;
|
|
80
80
|
|
|
81
|
+
--tabs-panel-background: initial;
|
|
82
|
+
--tabs-panel-border-width: var(--tabs-card-rail-border-width);
|
|
83
|
+
--tabs-panel-radius: var(--tabs-card-radius);
|
|
84
|
+
--tabs-panel-space-inset: var(--space-4);
|
|
85
|
+
|
|
86
|
+
--tabs-count-min-width: var(--space-4);
|
|
87
|
+
--tabs-count-space-inline: var(--space-1);
|
|
88
|
+
--tabs-count-gap: var(--space-1);
|
|
89
|
+
--tabs-count-font-size: var(--font-size-xs);
|
|
90
|
+
--tabs-count-radius: var(--radius-pill);
|
|
91
|
+
--tabs-count-background: initial;
|
|
92
|
+
|
|
93
|
+
--tabs-count-card-background: initial;
|
|
94
|
+
--tabs-count-color: initial;
|
|
95
|
+
--tabs-count-active-background: initial;
|
|
96
|
+
--tabs-count-active-color: initial;
|
|
97
|
+
--tabs-count-forced-outline-width: var(--stroke-hairline);
|
|
98
|
+
|
|
81
99
|
--tabs-bar-gap: var(--space-2);
|
|
82
100
|
--tabs-extra-gap: var(--space-2);
|
|
83
101
|
|
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>
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@godxjp/ui/data-display";
|
|
4
|
+
import { Button, Text } from "@godxjp/ui/general";
|
|
5
|
+
import { Flex, PageContainer } from "@godxjp/ui/layout";
|
|
6
|
+
import { Tabs } from "@godxjp/ui/navigation";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CARD TABS — the strip and its panel as ONE object (gh#762).
|
|
10
|
+
*
|
|
11
|
+
* The screen this page is built from is the one the issue draws in ASCII: a saved-view strip over
|
|
12
|
+
* a list, antd `type="editable-card"` shaped — closable views, a `+`, an action parked on the
|
|
13
|
+
* trailing edge of the bar, and a body that the open tab opens INTO rather than sits above.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is `Tabs` with documented props. There is no page-local CSS and no wrapper box
|
|
16
|
+
* around the panel: the point of `bodied` is that the consumer no longer has to guess which box
|
|
17
|
+
* to put underneath, because all three guesses were wrong (`<Card>` added a second 1px border at
|
|
18
|
+
* a 9.708px radius over an 8px gap; `<Card variant="borderless">` and no box at all left the same
|
|
19
|
+
* 8px gap). Measured after: 0px of gap, one continuous 1px line, and no line at all across the
|
|
20
|
+
* active tab.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type SavedView = {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
count: number;
|
|
27
|
+
/** antd `Tab.closable` — 「すべての課題」 is the system view and cannot be deleted. */
|
|
28
|
+
closable?: boolean;
|
|
29
|
+
rows: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const INITIAL_VIEWS: SavedView[] = [
|
|
33
|
+
{
|
|
34
|
+
value: "all",
|
|
35
|
+
label: "すべての課題",
|
|
36
|
+
count: 340,
|
|
37
|
+
closable: false,
|
|
38
|
+
rows: [
|
|
39
|
+
"TASK-1042 · 請求書の様式を更新",
|
|
40
|
+
"TASK-1041 · 監査ログの保存期間",
|
|
41
|
+
"TASK-1038 · 権限表の棚卸",
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
value: "mine",
|
|
46
|
+
label: "自分の担当",
|
|
47
|
+
count: 12,
|
|
48
|
+
rows: ["TASK-1042 · 請求書の様式を更新", "TASK-1029 · 締め処理の手順書"],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
value: "review",
|
|
52
|
+
label: "レビュー待ち",
|
|
53
|
+
count: 0,
|
|
54
|
+
rows: [],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** The list body a saved view opens into — a real panel, not a placeholder paragraph. */
|
|
59
|
+
function ViewBody({ view }: { view: SavedView }) {
|
|
60
|
+
if (view.rows.length === 0) {
|
|
61
|
+
return (
|
|
62
|
+
<Text as="p" tone="muted">
|
|
63
|
+
この条件に一致する課題はありません。
|
|
64
|
+
</Text>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
<Flex direction="col" gap="sm">
|
|
69
|
+
{view.rows.map((row) => (
|
|
70
|
+
<Text key={row} as="p">
|
|
71
|
+
{row}
|
|
72
|
+
</Text>
|
|
73
|
+
))}
|
|
74
|
+
</Flex>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PLACEMENT_ITEMS = [
|
|
79
|
+
{ value: "summary", label: "サマリ", content: <Text as="p">当月の売上合計 ¥4,820,000</Text> },
|
|
80
|
+
{ value: "detail", label: "明細", content: <Text as="p">明細 24 件 · 未承認 2 件</Text> },
|
|
81
|
+
{ value: "audit", label: "監査", content: <Text as="p">監査ログ 96 件</Text> },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const COUNT_ITEMS = [
|
|
85
|
+
{
|
|
86
|
+
value: "open",
|
|
87
|
+
label: "未対応",
|
|
88
|
+
count: 12,
|
|
89
|
+
countLabel: "件の課題",
|
|
90
|
+
content: <Text as="p">未対応 12 件</Text>,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
value: "doing",
|
|
94
|
+
label: "対応中",
|
|
95
|
+
count: 340,
|
|
96
|
+
overflowCount: 999,
|
|
97
|
+
countLabel: "件の課題",
|
|
98
|
+
content: <Text as="p">対応中 340 件</Text>,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
value: "capped",
|
|
102
|
+
label: "全期間",
|
|
103
|
+
count: 1280,
|
|
104
|
+
countLabel: "件の課題",
|
|
105
|
+
content: <Text as="p">全期間 1,280 件 · 既定の overflowCount は 99</Text>,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
value: "empty",
|
|
109
|
+
label: "保留",
|
|
110
|
+
count: 0,
|
|
111
|
+
countLabel: "件の課題",
|
|
112
|
+
content: <Text as="p">保留 0 件</Text>,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
value: "hidden-zero",
|
|
116
|
+
label: "却下",
|
|
117
|
+
count: 0,
|
|
118
|
+
showZero: false,
|
|
119
|
+
countLabel: "件の課題",
|
|
120
|
+
content: <Text as="p">showZero={"{false}"} なので 0 のピルは描かれない</Text>,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
value: "off",
|
|
124
|
+
label: "アーカイブ",
|
|
125
|
+
disabled: true,
|
|
126
|
+
count: 7,
|
|
127
|
+
countLabel: "件の課題",
|
|
128
|
+
content: <Text as="p">アーカイブ</Text>,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export default function Demo() {
|
|
133
|
+
const [views, setViews] = useState(INITIAL_VIEWS);
|
|
134
|
+
const [activeView, setActiveView] = useState("all");
|
|
135
|
+
const [nextView, setNextView] = useState(1);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<PageContainer
|
|
139
|
+
title="カードタブ"
|
|
140
|
+
subtitle="ストリップとパネルをひとつの箱にする bodied と、タブの件数バッジ。"
|
|
141
|
+
>
|
|
142
|
+
<Flex direction="col" gap="lg">
|
|
143
|
+
{/* THE SCREEN THE ISSUE DRAWS — saved views over a list. */}
|
|
144
|
+
<Card>
|
|
145
|
+
<CardHeader>
|
|
146
|
+
<CardTitle level={2}>保存したビュー · editable-card + bodied</CardTitle>
|
|
147
|
+
<CardDescription>
|
|
148
|
+
antd の <code>type="editable-card"</code> と同じ形。
|
|
149
|
+
<code>bodied</code> がパネルの枠・角丸・背景を描くので、ストリップとパネルが
|
|
150
|
+
ひとつの箱として読める。「すべての課題」は <code>closable: false</code>
|
|
151
|
+
なので × が出ない。
|
|
152
|
+
</CardDescription>
|
|
153
|
+
</CardHeader>
|
|
154
|
+
<CardContent>
|
|
155
|
+
<Tabs
|
|
156
|
+
id="saved-views"
|
|
157
|
+
variant="editable-card"
|
|
158
|
+
bodied
|
|
159
|
+
value={activeView}
|
|
160
|
+
onValueChange={setActiveView}
|
|
161
|
+
extra={
|
|
162
|
+
<Button size="sm" variant="outline">
|
|
163
|
+
フィルタを保存
|
|
164
|
+
</Button>
|
|
165
|
+
}
|
|
166
|
+
onEdit={(target, action) => {
|
|
167
|
+
if (action === "add") {
|
|
168
|
+
const value = `view-${nextView}`;
|
|
169
|
+
setViews((prev) => [
|
|
170
|
+
...prev,
|
|
171
|
+
{ value, label: `新しいビュー ${nextView}`, count: 0, rows: [] },
|
|
172
|
+
]);
|
|
173
|
+
setNextView((n) => n + 1);
|
|
174
|
+
setActiveView(value);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const key = String(target);
|
|
178
|
+
setViews((prev) => prev.filter((view) => view.value !== key));
|
|
179
|
+
setActiveView((current) =>
|
|
180
|
+
current === key ? (views.find((v) => v.value !== key)?.value ?? "all") : current,
|
|
181
|
+
);
|
|
182
|
+
}}
|
|
183
|
+
items={views.map((view) => ({
|
|
184
|
+
value: view.value,
|
|
185
|
+
label: view.label,
|
|
186
|
+
closable: view.closable,
|
|
187
|
+
count: view.count,
|
|
188
|
+
overflowCount: 999,
|
|
189
|
+
countLabel: "件の課題",
|
|
190
|
+
content: <ViewBody view={view} />,
|
|
191
|
+
}))}
|
|
192
|
+
/>
|
|
193
|
+
</CardContent>
|
|
194
|
+
</Card>
|
|
195
|
+
|
|
196
|
+
{/* THE DEFECT, SIDE BY SIDE. */}
|
|
197
|
+
<Card>
|
|
198
|
+
<CardHeader>
|
|
199
|
+
<CardTitle level={2}>bodied なし / あり</CardTitle>
|
|
200
|
+
<CardDescription>
|
|
201
|
+
<code>bodied</code> なしの card ストリップは 8px 離れた透明なパネルの上に浮く —「card
|
|
202
|
+
と tab が 1 つに融合していない」と報告された形。あり側は隙間 0px、 線は 1
|
|
203
|
+
本、アクティブなタブの下辺だけが開いている。
|
|
204
|
+
</CardDescription>
|
|
205
|
+
</CardHeader>
|
|
206
|
+
<CardContent>
|
|
207
|
+
<Flex direction="col" gap="lg">
|
|
208
|
+
<Flex direction="col" gap="sm">
|
|
209
|
+
<Text as="p" size="sm" tone="muted">
|
|
210
|
+
bodied なし(28.0.0 まで)
|
|
211
|
+
</Text>
|
|
212
|
+
<Tabs
|
|
213
|
+
id="card-unbodied"
|
|
214
|
+
defaultValue="summary"
|
|
215
|
+
variant="card"
|
|
216
|
+
items={PLACEMENT_ITEMS}
|
|
217
|
+
/>
|
|
218
|
+
</Flex>
|
|
219
|
+
<Flex direction="col" gap="sm">
|
|
220
|
+
<Text as="p" size="sm" tone="muted">
|
|
221
|
+
bodied
|
|
222
|
+
</Text>
|
|
223
|
+
<Tabs
|
|
224
|
+
id="card-bodied"
|
|
225
|
+
defaultValue="summary"
|
|
226
|
+
variant="card"
|
|
227
|
+
bodied
|
|
228
|
+
items={PLACEMENT_ITEMS}
|
|
229
|
+
/>
|
|
230
|
+
</Flex>
|
|
231
|
+
</Flex>
|
|
232
|
+
</CardContent>
|
|
233
|
+
</Card>
|
|
234
|
+
|
|
235
|
+
{/* EVERY PLACEMENT — the joined edge follows the strip. */}
|
|
236
|
+
<Card>
|
|
237
|
+
<CardHeader>
|
|
238
|
+
<CardTitle level={2}>bodied × tabPlacement</CardTitle>
|
|
239
|
+
<CardDescription>
|
|
240
|
+
角丸が落ちる辺・枠が重なる辺は、ストリップの位置に従う。
|
|
241
|
+
<code>start</code> / <code>end</code> は論理値なので RTL でも反転する (48rem 以下では
|
|
242
|
+
top / bottom に折り返す)。
|
|
243
|
+
</CardDescription>
|
|
244
|
+
</CardHeader>
|
|
245
|
+
<CardContent>
|
|
246
|
+
<Flex direction="col" gap="lg">
|
|
247
|
+
{(["top", "bottom", "start", "end"] as const).map((placement) => (
|
|
248
|
+
<Flex key={placement} direction="col" gap="sm">
|
|
249
|
+
<Text as="p" size="sm" tone="muted">
|
|
250
|
+
tabPlacement="{placement}"
|
|
251
|
+
</Text>
|
|
252
|
+
<Tabs
|
|
253
|
+
id={`card-bodied-${placement}`}
|
|
254
|
+
defaultValue="summary"
|
|
255
|
+
variant="card"
|
|
256
|
+
bodied
|
|
257
|
+
tabPlacement={placement}
|
|
258
|
+
items={PLACEMENT_ITEMS}
|
|
259
|
+
/>
|
|
260
|
+
</Flex>
|
|
261
|
+
))}
|
|
262
|
+
</Flex>
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
|
|
266
|
+
{/* THE COUNT. */}
|
|
267
|
+
<Card>
|
|
268
|
+
<CardHeader>
|
|
269
|
+
<CardTitle level={2}>タブの件数バッジ · count / countLabel</CardTitle>
|
|
270
|
+
<CardDescription>
|
|
271
|
+
<code>count</code> は Button・Toggle と同じカウンタ語彙で、
|
|
272
|
+
<code>Intl.NumberFormat</code> でロケール整形される。読み上げ名は 「未対応, 12
|
|
273
|
+
件の課題」 — 数字が直接ラベルに連結しないよう、ピルは <code>aria-hidden</code>、
|
|
274
|
+
読み上げ用の文だけが <code>sr-only</code> で隣に置かれる。 既定の上限は 99(
|
|
275
|
+
<code>overflowCount</code> で変更)、 0 を隠すには <code>showZero={"{false}"}</code>。
|
|
276
|
+
</CardDescription>
|
|
277
|
+
</CardHeader>
|
|
278
|
+
<CardContent>
|
|
279
|
+
<Flex direction="col" gap="lg">
|
|
280
|
+
<Flex direction="col" gap="sm">
|
|
281
|
+
<Text as="p" size="sm" tone="muted">
|
|
282
|
+
variant="line"
|
|
283
|
+
</Text>
|
|
284
|
+
<Tabs id="count-line" defaultValue="open" variant="line" items={COUNT_ITEMS} />
|
|
285
|
+
</Flex>
|
|
286
|
+
<Flex direction="col" gap="sm">
|
|
287
|
+
<Text as="p" size="sm" tone="muted">
|
|
288
|
+
variant="card" + bodied
|
|
289
|
+
</Text>
|
|
290
|
+
<Tabs
|
|
291
|
+
id="count-card"
|
|
292
|
+
defaultValue="open"
|
|
293
|
+
variant="card"
|
|
294
|
+
bodied
|
|
295
|
+
items={COUNT_ITEMS}
|
|
296
|
+
/>
|
|
297
|
+
</Flex>
|
|
298
|
+
<Flex direction="col" gap="sm">
|
|
299
|
+
<Text as="p" size="sm" tone="muted">
|
|
300
|
+
size="sm" / "md" / "lg"
|
|
301
|
+
</Text>
|
|
302
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
303
|
+
<Tabs
|
|
304
|
+
key={size}
|
|
305
|
+
id={`count-${size}`}
|
|
306
|
+
defaultValue="open"
|
|
307
|
+
variant="line"
|
|
308
|
+
size={size}
|
|
309
|
+
items={COUNT_ITEMS.slice(0, 3)}
|
|
310
|
+
/>
|
|
311
|
+
))}
|
|
312
|
+
</Flex>
|
|
313
|
+
</Flex>
|
|
314
|
+
</CardContent>
|
|
315
|
+
</Card>
|
|
316
|
+
|
|
317
|
+
{/* The other joined pair the package already ships. */}
|
|
318
|
+
<Card>
|
|
319
|
+
<CardHeader>
|
|
320
|
+
<CardTitle level={2}>もうひとつの「ひとつの箱」· Card tabList</CardTitle>
|
|
321
|
+
<CardDescription>
|
|
322
|
+
ストリップがカードの見出しの下に入る形は <code>Card tabList</code> が担当する (antd
|
|
323
|
+
`Card` の <code>tabList</code>)。枠はカードのもので、
|
|
324
|
+
<code>bodied</code> は要らない。card 系のストリップを単体で置くときだけ
|
|
325
|
+
<code>bodied</code> を使う。
|
|
326
|
+
</CardDescription>
|
|
327
|
+
</CardHeader>
|
|
328
|
+
<CardContent>
|
|
329
|
+
<Card
|
|
330
|
+
tabList={[
|
|
331
|
+
{ key: "all", tab: "すべての課題", closable: false },
|
|
332
|
+
{ key: "mine", tab: "自分の担当" },
|
|
333
|
+
]}
|
|
334
|
+
>
|
|
335
|
+
<CardContent>
|
|
336
|
+
<Text as="p">カードの枠がそのままパネルの枠になる。</Text>
|
|
337
|
+
</CardContent>
|
|
338
|
+
</Card>
|
|
339
|
+
</CardContent>
|
|
340
|
+
</Card>
|
|
341
|
+
</Flex>
|
|
342
|
+
</PageContainer>
|
|
343
|
+
);
|
|
344
|
+
}
|