@godxjp/ui 33.8.1 → 33.10.1

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.
Files changed (40) hide show
  1. package/DESIGN.md +9 -7
  2. package/README.md +5 -5
  3. package/agent/DESIGN.md +9 -7
  4. package/agent/START-HERE.md +2 -2
  5. package/agent/components/RichTextEditor.json +73 -0
  6. package/agent/components/Tabs.json +9 -2
  7. package/agent/components/TranslatableField.json +69 -0
  8. package/agent/components/TranslatableRichText.json +70 -0
  9. package/agent/components-index.json +15 -0
  10. package/agent/components.json +221 -2
  11. package/agent/index.json +5 -5
  12. package/agent/llms.txt +6 -6
  13. package/agent/tokens.json +35 -35
  14. package/dist/components/data-entry/index.d.ts +2 -2
  15. package/dist/components/data-entry/index.js +2 -1
  16. package/dist/components/data-entry/translatable-input.d.ts +24 -20
  17. package/dist/components/data-entry/translatable-input.js +8 -21
  18. package/dist/components/navigation/tabs-scroll.d.ts +1 -5
  19. package/dist/components/navigation/tabs-scroll.js +31 -8
  20. package/dist/components/navigation/tabs.d.ts +1 -1
  21. package/dist/components/navigation/tabs.js +264 -89
  22. package/dist/contracts/measurement.json +1 -1
  23. package/dist/i18n/messages/en.json +2 -1
  24. package/dist/i18n/messages/ja.json +2 -1
  25. package/dist/i18n/messages/vi.json +2 -1
  26. package/dist/props/components/data-entry.prop.d.ts +49 -5
  27. package/dist/props/components/navigation.prop.d.ts +20 -1
  28. package/dist/props/registry.d.ts +59 -0
  29. package/dist/props/registry.js +86 -0
  30. package/dist/styles/data-display-layout.css +41 -0
  31. package/dist/styles/layers.json +39 -1
  32. package/dist/styles.css +1 -1
  33. package/dist/tokens/components/card.css +3 -3
  34. package/dist/tokens/components/control.css +4 -4
  35. package/dist/tokens/components/form.css +1 -1
  36. package/dist/tokens/components/table.css +1 -1
  37. package/dist/tokens/foundation.css +11 -11
  38. package/dist/tokens/semantic/layout.css +3 -3
  39. package/docs/TOKENS.md +62 -1
  40. package/package.json +2 -2
package/agent/tokens.json CHANGED
@@ -132,10 +132,10 @@
132
132
  "value": "30 7% 93%"
133
133
  },
134
134
  {
135
- "description": "CONTROL BOUNDARY role — deliberately NOT the same value as --border (gh#315). The two roles answer different questions and are held to different bars: · --border is DECORATIVE chrome — table rules, card edges, section dividers. WCAG 2.2 SC 1.4.11 does not reach it (nothing about a divider is \"required to identify a component or its state\"), and darkening it would make the JP-dense table grid roar. It stays exactly where it is. · --input is the boundary that IS the control — the 1px edge of Input, Textarea, Select, the outline Button, TagInput, the composite date field, the topbar search. `--input` also fills the Switch's unchecked track. A text field here has NO fill of its own (`background: hsl(var(--background))`) and NO shadow to speak of, so that edge is the only thing that says \"you may type here\". That is squarely SC 1.4.11's 3:1 minimum. They shipped sharing `30 7% 83%`, which measured 1.46:1 on --background/--card — the one live WCAG failure a full DXS Platform sweep turned up. 53% clears 3:1 with margin on every surface a control actually sits on, not just the plain page (the gh#299 lesson: a value that passes on a card and fails inside a filter bar is not a line anyone can defend): --background/--card/--popover 3.47:1 · --muted/--secondary 3.18:1 · zebra stripe 3.35:1 · row hover 3.32:1. It also lifts the Switch's unchecked track from 1.46:1 to 3.47:1 against both the page and its own white thumb, so \"off\" is finally a visible state. Guarded by src/tokens/__tests__/input-boundary-contrast.test.ts. #90877f — control boundary, ≥3:1 (WCAG 2.2 SC 1.4.11)",
135
+ "description": "CONTROL BOUNDARY role — deliberately NOT the same value as --border (gh#315). The two roles answer different questions and are held to different bars: · --border is DECORATIVE chrome — table rules, card edges, section dividers. WCAG 2.2 SC 1.4.11 does not reach it (nothing about a divider is \"required to identify a component or its state\"), and darkening it would make the JP-dense table grid roar. It stays exactly where it is. · --input is the boundary that IS the control — the 1px edge of Input, Textarea, Select, the outline Button, TagInput, the composite date field, the topbar search. `--input` also fills the Switch's unchecked track. A text field here has NO fill of its own (`background: hsl(var(--background))`) and NO shadow to speak of, so that edge is the only thing that says \"you may type here\". That is squarely SC 1.4.11's 3:1 minimum. They shipped sharing `30 7% 83%`, which measured 1.46:1 on --background/--card — the one live WCAG failure a full DXS Platform sweep turned up. The modern refresh uses 51% lightness: the previous 53% passed the translucent hovered row but fell below 3:1 on the full --accent ground. The boundary test now includes that ground in both themes. Guarded by src/tokens/__tests__/input-boundary-contrast.test.ts. #8b8279 — control boundary, ≥3:1 (WCAG 2.2 SC 1.4.11)",
136
136
  "name": "--input",
137
137
  "tier": "foundation",
138
- "value": "30 7% 53%"
138
+ "value": "30 7% 51%"
139
139
  },
140
140
  {
141
141
  "description": "STATUS FILLS AND THE INK THAT RIDES ON THEM. The FILLS are the palette and they do not move: 若竹, 山吹, 群青, 朱 are named colours, every solid success/attention surface in every consumer is one of them, and a chip that changes hue on upgrade is a different design. What moved is the INK, and only where it failed to be readable (gh#643). Measured from these very tokens (src/tokens/__tests__/status-fill-contrast.test.ts, and confirmed in Chromium by `check:frame-axe` on /isolate/foundation-colors at 320/375/1440): role fill WAS near-white ink dark ink (48 8% 13%) success #68be8d 2.19:1 ✗ 7.08:1 ✓ attention #eb6101 3.32:1 ✗ 4.68:1 ✓ warning #f8b500 1.74:1 ✗ 8.91:1 ✓ (already dark — the precedent) primary #18181b 17.45:1 ✓ (the GoDX preset #008066: 4.86:1) destructive … 6.10:1 ✓ info #4c6cb3 5.00:1 ✓ Two of the eight light-theme fills carried an ink their own lightness cannot support, and the package already knew the answer twice over: --warning-foreground has always been this same near-black on the equally pale 山吹, and the DARK theme flips --success-foreground / --attention-foreground to 48 9% 9% for exactly this reason. The light block was the outlier. REJECTED — darkening 若竹 and 朱 so the near-white ink clears 4.5:1. A fill needs luminance ≤ 0.183 for that; 若竹 sits at 0.422, so \"keep the white text\" means a different green, not a darker one. That is the palette change this deliberately is not. 若竹 #68be8d",
@@ -525,13 +525,13 @@
525
525
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
526
526
  "name": "--shadow-md",
527
527
  "tier": "foundation",
528
- "value": "0 4px 6px -1px rgb(var(--shadow-color) / 0.1), 0 2px 4px -2px rgb(var(--shadow-color) / 0.1)"
528
+ "value": "0 6px 16px -4px rgb(var(--shadow-color) / 0.08), 0 1px 3px 0 rgb(var(--shadow-color) / 0.08)"
529
529
  },
