@godxjp/ui 31.3.0 → 31.4.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.
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 31.3.0.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 31.4.0.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -56,10 +56,10 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
56
56
  its `importPath`, and its examples. Fetch only the handful you picked in step 1.
57
57
  3. `rules.json` — 50 cardinal rules. The ones about raw HTML and hardcoded colour are not
58
58
  style advice.
59
- 4. `tokens.json` — 2075 design tokens, each tagged with its `tier`. **If you were handed a
59
+ 4. `tokens.json` — 2080 design tokens, each tagged with its `tier`. **If you were handed a
60
60
  brand, read the 211 `foundation` entries first** — `--primary`, `--background`,
61
61
  `--radius`, `--font-size-base` are the handful everything else derives from. The
62
- 1761 `component` entries are per-part knobs; reach for one only when a role is
62
+ 1765 `component` entries are per-part knobs; reach for one only when a role is
63
63
  right everywhere except one component.
64
64
  5. `anti-ai-tells.json` — 26 shapes that make generated UI look generated, each with the
65
65
  fix. Read before you reach for a gradient hero or a wall of coloured chips.
@@ -144,8 +144,8 @@ has stopped following the brand.
144
144
  | `tier` | count | what it is | set it? |
145
145
  |---|---|---|---|
146
146
  | `foundation` | 211 | the seeds — `--primary`, `--background`, `--foreground`, `--radius`, `--font-size-base`, `--shadow-color`. Everything below is derived from these | **yes — this is the main road.** Handed a brand colour, this is where it goes: `:root { --primary: <H> <S>% <L>%; }` (HSL components, no `hsl()` wrapper) |
147
- | `semantic` | 103 | named roles that follow the seeds — `--ring`, `--text-link`, `--primary-hover`, `--overlay-background` | only when the seed is right and ONE role must differ. That role then stops following a later brand change |
148
- | `component` | 1761 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
147
+ | `semantic` | 104 | named roles that follow the seeds — `--ring`, `--text-link`, `--primary-hover`, `--overlay-background` | only when the seed is right and ONE role must differ. That role then stops following a later brand change |
148
+ | `component` | 1765 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
149
149
 
150
150
  A token whose `value` is `initial` is not empty and not broken: `initial` is the guaranteed-invalid
151
151
  value, so the real default is computed where the element paints it. Set it and yours wins.
@@ -24,6 +24,11 @@
24
24
  "name": "onValueChange",
25
25
  "type": "(value: string) => void"
26
26
  },
27
+ {
28
+ "description": "Strip-only mode (gh#1021): the tabs drive content OUTSIDE the strip. No tabpanel is rendered, and the selected tab's `aria-controls` points at this id, which is the consumer's own region (give it `role=\"region\"` and a name). Content there is never remounted on a tab switch, so a typed draft survives. With the compound form it applies to triggers without a declared `TabsContent` (a declared panel wins). A pure filter with NO region to point at is not a tab strip; use `Segmented`. The root's `aria-label`/`aria-labelledby` name the `role=\"tablist\"` (gh#1020), and a compound `TabsTrigger` takes `count`/`overflowCount`/`showZero`/`countLabel`, drawn exactly like `items[].count`.",
29
+ "name": "controls",
30
+ "type": "IdProp"
31
+ },
27
32
  {
28
33
  "defaultValue": "\"default\"",
29
34
  "description": "Trigger-strip appearance — this is Ant Design's `type` under the library's own `variant` vocabulary. `default` is the pill strip (antd has no equivalent). `line` is UNDERLINE-ONLY: the selected trigger gets no ring or card border at all — only the token-owned 2px primary bar (--tabs-indicator-{background,size,offset}) — so the `:focus-visible` keyboard ring stays visible and clearly distinct from selection. `card` gives each tab a boxed face on a rail (--tabs-card-*). `editable-card` is `card` plus the add button and per-tab remove shortcut, and needs `onEdit` to do anything. With `items`, the variant is forwarded to the list; when composing manually, pass the same value to `<TabsList variant=\"line\">`.",
@@ -7880,6 +7880,11 @@
7880
7880
  "name": "onValueChange",
7881
7881
  "type": "(value: string) => void"
7882
7882
  },
7883
+ {
7884
+ "description": "Strip-only mode (gh#1021): the tabs drive content OUTSIDE the strip. No tabpanel is rendered, and the selected tab's `aria-controls` points at this id, which is the consumer's own region (give it `role=\"region\"` and a name). Content there is never remounted on a tab switch, so a typed draft survives. With the compound form it applies to triggers without a declared `TabsContent` (a declared panel wins). A pure filter with NO region to point at is not a tab strip; use `Segmented`. The root's `aria-label`/`aria-labelledby` name the `role=\"tablist\"` (gh#1020), and a compound `TabsTrigger` takes `count`/`overflowCount`/`showZero`/`countLabel`, drawn exactly like `items[].count`.",
7885
+ "name": "controls",
7886
+ "type": "IdProp"
7887
+ },
7883
7888
  {
7884
7889
  "defaultValue": "\"default\"",
7885
7890
  "description": "Trigger-strip appearance — this is Ant Design's `type` under the library's own `variant` vocabulary. `default` is the pill strip (antd has no equivalent). `line` is UNDERLINE-ONLY: the selected trigger gets no ring or card border at all — only the token-owned 2px primary bar (--tabs-indicator-{background,size,offset}) — so the `:focus-visible` keyboard ring stays visible and clearly distinct from selection. `card` gives each tab a boxed face on a rail (--tabs-card-*). `editable-card` is `card` plus the add button and per-tab remove shortcut, and needs `onEdit` to do anything. With `items`, the variant is forwarded to the list; when composing manually, pass the same value to `<TabsList variant=\"line\">`.",
package/agent/index.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "components": 176,
5
5
  "patterns": 21,
6
6
  "rules": 50,
7
- "tokens": 2075,
7
+ "tokens": 2080,
8
8
  "vocabulary": 14
9
9
  },
