@apliteni/apliteni-ui 0.85.1 → 0.87.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.85.1",
3
+ "version": "0.87.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -786,3 +786,36 @@ use it to stay the same whenever they run. `locale` names the months and weekday
786
786
  the trigger's trailing edge. Without `ariaLabel` the trigger's own text is its accessible
787
787
  name, so a control reading "17 September 2026" is heard as that rather than as its
788
788
  placeholder. The popover stays mounted while closed, and is `inert` while it is.
789
+
790
+ ## AccentPicker
791
+
792
+ `AccentPicker` is a controlled group of the kit’s four accent swatches. Pass `value`
793
+ and `onChange`; optionally pass `options` to choose a subset or its order. Values are
794
+ `default`, `phoenix`, `ocean`, and `emerald` (`Accent`). Import the kit CSS.
795
+
796
+ ```tsx
797
+ <AccentPicker value={accent} onChange={setAccent} />
798
+ ```
799
+
800
+ Each button has an accessible name and a pressed state. Tab visits each choice;
801
+ Enter and Space request a change without submitting a surrounding form. The host
802
+ owns setting `data-accent` on the page and persisting the choice. The component does
803
+ neither. A value outside `options` leaves every button unpressed; an empty list
804
+ renders an empty group. Supply unique options.
805
+
806
+ The group accepts native div attributes, an additional `className`, and a forwarded
807
+ ref. Its default accessible name is “Accent”; override it with `aria-label` or
808
+ `aria-labelledby` when multiple pickers need distinct names.
809
+
810
+ It emits no `data-accent-pick` on its buttons, so a page that calls
811
+ `wireTopbar(document)` — which a half-migrated page must, to wire its vanilla
812
+ `footer({ switcher: accentPicker() })` — cannot adopt a picker React owns, apply the
813
+ accent and persist it behind your `onChange`. This is the same decision `Dropdown`,
814
+ `Drawer` and `ThemeToggle` make with their own wiring hooks.
815
+
816
+ The selected swatch is marked by a tick inside the circle, in the kit's ink for a
817
+ saturated fill. Focus is the kit ring (`--ring`) and it is the only accent edge the
818
+ control ever draws: selection and focus are one signal each, and only focus is an
819
+ accent. Accent names and swatch paints come from the kit's `ACCENTS` and
820
+ `accentSwatchStyle`, which the vanilla factory also reads, so the two pickers cannot
821
+ drift.
@@ -58,6 +58,10 @@
58
58
  --tap-clear-x: var(--space-1, 4px);
59
59
  --tap-clear-y: var(--tap-gap);
60
60
  }