530
530
  {
531
531
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
532
532
  "name": "--shadow-lg",
533
533
  "tier": "foundation",
534
- "value": "0 10px 15px -3px rgb(var(--shadow-color) / 0.1), 0 4px 6px -4px rgb(var(--shadow-color) / 0.1)"
534
+ "value": "0 12px 28px -6px rgb(var(--shadow-color) / 0.1), 0 2px 6px -2px rgb(var(--shadow-color) / 0.08)"
535
535
  },
536
536
  {
537
537
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
@@ -582,16 +582,16 @@
582
582
  "value": "initial"
583
583
  },
584
584
  {
585
- "description": "── THE SWITCH ─────────────────────────────────────────────────────────────────────────── `--focus-outline` is ONE 0/1 flag, and every painted focus length below multiplies by it: --focus-ring-width: calc(var(--focus-ring-weight) * var(--focus-outline)) — so setting it to 0 zeroes the whole indicator at once. There is deliberately no parallel mechanism: one multiplier is the only shape in which a kill switch cannot be routed around by a per-component rebind. IT SHIPS ON, as of gh#544 — it used to ship off, and that reversal is the point of this note. The old default was a real product decision: the indicator had been reported as intrusive and the owner accepted the cost, which was that this library did not satisfy WCAG 2.2 SC 2.4.7 Focus Visible (AA) and so could carry no JIS X 8341-3:2016 AA statement. What changed is not the principle but two measurements. FIRST, what the default actually produced. Counting `data-focus-outline` across the four real consumers of this package: `ql` sets it (3 files); `godx-task`, `godx-chat` and the platform app set it NOWHERE. Three of four products shipped with no keyboard-focus indicator at all — not chosen, because the switch is only discoverable by reading this file. SECOND, what the ON state now costs. It is the HAIRLINE field indicator described below, not the 3px outline the original complaint was about. The thing being turned on is no longer the thing that was turned off. The escape hatch is the same shape it always was, one attribute, now pointing the other way: <html data-focus-outline=\"off\"> (or AppProvider's equivalent) `data-focus-outline=\"on\"` still works and still means on, so every consumer that already set it is untouched. Every painted length below multiplies by `--focus-outline`, so with the switch off the outline resolves to `0px`, the halo's spread resolves to `0px`, and `--focus-field-shadow` is `none`. The `:focus-visible` selectors themselves stay exactly where they are: only the paint goes. ── THE ON WEIGHT ──────────────────────────────────────────────────────────────────────── `--focus-outline-weight` is the ON state's thickness, and it is deliberately the HAIRLINE (1px) rather than the heavy outline weight (3px). The heavy mark is what the complaint was about: a 3px opaque outline stacked on an already-shaded selected nav row reads as two treatments on one element. WCAG's AA bar for an indicator is CONTRAST (SC 1.4.11, 3:1), not thickness, so the weight can come all the way down to the FIELD indicator — one hairline of `--ring`, which measures at worst 5.30:1 light and 6.87:1 dark across every surface a control sits on — while the criterion is still met. The 3px weight is one token away: set `--focus-outline-weight: var(--stroke-lg)`. The hue is the focus hue itself, NOT `--primary-border`. That is the one place the ON state departs from the OUTLINE form, and it is forced: `--primary-border` for this seed is #a66de3, which reaches 3.49:1 on the page and 2.92:1 light / 1.03:1 dark on an accent panel, so it cannot satisfy SC 1.4.11 on every surface at any thickness. A mark that fails the criterion is not an indicator. the switch — 1 = on (shipped default since gh#544), 0 = off",
585
+ "description": "── THE SWITCH ─────────────────────────────────────────────────────────────────────────── `--focus-outline` is ONE 0/1 flag, and every painted focus length below multiplies by it: --focus-ring-width: calc(var(--focus-ring-weight) * var(--focus-outline)) — so setting it to 0 zeroes the whole indicator at once. There is deliberately no parallel mechanism: one multiplier is the only shape in which a kill switch cannot be routed around by a per-component rebind. IT SHIPS ON, as of gh#544 — it used to ship off, and that reversal is the point of this note. The old default was a real product decision: the indicator had been reported as intrusive and the owner accepted the cost, which was that this library did not satisfy WCAG 2.2 SC 2.4.7 Focus Visible (AA) and so could carry no JIS X 8341-3:2016 AA statement. What changed is not the principle but two measurements. FIRST, what the default actually produced. Counting `data-focus-outline` across the four real consumers of this package: `ql` sets it (3 files); `godx-task`, `godx-chat` and the platform app set it NOWHERE. Three of four products shipped with no keyboard-focus indicator at all — not chosen, because the switch is only discoverable by reading this file. SECOND, what the ON state now costs. It is the HAIRLINE field indicator described below, not the 3px outline the original complaint was about. The thing being turned on is no longer the thing that was turned off. The escape hatch is the same shape it always was, one attribute, now pointing the other way: <html data-focus-outline=\"off\"> (or AppProvider's equivalent) `data-focus-outline=\"on\"` still works and still means on, so every consumer that already set it is untouched. Every painted length below multiplies by `--focus-outline`, so with the switch off the outline resolves to `0px`, the halo's spread resolves to `0px`, and `--focus-field-shadow` is `none`. The `:focus-visible` selectors themselves stay exactly where they are: only the paint goes. ── THE ON WEIGHT ────────────────────────────────────────────────────── The modern default is a 2px mark with 2px offset. Hue remains --ring for at least 3:1 contrast on supported surfaces; navigation keeps its inset. Consumers can retune the two thickness knobs or use the existing opt-out. 1 = on, 0 = off",
586
586
  "name": "--focus-outline",
587
587
  "tier": "foundation",
588
588
  "value": "1"
589
589
  },
590
590
  {
591
- "description": "default = var(--stroke-hairline), resolved at the call site (gh#906)",
591
+ "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
592
592
  "name": "--focus-outline-weight",
593
593
  "tier": "foundation",
594
- "value": "initial"
594
+ "value": "var(--stroke-md)"
595
595
  },
596
596
  {
597
597
  "description": "default = var(--focus-ring-color, var(--ring)), resolved at the focused element — a `:root` binding froze on the root's --ring, so a nested tenant scope never re-tinted the outline (gh#687; docs/TOKENS.md, the freeze rule)",
@@ -609,7 +609,7 @@
609
609
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
610
610
  "name": "--focus-outline-offset",
611
611
  "tier": "foundation",
612
- "value": "0px"
612
+ "value": "2px"
613
613
  },
