@apliteni/apliteni-ui 0.125.1 → 0.126.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.126.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.
@@ -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 })
@@ -1581,12 +1581,12 @@ function Denied({
1581
1581
  title = "You don\u2019t have access",
1582
1582
  sub,
1583
1583
  need,
1584
- icon: icon6 = "lock",
1584
+ icon: icon7 = "lock",
1585
1585
  className,
1586
1586
  children
1587
1587
  }) {
1588
1588
  return /* @__PURE__ */ jsxs14("div", { className: cx4("ui-denied", className), children: [
1589
- /* @__PURE__ */ jsx18("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx18(Icon, { name: icon6 }) }),
1589
+ /* @__PURE__ */ jsx18("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx18(Icon, { name: icon7 }) }),
1590
1590
  /* @__PURE__ */ jsx18("div", { className: "ui-denied__title", children: title }),
1591
1591
  sub && /* @__PURE__ */ jsx18("div", { className: "ui-denied__sub", children: sub }),
1592
1592
  need && /* @__PURE__ */ jsxs14("div", { className: "ui-denied__need", children: [
@@ -3903,11 +3903,11 @@ var cases = {
3903
3903
  "not-found": { title: "We can't find that page", sub: "Check the address or return to the home page.", icon: "folder" },
3904
3904
  "not-yet-built": { title: "This ships later", sub: "This screen is not available yet. Check back later.", icon: "clock" }
3905
3905
  };
3906
- function EmptyState({ variant = "first-run", title, sub, icon: icon6, art, actions }) {
3906
+ function EmptyState({ variant = "first-run", title, sub, icon: icon7, art, actions }) {
3907
3907
  const preset = cases[variant];
3908
3908
  const heading = title ?? preset.title;
3909
3909
  const guidance = sub ?? preset.sub;
3910
- const glyph = icon6 ?? preset.icon;
3910
+ const glyph = icon7 ?? preset.icon;
3911
3911
  return /* @__PURE__ */ jsxs31("div", { className: "ui-empty", children: [
3912
3912
  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
3913
  heading && /* @__PURE__ */ jsx36("div", { className: "ui-empty__title", children: heading }),
@@ -4760,6 +4760,97 @@ var AccentPicker = forwardRef10(function AccentPicker2({
4760
4760
  );
4761
4761
  });
4762
4762
 
4763
+ // src/NavTabs.tsx
4764
+ import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef21, useImperativeHandle as useImperativeHandle2 } from "react";
4765
+ import { safeUrl, observeNavFit, revealCurrentNav, revealFocusedNav } from "@apliteni/apliteni-ui";
4766
+ import { Fragment as Fragment15, jsx as jsx40, jsxs as jsxs34 } from "react/jsx-runtime";
4767
+ var NavTabs = forwardRef11(function NavTabs2({
4768
+ items,
4769
+ active,
4770
+ variant = "underline",
4771
+ renderLink,
4772
+ className,
4773
+ onFocus,
4774
+ "aria-label": ariaLabel = "Tabs",
4775
+ ...rest
4776
+ }, ref) {
4777
+ const navRef = useRef21(null);
4778
+ useImperativeHandle2(ref, () => navRef.current, []);
4779
+ const signature = items.map((item) => item.id).join("\0");
4780
+ useEffect17(() => observeNavFit(navRef.current), []);
4781
+ useEffect17(() => {
4782
+ if (navRef.current) revealCurrentNav(navRef.current);
4783
+ }, [active, signature]);
4784
+ return /* @__PURE__ */ jsx40(
4785
+ "nav",
4786
+ {
4787
+ ...rest,
4788
+ ref: navRef,
4789
+ className: ["ui-nav", "ui-nav--tabs", `is-${variant}`, className].filter(Boolean).join(" "),
4790
+ "aria-label": ariaLabel,
4791
+ onFocus: (event) => {
4792
+ if (navRef.current) revealFocusedNav(navRef.current, event.target);
4793
+ onFocus?.(event);
4794
+ },
4795
+ children: items.map((item) => {
4796
+ const badge = item.badge != null && typeof item.badge === "object" ? item.badge : { text: item.badge, tone: void 0 };
4797
+ const inner = /* @__PURE__ */ jsxs34(Fragment15, { children: [
4798
+ /* @__PURE__ */ jsx40("span", { className: "ui-nav__tab-label", children: item.label }),
4799
+ badge.text != null && badge.text !== "" && /* @__PURE__ */ jsx40("span", { className: `ui-nav__badge is-${badge.tone || "neutral"}`, children: badge.text })
4800
+ ] });
4801
+ if (item.disabled) return /* @__PURE__ */ jsx40("span", { className: "ui-nav__tab is-disabled", "aria-disabled": "true", children: inner }, item.id);
4802
+ const current = item.id === active;
4803
+ const linkProps = {
4804
+ className: ["ui-nav__tab", current && "is-active"].filter(Boolean).join(" "),
4805
+ href: safeUrl(item.href || `#${item.id}`),
4806
+ target: item.target,
4807
+ "aria-current": current ? "page" : void 0,
4808
+ children: inner
4809
+ };
4810
+ return /* @__PURE__ */ jsx40(Fragment14, { children: renderLink ? renderLink(item, linkProps) : /* @__PURE__ */ jsx40("a", { ...linkProps }) }, item.id);
4811
+ })
4812
+ }
4813
+ );
4814
+ });
4815
+
4816
+ // src/Breadcrumbs.tsx
4817
+ import { forwardRef as forwardRef12 } from "react";
4818
+ import { icon as icon6, safeUrl as safeUrl2 } from "@apliteni/apliteni-ui";
4819
+ import { Fragment as Fragment16, jsx as jsx41, jsxs as jsxs35 } from "react/jsx-runtime";
4820
+ var Breadcrumbs = forwardRef12(function Breadcrumbs2({
4821
+ items,
4822
+ renderLink,
4823
+ className,
4824
+ "aria-label": ariaLabel = "Breadcrumb",
4825
+ ...rest
4826
+ }, ref) {
4827
+ return /* @__PURE__ */ jsx41(
4828
+ "nav",
4829
+ {
4830
+ ...rest,
4831
+ ref,
4832
+ className: ["ui-nav", "ui-nav--crumbs", className].filter(Boolean).join(" "),
4833
+ "aria-label": ariaLabel,
4834
+ children: /* @__PURE__ */ jsx41("ol", { className: "ui-nav__crumbs", children: items.map((item, index) => {
4835
+ const current = index === items.length - 1;
4836
+ const inner = /* @__PURE__ */ jsxs35(Fragment16, { children: [
4837
+ item.icon && /* @__PURE__ */ jsx41("span", { className: "ui-nav__ic", dangerouslySetInnerHTML: { __html: icon6(item.icon) } }),
4838
+ /* @__PURE__ */ jsx41("span", { className: "ui-nav__crumb-label", children: item.label })
4839
+ ] });
4840
+ const linkProps = !current && item.href ? { className: "ui-nav__crumb", href: safeUrl2(item.href), target: item.target, children: inner } : null;
4841
+ return /* @__PURE__ */ jsx41("li", { className: "ui-nav__crumb-item", children: linkProps ? renderLink ? renderLink(item, linkProps) : /* @__PURE__ */ jsx41("a", { ...linkProps }) : /* @__PURE__ */ jsx41(
4842
+ "span",
4843
+ {
4844
+ className: ["ui-nav__crumb", current && "is-current"].filter(Boolean).join(" "),
4845
+ "aria-current": current ? "page" : void 0,
4846
+ children: inner
4847
+ }
4848
+ ) }, index);
4849
+ }) })
4850
+ }
4851
+ );
4852
+ });
4853
+
4763
4854
  // src/index.ts
4764
4855
  var PAGE_SIZES = KIT_PAGE_SIZES;
4765
4856
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
@@ -4768,6 +4859,7 @@ export {
4768
4859
  AppShell,
4769
4860
  BackLink,
4770
4861
  Badge,
4862
+ Breadcrumbs,
4771
4863
  BusyRegion,
4772
4864
  Button,
4773
4865
  Callout,
@@ -4793,6 +4885,7 @@ export {
4793
4885
  Icon,
4794
4886
  KeyValueList,
4795
4887
  Modal,
4888
+ NavTabs,
4796
4889
  NumericValue,
4797
4890
  PAGE_SIZES,
4798
4891
  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
  }
@@ -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; }