@apliteni/apliteni-ui 0.86.0 → 0.88.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.
@@ -0,0 +1,35 @@
1
+ # Button labels
2
+
3
+ ## Keep the words
4
+
5
+ <!-- rule: words-first -->
6
+
7
+ **Rule:** Give every button words, and add a glyph only to help those words.
8
+
9
+ **Do:** Export, Rename and Share, named at rest.
10
+
11
+ **Don't:** The same three, each name waiting on a hover a finger never performs.
12
+
13
+ ## Let the glyph repeat the verb
14
+
15
+ <!-- rule: glyph-repeats-the-verb -->
16
+
17
+ **Rule:** Add a glyph beside a label only when it depicts that label’s action, and treat every button in a row the same way.
18
+
19
+ **Except:** A dismissing action beside a committing one is not a peer, and stays plain.
20
+
21
+ **Do:** The arrow downloads, the sheets copy.
22
+
23
+ **Don't:** A table is what is exported, not the export, and the plain neighbour reads as lesser.
24
+
25
+ ## Earn a wordless button
26
+
27
+ <!-- rule: wordless-earns-it -->
28
+
29
+ **Rule:** Drop a button’s words only when its glyph is learned everywhere, its action is on the closed list, and its name is left to assistive technology and a hover title.
30
+
31
+ **Except:** [Icon-only controls](https://ui.apli.tech/storybook/?path=/story/guidelines-iconography--iconography) is the list, and it grows by decision. The folded rail shows its names beside the glyphs instead, so those carry no title.
32
+
33
+ **Do:** Overflow is on that list; the funnel is not.
34
+
35
+ **Don't:** A funnel could filter, sort or export, and the cog gave up the word that said which.
@@ -8,7 +8,7 @@
8
8
 
9
9
  **Why:** Without a legend, readers need labels for unfamiliar glyphs.
10
10
 
11
- **Except:** The list grows by decision, not review debate. Today: `x` (close or dismiss), `copy` (copy to clipboard), `moreHorizontal` (overflow menu), `moreVertical` (overflow menu), `chevronDown` (expand or collapse), `chevronUp` (expand or collapse), and the shell’s theme toggle, sidebar toggle and collapsed-rail links.
11
+ **Except:** The list grows by decision, not review debate, and [Earn a wordless button](https://ui.apli.tech/storybook/?path=/story/guidelines-button-labels--button-labels) states the three tests an entry passes. Today: `x` (close or dismiss), `copy` (copy to clipboard), `moreHorizontal` (overflow menu), `moreVertical` (overflow menu), `chevronDown` (expand or collapse), `chevronUp` (expand or collapse). The shell’s own chrome — theme toggle, sidebar toggle, collapsed-rail links — is wordless by a separate decision, not by passing those tests.
12
12
 
13
13
  **Do:** Show an overflow menu without words; keep the Settings label.
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -515,11 +515,12 @@ instead, and the word goes to the track's accessible name. The file reads as a
515
515
  stack: the name on top with the actions at its end, the size — or the failure
516
516
  message — under it, and the track the full width below. No tier takes a second
517
517
  line. The name truncates its stem and keeps its extension, with the whole name in
518
- a `title`, and nothing is hidden or moved at any width. `Remove` is the icon-only
519
- `x`, named by `removeLabel`; `Retry` keeps its word. Both appear only when you
520
- handle them. Children render above the row, inside the region the target covers;
521
- pass `dragging` to drive that target from a parent. `accept` filters the system
522
- picker only — validate type and size yourself.
518
+ a `title`, and nothing is hidden or moved at any width. `Remove` is the kit's
519
+ small text button, worded by `removeLabel`; `Retry` keeps its word beside a
520
+ glyph. Both appear only when you handle them. Children render above the row,
521
+ inside the region the target covers; pass `dragging` to drive that target from
522
+ a parent. `accept` filters the system picker only — validate type and size
523
+ yourself.
523
524
 
524
525
  Use `Field` when an existing labelled control needs the kit frame. Spread its
525
526
  render-prop attributes onto one labelable control, and apply the existing control
@@ -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);
@@ -698,7 +698,7 @@ type FileDropProps = {
698
698
  accept?: string;
699
699
  /** The words on the drop target. */
700
700
  dropLabel?: string;
701
- /** Names the icon-only remove button; it carries no visible word. */
701
+ /** The word on the remove button. */
702
702
  removeLabel?: string;
703
703
  retryLabel?: string;
704
704
  /** The file the consumer is holding. Left out, the row is at rest. */
@@ -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
  ]
@@ -2870,7 +2871,7 @@ function FileDrop({
2870
2871
  note,
2871
2872
  accept,
2872
2873
  dropLabel = "Drop to upload",
2873
- removeLabel = "Remove file",
2874
+ removeLabel = "Remove",
2874
2875
  retryLabel = "Retry",
2875
2876
  file,
2876
2877
  dragging,
@@ -2927,11 +2928,9 @@ function FileDrop({
2927
2928
  {
2928
2929
  variant: "ghost",
2929
2930
  size: "sm",
2930
- icon: "x",
2931
- iconOnly: true,
2932
- "aria-label": removeLabel,
2933
2931
  onClick: onRemove,
2934
- disabled
2932
+ disabled,
2933
+ children: removeLabel
2935
2934
  }
2936
2935
  )
2937
2936
  ] })
@@ -150,6 +150,24 @@ export const iconNames = Object.keys(ICONS);
150
150
  // `gear` or `externalLink`, because a reader meets them one at a time and a
151
151
  // toolbar is not a legend. Adding an entry is a decision recorded here, which
152
152
  // is the point: the alternative rules read well and could not be gated.
153
+ //
154
+ // Guidelines / Button labels states the three tests an entry passes: the glyph is
155
+ // learned everywhere, its action is on this list, and the control keeps a name
156
+ // assistive technology reads and a hover shows. button({ iconOnly }) writes
157
+ // `aria-label` and `title` together, so a control built from the list below has
158
+ // both — and neither reaches a keyboard reader who is not running one.
159
+ //
160
+ // The shell's folded-rail links and its sidebar toggle are the stated exception:
161
+ // they show the label itself beside the glyph on hover and on focus, so they carry
162
+ // no `title` — stories/apps/shell-rail.test.js holds them to that. They are a
163
+ // separate decision about the shell's own chrome, not entries that passed the
164
+ // tests above.
165
+ //
166
+ // `filter` — Lucide's funnel — fails the first test. Depicting a thing is not the
167
+ // fault; `copy` is on this list and depicts one too. The kit reads the funnel alone
168
+ // as not saying whether the control filters, sorts or exports — its own inference
169
+ // from the sources in docs/specification.md, not a measured recognition finding.
170
+ // See #565.
153
171
  export const iconOnlyAllowed = {
154
172
  x: 'close or dismiss',
155
173
  copy: 'copy to clipboard',
@@ -159,6 +177,21 @@ export const iconOnlyAllowed = {
159
177
  chevronUp: 'expand or collapse',
160
178
  };
161
179
 
180
+ // The word a wordless control's name may open with. The list above says which
181
+ // ACTIONS may drop their text; without this, a glyph on it carries any action a
182
+ // caller likes — `copy` for Duplicate, `x` for Cancel — and the gate stays green,
183
+ // which is what #566's review found. The opening word, not the whole name, so
184
+ // "Copy project ID" is Copy naming what it copies.
185
+ // stories/guidelines/iconography.test.js reads call sites against both maps.
186
+ export const iconOnlyNames = {
187
+ x: ['close', 'dismiss'],
188
+ copy: ['copy'],
189
+ moreHorizontal: ['more'],
190
+ moreVertical: ['more'],
191
+ chevronDown: ['expand', 'collapse', 'show', 'hide'],
192
+ chevronUp: ['expand', 'collapse', 'show', 'hide'],
193
+ };
194
+
162
195
  // What a glyph means when a component picks it for the reader rather than a
163
196
  // caller naming it. Two rules hold the map together:
164
197
  //
@@ -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 => {
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;
@@ -43,6 +45,7 @@ export declare const SCORE: Record<'exact' | 'prefix' | 'wordStart' | 'contains'
43
45
  export declare const iconNames: string[];
44
46
  export declare const iconCategories: { name: string; names: string[] }[];
45
47
  export declare const iconOnlyAllowed: Record<string, string>;
48
+ export declare const iconOnlyNames: Record<string, string[]>;
46
49
  export declare const iconMeanings: Record<string, string>;
47
50
  export declare const sun: string;
48
51
  export declare const moon: string;
package/src/index.js CHANGED
@@ -30,7 +30,7 @@ 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';
@@ -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); }
@@ -90,9 +90,58 @@
90
90
  }
91
91
  .ui-accent-picker button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
92
92
 
93
- .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%; }
94
- .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 */
95
110
  .ui-seg--underline button.is-active,
96
- .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
+ }
97
146
  .ui-seg button:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
98
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