614
614
  {
615
615
  "description": "FOCUS RING — the two global knobs for the keyboard-focus affordance (WCAG 2.4.7 / 2.4.11). `--focus-ring-color` is the themeable hue (HSL components, defaults to --ring) and `--focus-ring-width` the standard thickness; override either ONCE — even scoped under a `[data-tenant]` — to retint or resize EVERY focus ring. `--focus-ring-color` is declared `initial` (NOT `var(--ring)`): a :root binding to a role var would freeze at the :root --ring and a scoped [data-tenant] override of --ring alone would never reach it. Every :focus-visible rule reads it with the live-role fallback `hsl(var(--focus-ring-color, var(--ring)))`, so the default re-resolves at the element that paints the ring while an explicit override still wins. default = the live --ring role",
@@ -1203,19 +1203,19 @@
1203
1203
  "description": "Motion — the primitive tier for animation, so enter/transition timing reads a token instead of a literal 0.5s / cubic-bezier(...) / translateY(10px) (cardinal rule #2). the reference design keeps motion LOW: short durations, a calm decelerate curve, a small reveal shift. Consumers honour prefers-reduced-motion at the call site.",
1204
1204
  "name": "--duration-fast",
1205
1205
  "tier": "foundation",
1206
- "value": "150ms"
1206
+ "value": "120ms"
1207
1207
  },
1208
1208
  {
1209
1209
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
1210
1210
  "name": "--duration-base",
1211
1211
  "tier": "foundation",
1212
- "value": "250ms"
1212
+ "value": "180ms"
1213
1213
  },
1214
1214
  {
1215
1215
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
1216
1216
  "name": "--duration-slow",
1217
1217
  "tier": "foundation",
1218
- "value": "500ms"
1218
+ "value": "320ms"
1219
1219
  },
1220
1220
  {
1221
1221
  "description": "Foundation tokens: color, typography, raw spacing, ratio, radius, shadow.",
@@ -1440,16 +1440,16 @@
1440
1440
  "value": "var(--primary)"
1441
1441
  },
1442
1442
  {
1443
- "description": "Semantic spacing — ONE coherent 4px grid (Tailwind/Material/Ant standard). All steps reference the linear `--space-*` scale (NOT the golden φ scale — golden ratio is for modular SIZE scales like type/radius, not the spacing grid; mixing the two left an incoherent density rhythm). The φ tokens remain available as an optional golden-layout utility, just not the spacing baseline. 24px — page gutter",
1443
+ "description": "Semantic spacing — ONE coherent 4px grid (Tailwind/Material/Ant standard). All steps reference the linear `--space-*` scale (NOT the golden φ scale — golden ratio is for modular SIZE scales like type/radius, not the spacing grid; mixing the two left an incoherent density rhythm). The φ tokens remain available as an optional golden-layout utility, just not the spacing baseline. 32px — page gutter",
1444
1444
  "name": "--space-page-x",
1445
1445
  "tier": "semantic",
1446
- "value": "var(--space-6)"
1446
+ "value": "var(--space-8)"
1447
1447
  },
1448
1448
  {
1449
- "description": "24px",
1449
+ "description": "32px",
1450
1450
  "name": "--space-page-y",
1451
1451
  "tier": "semantic",
1452
- "value": "var(--space-6)"
1452
+ "value": "var(--space-8)"
1453
1453
  },
1454
1454
  {
1455
1455
  "description": "16px",
@@ -1641,7 +1641,7 @@
1641
1641
  "description": "Modal scrim — the single backdrop colour shared by EVERY overlay (Dialog, AlertDialog, Sheet, Drawer, AppShell mobile nav). Themeable (rule #44) so a service tints the wash to its brand once, e.g. a navy `--overlay-background: rgb(12 26 49 / .55)`, instead of forking each overlay. HOW IT REACHES AN OVERLAY (gh#215 — it previously reached NOTHING): each surface declares its own knob `initial` and resolves a SHARE of this colour at the call site, `var(--dialog-overlay-background, color-mix(in srgb, var(--overlay-background) var(--dialog-overlay-alpha), transparent))`. That keeps the calibrated per-surface depth — a slide-in Sheet washes the page more lightly (40%) than a modal Dialog (60%) — while ONE override here retints every backdrop. Per docs/TOKENS.md the knobs must stay `initial`: a `:root` binding would freeze and a scoped `[data-tenant]`/`.dark` override of this token would never reach the overlay. Overlays are portaled, so a tenant scope must also sit on the portal container (see docs/CUSTOMER-THEMING.md).",
1642
1642
  "name": "--overlay-background",
1643
1643
  "tier": "semantic",
1644
- "value": "rgb(0 0 0 / 0.5)"
1644
+ "value": "rgb(0 0 0 / 0.4)"
1645
1645
  },
1646
1646
  {
1647
1647
  "description": "RECESSED SURFACE role (gh#399) — \"the ground a card sits on\", one step BEHIND `--card`. It is a ROLE, not a derivation: the two themes order their surfaces differently, so no single expression is correct on both. Light puts `--muted` (95%) behind `--card` (99%); dark puts it (18%) in FRONT of `--card` (12%), so the shipped `hsl(var(--muted) / 0.4)` composited to rgb(35,33,30) against a rgb(33,32,28) card — the page read 1.015:1 LIGHTER than the cards on it, i.e. the elevation inverted. No alpha fixes that: the mix runs from `--background` (correct) toward `--muted` (wrong), so anything above ~0.33 crosses the card. Each theme therefore states its own ground below. Light keeps the exact shipped composite (1.036:1 behind the card); dark takes the page `--background`, which measures 1.089:1 — matching the light theme's separation instead of inverting it.",
@@ -2415,7 +2415,7 @@
2415
2415
  "description": "Gap between the header and the body, and the body's own top padding — the breathing room under a title before content begins.",
2416
2416
  "name": "--card-space-body-y",
2417
2417
  "tier": "component",
2418
- "value": "var(--space-section-active)"
2418
+ "value": "var(--space-6)"
2419
2419
  },
2420
2420
  {
2421
2421
  "description": "Symmetric block padding for a SOLO content slot, so a composed shell can tune block and inline spacing independently without targeting CardContent. Declared `initial` for the SAME reason as --card-space-shell-y above: written as `var(--card-space-inset)` here it would freeze at the :root inset, and a card carrying [data-density=\"tight\"|\"cozy\"] (which override --card-space-inset ON THE CARD) would keep the :root value. The default now resolves at the call site through the chain solo-y → shell-y → inset, so a solo body follows the general block knob when only that is set, and an explicit solo-y still wins. default = var(--card-space-shell-y, var(--card-space-inset))",
@@ -2445,7 +2445,7 @@
2445
2445
  "description": "Card component tokens: card chrome derives from semantic layout tokens.",
2446
2446
  "name": "--card-title-font-size",
2447
2447
  "tier": "component",
2448
- "value": "var(--font-size-base)"
2448
+ "value": "calc(var(--font-size-base) * var(--font-size-ratio))"
2449
2449
  },
