@apliteni/apliteni-ui 0.114.0 → 0.116.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.
@@ -68,3 +68,5 @@
68
68
  **Don't:** Put text on grey fields, chips, headers or footers.
69
69
 
70
70
  **Except:** The sidebar rail's current row. Its mark may be neither an accent nor an edge — an outline there means focus — and in the page shell the rail is the card surface already. It takes the quiet fill a rung off its rail, label and all. Artur, r34 of [#475](https://github.com/apliteni/apliteni-ui/issues/475).
71
+
72
+ **Except:** A status chip carries a soft fill. Where its tone reports no verdict it takes a light tint of muted ink that leaves the panel in one direction per theme, so the neutral tones read as the same family as the coloured ones.
@@ -40,6 +40,18 @@
40
40
 
41
41
  **Don't:** Display “TOTAL REVENUE” above the figure.
42
42
 
43
+ ## Say the noun once
44
+
45
+ <!-- rule: no-repeated-noun -->
46
+
47
+ **Rule:** Leave a noun out of a label its frame already names.
48
+
49
+ **Why:** Clutter hides the available action.
50
+
51
+ **Do:** The chips name the filters; the button says what it does.
52
+
53
+ **Don't:** The noun arrives twice.
54
+
43
55
  ## Rank card titles
44
56
 
45
57
  <!-- rule: title-rank -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.114.0",
3
+ "version": "0.116.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -571,17 +571,26 @@ type Filter = {
571
571
  disabled?: boolean;
572
572
  open?: boolean;
573
573
  };
574
+ /** A filter the bar can put on itself: the chip's name, and the values its menu offers. */
575
+ type AddFilter = {
576
+ id: string;
577
+ label: string;
578
+ items: DropdownEntry[];
579
+ };
574
580
  type FilterBarProps = {
575
581
  filters: Filter[];
576
582
  label?: string;
577
583
  clearLabel?: string;
578
584
  disabled?: boolean;
579
585
  busy?: boolean;
586
+ add?: AddFilter[];
587
+ addLabel?: string;
580
588
  onRemove: (id: string) => void;
581
589
  onClear: () => void;
582
590
  onChange: (id: string, value: string | undefined) => void;
591
+ onAdd?: (id: string, value: string | undefined) => void;
583
592
  };
584
- declare function FilterBar({ filters, label, clearLabel, disabled, busy, onRemove, onClear, onChange }: FilterBarProps): react.JSX.Element;
593
+ declare function FilterBar({ filters, label, clearLabel, add, addLabel, disabled, busy, onRemove, onClear, onChange, onAdd }: FilterBarProps): react.JSX.Element;
585
594
 
586
595
  type KeyValueRow = {
587
596
  label: string;
@@ -1046,4 +1055,4 @@ declare const AccentPicker: react.ForwardRefExoticComponent<Omit<HTMLAttributes<
1046
1055
  declare const PAGE_SIZES: readonly number[];
1047
1056
  declare const DEFAULT_PAGE_SIZE: number;
1048
1057
 
1049
- 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, Chart, type ChartBridgeStep, type ChartBridgeTones, type ChartPeriod, type ChartProps, type ChartSeries, type ChartTone, 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 };
1058
+ 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, 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 };
@@ -1908,7 +1908,8 @@ import {
1908
1908
  filterPanelFit,
1909
1909
  transitionMs as transitionMs2,
1910
1910
  dropdownViewportFit,
1911
- dropdownHeightFit
1911
+ dropdownHeightFit,
1912
+ filterPanelRow
1912
1913
  } from "@apliteni/apliteni-ui";
