@apliteni/apliteni-ui 0.124.1 → 0.125.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.
@@ -70,3 +70,15 @@
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
71
 
72
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.
73
+
74
+ ## Mark selection in forced colours
75
+
76
+ <!-- rule: selected-forced-colours -->
77
+
78
+ **Rule:** In forced colours, fill a selected item with the highlight colour and its label with the highlight text colour.
79
+
80
+ **Why:** The mode replaces author backgrounds. A specimen cannot switch it on, so this rule draws no pair.
81
+
82
+ **Do:** Ring a focused selected item in the page text colour.
83
+
84
+ **Don't:** Mark selection with an outline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.124.1",
3
+ "version": "0.125.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -545,6 +545,17 @@ class and invalid styling as appropriate:
545
545
  The frame supplies a stable ID, required state, and linked hint/error messages.
546
546
  An optional `id` lets the caller choose the control ID; it must be unique.
547
547
 
548
+ A `Dropdown` is a control too. Spread the attributes onto it and its trigger takes
549
+ `aria-describedby` and `aria-invalid`, so the error and hint are read when it gets focus.
550
+ Its accessible name is the label and then the chosen value. Outside a `Field`,
551
+ `Dropdown` takes `aria-describedby` and `aria-invalid` as props and forwards them to the trigger.
552
+
553
+ ```tsx
554
+ <Field label="Owner" error="That option is no longer available.">
555
+ {control => <Dropdown {...control} variant="select" items={owners} />}
556
+ </Field>
557
+ ```
558
+
548
559
  ### Search
549
560
 
550
561
  `SearchField` is the toolbar's search box: a search glyph, a native