2450
2450
  {
2451
2451
  "description": "Card component tokens: card chrome derives from semantic layout tokens.",
@@ -2508,10 +2508,10 @@
2508
2508
  "value": "initial"
2509
2509
  },
2510
2510
  {
2511
- "description": "The DXS hi-fi baseline uses a quiet 10px data surface with one shadow-sm elevation layer. Consumers can still flatten or lift cards through this knob. `initial`, NOT `var(--shadow-sm)` — a role-mirror knob written here binds at `<html>` and a scope below root that restates the elevation ramp can never reach it (docs/TOKEN-RESOLUTION.md §3). The default now resolves on `.ui-card`, so a `[data-tenant]`/`[data-theme]` region that sets `--shadow-sm` moves its own cards and nothing else. gh#880 item 7. default = var(--shadow-sm), resolved at the call site",
2511
+ "description": "Flat resting cards: the hairline defines the surface; elevation is reserved for overlays. Set --card-shadow to a shadow value for opt-in elevation, or to initial to restore the live --shadow-sm fallback at the call site (including scoped shadow ramps).",
2512
2512
  "name": "--card-shadow",
2513
2513
  "tier": "component",
2514
- "value": "initial"
2514
+ "value": "none"
2515
2515
  },
2516
2516
  {
2517
2517
  "description": "Brand glow layer — invisible no-op at rest (rule #44). Paired AFTER --card-shadow in the surface box-shadow so a service can wash every card with the global glow, e.g. --card-glow: var(--shadow-glow), with no markup change.",
@@ -3147,7 +3147,7 @@
3147
3147
  "description": "Control primitive tokens: heights, horizontal padding, adjacent control sizes.",
3148
3148
  "name": "--control-height-default",
3149
3149
  "tier": "component",
3150
- "value": "var(--band-height-md)"
3150
+ "value": "var(--band-height-lg)"
3151
3151
  },
3152
3152
  {
3153
3153
  "description": "Control primitive tokens: heights, horizontal padding, adjacent control sizes.",
@@ -3234,10 +3234,10 @@
3234
3234
  "value": "var(--radius)"
3235
3235
  },
3236
3236
  {
3237
- "description": "Button corner radius — defaults to the button's historical `rounded-md` so nothing changes by default, but is its OWN knob so a service theme can retune the button radius INDEPENDENTLY of input/control radius (issue #124). The `var(--radius-md, …)` fallback is gh#888, NOT the same fix as this token's own freeze: Button reads this value through `rounded-[var(--button-radius)]`, a Tailwind utility (`@layer utilities`) that this package cannot give a fallback to from CSS — so this knob is deliberately NOT flipped to `initial` (that would still be unreachable from a scoped theme, gh#824 measured `--control-radius: 37px` on `:root` moving 0 buttons). What DOES need the fallback is the read of `--radius-md` itself: gh#888 unfreezes the φ tier globally, so without this fallback `--radius-md` resolves to nothing and `--button-radius` — and therefore every Button's corner — would go to 0. The formula keeps this declaration byte-identical to before gh#888 (still frozen at :root against --radius, same historical limitation).",
3237
+ "description": "Match the default 6px field corner while retaining an independent Button knob. The utility consumes a concrete value, so this remains a root alias; scoped services override --button-radius directly. Pill and sharp shapes keep their own tokens.",
3238
3238
  "name": "--button-radius",
3239
3239
  "tier": "component",
3240
- "value": "var(--radius-md, calc(var(--radius) / var(--radius-ratio)))"
3240
+ "value": "var(--radius)"
3241
3241
  },
3242
3242
  {
3243
3243
  "description": "THE BUTTON'S OWN SURFACE — one knob per painted variant (gh#880, standard §1). Every variant painted a bare role (`hsl(var(--primary))`, `hsl(var(--background))`, `hsl(var(--secondary))`, `hsl(var(--destructive))`) with no knob of its own, so the only way to retint a button was to retint the ROLE — and `--primary` is also the link ink, the focus mark and a chart series. There was no way to say \"this button is translucent\" without saying it about eight other things. WHY BUTTONS AT ALL. namethatui's source draws the line this library needed: glassmorphism is a decorative skin for ANY surface including controls, while Apple's Liquid Glass reserves glass for the floating control layer above opaque content. A glass page with a solid primary button on it is the Liquid Glass split applied backwards. FOUR FILLS, NOT SIX. `ghost` and `link` paint no resting surface, so they have nothing to knob. The HOVER fills are deliberately not knobs either: they already resolve from roles at their own call sites (`--primary-hover`, `--accent`, `--secondary`), so a theme reaches them today — the documented consequence being that a glass RESTING fill with an untouched hover gives a button that turns opaque under the pointer, and a theme that wants glass on both moves the role too. ONE BLUR FOR THE WHOLE COMPONENT, because the material is one decision per component and the variants are one component. `initial`, so unset there is no blur and no backdrop root. default = hsl(var(--primary)) at the call site",
@@ -3309,7 +3309,7 @@
3309
3309
  "description": "Control primitive tokens: heights, horizontal padding, adjacent control sizes.",
3310
3310
  "name": "--control-shadow",
3311
3311
  "tier": "component",
3312
- "value": "var(--shadow-xs)"
3312
+ "value": "none"
3313
3313
  },
3314
3314
  {
3315
3315
  "description": "Control primitive tokens: heights, horizontal padding, adjacent control sizes.",
@@ -3588,10 +3588,10 @@
3588
3588
  "value": "1"
3589
3589
  },
3590
3590
  {
3591
- "description": "Mark width for a BORDERED FIELD. This knob exists because a field is the one control that already owns a boundary, so it is the one place the mark could plausibly be dropped and the recoloured border left to carry the state — which is the established treatment for a bordered field: a primary border plus a translucent halo, nothing in between. It ships at the same step as `--focus-outline-weight`, the hairline stroke, so a field and a button carry the same weight. A gate asserts the two stay equal, because this token cannot literally READ the global one without freezing at :root (docs/TOKENS.md, the freeze rule). It feeds `--focus-ring-weight`, never `--focus-ring-width`. The width is `weight × --focus-outline`, so a field's mark is still switched off with everything else — a knob that bypassed the switch would be a hole in it. If a service sets this, note the unit: the halo's spread is summed with a length in `calc()`, and CSS refuses to add a unitless number to a length — a bare `0` makes the whole box-shadow invalid at computed-value time and it resolves to NONE. Write `0px`. default = var(--stroke-hairline), resolved at the call site (gh#906)",
3591
+ "description": "Mark width for a BORDERED FIELD. This knob exists because a field is the one control that already owns a boundary, so it is the one place the mark could plausibly be dropped and the recoloured border left to carry the state — which is the established treatment for a bordered field: a primary border plus a translucent halo, nothing in between. It ships at the same step as `--focus-outline-weight`, the hairline stroke, so a field and a button carry the same weight. A gate asserts the two stay equal, because this token cannot literally READ the global one without freezing at :root (docs/TOKENS.md, the freeze rule). It feeds `--focus-ring-weight`, never `--focus-ring-width`. The width is `weight × --focus-outline`, so a field's mark is still switched off with everything else — a knob that bypassed the switch would be a hole in it. If a service sets this, note the unit: the halo's spread is summed with a length in `calc()`, and CSS refuses to add a unitless number to a length — a bare `0` makes the whole box-shadow invalid at computed-value time and it resolves to NONE. Write `0px`. modern 2px mark, matching global focus",
3592
3592
  "name": "--control-focus-ring-width",
3593
3593
  "tier": "component",
3594
- "value": "initial"
3594
+ "value": "var(--stroke-md)"
3595
3595
  },
