@apliteni/apliteni-ui 0.86.0 → 0.87.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/package.json +1 -1
- package/react/dist/index.css +4 -0
- package/react/dist/index.js +5 -4
- package/src/components/filter-bar.js +15 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +1 -1
- package/src/logic/filter-bar.js +77 -1
- package/src/styles/filter-bar.css +16 -0
- package/src/styles/segmented.css +52 -3
- package/src/styles/tap-zone.css +11 -0
package/package.json
CHANGED
package/react/dist/index.css
CHANGED
|
@@ -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);
|
package/react/dist/index.js
CHANGED
|
@@ -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
|
|
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",
|
|
2492
|
+
}, children: /* @__PURE__ */ jsx24(Button, { size: "sm", onClick: () => {
|
|
2492
2493
|
if (!blocked) onClear();
|
|
2493
2494
|
}, children: clearLabel }) })
|
|
2494
2495
|
]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
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';
|
package/src/logic/filter-bar.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/* What a filter chip shows, what it is called,
|
|
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); }
|
package/src/styles/segmented.css
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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"] {
|
|
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; }
|
package/src/styles/tap-zone.css
CHANGED
|
@@ -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
|
|