10
10
  "files": [
@@ -48,19 +48,19 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v31.3.0/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v31.4.0/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
55
55
  "tokenTiers": {
56
56
  "component": "per-part knobs, --{component}-{part}-{property}; usually leave these alone",
57
57
  "counts": {
58
- "component": 1761,
58
+ "component": 1765,
59
59
  "foundation": 211,
60
- "semantic": 103
60
+ "semantic": 104
61
61
  },
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "31.3.0"
65
+ "version": "31.4.0"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
- > A Japanese-enterprise React design system: 176 components, 2075 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 31.3.0.
3
+ > A Japanese-enterprise React design system: 176 components, 2080 design tokens,
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 31.4.0.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@31.3.0`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@31.4.0`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -18,7 +18,7 @@ that can only fetch URLs.
18
18
  - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 47 KB — all 176 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
19
19
  - [components/&lt;Name&gt;.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components/Select.json): one file per component (1 KB–35 KB, median 6 KB). Read the index, then fetch only the ones you chose — this is the selective route, and the reason you do not need the blob.
20
20
  - [components.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components.json): 1.2 MB — every entry in one file. Most URL fetchers truncate a response this size without saying so; prefer the per-component files.
21
- - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1761 `component` knobs.
21
+ - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 104 `semantic` roles, 1765 `component` knobs.
22
22
  - [vocabulary.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/vocabulary.json): the controlled prop vocabulary — which prop name means what, across every component.
23
23
  - [rules.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/rules.json): 50 cardinal rules.
24
24
  - [anti-ai-tells.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/anti-ai-tells.json): 26 shapes that make generated UI look generated, each with its fix.
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v31.3.0/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v31.4.0/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
package/agent/tokens.json CHANGED
@@ -1625,6 +1625,12 @@
1625
1625
  "tier": "semantic",
1626
1626
  "value": "none"
1627
1627
  },
1628
+ {
1629
+ "description": "The widest share of the header row the actions (`extra`) keep on ONE row before they wrap (gh#1025); the title keeps the rest. `initial` — default 60% at the call site.",
1630
+ "name": "--page-header-extra-max-measure",
1631
+ "tier": "semantic",
1632
+ "value": "initial"
1633
+ },
1628
1634
  {
1629
1635
  "description": "PageContainer `toolbar` — the fixed chrome band between the header and the (scrolling) body. ALL THREE knobs are quiet by default (rule #44): background — `transparent`, i.e. the band shows the page ground exactly as it did before the knob existed. It is bound at `:root` and NOT `initial`, unlike the divider below, because the default is a plain CSS keyword rather than another role token: there is nothing for a scoped [data-tenant]/.dark override to re-resolve, so the `initial` + call-site-fallback dance would buy nothing and only hide the default from anyone reading this file. A service that wants the band to read as its own surface — the design that asked for this knob puts a chat channel's workflow rail on the card ground — sets `--page-toolbar-background: hsl(var(--card));` ONCE in its theme. Never `className=\"bg-card\"` at the call site: that is hand-laid page chrome, it paints only under `variant=\"flush\"` gutters correctly by accident, and it cannot be re-themed per tenant. pad-block 0 — the band's ONLY breathing room, and the only one it should ever have. The band is chrome, so it sits FLUSH against the header above and the body below (src/styles/layout.css cancels the container's --page-band-gap from the band itself); there is deliberately no outside space to tune, because a ruled, painted band floating in a void divides nothing. A service that opts into the background or the divider gives the band its inset HERE (`--page-toolbar-pad-block: var(--space-2)`) instead of padding the strip at the call site. Still 0 by default: a transparent band is not a surface and has no inside to breathe, and under `fill` every pixel of it comes straight off the scroll viewport the slot exists to protect. divider — declared `initial` so the CALL SITE can fall back to --page-header-divider (`var(--page-toolbar-divider, var(--page-header-divider))`). One theme declaration therefore rules the whole page chrome consistently, and a scoped [data-tenant]/.dark override of the header divider still reaches the band — a `:root` binding would freeze it at the :root value (docs/TOKENS.md · \"Role-mirror knobs MUST be `initial`\"). Set `--page-toolbar-divider: none` to silence just this band.",
1630
1636
  "name": "--page-toolbar-background",
@@ -9384,7 +9390,7 @@
9384
9390
  "value": "initial"
9385
9391
  },
9386
9392
  {
9387
- "description": "Counter pill beside the label (gh#602). Geometry matches Button/Toggle count pills; colour does not. A Badge `secondary` fill is `--muted`, which is byte-identical to `--segmented-track-background`, so a consumer-nested Badge is 1.00:1 on unselected items. The pill therefore wears an opaque `--primary` / `--primary-foreground` pair that clears WCAG 2.2 SC 1.4.11 against both the recessed track (`--muted`) and the lifted slab (`--background`) in light and dark — measured in segmented-count-contrast.test.ts. antd has no `count` on SegmentedItemType; this knob exists because every faceted filter in the wild needs one and nesting Badge in `label` was the only API until now.",
9393
+ "description": "Counter pill beside the label (gh#602). Geometry matches Button/Toggle count pills; COLOUR follows `Tabs` (gh#1019): resting = `--background` / `--muted-foreground`, byte-identical to a resting `.ui-tabs-count` on a CARD tab — the Tabs rule for a pill whose host surface is `--muted`, which the Segmented track is (a `--muted` pill there is 1.00:1, shape gone). The selected segment's pill = `--primary` / `--primary-foreground`, identical to the active tab's. gh#602 had painted EVERY pill primary — four identical blobs that dropped the one thing a count pill in a one-of-N control can say, \"this is the chosen one\". The resting number is held to SC 1.4.3 (≥ 4.5:1 in both themes, measured in segmented-count-tabs-convention-1019.test.ts); it is not the selection indicator, so its fill only has to stand off the track, not reach 3:1. antd has no `count` on SegmentedItemType; this knob exists because every faceted filter in the wild needs one and nesting Badge in `label` was the only API until now.",
9388
9394
  "name": "--segmented-count-min-width",
9389
9395
  "tier": "component",
9390
9396
  "value": "var(--button-count-min-width)"
@@ -9414,17 +9420,41 @@
9414
9420
  "value": "var(--space-1)"
9415
9421
  },
9416
9422
  {
9417
- "description": "Segmented (one-of-N control) component tokens. EVERY VALUE BELOW IS A PORT OF AN ESTABLISHED SEGMENTED SPEC, NOT A CHOICE. The geometry and the role assignments come from the widely-implemented enterprise Segmented control, transcribed as ratios rather than as pixels: track padding: the bold line width → 2px track background: the page's recessed neutral item colour: the label ink · hover / selected colour: the body ink item hover fill: the lighter neutral · item active fill: the heavier neutral item selected fill: the elevated surface label height = control height − track padding × 2 → 32 − 4 = 28 label padding-inline = control padding-x − border width → 12 − 1 = 11 icon gap = the small margin step / 2 → 6 THE ROLE PORTS ARE THE ONES `Tabs` ALREADY MADE. TabsList is `bg-muted` and an active TabsTrigger is `bg-background` + `shadow-sm` — the same track-and-slab pair, so a Segmented and a default Tabs strip read as one control family in both themes rather than two near-misses. That matters more than reproducing the source dark ramp literally: that ramp's dark track is BLACK with a lighter slab on it, while this palette's dark `--muted` sits above `--background`, so a literal port would invert the pairing relative to every Tabs strip on the same page. NOT ONE OF THEM READS THE BRAND SEED. The spec's own derivation produces identical Segmented values for its default seed and for this system's `#0071bd` — the control is neutral by construction, and the only brand ink it can carry is the focus mark, which styles/focus-ring.css owns. So the ports below are ROLE references (the same neutrals this system already names), never copied hex. The disabled state is the ONE place this departs. The source recolours to a disabled ink and changes nothing else; this system disables every control with the one `--disabled-opacity` knob, and a single library-wide answer outranks a per-component one. The other thing NOT ported is the sliding thumb: it exists to animate between items, and the implementations that have one remove the `-item-selected` class while it runs, which means the selected state lives in two places at once. A static selected slab reads identically at rest and cannot desynchronise.",
9423
+ "description": "default = hsl(var(--background)) at the call site",
9418
9424
  "name": "--segmented-count-background",
9419
9425
  "tier": "component",
9420
9426
  "value": "initial"
9421
9427
  },
9422
9428
  {
9423
- "description": "Segmented (one-of-N control) component tokens. EVERY VALUE BELOW IS A PORT OF AN ESTABLISHED SEGMENTED SPEC, NOT A CHOICE. The geometry and the role assignments come from the widely-implemented enterprise Segmented control, transcribed as ratios rather than as pixels: track padding: the bold line width → 2px track background: the page's recessed neutral item colour: the label ink · hover / selected colour: the body ink item hover fill: the lighter neutral · item active fill: the heavier neutral item selected fill: the elevated surface label height = control height − track padding × 2 → 32 − 4 = 28 label padding-inline = control padding-x − border width → 12 − 1 = 11 icon gap = the small margin step / 2 → 6 THE ROLE PORTS ARE THE ONES `Tabs` ALREADY MADE. TabsList is `bg-muted` and an active TabsTrigger is `bg-background` + `shadow-sm` — the same track-and-slab pair, so a Segmented and a default Tabs strip read as one control family in both themes rather than two near-misses. That matters more than reproducing the source dark ramp literally: that ramp's dark track is BLACK with a lighter slab on it, while this palette's dark `--muted` sits above `--background`, so a literal port would invert the pairing relative to every Tabs strip on the same page. NOT ONE OF THEM READS THE BRAND SEED. The spec's own derivation produces identical Segmented values for its default seed and for this system's `#0071bd` — the control is neutral by construction, and the only brand ink it can carry is the focus mark, which styles/focus-ring.css owns. So the ports below are ROLE references (the same neutrals this system already names), never copied hex. The disabled state is the ONE place this departs. The source recolours to a disabled ink and changes nothing else; this system disables every control with the one `--disabled-opacity` knob, and a single library-wide answer outranks a per-component one. The other thing NOT ported is the sliding thumb: it exists to animate between items, and the implementations that have one remove the `-item-selected` class while it runs, which means the selected state lives in two places at once. A static selected slab reads identically at rest and cannot desynchronise.",
9429
+ "description": "default = hsl(var(--muted-foreground)) at the call site",
9424
9430
  "name": "--segmented-count-color",
9425
9431
  "tier": "component",
9426
9432
  "value": "initial"
9427
9433
  },
9434
+ {
9435
+ "description": "default = hsl(var(--primary)) at the call site",
9436
+ "name": "--segmented-count-selected-background",
9437
+ "tier": "component",
9438
+ "value": "initial"
9439
+ },
9440
+ {
9441
+ "description": "default = 100% at the call site",
9442
+ "name": "--segmented-count-selected-background-alpha",
9443
+ "tier": "component",
9444
+ "value": "initial"
9445
+ },
9446
+ {
9447
+ "description": "default = hsl(var(--primary-foreground)) at the call site",
9448
+ "name": "--segmented-count-selected-color",
9449
+ "tier": "component",
9450
+ "value": "initial"
9451
+ },
9452
+ {
9453
+ "description": "default = var(--stroke-hairline) at the call site",
9454
+ "name": "--segmented-count-forced-outline-width",
9455
+ "tier": "component",
9456
+ "value": "initial"
9457
+ },
9428
9458
  {
9429
9459
  "description": "Rule weight, both orientations and both halves of a labelled rule. default = var(--stroke-hairline), resolved at the call site (gh#906)",
9430
9460
  "name": "--separator-rule-size",
@@ -497,6 +497,7 @@ function Upload({
497
497
  disabled,
498
498
  onClick: openPicker,
499
499
  "aria-label": triggerAriaLabel,
500
+ className: "ui-upload-trigger",
500
501
  children: [
501
502
  /* @__PURE__ */ jsx(
502
503
  TriggerIcon,
@@ -553,7 +554,17 @@ function Upload({
553
554
  hiddenInput,
554
555
  liveRegion,
555
556
  rejection && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-error-strong", children: rejection }),
556
- /* @__PURE__ */ jsx(Button, { type: "button", disabled, variant: triggerVariant, onClick: openPicker, children: children ?? t("dataEntry.upload.addImage") }),
557
+ /* @__PURE__ */ jsx(
558
+ Button,
559
+ {
560
+ type: "button",
561
+ disabled,
562
+ variant: triggerVariant,
563
+ onClick: openPicker,
564
+ className: "ui-upload-trigger",
565
+ children: children ?? t("dataEntry.upload.addImage")
566
+ }
567
+ ),
557
568
  list
558
569
  ] });
559
570
  }
@@ -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, bodied, 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, controls, listClassName, contentClassName, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...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
  /**
@@ -24,7 +24,7 @@ export declare const TabsTrigger: React.ForwardRefExoticComponent<Omit<Omit<Reac
24
24
  value: string;
25
25
  /** RAC `isDisabled`; ALSO written to the button so `disabled:` utilities keep matching. */
26
26
  disabled?: boolean;
27
- } & React.RefAttributes<HTMLButtonElement>>;
27
+ } & Pick<TabItemProp, "count" | "countLabel" | "overflowCount" | "showZero"> & React.RefAttributes<HTMLButtonElement>>;
28
28
  export declare const TabsContent: React.ForwardRefExoticComponent<Omit<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "value"> & {
29
29
  /** The panel's key — must match a `TabsTrigger value`. RAC `id`. */
30
30
  value: string;
@@ -41,11 +41,14 @@ function TabCount({
41
41
  const { pill } = useCounterPill({ count, overflowCount, showZero, countLabel, slot: "tabs" });
42
42
  return pill;
43
43
  }
44
+ const NO_LIST_LABEL = {};
44
45
  const TabsFrameContext = React.createContext({
45
46
  orientation: "horizontal",
46
47
  selectionSuppressed: false,
47
48
  size: "md",
48
- panels: null
49
+ panels: null,
50
+ controls: void 0,
51
+ listLabel: NO_LIST_LABEL
49
52
  });
50
53
  function resolveFallbackTabValue(items, requested) {
51
54
  if (!items || items.length === 0) return requested;
@@ -122,9 +125,12 @@ function Tabs({
122
125
  indicator,
123
126
  moreIcon,
124
127
  onTabScroll,
128
+ controls,
125
129
  listClassName,
126
130
  contentClassName,
127
131
  children,
132
+ "aria-label": ariaLabel,
133
+ "aria-labelledby": ariaLabelledby,
128
134
  ...props
129
135
  }) {
130
136
  const { t } = useTranslation();
@@ -151,9 +157,20 @@ function Tabs({
151
157
  return next;
152
158
  });
153
159
  }, []);
160
+ const listLabel = React.useMemo(
161
+ () => ariaLabel === void 0 && ariaLabelledby === void 0 ? NO_LIST_LABEL : { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby },
162
+ [ariaLabel, ariaLabelledby]
163
+ );
154
164
  const frame = React.useMemo(
155
- () => ({ orientation: resolvedOrientation, selectionSuppressed, size, panels }),
156
- [resolvedOrientation, selectionSuppressed, size, panels]
165
+ () => ({
166
+ orientation: resolvedOrientation,
167
+ selectionSuppressed,
168
+ size,
169
+ panels,
170
+ controls,
171
+ listLabel
172
+ }),
173
+ [resolvedOrientation, selectionSuppressed, size, panels, controls, listLabel]
157
174
  );
158
175
  const editable = variant === "editable-card";
159
176
  const collapsible = items != null && overflow === "menu";
@@ -444,7 +461,7 @@ function Tabs({
444
461
  ] }) : null,
445
462
  extraEnd ? /* @__PURE__ */ jsx("div", { "data-slot": "tabs-extra", "data-side": "end", className: "ui-tabs-extra", children: extraEnd }) : null
446
463
  ] }) : list,