3596
3596
  {
3597
3597
  "description": "Outline-form gaps — marks where a radius-hugging ring would touch the glyph.",
@@ -7032,13 +7032,13 @@
7032
7032
  "value": "22.5rem"
7033
7033
  },
7034
7034
  {
7035
- "description": "Overlay scrims — `initial` so the shared semantic --overlay-background re-resolves at the CALL SITE under a scoped [data-tenant]/.dark theme (a :root binding to it freezes at :root and a scoped override never reaches the overlay). Each surface takes a SHARE of that one scrim rather than a private literal, so a service retints every backdrop with one --overlay-background while the calibrated per-surface depth survives: a slide-in Sheet washes the page more lightly than a modal Dialog. Defaults resolve to rgb(0 0 0 / 0.2) (sheet) and rgb(0 0 0 / 0.3) (dialog).",
7035
+ "description": "Overlay scrims — `initial` so the shared semantic --overlay-background re-resolves at the CALL SITE under a scoped [data-tenant]/.dark theme (a :root binding to it freezes at :root and a scoped override never reaches the overlay). Each surface takes a SHARE of that one scrim rather than a private literal, so a service retints every backdrop with one --overlay-background while the calibrated per-surface depth survives: a slide-in Sheet washes the page more lightly than a modal Dialog. Defaults resolve to rgb(0 0 0 / 0.16) (sheet) and rgb(0 0 0 / 0.24) (dialog).",
7036
7036
  "name": "--sheet-overlay-background",
7037
7037
  "tier": "component",
7038
7038
  "value": "initial"
7039
7039
  },
7040
7040
  {
7041
- "description": "A SHARE OF `--overlay-background`, NOT THE RESULTING ALPHA (gh#886). The base is a finished colour that already carries `/0.5`, so `40%` composites to **0.20**, and a theme author who sets `62%` gets 0.31. That is the library-wide convention for a `--*-alpha` knob in the `color-mix(in srgb, <base> <alpha>, transparent)` share form — 100% means \"the base as authored\", 0% means gone — and it is deliberate: the calibrated depth lives ONCE in `--overlay-background` so a service retints every scrim with one line. Want an absolute opacity instead? Set `--sheet-overlay-background` directly; it wins outright. Pinned by src/styles/__tests__/overlay-alpha-share-886.test.ts. 40% of rgb(0 0 0 / 0.5) = rgb(0 0 0 / 0.20)",
7041
+ "description": "A SHARE OF `--overlay-background`, NOT THE RESULTING ALPHA (gh#886). The base is a finished colour that already carries `/0.4`, so `40%` composites to **0.16**, and a theme author who sets `62%` gets 0.248. That is the library-wide convention for a `--*-alpha` knob in the `color-mix(in srgb, <base> <alpha>, transparent)` share form — 100% means \"the base as authored\", 0% means gone — and it is deliberate: the calibrated depth lives ONCE in `--overlay-background` so a service retints every scrim with one line. Want an absolute opacity instead? Set `--sheet-overlay-background` directly; it wins outright. Pinned by src/styles/__tests__/overlay-alpha-share-886.test.ts. 40% of rgb(0 0 0 / 0.4) = rgb(0 0 0 / 0.16)",
7042
7042
  "name": "--sheet-overlay-alpha",
7043
7043
  "tier": "component",
7044
7044
  "value": "40%"
@@ -7170,7 +7170,7 @@
7170
7170
  "value": "initial"
7171
7171
  },
7172
7172
  {
7173
- "description": "A SHARE OF `--overlay-background`, NOT THE RESULTING ALPHA (gh#886) — the miss that was reported, because this knob carried no note of its own and `60%` reads as \"0.6\". The base is a finished colour with `/0.5` baked in, so the product is **0.30**, and `62%` gives 0.31 rather than the darker scrim that was asked for. See `--sheet-overlay-alpha` above for the convention and for the escape hatch: `--dialog-overlay-background` is an absolute colour and wins outright. Pinned by src/styles/__tests__/overlay-alpha-share-886.test.ts. 60% of rgb(0 0 0 / 0.5) = rgb(0 0 0 / 0.30)",
7173
+ "description": "A SHARE OF `--overlay-background`, NOT THE RESULTING ALPHA (gh#886) — the miss that was reported, because this knob carried no note of its own and `60%` reads as \"0.6\". The base is a finished colour with `/0.4` baked in, so the product is **0.24**, and `62%` gives 0.248 rather than the darker scrim that was asked for. See `--sheet-overlay-alpha` above for the convention and for the escape hatch: `--dialog-overlay-background` is an absolute colour and wins outright. Pinned by src/styles/__tests__/overlay-alpha-share-886.test.ts. 60% of rgb(0 0 0 / 0.4) = rgb(0 0 0 / 0.24)",
7174
7174
  "name": "--dialog-overlay-alpha",
7175
7175
  "tier": "component",
7176
7176
  "value": "60%"
@@ -7956,10 +7956,10 @@
7956
7956
  "value": "var(--control-label-font-size, var(--text-sm))"
7957
7957
  },
7958
7958
  {
7959
- "description": "Row rhythm between a Form's own top-level blocks (gh#295) — e.g. the field group's CardContent and its CardFooter action row, or two FormFields that happen to be Form's OWN direct children (no Card framing). Unchanged from the historical value. 16px",
7959
+ "description": "Row rhythm between a Form's own top-level blocks (gh#295) — e.g. the field group's CardContent and its CardFooter action row, or two FormFields that happen to be Form's OWN direct children (no Card framing). Modern block spacing is 24px. 24px",
7960
7960
  "name": "--form-block-gap",
7961
7961
  "tier": "component",
7962
- "value": "var(--space-4)"
7962
+ "value": "var(--space-6)"
7963
7963
  },
7964
7964
  {
7965
7965
  "description": "THE VALIDATION FEEDBACK GLYPH. It was `controlIconClass`, which is `size-[length:var(--control-height)]` — the height of a whole CONTROL, meant for something that fills a control-sized box like the ColorPicker swatch. On a `text-xs` feedback line that rendered a ~32px icon beside ~12px text, which is what a reader actually saw and reported. A glyph that sits inline with text belongs on the ICON scale, one step above the text it accompanies so it reads as a mark rather than a letter. 14px beside 12px text",
@@ -7968,7 +7968,7 @@
7968
7968
  "value": "var(--icon-size-sm)"
7969
7969
  },
