@apliteni/apliteni-ui 0.113.0 → 0.115.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.
@@ -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.css CHANGED
@@ -36,6 +36,7 @@
36
36
  @import "./styles/filter-bar.css";
37
37
  @import "./styles/pagination.css";
38
38
  @import "./styles/stat.css";
39
+ @import "./styles/chart.css";
39
40
  @import "./styles/empty.css";
40
41
  @import "./styles/callout.css";
41
42
  @import "./styles/code.css";
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,
@@ -86,6 +92,24 @@ export type NumericValueOptions = { value?: string | number | null; unit?: strin
86
92
  export type DeltaValueOptions = { value?: string | null; tone?: 'success' | 'danger' | 'neutral'; basisId?: string; missing?: string };
87
93
  export declare function formatNumericValue(options?: NumericValueOptions): { text: string; unit: string; missing: string | undefined };
88
94
  export declare function formatDeltaValue(options?: DeltaValueOptions): { text: string; className: string; basisId: string | undefined };
95
+
96
+ /** The smallest band a series is drawn in, as a share of its own reach. */
97
+ export declare const CHART_FLOOR: number;
98
+ export type ChartScaleOptions = { ticks?: number; zero?: boolean; floor?: number; nice?: boolean };
99
+ export type ChartScale = { min: number; max: number; step: number; ticks: number[] };
100
+ export declare function chartScale(values: Iterable<number>, options?: ChartScaleOptions): ChartScale;
101
+ export type BridgeStepInput = { label: string; value?: number; kind?: 'total' | 'change' };
102
+ export type BridgeBar<S> = S & { kind: 'total' | 'change'; value: number; from: number; to: number };
103
+ export declare function bridgeWalk<S extends BridgeStepInput>(steps: Iterable<S>): BridgeBar<S>[];
104
+ export type ChartBox = { left: number; right: number; top: number; bottom: number };
105
+ export type PointTargetLimits = {
106
+ floor: number; min?: number; box: ChartBox;
107
+ column: [number, number]; apart?: Iterable<number>;
108
+ };
109
+ export type ChartTarget = { x: number; y: number; w: number; h: number };
110
+ export declare function pointTarget(
111
+ point: { x: number; y: number }, limits: PointTargetLimits,
112
+ ): ChartTarget;
89
113
  export declare const SCORE: Record<'exact' | 'prefix' | 'wordStart' | 'contains' | 'keyword' | 'description' | 'subsequence', number>;
90
114
  export declare const iconNames: string[];
91
115
  export declare const iconCategories: { name: string; names: string[] }[];
package/src/index.js CHANGED
@@ -35,3 +35,4 @@ export { PAGE_SIZES, DEFAULT_PAGE_SIZE } from './logic/pagination.js';
35
35
  export { calloutIcons } from './logic/callout.js';
36
36
  export { formatNumericValue, formatDeltaValue } from './logic/table-values.js';
37
37
  export { accentSwatchStyle } from './logic/accents.js';
38
+ export { CHART_FLOOR, chartScale, bridgeWalk, pointTarget } from './logic/chart.js';
package/src/inline.js CHANGED
@@ -50,6 +50,7 @@ export const styles = {
50
50
  filterBar: read('styles/filter-bar.css'),
51
51
  pagination: read('styles/pagination.css'),
52
52
  stat: read('styles/stat.css'),
53
+ chart: read('styles/chart.css'),
53
54
  empty: read('styles/empty.css'),
54
55
  callout: read('styles/callout.css'),
55
56
  code: read('styles/code.css'),
@@ -86,6 +87,7 @@ export const cssText = [
86
87
  styles.filterBar,
87
88
  styles.pagination,
88
89
  styles.stat,
90
+ styles.chart,
89
91
  styles.empty,
90
92
  styles.callout,
91
93
  styles.code,
@@ -0,0 +1,143 @@
1
+ // Chart arithmetic, shared by the React Chart and by anything that draws the
2
+ // same shapes without it. No DOM, no pixels: a scale and a walk, both pure.
3
+ // why: docs/components.md#react-charts
4
+
5
+ /** The steps a whole-unit axis may take, per decade, smallest first. 2.5 is
6
+ * among them — 25000 is a whole step and the one that turns a four-tick axis
7
+ * into four ticks — but it is taken only where it lands on a whole number, so
8
+ * nothing below the units decade can put 2.5 on an axis. */
9
+ const STEPS = [1, 2, 2.5, 5, 10];
10
+
11
+ /** The smallest band a series is drawn in, as a share of its own reach. Below
12
+ * this, a series that barely moves would be stretched into a cliff. */
13
+ export const CHART_FLOOR = 0.25;
14
+
15
+ const round = (n) => Math.round(n * 1e6) / 1e6;
16
+
17
+ /** The smallest nice step at or above `raw` that is a whole unit. */
18
+ function niceStep(raw) {
19
+ if (!(raw > 0)) return 1;
20
+ const decade = 10 ** Math.floor(Math.log10(raw));
21
+ for (const s of STEPS) {
22
+ const step = round(s * decade);
23
+ if (step >= raw - 1e-9 && step >= 1 && Number.isInteger(step)) return step;
24
+ }
25
+ return Math.max(1, Math.ceil(raw));
26
+ }
27
+
28
+ /**
29
+ * The band a series is drawn in, and the ticks written beside it.
30
+ *
31
+ * @param {Iterable<number>} values every number that has to fit
32
+ * @param {object} [options]
33
+ * @param {number} [options.ticks] how many steps to aim for
34
+ * @param {boolean} [options.zero] keep the zero line inside the band — true for
35
+ * bars, which are read from zero, false for a line read against itself
36
+ * @param {number} [options.floor] the smallest band, as a share of the series'
37
+ * own reach; `CHART_FLOOR` for a sparkline
38
+ * @param {boolean} [options.nice] round the band out to whole steps and return
39
+ * them; false for a chart with no axis
40
+ * @returns {{min: number, max: number, step: number, ticks: number[]}}
41
+ */
42
+ export function chartScale(values, { ticks = 4, zero = true, floor = 0, nice = true } = {}) {
43
+ const finite = [...values].filter((v) => Number.isFinite(v));
44
+ if (finite.length === 0) return { min: 0, max: 1, step: 1, ticks: [0, 1] };
45
+
46
+ let min = Math.min(...finite);
47
+ let max = Math.max(...finite);
48
+ if (zero) { min = Math.min(min, 0); max = Math.max(max, 0); }
49
+
50
+ const reach = Math.max(Math.abs(min), Math.abs(max)) || 1;
51
+ const want = reach * floor;
52
+ if (max - min < want) {
53
+ const pad = (want - (max - min)) / 2;
54
+ min -= pad;
55
+ max += pad;
56
+ if (zero) { min = Math.min(min, 0); max = Math.max(max, 0); }
57
+ }
58
+ if (min === max) { min -= 0.5; max += 0.5; }
59
+
60
+ if (!nice) return { min: round(min), max: round(max), step: 0, ticks: [] };
61
+
62
+ const step = niceStep((max - min) / Math.max(ticks, 1));
63
+ min = Math.floor(round(min / step)) * step;
64
+ max = Math.ceil(round(max / step)) * step;
65
+ const out = [];
66
+ for (let t = min; t <= max + 1e-9; t += step) out.push(round(t));
67
+ return { min: round(min), max: round(max), step, ticks: out };
68
+ }
69
+
70
+ /**
71
+ * One period walked from a starting total through its components to a result.
72
+ *
73
+ * The first step is a total unless it says otherwise; every later step is a
74
+ * component added to the running total. A step marked `total` with no value of
75
+ * its own takes the running total, which is how the closing column is written
76
+ * without repeating a sum the caller already gave piece by piece.
77
+ *
78
+ * @param {Iterable<{label: string, value?: number, kind?: 'total'|'change'}>} steps
79
+ * @returns {Array<{label: string, kind: 'total'|'change', value: number, from: number, to: number}>}
80
+ * each column's own amount and the two ends of the bar that draws it
81
+ */
82
+ export function bridgeWalk(steps) {
83
+ let running = 0;
84
+ return [...steps].map((step, index) => {
85
+ const kind = step.kind || (index === 0 ? 'total' : 'change');
86
+ if (kind === 'total') {
87
+ const value = Number.isFinite(step.value) ? step.value : running;
88
+ running = value;
89
+ return { ...step, kind, value: round(value), from: 0, to: round(value) };
90
+ }
91
+ const value = Number.isFinite(step.value) ? step.value : 0;
92
+ const from = running;
93
+ running += value;
94
+ return { ...step, kind, value: round(value), from: round(from), to: round(running) };
95
+ });
96
+ }
97
+
98
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
99
+
100
+ /**
101
+ * The pointer target over one drawn point, as a rectangle.
102
+ *
103
+ * A point is a mark a pointer has to reach rather than a control, so its target
104
+ * is a transparent box laid over it. The box grows to the floor this pointer
105
+ * asks for, stops at half the clear space to the nearest point on each side,
106
+ * and is then held inside the box the chart draws in — a target that reached
107
+ * past the chart's edge would be clipped there, and a nominal 44 that a
108
+ * drawing clips to 19 is not a target. `min` is the floor it keeps where the
109
+ * column has room. A narrower column owns the whole column instead, so no point
110
+ * answers for the period beside it.
111
+ * why: guidelines/accessibility-floor.md#tap-target-on-a-phone
112
+ *
113
+ * @param {{x: number, y: number}} point the point's centre
114
+ * @param {object} limits
115
+ * @param {number} limits.floor the target this pointer asks for, each way
116
+ * @param {number} [limits.min] the smallest target, where the column has room
117
+ * @param {{left: number, right: number, top: number, bottom: number}} limits.box
118
+ * the chart's own drawn box, which nothing may reach past
119
+ * @param {[number, number]} limits.column the point's own column, left and right
120
+ * @param {Iterable<number>} [limits.apart] the centre, down, of every other
121
+ * point in the same column
122
+ * @returns {{x: number, y: number, w: number, h: number}}
123
+ */
124
+ export function pointTarget({ x, y }, { floor, min = floor, box, column, apart = [] }) {
125
+ const fit = (centre, [outerLo, outerHi], [spaceLo, spaceHi], neighbours) => {
126
+ let lo = spaceLo;
127
+ let hi = spaceHi;
128
+ for (const n of neighbours) {
129
+ const mid = (centre + n) / 2;
130
+ if (n < centre) lo = Math.max(lo, mid);
131
+ if (n > centre) hi = Math.min(hi, mid);
132
+ }
133
+ const size = Math.min(floor, Math.max(0, hi - lo));
134
+ // Centred on the point, then held inside the space it may own, then inside
135
+ // the chart's box. The box is last so the point on the chart's own edge
136
+ // keeps a whole target instead of half of one.
137
+ const owned = clamp(centre - size / 2, lo, Math.max(lo, hi - size));
138
+ return { at: clamp(owned, outerLo, Math.max(outerLo, outerHi - size)), size };
139
+ };
140
+ const across = fit(x, [box.left, box.right], column, []);
141
+ const down = fit(y, [box.top, box.bottom], [box.top, box.bottom], apart);
142
+ return { x: across.at, y: down.at, w: across.size, h: down.size };
143
+ }
@@ -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
  }