@apliteni/apliteni-ui 0.37.0 → 0.38.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/README.md CHANGED
@@ -105,6 +105,10 @@ import { tokensCss, topbarCss, cssText } from '@apliteni/apliteni-ui/inline';
105
105
  // …inline tokensCss + topbarCss into the <style> you serve.
106
106
  ```
107
107
 
108
+ `cssText` includes every sheet. When selecting individual sheets from `styles`,
109
+ `successPanel()` needs both `styles.callout` (panel layout) and `styles.success`
110
+ (shared glowing check and reduced-motion styles), after `tokensCss` and `baseCss`.
111
+
108
112
  ## React components (stateful surfaces)
109
113
 
110
114
  `DataTable`, `Pagination`, `Modal`, `Button`, `Badge`, `Card` and `Icon` — same `.ui-*` classes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -162,6 +162,12 @@ type PagerProps = {
162
162
  type DataTableProps<T> = {
163
163
  columns: Column<T>[];
164
164
  rows: T[];
165
+ dense?: boolean;
166
+ density?: 'default' | 'dense' | 'compact';
167
+ stickyHeader?: boolean;
168
+ pinnedIdentity?: boolean;
169
+ scrollLabel?: string;
170
+ empty?: ReactNode;
165
171
  pageSize?: number;
166
172
  pageSizes?: readonly number[] | null;
167
173
  onPageSizeChange?: (size: number) => void;
@@ -185,7 +191,7 @@ type DataTableProps<T> = {
185
191
  declare function sortTableRows<T>(rows: T[], sort: TableSort<T>): T[];
186
192
  declare function DataTable<T extends {
187
193
  name: string;
188
- }>({ columns, rows, pageSize, pageSizes, onPageSizeChange, pager, pagerLabel, loading, selectable, selected, onToggle, onTogglePage, sort: controlledSort, onSortChange, page: controlledPage, onPageChange, total, hasMore, }: DataTableProps<T>): react.JSX.Element;
194
+ }>({ columns, rows, dense, density, stickyHeader, pinnedIdentity, scrollLabel, empty, pageSize, pageSizes, onPageSizeChange, pager, pagerLabel, loading, selectable, selected, onToggle, onTogglePage, sort: controlledSort, onSortChange, page: controlledPage, onPageChange, total, hasMore, }: DataTableProps<T>): react.JSX.Element;
189
195
 
190
196
  type PaginationProps = {
191
197
  page?: number;
@@ -363,7 +369,63 @@ type BackLinkProps<T extends ElementType = 'a'> = BackLinkOwnProps & {
363
369
  */
364
370
  declare function BackLink<T extends ElementType = 'a'>({ href, label, as, className, ...rest }: BackLinkProps<T>): react.JSX.Element | null;
365
371
 
372
+ type NumericValueProps = {
373
+ value?: string | number | null;
374
+ unit?: string;
375
+ missing?: string;
376
+ };
377
+ type DeltaValueProps = {
378
+ value?: string | null;
379
+ tone?: 'success' | 'danger' | 'neutral';
380
+ basisId?: string;
381
+ missing?: string;
382
+ };
383
+ declare function NumericValue(props: NumericValueProps): react.JSX.Element;
384
+ declare function DeltaValue(props: DeltaValueProps): react.JSX.Element;
385
+ type RowIdentityProps = {
386
+ symbol: string;
387
+ name: string;
388
+ logo?: string;
389
+ href?: string;
390
+ };
391
+ declare function RowIdentity({ symbol, name, logo, href }: RowIdentityProps): react.JSX.Element;
392
+
393
+ type SegmentOption = {
394
+ label: string;
395
+ value: string;
396
+ disabled?: boolean;
397
+ };
398
+ type SegmentedProps = {
399
+ options: SegmentOption[];
400
+ value: string;
401
+ onChange: (value: string) => void;
402
+ label: string;
403
+ appearance?: 'pill' | 'underline';
404
+ disabled?: boolean;
405
+ };
406
+ declare function Segmented({ options, value, onChange, label, appearance, disabled }: SegmentedProps): react.JSX.Element;
407
+
408
+ type Filter = {
409
+ id: string;
410
+ label: string;
411
+ value: string;
412
+ items: DropdownEntry[];
413
+ disabled?: boolean;
414
+ open?: boolean;
415
+ };
416
+ type FilterBarProps = {
417
+ filters: Filter[];
418
+ label?: string;
419
+ clearLabel?: string;
420
+ disabled?: boolean;
421
+ busy?: boolean;
422
+ onRemove: (id: string) => void;
423
+ onClear: () => void;
424
+ onChange: (id: string, value: string | undefined) => void;
425
+ };
426
+ declare function FilterBar(props: FilterBarProps): react.JSX.Element;
427
+
366
428
  declare const PAGE_SIZES: readonly number[];
367
429
  declare const DEFAULT_PAGE_SIZE: number;
368
430
 
369
- export { BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, Denied, type DeniedProps, Drawer, type DrawerProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, Icon, Modal, type ModalProps, PAGE_SIZES, Pagination, type PaginationProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TableSort, sortTableRows };
431
+ export { BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, type Filter, FilterBar, type FilterBarProps, Icon, Modal, type ModalProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, type TableSort, sortTableRows };
@@ -842,6 +842,12 @@ function sortTableRows(rows, sort) {
842
842
  function DataTable({
843
843
  columns,
844
844
  rows,
845
+ dense = false,
846
+ density,
847
+ stickyHeader = false,
848
+ pinnedIdentity = false,
849
+ scrollLabel = "Table",
850
+ empty = "No rows",
845
851
  pageSize,
846
852
  pageSizes = null,
847
853
  onPageSizeChange,
@@ -896,54 +902,66 @@ function DataTable({
896
902
  const caret = (k) => sort.key === k ? sort.dir === 1 ? " \u25B2" : " \u25BC" : " \u2195";
897
903
  const pageAllOn = selectable && slice.length > 0 && slice.every((r) => selected.has(r.name));
898
904
  return /* @__PURE__ */ jsxs8(Fragment2, { children: [
899
- /* @__PURE__ */ jsxs8(
900
- "table",
905
+ /* @__PURE__ */ jsx10(
906
+ "div",
901
907
  {
902
- className: "ui-table ui-table--hover ui-table--zebra",
903
- "aria-busy": loading || void 0,
904
- children: [
905
- /* @__PURE__ */ jsx10("thead", { children: /* @__PURE__ */ jsxs8("tr", { children: [
906
- selectable ? /* @__PURE__ */ jsx10("th", { scope: "col", children: /* @__PURE__ */ jsx10(
907
- "input",
908
- {
909
- type: "checkbox",
910
- checked: pageAllOn,
911
- "aria-label": "Select all rows on this page",
912
- onChange: () => onTogglePage(slice.map((r) => r.name))
913
- }
914
- ) }) : null,
915
- columns.map((c) => (
916
- // The sort control is a real <button> inside the header cell. It used to be
917
- // role="button" ON the <th>, which threw away the columnheader role and put
918
- // aria-sort on a role that forbids it.
919
- /* @__PURE__ */ jsx10(
920
- "th",
921
- {
922
- scope: "col",
923
- className: [c.num && "ui-table__num", c.sortable && "rx-sortable"].filter(Boolean).join(" "),
924
- "aria-sort": sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : c.sortable && sortIsKnown ? "none" : void 0,
925
- children: c.sortable ? /* @__PURE__ */ jsxs8("button", { type: "button", className: "rx-sort", onClick: () => onSort(c.key), children: [
926
- c.label,
927
- /* @__PURE__ */ jsx10("span", { className: "rx-caret", "aria-hidden": "true", children: caret(c.key) })
928
- ] }) : c.label
929
- },
930
- c.key
931
- )
932
- ))
933
- ] }) }),
934
- /* @__PURE__ */ jsx10("tbody", { children: slice.map((r) => /* @__PURE__ */ jsxs8("tr", { children: [
935
- selectable ? /* @__PURE__ */ jsx10("td", { children: /* @__PURE__ */ jsx10(
936
- "input",
937
- {
938
- type: "checkbox",
939
- checked: selected.has(r.name),
940
- "aria-label": `Select ${r.name}`,
941
- onChange: () => onToggle(r.name)
942
- }
943
- ) }) : null,
944
- columns.map((c) => /* @__PURE__ */ jsx10("td", { className: c.num ? "ui-table__num" : void 0, children: c.render ? c.render(r) : String(r[c.key]) }, c.key))
945
- ] }, r.name)) })
946
- ]
908
+ className: stickyHeader || pinnedIdentity ? "ui-table-scroll" : void 0,
909
+ role: stickyHeader || pinnedIdentity ? "region" : void 0,
910
+ "aria-label": stickyHeader || pinnedIdentity ? scrollLabel : void 0,
911
+ tabIndex: stickyHeader || pinnedIdentity ? 0 : void 0,
912
+ children: /* @__PURE__ */ jsxs8(
913
+ "table",
914
+ {
915
+ className: ["ui-table ui-table--hover", (density === "dense" || !density && dense) && "ui-table--dense", density === "compact" && "ui-table--compact", stickyHeader && "ui-table--sticky", pinnedIdentity && "ui-table--pinned"].filter(Boolean).join(" "),
916
+ "aria-busy": loading || void 0,
917
+ children: [
918
+ /* @__PURE__ */ jsx10("thead", { children: /* @__PURE__ */ jsxs8("tr", { children: [
919
+ selectable ? /* @__PURE__ */ jsx10("th", { scope: "col", className: "ui-table__selection", children: /* @__PURE__ */ jsx10(
920
+ "input",
921
+ {
922
+ type: "checkbox",
923
+ checked: pageAllOn,
924
+ "aria-label": "Select all rows on this page",
925
+ onChange: () => onTogglePage(slice.map((r) => r.name))
926
+ }
927
+ ) }) : null,
928
+ columns.map((c, columnIndex) => (
929
+ // The sort control is a real <button> inside the header cell. It used to be
930
+ // role="button" ON the <th>, which threw away the columnheader role and put
931
+ // aria-sort on a role that forbids it.
932
+ /* @__PURE__ */ jsx10(
933
+ "th",
934
+ {
935
+ scope: "col",
936
+ className: [pinnedIdentity && columnIndex === 0 && "ui-table__identity", c.num && "ui-table__num", c.sortable && "rx-sortable"].filter(Boolean).join(" "),
937
+ "aria-sort": sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : c.sortable && sortIsKnown ? "none" : void 0,
938
+ children: c.sortable ? /* @__PURE__ */ jsxs8("button", { type: "button", className: "rx-sort", onClick: () => onSort(c.key), children: [
939
+ c.label,
940
+ /* @__PURE__ */ jsx10("span", { className: "rx-caret", "aria-hidden": "true", children: caret(c.key) })
941
+ ] }) : c.label
942
+ },
943
+ c.key
944
+ )
945
+ ))
946
+ ] }) }),
947
+ /* @__PURE__ */ jsxs8("tbody", { children: [
948
+ !slice.length && /* @__PURE__ */ jsx10("tr", { children: /* @__PURE__ */ jsx10("td", { colSpan: columns.length + (selectable ? 1 : 0), children: loading ? "Loading rows\u2026" : empty }) }),
949
+ slice.map((r) => /* @__PURE__ */ jsxs8("tr", { children: [
950
+ selectable ? /* @__PURE__ */ jsx10("td", { className: "ui-table__selection", children: /* @__PURE__ */ jsx10(
951
+ "input",
952
+ {
953
+ type: "checkbox",
954
+ checked: selected.has(r.name),
955
+ "aria-label": `Select ${r.name}`,
956
+ onChange: () => onToggle(r.name)
957
+ }
958
+ ) }) : null,
959
+ columns.map((c, columnIndex) => /* @__PURE__ */ jsx10("td", { className: [c.num && "ui-table__num", pinnedIdentity && columnIndex === 0 && "ui-table__identity"].filter(Boolean).join(" "), children: c.render ? c.render(r) : String(r[c.key]) }, c.key))
960
+ ] }, r.name))
961
+ ] })
962
+ ]
963
+ }
964
+ )
947
965
  }
948
966
  ),
949
967
  pager ? /* @__PURE__ */ jsx10(
@@ -1470,6 +1488,96 @@ function BackLink({
1470
1488
  );
1471
1489
  }
1472
1490
 
1491
+ // src/TableValues.tsx
1492
+ import { numericValue, deltaValue } from "@apliteni/apliteni-ui";
1493
+ import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
1494
+ function NumericValue(props) {
1495
+ return /* @__PURE__ */ jsx14("span", { dangerouslySetInnerHTML: { __html: numericValue(props) } });
1496
+ }
1497
+ function DeltaValue(props) {
1498
+ return /* @__PURE__ */ jsx14("span", { dangerouslySetInnerHTML: { __html: deltaValue(props) } });
1499
+ }
1500
+ function RowIdentity({ symbol, name, logo, href }) {
1501
+ const Tag = href ? "a" : "span";
1502
+ return /* @__PURE__ */ jsxs12(Tag, { className: "ui-identity", href, children: [
1503
+ /* @__PURE__ */ jsxs12("span", { className: "ui-identity__logo", "aria-hidden": "true", children: [
1504
+ /* @__PURE__ */ jsx14("span", { children: symbol.slice(0, 1) }),
1505
+ logo && /* @__PURE__ */ jsx14("img", { src: logo, alt: "", onLoad: (e) => {
1506
+ e.currentTarget.hidden = false;
1507
+ }, onError: (e) => {
1508
+ e.currentTarget.hidden = true;
1509
+ } })
1510
+ ] }),
1511
+ /* @__PURE__ */ jsx14("span", { className: "ui-identity__symbol", children: symbol }),
1512
+ /* @__PURE__ */ jsx14("span", { className: "ui-identity__name", children: name })
1513
+ ] });
1514
+ }
1515
+
1516
+ // src/Segmented.tsx
1517
+ import { segmentedNextIndex } from "@apliteni/apliteni-ui";
1518
+ import { jsx as jsx15 } from "react/jsx-runtime";
1519
+ function Segmented({ options, value, onChange, label, appearance = "pill", disabled = false }) {
1520
+ const enabled = options.filter((o) => !o.disabled);
1521
+ const tabValue = enabled.some((o) => o.value === value) ? value : enabled[0]?.value;
1522
+ return /* @__PURE__ */ jsx15("div", { className: `ui-seg${appearance === "underline" ? " ui-seg--underline" : ""}`, role: "toolbar", "aria-label": label, children: options.map((option) => /* @__PURE__ */ jsx15(
1523
+ "button",
1524
+ {
1525
+ type: "button",
1526
+ "data-value": option.value,
1527
+ disabled: disabled || option.disabled,
1528
+ className: value === option.value ? "is-active" : void 0,
1529
+ "aria-pressed": value === option.value,
1530
+ tabIndex: option.value === tabValue ? 0 : -1,
1531
+ onClick: () => onChange(option.value),
1532
+ onKeyDown: (e) => {
1533
+ const next = segmentedNextIndex(e.key, enabled.indexOf(option), enabled.length);
1534
+ if (next == null) return;
1535
+ e.preventDefault();
1536
+ const buttons = [...e.currentTarget.parentElement.querySelectorAll("button:not(:disabled)")];
1537
+ buttons[next]?.focus();
1538
+ onChange(enabled[next].value);
1539
+ },
1540
+ children: option.label
1541
+ },
1542
+ option.value
1543
+ )) });
1544
+ }
1545
+
1546
+ // src/FilterBar.tsx
1547
+ import { useLayoutEffect as useLayoutEffect2, useRef as useRef7 } from "react";
1548
+ import { filterBar, initFilterBar } from "@apliteni/apliteni-ui";
1549
+ import { jsx as jsx16 } from "react/jsx-runtime";
1550
+ function FilterBar(props) {
1551
+ const host = useRef7(null);
1552
+ const latest = useRef7(props);
1553
+ latest.current = props;
1554
+ const controller = useRef7(null);
1555
+ const initial = useRef7({ __html: filterBar(props) });
1556
+ useLayoutEffect2(() => {
1557
+ const el = host.current;
1558
+ controller.current = initFilterBar(el, latest.current);
1559
+ const remove = (e) => latest.current.onRemove(e.detail.id);
1560
+ const clear = () => latest.current.onClear();
1561
+ const change = (e) => {
1562
+ const { id, value } = e.detail;
1563
+ latest.current.onChange(id, value);
1564
+ };
1565
+ el.addEventListener("ui-filter-remove", remove);
1566
+ el.addEventListener("ui-filter-clear", clear);
1567
+ el.addEventListener("ui-filter-change", change);
1568
+ return () => {
1569
+ controller.current?.destroy();
1570
+ el.removeEventListener("ui-filter-remove", remove);
1571
+ el.removeEventListener("ui-filter-clear", clear);
1572
+ el.removeEventListener("ui-filter-change", change);
1573
+ };
1574
+ }, []);
1575
+ useLayoutEffect2(() => {
1576
+ controller.current?.update(props);
1577
+ }, [props.filters, props.label, props.clearLabel, props.disabled, props.busy]);
1578
+ return /* @__PURE__ */ jsx16("div", { ref: host, dangerouslySetInnerHTML: initial.current });
1579
+ }
1580
+
1473
1581
  // src/index.ts
1474
1582
  var PAGE_SIZES = KIT_PAGE_SIZES;
1475
1583
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
@@ -1482,13 +1590,18 @@ export {
1482
1590
  CommandPalette,
1483
1591
  DEFAULT_PAGE_SIZE3 as DEFAULT_PAGE_SIZE,
1484
1592
  DataTable,
1593
+ DeltaValue,
1485
1594
  Denied,
1486
1595
  Drawer,
1487
1596
  Dropdown,
1597
+ FilterBar,
1488
1598
  Icon,
1489
1599
  Modal,
1600
+ NumericValue,
1490
1601
  PAGE_SIZES,
1491
1602
  Pagination,
1603
+ RowIdentity,
1604
+ Segmented,
1492
1605
  Skeleton,
1493
1606
  SkeletonTable,
1494
1607
  StatBand,
@@ -0,0 +1,56 @@
1
+ import { button, esc } from './index.js';
2
+ import { dropdown, wireDropdown } from './dropdown.js';
3
+
4
+ export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear all filters', disabled = false, busy = false } = {}) {
5
+ return `<fieldset class="ui-filter-bar" data-filter-bar${disabled || busy ? ' disabled' : ''}${busy ? ' aria-busy="true"' : ''}>`
6
+ + `<legend class="ui-filter-bar__legend">${esc(label)}</legend>`
7
+ + filters.map(filter => `<fieldset class="ui-filter-bar__chip" data-filter-id="${esc(filter.id)}"${filter.disabled ? ' disabled' : ''}>`
8
+ + `<legend class="ui-filter-bar__legend">${esc(filter.label)}</legend>`
9
+ + dropdown({ label: filter.label, value: filter.value, items: filter.items || [], variant: 'select', ariaLabel: `${filter.label}: ${filter.value}`, open: !!filter.open && !disabled && !busy && !filter.disabled })
10
+ + `<button type="button" class="ui-filter-bar__remove" data-filter-remove aria-label="${esc(`Remove ${filter.label} filter`)}">×</button></fieldset>`).join('')
11
+ + `<span data-filter-clear>${button({ label: clearLabel, size: 'sm', variant: 'ghost', disabled: !filters.length })}</span></fieldset>`;
12
+ }
13
+
14
+ // The host stays mounted; update() restores the action's focus after controlled removal.
15
+ export function initFilterBar(host, options = {}) {
16
+ let current = options;
17
+ const focusKey = () => {
18
+ const active = host.ownerDocument.activeElement;
19
+ if (!host.contains(active)) return null;
20
+ const chip = active.closest('[data-filter-id]');
21
+ return { id: chip?.dataset.filterId, remove: active.hasAttribute('data-filter-remove'),
22
+ index: [...host.querySelectorAll('[data-filter-id]')].indexOf(chip) };
23
+ };
24
+ const wire = () => wireDropdown(host);
25
+ const update = next => {
26
+ const focus = focusKey();
27
+ current = next;
28
+ host.innerHTML = filterBar(next); wire();
29
+ if (!focus) return;
30
+ const chips = [...host.querySelectorAll('[data-filter-id]')].filter(c => !c.disabled);
31
+ const same = chips.find(c => c.dataset.filterId === focus.id);
32
+ const target = same || chips[Math.min(Math.max(focus.index, 0), chips.length - 1)];
33
+ const control = target?.querySelector(same && focus.remove ? '[data-filter-remove]' : '[data-dropdown-trigger]')
34
+ || host.querySelector('[data-filter-clear] button');
35
+ if (control && !control.disabled && !control.closest('fieldset:disabled')) control.focus();
36
+ else { const bar = host.querySelector('[data-filter-bar]'); bar.tabIndex = -1; bar.focus(); }
37
+ };
38
+ const emit = (type, detail) => host.dispatchEvent(new host.ownerDocument.defaultView.CustomEvent(type, { bubbles: true, detail }));
39
+ const click = e => {
40
+ if (e.target.closest('fieldset:disabled')) return;
41
+ const chip = e.target.closest('[data-filter-id]');
42
+ if (e.target.closest('[data-filter-remove]')) emit('ui-filter-remove', { id: chip.dataset.filterId });
43
+ else if (e.target.closest('[data-filter-clear] button')) emit('ui-filter-clear', {});
44
+ else if (e.target.closest('[data-dd-item]')) {
45
+ const item = e.target.closest('[data-dd-item]');
46
+ if (item.getAttribute('aria-disabled') !== 'true') {
47
+ const id = chip?.dataset.filterId, value = item.dataset.value;
48
+ // Dropdown completes its own close/focus before the consumer replaces the markup.
49
+ queueMicrotask(() => emit('ui-filter-change', { id, value }));
50
+ }
51
+ }
52
+ };
53
+ if (!host.querySelector('[data-filter-bar]')) host.innerHTML = filterBar(current);
54
+ host.addEventListener('click', click, true); wire();
55
+ return { update, destroy: () => host.removeEventListener('click', click, true) };
56
+ }
@@ -2,10 +2,10 @@
2
2
  // viz/ server-render idiom so the portal can adopt them with no framework.
3
3
  import { icon } from '../assets/icons.js';
4
4
  import { illo } from '../assets/illustrations.js';
5
+ import { successCheck } from './success.js';
5
6
  const HTML_ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
6
7
  const cx = (...a) => a.filter(Boolean).join(' ');
7
8
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ENTITIES[c]);
8
-
9
9
  // ---- Button --------------------------------------------------------------
10
10
  // `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
