@godxjp/ui 28.0.0 → 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.js +14 -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 +14 -2
- package/dist/props/registry.js +23 -2
- package/dist/styles/navigation-layout.css +109 -0
- package/dist/tokens/components/navigation.css +18 -0
- package/docs/navigation/card-tabs.tsx +344 -0
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +83 -10
|
@@ -97,6 +97,11 @@ const Card = React.forwardRef(
|
|
|
97
97
|
value: item.key,
|
|
98
98
|
label: item.tab,
|
|
99
99
|
disabled: item.disabled,
|
|
100
|
+
// antd's per-tab remove control. Forwarded rather than dropped: `items` is built
|
|
101
|
+
// AFTER `...tabProps` above, so a consumer cannot supply these from outside —
|
|
102
|
+
// this mapping is the only place they can arrive (gh#757).
|
|
103
|
+
closable: item.closable,
|
|
104
|
+
closeIcon: item.closeIcon,
|
|
100
105
|
// The body belongs to the SELECTED tab and to no other: the card has exactly one
|
|
101
106
|
// set of children, so handing them to every panel would render them N times the
|
|
102
107
|
// moment `destroyOnHidden={false}` kept the hidden panels mounted.
|
|
@@ -8,7 +8,7 @@ export type TabsProps = Omit<React.ComponentPropsWithoutRef<"div">, "defaultValu
|
|
|
8
8
|
dir?: "ltr" | "rtl";
|
|
9
9
|
activationMode?: "manual" | "automatic";
|
|
10
10
|
};
|
|
11
|
-
export declare function Tabs({ className, orientation, items, value, defaultValue, onValueChange, activationMode, dir, variant, tabPlacement, size, centered, extra, destroyOnHidden, onEdit, addIcon, hideAdd, closeIcon, onTabClick, overflow, animated, indicator, moreIcon, onTabScroll, listClassName, contentClassName, children, ...props }: TabsProps): React.JSX.Element;
|
|
11
|
+
export declare function Tabs({ className, orientation, items, value, defaultValue, onValueChange, activationMode, dir, variant, tabPlacement, size, centered, bodied, extra, destroyOnHidden, onEdit, addIcon, hideAdd, closeIcon, onTabClick, overflow, animated, indicator, moreIcon, onTabScroll, listClassName, contentClassName, children, ...props }: TabsProps): React.JSX.Element;
|
|
12
12
|
export declare const TabsList: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
|
|
13
13
|
variant?: "default" | "line";
|
|
14
14
|
/**
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Tabs as AriaTabs
|
|
9
9
|
} from "react-aria-components";
|
|
10
10
|
import { MoreHorizontal, Plus, X } from "lucide-react";
|
|
11
|
+
import { useCounterPill } from "../ui/toggle.js";
|
|
11
12
|
import { useTranslation } from "../../i18n/use-translation.js";
|
|
12
13
|
import { useMaxWidthBreakpoint } from "../../lib/breakpoint-token.js";
|
|
13
14
|
import { cn } from "../../lib/utils.js";
|
|
@@ -31,6 +32,15 @@ function triggerSizeClassName(size) {
|
|
|
31
32
|
size === "lg" && "min-h-[var(--tabs-trigger-height-lg)] px-[var(--tabs-trigger-padding-x-lg)] text-[length:var(--tabs-trigger-font-size-lg)]"
|
|
32
33
|
);
|
|
33
34
|
}
|
|
35
|
+
function TabCount({
|
|
36
|
+
count,
|
|
37
|
+
overflowCount,
|
|
38
|
+
showZero,
|
|
39
|
+
countLabel
|
|
40
|
+
}) {
|
|
41
|
+
const { pill } = useCounterPill({ count, overflowCount, showZero, countLabel, slot: "tabs" });
|
|
42
|
+
return pill;
|
|
43
|
+
}
|
|
34
44
|
const TabsFrameContext = React.createContext({
|
|
35
45
|
orientation: "horizontal",
|
|
36
46
|
selectionSuppressed: false,
|
|
@@ -99,6 +109,7 @@ function Tabs({
|
|
|
99
109
|
tabPlacement,
|
|
100
110
|
size = "md",
|
|
101
111
|
centered,
|
|
112
|
+
bodied,
|
|
102
113
|
extra,
|
|
103
114
|
destroyOnHidden = true,
|
|
104
115
|
onEdit,
|
|
@@ -178,12 +189,13 @@ function Tabs({
|
|
|
178
189
|
const showAdd = editable && !hideAdd && Boolean(onEdit);
|
|
179
190
|
const needsBar = Boolean(extraStart || extraEnd || showAdd || collapsible);
|
|
180
191
|
const card = variant === "card" || variant === "editable-card";
|
|
192
|
+
const bodiedCard = card && Boolean(bodied);
|
|
181
193
|
const sizeTriggerClassName = triggerSizeClassName(size);
|
|
182
194
|
const CARD_FACE = {
|
|
183
|
-
top: "rounded-[var(--tabs-card-radius)_var(--tabs-card-radius)_0_0] data-[state=active]:border-b-background",
|
|
184
|
-
bottom: "rounded-[0_0_var(--tabs-card-radius)_var(--tabs-card-radius)] data-[state=active]:border-t-background",
|
|
185
|
-
start: "rounded-s-[var(--tabs-card-radius)] rounded-e-none data-[state=active]:border-e-background",
|
|
186
|
-
end: "rounded-e-[var(--tabs-card-radius)] rounded-s-none data-[state=active]:border-s-background"
|
|
195
|
+
top: "rounded-[var(--tabs-card-radius)_var(--tabs-card-radius)_0_0] data-[state=active]:border-b-[hsl(var(--tabs-panel-background,var(--background)))]",
|
|
196
|
+
bottom: "rounded-[0_0_var(--tabs-card-radius)_var(--tabs-card-radius)] data-[state=active]:border-t-[hsl(var(--tabs-panel-background,var(--background)))]",
|
|
197
|
+
start: "rounded-s-[var(--tabs-card-radius)] rounded-e-none data-[state=active]:border-e-[hsl(var(--tabs-panel-background,var(--background)))]",
|
|
198
|
+
end: "rounded-e-[var(--tabs-card-radius)] rounded-s-none data-[state=active]:border-s-[hsl(var(--tabs-panel-background,var(--background)))]"
|
|
187
199
|
};
|
|
188
200
|
const list = items ? /* @__PURE__ */ jsx(
|
|
189
201
|
TabsList,
|
|
@@ -258,6 +270,16 @@ function Tabs({
|
|
|
258
270
|
// pixel and `justify-content` has nothing left to centre — measured, the leading and
|
|
259
271
|
// trailing gaps were both 0px with `centered` on and off.
|
|
260
272
|
centered && "flex-none",
|
|
273
|
+
// LINE face: same argument as the card face below, and from the same source — antd
|
|
274
|
+
// puts no `flex-grow` on a tab of ANY `type`, so a line tab is content-width too.
|
|
275
|
+
// `card` was fixed on that reasoning (gh#736); `line` was left on the base `flex-1`
|
|
276
|
+
// and kept stretching. Measured by a consumer on a 1200px strip: three tabs at
|
|
277
|
+
// 321px each for labels needing 83 / 84 / 56px, so the active underline ran 321px
|
|
278
|
+
// under a 56px label.
|
|
279
|
+
//
|
|
280
|
+
// `centered` is NOT the escape hatch for this: it centres, which trades one wrong
|
|
281
|
+
// geometry for another, and it is antd's prop for centring rather than for width.
|
|
282
|
+
variant === "line" && "flex-none",
|
|
261
283
|
// The merged edge is the SURFACE colour, never `transparent`: the rail is an inset
|
|
262
284
|
// shadow at the strip's padding-box edge, so a see-through border would let that 1px
|
|
263
285
|
// of rail run straight across the tab it is joined to.
|
|
@@ -271,7 +293,14 @@ function Tabs({
|
|
|
271
293
|
// the × ended up 90px from the tab's own trailing edge (179px on a two-tab
|
|
272
294
|
// strip). antd's is 8px away. This is also why the face read as a free-standing
|
|
273
295
|
// bordered box rather than a tab.
|
|
274
|
-
|
|
296
|
+
//
|
|
297
|
+
// THE ACTIVE FACE IS THE BODY'S SURFACE, from the same knob the body reads
|
|
298
|
+
// (gh#762). The base trigger's `data-[state=active]:bg-background` was right only
|
|
299
|
+
// while the two could not disagree; once a service can retint the body, an active
|
|
300
|
+
// tab still painting `--background` reads as a DIFFERENT box sitting on the panel
|
|
301
|
+
// — which is the "two stacked boxes" complaint in one property. Same utilities
|
|
302
|
+
// group, so tailwind-merge drops the base step instead of stacking two fills.
|
|
303
|
+
card && "flex-none border-[color:hsl(var(--border))] bg-[hsl(var(--tabs-card-background,var(--muted)))] data-[state=active]:bg-[hsl(var(--tabs-panel-background,var(--background)))]",
|
|
275
304
|
card && CARD_FACE[placement]
|
|
276
305
|
),
|
|
277
306
|
children: [
|
|
@@ -285,6 +314,15 @@ function Tabs({
|
|
|
285
314
|
}
|
|
286
315
|
) : null,
|
|
287
316
|
item.label,
|
|
317
|
+
/* @__PURE__ */ jsx(
|
|
318
|
+
TabCount,
|
|
319
|
+
{
|
|
320
|
+
count: item.count,
|
|
321
|
+
overflowCount: item.overflowCount,
|
|
322
|
+
showZero: item.showZero,
|
|
323
|
+
countLabel: item.countLabel
|
|
324
|
+
}
|
|
325
|
+
),
|
|
288
326
|
removable ? (
|
|
289
327
|
// A POINTER SHORTCUT, not a control — and that is measured, not stylistic. antd's own
|
|
290
328
|
// TabNode puts a real <button> beside the role="tab" element; rendered through
|
|
@@ -334,6 +372,7 @@ function Tabs({
|
|
|
334
372
|
"data-placement": placement,
|
|
335
373
|
"data-size": size,
|
|
336
374
|
"data-centered": centered ? "true" : void 0,
|
|
375
|
+
"data-bodied": bodiedCard ? "true" : void 0,
|
|
337
376
|
"data-animated-ink-bar": motion.inkBar ? "true" : "false",
|
|
338
377
|
"data-animated-tab-pane": motion.tabPane ? "true" : "false",
|
|
339
378
|
"data-indicator-size": indicatorSize,
|
|
@@ -363,6 +402,7 @@ function Tabs({
|
|
|
363
402
|
"data-placement": placement,
|
|
364
403
|
"data-size": size,
|
|
365
404
|
"data-centered": centered ? "true" : void 0,
|
|
405
|
+
"data-bodied": bodiedCard ? "true" : void 0,
|
|
366
406
|
"data-animated-ink-bar": motion.inkBar ? "true" : "false",
|
|
367
407
|
"data-animated-tab-pane": motion.tabPane ? "true" : "false",
|
|
368
408
|
"data-indicator-size": indicatorSize,
|
|
@@ -493,7 +533,15 @@ const TabsTrigger = React.forwardRef(
|
|
|
493
533
|
// The line indicator lives in src/styles/navigation-layout.css so it reads --tabs-indicator-*.
|
|
494
534
|
// Selected and focused stay visually distinct (WCAG 2.4.7): selected is a 1px hairline in the
|
|
495
535
|
// border, focused is the 2px ring plus its halo outside it.
|
|
496
|
-
|
|
536
|
+
// `group-data-[variant=line]/tabs-list:flex-none` is the COMPOUND half of gh#757's
|
|
537
|
+
// width fix (gh#762). That fix landed on the `items` renderer only, which is the path
|
|
538
|
+
// the reporter measured; a hand-composed `<TabsList variant="line" className="w-full">`
|
|
539
|
+
// — what a saved-view strip that has to reach its own triggers writes — still ran on
|
|
540
|
+
// the base `flex-1`. Measured on a 1200px strip: three triggers at 394.66px each for
|
|
541
|
+
// labels needing 107 / 95 / 53px, i.e. the SAME defect one API away. antd puts no
|
|
542
|
+
// `flex-grow` on a tab of any `type`, so the rule belongs to the line STRIP rather than
|
|
543
|
+
// to one construction path; the pill strip keeps `flex-1`, which is what fills its box.
|
|
544
|
+
"text-muted-foreground ring-offset-background hover:text-foreground ui-focus-ring data-[state=active]:bg-background data-[state=active]:text-foreground group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:border-primary/25 relative inline-flex flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:flex-none group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:border-e-0 group-data-[variant=line]/tabs-list:border-b-0 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
|
|
497
545
|
// The tier the root was given. Without this the compound form ignored `size` outright.
|
|
498
546
|
triggerSizeClassName(size),
|
|
499
547
|
className
|
|
@@ -46,9 +46,14 @@ const ToggleGroup = React.forwardRef(
|
|
|
46
46
|
}, ref) => {
|
|
47
47
|
const radioSemantics = type === "single" && disallowEmptySelection === true;
|
|
48
48
|
const arrowNav = React.useRef(false);
|
|
49
|
+
const groupElement = React.useRef(null);
|
|
50
|
+
const [inToolbar, setInToolbar] = React.useState(false);
|
|
51
|
+
React.useLayoutEffect(() => {
|
|
52
|
+
setInToolbar(!!groupElement.current?.parentElement?.closest('[role="toolbar"]'));
|
|
53
|
+
}, []);
|
|
49
54
|
const context = React.useMemo(
|
|
50
|
-
() => ({ variant, size, shape, radioSemantics, arrowNav }),
|
|
51
|
-
[variant, size, shape, radioSemantics]
|
|
55
|
+
() => ({ variant, size, shape, radioSemantics, arrowNav, inToolbar }),
|
|
56
|
+
[variant, size, shape, radioSemantics, inToolbar]
|
|
52
57
|
);
|
|
53
58
|
const selectedKeys = React.useMemo(() => toSelectedKeys(value), [value]);
|
|
54
59
|
const defaultSelectedKeys = React.useMemo(() => toSelectedKeys(defaultValue), [defaultValue]);
|
|
@@ -82,6 +87,12 @@ const ToggleGroup = React.forwardRef(
|
|
|
82
87
|
{
|
|
83
88
|
...props,
|
|
84
89
|
...domProps,
|
|
90
|
+
ref: (node) => {
|
|
91
|
+
groupElement.current = node;
|
|
92
|
+
const aria = domProps.ref;
|
|
93
|
+
if (typeof aria === "function") aria(node);
|
|
94
|
+
else if (aria) aria.current = node;
|
|
95
|
+
},
|
|
85
96
|
...type === "single" && !radioSemantics ? { role: "group", "aria-orientation": void 0 } : null,
|
|
86
97
|
onKeyDownCapture: (event) => {
|
|
87
98
|
if (radioSemantics) {
|
|
@@ -134,7 +145,7 @@ const ToggleGroupItem = React.forwardRef(
|
|
|
134
145
|
const isPressed = groupState?.selectedKeys.has(value) ?? false;
|
|
135
146
|
const isSingle = groupState?.selectionMode === "single";
|
|
136
147
|
const radioSemantics = context.radioSemantics === true;
|
|
137
|
-
const rovingTabIndex = radioSemantics && groupState && groupState.selectedKeys.size > 0 ? isPressed ? 0 : -1 : void 0;
|
|
148
|
+
const rovingTabIndex = radioSemantics && !context.inToolbar && groupState && groupState.selectedKeys.size > 0 ? isPressed ? 0 : -1 : void 0;
|
|
138
149
|
return /* @__PURE__ */ jsxs(
|
|
139
150
|
ToggleButton,
|
|
140
151
|
{
|
|
@@ -42,9 +42,18 @@ type TogglePressedFields = {
|
|
|
42
42
|
/** Fired with the NEW pressed state — RAC `onChange`. */
|
|
43
43
|
onPressedChange?: (pressed: boolean) => void;
|
|
44
44
|
};
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Counter pill shared by `Toggle`, `ToggleGroupItem` and `Tabs` items. ACCESSIBLE NAME.
|
|
47
|
+
*
|
|
48
|
+
* `slot` names the hooks the pill emits (`data-slot="<slot>-count"` / `.ui-<slot>-count`) so a
|
|
49
|
+
* third component can reuse the whole construction — the Intl formatting, the `overflowCount`
|
|
50
|
+
* cap, and above all the `aria-hidden` + `sr-only` split that keeps «未対応12» out of the
|
|
51
|
+
* accessible name (gh#734) — without a second counting API growing beside this one (gh#762).
|
|
52
|
+
* It defaults to `toggle`, so every existing call emits the identical markup it always has.
|
|
53
|
+
*/
|
|
54
|
+
declare function useCounterPill({ count, overflowCount, showZero, countLabel, ariaLabel, slot, }: ToggleCountFields & {
|
|
47
55
|
ariaLabel?: string;
|
|
56
|
+
slot?: string;
|
|
48
57
|
}): {
|
|
49
58
|
pill: null;
|
|
50
59
|
resolvedAriaLabel: string | undefined;
|
|
@@ -50,7 +50,8 @@ function useCounterPill({
|
|
|
50
50
|
overflowCount = 99,
|
|
51
51
|
showZero = true,
|
|
52
52
|
countLabel,
|
|
53
|
-
ariaLabel
|
|
53
|
+
ariaLabel,
|
|
54
|
+
slot = "toggle"
|
|
54
55
|
}) {
|
|
55
56
|
const { locale } = useTranslation();
|
|
56
57
|
const visible = count != null && (count !== 0 || showZero);
|
|
@@ -65,7 +66,7 @@ function useCounterPill({
|
|
|
65
66
|
const spoken = countLabel ? `${formatted} ${countLabel}` : formatted;
|
|
66
67
|
return {
|
|
67
68
|
pill: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
68
|
-
/* @__PURE__ */ jsx("span", { "data-slot":
|
|
69
|
+
/* @__PURE__ */ jsx("span", { "data-slot": `${slot}-count`, className: `ui-${slot}-count`, "aria-hidden": "true", children: formatted }),
|
|
69
70
|
/* @__PURE__ */ jsx("span", { className: "sr-only", children: `, ${spoken}` })
|
|
70
71
|
] }),
|
|
71
72
|
resolvedAriaLabel: ariaLabel == null ? ariaLabel : `${ariaLabel}, ${spoken}`
|
|
@@ -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": "28.
|
|
3
|
+
"version": "28.1.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,
|
|
@@ -972,4 +972,19 @@ export type CardTabItemProp = {
|
|
|
972
972
|
tab: React.ReactNode;
|
|
973
973
|
/** Ant Design `disabled` — the tab stays in the strip and in the roving focus, unselectable. */
|
|
974
974
|
disabled?: boolean;
|
|
975
|
+
/**
|
|
976
|
+
* Ant Design `closable` — honoured by `tabProps={{ variant: "editable-card" }}`, where it
|
|
977
|
+
* decides whether THIS tab gets a remove button (gh#757).
|
|
978
|
+
*
|
|
979
|
+
* Without it the strip is all-or-nothing: `editable-card` puts a × on every tab, including the
|
|
980
|
+
* one that is not deletable — a consumer measured 3 of 3 removable where 2 was right. It could
|
|
981
|
+
* not be patched from outside either, because `Card` builds `items` AFTER spreading
|
|
982
|
+
* `...tabProps`, so a hand-written `items` never survived.
|
|
983
|
+
*
|
|
984
|
+
* Default (undefined) stays removable, which is antd's own default and `Tabs`' (`closable !==
|
|
985
|
+
* false`) — this only adds the way to say no.
|
|
986
|
+
*/
|
|
987
|
+
closable?: boolean;
|
|
988
|
+
/** Ant Design `closeIcon` — replaces the default × on this tab's remove button. */
|
|
989
|
+
closeIcon?: React.ReactNode;
|
|
975
990
|
};
|
|
@@ -264,6 +264,32 @@ export type TabItemProp = {
|
|
|
264
264
|
closable?: boolean;
|
|
265
265
|
/** Ant Design `Tab.closeIcon` — replaces the default × on this item's remove button. */
|
|
266
266
|
closeIcon?: React.ReactNode;
|
|
267
|
+
/**
|
|
268
|
+
* The count beside the label — 「未対応 **12**」, the list-screen pattern (gh#762).
|
|
269
|
+
*
|
|
270
|
+
* It is the SAME counter vocabulary `Button` (gh#312) and `Toggle`/`ToggleGroupItem` (gh#734)
|
|
271
|
+
* already publish, drawn by the same helper, so a counted tab and the counted chip under it
|
|
272
|
+
* are one thing at one size and one accessible shape. Formatted with `Intl.NumberFormat` in
|
|
273
|
+
* the active locale — never `String(n)`, never a hand-rolled thousands separator.
|
|
274
|
+
*
|
|
275
|
+
* WHY IT IS A SLOT AND NOT `label: <span>未対応 <Badge count={12} /></span>` (antd's answer):
|
|
276
|
+
* with the number inside the label the PACKAGE owns none of it — not the size, not the gap,
|
|
277
|
+
* not the tone as the tab moves between selected / unselected / disabled — so every consumer
|
|
278
|
+
* lands somewhere different. The slot is what lets the tab state drive the pill.
|
|
279
|
+
*/
|
|
280
|
+
count?: number;
|
|
281
|
+
/** Cap for `count` — beyond it the pill shows `{overflowCount}+` (e.g. `99+`). Default `99`. */
|
|
282
|
+
overflowCount?: number;
|
|
283
|
+
/** Render the pill when `count` is 0. Default `true`, matching Button and Toggle. */
|
|
284
|
+
showZero?: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* Localized description of what the count MEANS, folded into the tab's accessible name so it
|
|
287
|
+
* never announces as label-then-bare-digits. The digits concatenate straight onto the label in
|
|
288
|
+
* the accessibility tree («未対応12», gh#734's defect), so the pill is `aria-hidden` and the
|
|
289
|
+
* spoken clause is an `sr-only` sibling: `countLabel="未対応の課題"` reads
|
|
290
|
+
* 「未対応, 12 未対応の課題」. Already localized when it arrives — pass a `t()` string.
|
|
291
|
+
*/
|
|
292
|
+
countLabel?: string;
|
|
267
293
|
/**
|
|
268
294
|
* Ant Design `Tab.forceRender`. Mounts THIS panel up front and keeps it mounted while another
|
|
269
295
|
* tab is selected, without turning that on for the whole strip the way `destroyOnHidden={false}`
|
|
@@ -404,6 +430,30 @@ export type TabsProp = {
|
|
|
404
430
|
size?: "sm" | "md" | "lg";
|
|
405
431
|
/** Ant Design `centered` — centre the trigger strip on its own inline axis. */
|
|
406
432
|
centered?: boolean;
|
|
433
|
+
/**
|
|
434
|
+
* Draw the PANEL BODY the card strip opens into, so the strip and the panel are ONE object
|
|
435
|
+
* (gh#762): a surface, a border continuous with the rail, and the radius on the two corners
|
|
436
|
+
* away from the strip. The active tab's block-end edge is repainted in that body's own colour,
|
|
437
|
+
* which is what makes it read as opened into the body rather than closed off above it.
|
|
438
|
+
*
|
|
439
|
+
* WHY IT EXISTS. `variant="card"` already repaints the active face's merged edge — antd's
|
|
440
|
+
* `genCardStyle` — but the package shipped no body for it to merge INTO, so the consumer had to
|
|
441
|
+
* supply one and every choice was wrong. Measured on 28.0.0 against a `variant="card"` strip at
|
|
442
|
+
* 1440px: `<Card>` left an 8px gap AND added a second 1px border at a 9.708px radius (two
|
|
443
|
+
* stacked boxes), `<Card variant="borderless">` left the same 8px gap with no line at all, and
|
|
444
|
+
* no box at all left the 8px gap over a transparent panel. The gap is `--tabs-root-gap`, which
|
|
445
|
+
* is correct for every OTHER variant — a body is not a gap tweak, it is the missing half of the
|
|
446
|
+
* card face.
|
|
447
|
+
*
|
|
448
|
+
* HONOURED ONLY BY `card` / `editable-card`, and that is a statement, not a limitation. The
|
|
449
|
+
* pill strip is a free-floating control and the `line` strip's own body is the CARD around it —
|
|
450
|
+
* `<Card tabList>` is that pairing, already built and already joined. Passing `bodied` to any
|
|
451
|
+
* other variant emits no attribute and changes no pixel.
|
|
452
|
+
*
|
|
453
|
+
* OPT-IN, so no strip on 28.0.0 moves. A service that wants every card strip bodied has the
|
|
454
|
+
* `card`-variant defaults as a theme decision, not a prop it must repeat.
|
|
455
|
+
*/
|
|
456
|
+
bodied?: boolean;
|
|
407
457
|
/** Ant Design `tabBarExtraContent`, renamed and made logical. @see TabsExtraProp */
|
|
408
458
|
extra?: TabsExtraProp;
|
|
409
459
|
/**
|
package/dist/props/registry.d.ts
CHANGED
|
@@ -2350,7 +2350,11 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
2350
2350
|
readonly TabsProp: {
|
|
2351
2351
|
readonly group: "navigation";
|
|
2352
2352
|
readonly file: "components/navigation.prop.ts";
|
|
2353
|
-
readonly vocabulary: readonly ["ValueProp", "DefaultValueProp", "OnValueChangeProp", "ClassNameProp", "SizeProp", "ExtraProp"
|
|
2353
|
+
readonly vocabulary: readonly ["ValueProp", "DefaultValueProp", "OnValueChangeProp", "ClassNameProp", "SizeProp", "ExtraProp", {
|
|
2354
|
+
readonly field: "bodied";
|
|
2355
|
+
readonly local: true;
|
|
2356
|
+
readonly reason: "A positive boolean for the card strip's PANEL BODY (gh#762) — an axis on Tabs rather than a `CardTabs` export, because the thing missing was a surface the existing card face already repaints its edge into, not a second component. No vocabulary type: no other component draws a body for its own panel.";
|
|
2357
|
+
}];
|
|
2354
2358
|
};
|
|
2355
2359
|
readonly TabsVariantProp: {
|
|
2356
2360
|
readonly group: "navigation";
|
|
@@ -2414,7 +2418,15 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
2414
2418
|
readonly TabItemProp: {
|
|
2415
2419
|
readonly group: "navigation";
|
|
2416
2420
|
readonly file: "components/navigation.prop.ts";
|
|
2417
|
-
readonly vocabulary: readonly ["ValueProp", "LabelProp", "ChildrenProp", "DisabledProp", "IconProp"
|
|
2421
|
+
readonly vocabulary: readonly ["ValueProp", "LabelProp", "ChildrenProp", "DisabledProp", "IconProp", {
|
|
2422
|
+
readonly field: "count";
|
|
2423
|
+
readonly local: true;
|
|
2424
|
+
readonly reason: "Counter-pill vocabulary shared VERBATIM with ButtonProp and ToggleProp (count/overflowCount/showZero) and drawn by the same `useCounterPill`, so a counted tab, a counted chip and a counted button are one thing — gh#312 / gh#734 / gh#762.";
|
|
2425
|
+
}, {
|
|
2426
|
+
readonly field: "countLabel";
|
|
2427
|
+
readonly local: true;
|
|
2428
|
+
readonly reason: "Localized unit folded into the tab's accessible name («未対応, 12 件の課題»), so the digits never concatenate onto the label — gh#734's measured defect, same field name as Button/Toggle.";
|
|
2429
|
+
}];
|
|
2418
2430
|
};
|
|
2419
2431
|
readonly FormFieldArrayProp: {
|
|
2420
2432
|
readonly group: "form";
|
package/dist/props/registry.js
CHANGED
|
@@ -2735,7 +2735,12 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
2735
2735
|
"OnValueChangeProp",
|
|
2736
2736
|
"ClassNameProp",
|
|
2737
2737
|
"SizeProp",
|
|
2738
|
-
"ExtraProp"
|
|
2738
|
+
"ExtraProp",
|
|
2739
|
+
{
|
|
2740
|
+
field: "bodied",
|
|
2741
|
+
local: true,
|
|
2742
|
+
reason: "A positive boolean for the card strip's PANEL BODY (gh#762) \u2014 an axis on Tabs rather than a `CardTabs` export, because the thing missing was a surface the existing card face already repaints its edge into, not a second component. No vocabulary type: no other component draws a body for its own panel."
|
|
2743
|
+
}
|
|
2739
2744
|
]
|
|
2740
2745
|
},
|
|
2741
2746
|
TabsVariantProp: {
|
|
@@ -2803,7 +2808,23 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
2803
2808
|
TabItemProp: {
|
|
2804
2809
|
group: "navigation",
|
|
2805
2810
|
file: "components/navigation.prop.ts",
|
|
2806
|
-
vocabulary: [
|
|
2811
|
+
vocabulary: [
|
|
2812
|
+
"ValueProp",
|
|
2813
|
+
"LabelProp",
|
|
2814
|
+
"ChildrenProp",
|
|
2815
|
+
"DisabledProp",
|
|
2816
|
+
"IconProp",
|
|
2817
|
+
{
|
|
2818
|
+
field: "count",
|
|
2819
|
+
local: true,
|
|
2820
|
+
reason: "Counter-pill vocabulary shared VERBATIM with ButtonProp and ToggleProp (count/overflowCount/showZero) and drawn by the same `useCounterPill`, so a counted tab, a counted chip and a counted button are one thing \u2014 gh#312 / gh#734 / gh#762."
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
field: "countLabel",
|
|
2824
|
+
local: true,
|
|
2825
|
+
reason: "Localized unit folded into the tab's accessible name (\xAB\u672A\u5BFE\u5FDC, 12 \u4EF6\u306E\u8AB2\u984C\xBB), so the digits never concatenate onto the label \u2014 gh#734's measured defect, same field name as Button/Toggle."
|
|
2826
|
+
}
|
|
2827
|
+
]
|
|
2807
2828
|
},
|
|
2808
2829
|
FormFieldArrayProp: {
|
|
2809
2830
|
group: "form",
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
package/scripts/ui-audit.mjs
CHANGED
|
@@ -159,7 +159,25 @@ const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
|
|
|
159
159
|
const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
*
|
|
162
|
+
* Primitives whose `asChild` hands over THE VISUAL BOX, so the borrowed element cannot be a
|
|
163
|
+
* `<Button>` — wrapping it would paint a button where a card belongs.
|
|
164
|
+
*
|
|
165
|
+
* This set is deliberately tiny and deliberately NOT "anything with asChild". The first cut of
|
|
166
|
+
* this exemption was the broad version, and measuring it against a real consumer showed what that
|
|
167
|
+
* costs: of 39 `no-raw-button` findings across its three SPAs, it silenced 11 — and
|
|
168
|
+
* every single one was a BEHAVIOUR trigger, not a box:
|
|
169
|
+
*
|
|
170
|
+
* DropdownMenuTrigger · SheetTrigger · CollapsibleTrigger · PopoverTrigger · TooltipTrigger
|
|
171
|
+
*
|
|
172
|
+
* A Radix trigger supplies behaviour and no styling, so `<DropdownMenuTrigger asChild><Button>`
|
|
173
|
+
* is not only legal, it is the better answer — the package's button, correctly styled, keyboard
|
|
174
|
+
* and screen-reader shaped. Exempting those threw away 11 legitimate nudges to fix 0 real
|
|
175
|
+
* findings. Adding a name here means asserting that primitive paints the box itself.
|
|
176
|
+
*/
|
|
177
|
+
const ASCHILD_BOX_PRIMITIVES = new Set(["Card"]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is the raw tag at `index` the slot child of a primitive that hands over its BOX?
|
|
163
181
|
*
|
|
164
182
|
* `<Card asChild hoverable><button onClick={…}>` is not a violation — it is the shape this
|
|
165
183
|
* package PRESCRIBES. `Card.asChild`'s own docblock says so in as many words:
|
|
@@ -175,10 +193,11 @@ const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
|
|
|
175
193
|
* (godx-jp/shoots-gemba#8). A package that forbids its own prescription leaves the consumer no
|
|
176
194
|
* legal move at all, which is exactly what #740 was opened about.
|
|
177
195
|
*
|
|
178
|
-
* Walks BACKWARDS to the nearest preceding `>` and asks whether the tag it closes is
|
|
179
|
-
*
|
|
196
|
+
* Walks BACKWARDS to the nearest preceding `>` and asks whether the tag it closes is one of
|
|
197
|
+
* ASCHILD_BOX_PRIMITIVES carrying `asChild`. That covers both spellings prettier produces —
|
|
180
198
|
* `<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
|
|
199
|
+
* `</Card>` or `<div>` before the tag exempts nothing, because neither starts a component, and
|
|
200
|
+
* `<DropdownMenuTrigger asChild>` exempts nothing either, because a trigger paints no box.
|
|
182
201
|
*/
|
|
183
202
|
const isAsChildSlot = (content, index) => {
|
|
184
203
|
const before = content.slice(0, index).replace(/\s+$/, "");
|
|
@@ -186,8 +205,10 @@ const isAsChildSlot = (content, index) => {
|
|
|
186
205
|
const openStart = before.lastIndexOf("<", before.length - 1);
|
|
187
206
|
if (openStart === -1) return false;
|
|
188
207
|
const openTag = before.slice(openStart);
|
|
189
|
-
// A component, not `</Card>` and not a lowercase host element
|
|
190
|
-
|
|
208
|
+
// A component, not `</Card>` and not a lowercase host element…
|
|
209
|
+
const name = /^<([A-Z][\w.]*)\b/.exec(openTag)?.[1];
|
|
210
|
+
// …and one that hands over its BOX, not merely its behaviour. See ASCHILD_BOX_PRIMITIVES.
|
|
211
|
+
return name !== undefined && ASCHILD_BOX_PRIMITIVES.has(name) && /\basChild\b/.test(openTag);
|
|
191
212
|
};
|
|
192
213
|
|
|
193
214
|
/** `rawTag`, minus the matches that are an `asChild` primitive's borrowed element. */
|
|
@@ -644,10 +665,19 @@ const RULES = [
|
|
|
644
665
|
/*
|
|
645
666
|
* A lucide element rendered where NOTHING will size it.
|
|
646
667
|
*
|
|
647
|
-
* A lucide component ships `width="24" height="24"`, and
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
668
|
+
* A lucide component ships `width="24" height="24"`, and this library overrides that in
|
|
669
|
+
* **31** selectors (counted 2026-09-19 across `src/styles/*.css`; this comment used to say
|
|
670
|
+
* "exactly four" and name them, which had drifted far enough to cause gh#755). Most are
|
|
671
|
+
* component-internal — a consumer cannot put a glyph inside `.ui-rating-star` — so the number
|
|
672
|
+
* is not the exemption list and never was.
|
|
673
|
+
*
|
|
674
|
+
* What a consumer CAN reach is a SLOT, and that is what `iconSizingRanges` keys on: the slot's
|
|
675
|
+
* NAME at the call site, not the selector that ends up sizing it. That is why the list there
|
|
676
|
+
* is prop names rather than CSS, and why it must cover both spellings a slot takes —
|
|
677
|
+
* `icon={<Users />}` and `icon: <Users />` inside an items array (gh#755).
|
|
678
|
+
*
|
|
679
|
+
* Outside a slot — in a `Text`, a table cell, an `<a>`, a `Flex` — the glyph draws at 24px
|
|
680
|
+
* beside 14px type. It is the one defect class that is
|
|
651
681
|
* INVISIBLE in review: the JSX is correct, the import is correct, and only the screen is
|
|
652
682
|
* wrong. A consumer swept one app and found 38 (gh#712).
|
|
653
683
|
*
|
|
@@ -1094,9 +1124,52 @@ function iconSizingRanges(source) {
|
|
|
1094
1124
|
const close = matchBracket(source, open);
|
|
1095
1125
|
ranges.push([open, close < 0 ? source.length : close + 1]);
|
|
1096
1126
|
}
|
|
1127
|
+
/*
|
|
1128
|
+
* The SAME slots, written as an object property instead of a JSX prop (gh#755).
|
|
1129
|
+
*
|
|
1130
|
+
* <Tabs items={saved.map((s) => ({ icon: s.shared ? <Users /> : undefined }))} />
|
|
1131
|
+
*
|
|
1132
|
+
* `Tabs` wraps `items[].icon` in `<span class="ui-tabs-trigger-icon">`, and
|
|
1133
|
+
* `navigation-layout.css` sizes `.ui-tabs-trigger-icon svg` — so the glyph IS measured, by the
|
|
1134
|
+
* same component that owns the slot. But the prop form above requires `=`, so `icon:` matched
|
|
1135
|
+
* nothing and the call site was told to size a box the design system owns — the opposite of
|
|
1136
|
+
* what CONSUMER-RULES asks for.
|
|
1137
|
+
*
|
|
1138
|
+
* A property value is not bracketed like `={...}`, so the range runs to the `,` or `}` that
|
|
1139
|
+
* closes it at depth zero. Strings and nested brackets are skipped so a comma inside
|
|
1140
|
+
* `style={{a: 1, b: 2}}` or inside a string does not end it early.
|
|
1141
|
+
*/
|
|
1142
|
+
for (const m of source.matchAll(
|
|
1143
|
+
/\b(?:icon|leading|trailing|mark|indicator|avatar|prefix|suffix|addonBefore|addonAfter)\s*:/g,
|
|
1144
|
+
)) {
|
|
1145
|
+
const from = m.index + m[0].length;
|
|
1146
|
+
ranges.push([from, objectPropertyValueEnd(source, from)]);
|
|
1147
|
+
}
|
|
1097
1148
|
return ranges;
|
|
1098
1149
|
}
|
|
1099
1150
|
|
|
1151
|
+
/** Where an object property's value ends: the `,` or `}` closing it at depth zero. */
|
|
1152
|
+
function objectPropertyValueEnd(source, from) {
|
|
1153
|
+
let depth = 0;
|
|
1154
|
+
for (let i = from; i < source.length; i += 1) {
|
|
1155
|
+
const c = source[i];
|
|
1156
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
1157
|
+
// Skip to the closing quote of the same kind, honouring backslash escapes.
|
|
1158
|
+
const quote = c;
|
|
1159
|
+
i += 1;
|
|
1160
|
+
while (i < source.length && source[i] !== quote) i += source[i] === "\\" ? 2 : 1;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (c === "(" || c === "[" || c === "{") depth += 1;
|
|
1164
|
+
else if (c === ")" || c === "]") depth -= 1;
|
|
1165
|
+
else if (c === "}") {
|
|
1166
|
+
if (depth === 0) return i;
|
|
1167
|
+
depth -= 1;
|
|
1168
|
+
} else if (c === "," && depth === 0) return i;
|
|
1169
|
+
}
|
|
1170
|
+
return source.length;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1100
1173
|
/** A lucide element that no rule, no slot and no author-supplied size will ever measure. */
|
|
1101
1174
|
function* lucideGlyphMatches(source) {
|
|
1102
1175
|
const names = lucideLocalNames(source);
|