@godxjp/ui 33.8.0 → 33.9.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.
@@ -19,6 +19,7 @@ import {
19
19
  DropdownMenuTrigger
20
20
  } from "./dropdown-menu.js";
21
21
  import {
22
+ syncActiveTabIntoView,
22
23
  useKeepActiveTabVisible,
23
24
  useTabsOverflowValues,
24
25
  useTabsScrollReporter
@@ -41,6 +42,8 @@ function TabCount({
41
42
  const { pill } = useCounterPill({ count, overflowCount, showZero, countLabel, slot: "tabs" });
42
43
  return pill;
43
44
  }
45
+ const TABS_LIST_BASE_CLASS_NAME = "group/tabs-list text-muted-foreground inline-flex w-fit max-w-full min-w-0 items-center justify-center-safe rounded-lg p-1 group-data-[orientation=vertical]/tabs:flex-col data-[orientation=horizontal]:[scrollbar-width:none] data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden data-[variant=line]:gap-[var(--tabs-list-line-space-gap)] data-[variant=line]:rounded-none data-[variant=line]:bg-transparent [&[data-orientation=horizontal]::-webkit-scrollbar]:hidden";
46
+ const TABS_TRIGGER_BASE_CLASS_NAME = "text-muted-foreground ring-offset-background hover:text-foreground ui-focus-ring data-[state=active]:text-foreground group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:border-primary/25 relative inline-flex flex-1 items-center justify-center gap-1.5 rounded-md border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-all data-[overflow-hidden]:transition-none data-[overflow-hidden]:opacity-0 data-[overflow-hidden]:pointer-events-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:flex-none group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:border-e-0 group-data-[variant=line]/tabs-list:border-b-0 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none";
44
47
  const NO_LIST_LABEL = {};
45
48
  const TabsFrameContext = React.createContext({
46
49
  orientation: "horizontal",
@@ -126,6 +129,7 @@ function Tabs({
126
129
  moreIcon,
127
130
  onTabScroll,
128
131
  controls,
132
+ linkComponent,
129
133
  listClassName,
130
134
  contentClassName,
131
135
  children,
@@ -173,7 +177,8 @@ function Tabs({
173
177
  [resolvedOrientation, selectionSuppressed, size, panels, controls, listLabel]
174
178
  );
175
179
  const editable = variant === "editable-card";
176
- const collapsible = items != null && overflow === "menu";
180
+ const routeStrip = items != null && items.some((item) => item.href !== void 0);
181
+ const collapsible = items != null && overflow === "menu" && !routeStrip;
177
182
  const listRef = React.useRef(null);
178
183
  const [hiddenValues, setHiddenValues] = React.useState([]);
179
184
  const itemValues = React.useMemo(
@@ -214,53 +219,99 @@ function Tabs({
214
219
  start: "rounded-s-[var(--tabs-card-radius)] rounded-e-none data-[state=active]:border-e-[hsl(var(--tabs-panel-background,var(--background)))]",
215
220
  end: "rounded-e-[var(--tabs-card-radius)] rounded-s-none data-[state=active]:border-s-[hsl(var(--tabs-panel-background,var(--background)))]"
216
221
  };
222
+ const itemsListClassName = cn(
223
+ // The inset override is an arbitrary value reading the knob (never `p-0`): it must
224
+ // stay a UTILITY so tailwind-merge still drops the strip's own padding step.
225
+ //
226
+ // IT IS SPLIT INTO `px-`/`py-` FOR THE FOCUS RING (gh#376). The strip clips its
227
+ // block axis (`overflow-y: hidden` beside `overflow-x: auto`), and this variant's
228
+ // inset is 0px, so a focused trigger had NO block headroom at all — measured in
229
+ // Chromium, 0px top and bottom, i.e. the entire ring was shaved. The headroom has to
230
+ // come from layout: `overflow-y: visible` next to `overflow-x: auto` computes back
231
+ // to `auto` per spec (a second scroll container, not a ring), and Chromium honours
232
+ // `overflow-clip-margin` only when BOTH axes are `clip`. So the block padding carries
233
+ // the ring's outer reach and an equal negative block margin hands it straight back —
234
+ // the ring paints inside the scrollport and the strip's OUTER box is unchanged
235
+ // (measured: 38px before and after). Both values read the same token the ring is
236
+ // sized from, so the two can never drift apart.
237
+ //
238
+ // The cost, recorded rather than hidden: the rail hairline (`border-b`, drawn at the
239
+ // border box) now sits the ring's reach below the triggers instead of flush. That is
240
+ // where a hand-composed <TabsList variant="line"> already put it — its `p-1` gives
241
+ // the same block inset — so the two construction paths now agree. A service that
242
+ // wants the active bar parked back on the hairline raises `--tabs-indicator-offset`,
243
+ // which exists for exactly that.
244
+ // `w-full` is scoped to the HORIZONTAL axis. Unscoped it also applied when the root is
245
+ // vertical, where the strip is a COLUMN beside the panel: `width: 100%` made the strip
246
+ // claim the whole row and the panel collapsed to its own min-content floor. Measured at a
247
+ // 1232px root: strip 1133.72px (92%) / panel 90.28px (7%). And it is not a layer problem —
248
+ // tailwind-merge drops the base `w-fit` as a same-group conflict, so `w-fit` never reached
249
+ // the DOM at all. Scoping puts it back on the axis it was written for.
250
+ variant === "line" && "my-[calc(-1_*_var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width))))] h-auto justify-start border-b px-[var(--tabs-list-line-space-inset)] py-[calc(var(--tabs-list-line-space-inset)_+_var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width))))] data-[orientation=horizontal]:w-full",
251
+ // CARD strip. The list keeps `data-variant="default"` on purpose (a hand-composed
252
+ // <TabsList> must be unaffected), so the card face is selected from the ROOT — but the
253
+ // three properties the base list already claims as utilities (`bg-muted`, `p-1`,
254
+ // `rounded-lg`) have to be replaced with utilities too, or the components layer loses.
255
+ card && "items-end justify-start gap-[var(--tabs-card-list-space-gap)] rounded-[var(--tabs-card-list-radius)] p-[var(--tabs-card-list-space-inset)] data-[orientation=horizontal]:w-full data-[variant=default]:bg-transparent",
256
+ // CENTERED. Two independent moves, because the strip has two shapes: the pill/card strip
257
+ // is `w-fit` (auto inline margins centre the BOX) and the line strip is `w-full`
258
+ // (`justify-content` centres its CONTENT). `safe` keeps the overflow rule the strip
259
+ // already depends on — plain `center` strands the leading tab outside the scrollport.
260
+ centered && "mx-auto justify-center-safe",
261
+ listClassName
262
+ );
263
+ const itemTriggerClassName = cn(
264
+ // Geometry only — the active underline is the token-owned `::after` bar the
265
+ // line variant already owns (never a second, hand-rolled border-b indicator).
266
+ // They are arbitrary-value utilities so tailwind-merge keeps dropping the pill
267
+ // trigger's own radius / padding steps from the base class list.
268
+ variant === "line" && "rounded-[var(--tabs-trigger-line-radius)] px-[var(--tabs-trigger-line-padding-x)] py-[var(--tabs-trigger-line-padding-y)]",
269
+ sizeTriggerClassName,
270
+ // A centred strip must let its triggers SHRINK to their labels first. The base
271
+ // trigger is `flex-1`, so on a full-width (`line`) strip they already fill every
272
+ // pixel and `justify-content` has nothing left to centre — measured, the leading and
273
+ // trailing gaps were both 0px with `centered` on and off.
274
+ centered && "flex-none",
275
+ // LINE face: same argument as the card face below, and from the same source — antd
276
+ // puts no `flex-grow` on a tab of ANY `type`, so a line tab is content-width too.
277
+ // `card` was fixed on that reasoning (gh#736); `line` was left on the base `flex-1`
278
+ // and kept stretching. Measured by a consumer on a 1200px strip: three tabs at
279
+ // 321px each for labels needing 83 / 84 / 56px, so the active underline ran 321px
280
+ // under a 56px label.
281
+ //
282
+ // `centered` is NOT the escape hatch for this: it centres, which trades one wrong
283
+ // geometry for another, and it is antd's prop for centring rather than for width.
284
+ variant === "line" && "flex-none",
285
+ // The merged edge is the SURFACE colour, never `transparent`: the rail is an inset
286
+ // shadow at the strip's padding-box edge, so a see-through border would let that 1px
287
+ // of rail run straight across the tab it is joined to.
288
+ // CARD face: a real boundary on every side, opened toward the panel by
289
+ // `CARD_FACE`. `flex-none` is antd's geometry, not a preference — its
290
+ // `nav-list` is a plain `display: flex` with no `flex-grow` on the tab, so a
291
+ // card tab is CONTENT-WIDTH. The base trigger is `flex-1`, which made every
292
+ // card tab stretch to an equal share of the strip: measured, three tabs in a
293
+ // 942px strip came out 311px each for labels needing ~70px, and with
294
+ // `justify-center` the label and its × floated in the middle of that box —
295
+ // the × ended up 90px from the tab's own trailing edge (179px on a two-tab
296
+ // strip). antd's is 8px away. This is also why the face read as a free-standing
297
+ // bordered box rather than a tab.
298
+ //
299
+ // THE ACTIVE FACE IS THE BODY'S SURFACE, from the same knob the body reads
300
+ // (gh#762). The base trigger's `data-[state=active]:bg-background` was right only
301
+ // while the two could not disagree; once a service can retint the body, an active
302
+ // tab still painting `--background` reads as a DIFFERENT box sitting on the panel
303
+ // — which is the "two stacked boxes" complaint in one property. Same utilities
304
+ // group, so tailwind-merge drops the base step instead of stacking two fills.
305
+ card && "flex-none border-[color:hsl(var(--border))] bg-[hsl(var(--tabs-card-background,var(--muted)))] data-[state=active]:bg-[hsl(var(--tabs-panel-background,var(--background)))]",
306
+ card && CARD_FACE[placement]
307
+ );
217
308
  const list = items ? /* @__PURE__ */ jsx(
218
309
  TabsList,
219
310
  {
220
311
  ref: listRef,
221
312
  "data-slot": "tabs-list",
222
313
  variant: variant === "line" ? "line" : "default",
223
- className: cn(
224
- // The inset override is an arbitrary value reading the knob (never `p-0`): it must
225
- // stay a UTILITY so tailwind-merge still drops the strip's own padding step.
226
- //
227
- // IT IS SPLIT INTO `px-`/`py-` FOR THE FOCUS RING (gh#376). The strip clips its
228
- // block axis (`overflow-y: hidden` beside `overflow-x: auto`), and this variant's
229
- // inset is 0px, so a focused trigger had NO block headroom at all — measured in
230
- // Chromium, 0px top and bottom, i.e. the entire ring was shaved. The headroom has to
231
- // come from layout: `overflow-y: visible` next to `overflow-x: auto` computes back
232
- // to `auto` per spec (a second scroll container, not a ring), and Chromium honours
233
- // `overflow-clip-margin` only when BOTH axes are `clip`. So the block padding carries
234
- // the ring's outer reach and an equal negative block margin hands it straight back —
235
- // the ring paints inside the scrollport and the strip's OUTER box is unchanged
236
- // (measured: 38px before and after). Both values read the same token the ring is
237
- // sized from, so the two can never drift apart.
238
- //
239
- // The cost, recorded rather than hidden: the rail hairline (`border-b`, drawn at the
240
- // border box) now sits the ring's reach below the triggers instead of flush. That is
241
- // where a hand-composed <TabsList variant="line"> already put it — its `p-1` gives
242
- // the same block inset — so the two construction paths now agree. A service that
243
- // wants the active bar parked back on the hairline raises `--tabs-indicator-offset`,
244
- // which exists for exactly that.
245
- // `w-full` is scoped to the HORIZONTAL axis. Unscoped it also applied when the root is
246
- // vertical, where the strip is a COLUMN beside the panel: `width: 100%` made the strip
247
- // claim the whole row and the panel collapsed to its own min-content floor. Measured at a
248
- // 1232px root: strip 1133.72px (92%) / panel 90.28px (7%). And it is not a layer problem —
249
- // tailwind-merge drops the base `w-fit` as a same-group conflict, so `w-fit` never reached
250
- // the DOM at all. Scoping puts it back on the axis it was written for.
251
- variant === "line" && "my-[calc(-1_*_var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width))))] h-auto justify-start border-b px-[var(--tabs-list-line-space-inset)] py-[calc(var(--tabs-list-line-space-inset)_+_var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width))))] data-[orientation=horizontal]:w-full",
252
- // CARD strip. The list keeps `data-variant="default"` on purpose (a hand-composed
253
- // <TabsList> must be unaffected), so the card face is selected from the ROOT — but the
254
- // three properties the base list already claims as utilities (`bg-muted`, `p-1`,
255
- // `rounded-lg`) have to be replaced with utilities too, or the components layer loses.
256
- card && "items-end justify-start gap-[var(--tabs-card-list-space-gap)] rounded-[var(--tabs-card-list-radius)] p-[var(--tabs-card-list-space-inset)] data-[orientation=horizontal]:w-full data-[variant=default]:bg-transparent",
257
- // CENTERED. Two independent moves, because the strip has two shapes: the pill/card strip
258
- // is `w-fit` (auto inline margins centre the BOX) and the line strip is `w-full`
259
- // (`justify-content` centres its CONTENT). `safe` keeps the overflow rule the strip
260
- // already depends on — plain `center` strands the leading tab outside the scrollport.
261
- centered && "mx-auto justify-center-safe",
262
- listClassName
263
- ),
314
+ className: itemsListClassName,
264
315
  children: items.map((item) => {
265
316
  const removable = editable && Boolean(onEdit) && item.closable !== false && !item.disabled;
266
317
  return /* @__PURE__ */ jsxs(
@@ -275,51 +326,7 @@ function Tabs({
275
326
  event.preventDefault();
276
327
  onEdit?.(item.value, "remove");
277
328
  } : void 0,
278
- className: cn(
279
- // Geometry only — the active underline is the token-owned `::after` bar the
280
- // line variant already owns (never a second, hand-rolled border-b indicator).
281
- // They are arbitrary-value utilities so tailwind-merge keeps dropping the pill
282
- // trigger's own radius / padding steps from the base class list.
283
- variant === "line" && "rounded-[var(--tabs-trigger-line-radius)] px-[var(--tabs-trigger-line-padding-x)] py-[var(--tabs-trigger-line-padding-y)]",
284
- sizeTriggerClassName,
285
- // A centred strip must let its triggers SHRINK to their labels first. The base
286
- // trigger is `flex-1`, so on a full-width (`line`) strip they already fill every
287
- // pixel and `justify-content` has nothing left to centre — measured, the leading and
288
- // trailing gaps were both 0px with `centered` on and off.
289
- centered && "flex-none",
290
- // LINE face: same argument as the card face below, and from the same source — antd
291
- // puts no `flex-grow` on a tab of ANY `type`, so a line tab is content-width too.
292
- // `card` was fixed on that reasoning (gh#736); `line` was left on the base `flex-1`
293
- // and kept stretching. Measured by a consumer on a 1200px strip: three tabs at
294
- // 321px each for labels needing 83 / 84 / 56px, so the active underline ran 321px
295
- // under a 56px label.
296
- //
297
- // `centered` is NOT the escape hatch for this: it centres, which trades one wrong
298
- // geometry for another, and it is antd's prop for centring rather than for width.
299
- variant === "line" && "flex-none",
300
- // The merged edge is the SURFACE colour, never `transparent`: the rail is an inset
301
- // shadow at the strip's padding-box edge, so a see-through border would let that 1px
302
- // of rail run straight across the tab it is joined to.
303
- // CARD face: a real boundary on every side, opened toward the panel by
304
- // `CARD_FACE`. `flex-none` is antd's geometry, not a preference — its
305
- // `nav-list` is a plain `display: flex` with no `flex-grow` on the tab, so a
306
- // card tab is CONTENT-WIDTH. The base trigger is `flex-1`, which made every
307
- // card tab stretch to an equal share of the strip: measured, three tabs in a
308
- // 942px strip came out 311px each for labels needing ~70px, and with
309
- // `justify-center` the label and its × floated in the middle of that box —
310
- // the × ended up 90px from the tab's own trailing edge (179px on a two-tab
311
- // strip). antd's is 8px away. This is also why the face read as a free-standing
312
- // bordered box rather than a tab.
313
- //
314
- // THE ACTIVE FACE IS THE BODY'S SURFACE, from the same knob the body reads
315
- // (gh#762). The base trigger's `data-[state=active]:bg-background` was right only
316
- // while the two could not disagree; once a service can retint the body, an active
317
- // tab still painting `--background` reads as a DIFFERENT box sitting on the panel
318
- // — which is the "two stacked boxes" complaint in one property. Same utilities
319
- // group, so tailwind-merge drops the base step instead of stacking two fills.
320
- card && "flex-none border-[color:hsl(var(--border))] bg-[hsl(var(--tabs-card-background,var(--muted)))] data-[state=active]:bg-[hsl(var(--tabs-panel-background,var(--background)))]",
321
- card && CARD_FACE[placement]
322
- ),
329
+ className: itemTriggerClassName,
323
330
  children: [
324
331
  item.icon ? /* @__PURE__ */ jsx(
325
332
  "span",
@@ -380,6 +387,41 @@ function Tabs({
380
387
  })
381
388
  }
382
389
  ) : null;
390
+ if (routeStrip) {
391
+ return /* @__PURE__ */ jsx(
392
+ TabsRouteStrip,
393
+ {
394
+ items,
395
+ activeValue,
396
+ overflow,
397
+ orientation: resolvedOrientation,
398
+ linkComponent,
399
+ onTabClick,
400
+ onTabScroll,
401
+ listLabel,
402
+ listClassName: itemsListClassName,
403
+ listVariant: variant === "line" ? "line" : "default",
404
+ triggerClassName: itemTriggerClassName,
405
+ extraStart,
406
+ extraEnd,
407
+ className: cn("group/tabs flex data-[orientation=horizontal]:flex-col", className),
408
+ rootProps: {
409
+ ...props,
410
+ dir,
411
+ "data-slot": "tabs",
412
+ "data-orientation": resolvedOrientation,
413
+ "data-variant": variant,
414
+ "data-placement": placement,
415
+ "data-size": size,
416
+ "data-centered": centered ? "true" : void 0,
417
+ "data-animated-ink-bar": motion.inkBar ? "true" : "false",
418
+ "data-animated-tab-pane": motion.tabPane ? "true" : "false",
419
+ "data-indicator-size": indicatorSize,
420
+ "data-indicator-align": indicatorAlign
421
+ }
422
+ }
423
+ );
424
+ }
383
425
  return /* @__PURE__ */ jsx(TabsFrameContext.Provider, { value: frame, children: /* @__PURE__ */ jsx(TabsPanelRegistryContext.Provider, { value: registerPanel, children: /* @__PURE__ */ jsx(
384
426
  AriaTabs,
385
427
  {
@@ -480,6 +522,139 @@ function Tabs({
480
522
  }
481
523
  ) }) });
482
524
  }
525
+ function TabsRouteStrip({
526
+ overflow,
527
+ items,
528
+ activeValue,
529
+ orientation,
530
+ linkComponent,
531
+ onTabClick,
532
+ onTabScroll,
533
+ listLabel,
534
+ listClassName,
535
+ listVariant,
536
+ triggerClassName,
537
+ extraStart,
538
+ extraEnd,
539
+ className,
540
+ rootProps
541
+ }) {
542
+ const { t } = useTranslation();
543
+ const navRef = React.useRef(null);
544
+ useKeepActiveTabVisible(navRef);
545
+ useTabsScrollReporter(navRef, orientation === "vertical", onTabScroll);
546
+ const [hiddenValues, setHiddenValues] = React.useState([]);
547
+ useTabsOverflowValues(
548
+ navRef,
549
+ overflow === "menu" ? items.map((item) => item.value) : void 0,
550
+ setHiddenValues
551
+ );
552
+ const hiddenItems = overflow === "menu" ? items.filter((item) => hiddenValues.includes(item.value)) : [];
553
+ const MenuLink = linkComponent ?? "a";
554
+ const Link = linkComponent ?? "a";
555
+ const named = listLabel["aria-label"] !== void 0 || listLabel["aria-labelledby"] !== void 0;
556
+ const nav = /* @__PURE__ */ jsx(
557
+ "nav",
558
+ {
559
+ ref: navRef,
560
+ "data-slot": "tabs-list",
561
+ "data-variant": listVariant,
562
+ "data-orientation": orientation,
563
+ "aria-label": named ? listLabel["aria-label"] : t("navigation.tabs.routeNav"),
564
+ "aria-labelledby": listLabel["aria-labelledby"],
565
+ onFocus: () => syncActiveTabIntoView(navRef.current),
566
+ className: cn(
567
+ TABS_LIST_BASE_CLASS_NAME,
568
+ listClassName,
569
+ // Keep the first/last link's focus ring inside the clipped inline scrollport too.
570
+ listVariant === "line" && "scroll-px-[var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width)))] px-[max(var(--tabs-list-line-space-inset),var(--tabs-list-focus-ring-space-inset,calc(var(--focus-ring-width)_+_var(--focus-ring-glow-width))))]"
571
+ ),
572
+ children: items.map((item) => {
573
+ const active = item.value === activeValue;
574
+ const inert = item.disabled === true || item.href === void 0;
575
+ return /* @__PURE__ */ jsxs(
576
+ Link,
577
+ {
578
+ href: inert ? void 0 : item.href,
579
+ role: inert && linkComponent === void 0 ? "link" : void 0,
580
+ "aria-current": active ? "page" : void 0,
581
+ "aria-disabled": inert ? true : void 0,
582
+ "data-active": active ? "true" : void 0,
583
+ "data-slot": "tabs-trigger",
584
+ "data-state": active ? "active" : "inactive",
585
+ "data-orientation": orientation,
586
+ "data-value": item.value,
587
+ className: cn(
588
+ TABS_TRIGGER_BASE_CLASS_NAME,
589
+ // `disabled:` never matches an anchor; an inert route reads `aria-disabled` instead.
590
+ "aria-disabled:pointer-events-none aria-disabled:opacity-[var(--disabled-opacity)]",
591
+ triggerClassName
592
+ ),
593
+ onClick: onTabClick ? (event) => (
594
+ // The published event type names the tablist's <button>; here currentTarget
595
+ // is the <a>. Never `preventDefault()` — the browser owns the link.
596
+ onTabClick(item.value, event)
597
+ ) : void 0,
598
+ children: [
599
+ item.icon ? /* @__PURE__ */ jsx(
600
+ "span",
601
+ {
602
+ "data-slot": "tabs-trigger-icon",
603
+ className: "ui-tabs-trigger-icon",
604
+ "aria-hidden": "true",
605
+ children: item.icon
606
+ }
607
+ ) : null,
608
+ item.label,
609
+ /* @__PURE__ */ jsx(
610
+ TabCount,
611
+ {
612
+ count: item.count,
613
+ overflowCount: item.overflowCount,
614
+ showZero: item.showZero,
615
+ countLabel: item.countLabel
616
+ }
617
+ )
618
+ ]
619
+ },
620
+ item.value
621
+ );
622
+ })
623
+ }
624
+ );
625
+ return /* @__PURE__ */ jsx("div", { ...rootProps, className, children: extraStart || extraEnd || overflow === "menu" ? /* @__PURE__ */ jsxs("div", { "data-slot": "tabs-bar", className: "ui-tabs-bar", children: [
626
+ extraStart ? /* @__PURE__ */ jsx("div", { "data-slot": "tabs-extra", "data-side": "start", className: "ui-tabs-extra", children: extraStart }) : null,
627
+ nav,
628
+ hiddenItems.length > 0 ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
629
+ /* @__PURE__ */ jsx(
630
+ DropdownMenuTrigger,
631
+ {
632
+ className: "ui-tabs-overflow",
633
+ "aria-label": t("navigation.tabs.moreTabs"),
634
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "ui-tabs-overflow-icon", "aria-hidden": "true" })
635
+ }
636
+ ),
637
+ /* @__PURE__ */ jsx(DropdownMenuContent, { placement: "bottomEnd", children: hiddenItems.map((item) => /* @__PURE__ */ jsx(
638
+ DropdownMenuItem,
639
+ {
640
+ asChild: true,
641
+ disabled: item.disabled || item.href === void 0,
642
+ children: /* @__PURE__ */ jsx(
643
+ MenuLink,
644
+ {
645
+ className: "",
646
+ href: item.disabled ? void 0 : item.href,
647
+ "aria-current": item.value === activeValue ? "page" : void 0,
648
+ children: item.label
649
+ }
650
+ )
651
+ },
652
+ item.value
653
+ )) })
654
+ ] }) : null,
655
+ extraEnd ? /* @__PURE__ */ jsx("div", { "data-slot": "tabs-extra", "data-side": "end", className: "ui-tabs-extra", children: extraEnd }) : null
656
+ ] }) : nav });
657
+ }
483
658
  const TabsList = React.forwardRef(
484
659
  ({ className, variant = "default", loop: _loop, children, ...props }, ref) => {
485
660
  const { listLabel } = React.useContext(TabsFrameContext);
@@ -523,7 +698,7 @@ const TabsList = React.forwardRef(
523
698
  // stayed opaque `--muted`. Nothing about the card/line strips changes: both clear the
524
699
  // track with utilities of their own (`data-[variant=default]:bg-transparent`,
525
700
  // `data-[variant=line]:bg-transparent`), and a utility still outranks the new rule.
526
- "group/tabs-list text-muted-foreground inline-flex w-fit max-w-full min-w-0 items-center justify-center-safe rounded-lg p-1 group-data-[orientation=vertical]/tabs:flex-col data-[orientation=horizontal]:[scrollbar-width:none] data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden data-[variant=line]:gap-[var(--tabs-list-line-space-gap)] data-[variant=line]:rounded-none data-[variant=line]:bg-transparent [&[data-orientation=horizontal]::-webkit-scrollbar]:hidden",
701
+ TABS_LIST_BASE_CLASS_NAME,
527
702
  className
528
703
  ),
529
704
  render: (domProps) => withDomProps("div", "tabs-list", props, domProps, { "data-variant": variant }),
@@ -588,7 +763,7 @@ const TabsTrigger = React.forwardRef(
588
763
  // — Segmented — has been `--segmented-item-selected-background` since gh#848, and these
589
764
  // two are documented as the same pair. The card face and the line strip both restate the
590
765
  // fill as utilities, which still outrank the new rule, so neither moves.
591
- "text-muted-foreground ring-offset-background hover:text-foreground ui-focus-ring data-[state=active]:text-foreground group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:border-primary/25 relative inline-flex flex-1 items-center justify-center gap-1.5 rounded-md border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:flex-none group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:border-e-0 group-data-[variant=line]/tabs-list:border-b-0 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
766
+ TABS_TRIGGER_BASE_CLASS_NAME,
592
767
  // The tier the root was given. Without this the compound form ignored `size` outright.
593
768
  triggerSizeClassName(size),
594
769
  className
@@ -182,7 +182,7 @@ function ToolbarGroup({ label, controlId, className, children }) {
182
182
  className: cn("ui-stack-xs ui-toolbar-group", className),
183
183
  children: [
184
184
  label ? controlId ? /* @__PURE__ */ jsx("label", { id: labelId, htmlFor: controlId, className: "ui-toolbar-label", children: label }) : /* @__PURE__ */ jsx("div", { id: labelId, className: "ui-toolbar-label", children: label }) : null,
185
- children
185
+ /* @__PURE__ */ jsx("div", { className: "ui-toolbar-control", children })
186
186
  ]
187
187
  }
188
188
  );
@@ -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": "33.8.0",
3
+ "version": "33.9.1",
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,
@@ -558,7 +558,8 @@
558
558
  "tabs": {
559
559
  "addTab": "Add tab",
560
560
  "removeTab": "Close tab",
561
- "moreTabs": "More tabs"
561
+ "moreTabs": "More tabs",
562
+ "routeNav": "Section navigation"
562
563
  },
563
564
  "steps": {
564
565
  "ariaLabel": "Progress",
@@ -547,7 +547,8 @@
547
547
  "tabs": {
548
548
  "addTab": "タブを追加",
549
549
  "removeTab": "タブを閉じる",
550
- "moreTabs": "他のタブ"
550
+ "moreTabs": "他のタブ",
551
+ "routeNav": "セクションナビゲーション"
551
552
  },
552
553
  "steps": {
553
554
  "ariaLabel": "進行状況",
@@ -548,7 +548,8 @@
548
548
  "tabs": {
549
549
  "addTab": "Thêm tab",
550
550
  "removeTab": "Đóng tab",
551
- "moreTabs": "Tab khác"
551
+ "moreTabs": "Tab khác",
552
+ "routeNav": "Điều hướng mục"
552
553
  },
553
554
  "steps": {
554
555
  "ariaLabel": "Tiến trình",
@@ -235,8 +235,12 @@ export type TextareaProp = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
235
235
  * Spatie translatable, Rails Mobility) already sends.
236
236
  */
237
237
  export type TranslatableValue = Partial<Record<string, string>>;
238
- /** The locale + value contract shared by {@link TranslatableInputProp} and {@link TranslatableTextareaProp}. */
239
- type TranslatableFieldProp = {
238
+ /**
239
+ * The locale + value contract shared by every translatable control: {@link TranslatableInputProp},
240
+ * {@link TranslatableTextareaProp}, {@link TranslatableFieldProp} and `@godxjp/block-editor`'s
241
+ * `TranslatableRichText`.
242
+ */
243
+ export type TranslatableLocaleProp = {
240
244
  /** Controlled per-locale values. */
241
245
  value?: ValueProp<TranslatableValue>;
242
246
  /** Uncontrolled initial per-locale values. */
@@ -266,10 +270,51 @@ type TranslatableFieldProp = {
266
270
  */
267
271
  errors?: Partial<Record<string, ErrorProp>>;
268
272
  };
273
+ /**
274
+ * What {@link TranslatableFieldProp}'s `renderControl` hands the ONE control in the panel: the
275
+ * active locale's text and setter, its `lang`, the resolved placeholder, and the field-a11y contract
276
+ * with the active locale's error folded in. Every prop the field does not own (`id`, `disabled`,
277
+ * `aria-describedby`, `rows`, …) is passed through as well.
278
+ */
279
+ export type TranslatableFieldControlProp = FieldA11yProps & Record<string, unknown> & {
280
+ /** The active locale's BCP-47 tag — the control's `lang`. */
281
+ lang: string;
282
+ /** The active locale's text (`""` when empty). */
283
+ value: string;
284
+ /** Writes the active locale's text; the field reports the whole map. */
285
+ onValueChange: (text: string) => void;
286
+ /** The fallback locale's text while the active locale is empty, else the field's `placeholder`. */
287
+ placeholder?: string;
288
+ /** `"error"` while the active locale has an error. */
289
+ status?: ControlStatusProp;
290
+ };
291
+ /**
292
+ * @see TranslatableField — the locale-tab engine behind every translatable control (gh#1336,
293
+ * gh#1365): the tab strip, the per-locale error marks, the fallback placeholder and the FormField
294
+ * error report, around ONE control the caller renders. `TranslatableInput`, `TranslatableTextarea`
295
+ * and `@godxjp/block-editor`'s `TranslatableRichText` are this field around an `Input`, a
296
+ * `Textarea` and a `RichTextEditor`.
297
+ */
298
+ export type TranslatableFieldProp = TranslatableLocaleProp & FieldA11yProps & {
299
+ /** Renders the one control in the panel from the active locale's props. */
300
+ renderControl: (control: TranslatableFieldControlProp) => React.ReactNode;
301
+ /**
302
+ * Turns the fallback locale's stored text into the plain text an EMPTY locale shows as its
303
+ * placeholder — HTML to its text, for a rich-text control. Default: the text as stored.
304
+ */
305
+ toPlaceholder?: (fallbackText: string) => string;
306
+ /** The control's tier; the tab strip uses the Tabs `sm` tier (`md` for an `lg` field). */
307
+ size?: SizeProp;
308
+ /** Shown while the active locale is empty and there is no fallback text to show. */
309
+ placeholder?: PlaceholderProp;
310
+ status?: ControlStatusProp;
311
+ id?: IdProp;
312
+ className?: ClassNameProp;
313
+ };
269
314
  /** @see TranslatableInput — one `Input` holding a value per locale, switched by locale tabs. */
270
- export type TranslatableInputProp = Omit<InputProp, "value" | "defaultValue" | "onValueChange" | "lang"> & TranslatableFieldProp;
315
+ export type TranslatableInputProp = Omit<InputProp, "value" | "defaultValue" | "onValueChange" | "lang"> & TranslatableLocaleProp;
271
316
  /** @see TranslatableTextarea — the `Textarea` counterpart of {@link TranslatableInputProp}. */
272
- export type TranslatableTextareaProp = Omit<TextareaProp, "value" | "defaultValue" | "onValueChange" | "lang"> & TranslatableFieldProp;
317
+ export type TranslatableTextareaProp = Omit<TextareaProp, "value" | "defaultValue" | "onValueChange" | "lang"> & TranslatableLocaleProp;
273
318
  /**
274
319
  * @see NumberInput — localized numeric spinbutton (composes `Input` + step `Button`s).
275
320
  * `value`/`defaultValue`/`onValueChange` carry a `number | null` (null = empty). `step` drives both
@@ -2524,4 +2569,3 @@ export type EmojiPickerProp = {
2524
2569
  id?: IdProp;
2525
2570
  className?: ClassNameProp;
2526
2571
  };
2527
- export {};
@@ -1,7 +1,7 @@
1
1
  /** Navigation component prop types — @see docs/COMPONENTS.md#navigation */
2
2
  import type * as React from "react";
3
3
  import type { ReactNode } from "react";
4
- import type { AffixProp, AffixTargetProp, ResponsiveGridColumnsProp } from "./layout.prop.js";
4
+ import type { AffixProp, AffixTargetProp, ResponsiveGridColumnsProp, SidebarLinkComponentProp } from "./layout.prop.js";
5
5
  import type { ActionsProp, ChildrenProp, ClassNameProp, DisabledProp, ErrorProp, HasActiveFiltersProp, IdProp, LabelProp, OnClearFiltersProp, OnOpenChangeProp, OpenProp, DefaultOpenProp, PendingProp, PlaceholderProp, StickyProp, TextAlignProp } from "../vocabulary/index.js";
6
6
  import type { FormProp, SearchSelectOptionProp } from "./data-entry.prop.js";
7
7
  /**
@@ -321,6 +321,17 @@ export type TabItemProp = {
321
321
  */
322
322
  content?: React.ReactNode;
323
323
  disabled?: boolean;
324
+ /**
325
+ * ROUTE TAB (gh#1364). The tab's destination. When ANY item carries an `href` the strip is a
326
+ * route strip: it renders a `<nav>` landmark of real links instead of a tablist — the current
327
+ * route (`value`) gets `aria-current="page"`, no `tabpanel` is rendered (`content` is ignored;
328
+ * the route renders its own page), and a plain, modified or middle click is never intercepted,
329
+ * so "open in new tab" works. An item without `href` (or a `disabled` one) is an inert anchor.
330
+ * A route strip always scrolls its overflow. `overflow="menu"` also exposes hidden routes
331
+ * as native links in a menu, preserving browser link actions.
332
+ * Links go through `TabsProp.linkComponent` when set.
333
+ */
334
+ href?: string;
324
335
  /** Leading glyph inside the trigger (Ant Design `Tab.icon`). */
325
336
  icon?: React.ReactNode;
326
337
  /**
@@ -592,6 +603,14 @@ export type TabsProp = {
592
603
  * (`radiogroup`), not a strip-only `Tabs`.
593
604
  */
594
605
  controls?: IdProp;
606
+ /**
607
+ * Router link component for a ROUTE strip (items with `href`, gh#1364) — the SAME contract and
608
+ * the same value as `Sidebar.linkComponent` / `NavList.linkComponent`: Inertia's `<Link>`, or a
609
+ * React Router / TanStack link wrapped by `createSidebarLink(Link, "to")`. It must forward every
610
+ * prop to its `<a>` — the strip's `data-slot` / `data-state` styling hooks travel with them.
611
+ * Without it each route is a plain `<a href>`.
612
+ */
613
+ linkComponent?: SidebarLinkComponentProp;
595
614
  className?: ClassNameProp;
596
615
  listClassName?: ClassNameProp;
597
616
  contentClassName?: ClassNameProp;