@@ -501,6 +501,10 @@ type DropdownProps = {
501
501
  /** A field above the rows that filters them as the reader types. */
502
502
  search?: boolean | DropdownSearch;
503
503
  ariaLabel?: string;
504
+ /** Names the elements that describe the trigger, e.g. a field's error and hint. */
505
+ 'aria-describedby'?: string;
506
+ /** Marks the trigger invalid. `Field` supplies it while its error shows. */
507
+ 'aria-invalid'?: boolean | 'true' | 'false';
504
508
  id?: string;
505
509
  panelClass?: string;
506
510
  /** Controlled: the host owns the open state and answers `onOpenChange`. */
@@ -512,7 +516,7 @@ type DropdownProps = {
512
516
  /** Draw the row yourself. `props` is everything the row needs; spread it. */
513
517
  row?: (item: DropdownItem, props: DropdownRowProps) => ReactNode;
514
518
  };
515
- declare function Dropdown({ label, value, placeholder, variant, items, sections, header, footer, triggerContent, triggerClass, disabled, chevron, align, direction, scroll, search, ariaLabel, id, panelClass, open: openProp, defaultOpen, onOpenChange, onSelect, row, }: DropdownProps): react.JSX.Element;
519
+ declare function Dropdown({ label, value, placeholder, variant, items, sections, header, footer, triggerContent, triggerClass, disabled, chevron, align, direction, scroll, search, ariaLabel, id, panelClass, 'aria-describedby': describedBy, 'aria-invalid': invalid, open: openProp, defaultOpen, onOpenChange, onSelect, row, }: DropdownProps): react.JSX.Element;
516
520
 
517
521
  type BackLinkOwnProps = {
518
522
  /** The address. No address, or a `javascript:` one, and nothing is rendered. */
@@ -2072,6 +2072,7 @@ function DatePicker({
2072
2072
  import {
2073
2073
  Fragment as Fragment5,
2074
2074
  useCallback as useCallback4,
2075
+ useContext as useContext2,
2075
2076
  useEffect as useEffect7,
2076
2077
  useId as useId8,
2077
2078
  useRef as useRef11,
@@ -2087,6 +2088,12 @@ import {
2087
2088
  dropdownHeightFit,
2088
2089
  filterPanelRow
2089
2090
  } from "@apliteni/apliteni-ui";
2091
+
2092
+ // src/fieldContext.ts
2093
+ import { createContext as createContext2 } from "react";
2094
+ var FieldContext = createContext2(null);
2095
+
2096
+ // src/Dropdown.tsx
2090
2097
  import { Fragment as Fragment6, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
2091
2098
  var EXIT_SLACK_MS2 = 50;
2092
2099
  var SETTLE_MS = 450;
@@ -2140,6 +2147,8 @@ function Dropdown({
2140
2147
  ariaLabel,
2141
2148
  id,
2142
2149
  panelClass = "",
2150
+ "aria-describedby": describedBy,
2151
+ "aria-invalid": invalid,
2143
2152
  open: openProp,
2144
2153
  defaultOpen = false,
2145
2154
  onOpenChange,
@@ -2159,6 +2168,8 @@ function Dropdown({
2159
2168
  const at = useRef11("");
2160
2169
  const auto = useId8().replace(/:/g, "");
2161
2170
  const uid = id ?? auto;
2171
+ const inField = useContext2(FieldContext);
2172
+ const fieldName = inField && id === inField.id && !(ariaLabel && triggerContent != null) ? { id: `${uid}-trigger`, labelId: inField.labelId, labelledBy: `${inField.labelId} ${uid}-trigger` } : null;
2162
2173
  const setOpen = useCallback4((next) => {
2163
2174
  if (openProp === void 0) setSelfOpen(next);
2164
2175
  onOpenChange?.(next);
@@ -2559,7 +2570,11 @@ function Dropdown({
2559
2570
  disabled: disabled || void 0,
2560
2571
  "aria-haspopup": sx ? "dialog" : listRole,
2561
2572
  "aria-expanded": open,
2573
+ id: fieldName?.id,
2562
2574
  "aria-label": ariaLabel && triggerContent != null ? ariaLabel : void 0,
2575
+ "aria-labelledby": fieldName?.labelledBy,
2576
+ "aria-describedby": describedBy,
2577
+ "aria-invalid": invalid,
2563
2578
  ref: trigger,
2564
2579
  onClick: (e) => {
2565
2580
  e.stopPropagation();
@@ -2588,6 +2603,7 @@ function Dropdown({
2588
2603
  "data-dropdown-panel": "",
2589
2604
  role: sx ? "dialog" : listRole,
2590
2605
  "aria-label": sx ? name : ariaLabel,
2606
+ "aria-labelledby": !sx && !ariaLabel ? fieldName?.labelId : void 0,
2591
2607
  style: sx ? void 0 : cap,
2592
2608
  ref: panel,
2593
2609
  onClick: (e) => e.stopPropagation(),
@@ -3084,9 +3100,9 @@ import { jsx as jsx29, jsxs as jsxs24 } from "react/jsx-runtime";
3084
3100
  function messages(id, hint, error) {
3085
3101
  return [error && `${id}-error`, hint && `${id}-hint`].filter(Boolean).join(" ") || void 0;
3086
3102
  }
3087
- function Frame({ id, label, hint, error, required, children }) {
3103
+ function Frame({ id, labelId, label, hint, error, required, children }) {
3088
3104
  return /* @__PURE__ */ jsxs24("div", { className: "ui-field", children: [
3089
- /* @__PURE__ */ jsxs24("label", { className: "ui-field__label", htmlFor: id, children: [
3105
+ /* @__PURE__ */ jsxs24("label", { className: "ui-field__label", id: labelId, htmlFor: id, children: [
3090
3106
  label,
3091
3107
  required && /* @__PURE__ */ jsx29("span", { className: "ui-field__req", "aria-hidden": "true", children: "*" })
3092
3108
  ] }),
@@ -3109,7 +3125,8 @@ function wiring(id, hint, error) {
3109
3125
  function Field({ id: suppliedId, label, hint, error, required, children }) {
3110
3126
  const generatedId = useId10();
3111
3127
  const id = suppliedId ?? generatedId;
3112
- return /* @__PURE__ */ jsx29(Frame, { ...{ id, label, hint, error, required }, children: children({ ...wiring(id, hint, error), required }) });
3128
+ const labelId = `${id}-label`;
3129
+ return /* @__PURE__ */ jsx29(Frame, { ...{ id, labelId, label, hint, error, required }, children: /* @__PURE__ */ jsx29(FieldContext.Provider, { value: { id, labelId }, children: children({ ...wiring(id, hint, error), required }) }) });
3113
3130
  }
3114
3131
  var TextField = forwardRef6(function TextField2({ label, hint, error, required, type = "text", unit, icon: glyph, className = "", ...props }, ref) {
3115
3132
  const id = useId10();
@@ -3397,13 +3414,13 @@ var SearchField = forwardRef7(function SearchField2({ ariaLabel, className, ...p
3397
3414
  });
3398
3415
 
3399
3416
  // src/Toast.tsx
3400
- import { createContext as createContext2, useCallback as useCallback5, useContext as useContext2, useEffect as useEffect12, useLayoutEffect as useLayoutEffect4, useRef as useRef18, useState as useState12 } from "react";
3417
+ import { createContext as createContext3, useCallback as useCallback5, useContext as useContext3, useEffect as useEffect12, useLayoutEffect as useLayoutEffect4, useRef as useRef18, useState as useState12 } from "react";
3401
3418
  import { createPortal as createPortal4 } from "react-dom";
3402
3419
  import { jsx as jsx32, jsxs as jsxs27 } from "react/jsx-runtime";
3403
- var Context = createContext2(null);
3420
+ var Context = createContext3(null);
3404
3421
  var glyphs = { success: "circleCheck", danger: "circleX", warn: "circleAlert", info: "info", neutral: "bolt" };
3405
3422
  function useToast() {
3406
- const push = useContext2(Context);
3423
+ const push = useContext3(Context);
3407
3424
  if (!push) throw new Error("useToast requires a Toast provider");
3408
3425
  return push;
3409
3426
  }
@@ -132,10 +132,10 @@ function withControlId(html) {
132
132
  // aria-describedby, aria-invalid, required. Anything the control already spells
133
133
  // out for itself wins — a caller who passed `invalid` to input() keeps their own
134
134
  // aria-invalid. `true` renders a bare boolean attribute.
135
- function withControlAttrs(html, attrs) {
135
+ function withAttrsOn(html, openTag, attrs) {
136
136
  const pairs = Object.entries(attrs).filter(([, v]) => v != null && v !== false);
137
137
  if (!pairs.length) return html;
138
- return html.replace(/<(input|textarea|select)\b([^>]*?)(\s*\/?)>/, (m, tag, rest, tail) => {
138
+ return html.replace(openTag, (m, tag, rest, tail) => {
139
139
  const add = pairs
140
140
  .filter(([k]) => !new RegExp(`\\s${k}(?=[\\s=>/]|$)`).test(rest))
141
141
  .map(([k, v]) => (v === true ? ` ${k}` : ` ${k}="${esc(v)}"`))
@@ -143,6 +143,38 @@ function withControlAttrs(html, attrs) {
143
143
  return `<${tag}${rest}${add}${tail}>`;
144
144
  });
145
145
  }
146
+ const withControlAttrs = (html, attrs) => withAttrsOn(html, /<(input|textarea|select)\b([^>]*?)(\s*\/?)>/, attrs);
147
+
148
+ // A dropdown() is not a form element: its id sits on the outer div, which a
149
+ // <label for> cannot name, and its one input (the search field) is inside the
150
+ // panel. So field() wires the trigger button instead: the label and the value
151
+ // name it, and the hint or error describes it. why: docs/components.md#forms
152
+ const DD_ROOT = /<(div)\b((?=[^>]*\bdata-dropdown(?=[\s>]))[^>]*?)(\s*\/?)>/;
153
+ const DD_TRIGGER = /<(button)\b((?=[^>]*\bdata-dropdown-trigger\b)[^>]*?)(\s*\/?)>/;
154
+ const DD_PANEL = /<(div)\b((?=[^>]*\bdata-dropdown-panel\b)[^>]*?)(\s*\/?)>/;
155
+ const attrId = (tag) => tag?.match(/\sid="([^"]+)"/)?.[1] || null;
156
+ function withDropdownId(html, id) {
157
+ const root = html.match(DD_ROOT)[0];
158
+ const existing = attrId(root);
159
+ if (existing) return { html, id: existing };
160
+ const own = id || nextId('field');
161
+ return { html: html.replace(DD_ROOT, (m) => m.replace('<div', `<div id="${esc(own)}"`)), id: own };
162
+ }
163
+ function withDropdownAttrs(html, { rootId, labelId, describedby, invalid }) {
164
+ const trigger = html.match(DD_TRIGGER)?.[0] || '';
165
+ const triggerId = attrId(trigger) || `${rootId}-trigger`;
166
+ // A trigger that carries its own aria-label keeps it: the caller named it.
167
+ const named = labelId && !/\saria-label(?:ledby)?=/.test(trigger);
168
+ let out = withAttrsOn(html, DD_TRIGGER, {
169
+ id: named ? triggerId : null,
170
+ 'aria-labelledby': named ? `${labelId} ${triggerId}` : null,
171
+ 'aria-describedby': describedby,
172
+ 'aria-invalid': invalid,
173
+ });
174
+ const panel = html.match(DD_PANEL)?.[0] || '';
175
+ if (labelId && !/\saria-label(?:ledby)?=/.test(panel)) out = withAttrsOn(out, DD_PANEL, { 'aria-labelledby': labelId });
176
+ return out;
177
+ }
146
178
 
147
179
  // A labelled control with, optionally, a hint or an error under it.
148
180
  //
@@ -156,21 +188,28 @@ function withControlAttrs(html, attrs) {
156
188
  export function field({ label, hint, error, control = '', id, required = false } = {}) {
157
189
  let ctl = control;
158
190
  let forId = id;
191
+ const dd = DD_ROOT.test(control);
192
+ let ddId = null;
159
193
  if (label && control) {
160
- const r = withControlId(control);
194
+ const r = dd ? withDropdownId(control, id) : withControlId(control);
161
195
  ctl = r.html;
162
196
  forId = id || r.id;
197
+ if (dd) ddId = r.id;
163
198
  }
164
199
  // Error wins over hint — only one message shows, so only one id is minted.
165
200
  const msg = error || hint;
166
201
  const msgId = msg && control ? nextId(error ? 'field-err' : 'field-hint') : null;
167
- ctl = withControlAttrs(ctl, {
168
- 'aria-describedby': msgId,
169
- 'aria-invalid': error ? 'true' : null,
170
- required: required || null,
171
- });
202
+ ctl = dd
203
+ ? withDropdownAttrs(ctl, {
204
+ rootId: ddId, labelId: ddId && `${ddId}-label`, describedby: msgId, invalid: error ? 'true' : null,
205
+ })
206
+ : withControlAttrs(ctl, {
207
+ 'aria-describedby': msgId,
208
+ 'aria-invalid': error ? 'true' : null,
209
+ required: required || null,
210
+ });
172
211
  const lab = label
173
- ? `<label class="ui-field__label"${forId ? ` for="${id ? esc(id) : forId}"` : ''}>${esc(label)}`
212
+ ? `<label class="ui-field__label"${ddId ? ` id="${esc(ddId)}-label"` : ''}${forId ? ` for="${id ? esc(id) : forId}"` : ''}>${esc(label)}`
174
213
  + `${required ? '<span class="ui-field__req" aria-hidden="true">*</span>' : ''}</label>`
175
214
  : '';
176
215
  const foot = error
@@ -340,3 +340,20 @@
340
340
  @media (prefers-reduced-motion: reduce) {
341
341
  .ui-cmdk.is-open * { transition: none !important; }
342
342
  }
343
+
344
+ /* Forced colours replace every author background, so a selected item takes the system
345
+ pair as its fill. `forced-color-adjust: none` is what keeps the pair from sitting on
346
+ Chromium's Canvas backplate, which would leave the label unreadable. Focus keeps its
347
+ own ring, drawn in CanvasText so the fill is never mistaken for it. */
348
+ @media (forced-colors: active) {
349
+ .ui-cmdk__item.is-active { forced-color-adjust: none; background: Highlight !important; color: HighlightText !important; --ring: var(--ring-width) solid CanvasText; }
350
+ .ui-cmdk__item.is-active .ui-cmdk__label,
351
+ .ui-cmdk__item.is-active .ui-cmdk__ic,
352
+ .ui-cmdk__item.is-active .ui-cmdk__desc,
353
+ .ui-cmdk__item.is-danger.is-active .ui-cmdk__label,
354
+ .ui-cmdk__item.is-danger.is-active .ui-cmdk__ic { color: HighlightText; }
355
+ .ui-cmdk--roomy .ui-cmdk__item.is-active .ui-cmdk__ic { background: none; }
356
+ .ui-cmdk__item.is-active .ui-cmdk__badge { background: HighlightText; color: Highlight; }
357
+ /* Shortcut caps inherit the adjust, so they take the pair too: the item's ink on its fill, with an ink edge. */
358
+ .ui-cmdk__item.is-active .ui-cmdk__key { background: Highlight; color: HighlightText; border-color: HighlightText; }
359
+ }
@@ -119,7 +119,7 @@
119
119
  resting step. */
120
120
  /* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
121
121
  the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
122
- stories/apps/shell-states.test.js:1706 `a resting glyph is dimmer but still legible`. */
122
+ stories/apps/shell-states.test.js:1708 `a resting glyph is dimmer but still legible`. */
123
123
  .ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
124
124
 
125
125
  /* A destructive row of the rail is a navigation row and rests like one; the danger
@@ -708,3 +708,41 @@
708
708
  .ui-section-head { text-align: center; max-width: var(--panel-lg); margin: 0 auto clamp(40px, 6vh, 64px); }
709
709
  .ui-section-head h2 { color: var(--strong); font-size: clamp(28px, 4vw, 40px); font-weight: 700; letter-spacing: -0.02em; margin-bottom: 14px; }
710
710
  .ui-section-head p { color: var(--text); font-size: calc(var(--text-lg) - 1px); line-height: 1.6; }
711
+
712
+ /* Forced colours replace every author background, so a selected item takes the system
713
+ pair as its fill. `forced-color-adjust: none` is what keeps the pair from sitting on
714
+ Chromium's Canvas backplate, which would leave the label unreadable. Focus keeps its
715
+ own ring, drawn in CanvasText so the fill is never mistaken for it. The pair is
716
+ `!important` so the shell's danger ink and every hover or focus state yield to it. */
717
+ @media (forced-colors: active) {
718
+ .ui-app__rail .ui-nav__item:is(.is-active, .is-current):not(.is-disabled) { forced-color-adjust: none; background: Highlight !important; color: HighlightText !important; --ring: var(--ring-width) solid CanvasText; }
719
+ .ui-app__rail .ui-nav__item:is(.is-active, .is-current).is-disabled { forced-color-adjust: none; background: GrayText !important; color: Canvas !important; /* muted-ink: state — unavailable control */ }
720
+ .ui-app__rail .ui-nav__item.is-active:not(.is-disabled)::before { background: Highlight; }
721
+ .ui-app__rail .ui-nav__item.is-active.is-disabled::before { background: GrayText; }
722
+ /* A folded row is only an icon strip: the plate carries the fill, so the row paints none. */
723
+ .ui-app.is-collapsed .ui-app__rail .ui-nav__item:is(.is-active, .is-current) { isolation: isolate; background: none !important; }
724
+ .ui-app.is-collapsed .ui-app__rail .ui-nav__item.is-current:not(.is-active)::before {
725
+ content: ""; position: absolute; left: 0; top: 50%; width: var(--ui-nav-strip); height: 100%; border-radius: 10px;
726
+ transform: translateY(-50%); /* motion: still — a mark on the current group, centred and not travelling */
727
+ z-index: -1;
728
+ }
729
+ .ui-app.is-collapsed .ui-app__rail .ui-nav__item.is-current:not(.is-active):not(.is-disabled)::before { background: Highlight; }
730
+ .ui-app.is-collapsed .ui-app__rail .ui-nav__item.is-current.is-disabled:not(.is-active)::before { background: GrayText; }
731
+ /* The floating name of a selected folded row inherits the row's adjust, so it is painted in system colours. */
732
+ .ui-app.is-collapsed .ui-app__rail .ui-nav__item:is(.is-active, .is-current):is(:hover, :focus-visible) > .ui-nav__label {
733
+ forced-color-adjust: auto; background: Canvas; color: CanvasText; border-color: CanvasText;
734
+ }
735
+ }
736
+ @media (forced-colors: active) and (max-width: 720px) {
737
+ .ui-app .ui-app__rail .ui-nav__item:is(.is-active, .is-current) { isolation: isolate; background: none !important; }
738
+ .ui-app .ui-app__rail .ui-nav__item.is-current:not(.is-active)::before {
739
+ content: ""; position: absolute; left: 0; top: 50%; width: var(--ui-nav-strip); height: 100%; border-radius: 10px;
740
+ transform: translateY(-50%);
741
+ z-index: -1;
742
+ }
743
+ .ui-app .ui-app__rail .ui-nav__item.is-current:not(.is-active):not(.is-disabled)::before { background: Highlight; }
744
+ .ui-app .ui-app__rail .ui-nav__item.is-current.is-disabled:not(.is-active)::before { background: GrayText; }
745
+ .ui-app .ui-app__rail .ui-nav__item:is(.is-active, .is-current):is(:hover, :focus-visible) > .ui-nav__label {
746
+ forced-color-adjust: auto; background: Canvas; color: CanvasText; border-color: CanvasText;
747
+ }
748
+ }
@@ -365,3 +365,64 @@ a.ui-nav__crumb:hover { color: var(--strong); background: var(--surface); /* cod
365
365
  a.ui-nav__crumb:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
366
366
  .ui-nav__crumb.is-current { color: var(--strong); font-weight: 500; }
367
367
  .ui-nav__crumb .ui-nav__ic svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2.5; }
368
+
369
+ /* Forced colours replace every author background, so a selected item takes the system
370
+ pair as its fill. `forced-color-adjust: none` is what keeps the pair from sitting on
371
+ Chromium's Canvas backplate, which would leave the label unreadable. Focus keeps its
372
+ own ring, drawn in CanvasText so the fill is never mistaken for it.
373
+ The pair is `!important` because it is the reader's palette: a danger wash, a hover
374
+ or a focus state must never repaint it. A child of the row inherits the adjust, so
375
+ each child that paints its own colour is repainted here as well. */
376
+ @media (forced-colors: active) {
377
+ .ui-nav__item.is-active:not(.is-disabled),
378
+ .ui-nav__item.is-current:not(.is-disabled),
379
+ .ui-nav__tab.is-active:not(.is-disabled),
380
+ .ui-nav--tabs.is-pill .ui-nav__tab.is-active:not(.is-disabled),
381
+ .ui-nav__crumb.is-current {
382
+ forced-color-adjust: none;
383
+ background: Highlight !important;
384
+ color: HighlightText !important;
385
+ --ring: var(--ring-width) solid CanvasText;
386
+ }
387
+ .ui-nav__item.is-active:not(.is-disabled) .ui-nav__ic svg,
388
+ .ui-nav__item.is-current:not(.is-disabled) .ui-nav__ic svg,
389
+ .ui-nav__tab.is-active:not(.is-disabled) .ui-nav__ic svg,
390
+ .ui-nav__crumb.is-current .ui-nav__ic svg { stroke: HighlightText; }
391
+ .ui-nav__item.is-active .ui-nav__caret,
392
+ .ui-nav__item.is-current .ui-nav__caret { border-color: currentColor; }
393
+ .ui-nav__item.is-active:not(.is-disabled) .ui-nav__badge,
394
+ .ui-nav__item.is-current:not(.is-disabled) .ui-nav__badge,
395
+ .ui-nav__tab.is-active:not(.is-disabled) .ui-nav__badge {
396
+ background: HighlightText !important;
397
+ color: Highlight !important;
398
+ }
399
+ /* A selected item that is unavailable must not look live, so it takes the disabled system pair. */
400
+ .ui-nav__item.is-active.is-disabled,
401
+ .ui-nav__item.is-current.is-disabled,
402
+ .ui-nav__tab.is-active.is-disabled {
403
+ forced-color-adjust: none;
404
+ background: GrayText !important;
405
+ color: Canvas !important; /* muted-ink: state — unavailable control */
406
+ }
407
+ .ui-nav__item.is-active.is-disabled .ui-nav__ic svg,
408
+ .ui-nav__item.is-current.is-disabled .ui-nav__ic svg,
409
+ .ui-nav__tab.is-active.is-disabled .ui-nav__ic svg { stroke: Canvas; }
410
+ .ui-nav__item.is-active.is-disabled .ui-nav__badge,
411
+ .ui-nav__item.is-current.is-disabled .ui-nav__badge,
412
+ .ui-nav__tab.is-active.is-disabled .ui-nav__badge {
413
+ background: Canvas !important;
414
+ color: GrayText !important; /* muted-ink: state — unavailable control */
415
+ }
416
+ /* A folded row is only an icon strip: the plate carries the fill, so the row paints none.
417
+ A current group takes the same plate as the current row it holds. */
418
+ .ui-nav--side.is-collapsed .ui-nav__item:is(.is-active, .is-current) { isolation: isolate; background: none !important; }
419
+ .ui-nav--side.is-collapsed .ui-nav__item.is-current:not(.is-active)::before {
420
+ content: ""; position: absolute; left: 0; top: 50%; width: var(--ui-nav-strip); height: 100%; border-radius: 10px;
421
+ transform: translateY(-50%); /* motion: still — a mark on the current group, centred and not travelling */
422
+ z-index: -1;
423
+ }
424
+ .ui-nav--side.is-collapsed .ui-nav__item:is(.is-active, .is-current):not(.is-disabled)::before { background: Highlight; }
425
+ .ui-nav--side.is-collapsed .ui-nav__item:is(.is-active, .is-current).is-disabled::before { background: GrayText; }
426
+ /* The fill is the one mark, so the underline bar, which would paint the accent inside it, is gone. */
427
+ .ui-nav--tabs.is-underline .ui-nav__tab.is-active::after { display: none; /* motion: still — the bar is not drawn while the fill marks the tab */ }
428
+ }
@@ -137,3 +137,22 @@ a.ui-pager__step,
137
137
  a.ui-pager__page { color: var(--text); }
138
138
  a.ui-pager__step:hover,
139
139
  a.ui-pager__page:hover { color: var(--strong); }
140
+
141
+ /* Forced colours replace every author background, so a selected item takes the system
142
+ pair as its fill. `forced-color-adjust: none` is what keeps the pair from sitting on
143
+ Chromium's Canvas backplate, which would leave the label unreadable. Focus keeps its
144
+ own ring, drawn in CanvasText so the fill is never mistaken for it. */
145
+ @media (forced-colors: active) {
146
+ .ui-pager__page.is-current:not([disabled]) {
147
+ forced-color-adjust: none;
148
+ background: Highlight;
149
+ color: HighlightText;
150
+ --ring: var(--ring-width) solid CanvasText;
151
+ }
152
+ /* A busy pager must not look live, so its current page takes the disabled system colour. */
153
+ .ui-pager__page.is-current[disabled] {
154
+ forced-color-adjust: none;
155
+ background: GrayText;
156
+ color: Canvas; /* muted-ink: state — unavailable control */
157
+ }
158
+ }
@@ -153,11 +153,31 @@
153
153
  }
154
154
  .ui-seg--underline button.is-active::before,
155
155
  .ui-seg--underline button[aria-pressed="true"]::before { opacity: 1; transform: scaleX(1); }
156
- /* The mode repaints an author colour, so the bar comes back as the labels' own
157
- ink. `Highlight` is kept where it is named: one mark the mode cannot flatten. */
156
+ .ui-seg button:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
157
+ .ui-seg button:disabled { /* muted-ink: state — unavailable choice */ color: var(--disabled-ink); cursor: not-allowed; }
158
+
159
+ /* Forced colours replace every author background, so a selected item takes the system
160
+ pair as its fill. `forced-color-adjust: none` is what keeps the pair from sitting on
161
+ Chromium's Canvas backplate, which would leave the label unreadable. Focus keeps its
162
+ own ring, drawn in CanvasText so the fill is never mistaken for it. */
158
163
  @media (forced-colors: active) {
164
+ .ui-seg button.is-active,
165
+ .ui-seg button[aria-pressed="true"],
166
+ .ui-seg button[aria-selected="true"] {
167
+ forced-color-adjust: none;
168
+ background: Highlight;
169
+ color: HighlightText;
170
+ --ring: var(--ring-width) solid CanvasText;
171
+ }
172
+ /* The fill is the one mark, so the underline bar, which would paint the accent inside it, is gone. */
159
173
  .ui-seg--underline button.is-active::before,
160
- .ui-seg--underline button[aria-pressed="true"]::before { background: Highlight; }
174
+ .ui-seg--underline button[aria-pressed="true"]::before,
175
+ .ui-seg--underline button[aria-selected="true"]::before { display: none; /* motion: still — the bar is not drawn while the fill marks the choice */ }
176
+ /* A disabled selected choice must not look live, so it takes the disabled system colour. */
177
+ .ui-seg button.is-active:disabled,
178
+ .ui-seg button[aria-pressed="true"]:disabled,
179
+ .ui-seg button[aria-selected="true"]:disabled {
180
+ background: GrayText;
181
+ color: Canvas; /* muted-ink: state — unavailable choice */
182
+ }
161
183
  }
162
- .ui-seg button:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
163
- .ui-seg button:disabled { /* muted-ink: state — unavailable choice */ color: var(--disabled-ink); cursor: not-allowed; }