@apliteni/apliteni-ui 0.125.1 → 0.127.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.125.1",
3
+ "version": "0.127.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -910,3 +910,63 @@ instead of 32, with the same line drawn in the middle of it.
910
910
  { label: 'Closing', kind: 'total' },
911
911
  ]} />
912
912
  ```
913
+
914
+ ## NavTabs and Breadcrumbs
915
+
916
+ `NavTabs` renders route links with the kit’s underline or pill presentation. Pass
917
+ unique item IDs and set `active` to the current route’s ID. The current link has
918
+ `aria-current="page"`; disabled items are text outside the Tab order. Optional
919
+ badges accept text, numbers (including zero), or `{ text, tone }`.
920
+
921
+ ```tsx
922
+ <NavTabs aria-label="Finance views" active="payouts" items={[
923
+ { id: 'summary', label: 'Summary', href: '/finance' },
924
+ { id: 'payouts', label: 'Payouts', href: '/payouts', badge: 3 },
925
+ { id: 'exports', label: 'Exports', disabled: true },
926
+ ]} />
927
+ <Breadcrumbs items={[
928
+ { label: 'Finance', href: '/finance' },
929
+ { label: 'Payouts', href: '/payouts' },
930
+ { label: 'PY-4821' },
931
+ ]} />
932
+ ```
933
+
934
+ Use `Tabs` for panels within a page. `NavTabs` keeps normal link keyboard behavior.
935
+ An omitted `href` falls back to `#` plus the item ID. `variant="pill"` selects the
936
+ existing pill styles. A badge of `null` renders no badge, so a count that is not in
937
+ yet costs nothing to pass.
938
+
939
+ Both components take `renderLink`, the prop `AppShell` already uses, so a router
940
+ handles the navigation instead of the browser reloading the document:
941
+
942
+ ```tsx
943
+ <NavTabs active="payouts" items={items}
944
+ renderLink={(item, { href, children, ...props }) => (
945
+ <Link {...props} to={href!}>{children}</Link>
946
+ )} />
947
+ ```
948
+
949
+ Without it both render a plain `<a>`. The kit's class, destination, current state
950
+ and children all arrive in `linkProps`, so pass them through rather than rebuilding
951
+ them.
952
+
953
+ `Breadcrumbs` renders an ordered trail. Earlier items link only when they have an
954
+ `href`; the final item is always current-page text. Items accept a decorative kit
955
+ `icon` name. Both components accept native navigation attributes and a forwarded
956
+ root ref. Use `aria-label` to distinguish navigation landmarks. Link items accept
957
+ `target`. Script, data, and VBScript destinations fall back to `#`.
958
+
959
+ Tab rows scroll within their container when the links do not fit; a breadcrumb
960
+ trail wraps instead. A row whose links do fit scrolls nothing and clips nothing, so
961
+ a focus ring in it keeps its complete band. The current route is revealed after
962
+ mounting and route changes. Content and container changes update the fit state
963
+ without revealing the route again, so they preserve the reader's scroll position
964
+ where space permits. Keyboard focus stays put throughout.
965
+ When a link already fits in the row, it keeps its content width. The cap only
966
+ limits one link that is wider than the row.
967
+
968
+ A row also scrolls to a link that takes focus, far enough that the ring the link
969
+ draws clears the scrolling edge rather than being cut off by it. `NavTabs` does
970
+ that from `onFocus`, and still calls an `onFocus` you pass. A row whose links are
971
+ all disabled holds no Tab stop, so the browser makes the row itself one; it draws
972
+ the kit's inward ring there, never the browser's own outline.
@@ -49,6 +49,7 @@
49
49
  height: min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y)));
50
50
  }
51
51
  .ui-check input::before,
52
+ .ui-table__selection input[type=checkbox]::before,
52
53
  .ui-toast__close::before {
53
54
  width: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x))));
54
55
  height: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y))));
@@ -102,6 +103,14 @@
102
103
  --tap-clear-x: var(--tap-gap);
103
104
  --tap-clear-y: 0px;
104
105
  }
106
+ .ui-table__selection {
107
+ --tap-clear-x: var(--tap-min);
108
+ --tap-clear-y: var(--tap-min);
109
+ }
110
+ .ui-table--dense .ui-table__selection,
111
+ .ui-table--compact .ui-table__selection {
112
+ --tap-clear-y: 0px;
113
+ }
105
114
  .ui-toast {
106
115
  row-gap: var(--tap-gap) !important;
107
116
  --tap-clear-y: var(--tap-gap);
@@ -1071,7 +1071,51 @@ declare const AccentPicker: react.ForwardRefExoticComponent<Omit<HTMLAttributes<
1071
1071
  options?: readonly Accent[];
1072
1072
  } & react.RefAttributes<HTMLDivElement>>;
1073
1073
 
1074
+ type NavTabBadge = string | number | {
1075
+ text: string | number;
1076
+ tone?: 'neutral' | 'accent' | 'live' | 'danger';
1077
+ };
1078
+ type NavTabItem = {
1079
+ id: string;
1080
+ label: string;
1081
+ href?: string;
1082
+ target?: string;
1083
+ disabled?: boolean;
1084
+ badge?: NavTabBadge | null;
1085
+ };
1086
+ type NavTabsProps = Omit<ComponentPropsWithoutRef<'nav'>, 'children'> & {
1087
+ items: readonly NavTabItem[];
1088
+ active?: string;
1089
+ variant?: 'underline' | 'pill';
1090
+ /** Spread linkProps onto the router link, including children. */
1091
+ renderLink?: (item: NavTabItem, linkProps: ComponentPropsWithoutRef<'a'>) => ReactNode;
1092
+ };
1093
+ declare const NavTabs: react.ForwardRefExoticComponent<Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLElement>, HTMLElement>, "ref">, "children"> & {
1094
+ items: readonly NavTabItem[];
1095
+ active?: string;
1096
+ variant?: "underline" | "pill";
1097
+ /** Spread linkProps onto the router link, including children. */
1098
+ renderLink?: (item: NavTabItem, linkProps: ComponentPropsWithoutRef<"a">) => ReactNode;
1099
+ } & react.RefAttributes<HTMLElement>>;
1100
+
1101
+ type BreadcrumbItem = {
1102
+ label: string;
1103
+ href?: string;
1104
+ target?: string;
1105
+ icon?: string;
1106
+ };
1107
+ type BreadcrumbsProps = Omit<ComponentPropsWithoutRef<'nav'>, 'children'> & {
1108
+ items: readonly BreadcrumbItem[];
1109
+ /** Spread linkProps onto the router link, including children. */
1110
+ renderLink?: (item: BreadcrumbItem, linkProps: ComponentPropsWithoutRef<'a'>) => ReactNode;
1111
+ };
1112
+ declare const Breadcrumbs: react.ForwardRefExoticComponent<Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLElement>, HTMLElement>, "ref">, "children"> & {
1113
+ items: readonly BreadcrumbItem[];
1114
+ /** Spread linkProps onto the router link, including children. */
1115
+ renderLink?: (item: BreadcrumbItem, linkProps: ComponentPropsWithoutRef<"a">) => ReactNode;
1116
+ } & react.RefAttributes<HTMLElement>>;
1117
+
1074
1118
  declare const PAGE_SIZES: readonly number[];
