@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.
- package/guidelines/labels-and-titles.md +18 -6
- package/package.json +1 -1
- package/react/README.md +69 -1
- package/react/dist/index.d.ts +92 -2
- package/react/dist/index.js +789 -49
- package/src/components/dropdown.js +53 -19
- package/src/components/filter-bar.js +6 -3
- package/src/index.css +1 -0
- package/src/index.d.ts +30 -6
- package/src/index.js +1 -0
- package/src/inline.js +2 -0
- package/src/logic/chart.js +143 -0
- package/src/logic/filter-bar.js +34 -16
- package/src/styles/chart.css +282 -0
- package/src/styles/dropdown.css +2 -2
- package/src/styles/filter-bar.css +55 -16
- package/src/styles/topbar.css +1 -1
|
@@ -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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
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
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
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
|
+
}
|
package/src/logic/filter-bar.js
CHANGED
|
@@ -67,37 +67,55 @@ function stopsAround(host) {
|
|
|
67
67
|
...order.filter(el => host.compareDocumentPosition(el) & PRECEDING).reverse()];
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** The
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
83
|
-
* itself
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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 =
|
|
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
|
|
118
|
+
for (const stop of focusStops(host)) {
|
|
101
119
|
stop.focus();
|
|
102
120
|
if (doc.activeElement === stop) return stop;
|
|
103
121
|
}
|