1913
1914
  import { Fragment as Fragment5, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
1914
1915
  var EXIT_SLACK_MS2 = 50;
@@ -2011,7 +2012,7 @@ function Dropdown({
2011
2012
  };
2012
2013
  fit();
2013
2014
  const view = root.current?.ownerDocument?.defaultView;
2014
- const row2 = root.current?.closest(".ui-filter-bar__chip")?.closest(".ui-filter-bar");
2015
+ const row2 = filterPanelRow(root.current);
2015
2016
  if (!row2 || !view) return;
2016
2017
  const stop = [];
2017
2018
  let ro = null;
@@ -2046,7 +2047,7 @@ function Dropdown({
2046
2047
  opened.current = true;
2047
2048
  return;
2048
2049
  }
2049
- if (!opened.current || !el || !root.current?.closest(".ui-filter-bar__chip")) return;
2050
+ if (!opened.current || !el || !filterPanelRow(root.current)) return;
2050
2051
  el.classList.add("is-closing");
2051
2052
  let done = false;
2052
2053
  const finish = () => {
@@ -2144,7 +2145,7 @@ function Dropdown({
2144
2145
  const want = landOn.current;
2145
2146
  landOn.current = null;
2146
2147
  if (!open) {
2147
- if (panel.current?.style && root.current?.closest(".ui-filter-bar") && !root.current.closest(".ui-filter-bar__chip")) {
2148
+ if (panel.current?.style && root.current?.closest(".ui-filter-bar") && !filterPanelRow(root.current)) {
2148
2149
  panel.current.style.minWidth = "";
2149
2150
  }
2150
2151
  return;
@@ -2568,18 +2569,43 @@ function Segmented({ options, value, onChange, label, appearance = "pill", size,
2568
2569
  // src/FilterBar.tsx
2569
2570
  import { useRef as useRef11, useState as useState8 } from "react";
2570
2571
  import { filterChipText, filterChipName, filterChipUnset, filterChipItems, focusNextStop } from "@apliteni/apliteni-ui";
2571
- import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
2572
- function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters", disabled = false, busy = false, onRemove, onClear, onChange }) {
2572
+ import { Fragment as Fragment6, jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
2573
+ var SEARCH_AT = 10;
2574
+ var isRow2 = (entry) => entry !== "---" && !entry.separator;
2575
+ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all", add, addLabel = "Filter", disabled = false, busy = false, onRemove, onClear, onChange, onAdd }) {
2573
2576
  const bar = useRef11(null);
2574
2577
  const focused = useRef11(null);
2578
+ const landing = useRef11(null);
2575
2579
  const [opened, setOpened] = useState8({});
2580
+ const [adding, setAdding] = useState8(false);
2576
2581
  const blocked = disabled || busy;
2582
+ const offer = (add || []).filter((entry) => !filters.some((filter) => filter.id === entry.id));
2583
+ const source = /* @__PURE__ */ new Map();
2584
+ const sections = offer.map((entry) => ({ label: entry.label, items: entry.items.map((item) => {
2585
+ if (!isRow2(item)) return item;
2586
+ const copy = { ...item };
2587
+ source.set(copy, entry.id);
2588
+ return copy;
2589
+ }) }));
2590
+ const options = sections.reduce((count, section) => count + section.items.filter(isRow2).length, 0);
2591
+ const offering = Boolean(onAdd && offer.length);
2577
2592
  useIsoLayoutEffect(() => {
2578
2593
  const stale = Object.keys(opened).filter((id) => !filters.some((filter) => filter.id === id && filter.open === opened[id].against));
2579
2594
  if (stale.length) setOpened((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !stale.includes(id))));
2595
+ const chips = Array.from(bar.current?.querySelectorAll("[data-filter-id]") ?? []);
2596
+ const asked = landing.current;
2597
+ landing.current = null;
2598
+ if (asked !== null) {
2599
+ const chip = chips.find((c) => c.dataset.filterId === asked && !c.disabled);
2600
+ const trigger = !blocked && chip?.querySelector("[data-dropdown-trigger]");
2601
+ if (trigger) {
2602
+ focused.current = null;
2603
+ trigger.focus();
2604
+ return;
2605
+ }
2606
+ }
2580
2607
  const prior = focused.current;
2581
2608
  if (!prior || prior.id === null && filters.length > 0 || filters.some((f) => f.id === prior.id)) return;
2582
- const chips = Array.from(bar.current?.querySelectorAll("[data-filter-id]") ?? []);
2583
2609
  const next = chips.slice(prior.index).find((chip) => !chip.disabled) || chips.slice(0, prior.index).reverse().find((chip) => !chip.disabled);
2584
2610
  const target = !blocked && next?.querySelector("[data-dropdown-trigger]");
2585
2611
  focused.current = null;
@@ -2642,9 +2668,32 @@ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters
2642
2668
  },
2643
2669
  filter.id
2644
2670
  )),
2671
+ offering && /* @__PURE__ */ jsx24("div", { "data-filter-add": "", onFocus: () => {
2672
+ focused.current = null;
2673
+ }, children: /* @__PURE__ */ jsx24(
2674
+ Dropdown,
2675
+ {
2676
+ variant: "menu",
2677
+ sections,
2678
+ ariaLabel: addLabel,
2679
+ triggerContent: /* @__PURE__ */ jsxs19(Fragment6, { children: [
2680
+ /* @__PURE__ */ jsx24(Icon, { name: "plus" }),
2681
+ addLabel
2682
+ ] }),
2683
+ search: options >= SEARCH_AT && { label: `Search ${label.toLowerCase()}` },
2684
+ open: adding && !blocked,
2685
+ onOpenChange: setAdding,
2686
+ onSelect: (value, item) => {
2687
+ const id = source.get(item);
2688
+ if (blocked || !id) return;
2689
+ landing.current = id;
2690
+ onAdd?.(id, value == null ? void 0 : String(value));
2691
+ }
2692
+ }
2693
+ ) }),
2645
2694
  filters.length > 0 && /* @__PURE__ */ jsx24("span", { "data-filter-clear": "", onFocus: () => {
2646
2695
  focused.current = { id: null, index: 0 };
2647
- }, children: /* @__PURE__ */ jsx24(Button, { size: "sm", onClick: () => {
2696
+ }, children: /* @__PURE__ */ jsx24(Button, { size: "sm", variant: "ghost", onClick: () => {
2648
2697
  if (!blocked) onClear();
2649
2698
  }, children: clearLabel }) })
2650
2699
  ]
@@ -2678,7 +2727,7 @@ function DrawerSection({ title, headingLevel = 3, className, children, ...rest }
2678
2727
 
2679
2728
  // src/Snippet.tsx
2680
2729
  import { useEffect as useEffect7, useRef as useRef12, useState as useState9 } from "react";
2681
- import { Fragment as Fragment6, jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
2730
+ import { Fragment as Fragment7, jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
2682
2731
  function Snippet({ label = "shell", code = "", children, copy = true, reveal = false, copyLabel = "Copy code" }) {
2683
2732
  const [status, setStatus] = useState9("idle");
2684
2733
  const request = useRef12(0);
@@ -2706,7 +2755,7 @@ function Snippet({ label = "shell", code = "", children, copy = true, reveal = f
2706
2755
  return /* @__PURE__ */ jsxs21("div", { className: reveal ? "ui-snippet ui-snippet--reveal" : "ui-snippet", children: [
2707
2756
  /* @__PURE__ */ jsxs21("div", { className: "ui-snippet__bar", children: [
2708
2757
  /* @__PURE__ */ jsx26("span", { children: label }),
2709
- copy && /* @__PURE__ */ jsxs21(Fragment6, { children: [
2758
+ copy && /* @__PURE__ */ jsxs21(Fragment7, { children: [
2710
2759
  /* @__PURE__ */ jsx26(
2711
2760
  "button",
2712
2761
  {
@@ -2728,7 +2777,7 @@ function Snippet({ label = "shell", code = "", children, copy = true, reveal = f
2728
2777
  // src/Tabs.tsx
2729
2778
  import { useEffect as useEffect8, useId as useId8, useRef as useRef13 } from "react";
2730
2779
  import { playEntrance } from "@apliteni/apliteni-ui/motion";
2731
- import { Fragment as Fragment7, jsx as jsx27, jsxs as jsxs22 } from "react/jsx-runtime";
2780
+ import { Fragment as Fragment8, jsx as jsx27, jsxs as jsxs22 } from "react/jsx-runtime";
2732
2781
  function Tabs({ items, value, onChange, label }) {
2733
2782
  const id = useId8();
2734
2783
  const previous = useRef13(value);
@@ -2777,7 +2826,7 @@ function Tabs({ items, value, onChange, label }) {
2777
2826
  },
2778
2827
  children: [
2779
2828
  item.label,
2780
- item.count != null && /* @__PURE__ */ jsxs22(Fragment7, { children: [
2829
+ item.count != null && /* @__PURE__ */ jsxs22(Fragment8, { children: [
2781
2830
  " ",
2782
2831
  item.count
2783
2832
  ] })
@@ -3004,7 +3053,7 @@ function FileField({ label, hint, error, required, disabled, onFileChange, class
3004
3053
 
3005
3054
  // src/FileDrop.tsx
3006
3055
  import { useRef as useRef16, useState as useState11 } from "react";
3007
- import { Fragment as Fragment8, jsx as jsx30, jsxs as jsxs25 } from "react/jsx-runtime";
3056
+ import { Fragment as Fragment9, jsx as jsx30, jsxs as jsxs25 } from "react/jsx-runtime";
3008
3057
  var MARKS = {
3009
3058
  uploading: "clock",
3010
3059
  done: "circleCheck",
@@ -3116,7 +3165,7 @@ function FileDrop({
3116
3165
  children: /* @__PURE__ */ jsx30("span", { style: { width: `${file.progress}%` } })
3117
3166
  }
3118
3167
  )
3119
- ] }) : /* @__PURE__ */ jsxs25(Fragment8, { children: [
3168
+ ] }) : /* @__PURE__ */ jsxs25(Fragment9, { children: [
3120
3169
  /* @__PURE__ */ jsx30(
3121
3170
  Button,
3122
3171
  {
@@ -3313,7 +3362,7 @@ import { applyTheme, paletteHotkey, prism, railCollapsed, themeIcon, themeName }
3313
3362
  // src/SidebarNav.tsx
3314
3363
  import { forwardRef as forwardRef8, useEffect as useEffect12, useId as useId10, useState as useState13 } from "react";
3315
3364
  import { ENTRANCE_FALLBACK_MS } from "@apliteni/apliteni-ui/motion";
3316
- import { Fragment as Fragment9, jsx as jsx33, jsxs as jsxs28 } from "react/jsx-runtime";
3365
+ import { Fragment as Fragment10, jsx as jsx33, jsxs as jsxs28 } from "react/jsx-runtime";
3317
3366
  var badgeText = (badge) => typeof badge === "object" ? badge.text : badge;
3318
3367
  function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
3319
3368
  const uid = useId10();
@@ -3339,7 +3388,7 @@ function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
3339
3388
  item.disabled && "is-disabled",
3340
3389
  item.danger && "is-danger"
3341
3390
  ].filter(Boolean).join(" ");
3342
- const children = /* @__PURE__ */ jsxs28(Fragment9, { children: [
3391
+ const children = /* @__PURE__ */ jsxs28(Fragment10, { children: [
3343
3392
  (item.leading ?? item.icon) && /* @__PURE__ */ jsx33("span", { className: "ui-nav__ic", children: item.leading ?? /* @__PURE__ */ jsx33(Icon, { name: item.icon }) }),
3344
3393
  /* @__PURE__ */ jsx33("span", { className: "ui-nav__label", children: item.label }),
3345
3394
  count != null && count !== "" && /* @__PURE__ */ jsx33("span", { className: `ui-nav__badge is-${typeof item.badge === "object" ? item.badge.tone ?? "neutral" : "neutral"}`, children: count }),
@@ -3408,7 +3457,7 @@ var SidebarNav = forwardRef8(function SidebarNav2({
3408
3457
  });
3409
3458
 
3410
3459
  // src/AppShell.tsx
3411
- import { Fragment as Fragment10, jsx as jsx34, jsxs as jsxs29 } from "react/jsx-runtime";
3460
+ import { Fragment as Fragment11, jsx as jsx34, jsxs as jsxs29 } from "react/jsx-runtime";
3412
3461
  var path = (value) => value.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
3413
3462
  function AppShell({
3414
3463
  sections,
@@ -3483,7 +3532,7 @@ function AppShell({
3483
3532
  onClick: (event) => {
3484
3533
  if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMore(false);
3485
3534
  },
3486
- children: /* @__PURE__ */ jsxs29(Fragment10, { children: [
3535
+ children: /* @__PURE__ */ jsxs29(Fragment11, { children: [
3487
3536
  /* @__PURE__ */ jsx34("span", { className: "ui-nav__ic", children: /* @__PURE__ */ jsx34(Icon, { name: section.icon }) }),
3488
3537
  /* @__PURE__ */ jsx34("span", { className: "ui-nav__label", children: section.label }),
3489
3538
  section.count != null && /* @__PURE__ */ jsx34("span", { className: "ui-nav__badge is-neutral", children: section.count })
@@ -3817,7 +3866,7 @@ import {
3817
3866
  useState as useState16
3818
3867
  } from "react";
3819
3868
  import { CHART_FLOOR, bridgeWalk, chartScale, pointTarget } from "@apliteni/apliteni-ui";
3820
- import { Fragment as Fragment11, jsx as jsx38, jsxs as jsxs33 } from "react/jsx-runtime";
3869
+ import { Fragment as Fragment12, jsx as jsx38, jsxs as jsxs33 } from "react/jsx-runtime";
3821
3870
  var HEIGHT = { months: 216, bridge: 216, spark: 32 };
3822
3871
  var PAD = 2;
3823
3872
  var ZERO_INSET = 1.5;
@@ -4447,7 +4496,7 @@ function Chart(props) {
4447
4496
  `${column.label}-${i}`
4448
4497
  )) })
4449
4498
  ] }) }),
4450
- /* @__PURE__ */ jsx38("span", { ref: tipEl, className: cx8("ui-tip", openId && "is-open"), role: open ? "tooltip" : void 0, children: open && /* @__PURE__ */ jsxs33(Fragment11, { children: [
4499
+ /* @__PURE__ */ jsx38("span", { ref: tipEl, className: cx8("ui-tip", openId && "is-open"), role: open ? "tooltip" : void 0, children: open && /* @__PURE__ */ jsxs33(Fragment12, { children: [
4451
4500
  /* @__PURE__ */ jsx38("span", { className: "ui-tip__label", children: spark ? columns[open.column]?.label : `${open.name}, ${columns[open.column]?.label}` }),
4452
4501
  /* @__PURE__ */ jsx38("span", { className: "ui-tip__value", children: format(open.value) }),
4453
4502
  (stateOf(columns[open.column]) || open.detail) && /* @__PURE__ */ jsx38("span", { className: "ui-tip__detail", children: stateOf(columns[open.column]) || open.detail })
@@ -4470,7 +4519,7 @@ function Chart(props) {
4470
4519
  /* @__PURE__ */ jsx38("tbody", { children: tableRows.map((row, i) => /* @__PURE__ */ jsxs33("tr", { "aria-current": pick === i ? true : void 0, children: [
4471
4520
  /* @__PURE__ */ jsxs33("th", { scope: "row", children: [
4472
4521
  row.column.label,
4473
- row.column.estimated && /* @__PURE__ */ jsxs33(Fragment11, { children: [
4522
+ row.column.estimated && /* @__PURE__ */ jsxs33(Fragment12, { children: [
4474
4523
  " ",
4475
4524
  /* @__PURE__ */ jsx38("span", { className: "ui-chart__state", children: stateOf(row.column) })
4476
4525
  ] })
@@ -384,6 +384,45 @@ function ddResetSearch(dd, panel, search) {
384
384
  * fails when one drifts. why: src/styles/dropdown.css */
385
385
  export const DD_MENU_FLOOR = 240;
386
386
 
387
+ /** The floor a panel asks for in the sheet, or null. A separate name from the
388
+ * floor this fit writes back: one that read its own output would pin a menu at
389
+ * whatever a narrow row once allowed. why: src/styles/filter-bar.css */
390
+ function ddPanelAsk(panel) {
391
+ const view = panel?.ownerDocument?.defaultView;
392
+ if (!panel || typeof view?.getComputedStyle !== 'function') return null;
393
+ const asked = Number.parseFloat(view.getComputedStyle(panel).getPropertyValue('--ui-filter-panel-ask'));
394
+ return Number.isFinite(asked) && asked > 0 ? asked : null;
395
+ }
396
+
397
+ /** The `.ui-dropdown__panel` inside a dropdown, by class rather than by data
398
+ * attribute: the fit's subjects are the panels the stylesheet's rules match, and
399
+ * filter-panel-fit.test.js hands this function hand-made elements. */
400
+ const ddFitPanelOf = (dd) => (typeof dd?.querySelector === 'function'
401
+ ? dd.querySelector('.ui-dropdown__panel') : null);
402
+
403
+ /**
404
+ * The filter row an anchored menu is measured inside, or null outside one.
405
+ *
406
+ * Two panels in a row are anchored at a trigger of their own: a chip's values, and
407
+ * the add control's catalogue, which says so by asking for a width. The slide
408
+ * filterPanelFit() computes is measured from that trigger's offset along the row,
409
+ * which is arithmetic for either. A panel anchored to the row itself is neither,
410
+ * and sizes itself.
411
+ *
412
+ * Published because `<Dropdown>` asks the same question — the same row to observe,
413
+ * the same panels to hold through a close — and a second copy of this rule would
414
+ * drift from the stylesheet. why: src/styles/filter-bar.css
415
+ *
416
+ * @param {Element} dd a `.ui-dropdown` that may be inside a filter row
417
+ * @returns {Element|null} the `.ui-filter-bar` this menu's fit is measured in
418
+ */
419
+ export function filterPanelRow(dd) {
420
+ if (typeof dd?.closest !== 'function') return null;
421
+ const chip = dd.closest('.ui-filter-bar__chip');
422
+ if (chip) return chip.closest('.ui-filter-bar');
423
+ return ddPanelAsk(ddFitPanelOf(dd)) ? dd.closest('.ui-filter-bar') : null;
424
+ }
425
+
387
426
  /**
388
427
  * Where a filter chip's menu may sit. #484 bounds the panel to its trigger, so a
389
428
  * chip printing its value alone (#536) left a 48px menu breaking words
@@ -396,23 +435,23 @@ export const DD_MENU_FLOOR = 240;
396
435
  * other way. Measuring an end-anchored panel forwards put one off the page.
397
436
  * why: docs/components.md#a-filter-row-holds-its-panels
398
437
  *
438
+ * A menu that is not a chip's takes this arithmetic by asking, on its own panel,
439
+ * for a width: `--ui-filter-panel-ask`. why: src/styles/filter-bar.css
440
+ *
399
441
  * @param {Element} dd a `.ui-dropdown` that may be inside a filter row
400
- * @param {number} [floor] the width to reach for
442
+ * @param {number} [floor] the width to reach for; the panel's own ask, else the kit's
401
443
  * @returns {{room: number, shift: number, floor: number, end: boolean}|null}
402
444
  */
403
- export function filterPanelFit(dd, floor = DD_MENU_FLOOR) {
404
- /* A chip's menu, not any menu in a filter row: the slide below is measured from
405
- * the trigger's offset along the row, so a panel anchored to the row itself is
406
- * not this function's subject and must size itself. why: src/styles/filter-bar.css */
407
- const chip = typeof dd?.closest === 'function' ? dd.closest('.ui-filter-bar__chip') : null;
408
- const bar = chip ? chip.closest('.ui-filter-bar') : null;
445
+ export function filterPanelFit(dd, floor) {
446
+ const panel = ddFitPanelOf(dd);
447
+ // Which menus are this function's subjects is filterPanelRow()'s question.
448
+ const bar = filterPanelRow(dd);
409
449
  if (!bar || typeof bar.getBoundingClientRect !== 'function') return null;
410
450
  const row = bar.getBoundingClientRect();
411
451
  const box = dd.getBoundingClientRect();
412
- const panel = typeof dd.querySelector === 'function' ? dd.querySelector('.ui-dropdown__panel') : null;
413
452
  const end = !!panel?.classList?.contains('is-end');
414
453
  // A row narrower than the floor decides the width; nothing may leave the row.
415
- const want = Math.min(floor, row.width);
454
+ const want = Math.min(floor ?? ddPanelAsk(panel) ?? DD_MENU_FLOOR, row.width);
416
455
  const ahead = end
417
456
  ? Math.max(0, box.right - row.left) // an end-anchored panel grows backwards
418
457
  : Math.max(0, row.right - box.left);
@@ -466,11 +505,6 @@ function ddRefitFilterPanel(dd, panel) {
466
505
  if (panel.offsetWidth) panel.style.minWidth = `${panel.offsetWidth}px`;
467
506
  }
468
507
 
469
- /** The row this chip's menu is fitted to, or null outside one. */
470
- function ddRowOf(dd) {
471
- return dd?.closest?.('.ui-filter-bar__chip')?.closest('.ui-filter-bar') || null;
472
- }
473
-
474
508
  /** Is this node inside some dropdown panel? Both fits write to a panel and to
475
509
  * nothing else, so a change inside one is never news — and a panel is
476
510
  * `position: absolute`, so nothing in it can move a chip along its row. Any
@@ -501,7 +535,7 @@ function ddOurs(node) {
501
535
  */
502
536
  function ddWatchRow(dd, panel) {
503
537
  ddUnwatchRow(dd);
504
- const row = ddRowOf(dd);
538
+ const row = filterPanelRow(dd);
505
539
  const view = dd.ownerDocument?.defaultView;
506
540
  if (!row) return;
507
541
  const refit = () => { if (dd.classList.contains('open')) ddRefitFilterPanel(dd, panel); };
@@ -619,7 +653,7 @@ const DD_EXIT_SLACK_MS = 50;
619
653
  const DD_SETTLE_MS = 450;
620
654
 
621
655
  /**
622
- * Hold a closing chip menu's open geometry until its fade has finished.
656
+ * Hold a closing anchored menu's open geometry until its fade has finished.
623
657
  *
624
658
  * `.open` stops matching in the frame the menu closes; the fade does not — the
625
659
  * panel transitions opacity over --dur-med. Dropping the width there left a 240px
@@ -823,10 +857,10 @@ function closeDropdown(dd) {
823
857
  /* A search panel carries the width it was opened at as an inline `min-width`,
824
858
  * and inline beats any sheet. Inside a filter row that leaves a SHUT panel wider
825
859
  * than its trigger, which is #467. Cleared here, and ddResetSearch() writes it
826
- * again on the next open — except for a chip's menu, which gives the pin and the
827
- * fit back at the END of its fade, both being its open geometry. A portalled
860
+ * again on the next open — except for an anchored menu, which gives the pin and
861
+ * the fit back at the END of its fade, both being its open geometry. A portalled
828
862
  * panel is never fitted, so it has none to hold. why: ddHoldClose() */
829
- if (panel?.style && !dd.__ddPanel && ddRowOf(dd)) ddHoldClose(dd, panel);
863
+ if (panel?.style && !dd.__ddPanel && filterPanelRow(dd)) ddHoldClose(dd, panel);
830
864
  else if (panel?.style && dd.closest?.('.ui-filter-bar')) panel.style.minWidth = '';
831
865
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
832
866
  /* The anchor may have moved while the panel was open, and a shut panel is laid
@@ -2,7 +2,7 @@ import { button, esc } from './index.js';
2
2
  import { dropdown, wireDropdown } from './dropdown.js';
3
3
  import { filterChipText, filterChipName, filterChipUnset, filterChipItems, focusNextStop } from '../logic/filter-bar.js';
4
4
 
5
- export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear all filters', disabled = false, busy = false } = {}) {
5
+ export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear all', disabled = false, busy = false } = {}) {
6
6
  return `<fieldset class="ui-filter-bar" data-filter-bar${disabled || busy ? ' disabled' : ''}${busy ? ' aria-busy="true"' : ''}>`
7
7
  + `<legend class="ui-filter-bar__legend">${esc(label)}</legend>`
8
8
  + filters.map(filter => `<fieldset class="ui-filter-bar__chip" data-filter-id="${esc(filter.id)}"${filter.disabled ? ' disabled' : ''}>`
@@ -21,7 +21,7 @@ export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear
21
21
  // live action beside two chips has to read as live.
22
22
  // why: docs/components.md#a-filter-row-holds-its-panels
23
23
  + (filters.length
24
- ? `<span data-filter-clear>${button({ label: clearLabel, size: 'sm' })}</span>`
24
+ ? `<span data-filter-clear>${button({ label: clearLabel, size: 'sm', variant: 'ghost' })}</span>`
25
25
  : '')
26
26
  + '</fieldset>';
27
27
  }
@@ -58,7 +58,10 @@ export function initFilterBar(host, options = {}) {
58
58
  const chip = e.target.closest('[data-filter-id]');
59
59
  if (e.target.closest('[data-filter-remove]')) emit('ui-filter-remove', { id: chip.dataset.filterId });
60
60
  else if (e.target.closest('[data-filter-clear] button')) emit('ui-filter-clear', {});
61
- else if (e.target.closest('[data-dd-item]')) {
61
+ // A chip's menu only. The catalogue a vanilla page draws under `data-filter-add` is the
62
+ // page's own, and a pick in it names no chip: reported as a change, it had a consumer
63
+ // mapping over its filters and writing the catalogue's value into every one of them. #518
64
+ else if (e.target.closest('[data-dd-item]') && !e.target.closest('[data-filter-add]')) {
62
65
  const item = e.target.closest('[data-dd-item]');
63
66
  if (item.getAttribute('aria-disabled') !== 'true') {
64
67
  const id = chip?.dataset.filterId, value = item.dataset.value;
package/src/index.d.ts CHANGED
@@ -43,12 +43,18 @@ export declare function dropdownAvail(opts: {
43
43
  }): number;
44
44
  /** The kit's menu floor, the width `.ui-dropdown__panel` writes. */
45
45
  export declare const DD_MENU_FLOOR: number;
46
- /** Where a filter chip's open menu can sit: the room from the edge it is
47
- * anchored at to the far side of its row once slid, how far it had to slide,
48
- * the width it reached for, and whether it is anchored at its inline end.
49
- * `null` unless the dropdown is inside a `.ui-filter-bar__chip`: the slide is a
50
- * chip's offset along its row, so a panel anchored to the row itself is not this
51
- * function's subject and sizes itself. why: docs/components.md */
46
+ /** The `.ui-filter-bar` an anchored menu's fit is measured inside, or `null`.
47
+ * A chip's menu, and a menu that asks for a width with `--ui-filter-panel-ask` on
48
+ * its own panel: the slide is that trigger's offset along the row, so those two
49
+ * are the subjects and a panel anchored to the row itself sizes itself.
50
+ * why: docs/components.md */
51
+ export declare function filterPanelRow(dd: Element | null | undefined): Element | null;
52
+ /** Where an anchored filter menu can sit: the room from the edge it is anchored
53
+ * at to the far side of its row once slid, how far it had to slide, the width it
54
+ * reached for, and whether it is anchored at its inline end. `null` for a
55
+ * dropdown `filterPanelRow()` does not answer for. The width reached for is
56
+ * `floor`, else the panel's own `--ui-filter-panel-ask`, else `DD_MENU_FLOOR`.
57
+ * why: docs/components.md */
52
58
  export declare function filterPanelFit(
53
59
  dd: Element | null | undefined,
54
60
  floor?: number,
@@ -1 +1,9 @@
1
- export const calloutIcons = { info: 'info', success: 'check', warn: 'alert', danger: 'alert', neutral: 'info' };
1
+ // Default glyph per callout tone. A callout REPORTS a state, so its status
2
+ // glyphs are the circled ones — the same family toast() uses (TOAST_ICON in
3
+ // src/components/index.js) and the same split iconMeanings writes down. The
4
+ // bare `check` and `alert` this used to ship read as actions, which is why
5
+ // every showcase overrode them per instance. #453
6
+ //
7
+ // Neutral keeps `info` rather than the toast's `bolt`: a neutral callout is a
8
+ // standing note on the page, not something that just happened.
9
+ export const calloutIcons = { info: 'info', success: 'circleCheck', warn: 'circleAlert', danger: 'circleX', neutral: 'info' };
@@ -67,37 +67,55 @@ function stopsAround(host) {
67
67
  ...order.filter(el => host.compareDocumentPosition(el) & PRECEDING).reverse()];
68
68
  }
69
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.
70
+ /** The bar `host` is, or the one inside it. */
71
+ function barIn(host) {
72
+ if (!host || typeof host.matches !== 'function') return null;
73
+ return host.matches('[data-filter-bar]') ? host : host.querySelector('[data-filter-bar]');
74
+ }
75
+
76
+ /** Where an emptied bar hands the focus, in the order it tries: the way to add one
77
+ * still drawn on the row, which outlives the chips and so is nearer than anything
78
+ * outside, then the controls around the bar. The answer a consumer reads and the
79
+ * move the bar makes both come from here, so the two cannot disagree. A control
80
+ * the reader cannot reach is left out of the list. #518 */
81
+ function focusStops(host) {
82
+ const bar = barIn(host);
83
+ const add = bar && !bar.querySelector('[data-filter-id]')
84
+ && bar.querySelector('[data-filter-add] [data-dropdown-trigger]');
85
+ const around = stopsAround(host);
86
+ return add && !unreachable(add) ? [add, ...around] : around;
87
+ }
88
+
89
+ /** Where an emptied bar sends the focus, without moving it: the way to add one
90
+ * still on its row, else the control a reader's next Tab would reach — forward in
91
+ * document order, else the nearest one behind. A bar that has just dropped its
92
+ * last chip keeps no box the focus ring can sit on, so with no control of its own
93
+ * left the focus has to leave rather than land on a line with no height.
94
+ * `focusNextStop()` acts on this same list.
74
95
  * why: docs/components.md#a-filter-row-holds-its-panels */
75
96
  export function nextFocusStop(host) {
76
- return stopsAround(host)[0] || null;
97
+ return focusStops(host)[0] || null;
77
98
  }
78
99
 
79
100
  /** Puts the focus where an emptied bar hands it on, and answers with the element
80
101
  * it ended on, or null if nothing took it.
81
102
  *
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.
103
+ * A bar that still holds chips draws a box around them and takes the focus
104
+ * itself. Emptied of them it has only an out-of-flow legend and measures 0 high,
105
+ * so the focus goes where `nextFocusStop()` names — the way to add one still on
106
+ * the row, which is what the Stock screener draws, else outside the bar.
107
+ * Being reachable is not taking the focus: each candidate is asked and then
108
+ * checked. #518
91
109
  * why: docs/components.md#a-filter-row-holds-its-panels */
92
110
  export function focusNextStop(host) {
93
111
  const doc = host && host.ownerDocument;
94
112
  if (!doc) return null;
95
- const bar = host.matches('[data-filter-bar]') ? host : host.querySelector('[data-filter-bar]');
113
+ const bar = barIn(host);
96
114
  if (bar && bar.querySelector('[data-filter-id]')) {
97
115
  bar.tabIndex = -1; bar.focus();
98
116
  return doc.activeElement === bar ? bar : null;
99
117
  }
100
- for (const stop of stopsAround(host)) {
118
+ for (const stop of focusStops(host)) {
101
119
  stop.focus();
102
120
  if (doc.activeElement === stop) return stop;
103
121
  }
@@ -15,24 +15,29 @@
15
15
  padding: 3px 9px;
16
16
  border-radius: var(--radius-pill);
17
17
  color: var(--text);
18
- background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
18
+ background: var(--chip-neutral-fill);
19
19
  white-space: nowrap;
20
20
  }
21
21
  /* Status chips take the --chip-* pairs, so the light app's solid fills live in
22
22
  tokens.css rather than in a theme override down here: on white the glow-tints
23
- wash out and the ink has to deepen to clear AA, and that is a token decision. */
24
- .ui-badge--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
25
- .ui-badge--soon { color: var(--accent); background: var(--surface); /* code-bg: inherit — a wash paints over a ground and behind a chip, so the opaque ground below it is the step. */ }
26
- .ui-badge--info { color: var(--chip-info-ink); background: var(--chip-info-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
23
+ wash out and the ink has to deepen to clear AA, and that is a token decision.
24
+ The tones with no status take --chip-neutral-fill for the same reason: before #453
25
+ they painted the opaque --surface, the one soft badge fill that sat DARKER than the
26
+ panel in dark. why: docs/foundations.md#elevation
27
+ Soon takes body ink here, not --accent: accent ink measured 4.04:1 on a dark floating
28
+ panel under three of the four accents, body ink 9.74:1. See docs/foundations.md. */
29
+ .ui-badge--live { color: var(--chip-success-ink); background: var(--chip-success-fill); }
30
+ .ui-badge--soon { color: var(--text); background: var(--chip-neutral-fill); }
31
+ .ui-badge--info { color: var(--chip-info-ink); background: var(--chip-info-fill); }
27
32
  .ui-badge--warn { color: var(--chip-warn-ink); background: var(--chip-warn-fill); }
28
- .ui-badge--danger { color: var(--chip-danger-ink); background: var(--chip-danger-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
29
- .ui-badge--archive { /* muted-ink: state — archived record */ color: var(--muted); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
33
+ .ui-badge--danger { color: var(--chip-danger-ink); background: var(--chip-danger-fill); }
34
+ .ui-badge--archive { /* muted-ink: state — archived record */ color: var(--muted); background: var(--chip-neutral-fill); }
30
35
 
31
36
  /* Semantic status variants — for record/row status in data tables
32
37
  (paid / verified / pending / failed …). Same chip, mapped to meaning. */
33
- .ui-badge--success { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
38
+ .ui-badge--success { color: var(--chip-success-ink); background: var(--chip-success-fill); }
34
39
  .ui-badge--pending { color: var(--chip-warn-ink); background: var(--chip-warn-fill); }
35
- .ui-badge--neutral { color: var(--text); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
40
+ .ui-badge--neutral { color: var(--text); background: var(--chip-neutral-fill); }
36
41
 
37
42
  /* Pill: same shape, sentence case, a touch larger — a metadata chip */
38
43
  .ui-pill {
@@ -42,7 +47,7 @@
42
47
  /* rank: chip */
43
48
  font-size: var(--text-xs);
44
49
  color: var(--text);
45
- background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
50
+ background: var(--chip-neutral-fill);
46
51
  border-radius: var(--radius-pill);
47
52
  padding: 4px 11px;
48
53
  font-weight: var(--weight-semibold);
@@ -50,8 +55,8 @@
50
55
  /* The live pill is the same status paint as the live badge, so it takes the
51
56
  same pair. On its own it was --green on --glow-green, which is 3.97:1 over
52
57
  white — under AA, while the badge beside it cleared at 4.64:1. See issue #131. */
53
- .ui-pill--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
54
- .ui-pill--soon { color: var(--accent); background: var(--surface); /* code-bg: inherit — a wash paints over a ground and behind a chip, so the opaque ground below it is the step. */ }
58
+ .ui-pill--live { color: var(--chip-success-ink); background: var(--chip-success-fill); }
59
+ .ui-pill--soon { color: var(--text); background: var(--chip-neutral-fill); }
55
60
 
56
61
  /* Status dot */
57
62
  .ui-dot {
@@ -31,9 +31,9 @@
31
31
  accent band is the second band #578 round r34 took off the focus rules, and a
32
32
  pointer resting on a focused control would put it back. One indicator, which is
33
33
  what the kit's rows already chose. why: docs/foundations.md#the-focus-ring */
34
- .ui-dropdown__trigger:hover:not(:focus-visible) { border-color: var(--accent); }
34
+ .ui-dropdown__trigger:enabled:hover:not(:focus-visible) { border-color: var(--accent); }
35
35
  .ui-dropdown__trigger:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
36
- /* Off. The off hover-not-focus selectors are (0,4,0), above live hover's (0,3,0).
36
+ /* Off. The off hover-not-focus selectors are (0,4,0), tied with live hover and later.
37
37
  A pointer over a dead control changes nothing while an aria-disabled one keeps its stop and the ring.
38
38
  No fill: --disabled-surface IS this control's --surface, in both themes. The
39
39
  activation is refused in ddTriggerOff(), not with pointer-events, which would
@@ -295,12 +295,12 @@
295
295
  margin-left: auto;
296
296
  flex: none;
297
297
  color: var(--text);
298
- background: color-mix(in srgb, var(--muted) 12%, transparent); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
299
- }
300
- .ui-dropdown__badge.is-live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
301
- :root[data-theme="light"] .ui-dropdown__badge:not(.is-live) {
302
- background: color-mix(in srgb, var(--muted) 15%, transparent); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
298
+ /* The kit's one neutral chip fill, which #453 moved into a token so the badge, the
299
+ pill, this chip and the version badge are one family. The per-theme alphas that
300
+ used to be written here twice live with the token. */
301
+ background: var(--chip-neutral-fill); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */
303
302
  }
303
+ .ui-dropdown__badge.is-live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only surface has no nested chip. */ }
304
304
 
305
305
  /* Disabled + danger rows */
306
306
  .ui-dropdown__item.is-disabled { /* muted-ink: state — unavailable control */ color: var(--disabled-ink); cursor: not-allowed; pointer-events: none; }
@@ -13,15 +13,19 @@
13
13
  background: var(--surface);
14
14
  }
15
15
  .ui-filter-bar__chip .ui-dropdown__trigger { border: 0; background: transparent; padding: var(--space-1) var(--space-2); }
16
- .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; }
16
+ /* The chip is the edge the row's other boxes are measured against, so it fades with them:
17
+ off, the chips stayed at full strength and only their ink said so. Two carriers, because
18
+ a bar turned off disables the controls in a nested fieldset and not the fieldset. #518
19
+ why: guidelines/accessibility-floor.md#disabled-control-legibility */
20
+ .ui-filter-bar__chip:disabled,
21
+ .ui-filter-bar:disabled .ui-filter-bar__chip { border-color: var(--disabled-border); }
22
+ .ui-filter-bar__remove { border: 0; background: transparent; /* muted-ink: glyph — filter remove mark */ color: var(--muted); 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; }
17
23
  .ui-filter-bar__remove:focus-visible,
18
24
  .ui-filter-bar:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
19
25
  .ui-filter-bar button:disabled { /* muted-ink: state — disabled filters */ color: var(--disabled-ink); cursor: not-allowed; }
26
+ .ui-filter-bar .ui-filter-bar__remove:disabled { /* muted-ink: state — unavailable remove mark on the page */ color: var(--disabled-ink-bare); }
20
27
 
21
- /* The chip's value takes its button's own ink, so a disabled chip keeps the
22
- disabled ink it inherits instead of the value ink the enabled rules set. */
23
- .ui-filter-bar .ui-dropdown__value { color: inherit; }
24
- .ui-filter-bar button:enabled .ui-dropdown__value { color: var(--strong); }
28
+ .ui-filter-bar .ui-dropdown__value { color: inherit; font-weight: inherit; letter-spacing: normal; }
25
29
  /* A chip with nothing chosen prints the field's name in the slot the value will
26
30
  take — in the page's reading ink, not --muted. --disabled-ink IS --muted, so a
27
31
  muted name on an offered filter said "switched off" in the one ink the kit
@@ -34,11 +38,8 @@
34
38
  dropdown gets: it is laid out while hidden, so a wider one widened the page
35
39
  (#467). why: docs/components.md#a-filter-row-holds-its-panels */
36
40
  .ui-filter-bar .ui-dropdown__panel { min-width: 100%; max-width: 100%; overflow-wrap: anywhere; }
37
- /* The clear action takes the kit's bordered skin minus its fill: words belong on
38
- the page, not on a grey block, in any state — Artur, round r28. Rest stays at
39
- (0,1,1) so `.ui-btn:hover`'s accent wash still outranks it; the rules under it
40
- answer each state that paints a NEUTRAL fill, one step above the kit rule
41
- writing it. Held by filter-bar.test.js. why: docs/components.md#a-filter-row-holds-its-panels */
41
+ /* Clear is a quiet text button: no edge in any state, and no fill but the pointer's wash below.
42
+ why: docs/components.md#a-filter-row-holds-its-panels */
42
43
  [data-filter-clear] button { background: transparent; }
43
44
  .ui-filter-bar [data-filter-clear] button:disabled,
44
45
  .ui-filter-bar [data-filter-clear] button[aria-disabled="true"],
@@ -46,23 +47,40 @@
46
47
  .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:hover,
47
48
  .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:active,
48
49
  .ui-filter-bar [data-filter-clear] button[aria-busy="true"][data-btn-disabled] { background: transparent; }
49
- /* No fill, so the label is read on the page: the ink button-disabled.test.js
50
- measures on every ground. The edge stays the kit's --disabled-border. */
50
+ /* The ink button-disabled.test.js measures on every ground, since there is no fill. */
51
51
  .ui-filter-bar [data-filter-clear] button:disabled,
52
52
  .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); }
53
+ .ui-filter-bar [data-filter-clear] button { border-color: transparent; }
54
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"],
55
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:hover,
56
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:active { border-color: transparent; }
57
+ /* The kit's own pointer answer, `.ui-btn:hover`: --accent at 14% and --strong ink, over the page
58
+ ground rather than --surface, which would be a grey block under words. Busy restated after. */
59
+ .ui-filter-bar [data-filter-clear] button:enabled:hover { border-color: transparent; background: color-mix(in srgb, var(--accent) 14%, transparent); /* code-bg: inherit — a translucent wash moves the ground, not the chip on it. */ }
60
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"]:hover { background: transparent; }
61
+ .ui-filter-bar [data-filter-clear] button:disabled,
62
+ .ui-filter-bar [data-filter-clear] button[aria-disabled="true"],
63
+ .ui-filter-bar [data-filter-clear] button[aria-busy="true"][data-btn-disabled] { border-color: transparent; }
53
64
 
54
- /* Open, a chip's menu takes the kit's menu floor, measured and slid by
55
- filterPanelFit(); `is-closing` holds that geometry through the fade. #549
65
+ /* Open, a filter menu anchored at its own trigger takes a floor measured and slid by
66
+ filterPanelFit(); `is-closing` holds that geometry through the fade. A chip's menu
67
+ reaches for the kit's menu floor; the add control's catalogue asks for a panel's
68
+ width instead, because a search field over it reads "Searc" at 240. #549, #496
56
69
  why: docs/components.md#a-filter-row-holds-its-panels */
70
+ .ui-filter-bar [data-filter-add] .ui-dropdown__panel { --ui-filter-panel-ask: var(--panel-sm); }
57
71
  .ui-filter-bar__chip .ui-dropdown.open .ui-dropdown__panel,
58
- .ui-filter-bar__chip .ui-dropdown__panel.is-closing {
72
+ .ui-filter-bar__chip .ui-dropdown__panel.is-closing,
73
+ .ui-filter-bar [data-filter-add] .ui-dropdown.open .ui-dropdown__panel,
74
+ .ui-filter-bar [data-filter-add] .ui-dropdown__panel.is-closing {
59
75
  min-width: min(var(--ui-filter-panel-floor, 240px), var(--ui-filter-panel-room, 100%));
60
76
  max-width: var(--ui-filter-panel-room, 100%);
61
77
  margin-inline-start: calc(-1 * var(--ui-filter-panel-shift, 0px));
62
78
  }
63
79
  /* An end-aligned panel is pinned by its right edge, so the slide pushes it. */
64
80
  .ui-filter-bar__chip .ui-dropdown.open .ui-dropdown__panel.is-end,
65
- .ui-filter-bar__chip .ui-dropdown__panel.is-end.is-closing {
81
+ .ui-filter-bar__chip .ui-dropdown__panel.is-end.is-closing,
82
+ .ui-filter-bar [data-filter-add] .ui-dropdown.open .ui-dropdown__panel.is-end,
83
+ .ui-filter-bar [data-filter-add] .ui-dropdown__panel.is-end.is-closing {
66
84
  margin-inline-start: 0;
67
85
  margin-inline-end: calc(-1 * var(--ui-filter-panel-shift, 0px));
68
86
  }
@@ -104,3 +122,24 @@
104
122
  field and filterBar() builds none, so an arrow key moves real focus instead. #578
105
123
  why: docs/components.md#a-filter-row-holds-its-panels */
106
124
  .ui-filter-bar .ui-dropdown__item.is-selected:hover { background: color-mix(in srgb, var(--glow-purple), var(--accent) 9%); /* code-bg: inherit — a translucent wash moves the ground, not the chip on it. */ }
125
+
126
+ /* A chip's height — its remove mark's line box, padding and border — named so the control
127
+ stands as tall on a line of its own, where there is no chip to stretch to. */
128
+ .ui-filter-bar { --ui-filter-row-h: calc(2px + 2 * var(--space-1) + var(--leading-normal) * var(--text-lg)); }
129
+
130
+ /* A chip's neighbour takes the chip's corner, that height and edge, but not its painted fill:
131
+ the trigger's own edge can differ from the row's, while its --surface made the
132
+ row's least consequential action its loudest box — and off, with its edge down, the one thing
133
+ still looking pressable. The states are
134
+ spelled out because this depth outranks the base rules; the fill needs one rule, because the
135
+ kit paints it at rest only. Under a pointer it takes the kit's trigger edge, --accent, which
136
+ steps aside under the band; held at its resting edge, hover and rest were one paint. #496, #518
137
+ why: guidelines/colour-and-theming.md#keep-text-off-grey-fills */
138
+ .ui-filter-bar [data-filter-add] { display: flex; align-self: stretch; }
139
+ .ui-filter-bar [data-filter-add] .ui-dropdown { display: flex; }
140
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger { border-radius: var(--radius-sm); min-height: var(--ui-filter-row-h); border-color: var(--control-edge); background: transparent; }
141
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger svg { width: 16px; height: 16px; flex: none; stroke-width: 2.4; }
142
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger:enabled:hover:not(:focus-visible) { border-color: var(--accent); }
143
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger:disabled { border-color: var(--disabled-border); }
144
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger[aria-disabled="true"],
145
+ .ui-filter-bar [data-filter-add] .ui-dropdown__trigger[aria-disabled="true"]:hover:not(:focus-visible) { border-color: var(--disabled-border); }
@@ -209,7 +209,7 @@
209
209
  src/styles/dropdown.css:271 `.ui-dropdown__item:hover`.
210
210
  why: docs/foundations.md#elevation */
211
211
  .vopt:hover { outline: 1px solid var(--control-edge); outline-offset: -1px; background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
212
- /* The row is a keyboard stop: src/components/dropdown.js:1107
212
+ /* The row is a keyboard stop: src/components/dropdown.js:1141
213
213
  `items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
214
214
  the arrow keys, and this is one. It takes the ring on its own 10px radius, the
215
215
  same way `.amenu a` above does. #482 */
@@ -226,8 +226,11 @@
226
226
  flex: none;
227
227
  margin-top: 1px;
228
228
  }
229
- .vbadge.live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
230
- .vbadge.arch { /* muted-ink: state — archived version */ color: var(--muted); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
229
+ .vbadge.live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only surface has no nested chip. */ }
230
+ /* The archived version badge is the archive badge tone in a floating menu, so it takes
231
+ the same neutral chip fill rather than the card surface — which, inside a panel that
232
+ is itself lighter than a card, was darker than the ground it sat on. #453 */
233
+ .vbadge.arch { /* muted-ink: state — archived version */ color: var(--muted); background: var(--chip-neutral-fill); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
231
234
  /* The phone step — the same one the rail folds at: docs/foundations.md#breakpoints.
232
235
  The switcher keeps its glyph and drops its label.
233
236
  The menu hangs the other way here. Anchored by its left edge it opened
@@ -233,10 +233,18 @@
233
233
  --chip-success-fill: var(--glow-green);
234
234
  --chip-warn-ink: var(--amber);
235
235
  --chip-warn-fill: color-mix(in srgb, var(--amber) 15%, transparent);
236
- --chip-danger-ink: var(--pink);
236
+ /* Not --pink: that reads 4.23:1 on this fill over a floating panel, under the 4.5:1 badge
237
+ floor. Same hue lifted toward white — 8.64 page / 7.02 card / 6.30 panel. The signal
238
+ and the solid-toast pair keep --pink. why: docs/foundations.md#colour-and-contrast, #453 */
239
+ --chip-danger-ink: #f5aec6;
237
240
  --chip-danger-fill: var(--glow-pink);
238
241
  --chip-info-ink: var(--cyan);
239
242
  --chip-info-fill: var(--glow-cyan);
243
+ /* The fill of a chip with no status to carry. The themes match on SEPARATION, not on
244
+ alpha — 12% here is 1.22:1 off the card, 15% in light is 1.23:1 — because --muted is
245
+ a light violet-grey in dark and a mid slate in light.
246
+ why: docs/foundations.md#colour-and-contrast, #453 */
247
+ --chip-neutral-fill: color-mix(in srgb, var(--muted) 12%, transparent);
240
248
 
241
249
  /* Solid signal surfaces — a status that has stopped being ink and become the FILL (a
242
250
  solid toast). Fill and ink are ONE pair, never two independent picks: the fill is the
@@ -412,7 +420,10 @@
412
420
  chip text clears WCAG AA on its own pale fill, and on the soft toast's wash, which
413
421
  #131 gave the same ink. Three came down again at #295 with the page; danger did not
414
422
  need to. */
415
- --chip-success-ink: #1c7034;
423
+ /* Success is held at the 7:1 body aim rather than the 4.5:1 floor: 7.17:1 on its own
424
+ fill, 8.33:1 on the card. Was #1c7034 at 5.29:1. The fill did not move.
425
+ why: docs/foundations.md#colour-and-contrast, #453 */
426
+ --chip-success-ink: #145a26;
416
427
  --chip-success-fill: #dff3e4;
417
428
  --chip-warn-ink: #825900;
418
429
  --chip-warn-fill: #fbedd2;
@@ -423,6 +434,8 @@
423
434
  already occupy. See issue #131. */
424
435
  --chip-info-ink: #096a7c;
425
436
  --chip-info-fill: #ddeff3;
437
+ /* See the dark block for why the two alphas differ. */
438
+ --chip-neutral-fill: color-mix(in srgb, var(--muted) 13%, transparent);
426
439
 
427
440
  /* Light runs the same rule the other way. The signals are mid-tone here, so NO ink
428
441
  rescues them and the fill is what has to move: it takes the deepened form the chip
@@ -579,3 +592,18 @@
579
592
  .ui-callout--warn,
580
593
  .ui-callout--danger,
581
594
  .ui-success { --code-bg: var(--wash-code-bg); }
595
+
596
+ /* React badges and pills accept inline code on opaque or translucent status fills. */
597
+ .ui-badge,
598
+ .ui-badge--live,
599
+ .ui-badge--soon,
600
+ .ui-badge--info,
601
+ .ui-badge--warn,
602
+ .ui-badge--danger,
603
+ .ui-badge--archive,
604
+ .ui-badge--success,
605
+ .ui-badge--pending,
606
+ .ui-badge--neutral,
607
+ .ui-pill,
608
+ .ui-pill--live,
609
+ .ui-pill--soon { --code-bg: var(--wash-code-bg); }