1075
1119
  declare const DEFAULT_PAGE_SIZE: number;
1076
1120
 
1077
- export { type Accent, AccentPicker, type AccentPickerProps, type AddFilter, AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonLinkProps, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, Chart, type ChartBridgeStep, type ChartBridgeTones, type ChartPeriod, type ChartProps, type ChartSeries, type ChartTone, CheckList, type CheckListProps, Checkbox, type CheckboxProps, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DatePicker, type DatePickerMode, type DatePickerPreset, type DatePickerProps, type DatePickerRange, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, Field, type FieldControlProps, type FieldProps, FileDrop, type FileDropFile, type FileDropProps, type FileDropStatus, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, type NativeButtonProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, Pill, type PillProps, RowIdentity, type RowIdentityProps, SearchField, type SearchFieldProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, SidebarNav, type SidebarNavBadge, type SidebarNavItem, type SidebarNavLeaf, type SidebarNavProps, type SidebarNavSection, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, StatusDot, type StatusDotProps, Success, SuccessCheck, type SuccessCheckProps, type SuccessCountdown, SuccessPanel, type SuccessPanelProps, type SuccessProps, Switch, type SwitchProps, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineEventKind, type TimelineProps, Toast, type ToastNotice, type ToastProps, Tooltip, type TooltipProps, sortTableRows, useToast };
1121
+ export { type Accent, AccentPicker, type AccentPickerProps, type AddFilter, AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, type BreadcrumbItem, Breadcrumbs, type BreadcrumbsProps, BusyRegion, type BusyRegionProps, Button, type ButtonLinkProps, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, Chart, type ChartBridgeStep, type ChartBridgeTones, type ChartPeriod, type ChartProps, type ChartSeries, type ChartTone, CheckList, type CheckListProps, Checkbox, type CheckboxProps, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DatePicker, type DatePickerMode, type DatePickerPreset, type DatePickerProps, type DatePickerRange, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, Field, type FieldControlProps, type FieldProps, FileDrop, type FileDropFile, type FileDropProps, type FileDropStatus, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, type NativeButtonProps, type NavTabBadge, type NavTabItem, NavTabs, type NavTabsProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, Pill, type PillProps, RowIdentity, type RowIdentityProps, SearchField, type SearchFieldProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, SidebarNav, type SidebarNavBadge, type SidebarNavItem, type SidebarNavLeaf, type SidebarNavProps, type SidebarNavSection, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, StatusDot, type StatusDotProps, Success, SuccessCheck, type SuccessCheckProps, type SuccessCountdown, SuccessPanel, type SuccessPanelProps, type SuccessProps, Switch, type SwitchProps, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineEventKind, type TimelineProps, Toast, type ToastNotice, type ToastProps, Tooltip, type TooltipProps, sortTableRows, useToast };
@@ -94,7 +94,7 @@ var cx = (...a) => a.filter(Boolean).join(" ");
94
94
  var Button = forwardRef3(function Button2({
95
95
  variant = "secondary",
96
96
  size = "md",
97
- icon: icon6,
97
+ icon: icon7,
98
98
  leading,
99
99
  iconRight,
100
100
  iconOnly,
@@ -158,7 +158,7 @@ var Button = forwardRef3(function Button2({
158
158
  className
159
159
  );
160
160
  const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
161
- const fallback = typeof children === "string" && children.trim() ? children.trim() : icon6;
161
+ const fallback = typeof children === "string" && children.trim() ? children.trim() : icon7;
162
162
  const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
163
163
  const Root = href === void 0 ? "button" : "a";
164
164
  return /* @__PURE__ */ jsxs3(
@@ -203,7 +203,7 @@ var Button = forwardRef3(function Button2({
203
203
  if (!blockKey(event)) onKeyUp?.(event);
204
204
  },
205
205
  children: [
206
- leading != null ? /* @__PURE__ */ jsx4("span", { "aria-hidden": "true", style: { display: "inline-flex" }, children: leading }) : icon6 && /* @__PURE__ */ jsx4(Icon, { name: icon6 }),
206
+ leading != null ? /* @__PURE__ */ jsx4("span", { "aria-hidden": "true", style: { display: "inline-flex" }, children: leading }) : icon7 && /* @__PURE__ */ jsx4(Icon, { name: icon7 }),
207
207
  !iconOnly && renderedChildren != null && /* @__PURE__ */ jsx4("span", { className: "ui-btn__label-slot", children: /* @__PURE__ */ jsx4("span", { className: "ui-btn__label", ref: labelRef, children: renderedChildren }) }),
208
208
  iconRight && /* @__PURE__ */ jsx4(Icon, { name: iconRight }),
209
209
  busy && /* @__PURE__ */ jsxs3("span", { className: "ui-btn__dots", "aria-hidden": "true", children: [
@@ -273,9 +273,9 @@ function Card({ title, sub, level = 2, className, children }) {
273
273
  // src/primitives/Callout.tsx
274
274
  import { calloutIcons, icon as kitIcon } from "@apliteni/apliteni-ui";
275
275
  import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
276
- function Callout({ variant, icon: icon6 = calloutIcons[variant ?? "neutral"], children, actions }) {
276
+ function Callout({ variant, icon: icon7 = calloutIcons[variant ?? "neutral"], children, actions }) {
277
277
  return /* @__PURE__ */ jsxs5("div", { className: `ui-callout${variant && variant !== "neutral" ? ` ui-callout--${variant}` : ""}`, role: variant === "danger" ? "alert" : void 0, children: [
278
- /* @__PURE__ */ jsx9("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(icon6) } }),
278
+ /* @__PURE__ */ jsx9("span", { className: "ui-callout__icon", dangerouslySetInnerHTML: { __html: kitIcon(icon7) } }),
279
279
  /* @__PURE__ */ jsxs5("div", { className: "ui-callout__body", children: [
280
280
  children,
281
281
  actions != null && /* @__PURE__ */ jsx9("div", { className: "ui-toolbar ui-callout__actions", children: actions })
@@ -1420,6 +1420,7 @@ function DataTable({
1420
1420
  }
1421
1421
  );
1422
1422
  const pageAllOn = selectable && slice.length > 0 && slice.every((r) => selected.has(r.name));
1423
+ const pageSomeOn = selectable && !pageAllOn && slice.some((r) => selected.has(r.name));
1423
1424
  return /* @__PURE__ */ jsxs13(Fragment4, { children: [
1424
1425
  /* @__PURE__ */ jsx17(
1425
1426
  "div",
@@ -1442,6 +1443,9 @@ function DataTable({
1442
1443
  type: "checkbox",
1443
1444
  checked: pageAllOn,
1444
1445
  "aria-label": "Select all rows on this page",
1446
+ ref: (input) => {
1447
+ if (input) input.indeterminate = pageSomeOn;
1448
+ },
1445
1449
  onChange: () => onTogglePage(slice.map((r) => r.name))
1446
1450
  }
1447
1451
  ) }) : null,
@@ -1581,12 +1585,12 @@ function Denied({
1581
1585
  title = "You don\u2019t have access",
1582
1586
  sub,
1583
1587
  need,
1584
- icon: icon6 = "lock",
1588
+ icon: icon7 = "lock",
1585
1589
  className,
1586
1590
  children
1587
1591
  }) {
1588
1592
  return /* @__PURE__ */ jsxs14("div", { className: cx4("ui-denied", className), children: [
1589
- /* @__PURE__ */ jsx18("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx18(Icon, { name: icon6 }) }),
1593
+ /* @__PURE__ */ jsx18("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx18(Icon, { name: icon7 }) }),
1590
1594
  /* @__PURE__ */ jsx18("div", { className: "ui-denied__title", children: title }),
1591
1595
  sub && /* @__PURE__ */ jsx18("div", { className: "ui-denied__sub", children: sub }),
1592
1596
  need && /* @__PURE__ */ jsxs14("div", { className: "ui-denied__need", children: [
@@ -3903,11 +3907,11 @@ var cases = {
3903
3907
  "not-found": { title: "We can't find that page", sub: "Check the address or return to the home page.", icon: "folder" },
3904
3908
  "not-yet-built": { title: "This ships later", sub: "This screen is not available yet. Check back later.", icon: "clock" }
3905
3909
  };
3906
- function EmptyState({ variant = "first-run", title, sub, icon: icon6, art, actions }) {
3910
+ function EmptyState({ variant = "first-run", title, sub, icon: icon7, art, actions }) {
3907
3911
  const preset = cases[variant];
3908
3912
  const heading = title ?? preset.title;
3909
3913
  const guidance = sub ?? preset.sub;
3910
- const glyph = icon6 ?? preset.icon;
3914
+ const glyph = icon7 ?? preset.icon;
3911
3915
  return /* @__PURE__ */ jsxs31("div", { className: "ui-empty", children: [
3912
3916
  art ? typeof art === "string" ? /* @__PURE__ */ jsx36("div", { className: "ui-empty__art", dangerouslySetInnerHTML: { __html: /<svg/.test(art) ? art : illo(art) } }) : /* @__PURE__ */ jsx36("div", { className: "ui-empty__art", "aria-hidden": "true", children: art }) : glyph ? /* @__PURE__ */ jsx36("div", { className: "ui-empty__icon", dangerouslySetInnerHTML: { __html: kitIcon2(glyph) } }) : null,
3913
3917
  heading && /* @__PURE__ */ jsx36("div", { className: "ui-empty__title", children: heading }),
@@ -4760,6 +4764,97 @@ var AccentPicker = forwardRef10(function AccentPicker2({
4760
4764
  );
4761
4765
  });
4762
4766
 
4767
+ // src/NavTabs.tsx
4768
+ import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef21, useImperativeHandle as useImperativeHandle2 } from "react";
4769
+ import { safeUrl, observeNavFit, revealCurrentNav, revealFocusedNav } from "@apliteni/apliteni-ui";
4770
+ import { Fragment as Fragment15, jsx as jsx40, jsxs as jsxs34 } from "react/jsx-runtime";
4771
+ var NavTabs = forwardRef11(function NavTabs2({
4772
+ items,
4773
+ active,
4774
+ variant = "underline",
4775
+ renderLink,
4776
+ className,
4777
+ onFocus,
4778
+ "aria-label": ariaLabel = "Tabs",
4779
+ ...rest
4780
+ }, ref) {
4781
+ const navRef = useRef21(null);
4782
+ useImperativeHandle2(ref, () => navRef.current, []);
4783
+ const signature = items.map((item) => item.id).join("\0");
4784
+ useEffect17(() => observeNavFit(navRef.current), []);
4785
+ useEffect17(() => {
4786
+ if (navRef.current) revealCurrentNav(navRef.current);
4787
+ }, [active, signature]);
4788
+ return /* @__PURE__ */ jsx40(
4789
+ "nav",
4790
+ {
4791
+ ...rest,
4792
+ ref: navRef,
4793
+ className: ["ui-nav", "ui-nav--tabs", `is-${variant}`, className].filter(Boolean).join(" "),
4794
+ "aria-label": ariaLabel,
4795
+ onFocus: (event) => {
4796
+ if (navRef.current) revealFocusedNav(navRef.current, event.target);
4797
+ onFocus?.(event);
4798
+ },
4799
+ children: items.map((item) => {
4800
+ const badge = item.badge != null && typeof item.badge === "object" ? item.badge : { text: item.badge, tone: void 0 };
4801
+ const inner = /* @__PURE__ */ jsxs34(Fragment15, { children: [
4802
+ /* @__PURE__ */ jsx40("span", { className: "ui-nav__tab-label", children: item.label }),
4803
+ badge.text != null && badge.text !== "" && /* @__PURE__ */ jsx40("span", { className: `ui-nav__badge is-${badge.tone || "neutral"}`, children: badge.text })
4804
+ ] });
4805
+ if (item.disabled) return /* @__PURE__ */ jsx40("span", { className: "ui-nav__tab is-disabled", "aria-disabled": "true", children: inner }, item.id);
4806
+ const current = item.id === active;
4807
+ const linkProps = {
4808
+ className: ["ui-nav__tab", current && "is-active"].filter(Boolean).join(" "),
4809
+ href: safeUrl(item.href || `#${item.id}`),
4810
+ target: item.target,
4811
+ "aria-current": current ? "page" : void 0,
4812
+ children: inner
4813
+ };
4814
+ return /* @__PURE__ */ jsx40(Fragment14, { children: renderLink ? renderLink(item, linkProps) : /* @__PURE__ */ jsx40("a", { ...linkProps }) }, item.id);
4815
+ })
4816
+ }
4817
+ );
4818
+ });
4819
+
4820
+ // src/Breadcrumbs.tsx
4821
+ import { forwardRef as forwardRef12 } from "react";
4822
+ import { icon as icon6, safeUrl as safeUrl2 } from "@apliteni/apliteni-ui";
4823
+ import { Fragment as Fragment16, jsx as jsx41, jsxs as jsxs35 } from "react/jsx-runtime";
4824
+ var Breadcrumbs = forwardRef12(function Breadcrumbs2({
4825
+ items,
4826
+ renderLink,
4827
+ className,
4828
+ "aria-label": ariaLabel = "Breadcrumb",
4829
+ ...rest
4830
+ }, ref) {
4831
+ return /* @__PURE__ */ jsx41(
4832
+ "nav",
4833
+ {
4834
+ ...rest,
4835
+ ref,
4836
+ className: ["ui-nav", "ui-nav--crumbs", className].filter(Boolean).join(" "),
4837
+ "aria-label": ariaLabel,
4838
+ children: /* @__PURE__ */ jsx41("ol", { className: "ui-nav__crumbs", children: items.map((item, index) => {
4839
+ const current = index === items.length - 1;
4840
+ const inner = /* @__PURE__ */ jsxs35(Fragment16, { children: [
4841
+ item.icon && /* @__PURE__ */ jsx41("span", { className: "ui-nav__ic", dangerouslySetInnerHTML: { __html: icon6(item.icon) } }),
4842
+ /* @__PURE__ */ jsx41("span", { className: "ui-nav__crumb-label", children: item.label })
4843
+ ] });
4844
+ const linkProps = !current && item.href ? { className: "ui-nav__crumb", href: safeUrl2(item.href), target: item.target, children: inner } : null;
4845
+ return /* @__PURE__ */ jsx41("li", { className: "ui-nav__crumb-item", children: linkProps ? renderLink ? renderLink(item, linkProps) : /* @__PURE__ */ jsx41("a", { ...linkProps }) : /* @__PURE__ */ jsx41(
4846
+ "span",
4847
+ {
4848
+ className: ["ui-nav__crumb", current && "is-current"].filter(Boolean).join(" "),
4849
+ "aria-current": current ? "page" : void 0,
4850
+ children: inner
4851
+ }
4852
+ ) }, index);
4853
+ }) })
4854
+ }
4855
+ );
4856
+ });
4857
+
4763
4858
  // src/index.ts
4764
4859
  var PAGE_SIZES = KIT_PAGE_SIZES;
4765
4860
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
@@ -4768,6 +4863,7 @@ export {
4768
4863
  AppShell,
4769
4864
  BackLink,
4770
4865
  Badge,
4866
+ Breadcrumbs,
4771
4867
  BusyRegion,
4772
4868
  Button,
4773
4869
  Callout,
@@ -4793,6 +4889,7 @@ export {
4793
4889
  Icon,
4794
4890
  KeyValueList,
4795
4891
  Modal,
4892
+ NavTabs,
4796
4893
  NumericValue,
4797
4894
  PAGE_SIZES,
4798
4895
  Pagination,
@@ -7,7 +7,7 @@ import { icon } from '../assets/icons.js';
7
7
  import { illo } from '../assets/illustrations.js';
8
8
  import { successCheck } from './success.js';
9
9
  import { esc, safeUrl } from '../html.js';
10
- export { esc } from '../html.js';
10
+ export { esc, safeUrl } from '../html.js';
11
11
  const cx = (...a) => a.filter(Boolean).join(' ');
12
12
  // ---- Button --------------------------------------------------------------
13
13
  // `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
@@ -5,10 +5,11 @@
5
5
  //
6
6
  // These are NAVIGATION controls, not tab panels — so tabs render as
7
7
  // <nav> + <a aria-current="page">, not role="tablist" (that is what segmented()
8
- // is for). Only the collapsible sidebar groups need JS; wire with wireNav().
8
+ // is for). wireNav() opens sidebar groups and reveals the current route link.
9
9
  import { esc, icon } from './index.js';
10
10
  import { playEntrance } from '../motion.js';
11
11
  import { safeUrl } from '../html.js';
12
+ import { observeNavFit, revealCurrentNav, revealFocusedNav } from '../logic/nav.js';
12
13
  const cx = (...a) => a.filter(Boolean).join(' ');
13
14
 
14
15
  // Unique-per-render ids so a section heading can label its own list. Module
@@ -177,9 +178,9 @@ export function nav({ variant = 'sidebar', ...opts } = {}) {
177
178
  }
178
179
 
179
180
  // ---- Behaviour -----------------------------------------------------------
180
- // Only the collapsible sidebar groups need JS: toggle a group's `.is-open` +
181
- // its button's aria-expanded, and show/hide the nested list. Idempotent and
182
- // event-delegated, so it's safe to call repeatedly (Storybook re-renders).
181
+ // Toggle a sidebar group's `.is-open` and its button's aria-expanded, and
182
+ // show/hide the nested list. Idempotent and event-delegated, so it's safe to
183
+ // call repeatedly (Storybook re-renders).
183
184
  let _navGlobalWired = false;
184
185
 
185
186
  function toggleGroup(btn) {
@@ -194,10 +195,24 @@ function toggleGroup(btn) {
194
195
  }
195
196
  }
196
197
 
198
+ const fitObservers = new Map();
197
199
  export function wireNav(root = document) {
200
+ for (const [nav, stop] of fitObservers) {
201
+ if (!nav.isConnected) { stop(); fitObservers.delete(nav); }
202
+ }
203
+ root.querySelectorAll('.ui-nav--tabs').forEach((nav) => {
204
+ if (!fitObservers.has(nav)) fitObservers.set(nav, observeNavFit(nav));
205
+ revealCurrentNav(nav);
206
+ });
198
207
  // Per-collapsed-rail behaviour would go here; groups use one delegated handler.
199
208
  if (_navGlobalWired) return;
200
209
  _navGlobalWired = true;
210
+ // A tab row scrolls its own focused link clear of its edge, so the ring the link
211
+ // draws is not cut off by the scroll box. Delegated, because the links come and go.
212
+ document.addEventListener('focusin', (e) => {
213
+ const row = e.target.closest && e.target.closest('.ui-nav--tabs');
214
+ if (row) revealFocusedNav(row, e.target);
215
+ });
201
216
  document.addEventListener('click', (e) => {
202
217
  const btn = e.target.closest && e.target.closest('[data-nav-toggle]');
203
218
  if (btn) { e.preventDefault(); toggleGroup(btn); }
package/src/index.d.ts CHANGED
@@ -1,4 +1,12 @@
1
+ /** Watch tab content and width without changing the current route. Call the result on removal. */
2
+ export declare function observeNavFit(nav: HTMLElement): () => void;
1
3
  export declare function esc(value: unknown): string;
4
+ /** The URL boundary every factory and React component sends a destination through. */
5
+ export declare function safeUrl(value: unknown, fallback?: string): string;
6
+ /** Scroll a tab row so its `aria-current` link is inside the viewport. */
7
+ export declare function revealCurrentNav(nav: HTMLElement): void;
8
+ /** Scroll a tab row so the link a focus event landed on clears the row's edge. */
9
+ export declare function revealFocusedNav(nav: HTMLElement, target: EventTarget | null): void;
2
10
  export declare function field(opts?: Record<string, unknown>): string;
3
11
  export declare function input(opts?: Record<string, unknown>): string;
4
12
  export declare function textarea(opts?: Record<string, unknown>): string;
package/src/index.js CHANGED
@@ -35,4 +35,5 @@ export { PAGE_SIZES, DEFAULT_PAGE_SIZE } from './logic/pagination.js';
35
35
  export { calloutIcons } from './logic/callout.js';
36
36
  export { formatNumericValue, formatDeltaValue } from './logic/table-values.js';
37
37
  export { accentSwatchStyle } from './logic/accents.js';
38
+ export { observeNavFit, revealCurrentNav, revealFocusedNav } from './logic/nav.js';
38
39
  export { CHART_FLOOR, chartScale, bridgeWalk, pointTarget } from './logic/chart.js';
@@ -0,0 +1,65 @@
1
+ // A tab row's padding is the room its links' focus rings are painted in, so an item
2
+ // brought to the row's CONTENT edge keeps its whole ring inside the scroll box: the
3
+ // solid band reaches --ring-gap-width + --ring-width = 3px out of a 4px padding.
4
+ // Both readers below want that edge — the current route so its label is not cut off,
5
+ // a link Tab landed on so its ring is not. Neither moves focus or scrolls the page;
6
+ // only the row's own scrollLeft changes.
7
+ function revealNavItem(nav, item) {
8
+ if (!item || nav.scrollWidth <= nav.clientWidth) return;
9
+ const bounds = nav.getBoundingClientRect();
10
+ const box = item.getBoundingClientRect();
11
+ const style = nav.ownerDocument.defaultView.getComputedStyle(nav);
12
+ const left = bounds.left + (parseFloat(style.paddingLeft) || 0);
13
+ const right = bounds.right - (parseFloat(style.paddingRight) || 0);
14
+ if (box.left < left) nav.scrollLeft += box.left - left;
15
+ else if (box.right > right) nav.scrollLeft += box.right - right;
16
+ }
17
+
18
+ // Measure with overflow enabled so visible child paint cannot inflate scrollWidth.
19
+ // One pixel allows fractional columns. Unmeasured rows keep their scroll containment.
20
+ function fitNav(nav) {
21
+ nav.removeAttribute('data-nav-fit');
22
+ if (!nav.clientWidth) return;
23
+ if (nav.scrollWidth - nav.clientWidth <= 1) nav.setAttribute('data-nav-fit', '');
24
+ }
25
+
26
+ // Watch layout independently from route changes so a resize preserves the reader's scroll.
27
+ export function observeNavFit(nav) {
28
+ const win = nav.ownerDocument.defaultView;
29
+ const measure = () => fitNav(nav);
30
+ const resize = win.ResizeObserver ? new win.ResizeObserver(measure) : null;
31
+ const observe = () => {
32
+ resize?.disconnect();
33
+ resize?.observe(nav);
34
+ for (const child of nav.children) resize?.observe(child);
35
+ measure();
36
+ };
37
+ const changes = new win.MutationObserver(observe);
38
+ changes.observe(nav, {
39
+ childList: true, subtree: true, characterData: true,
40
+ attributes: true, attributeFilter: ['class', 'style'],
41
+ });
42
+ observe();
43
+ win.addEventListener('resize', measure);
44
+ return () => {
45
+ resize?.disconnect();
46
+ changes.disconnect();
47
+ win.removeEventListener('resize', measure);
48
+ };
49
+ }
50
+
51
+ // Reveal only on an explicit route change or initial wiring.
52
+ export function revealCurrentNav(nav) {
53
+ fitNav(nav);
54
+ revealNavItem(nav, nav.querySelector('[aria-current="page"]'));
55
+ }
56
+
57
+ // Reveal the tab a focus event landed on. Chromium brings a focused child to the
58
+ // scroll box's own edge at best, which is the ring's room rather than the label's,
59
+ // so the solid band is clipped by the pixels the link hangs out: measured at 390px,
60
+ // 3.86px on the underline row and 13.86px on the pill row, with scrollLeft left at
61
+ // 0. Repaying the padding puts the band back inside. #429
62
+ export function revealFocusedNav(nav, target) {
63
+ const item = target && target.closest ? target.closest('.ui-nav__tab') : null;
64
+ if (item && nav.contains(item)) revealNavItem(nav, item);
65
+ }
@@ -8,6 +8,7 @@
8
8
  instead — a 22px penumbra off a 10px offset, the width Artur's reference uses on a
9
9
  card. Chosen by him on #490 round t3. why: docs/foundations.md#elevation */
10
10
  .ui-card {
11
+ --ui-card-ground: var(--surface); /* the ground a scrolling row inside covers its edge with; a tint repeats it below */
11
12
  background: var(--surface);
12
13
  border: 1px solid var(--card-edge);
13
14
  box-shadow: var(--elev-rest);
@@ -51,10 +52,12 @@
51
52
  works in dark sinks a light card below the page. Each variant edges itself in its own
52
53
  colour. why: docs/foundations.md#elevation */
53
54
  .ui-card--accent {
55
+ --ui-card-ground: color-mix(in srgb, var(--accent) var(--card-tint), var(--surface));
54
56
  background: color-mix(in srgb, var(--accent) var(--card-tint), var(--surface));
55
57
  border-color: color-mix(in srgb, var(--accent) 22%, transparent);
56
58
  }
57
59
  .ui-card--live {
60
+ --ui-card-ground: color-mix(in srgb, var(--green) var(--card-tint), var(--surface));
58
61
  background: color-mix(in srgb, var(--green) var(--card-tint), var(--surface));
59
62
  border-color: color-mix(in srgb, var(--green) 22%, transparent);
60
63
  }
@@ -123,7 +123,9 @@
123
123
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6270' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
124
124
  }
125
125
 
126
- /* Checkbox / radio — custom, token-driven */
126
+ /* Checkbox / radio — custom, token-driven. DataTable's selection boxes are bare
127
+ inputs in a cell with no label, and take the same box so a partial page reads in
128
+ the kit's paint rather than the browser's. #619 */
127
129
  .ui-check {
128
130
  display: inline-flex;
129
131
  align-items: flex-start;
@@ -133,7 +135,8 @@
133
135
  color: var(--text);
134
136
  line-height: 1.5;
135
137
  }
136
- .ui-check input {
138
+ .ui-check input,
139
+ .ui-table__selection input[type="checkbox"] {
137
140
  appearance: none;
138
141
  width: 19px;
139
142
  height: 19px;
@@ -146,6 +149,9 @@
146
149
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
147
150
  position: relative;
148
151
  }
152
+ /* In a cell there is no label line to sit beside, so the box centres on the row's
153
+ text instead of dropping 1px to meet a label's first line. */
154
+ .ui-table__selection input[type="checkbox"] { margin-top: 0; vertical-align: middle; }
149
155
  /* The target, not the ink. The box above is drawn at 19x19 because that is what
150
156
  sits beside a 15px label without shouting; WCAG 2.5.8 wants 24 CSS px of
151
157
  pointer target, which is a different question. This overlay answers it and
@@ -155,7 +161,8 @@
155
161
  inside the same <label> (which toggles the same input, so an overlap there
156
162
  would cost nothing) and 14px between stacked rows in the specimen. Measured
157
163
  in stories/guidelines/accessibility-floor.test.js. #219 */
158
- .ui-check input::before {
164
+ .ui-check input::before,
165
+ .ui-table__selection input[type="checkbox"]::before {
159
166
  content: '';
160
167
  position: absolute;
161
168
  left: 50%;
@@ -171,9 +178,12 @@
171
178
  accent band is the second band #578 round r34 took off the focus rules, and a
172
179
  pointer resting on a focused control would put it back. One indicator, which is
173
180
  what the kit's rows already chose. why: docs/foundations.md#the-focus-ring */
174
- .ui-check input:enabled:hover:not(:focus-visible) { border-color: var(--accent); }
175
- .ui-check input:checked { background: var(--accent); border-color: var(--accent); }
176
- .ui-check input[type="checkbox"]:checked::after {
181
+ .ui-check input:enabled:hover:not(:focus-visible),
182
+ .ui-table__selection input[type="checkbox"]:enabled:hover:not(:focus-visible) { border-color: var(--accent); }
183
+ .ui-check input:checked,
184
+ .ui-table__selection input[type="checkbox"]:checked { background: var(--accent); border-color: var(--accent); }
185
+ .ui-check input[type="checkbox"]:checked::after,
186
+ .ui-table__selection input[type="checkbox"]:checked::after {
177
187
  content: "";
178
188
  position: absolute;
179
189
  left: 5px; top: 1.5px;
@@ -199,14 +209,59 @@
199
209
  ground together. A checked box drops the accent for the reason a disabled primary
200
210
  button does: the accent IS the "press me". Its mark inverts to --disabled-surface,
201
211
  so the tick and the dot keep the measured --disabled-ink pair in both themes. */
202
- .ui-check input:disabled {
212
+ .ui-check input:disabled,
213
+ .ui-table__selection input[type="checkbox"]:disabled {
203
214
  background: var(--disabled-surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */
204
215
  border-color: var(--disabled-border);
205
216
  cursor: not-allowed;
206
217
  }
207
- .ui-check input:disabled:checked { background: var(--disabled-ink); border-color: var(--disabled-ink); }
208
- .ui-check input[type="checkbox"]:disabled:checked::after { border-color: var(--disabled-surface); }
218
+ .ui-check input:disabled:checked,
219
+ .ui-table__selection input[type="checkbox"]:disabled:checked { background: var(--disabled-ink); border-color: var(--disabled-ink); }
220
+ .ui-check input[type="checkbox"]:disabled:checked::after,
221
+ .ui-table__selection input[type="checkbox"]:disabled:checked::after { border-color: var(--disabled-surface); }
209
222
  .ui-check input[type="radio"]:disabled:checked::after { background: var(--disabled-surface); /* code-bg: inherit — the mark inside the box; the box carries the focus indicator. */ }
223
+ /* Partial: some of a group is on, not all. The checked box's fill with a level
224
+ dash where the tick would be, so the box still reads as "on" from across a table
225
+ and the dash alone says "not all". It wins over a tick when both are set, as the
226
+ browser's own box does. Drawn with a border, not a background, so forced colours
227
+ keeps the dash. #619 */
228
+ .ui-check input[type="checkbox"]:indeterminate,
229
+ .ui-table__selection input[type="checkbox"]:indeterminate { background: var(--accent); border-color: var(--accent); }
230
+ .ui-check input[type="checkbox"]:indeterminate::after,
231
+ .ui-table__selection input[type="checkbox"]:indeterminate::after {
232
+ content: "";
233
+ position: absolute;
234
+ inset: 0;
235
+ margin: auto;
236
+ width: 11px; height: 0; /* odd, so the 17px padding box splits into equal gaps */
237
+ border: solid var(--accent-contrast);
238
+ border-width: 2px 0 0;
239
+ transform: none;
240
+ }
241
+ /* White ink falls below 3:1 on the lighter dark-theme accents, so the tick, the dash and
242
+ the radio's dot take the ink a selected Segmented pill takes there. */
243
+ :where(:root[data-theme="dark"]) .ui-check input[type="checkbox"]:enabled:is(:checked, :indeterminate)::after,
244
+ :where(:root[data-theme="dark"]) .ui-table__selection input[type="checkbox"]:enabled:is(:checked, :indeterminate)::after { border-color: var(--signal-contrast); }
245
+ :where(:root[data-theme="dark"]) .ui-check input[type="radio"]:enabled:checked::after { background: var(--signal-contrast); }
246
+ .ui-check input[type="checkbox"]:disabled:indeterminate,
247
+ .ui-table__selection input[type="checkbox"]:disabled:indeterminate { background: var(--disabled-ink); border-color: var(--disabled-ink); }
248
+ .ui-check input[type="checkbox"]:disabled:indeterminate::after,
249
+ .ui-table__selection input[type="checkbox"]:disabled:indeterminate::after { border-color: var(--disabled-surface); }
250
+ /* Forced colours replace every fill, so a disabled box that painted only a fill and
251
+ a --disabled-* edge drew the same edge and mark as an enabled one, and a radio's dot
252
+ vanished. GrayText is the system colour for an unavailable control. The tick and the
253
+ dash are borders, so they keep it; the dot is a fill, so it names a system colour. */
254
+ @media (forced-colors: active) {
255
+ .ui-check input:disabled,
256
+ .ui-check input:disabled:is(:checked, :indeterminate),
257
+ .ui-check input[type="checkbox"]:disabled:is(:checked, :indeterminate),
258
+ .ui-table__selection input[type="checkbox"]:disabled,
259
+ .ui-table__selection input[type="checkbox"]:disabled:is(:checked, :indeterminate) { border-color: GrayText; }
260
+ .ui-check input[type="checkbox"]:disabled:is(:checked, :indeterminate)::after,
261
+ .ui-table__selection input[type="checkbox"]:disabled:is(:checked, :indeterminate)::after { border-color: GrayText; }
262
+ .ui-check input[type="radio"]:enabled:checked::after { background: CanvasText; }
263
+ .ui-check input[type="radio"]:disabled:checked::after { background: GrayText; }
264
+ }
210
265
  /* The label is the click target, so the cursor belongs on it, and the words it
211
266
  holds go quiet with the box beside them. */
212
267
  .ui-check:has(input:disabled) { /* muted-ink: state — unavailable control */ color: var(--disabled-ink); cursor: not-allowed; }
@@ -285,14 +285,58 @@
285
285
 
286
286
  /* -- Horizontal tabs ----------------------------------------------------- */
287
287
  .ui-nav--tabs {
288
+ /* A scroll box clips at its padding edge, so the ring needs padding, not bare
289
+ paint room. Equal negative margins give the row back its place on the gutter
290
+ and in the flow, and the rule is painted as a background line one pixel above
291
+ the padding — a border-bottom would sit below it and lift the active
292
+ underline off the rule. */
293
+ --ui-tabs-ring-room: var(--space-1);
294
+ --ui-tabs-rule: 1px;
295
+ --ui-tabs-ground: var(--bg);
296
+ --ui-tabs-edge: color-mix(in srgb, var(--sheen) 12%, transparent);
297
+ min-width: 0;
298
+ max-width: calc(100% + 2 * var(--ui-tabs-ring-room));
299
+ overflow-x: auto;
300
+ padding: var(--ui-tabs-ring-room);
301
+ margin: calc(-1 * var(--ui-tabs-ring-room)) calc(-1 * var(--ui-tabs-ring-room))
302
+ calc(var(--ui-tabs-rule) - var(--ui-tabs-ring-room));
288
303
  display: flex;
289
304
  align-items: stretch;
290
305
  gap: 4px;
291
- border-bottom: 1px solid var(--border);
306
+ /* Local covers hide the table-style shades when their edge is reached.
307
+ The row rule stays above the covers and keeps its original position. */
308
+ background-image:
309
+ linear-gradient(var(--border), var(--border)),
310
+ linear-gradient(to right, var(--ui-tabs-ground) 50%, transparent),
311
+ linear-gradient(to left, var(--ui-tabs-ground) 50%, transparent),
312
+ radial-gradient(farthest-side at 0 50%, var(--ui-tabs-edge), transparent),
313
+ radial-gradient(farthest-side at 100% 50%, var(--ui-tabs-edge), transparent);
314
+ background-repeat: no-repeat;
315
+ background-position:
316
+ center bottom calc(var(--ui-tabs-ring-room) - var(--ui-tabs-rule)),
317
+ 0 0, 100% 0, 0 0, 100% 0;
318
+ background-size:
319
+ calc(100% - 2 * var(--ui-tabs-ring-room)) var(--ui-tabs-rule),
320
+ var(--space-6) 100%, var(--space-6) 100%, var(--space-3) 100%, var(--space-3) 100%;
321
+ background-attachment: scroll, local, local, scroll, scroll;
292
322
  }
293
- .ui-nav--tabs.is-pill { border-bottom: 0; gap: 6px; }
323
+ /* Fitting rows keep overflow visible. Measurements retain the same padding and margins,
324
+ so switching containment does not move the links. Unwired rows keep containment. */
325
+ .ui-nav--tabs[data-nav-fit] { overflow: visible; }
326
+ .ui-nav--tabs.is-pill { --ui-tabs-rule: 0px; gap: 6px; }
327
+ .ui-card .ui-nav--tabs { --ui-tabs-ground: var(--ui-card-ground); }
328
+ :root[data-theme="light"] .ui-nav--tabs {
329
+ --ui-tabs-edge: color-mix(in srgb, var(--shadow-ink) 13%, transparent);
330
+ }
331
+ /* The row is a keyboard stop of its own only while it scrolls AND holds no focusable
332
+ link — every tab disabled — and Chromium answers that stop with its black outline. It
333
+ is a scroll region, so it takes the kit's inward band like the other six: an outset
334
+ ring would be clipped by the very box that scrolls. #531's reading, on #429's row. */
335
+ .ui-nav--tabs:focus-visible { outline: var(--ring-scroll); outline-offset: var(--ring-scroll-offset); }
294
336
  .ui-nav__tab {
337
+ flex: none;
295
338
  position: relative;
339
+ max-width: 100%;
296
340
  display: inline-flex;
297
341
  align-items: center;
298
342
  gap: 8px;
@@ -307,6 +351,7 @@
307
351
  .ui-nav__tab:hover { color: var(--strong); }
308
352
  .ui-nav__tab:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: 8px; }
309
353
  .ui-nav__tab.is-active { color: var(--strong); }
354
+ .ui-nav__tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
310
355
 
311
356
  /* underline variant */
312
357
  .ui-nav--tabs.is-underline .ui-nav__tab::after {
@@ -324,10 +369,26 @@
324
369
 
325
370
  /* pill variant */
326
371
  .ui-nav--tabs.is-pill .ui-nav__tab { border-radius: var(--radius-pill); padding: 8px 15px; }
327
- .ui-nav--tabs.is-pill .ui-nav__tab:hover { background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
372
+ .ui-nav--tabs.is-pill .ui-nav__tab:hover { background: var(--surface); /* code-bg: inherit — control paint; its focus indicator uses the containing surface. */ }
373
+ /* The current route keeps its quiet fill and uses accent ink. */
328
374
  .ui-nav--tabs.is-pill .ui-nav__tab.is-active {
329
- background: var(--glow-purple); /* code-bg: inherit — a wash paints over a ground and behind a chip, so the opaque ground below it is the step. */
330
375
  color: var(--accent);
376
+ background: var(--glow-purple); /* code-bg: inherit — translucent wash retains the opaque containing gap. */
377
+ }
378
+
379
+ /* Forced colours: the selected-tab marks are in the block below. */
380
+ @media (forced-colors: active) {
381
+ /* Forced colours drop background images, so the row's rule comes back as a border.
382
+ The last pixel of the bottom padding pays for it and the row keeps its size. The
383
+ border image leaves the ring room at both ends bare, so the rule ends on the gutter
384
+ as the background line does. */
385
+ .ui-nav--tabs {
386
+ padding-bottom: calc(var(--ui-tabs-ring-room) - var(--ui-tabs-rule));
387
+ border-bottom: var(--ui-tabs-rule) solid;
388
+ border-image: linear-gradient(to right, transparent var(--ui-tabs-ring-room),
389
+ CanvasText var(--ui-tabs-ring-room) calc(100% - var(--ui-tabs-ring-room)),
390
+ transparent calc(100% - var(--ui-tabs-ring-room))) 0 0 1 0;
391
+ }
331
392
  }
332
393
 
333
394
  .ui-nav__tab.is-disabled { /* muted-ink: state — unavailable control */ color: var(--disabled-ink); cursor: default; }
@@ -467,6 +467,9 @@ a.ui-identity:hover { text-decoration: underline; }
467
467
  .ui-table--pinned .ui-identity__name { min-width: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
468
468
  }
469
469
 
470
+ /* Automatic layout hands every column a share of the free width, and a box is not worth one: the selection column takes the width of its box and the gap after it. The pinned table below fixes it instead. */
471
+ .ui-table .ui-table__selection { width: 1%; }
472
+
470
473
  /* One width reserves the selection track and offsets the adjacent identity. */
471
474
  .ui-table--pinned { --ui-table-selection-width: var(--space-12); --ui-table-identity-max: min(var(--panel-sm), 50vw); }
472
475
  .ui-table--pinned .ui-table__selection { box-sizing: border-box; width: var(--ui-table-selection-width); min-width: var(--ui-table-selection-width); max-width: var(--ui-table-selection-width); padding-inline: 0; text-align: center; }
@@ -96,6 +96,7 @@
96
96
  with a 1.5px border, so without it the clamp takes this layer DOWN to 22
97
97
  and under the kit's own minimum. */
98
98
  .ui-check input::before,
99
+ .ui-table__selection input[type="checkbox"]::before,
99
100
  .ui-toast__close::before {
100
101
  width: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x))));
101
102
  height: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y))));
@@ -168,6 +169,12 @@
168
169
  }
169
170
  .ui-table__act { --tap-clear-x: var(--tap-gap); --tap-clear-y: 0px; }
170
171
 
172
+ /* The selection box is 19px in a 58px row and a 34px header: the layer has room to reach the floor both ways. */
173
+ .ui-table__selection { --tap-clear-x: var(--tap-min); --tap-clear-y: var(--tap-min); }
174
+ /* A dense or compact table's rows are 33px to 40px apart and share an edge, so a layer that grows down takes the pixels nearest the next row's box. Those rows keep the 24px floor. */
175
+ .ui-table--dense .ui-table__selection,
176
+ .ui-table--compact .ui-table__selection { --tap-clear-y: 0px; }
177
+
171
178
  /* Down only, like the nine rows below: a toast is a full row at this width,
172
179
  and opening it across took 24px out of the message for no reach at all —
173
180
  its action already clears the floor on the vertical half. The box does not