447
- items.map((item) => {
464
+ controls !== void 0 ? null : items.map((item) => {
448
465
  const keepMounted = !destroyOnHidden || item.forceRender === true;
449
466
  return /* @__PURE__ */ jsx(
450
467
  TabsContent,
@@ -465,6 +482,9 @@ function Tabs({
465
482
  }
466
483
  const TabsList = React.forwardRef(
467
484
  ({ className, variant = "default", loop: _loop, children, ...props }, ref) => {
485
+ const { listLabel } = React.useContext(TabsFrameContext);
486
+ const ownName = props["aria-label"] !== void 0 || props["aria-labelledby"] !== void 0;
487
+ const name = ownName ? { "aria-label": props["aria-label"], "aria-labelledby": props["aria-labelledby"] } : listLabel;
468
488
  const listRef = React.useRef(null);
469
489
  const setListRef = React.useCallback(
470
490
  (node) => {
@@ -479,6 +499,8 @@ const TabsList = React.forwardRef(
479
499
  AriaTabList,
480
500
  {
481
501
  ref: setListRef,
502
+ "aria-label": name["aria-label"],
503
+ "aria-labelledby": name["aria-labelledby"],
482
504
  className: cn(
483
505
  // `min-w-0 max-w-full` let the list shrink to (and never exceed) whatever width its
484
506
  // ancestors actually give it instead of forcing them wider; horizontal orientation then
@@ -512,10 +534,22 @@ const TabsList = React.forwardRef(
512
534
  );
513
535
  TabsList.displayName = "TabsList";
514
536
  const TabsTrigger = React.forwardRef(
515
- ({ className, value, disabled, children, onKeyDown, onClick, ...props }, ref) => {
516
- const { orientation, selectionSuppressed, size, panels } = React.useContext(TabsFrameContext);
537
+ ({
538
+ className,
539
+ value,
540
+ disabled,
541
+ count,
542
+ overflowCount,
543
+ showZero,
544
+ countLabel,
545
+ children,
546
+ onKeyDown,
547
+ onClick,
548
+ ...props
549
+ }, ref) => {
550
+ const { orientation, selectionSuppressed, size, panels, controls } = React.useContext(TabsFrameContext);
517
551
  const controlsAPanel = panels === null || panels.has(value);
518
- return /* @__PURE__ */ jsx(
552
+ return /* @__PURE__ */ jsxs(
519
553
  AriaTab,
520
554
  {
521
555
  ref,
@@ -565,8 +599,10 @@ const TabsTrigger = React.forwardRef(
565
599
  // here for BOTH states, exactly as Radix did.
566
600
  "data-state": renderProps.isSelected && !selectionSuppressed ? "active" : "inactive",
567
601
  "aria-selected": renderProps.isSelected && !selectionSuppressed,
568
- // RAC writes this unconditionally; see `TabsFrame.panels` (gh#643).
569
- "aria-controls": controlsAPanel ? domProps["aria-controls"] : void 0,
602
+ // RAC writes this unconditionally; see `TabsFrame.panels` (gh#643). With no panel
603
+ // declared, a strip-only root's `controls` region takes its place — on the SELECTED
604
+ // tab only, the same rule RAC applies to a real panel (gh#1021).
605
+ "aria-controls": controlsAPanel ? domProps["aria-controls"] : renderProps.isSelected && !selectionSuppressed ? controls : void 0,
570
606
  "data-orientation": orientation,
571
607
  disabled,
572
608
  // CHAINED, not spread — the same reason `onKeyDown` below is. `withDomProps` lays the
@@ -591,7 +627,18 @@ const TabsTrigger = React.forwardRef(
591
627
  },
592
628
  tabIndex: props.tabIndex ?? domProps.tabIndex
593
629
  }),
594
- children
630
+ children: [
631
+ children,
632
+ /* @__PURE__ */ jsx(
633
+ TabCount,
634
+ {
635
+ count,
636
+ overflowCount,
637
+ showZero,
638
+ countLabel
639
+ }
640
+ )
641
+ ]
595
642
  }
596
643
  );
597
644
  }
@@ -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": "31.3.0",
3
+ "version": "31.4.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,
@@ -255,7 +255,11 @@ export type StepsSeparatorProp = "chevron" | "arrow";
255
255
  export type TabItemProp = {
256
256
  value: string;
257
257
  label: React.ReactNode;
258
- content: React.ReactNode;
258
+ /**
259
+ * The panel body. Optional because a strip-only `Tabs` (`controls`, gh#1021) renders no panel
260
+ * at all — its content lives in the consumer's own region.
261
+ */
262
+ content?: React.ReactNode;
259
263
  disabled?: boolean;
260
264
  /** Leading glyph inside the trigger (Ant Design `Tab.icon`). */
261
265
  icon?: React.ReactNode;
@@ -514,6 +518,20 @@ export type TabsProp = {
514
518
  moreIcon?: React.ReactNode;
515
519
  /** Ant Design `onTabScroll`, on the logical axis. @see TabsOnScrollProp */
516
520
  onTabScroll?: TabsOnScrollProp;
521
+ /**
522
+ * STRIP-ONLY MODE (gh#1021) — the `id` of the element OUTSIDE this `Tabs` that the strip
523
+ * drives: a status strip (未対応 / 対応中 / 完了) over a MasterDetail, a saved-view ribbon over a
524
+ * grid. With it set the `items` API renders the tablist ONLY — no `tabpanel`, empty or
525
+ * otherwise — and the selected tab's `aria-controls` points at this id, so the WAI-ARIA APG
526
+ * tab → controlled-region relationship stays real. The consumer's region is never mounted by
527
+ * the strip, so switching tabs cannot remount it (an unsent reply draft survives).
528
+ *
529
+ * Give the region an accessible name (`aria-label`/`aria-labelledby`, e.g. a `<section>`). In
530
+ * the compound form it applies to every trigger with no `TabsContent` declared for its value; a
531
+ * declared panel always wins. A pure FILTER with no region to point at is a `Segmented`
532
+ * (`radiogroup`), not a strip-only `Tabs`.
533
+ */
534
+ controls?: IdProp;
517
535
  className?: ClassNameProp;
518
536
  listClassName?: ClassNameProp;
519
537
  contentClassName?: ClassNameProp;
@@ -2626,7 +2626,7 @@ export declare const COMPONENT_PROP_REGISTRY: {
2626
2626
  readonly TabsProp: {
2627
2627
  readonly group: "navigation";
2628
2628
  readonly file: "components/navigation.prop.ts";
2629
- readonly vocabulary: readonly ["ValueProp", "DefaultValueProp", "OnValueChangeProp", "ClassNameProp", "SizeProp", "ExtraProp", {
2629
+ readonly vocabulary: readonly ["ValueProp", "DefaultValueProp", "OnValueChangeProp", "ClassNameProp", "SizeProp", "ExtraProp", "IdProp", {
2630
2630
  readonly field: "bodied";
2631
2631
  readonly local: true;
2632
2632
  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.";
@@ -3063,6 +3063,7 @@ const COMPONENT_PROP_REGISTRY = {
3063
3063
  "ClassNameProp",
3064
3064
  "SizeProp",
3065
3065
  "ExtraProp",
3066
+ "IdProp",
3066
3067
  {
3067
3068
  field: "bodied",
3068
3069
  local: true,
@@ -289,6 +289,18 @@
289
289
  background: var(--skeleton-background, hsl(var(--muted)));
290
290
  }
291
291
 
292
+ .ui-skeleton-line {
293
+ block-size: var(--skeleton-block-height);
294
+ }
295
+
296
+ .ui-skeleton-caption {
297
+ block-size: var(--skeleton-caption-height);
298
+ }
299
+
300
+ .ui-skeleton-title {
301
+ block-size: var(--skeleton-title-height);
302
+ }
303
+
292
304
  .ui-skeleton-table {
293
305
  overflow: hidden;
294
306
  border: var(--stroke-hairline) solid hsl(var(--border));
@@ -327,18 +339,6 @@
327
339
  border-radius: var(--skeleton-radius, var(--radius));
328
340
  padding: var(--skeleton-card-inset);
329
341
 
330
- .ui-skeleton-line {
331
- block-size: var(--skeleton-block-height);
332
- }
333
-
334
- .ui-skeleton-caption {
335
- block-size: var(--skeleton-caption-height);
336
- }
337
-
338
- .ui-skeleton-title {
339
- block-size: var(--skeleton-title-height);
340
- }
341
-
342
342
  .ui-skeleton-detail-label {
343
343
  block-size: var(--skeleton-caption-height);
344
344
  inline-size: var(--skeleton-label-width);
@@ -1282,13 +1282,29 @@
1282
1282
  margin-inline-start: var(--segmented-count-gap);
1283
1283
  border-radius: var(--segmented-count-radius);
1284
1284
  padding-inline: var(--segmented-count-space-inline);
1285
- background: var(--segmented-count-background, hsl(var(--primary)));
1286
- color: var(--segmented-count-color, hsl(var(--primary-foreground)));
1285
+
1286
+ background: var(--segmented-count-background, hsl(var(--background)));
1287
+ color: var(--segmented-count-color, hsl(var(--muted-foreground)));
1287
1288
  font-size: var(--segmented-count-font-size);
1288
1289
  line-height: 1;
1289
1290
  font-variant-numeric: tabular-nums;
1290
1291
  }
1291
1292
 
1293
+ .ui-segmented-item[data-state="checked"] .ui-segmented-count {
1294
+ background: var(
1295
+ --segmented-count-selected-background,
1296
+ hsl(var(--primary) / var(--segmented-count-selected-background-alpha, 100%))
1297
+ );
1298
+ color: var(--segmented-count-selected-color, hsl(var(--primary-foreground)));
1299
+ }
1300
+
1301
+ @media (forced-colors: active) {
1302
+ .ui-segmented-item[data-state="checked"] .ui-segmented-count {
1303
+ outline: var(--segmented-count-forced-outline-width, var(--stroke-hairline)) solid
1304
+ currentColor;
1305
+ }
1306
+ }
1307
+
1292
1308
  .ui-tag-input {
1293
1309
  display: flex;
1294
1310
  flex-wrap: wrap;
@@ -361,6 +361,10 @@
361
361
  color: hsl(var(--muted-foreground));
362
362
  }
363
363
 
364
+ .ui-upload-trigger {
365
+ align-self: start;
366
+ }
367
+
364
368
  .ui-upload-grid {
365
369
  display: flex;
366
370
  flex-wrap: wrap;
@@ -60,6 +60,9 @@
60
60
  inline-size: 100%;
61
61
 
62
62
  align-self: start;
63
+
64
+ --form-field-control-band: initial;
65
+ --form-field-first-line: initial;
63
66
  }
64
67
 
65
68
  .ui-form-field[data-layout="inline"] {
@@ -84,6 +87,40 @@
84
87
 
85
88
  min-height: var(--control-height);
86
89
  align-content: center;
90
+
91
+ grid-template-rows: minmax(var(--form-field-control-band, auto), auto);
92
+ align-items: center;
93
+ }
94
+
95
+ .ui-form-field-control:has(> .ui-form-field-note:first-child) {
96
+ grid-template-rows: none;
97
+ }
98
+
99
+ .ui-form-field-control
100
+ > fieldset
101
+ > :is(.ui-choice-group[data-orientation="vertical"], .ui-choice-field) {
102
+ align-self: start;
103
+ margin-block-start: max(
104
+ 0px,
105
+ calc(
106
+ (
107
+ var(--form-field-control-band, 0px) - var(--font-size-base) *
108
+ var(--control-label-line-height)
109
+ ) /
110
+ 2
111
+ )
112
+ );
113
+ }
114
+
115
+ .ui-form-field:has(
116
+ > .ui-form-field-control
117
+ > fieldset
118
+ > :is(.ui-control-multiline, [data-slot="textarea-affix-wrapper"])
119
+ ) {
120
+ --form-field-first-line: calc(
121
+ var(--control-border-width, var(--stroke-hairline)) + var(--control-multiline-padding-block) +
122
+ 0.5lh
123
+ );
87
124
  }
88
125
 
89
126
  .ui-form-field-control > * {
@@ -96,13 +133,14 @@
96
133
  ) {
97
134
  grid-template-columns: var(--form-label-width, max-content) minmax(0, 1fr);
98
135
  align-items: start;
136
+ --form-field-control-band: var(--control-height);
99
137
  column-gap: var(--form-label-gap);
100
138
  row-gap: var(--space-stack-xs);
101
139
  }
102
140
 
103
141
  .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
104
142
  > .ui-form-field-label {
105
- min-block-size: var(--control-height);
143
+ min-block-size: calc(2 * var(--form-field-first-line, var(--control-height) / 2));
106
144
  flex-wrap: wrap;
107
145
  }
108
146
  .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
@@ -131,12 +169,13 @@
131
169
  ) {
132
170
  grid-template-columns: var(--form-label-width, max-content) minmax(0, 1fr);
133
171
  align-items: start;
172
+ --form-field-control-band: var(--control-height);
134
173
  column-gap: var(--form-label-gap);
135
174
  row-gap: var(--space-stack-xs);
136
175
  }
137
176
  .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
138
177
  > .ui-form-field-label {
139
- min-block-size: var(--control-height);
178
+ min-block-size: calc(2 * var(--form-field-first-line, var(--control-height) / 2));
140
179
  flex-wrap: wrap;
141
180
  }
142
181
  .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
@@ -165,12 +204,13 @@
165
204
  ) {
166
205
  grid-template-columns: var(--form-label-width, max-content) minmax(0, 1fr);
167
206
  align-items: start;
207
+ --form-field-control-band: var(--control-height);
168
208
  column-gap: var(--form-label-gap);
169
209
  row-gap: var(--space-stack-xs);
170
210
  }
171
211
  .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
172
212
  > .ui-form-field-label {
173
- min-block-size: var(--control-height);
213
+ min-block-size: calc(2 * var(--form-field-first-line, var(--control-height) / 2));
174
214
  flex-wrap: wrap;
175
215
  }
176
216
  .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
@@ -199,12 +239,13 @@
199
239
  ) {
200
240
  grid-template-columns: var(--form-label-width, max-content) minmax(0, 1fr);
201
241
  align-items: start;
242
+ --form-field-control-band: var(--control-height);
202
243
  column-gap: var(--form-label-gap);
203
244
  row-gap: var(--space-stack-xs);
204
245
  }
205
246
  .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
206
247
  > .ui-form-field-label {
207
- min-block-size: var(--control-height);
248
+ min-block-size: calc(2 * var(--form-field-first-line, var(--control-height) / 2));
208
249
  flex-wrap: wrap;
209
250
  }
210
251
  .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
@@ -233,12 +274,13 @@
233
274
  ) {
234
275
  grid-template-columns: var(--form-label-width, max-content) minmax(0, 1fr);
235
276
  align-items: start;
277
+ --form-field-control-band: var(--control-height);
236
278
  column-gap: var(--form-label-gap);
237
279
  row-gap: var(--space-stack-xs);
238
280
  }
239
281
  .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
240
282
  > .ui-form-field-label {
241
- min-block-size: var(--control-height);
283
+ min-block-size: calc(2 * var(--form-field-first-line, var(--control-height) / 2));
242
284
  flex-wrap: wrap;
243
285
  }
244
286
  .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "31.3.0",
2
+ "version": "31.4.0",
3
3
  "generatedBy": "scripts/gen-style-layers.mjs — do not edit by hand; run `pnpm gen:style-layers`",
4
4
  "base": "base.css",
5
5
  "fonts": "fonts.css",
@@ -918,7 +918,8 @@
918
918
 
919
919
  .ui-page-header-extra {
920
920
  width: auto;
921
- flex-shrink: 1;
921
+ flex-shrink: 0;
922
+ max-inline-size: var(--page-header-extra-max-measure, 60%);
922
923
  min-inline-size: auto;
923
924
  justify-content: flex-end;
924
925
  }
@@ -46,4 +46,8 @@
46
46
  --segmented-count-gap: var(--space-1);
47
47
  --segmented-count-background: initial;
48
48
  --segmented-count-color: initial;
49
+ --segmented-count-selected-background: initial;
50
+ --segmented-count-selected-background-alpha: initial;
51
+ --segmented-count-selected-color: initial;
52
+ --segmented-count-forced-outline-width: initial;
49
53
  }
@@ -51,6 +51,8 @@
51
51
  --page-footer-pad-block-end: var(--space-stack-md);
52
52
  --page-header-divider: none;
53
53
 
54
+ --page-header-extra-max-measure: initial;
55
+
54
56
  --page-toolbar-background: transparent;
55
57
  --page-toolbar-pad-block: 0px;
56
58
  --page-toolbar-divider: initial;
@@ -90,6 +90,8 @@ export default function Demo() {
90
90
  <CardDescription>
91
91
  一覧の上の状態フィルター。4 つが 1 行に収まらない幅では、トラックが 2 行目に折り返し
92
92
  ます。ラベルも件数も切り詰めません。`check:segmented-wrap` がこのカードを測ります。
93
+ 件数は Tabs と同じ扱いで、選択中の項目だけ強調し、ほかは控えめな色です。0 件を隠す
94
+ ときは `showZero: false`。
93
95
  </CardDescription>
94
96
  </CardHeader>
95
97
  <CardContent>
@@ -146,6 +146,7 @@ export default function Demo() {
146
146
  },
147
147
  ]);
148
148
  const [editableTab, setEditableTab] = useState("je-0042");
149
+ const [inboxStatus, setInboxStatus] = useState("open");
149
150
 
150
151
  return (
151
152
  <PageContainer
@@ -646,6 +647,48 @@ export default function Demo() {
646
647
  </CardContent>
647
648
  </Card>
648
649
 
650
+ {/* STRIP-ONLY (gh#1021). The strip drives a region OUTSIDE the Tabs, so no tabpanel is
651
+ rendered and the view below is never remounted by a tab switch. */}
652
+ <Card>
653
+ <CardHeader>
654
+ <CardTitle level={2}>controls · タブ列だけ(外部の領域を切り替える)</CardTitle>
655
+ <CardDescription>
656
+ controls に外部領域の id を渡すと tabpanel を描かず、選択中のタブの aria-controls
657
+ がその領域を指す。領域は Tabs の外にあるのでタブを切り替えても再マウントされない。
658
+ compound の TabsTrigger も items と同じ count を描く。
659
+ </CardDescription>
660
+ </CardHeader>
661
+ <CardContent>
662
+ <Flex direction="col" gap="md">
663
+ <Tabs
664
+ aria-label="対応状況"
665
+ controls="tabs-inbox-view"
666
+ variant="line"
667
+ value={inboxStatus}
668
+ onValueChange={setInboxStatus}
669
+ items={[
670
+ { value: "open", label: "未対応", count: 12, countLabel: "件" },
671
+ { value: "doing", label: "対応中", count: 3, countLabel: "件" },
672
+ { value: "done", label: "完了", count: 128, countLabel: "件" },
673
+ ]}
674
+ />
675
+ <section id="tabs-inbox-view" aria-label="スレッド一覧">
676
+ <Text as="p">表示中: {inboxStatus}</Text>
677
+ </section>
678
+ <Tabs aria-label="保存ビュー" controls="tabs-inbox-view" defaultValue="mine">
679
+ <TabsList variant="line">
680
+ <TabsTrigger value="mine" count={4} countLabel="件">
681
+ 自分の担当
682
+ </TabsTrigger>
683
+ <TabsTrigger value="all" count={143} countLabel="件">
684
+ すべて
685
+ </TabsTrigger>
686
+ </TabsList>
687
+ </Tabs>
688
+ </Flex>
689
+ </CardContent>
690
+ </Card>
691
+
649
692
  <Card>
650
693
  <CardHeader>
651
694
  <CardTitle level={2}>indicator · animated</CardTitle>
@@ -1,5 +1,5 @@
1
1
  import { useState } from "react";
2
- import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
2
+ import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
 
4
4
  import {
5
5
  Badge,
@@ -13,7 +13,7 @@ import {
13
13
  type ColumnDef,
14
14
  } from "@godxjp/ui/data-display";
15
15
  import { SkeletonTable } from "@godxjp/ui/feedback";
16
- import { Text } from "@godxjp/ui/general";
16
+ import { Button, Text } from "@godxjp/ui/general";
17
17
  import { Flex, PageContainer } from "@godxjp/ui/layout";
18
18
  import { AuthExpiryProvider, DataState } from "@godxjp/ui/query";
19
19
 
@@ -51,15 +51,36 @@ function SuccessBlock() {
51
51
  );
52
52
  }
53
53
 
54
+ /** Loads for 2s and comes back empty — the whole lifecycle: skeleton → EmptyState. */
55
+ const LOAD_MS = 2000;
56
+
54
57
  function LoadingBlock() {
58
+ const client = useQueryClient();
55
59
  const query = useQuery({
56
60
  queryKey: ["ds-loading"],
57
- queryFn: () => new Promise<Invoice[]>(() => {}),
61
+ queryFn: () => new Promise<Invoice[]>((resolve) => setTimeout(() => resolve([]), LOAD_MS)),
58
62
  });
59
63
  return (
60
- <DataState query={query} skeleton={<SkeletonTable />}>
61
- {(d) => <DataTable data={d} columns={columns} getRowId={(r) => r.id} />}
62
- </DataState>
64
+ <Flex direction="col" gap="sm">
65
+ <DataState
66
+ query={query}
67
+ skeleton={<SkeletonTable rows={4} columns={3} />}
68
+ isEmpty={(d) => d.length === 0}
69
+ empty={
70
+ <EmptyState title="データがありません" description="条件に合う請求書はありません。" />
71
+ }
72
+ >
73
+ {(d) => <DataTable data={d} columns={columns} getRowId={(r) => r.id} />}
74
+ </DataState>
75
+ {/* resetQueries puts the query back to pending, so the skeleton plays again. */}
76
+ <Button
77
+ variant="outline"
78
+ size="sm"
79
+ onClick={() => void client.resetQueries({ queryKey: ["ds-loading"] })}
80
+ >
81
+ もう一度読み込む
82
+ </Button>
83
+ </Flex>
63
84
  );
64
85
  }
65
86
 
@@ -174,8 +195,11 @@ export default function Demo() {
174
195
 
175
196
  <Card>
176
197
  <CardHeader>
177
- <CardTitle level={2}>Loading (skeleton)</CardTitle>
178
- <CardDescription>The skeleton prop renders during the pending phase.</CardDescription>
198
+ <CardTitle level={2}>Loading → empty</CardTitle>
199
+ <CardDescription>
200
+ The skeleton (animated) renders while pending; when the data arrives empty the
201
+ skeleton goes away and the `empty` slot renders.
202
+ </CardDescription>
179
203
  </CardHeader>
180
204
  <CardContent flush>
181
205
  <LoadingBlock />
@@ -367,19 +367,14 @@ const DOT_CLASS: Record<SavedView["dot"], string> = {
367
367
  neutral: "bg-muted-foreground",
368
368
  };
369
369
 
370
- // TabsTrigger renders a <button>, whose content model is phrasing content only — so the row and
371
- // the count chip take `as="span"`. Before Flex/Badge had that seam the only valid shape here was a
372
- // raw `<span className="flex …">`, i.e. utility layout.
373
- function ViewTrigger({ view, count }: { view: SavedView; count: number }) {
370
+ // TabsTrigger renders a <button>, whose content model is phrasing content only — so the row takes
371
+ // `as="span"`. The count is the trigger's own `count` slot (gh#1021), drawn exactly as the `items`
372
+ // API draws it, rather than a hand-placed Badge.
373
+ function ViewTrigger({ view }: { view: SavedView }) {
374
374
  return (
375
375
  <Flex as="span" align="center" gap="sm">
376
376
  <span aria-hidden="true" className={`size-2 rounded-full ${DOT_CLASS[view.dot]}`} />
377
377
  <span>{view.label}</span>
378
- <Badge as="span" tone="neutral" shape="pill">
379
- <Text as="span" size="2xs" weight="medium" tabular>
380
- {count}
381
- </Text>
382
- </Badge>
383
378
  </Flex>
384
379
  );
385
380
  }
@@ -432,23 +427,30 @@ export default function Demo() {
432
427
  }
433
428
  >
434
429
  <Flex direction="col" gap="md">
435
- {/* Saved-view ribbon — controlled Tabs; switching swaps the preset below. */}
436
- <Tabs value={view} onValueChange={(v) => setView(v as ViewId)}>
430
+ {/* Saved-view ribbon — controlled, strip-only Tabs (gh#1021): `controls` points the
431
+ selected tab at the grid below, which lives outside the Tabs and is never remounted. */}
432
+ <Tabs
433
+ aria-label="保存ビュー"
434
+ controls="attendance-grid"
435
+ value={view}
436
+ onValueChange={(v) => setView(v as ViewId)}
437
+ >
437
438
  <TabsList variant="line" className="h-auto w-full flex-wrap justify-start border-b">
438
439
  {VIEWS.map((v) => (
439
440
  <TabsTrigger
440
441
  key={v.id}
441
442
  value={v.id}
443
+ count={counts[v.id]}
442
444
  className="rounded-[var(--tabs-trigger-line-radius)] px-[var(--tabs-trigger-line-padding-x)] py-[var(--tabs-trigger-line-padding-y)]"
443
445
  >
444
- <ViewTrigger view={v} count={counts[v.id]} />
446
+ <ViewTrigger view={v} />
445
447
  </TabsTrigger>
446
448
  ))}
447
449
  </TabsList>
448
450
  </Tabs>
449
451
 
450
452
  {/* The grid — columns + data both come from the active saved view. */}
451
- <Card>
453
+ <Card id="attendance-grid" role="region" aria-label={active.label}>
452
454
  <CardContent flush>
453
455
  <DataTable
454
456
  data={rows}
@@ -235,7 +235,9 @@
235
235
  --conversations-item-active-background: hsl(220 20% 90%);
236
236
  --conversations-item-active-color: hsl(220 26% 12%);
237
237
  --tabs-count-active-background: hsl(220 20% 86%);
238
+ --segmented-count-selected-background: hsl(220 20% 86%);
238
239
  --tabs-count-active-color: hsl(220 26% 12%);
240
+ --segmented-count-selected-color: hsl(220 26% 12%);
239
241
  --toggle-soft-hover-background: hsl(220 16% 90%);
240
242
  --toggle-pressed-count-background: hsl(220 20% 84%);
241
243
  --toggle-pressed-count-color: hsl(220 26% 12%);
@@ -419,7 +421,9 @@
419
421
  --conversations-item-active-background: hsl(220 16% 22%);
420
422
  --conversations-item-active-color: hsl(220 20% 94%);
421
423
  --tabs-count-active-background: hsl(220 16% 26%);
424
+ --segmented-count-selected-background: hsl(220 16% 26%);
422
425
  --tabs-count-active-color: hsl(220 20% 94%);
426
+ --segmented-count-selected-color: hsl(220 20% 94%);
423
427
  --toggle-soft-hover-background: hsl(220 14% 22%);
424
428
  --toggle-pressed-count-background: hsl(220 16% 26%);
425
429
  --toggle-pressed-count-color: hsl(220 20% 94%);
@@ -613,7 +613,9 @@
613
613
  --conversations-item-active-background-alpha: 18%;
614
614
  --conversations-item-active-color: hsl(var(--glass-ink));
615
615
  --tabs-count-active-background-alpha: 24%;
616
+ --segmented-count-selected-background-alpha: 24%;
616
617
  --tabs-count-active-color: hsl(var(--glass-ink));
618
+ --segmented-count-selected-color: hsl(var(--glass-ink));
617
619
  --toggle-pressed-count-background-alpha: 26%;
618
620
  --toggle-pressed-count-color: hsl(var(--glass-ink));
619
621
  --toggle-pressed-border-color-alpha: 45%;
@@ -279,7 +279,9 @@
279
279
  --conversations-item-active-background: hsl(48 90% 78%);
280
280
  --conversations-item-active-color: hsl(0 0% 0%);
281
281
  --tabs-count-active-background: hsl(48 90% 74%);
282
+ --segmented-count-selected-background: hsl(48 90% 74%);
282
283
  --tabs-count-active-color: hsl(0 0% 0%);
284
+ --segmented-count-selected-color: hsl(0 0% 0%);
283
285
  --toggle-soft-hover-background: hsl(48 90% 84%);
284
286
  --toggle-pressed-count-background: hsl(48 90% 74%);
285
287
  --toggle-pressed-count-color: hsl(0 0% 0%);
@@ -331,6 +333,7 @@
331
333
  --mega-menu-link-hover-background: hsl(from hsl(var(--primary)) h 90% 82%);
332
334
  --conversations-item-active-background: hsl(from hsl(var(--primary)) h 90% 78%);
333
335
  --tabs-count-active-background: hsl(from hsl(var(--primary)) h 90% 74%);
336
+ --segmented-count-selected-background: hsl(from hsl(var(--primary)) h 90% 74%);
334
337
  --toggle-soft-hover-background: hsl(from hsl(var(--primary)) h 90% 84%);
335
338
  --toggle-pressed-count-background: hsl(from hsl(var(--primary)) h 90% 74%);
336
339
  }
@@ -423,7 +426,9 @@
423
426
  --conversations-item-active-background: hsl(48 90% 26%);
424
427
  --conversations-item-active-color: hsl(0 0% 100%);
425
428
  --tabs-count-active-background: hsl(48 90% 26%);
429
+ --segmented-count-selected-background: hsl(48 90% 26%);
426
430
  --tabs-count-active-color: hsl(0 0% 100%);
431
+ --segmented-count-selected-color: hsl(0 0% 100%);
427
432
  --toggle-soft-hover-background: hsl(48 90% 24%);
428
433
  --toggle-pressed-count-background: hsl(48 90% 26%);
429
434
  --toggle-pressed-count-color: hsl(0 0% 100%);
@@ -469,6 +474,7 @@
469
474
  --mega-menu-link-hover-background: hsl(from hsl(var(--primary)) h 90% 26%);
470
475
  --conversations-item-active-background: hsl(from hsl(var(--primary)) h 90% 26%);
471
476
  --tabs-count-active-background: hsl(from hsl(var(--primary)) h 90% 26%);
477
+ --segmented-count-selected-background: hsl(from hsl(var(--primary)) h 90% 26%);
472
478
  --toggle-soft-hover-background: hsl(from hsl(var(--primary)) h 90% 24%);
473
479
  --toggle-pressed-count-background: hsl(from hsl(var(--primary)) h 90% 26%);
474
480
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "31.3.0",
4
- "godxUiMcp": "31.3.0",
3
+ "version": "31.4.0",
4
+ "godxUiMcp": "31.4.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",