7970
7970
  {
7971
- "description": "Field-to-field row rhythm (gh#295) — mirrors --descriptions-row-gap so a read-only value mixed in via `FormField.staticText` (gh#294) and a real Descriptions block share ONE canonical rhythm. Applied via margin-based sibling spacing on FormField itself (not Form's flex `gap`, which only reaches DIRECT children — dead in the common `Form > CardContent > FormField*` composition every real Save-button form needs), so it holds at any DOM depth relative to Form. 12px",
7971
+ "description": "Field-to-field row rhythm (gh#295) — mirrors --descriptions-row-gap so a read-only value mixed in via `FormField.staticText` (gh#294) and a real Descriptions block share ONE canonical rhythm. Applied via margin-based sibling spacing on FormField itself (not Form's flex `gap`, which only reaches DIRECT children — dead in the common `Form > CardContent > FormField*` composition every real Save-button form needs), so it holds at any DOM depth relative to Form. 12px — shared with Descriptions",
7972
7972
  "name": "--form-field-row-gap",
7973
7973
  "tier": "component",
7974
7974
  "value": "var(--space-3)"
@@ -10722,13 +10722,13 @@
10722
10722
  "value": "22.5rem"
10723
10723
  },
10724
10724
  {
10725
- "description": "Mobile-drawer scrim — `initial` so the shared --overlay-background default re-resolves at the CALL SITE under a scoped [data-tenant]/.dark theme (a :root binding freezes at :root). Default = var(--app-shell-mobile-nav-alpha) of the global scrim, i.e. rgb(0 0 0 / 0.2) out of the box.",
10725
+ "description": "Mobile-drawer scrim — `initial` so the shared --overlay-background default re-resolves at the CALL SITE under a scoped [data-tenant]/.dark theme (a :root binding freezes at :root). Default = var(--app-shell-mobile-nav-alpha) of the global scrim, i.e. rgb(0 0 0 / 0.16) out of the box.",
10726
10726
  "name": "--app-shell-mobile-nav-background",
10727
10727
  "tier": "component",
10728
10728
  "value": "initial"
10729
10729
  },
10730
10730
  {
10731
- "description": "Share of the global --overlay-background this drawer's scrim uses. A slide-in drawer washes the page more lightly than a modal dialog; keeping it a RATIO means a service retints the whole system with one --overlay-background and every overlay keeps its calibrated depth. A SHARE, not the resulting alpha (gh#886): the base already carries `/0.5`, so the product is 0.20. 40% of rgb(0 0 0 / 0.5) = rgb(0 0 0 / 0.20)",
10731
+ "description": "Share of the global --overlay-background this drawer's scrim uses. A slide-in drawer washes the page more lightly than a modal dialog; keeping it a RATIO means a service retints the whole system with one --overlay-background and every overlay keeps its calibrated depth. A SHARE, not the resulting alpha (gh#886): the base already carries `/0.4`, so the product is 0.16. 40% of rgb(0 0 0 / 0.4) = rgb(0 0 0 / 0.16)",
10732
10732
  "name": "--app-shell-mobile-nav-alpha",
10733
10733
  "tier": "component",
10734
10734
  "value": "40%"
@@ -12195,7 +12195,7 @@
12195
12195
  "description": "Table component tokens: row height, cell padding.",
12196
12196
  "name": "--table-row-height-default",
12197
12197
  "tier": "component",
12198
- "value": "var(--band-height-md)"
12198
+ "value": "var(--band-height-lg)"
12199
12199
  },