11
11
  // branded glyphs the kit's icon set doesn't own — e.g. a Google "G". It takes
@@ -80,16 +80,16 @@ export function card({ title, sub, body = '', variant, pad, icon: ic, level = 2
80
80
  // `ariaLabel` names the strip. `name` seeds data-seg and is deliberately
81
81
  // not an accessible name — it is an identifier, not prose, defaulting to
82
82
  // "Options" so a strip is never left unlabelled.
83
- export function segmented({ options = [], active = 0, size, block, name = 'seg', ariaLabel = 'Options' } = {}) {
84
- const cls = cx('ui-seg', size && `ui-seg--${size}`, block && 'ui-seg--block');
83
+ export function segmented({ options = [], active = 0, size, block, name = 'seg', ariaLabel = 'Options', appearance, disabled = false } = {}) {
84
+ const cls = cx('ui-seg', appearance === 'underline' && 'ui-seg--underline', size && `ui-seg--${size}`, block && 'ui-seg--block');
85
85
  // Exactly one option holds the Tab stop. If `active` points nowhere (nothing
86
86
  // selected yet) the first option holds it, so the strip is never unreachable.
87
- const rove = active >= 0 && active < options.length ? active : 0;
87
+ const rove = active >= 0 && active < options.length && !options[active]?.disabled ? active : options.findIndex(o => !o.disabled);
88
88
  const btns = options.map((o, i) => {
89
89
  const label = typeof o === 'string' ? o : o.label;
90
90
  const val = typeof o === 'string' ? o : (o.value ?? o.label);
91
91
  const on = i === active;
92
- return `<button type="button" aria-pressed="${on}" tabindex="${i === rove ? '0' : '-1'}"`
92
+ return `<button type="button"${disabled || o.disabled ? ' disabled' : ''} aria-pressed="${on}" tabindex="${i === rove ? '0' : '-1'}"`
93
93
  + ` data-value="${esc(val)}"${on ? ' class="is-active"' : ''}>${esc(label)}</button>`;
94
94
  }).join('');
95
95
  return `<div class="${cls}" role="toolbar" aria-label="${esc(ariaLabel)}" data-seg="${name}">${btns}</div>`;
@@ -261,9 +261,9 @@ export function toast({
261
261
  // The kit also publishes success() from components/success.js, which is the same
262
262
  // idea at page size: layouts, a backdrop, follow-up buttons and an auto-redirect
263
263
  // countdown. Pick by how much of the screen the confirmation owns. The two share
264
- // no CSS, so changing one never moves the other.
264
+ // the glowing check; their layout and content remain independent.
265
265
  export function successPanel({ title = 'Done', sub = '' } = {}) {
266
- return `<div class="ui-success"><div class="ui-success__check">${icon('check')}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
266
+ return `<div class="ui-success"><div class="ui-success__check">${successCheck()}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
267
267
  }
268
268
 
269
269
  // ---- Empty state ---------------------------------------------------------
@@ -0,0 +1,40 @@
1
+ export function segmentedNextIndex(key, index, length) {
2
+ if (!length) return null;
3
+ if (key === 'ArrowRight' || key === 'ArrowDown') return (index + 1) % length;
4
+ if (key === 'ArrowLeft' || key === 'ArrowUp') return (index - 1 + length) % length;
5
+ if (key === 'Home') return 0;
6
+ if (key === 'End') return length - 1;
7
+ return null;
8
+ }
9
+
10
+ export function initSegmented(root = document) {
11
+ const cleanups = [];
12
+ root.querySelectorAll('[data-seg]').forEach(group => {
13
+ if (group.__segWired) return;
14
+ group.__segWired = true;
15
+ const buttons = () => [...group.querySelectorAll('button')].filter(b => !b.disabled);
16
+ const pick = button => {
17
+ group.querySelectorAll('button').forEach(b => {
18
+ const active = b === button;
19
+ b.setAttribute('aria-pressed', String(active));
20
+ b.classList.toggle('is-active', active);
21
+ b.tabIndex = active ? 0 : -1;
22
+ });
23
+ group.dispatchEvent(new group.ownerDocument.defaultView.CustomEvent('ui-segment-change', {
24
+ bubbles: true, detail: { value: button.dataset.value },
25
+ }));
26
+ };
27
+ const click = e => { const b = e.target.closest('button'); if (buttons().includes(b)) pick(b); };
28
+ const keydown = e => {
29
+ const list = buttons();
30
+ const index = list.indexOf(e.target);
31
+ if (index < 0) return;
32
+ const next = segmentedNextIndex(e.key, index, list.length);
33
+ if (next == null) return;
34
+ e.preventDefault(); list[next].focus(); pick(list[next]);
35
+ };
36
+ group.addEventListener('click', click); group.addEventListener('keydown', keydown);
37
+ cleanups.push(() => { group.removeEventListener('click', click); group.removeEventListener('keydown', keydown); delete group.__segWired; });
38
+ });
39
+ return () => cleanups.forEach(fn => fn());
40
+ }
@@ -60,8 +60,8 @@ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
60
60
  // For a confirmation that stays inside the page the user is already on, the kit
61
61
  // publishes successPanel() from components/index.js — a check, a title and one
62
62
  // line of sub, and nothing to configure. Pick by how much of the screen the
63
- // confirmation owns. The two share no CSS (.ui-sx here, .ui-success there), so
64
- // changing one never moves the other.
63
+ // confirmation owns. The two share the glowing check, while their layout
64
+ // and content remain independent.
65
65
  export function success({
66
66
  layout = 'hero', // 'hero' | 'split' | 'compact'
67
67
  level, // heading level of the title; see the note below
@@ -0,0 +1,32 @@
1
+ import { esc } from './index.js';
2
+
3
+ export function numericValue({ value, unit = '', missing = 'Not available' } = {}) {
4
+ if (value == null || value === '') return `<span class="ui-value" aria-label="${esc(missing)}">—</span>`;
5
+ return `<span class="ui-value">${esc(value)}${unit ? `<span class="ui-value__unit">${esc(unit)}</span>` : ''}</span>`;
6
+ }
7
+
8
+ export function deltaValue({ value, tone = 'neutral', basisId, missing = 'No earlier figure' } = {}) {
9
+ const present = value != null && value !== '';
10
+ const judged = present && ['success', 'danger'].includes(tone) && !/^[+−-]?0(?:[.,]0+)?%?$/.test(String(value).replace(/\s/g, ''));
11
+ return `<span class="ui-delta${judged ? ` ui-delta--${tone}` : ''}"${basisId ? ` aria-describedby="${esc(basisId)}"` : ''}>${esc(present ? value : missing)}</span>`;
12
+ }
13
+
14
+ export function rowIdentity({ symbol = '', name = '', logo, href } = {}) {
15
+ const tag = href ? 'a' : 'span';
16
+ return `<${tag} class="ui-identity"${href ? ` href="${esc(href)}"` : ''}>`
17
+ + `<span class="ui-identity__logo" aria-hidden="true"><span>${esc(symbol.slice(0, 1))}</span>${logo ? `<img src="${esc(logo)}" alt="">` : ''}</span>`
18
+ + `<span class="ui-identity__symbol">${esc(symbol)}</span><span class="ui-identity__name">${esc(name)}</span></${tag}>`;
19
+ }
20
+
21
+ const initializedImages = new WeakSet();
22
+
23
+ // Images keep their fallback underneath; no inline handlers in server-rendered markup.
24
+ export function initRowIdentity(root = document) {
25
+ root.querySelectorAll('.ui-identity__logo img').forEach(img => {
26
+ if (img.complete && !img.naturalWidth) img.hidden = true;
27
+ if (initializedImages.has(img)) return;
28
+ initializedImages.add(img);
29
+ img.addEventListener('error', () => { img.hidden = true; });
30
+ img.addEventListener('load', () => { img.hidden = false; });
31
+ });
32
+ }
package/src/index.css CHANGED
@@ -32,6 +32,7 @@
32
32
  @import "./styles/confirm.css";
33
33
  @import "./styles/command-palette.css";
34
34
  @import "./styles/table.css";
35
+ @import "./styles/filter-bar.css";
35
36
  @import "./styles/pagination.css";
36
37
  @import "./styles/stat.css";
37
38
  @import "./styles/empty.css";
package/src/index.js CHANGED
@@ -21,3 +21,7 @@ export * from './components/stat.js';
21
21
  export * from './assets/icons.js';
22
22
  export * from './assets/brand.js';
23
23
  export * from './motion.js';
24
+
25
+ export * from './components/table-values.js';
26
+ export * from './components/segmented.js';
27
+ export * from './components/filter-bar.js';
package/src/inline.js CHANGED
@@ -25,7 +25,7 @@ export const baseCss = read('styles/base.css');
25
25
  // Canonical topbar (same class names the portal already uses).
26
26
  export const topbarCss = read('styles/topbar.css');
27
27
 
28
- // Individual component stylesheets, addressable by name.
28
+ // Individual sheets; successPanel() needs both styles.callout and styles.success.
29
29
  export const styles = {
30
30
  base: baseCss,
31
31
  reducedMotion: read('styles/reduced-motion.css'),
@@ -45,6 +45,7 @@ export const styles = {
45
45
  confirm: read('styles/confirm.css'),
46
46
  commandPalette: read('styles/command-palette.css'),
47
47
  table: read('styles/table.css'),
48
+ filterBar: read('styles/filter-bar.css'),
48
49
  pagination: read('styles/pagination.css'),
49
50
  stat: read('styles/stat.css'),
50
51
  empty: read('styles/empty.css'),
@@ -79,6 +80,7 @@ export const cssText = [
79
80
  styles.confirm,
80
81
  styles.commandPalette,
81
82
  styles.table,
83
+ styles.filterBar,
82
84
  styles.pagination,
83
85
  styles.stat,
84
86
  styles.empty,
@@ -181,14 +181,10 @@
181
181
  background: var(--glow-green); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */
182
182
  }
183
183
  .ui-success__check {
184
+ --sx-green: var(--chip-success-ink);
184
185
  width: 48px; height: 48px;
185
- border-radius: 50%;
186
- background: var(--green);
187
- color: var(--signal-contrast);
188
- display: grid;
189
- place-items: center;
190
186
  margin: 0 auto 14px;
191
187
  }
192
- .ui-success__check svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 2.4; }
188
+ .ui-success__check .ui-sx__check { width: 100%; height: 100%; }
193
189
  .ui-success__title { color: var(--strong); font-size: var(--text-lg); font-weight: var(--weight-semibold); margin-bottom: 5px; }
194
190
  .ui-success__sub { color: var(--text); font-size: var(--text-base); }
@@ -0,0 +1,10 @@
1
+ .ui-filter-bar { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); min-width: 0; }
2
+ .ui-filter-bar__legend { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); }
3
+ .ui-filter-bar__chip { display: inline-flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--space-1); margin: 0; min-width: 0; }
4
+ .ui-filter-bar__chip .ui-dropdown__trigger { border: 0; background: transparent; padding: var(--space-1) var(--space-2); }
5
+ .ui-filter-bar__remove { border: 0; background: transparent; color: var(--text); font: inherit; text-align: center; font-size: var(--text-lg); padding: 0; min-width: 24px; min-height: 24px; border-radius: var(--radius-xs); cursor: pointer; }
6
+ .ui-filter-bar__remove:focus-visible,
7
+ .ui-filter-bar:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
8
+ .ui-filter-bar button:disabled { /* muted-ink: state — disabled filters */ color: var(--disabled-ink); cursor: not-allowed; }
9
+
10
+ .ui-filter-bar .ui-dropdown__pre, .ui-filter-bar .ui-dropdown__value { color: inherit; }
@@ -55,3 +55,10 @@
55
55
  box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--swatch, var(--accent));
56
56
  }
57
57
  .ui-accent-picker button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
58
+
59
+ .ui-seg--underline { background: transparent; --ring-gap: inherit; border-radius: 0; border-bottom: 1px solid var(--border); padding: var(--space-1); overflow: auto; max-width: 100%; }
60
+ .ui-seg--underline button { border-radius: var(--radius-xs); border-bottom: 2px solid transparent; padding: var(--space-3); white-space: nowrap; }
61
+ .ui-seg--underline button.is-active,
62
+ .ui-seg--underline button[aria-pressed="true"] { background: transparent; border-bottom-color: var(--accent); }
63
+ .ui-seg button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
64
+ .ui-seg button:disabled { /* muted-ink: state — unavailable choice */ color: var(--disabled-ink); cursor: not-allowed; }
@@ -11,11 +11,13 @@
11
11
  * the foot. Markup comes from success() in components/index (re-exported).
12
12
  * ========================================================================== */
13
13
 
14
- .ui-sx {
15
- --sx-green: var(--green);
14
+ .ui-sx, .ui-success__check {
16
15
  --sx-tint: color-mix(in srgb, var(--green) 18%, transparent);
17
16
  --sx-track: color-mix(in srgb, var(--green) 32%, transparent);
17
+ }
18
18
 
19
+ .ui-sx {
20
+ --sx-green: var(--green);
19
21
  position: relative;
20
22
  overflow: hidden;
21
23
  border-radius: 20px;
@@ -1,5 +1,5 @@
1
1
  /* ============================================================================
2
- * Table — .ui-table (borderless-body, hairline rows, muted head)
2
+ * Table — .ui-table (hairline rows, opaque base surface)
3
3
  * Row modifiers: .is-dead (revoked/struck), right-aligned .ui-table__act
4
4
  * ========================================================================== */
5
5
 
@@ -26,7 +26,7 @@
26
26
  }
27
27
  .ui-table tr:last-child td { border-bottom: 0; }
28
28
  .ui-table td.ui-table__title { color: var(--strong); font-weight: var(--weight-medium); width: 99%; white-space: normal; }
29
- .ui-table th:last-child,
29
+ .ui-table th:last-child { padding-right: 0; }
30
30
  .ui-table td.ui-table__act { padding-right: 0; text-align: right; }
31
31
  .ui-table tr.is-dead td { /* muted-ink: state — revoked row */ color: var(--muted); }
32
32
  .ui-table tr.is-dead td.ui-table__title { text-decoration: line-through; }
@@ -50,7 +50,7 @@
50
50
 
51
51
  /* Data-table modifiers, composable: .ui-table--dense --zebra --hover.
52
52
  * --dense --space-3 across, --space-2 down — one step in from the base table
53
- * --zebra row striping; 2px header rule; the hover tint out-ranks the stripe
53
+ * --zebra legacy spacing and 2px header rule; the base-surface rule below removes stripes
54
54
  * Cell helpers: .ui-table__num (right, tabular), .ui-table__code.
55
55
  * why: docs/specification.md#spacing-and-rhythm
56
56
  * held by: stories/table-rhythm.test.js */
@@ -83,3 +83,45 @@
83
83
 
84
84
  /* The empty state (.ui-empty) — shown when a table/list has no rows — now
85
85
  lives in styles/empty.css as a standalone component (illustration support). */
86
+
87
+ /* White in light, base canvas in dark; never a grey or tinted data surface. */
88
+ .ui-table { background: var(--table-bg); --ring-gap: var(--table-bg); }
89
+ .ui-table--zebra tbody tr:nth-child(even) td,
90
+ .ui-table--hover:not(.ui-table--zebra) tbody tr:hover td,
91
+ .ui-table--zebra tbody tr:hover td,
92
+ .ui-table--zebra.ui-table--hover tbody tr:hover td {
93
+ background: var(--table-bg); --ring-gap: var(--table-bg);
94
+ }
95
+ .ui-table tbody tr:hover { outline: 1px solid var(--border-strong); outline-offset: -1px; }
96
+ .ui-table td.ui-table__num { text-align: right; }
97
+ .ui-table--compact td { padding: var(--space-1) var(--space-3); font-size: var(--text-sm); line-height: var(--leading-snug); height: 33px; }
98
+ .ui-table--compact th { padding: var(--space-2) var(--space-3); }
99
+ .ui-table-scroll { overflow: auto; max-height: var(--ui-table-height, 70vh); padding: 0 var(--space-1); }
100
+ .ui-table-scroll:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
101
+ .ui-table--sticky { border-collapse: separate; border-spacing: 0; }
102
+ .ui-table--sticky thead th { position: sticky; top: 0; z-index: var(--z-sticky); background: var(--table-bg); --ring-gap: var(--table-bg); padding-top: var(--space-2); }
103
+ .ui-table--pinned .ui-table__identity, .ui-table--pinned .ui-table__selection { position: sticky; left: 0; z-index: var(--z-base); background: var(--table-bg); --ring-gap: var(--table-bg); border-right: 1px solid var(--border); }
104
+ .ui-table--pinned thead .ui-table__identity, .ui-table--pinned thead .ui-table__selection { z-index: calc(var(--z-sticky) + 1); }
105
+ .ui-value, .ui-delta { white-space: nowrap; font-variant-numeric: tabular-nums; }
106
+ .ui-value__unit { margin-left: var(--space-1); font-size: var(--text-xs); color: var(--text); }
107
+ .ui-delta--success { color: var(--chip-success-ink); }
108
+ .ui-delta--danger { color: var(--chip-danger-ink); }
109
+ .ui-identity { min-height: 24px; display: inline-flex; align-items: center; gap: var(--space-2); color: var(--text); text-decoration: none; }
110
+ .ui-identity__symbol { font-family: var(--font-mono); font-weight: var(--weight-semibold); }
111
+ .ui-identity__name { white-space: normal; max-width: var(--panel-sm); }
112
+ .ui-identity__logo { width: 24px; height: 24px; flex: 0 0 24px; position: relative; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-xs); font-size: var(--text-xs); }
113
+ .ui-identity__logo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
114
+ .ui-identity__logo img[hidden] { display: none; /* motion: still — a failed resource is replaced without animating the error. */ }
115
+ a.ui-identity:hover { text-decoration: underline; }
116
+ .ui-identity:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); border-radius: var(--radius-xs); }
117
+ @media (max-width: 720px) {
118
+ .ui-table--pinned .ui-table__identity { width: 1%; }
119
+ .ui-table--pinned .ui-identity__logo { display: none; }
120
+ .ui-table--pinned .ui-identity__name { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
121
+ .ui-table--pinned .ui-identity { max-width: var(--panel-sm); }
122
+ }
123
+
124
+ /* One width reserves the selection track and offsets the adjacent identity. */
125
+ .ui-table--pinned { --ui-table-selection-width: var(--space-12); }
126
+ .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; }
127
+ .ui-table--pinned .ui-table__selection + .ui-table__identity { left: var(--ui-table-selection-width); }
@@ -138,6 +138,7 @@
138
138
  below the floating step; the floating step itself adds --elev-drop under its inner
139
139
  line and nothing else in the kit does. why: docs/specification.md#elevation */
140
140
  --bg: #0e0d14;
141
+ --table-bg: var(--bg);
141
142
  --surface-2: #161520; /* SUNKEN — fields, tracks, disabled, code */
142
143
  --surface: #211e2d; /* the card */
143
144
  --bg-elevated: #2a2639; /* FLOATING — menus, panels, drawer, modal, toasts */
@@ -284,6 +285,7 @@
284
285
  white a card already was, so light moves DOWN and keeps white for the top step.
285
286
  why: docs/specification.md#elevation */
286
287
  --bg: #eef0f5;
288
+ --table-bg: #ffffff;
287
289
  --surface-2: #e3e6ee; /* SUNKEN — fields, tracks, disabled, code */
288
290
  --surface: #f8f9fc; /* the card */
289
291
  --bg-elevated: #ffffff; /* FLOATING — menus, panels, drawer, modal, toasts */
@@ -430,6 +432,9 @@
430
432
  .ui-snippet,
431
433
  .ui-snippet--reveal,
432
434
  .ui-sx,
435
+ .ui-table,
436
+ .ui-table--sticky thead th,
437
+ .ui-table--pinned .ui-table__identity, .ui-table--pinned .ui-table__selection,
433
438
  .ui-table--hover:not(.ui-table--zebra) tbody tr:hover td,
434
439
  .ui-table--zebra tbody tr:nth-child(even) td,
435
440
  .ui-table--zebra tbody tr:hover td,