61
+ .ui-seg--underline {
62
+ row-gap: var(--space-1, 4px) !important;
63
+ --tap-clear-y: var(--space-1, 4px);
64
+ }
61
65
  .ui-tabs__list {
62
66
  --tap-clear-x: 2px;
63
67
  --tap-clear-y: var(--tap-gap);
@@ -1,5 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { InputHTMLAttributes, ReactNode, ButtonHTMLAttributes, RefAttributes, ReactElement, AnchorHTMLAttributes, HTMLAttributes, RefObject, MouseEvent, ElementType, ComponentPropsWithoutRef, SelectHTMLAttributes, TextareaHTMLAttributes, SVGAttributes } from 'react';
3
+ import { ACCENTS } from '@apliteni/apliteni-ui';
3
4
 
4
5
  declare function Icon({ name }: {
5
6
  name: string;
@@ -931,7 +932,19 @@ declare const SidebarNav: react.ForwardRefExoticComponent<Omit<Omit<react.Detail
931
932
  renderLink?: (item: SidebarNavItem, props: ComponentPropsWithoutRef<"a">) => ReactNode;
932
933
  } & react.RefAttributes<HTMLElement>>;
933
934
 
935
+ type Accent = (typeof ACCENTS)[number];
936
+ type AccentPickerProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children'> & {
937
+ value: Accent;
938
+ onChange: (value: Accent) => void;
939
+ options?: readonly Accent[];
940
+ };
941
+ declare const AccentPicker: react.ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLDivElement>, "children" | "onChange"> & {
942
+ value: Accent;
943
+ onChange: (value: Accent) => void;
944
+ options?: readonly Accent[];
945
+ } & react.RefAttributes<HTMLDivElement>>;
946
+
934
947
  declare const PAGE_SIZES: readonly number[];
935
948
  declare const DEFAULT_PAGE_SIZE: number;
936
949
 
937
- export { 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, 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 };
950
+ export { type Accent, AccentPicker, type AccentPickerProps, 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, 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 };
@@ -2412,7 +2412,7 @@ function Segmented({ options, value, onChange, label, appearance = "pill", disab
2412
2412
 
2413
2413
  // src/FilterBar.tsx
2414
2414
  import { useRef as useRef12, useState as useState8 } from "react";
2415
- import { filterChipText, filterChipName, filterChipUnset } from "@apliteni/apliteni-ui";
2415
+ import { filterChipText, filterChipName, filterChipUnset, focusNextStop } from "@apliteni/apliteni-ui";
2416
2416
  import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
2417
2417
  function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters", disabled = false, busy = false, onRemove, onClear, onChange }) {
2418
2418
  const bar = useRef12(null);
@@ -2428,7 +2428,8 @@ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters
2428
2428
  const next = chips.slice(prior.index).find((chip) => !chip.disabled) || chips.slice(0, prior.index).reverse().find((chip) => !chip.disabled);
2429
2429
  const target = !blocked && next?.querySelector("[data-dropdown-trigger]");
2430
2430
  focused.current = null;
2431
- (target || bar.current)?.focus();
2431
+ if (target) target.focus();
2432
+ else focusNextStop(bar.current);
2432
2433
  });
2433
2434
  return /* @__PURE__ */ jsx24("div", { children: /* @__PURE__ */ jsxs19(
2434
2435
  "fieldset",
@@ -2486,9 +2487,9 @@ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters
2486
2487
  },
2487
2488
  filter.id
2488
2489
  )),
2489
- /* @__PURE__ */ jsx24("span", { "data-filter-clear": "", onFocus: () => {
2490
+ filters.length > 0 && /* @__PURE__ */ jsx24("span", { "data-filter-clear": "", onFocus: () => {
2490
2491
  focused.current = { id: null, index: 0 };
2491
- }, children: /* @__PURE__ */ jsx24(Button, { size: "sm", variant: "ghost", disabled: !filters.length, onClick: () => {
2492
+ }, children: /* @__PURE__ */ jsx24(Button, { size: "sm", onClick: () => {
2492
2493
  if (!blocked) onClear();
2493
2494
  }, children: clearLabel }) })
2494
2495
  ]
@@ -3646,10 +3647,52 @@ var Success = forwardRef9(function Success2({
3646
3647
  );
3647
3648
  });
3648
3649
 
3650
+ // src/AccentPicker.tsx
3651
+ import { forwardRef as forwardRef10 } from "react";
3652
+ import { ACCENTS, accentSwatchStyle } from "@apliteni/apliteni-ui";
3653
+ import { jsx as jsx38 } from "react/jsx-runtime";
3654
+ var AccentPicker = forwardRef10(function AccentPicker2({
3655
+ value,
3656
+ onChange,
3657
+ options = ACCENTS,
3658
+ className,
3659
+ "aria-label": label = "Accent",
3660
+ ...rest
3661
+ }, ref) {
3662
+ return /* @__PURE__ */ jsx38(
3663
+ "div",
3664
+ {
3665
+ ...rest,
3666
+ ref,
3667
+ className: ["ui-accent-picker", className].filter(Boolean).join(" "),
3668
+ "data-accent-group": "",
3669
+ role: "group",
3670
+ "aria-label": label,
3671
+ children: options.map((accent) => {
3672
+ const title = accent.charAt(0).toUpperCase() + accent.slice(1);
3673
+ return /* @__PURE__ */ jsx38(
3674
+ "button",
3675
+ {
3676
+ type: "button",
3677
+ className: accent === value ? "is-active" : void 0,
3678
+ style: accentSwatchStyle(accent),
3679
+ "aria-pressed": accent === value,
3680
+ "aria-label": `${title} accent`,
3681
+ title,
3682
+ onClick: () => onChange(accent)
3683
+ },
3684
+ accent
3685
+ );
3686
+ })
3687
+ }
3688
+ );
3689
+ });
3690
+
3649
3691
  // src/index.ts
3650
3692
  var PAGE_SIZES = KIT_PAGE_SIZES;
3651
3693
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
3652
3694
  export {
3695
+ AccentPicker,
3653
3696
  AppShell,
3654
3697
  BackLink,
3655
3698
  Badge,
@@ -1,6 +1,6 @@
1
1
  import { button, esc } from './index.js';
2
2
  import { dropdown, wireDropdown } from './dropdown.js';
3
- import { filterChipText, filterChipName, filterChipUnset } from '../logic/filter-bar.js';
3
+ import { filterChipText, filterChipName, filterChipUnset, focusNextStop } from '../logic/filter-bar.js';
4
4
 
5
5
  export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear all filters', disabled = false, busy = false } = {}) {
6
6
  return `<fieldset class="ui-filter-bar" data-filter-bar${disabled || busy ? ' disabled' : ''}${busy ? ' aria-busy="true"' : ''}>`
@@ -13,7 +13,17 @@ export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear
13
13
  triggerContent: `<span class="${filterChipUnset(filter) ? 'ui-dropdown__value is-placeholder' : 'ui-dropdown__value'}">${esc(filterChipText(filter))}</span>`,
14
14
  ariaLabel: filterChipName(filter), open: !!filter.open && !disabled && !busy && !filter.disabled })
15
15
  + `<button type="button" class="ui-filter-bar__remove" data-filter-remove aria-label="${esc(`Remove ${filter.label} filter`)}">×</button></fieldset>`).join('')
16
- + `<span data-filter-clear>${button({ label: clearLabel, size: 'sm', variant: 'ghost', disabled: !filters.length })}</span></fieldset>`;
16
+ // Nothing to clear, no control: an unavailable action standing in an empty
17
+ // row is the only thing in it, and it reads as a bar that has been turned
18
+ // off rather than one with no filters on it. It is the kit's own `one-page`
19
+ // rule — a control with no action to offer is not shown. It returns with
20
+ // the first chip, in the bordered skin rather than the ghost one, because a
21
+ // live action beside two chips has to read as live.
22
+ // why: docs/specification.md#a-filter-row-holds-its-panels
23
+ + (filters.length
24
+ ? `<span data-filter-clear>${button({ label: clearLabel, size: 'sm' })}</span>`
25
+ : '')
26
+ + '</fieldset>';
17
27
  }
18
28
 
19
29
  // The host stays mounted; update() restores the action's focus after controlled removal.
@@ -38,7 +48,9 @@ export function initFilterBar(host, options = {}) {
38
48
  const control = target?.querySelector(same && focus.remove ? '[data-filter-remove]' : '[data-dropdown-trigger]')
39
49
  || host.querySelector('[data-filter-clear] button');
40
50
  if (control && !control.disabled && !control.closest('fieldset:disabled')) control.focus();
41
- else { const bar = host.querySelector('[data-filter-bar]'); bar.tabIndex = -1; bar.focus(); }
51
+ // No control of its own left to hold it: the shared pair decides where the
52
+ // focus goes and checks that it arrived. why: src/logic/filter-bar.js
53
+ else focusNextStop(host);
42
54
  };
43
55
  const emit = (type, detail) => host.dispatchEvent(new host.ownerDocument.defaultView.CustomEvent(type, { bubbles: true, detail }));
44
56
  const click = e => {
@@ -1,4 +1,5 @@
1
1
  import { calloutIcons } from '../logic/callout.js';
2
+ import { ACCENTS, accentSwatchStyle } from '../logic/accents.js';
2
3
  export { calloutIcons } from '../logic/callout.js';
3
4
  // apliteni-ui component factories — each returns an HTML string, matching the
4
5
  // viz/ server-render idiom so the portal can adopt them with no framework.
@@ -99,25 +100,16 @@ export function segmented({ options = [], active = 0, size, block, name = 'seg',
99
100
  }
100
101
 
101
102
  // ---- Accent picker -------------------------------------------------------
102
- // Each swatch is made of the tokens its accent selects: it fades from the next
103
- // distinct step up that accent's dark ramp down to dark --accent. Most accents
104
- // land on --purple-light; Nebula's is its own --accent since #157, so it walks
105
- // on to --purple-mid. site/chrome.mjs and site/index.html hand-keep the same
106
- // four strings and must change with these. Held by stories/accent-swatch.test.js,
107
- // which derives both sides rather than restating them. See issue #190.
108
- const ACCENT_SWATCH = {
109
- default: 'linear-gradient(135deg,#bd8cff,#b479ff)',
110
- phoenix: 'linear-gradient(135deg,#ff8a5c,#ff6a3d)',
111
- ocean: 'linear-gradient(135deg,#5ab0ff,#3b9dff)',
112
- emerald: 'linear-gradient(135deg,#3ad9a0,#16c98a)',
113
- };
103
+ // The accent list and every swatch's paints live in ../logic/accents.js, which
104
+ // the React picker reads too, so neither copy can drift from the other.
114
105
  // Swatches have no text and no glyph at all — the aria-label is the whole
115
106
  // accessible name, and aria-pressed carries which one is on.
116
- export function accentPicker({ active = 'default', options = ['default', 'phoenix', 'ocean', 'emerald'] } = {}) {
107
+ export function accentPicker({ active = 'default', options = ACCENTS } = {}) {
117
108
  const title = (o) => o.charAt(0).toUpperCase() + o.slice(1);
109
+ const style = (o) => Object.entries(accentSwatchStyle(o)).map(([k, v]) => `${k}:${v}`).join(';');
118
110
  return `<div class="ui-accent-picker" data-accent-group role="group" aria-label="Accent">${options.map((o) =>
119
111
  `<button type="button" data-accent-pick="${esc(o)}"${o === active ? ' class="is-active"' : ''}`
120
- + ` style="--swatch:${ACCENT_SWATCH[o] || 'transparent'}" aria-pressed="${o === active ? 'true' : 'false'}"`
112
+ + ` style="${style(o)}" aria-pressed="${o === active ? 'true' : 'false'}"`
121
113
  + ` aria-label="${esc(title(o))} accent" title="${esc(title(o))}"></button>`).join('')}</div>`;
122
114
  }
123
115
 
@@ -119,7 +119,8 @@ export function applyTheme(t, root = document.documentElement) {
119
119
  }
120
120
 
121
121
  const ACCENT_KEY = 'apliteni-strategy-accent';
122
- export const ACCENTS = ['default', 'phoenix', 'ocean', 'emerald'];
122
+ // One list for the pickers, this wiring and the React component.
123
+ export { ACCENTS } from '../logic/accents.js';
123
124
 
124
125
  export function applyAccent(name, root = document.documentElement) {
125
126
  if (!name || name === 'default') root.removeAttribute('data-accent');
package/src/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export declare function initFilterBar(host: Element, opts?: Record<string, unkno
10
10
  export declare function filterChipText(filter?: { label?: string; value?: string }): string;
11
11
  export declare function filterChipName(filter?: { label?: string; value?: string }): string;
12
12
  export declare function filterChipUnset(filter?: { value?: string }): boolean;
13
+ export declare function nextFocusStop(host: Element | null | undefined): HTMLElement | null;
14
+ export declare function focusNextStop(host: Element | null | undefined): HTMLElement | null;
13
15
  export declare function segmentedNextIndex(key: string, index: number, length: number): number | null;
14
16
  export declare function icon(name: string, className?: string): string;
15
17
  export declare function snippet(opts?: Record<string, unknown>): string;
@@ -53,6 +55,10 @@ export declare function illo(name: string): string;
53
55
  export * from './motion.js';
54
56
  export declare const illoNames: string[];
55
57
 
58
+ // The custom properties one accent swatch button carries. ACCENTS is declared
59
+ // with the topbar wiring that re-exports it.
60
+ export declare function accentSwatchStyle(accent: string): Record<string, string>;
61
+
56
62
  // Vanilla factories accept their existing option bags and return HTML.
57
63
  export declare function accentPicker(opts?: Record<string, unknown>): string;
58
64
  export declare function accountMenu(opts?: Record<string, unknown>): string;
@@ -96,7 +102,8 @@ export declare function themeName(theme: string): string;
96
102
  export declare function themeToggle(theme?: string): string;
97
103
  export declare function versionSwitcher(versions?: { label: string; meta?: string; badge?: string }[], activeIdx?: number): string;
98
104
 
99
- export declare const ACCENTS: string[];
105
+ // A tuple, so React can derive its Accent union from it.
106
+ export declare const ACCENTS: readonly ['default', 'phoenix', 'ocean', 'emerald'];
100
107
  export declare const ACCOUNT_NAV: { id: string; icon: string; label: string }[];
101
108
  export declare const RAIL_COOKIE: 'apliteni-ui-rail';
102
109
  export declare const STAT_TONES: string[];
package/src/index.js CHANGED
@@ -30,7 +30,8 @@ export * from './components/filter-bar.js';
30
30
  export { dropdownMatch, dropdownFiltering } from './logic/dropdown.js';
31
31
  export { SCORE, rankGroups, rankCommands, scoreCommand, paletteHotkey } from './logic/command-palette.js';
32
32
  export { segmentedNextIndex } from './logic/segmented.js';
33
- export { filterChipText, filterChipName, filterChipUnset } from './logic/filter-bar.js';
33
+ export { filterChipText, filterChipName, filterChipUnset, nextFocusStop, focusNextStop } from './logic/filter-bar.js';
34
34
  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
+ export { accentSwatchStyle } from './logic/accents.js';
@@ -0,0 +1,35 @@
1
+ // The accents the kit ships, and the paints one accent swatch needs.
2
+ //
3
+ // Every copy of the picker reads this module, so there is one list and one set of
4
+ // paints rather than a copy per renderer. src/components/index.js and
5
+ // react/src/AccentPicker.tsx call accentSwatchStyle(); site/chrome.mjs and
6
+ // site/index.html still hand-keep the same four gradients. Held by
7
+ // stories/accent-swatch.test.js, which derives both sides out of src/tokens/.
8
+ // why: docs/specification.md#react-accent-picker. See issues #190 and #429.
9
+ export const ACCENTS = ['default', 'phoenix', 'ocean', 'emerald'];
10
+
11
+ // Per accent, the gradient's two stops — each the value src/tokens/ declares for
12
+ // that accent. The swatch gate reads the tokens and this table apart and
13
+ // compares them.
14
+ const PAINTS = {
15
+ default: { from: '#bd8cff', to: '#b479ff' },
16
+ phoenix: { from: '#ff8a5c', to: '#ff6a3d' },
17
+ ocean: { from: '#5ab0ff', to: '#3b9dff' },
18
+ emerald: { from: '#3ad9a0', to: '#16c98a' },
19
+ };
20
+
21
+ /**
22
+ * The custom properties one swatch button carries, as property → value.
23
+ *
24
+ * An accent with no entry here gets `--swatch: transparent`, which is what it
25
+ * had before this module existed: the strip shows an empty circle you can still
26
+ * press, and the swatch gate reports it by name. Selected, that circle wears the
27
+ * same near-black tick, which is ink for a fill rather than for the surface
28
+ * showing through it — a cost that belongs to the missing paints, not the mark.
29
+ * Painting the circle as some other accent would be worse.
30
+ */
31
+ export function accentSwatchStyle(accent) {
32
+ const paint = PAINTS[accent];
33
+ if (!paint) return { '--swatch': 'transparent' };
34
+ return { '--swatch': `linear-gradient(135deg,${paint.from},${paint.to})` };
35
+ }
@@ -1,4 +1,5 @@
1
- /* What a filter chip shows, what it is called, and when it counts as unset.
1
+ /* What a filter chip shows, what it is called, when it counts as unset, and
2
+ * where the focus goes when the bar runs out of controls.
2
3
  * The chip prints the chosen value by itself — never the field's name beside it
3
4
  * — and the field's name stands in while nothing is chosen. Both faces ask
4
5
  * these, so a chip reads the same in vanilla and in React.
@@ -27,3 +28,78 @@ export function filterChipName(filter = {}) {
27
28
  if (!field) return text(filter.value);
28
29
  return filterChipUnset(filter) ? `${field}: any` : `${field}: ${text(filter.value)}`;
29
30
  }
31
+
32
+ /* compareDocumentPosition's own flags, without reaching for a `Node` global
33
+ that a test environment need not define. */
34
+ const FOLLOWING = 4;
35
+ const PRECEDING = 2;
36
+
37
+ /** A tab stop the focus cannot actually land on. `hidden`, a disabled fieldset and
38
+ * `inert` are read off the document; `visibility` and `display` are read off the
39
+ * cascade, because a `visibility: hidden` control keeps its box and so passes any
40
+ * test made of rectangles. Each of them refuses `focus()` in a browser, and a
41
+ * refusal is silent: the focus lands on the body, with no ring on anything and
42
+ * the next Tab starting over from the top of the page. */
43
+ function unreachable(el) {
44
+ if (el.tabIndex < 0 || el.disabled || el.inert) return true;
45
+ if (el.closest('[hidden],[inert],fieldset:disabled')) return true;
46
+ const view = el.ownerDocument.defaultView;
47
+ const style = view && view.getComputedStyle(el);
48
+ return !!style && (style.visibility === 'hidden' || style.visibility === 'collapse'
49
+ || style.display === 'none');
50
+ }
51
+
52
+ /** The controls a reader's Tab reaches from `host`, in the order the focus should
53
+ * try them: forward in document order, then back from `host`.
54
+ *
55
+ * It reads the document, not the layout. Where boxes are measurable, a control
56
+ * that draws nothing is skipped; where nothing has a box — JSDOM has no layout
57
+ * — every tab stop counts, so the answer stays the document's order rather than
58
+ * becoming empty. */
59
+ function stopsAround(host) {
60
+ const doc = host && host.ownerDocument;
61
+ if (!doc) return [];
62
+ const stops = [...doc.querySelectorAll('a[href],button,input,select,textarea,[tabindex]')]
63
+ .filter(el => !host.contains(el) && !unreachable(el));
64
+ const drawn = stops.filter(el => el.getClientRects().length);
65
+ const order = drawn.length ? drawn : stops;
66
+ return [...order.filter(el => host.compareDocumentPosition(el) & FOLLOWING),
67
+ ...order.filter(el => host.compareDocumentPosition(el) & PRECEDING).reverse()];
68
+ }
69
+
70
+ /** The control a reader's next Tab would reach from `host`: forward in document
71
+ * order, else the nearest one behind it. A filter bar that has just dropped its
72
+ * last control keeps no box the focus ring can sit on, so the focus has to
73
+ * leave the bar rather than land on a line with no height.
74
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
75
+ export function nextFocusStop(host) {
76
+ return stopsAround(host)[0] || null;
77
+ }
78
+
79
+ /** Puts the focus where an emptied bar hands it on, and answers with the element
80
+ * it ended on, or null if nothing took it.
81
+ *
82
+ * A bar that still holds chips draws a box around them, so it takes the focus
83
+ * itself, made focusable for that moment. Emptied, it has only an out-of-flow
84
+ * legend left and measures 0 high, so the focus goes to the first control
85
+ * outside it that will have it — on the Stock screener the caller's own
86
+ * `Add filter`, in the React Finance composition the view strip's chosen tab.
87
+ *
88
+ * Being reachable is not the same as taking the focus: a control can be visible
89
+ * and enabled and still refuse, so each candidate is asked and then checked, and
90
+ * the next one tried.
91
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
92
+ export function focusNextStop(host) {
93
+ const doc = host && host.ownerDocument;
94
+ if (!doc) return null;
95
+ const bar = host.matches('[data-filter-bar]') ? host : host.querySelector('[data-filter-bar]');
96
+ if (bar && bar.querySelector('[data-filter-id]')) {
97
+ bar.tabIndex = -1; bar.focus();
98
+ return doc.activeElement === bar ? bar : null;
99
+ }
100
+ for (const stop of stopsAround(host)) {
101
+ stop.focus();
102
+ if (doc.activeElement === stop) return stop;
103
+ }
104
+ return null;
105
+ }
@@ -23,3 +23,19 @@
23
23
  and off the page. It inherits, reaching an option's label and description.
24
24
  why: docs/specification.md#a-filter-row-holds-its-panels */
25
25
  .ui-filter-bar .ui-dropdown__panel { min-width: 100%; max-width: 100%; overflow-wrap: anywhere; }
26
+ /* The clear action takes the kit's bordered skin minus its fill: words belong on
27
+ the page, not on a grey block, in any state — Artur, round r28. Rest stays at
28
+ (0,1,1) so `.ui-btn:hover`'s accent wash still outranks it; the rules under it
29
+ answer each state that paints a NEUTRAL fill, one step above the kit rule
30
+ writing it. Held by filter-bar.test.js. why: docs/specification.md#a-filter-row-holds-its-panels */
31
+ [data-filter-clear] button { background: transparent; }
32
+ .ui-filter-bar [data-filter-clear] button:disabled,
33
+ .ui-filter-bar [data-filter-clear] button[aria-disabled="true"],
34
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"],
35
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:hover,
36
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:active,
37
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"][data-btn-disabled] { background: transparent; }
38
+ /* No fill, so the label is read on the page: the ink button-disabled.test.js
39
+ measures on every ground. The edge stays the kit's --disabled-border. */
40
+ .ui-filter-bar [data-filter-clear] button:disabled,
41
+ .ui-filter-bar [data-filter-clear] button[aria-disabled="true"] { /* muted-ink: state — the clear action is off and its words sit on the page */ color: var(--disabled-ink-bare); }
@@ -54,7 +54,7 @@
54
54
  .ui-seg--block { display: flex; align-self: stretch; justify-self: stretch; }
55
55
  .ui-seg--block button { flex: 1; }
56
56
 
57
- /* Accent picker — round colour swatches, active gets a ring */
57
+ /* Accent picker — round colour swatches, the selected one ticked */
58
58
  .ui-accent-picker { display: inline-flex; gap: 10px; align-items: center; }
59
59
  .ui-accent-picker button {
60
60
  width: 26px;
@@ -63,19 +63,85 @@
63
63
  border: 0;
64
64
  padding: 0;
65
65
  cursor: pointer;
66
+ position: relative;
66
67
  background: var(--swatch, var(--accent));
67
68
  box-shadow: 0 0 0 2px var(--surface), 0 0 0 2px var(--surface);
68
69
  transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
69
70
  }
70
71
  .ui-accent-picker button:hover { transform: scale(1.08); }
71
- .ui-accent-picker button.is-active {
72
- box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--swatch, var(--accent));
72
+ /* Selected is a tick inside the circle, not a second ring around it: one element
73
+ * carries one accent signal, and on a focusable control that signal is the kit's
74
+ * focus ring. The mark is the one .ui-check draws for a checked box, at the same
75
+ * 2px stroke, in --signal-contrast — the circle wears the accent's dark ramp in
76
+ * both themes, so its ink is the one for a fill, not the --accent-contrast that
77
+ * pairs with --accent-strong. Held by stories/accent-mark.test.js.
78
+ * why: docs/specification.md#react-accent-picker */
79
+ .ui-accent-picker button.is-active::after {
80
+ content: "";
81
+ position: absolute;
82
+ left: 50%;
83
+ /* Lifted 2px: turning the box leaves the drawn corner below its middle. */
84
+ top: calc(50% - 2px);
85
+ width: 6px;
86
+ height: 11px;
87
+ border: solid var(--signal-contrast);
88
+ border-width: 0 2px 2px 0;
89
+ transform: translate(-50%, -50%) rotate(45deg); /* motion: still — the turn is the tick's shape, not travel, and the mark lands with the press that asked for it. Nothing else on the swatch changes with selection, so there is no paint for it to arrive with. */
73
90
  }
74
91
  .ui-accent-picker button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
75
92
 
76
- .ui-seg--underline { flex-wrap: nowrap; align-self: stretch; justify-self: stretch; background: transparent; --ring-gap: inherit; border-radius: 0; border-bottom: 1px solid var(--border); padding: var(--space-1); overflow: auto; max-width: 100%; }
77
- .ui-seg--underline button { border-radius: var(--radius-xs); border-bottom: 2px solid transparent; padding: var(--space-3); white-space: nowrap; }
93
+ /* Underline tabs wrap for the reason the pills above do — #435 — and draw no
94
+ rule of their own: the chosen tab carries the selection. #527, #545
95
+ why: docs/specification.md#the-chosen-tab-in-an-underline-strip */
96
+ .ui-seg--underline { flex-wrap: wrap; overflow: visible; row-gap: var(--space-1); align-self: stretch; justify-self: stretch; background: transparent; --ring-gap: inherit; border-radius: 0; padding: var(--space-1); max-width: 100%; }
97
+ /* `anywhere` is the half `break-word` cannot do: it lowers the tab's min-content
98
+ width, so one unbreakable word narrows rather than widening the page. The
99
+ padding is the scale's own step and the height is the tap floor, so the tab
100
+ draws 44 whatever line box its font hands it: the off-scale `--space-3 + 1px`
101
+ this replaces drew 44 with the webfont loaded and 41 without, which is under
102
+ the floor it claimed. The radius is left for the focus ring; the tab paints no
103
+ box. #527
104
+ why: guidelines/layout-and-density.md, the spacing scale */
105
+ .ui-seg--underline button { position: relative; border-radius: var(--radius-sm); padding: var(--space-3); min-height: var(--tap-min, 44px); white-space: normal; overflow-wrap: anywhere; font-weight: var(--weight-medium); }
106
+ /* The chosen tab is its label and one bar under it: no plate, no hairline, no
107
+ upright rail — those read as a list-row grip. `outline: 0` cancels the accent
108
+ outline the pill rule gives every chosen button, which is #544's second mark.
109
+ #527 r31. why: docs/specification.md#the-chosen-tab-in-an-underline-strip */
78
110
  .ui-seg--underline button.is-active,
79
- .ui-seg--underline button[aria-pressed="true"] { background: transparent; border-bottom-color: var(--accent); }
111
+ .ui-seg--underline button[aria-pressed="true"] {
112
+ background: none;
113
+ color: var(--strong);
114
+ font-weight: var(--weight-semibold);
115
+ outline: 0;
116
+ }
117
+ /* Every tab reserves the bar and hides it, so the mark grows in place when the
118
+ choice moves rather than appearing — the structure and the timing
119
+ .ui-nav--tabs.is-underline already uses for the same 2px bar. ::before, not
120
+ ::after: below the phone step tap-zone.css sizes ::after to the 44px floor,
121
+ and a bar drawn there collapses that layer to 2px. Inset from the tab's
122
+ bottom edge, which is the line two wrapped rows share. */
123
+ .ui-seg--underline button::before {
124
+ content: "";
125
+ position: absolute;
126
+ left: var(--space-3); right: var(--space-3); bottom: 3px;
127
+ height: 2px;
128
+ border-radius: 2px;
129
+ background: var(--accent);
130
+ opacity: 0;
131
+ transform: scaleX(0.4);
132
+ transition: opacity var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
133
+ }
134
+ .ui-seg--underline button.is-active::before,
135
+ .ui-seg--underline button[aria-pressed="true"]::before { opacity: 1; transform: scaleX(1); }
136
+ /* `outline: 0` above outranks the kit's :focus-visible rule — (0,3,0) against
137
+ (0,2,1) — so the chosen tab would focus with no indicator. Restated here. */
138
+ .ui-seg--underline button.is-active:focus-visible,
139
+ .ui-seg--underline button[aria-pressed="true"]:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
140
+ /* The mode repaints an author colour, so the bar comes back as the labels' own
141
+ ink. `Highlight` is kept where it is named: one mark the mode cannot flatten. */
142
+ @media (forced-colors: active) {
143
+ .ui-seg--underline button.is-active::before,
144
+ .ui-seg--underline button[aria-pressed="true"]::before { background: Highlight; }
145
+ }
80
146
  .ui-seg button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
81
147
  .ui-seg button:disabled { /* muted-ink: state — unavailable choice */ color: var(--disabled-ink); cursor: not-allowed; }
@@ -110,6 +110,17 @@
110
110
  --tap-clear-y: var(--tap-gap);
111
111
  }
112
112
 
113
+ /* The underline appearance keeps the gap it draws, at every width and under
114
+ both pointers, so a wrapped strip reads as one block. It can because the tab
115
+ draws the floor itself — segmented.css gives it `min-height: var(--tap-min)`
116
+ — leaving its layer inside the drawn box. The clearance is still declared,
117
+ and declared SMALL on purpose: the pill rule above hands every strip
118
+ --tap-gap, and this one has 4px of row to give, not 20. So a layer here can
119
+ never reach past the gap it was promised, whatever a tab draws. A pill draws
120
+ 31 tall and does want the full 20. #527, measured in
121
+ stories/segmented-wrap.test.js. */
122
+ .ui-seg--underline { row-gap: var(--space-1, 4px) !important; --tap-clear-y: var(--space-1, 4px); }
123
+
113
124
  /* Tabs are wide enough across; down, the panel under them has the room. */
114
125
  .ui-tabs__list { --tap-clear-x: 2px; --tap-clear-y: var(--tap-gap); }
115
126