12200
12200
  {
12201
12201
  "description": "Table component tokens: row height, cell padding.",
@@ -9,8 +9,8 @@ export { CheckboxGroup } from "./checkbox-group.js";
9
9
  export { Radio, RadioGroup, RadioItem, RadioGroupRoot } from "./radio.js";
10
10
  export { Textarea } from "./textarea.js";
11
11
  export type { TextareaProps } from "./textarea.js";
12
- export { TranslatableInput, TranslatableTextarea } from "./translatable-input.js";
13
- export type { TranslatableValue, TranslatableInputProp, TranslatableInputProps, TranslatableTextareaProp, TranslatableTextareaProps, } from "./translatable-input.js";
12
+ export { TranslatableField, TranslatableInput, TranslatableTextarea } from "./translatable-input.js";
13
+ export type { TranslatableValue, TranslatableLocaleProp, TranslatableFieldProp, TranslatableFieldProps, TranslatableFieldControlProp, TranslatableInputProp, TranslatableInputProps, TranslatableTextareaProp, TranslatableTextareaProps, } from "./translatable-input.js";
14
14
  export { Form, useFormLayout, type FormLayoutContextValue } from "./form.js";
15
15
  export type { FormProp, FormProps } from "./form.js";
16
16
  export { FormField } from "./form-field.js";
@@ -17,7 +17,7 @@ import { Checkbox } from "./checkbox.js";
17
17
  import { CheckboxGroup } from "./checkbox-group.js";
18
18
  import { Radio, RadioGroup, RadioItem, RadioGroupRoot } from "./radio.js";
19
19
  import { Textarea } from "./textarea.js";
20
- import { TranslatableInput, TranslatableTextarea } from "./translatable-input.js";
20
+ import { TranslatableField, TranslatableInput, TranslatableTextarea } from "./translatable-input.js";
21
21
  import { Form, useFormLayout } from "./form.js";
22
22
  import { FormField } from "./form-field.js";
23
23
  import { FormErrors, FormErrorsProvider } from "./form-errors.js";
@@ -126,6 +126,7 @@ export {
126
126
  ToggleGroup,
127
127
  ToggleGroupItem,
128
128
  Transfer,
129
+ TranslatableField,
129
130
  TranslatableInput,
130
131
  TranslatableTextarea,
131
132
  TreeSelect,
@@ -1,6 +1,26 @@
1
1
  import * as React from "react";
2
- import type { TranslatableValue } from "../../props/components/data-entry.prop.js";
3
- export type { TranslatableValue, TranslatableInputProp, TranslatableInputProp as TranslatableInputProps, TranslatableTextareaProp, TranslatableTextareaProp as TranslatableTextareaProps, } from "../../props/components/data-entry.prop.js";
2
+ import type { TranslatableFieldProp } from "../../props/components/data-entry.prop.js";
3
+ export type { TranslatableValue, TranslatableLocaleProp, TranslatableFieldProp, TranslatableFieldProp as TranslatableFieldProps, TranslatableFieldControlProp, TranslatableInputProp, TranslatableInputProp as TranslatableInputProps, TranslatableTextareaProp, TranslatableTextareaProp as TranslatableTextareaProps, } from "../../props/components/data-entry.prop.js";
4
+ /**
5
+ * TranslatableField — the shared engine of {@link TranslatableInput}, {@link TranslatableTextarea}
6
+ * and `@godxjp/block-editor`'s `TranslatableRichText` (gh#1365): it owns the locale list, the active
7
+ * tab, the per-locale map, the fallback placeholder, the error marks and the FormField error report,
8
+ * and renders the ONE control in the panel through `renderControl`. Every prop it does not own is
9
+ * passed through to that control.
10
+ *
11
+ * WHY THE SWITCHER IS `Tabs`, NOT `Segmented`. The WAI-ARIA APG radio group is for CHOOSING A VALUE
12
+ * — a segmented control's choice is form state and is submitted with the form. The locale here is
13
+ * not form state: every locale's text is submitted together, and the switcher only decides which
14
+ * of them the one editing region shows. That is the APG tabs pattern — a tablist whose selected
15
+ * tab controls one tabpanel — so a screen reader announces "English, tab, 2 of 3" and the region
16
+ * that follows, instead of offering a "language" question the form never asks.
17
+ *
18
+ * ONE CONTROL, NOT ONE PER LOCALE. The panel holds a single control whose value, `lang` and
19
+ * placeholder follow the active tab (`Tabs.controls` points the tab at it, gh#1021). Swapping
20
+ * elements per tab would hand the forwarded `ref`, the FormField `id` and an IME composition to a
21
+ * new node on every switch.
22
+ */
23
+ export declare function TranslatableField({ renderControl, toPlaceholder, value: valueProp, defaultValue, onValueChange, locales: localesProp, defaultLocale, fallbackLocale: fallbackLocaleProp, errors, size, placeholder, className, ...rest }: TranslatableFieldProp): React.JSX.Element;
4
24
  /**
5
25
  * TranslatableInput — ONE text field that holds a value per locale (`{ ja, en, vi }`), switched by
6
26
  * locale tabs above it (gh#1336). The locale list and the first tab come from `AppProvider`
@@ -8,22 +28,6 @@ export type { TranslatableValue, TranslatableInputProp, TranslatableInputProp as
8
28
  * its placeholder; a locale with an error is marked on its tab. Every other `Input` prop applies to
9
29
  * the single control, and `ref` is that control's `<input>`.
10
30
  */
11
- export declare const TranslatableInput: React.ForwardRefExoticComponent<Omit<import("./input.js").InputProp, "defaultValue" | "lang" | "onValueChange" | "value"> & {
12
- value?: import("../../props/index.js").ValueProp<TranslatableValue>;
13
- defaultValue?: import("../../props/index.js").DefaultValueProp<TranslatableValue>;
14
- onValueChange?: import("../../props/index.js").OnValueChangeProp<TranslatableValue>;
15
- locales?: readonly string[];
16
- defaultLocale?: string;
17
- fallbackLocale?: string;
18
- errors?: Partial<Record<string, import("../../props/index.js").ErrorProp>>;
19
- } & React.RefAttributes<HTMLInputElement>>;
31
+ export declare const TranslatableInput: React.ForwardRefExoticComponent<Omit<import("./input.js").InputProp, "defaultValue" | "lang" | "onValueChange" | "value"> & import("./translatable-input.js").TranslatableLocaleProp & React.RefAttributes<HTMLInputElement>>;
20
32
  /** TranslatableTextarea — {@link TranslatableInput} over a `Textarea`; `ref` is the `<textarea>`. */
21
- export declare const TranslatableTextarea: React.ForwardRefExoticComponent<Omit<import("./textarea.js").TextareaProp, "defaultValue" | "lang" | "onValueChange" | "value"> & {
22
- value?: import("../../props/index.js").ValueProp<TranslatableValue>;
23
- defaultValue?: import("../../props/index.js").DefaultValueProp<TranslatableValue>;
24
- onValueChange?: import("../../props/index.js").OnValueChangeProp<TranslatableValue>;
25
- locales?: readonly string[];
26
- defaultLocale?: string;
27
- fallbackLocale?: string;
28
- errors?: Partial<Record<string, import("../../props/index.js").ErrorProp>>;
29
- } & React.RefAttributes<HTMLTextAreaElement>>;
33
+ export declare const TranslatableTextarea: React.ForwardRefExoticComponent<Omit<import("./textarea.js").TextareaProp, "defaultValue" | "lang" | "onValueChange" | "value"> & import("./translatable-input.js").TranslatableLocaleProp & React.RefAttributes<HTMLTextAreaElement>>;
@@ -21,7 +21,8 @@ function localeName(code, uiLocale) {
21
21
  }
22
22
  const idPart = (code) => code.replace(/[^A-Za-z0-9_-]/g, "_");
23
23
  function TranslatableField({
24
- kind,
24
+ renderControl,
25
+ toPlaceholder,
25
26
  value: valueProp,
26
27
  defaultValue,
27
28
  onValueChange,
@@ -32,7 +33,6 @@ function TranslatableField({
32
33
  size,
33
34
  placeholder,
34
35
  className,
35
- forwardedRef,
36
36
  ...rest
37
37
  }) {
38
38
  const { t, locale: uiLocale, fallbackLocale: appFallbackLocale } = useTranslation();
@@ -74,11 +74,11 @@ function TranslatableField({
74
74
  const showOwnError = hasActiveError && !insideFormField;
75
75
  const controlProps = {
76
76
  ...rest,
77
- ref: forwardedRef,
77
+ size,
78
78
  lang: active,
79
79
  value: activeText,
80
80
  onValueChange: handleText,
81
- placeholder: fallbackText ? fallbackText : placeholder,
81
+ placeholder: fallbackText ? toPlaceholder?.(fallbackText) ?? fallbackText : placeholder,
82
82
  // The control is named by the field AND the locale it holds: "商品名 English".
83
83
  "aria-labelledby": labelledBy ? `${labelledBy} ${localeNameId}` : void 0,
84
84
  "aria-invalid": hasActiveError ? true : rest["aria-invalid"],
@@ -139,19 +139,7 @@ function TranslatableField({
139
139
  id: panelId,
140
140
  "aria-labelledby": tabId(active),
141
141
  className: "ui-translatable-field-panel",
142
- children: kind === "input" ? /* @__PURE__ */ jsx(
143
- Input,
144
- {
145
- ...controlProps,
146
- size
147
- }
148
- ) : /* @__PURE__ */ jsx(
149
- Textarea,
150
- {
151
- ...controlProps,
152
- size
153
- }
154
- )
142
+ children: renderControl(controlProps)
155
143
  }
156
144
  ),
157
145
  showOwnError ? /* @__PURE__ */ jsx("p", { id: ownErrorId, role: "alert", className: "ui-form-field-note text-error-strong text-xs", children: activeError }) : null
@@ -164,8 +152,7 @@ const TranslatableInput = React.forwardRef(
164
152
  TranslatableField,
165
153
  {
166
154
  ...props,
167
- kind: "input",
168
- forwardedRef: ref
155
+ renderControl: (control) => /* @__PURE__ */ jsx(Input, { ...control, ref })
169
156
  }
170
157
  )
171
158
  );
@@ -175,13 +162,13 @@ const TranslatableTextarea = React.forwardRef(
175
162
  TranslatableField,
176
163
  {
177
164
  ...props,
178
- kind: "textarea",
179
- forwardedRef: ref
165
+ renderControl: (control) => /* @__PURE__ */ jsx(Textarea, { ...control, ref })
180
166
  }
181
167
  )
182
168
  );
183
169
  TranslatableTextarea.displayName = "TranslatableTextarea";
184
170
  export {
171
+ TranslatableField,
185
172
  TranslatableInput,
186
173
  TranslatableTextarea
187
174
  };
@@ -15,10 +15,6 @@ export type TabsScrollBox = {
15
15
  * coordinates, so it is direction- and orientation-agnostic (LTR, RTL, vertical rail alike).
16
16
  */
17
17
  export declare function isTabFullyVisible(scrollport: TabsScrollBox, trigger: TabsScrollBox): boolean;
18
- /**
19
- * The trigger the strip must keep reachable: the focused trigger when the roving focus is inside
20
- * this list (manual activation can park focus off the selected tab), otherwise the active one.
21
- */
22
18
  export declare function resolveTabScrollTarget(list: HTMLElement): HTMLElement | null;
23
19
  /** `true` when the user has asked for reduced motion (falsy/unsupported environments say no). */
24
20
  export declare function prefersReducedMotion(): boolean;
@@ -80,7 +76,7 @@ export declare function useTabsScrollReporter(listRef: React.RefObject<HTMLEleme
80
76
  * removed) and the strip scrolling (the user swiping, or `useKeepActiveTabVisible` re-pinning a
81
77
  * trigger).
82
78
  *
83
- * `values` is joined into the effect key rather than passed as a dependency, so a consumer that
79
+ * `values` is serialized into the effect key rather than passed as a dependency, so a consumer that
84
80
  * rebuilds its `items` array on every render does not re-arm the observers on every render.
85
81
  *
86
82
  * The first measurement ALWAYS reports, even when nothing is hidden. Suppressing it looks harmless
@@ -7,12 +7,15 @@ function isTabFullyVisible(scrollport, trigger) {
7
7
  if (width <= 0 && height <= 0) return true;
8
8
  return trigger.left >= scrollport.left - VISIBILITY_EPSILON && trigger.right <= scrollport.right + VISIBILITY_EPSILON && trigger.top >= scrollport.top - VISIBILITY_EPSILON && trigger.bottom <= scrollport.bottom + VISIBILITY_EPSILON;
9
9
  }
10
+ const TAB_TRIGGER_SELECTOR = '[role="tab"], [data-slot="tabs-trigger"]';
10
11
  function resolveTabScrollTarget(list) {
11
12
  const focused = list.ownerDocument?.activeElement;
12
- if (focused instanceof HTMLElement && focused !== list && list.contains(focused) && focused.getAttribute("role") === "tab") {
13
+ if (focused instanceof HTMLElement && focused !== list && list.contains(focused) && focused.matches(TAB_TRIGGER_SELECTOR)) {
13
14
  return focused;
14
15
  }
15
- return list.querySelector('[role="tab"][data-state="active"]');
16
+ return list.querySelector(
17
+ '[role="tab"][data-state="active"], [data-slot="tabs-trigger"][data-state="active"]'
18
+ );
16
19
  }
17
20
  function prefersReducedMotion() {
18
21
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
@@ -69,7 +72,7 @@ function useKeepActiveTabVisible(listRef) {
69
72
  }
70
73
  function resolveHiddenTabValues(list, values) {
71
74
  const scrollport = list.getBoundingClientRect();
72
- const triggers = [...list.querySelectorAll('[role="tab"]')];
75
+ const triggers = [...list.querySelectorAll(TAB_TRIGGER_SELECTOR)];
73
76
  const hidden = [];
74
77
  triggers.forEach((trigger, index) => {
75
78
  const value = values[index];
@@ -115,16 +118,30 @@ function useTabsOverflowValues(listRef, values, onChange) {
115
118
  React.useEffect(() => {
116
119
  onChangeRef.current = onChange;
117
120
  });
118
- const key = values === void 0 ? null : values.join(" ");
119
- React.useEffect(() => {
121
+ const key = values === void 0 ? null : JSON.stringify(values);
122
+ React.useLayoutEffect(() => {
120
123
  if (key === null) return void 0;
121
124
  const list = listRef.current;
122
125
  if (!list) return void 0;
123
- const all = key === "" ? [] : key.split(" ");
126
+ const all = JSON.parse(key);
124
127
  let last = null;
125
128
  const measure = () => {
126
- const hidden = resolveHiddenTabValues(list, all);
127
- const next = hidden.join(" ");
129
+ const triggers = [...list.querySelectorAll(TAB_TRIGGER_SELECTOR)];
130
+ const menu = list.parentElement?.querySelector(".ui-tabs-overflow");
131
+ const vertical = list.dataset.orientation === "vertical";
132
+ const style = getComputedStyle(list);
133
+ const boxes = triggers.map((trigger) => trigger.getBoundingClientRect());
134
+ const extent = boxes.length ? Math.max(...boxes.map((box2) => vertical ? box2.bottom : box2.right)) - Math.min(...boxes.map((box2) => vertical ? box2.top : box2.left)) : 0;
135
+ const padding = vertical ? parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) : parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
136
+ const box = list.getBoundingClientRect();
137
+ const menuBox = menu?.getBoundingClientRect();
138
+ const gap = menu && list.parentElement ? parseFloat(getComputedStyle(list.parentElement).gap) || 0 : 0;
139
+ const fitsWithoutMenu = menuBox && extent + padding <= (vertical ? box.height + menuBox.height : box.width + menuBox.width) + gap;
140
+ const hidden = fitsWithoutMenu ? [] : resolveHiddenTabValues(list, all);
141
+ triggers.forEach((trigger, index) => {
142
+ trigger.toggleAttribute("data-overflow-hidden", hidden.includes(all[index]));
143
+ });
144
+ const next = JSON.stringify(hidden);
128
145
  if (next === last) return;
129
146
  last = next;
130
147
  onChangeRef.current(hidden);
@@ -139,12 +156,18 @@ function useTabsOverflowValues(listRef, values, onChange) {
139
156
  if (typeof ResizeObserver !== "undefined") {
140
157
  const resizeObserver = new ResizeObserver(measure);
141
158
  resizeObserver.observe(list);
159
+ list.querySelectorAll(TAB_TRIGGER_SELECTOR).forEach((trigger) => {
160
+ resizeObserver.observe(trigger);
161
+ });
142
162
  cleanups.push(() => {
143
163
  resizeObserver.disconnect();
144
164
  });
145
165
  }
146
166
  return () => {
147
167
  for (const cleanup of cleanups) cleanup();
168
+ list.querySelectorAll(TAB_TRIGGER_SELECTOR).forEach((trigger) => {
169
+ trigger.removeAttribute("data-overflow-hidden");
170
+ });
148
171
  };
149
172
  }, [key, listRef]);
150
173
  }
@@ -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, controls, listClassName, contentClassName, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...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, linkComponent, 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
  /**