@apliteni/apliteni-ui 0.23.3 → 0.24.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/README.md +6 -0
- package/package.json +1 -1
- package/src/components/dropdown.js +129 -7
- package/src/styles/base.css +1 -1
- package/src/styles/dropdown.css +30 -1
- package/src/tokens/accents.css +18 -3
- package/src/tokens/tokens.css +0 -2
package/README.md
CHANGED
|
@@ -132,6 +132,12 @@ Each accent re-points only the accent family (`--accent`, `--purple*`, `--glow-p
|
|
|
132
132
|
stay put — so **every accent works in both themes** and every component follows with no
|
|
133
133
|
component-level change.
|
|
134
134
|
|
|
135
|
+
Both attributes are overrides, not requirements: with neither present the kit paints dark
|
|
136
|
+
Nebula, and `data-accent` alone paints that accent on the dark theme. An absent `data-theme`
|
|
137
|
+
is *not* "follow the system" — the kit ships no `prefers-color-scheme` rule, so a host that
|
|
138
|
+
wants the OS preference resolves it in JS and stamps the attribute. See
|
|
139
|
+
[`docs/library.md`](./docs/library.md#an-absent-attribute-means-dark).
|
|
140
|
+
|
|
135
141
|
Shipped accents: **Nebula** (purple, default), **Phoenix** (ember), **Ocean** (azure),
|
|
136
142
|
**Emerald** (jade). Runtime helpers: `applyTheme('light')` / `applyAccent('phoenix')`
|
|
137
143
|
(both persist to `localStorage`); or the `accentPicker()` component wired by `wireTopbar()`.
|
package/package.json
CHANGED
|
@@ -76,6 +76,8 @@ function ddBody({ items, sections }, listbox) {
|
|
|
76
76
|
* @param {string} [o.header] raw HTML pinned to the top of the panel
|
|
77
77
|
* @param {string} [o.footer] raw HTML pinned to the bottom of the panel
|
|
78
78
|
* @param {string} [o.align] 'start' (default) | 'end' — the edge the panel hugs
|
|
79
|
+
* @param {string} [o.direction] 'down' (default) | 'up' | 'auto' — the way it opens
|
|
80
|
+
* @param {boolean} [o.portal] mount the panel on <body>, for a clipping or sticky ancestor
|
|
79
81
|
* @param {boolean|number} [o.scroll] true, or a maxHeight in px, to cap and scroll
|
|
80
82
|
* @param {boolean} [o.open] render already-open (handy for screenshots)
|
|
81
83
|
* @param {string} [o.ariaLabel] accessible name for the panel and trigger
|
|
@@ -84,7 +86,8 @@ function ddBody({ items, sections }, listbox) {
|
|
|
84
86
|
export function dropdown({
|
|
85
87
|
label, value, placeholder = 'Select…', variant, items, sections,
|
|
86
88
|
header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
|
|
87
|
-
align = 'start',
|
|
89
|
+
align = 'start', direction = 'down', portal = false,
|
|
90
|
+
scroll = false, open = false, ariaLabel, id, panelClass = '',
|
|
88
91
|
} = {}) {
|
|
89
92
|
const flat = sections ? sections.flatMap((s) => s.items || []) : (items || []);
|
|
90
93
|
const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
|
|
@@ -104,15 +107,30 @@ export function dropdown({
|
|
|
104
107
|
ariaLabel && triggerContent != null ? `aria-label="${esc(ariaLabel)}"` : '',
|
|
105
108
|
].filter(Boolean).join(' ');
|
|
106
109
|
|
|
110
|
+
// `is-open` beside `open` because the descendant selector the panel normally
|
|
111
|
+
// takes its open state from stops matching once wireDropdown() portals it.
|
|
107
112
|
const panelAttrs = [
|
|
108
|
-
`class="${cx(
|
|
113
|
+
`class="${cx(
|
|
114
|
+
'ui-dropdown__panel',
|
|
115
|
+
align === 'end' && 'is-end',
|
|
116
|
+
direction === 'up' && 'is-up',
|
|
117
|
+
scroll && 'is-scroll',
|
|
118
|
+
portal && 'ui-dropdown__panel--portal',
|
|
119
|
+
portal && open && 'is-open',
|
|
120
|
+
panelClass,
|
|
121
|
+
)}"`,
|
|
109
122
|
'data-dropdown-panel',
|
|
110
123
|
`role="${listRole}"`,
|
|
111
124
|
ariaLabel ? `aria-label="${esc(ariaLabel)}"` : '',
|
|
112
125
|
scroll && scroll !== true ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
|
|
113
126
|
].filter(Boolean).join(' ');
|
|
114
127
|
|
|
115
|
-
|
|
128
|
+
const ddAttrs = 'data-dropdown'
|
|
129
|
+
+ (isSelect ? ' data-dropdown-select' : '')
|
|
130
|
+
+ (direction === 'auto' ? ' data-dropdown-direction="auto"' : '')
|
|
131
|
+
+ (portal ? ' data-dropdown-portal' : '');
|
|
132
|
+
|
|
133
|
+
return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
|
|
116
134
|
`<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
|
|
117
135
|
`<div ${panelAttrs}>${header}${ddBody({ items, sections }, isSelect)}${footer}</div>` +
|
|
118
136
|
`</div>`;
|
|
@@ -129,16 +147,74 @@ export function dropdown({
|
|
|
129
147
|
// document. Safe to call repeatedly (e.g. Storybook re-renders).
|
|
130
148
|
let _ddGlobalWired = false;
|
|
131
149
|
|
|
150
|
+
// The trigger-to-panel offset is --ui-dropdown-gap in src/styles/dropdown.css.
|
|
151
|
+
// This is the fallback for a document that has not loaded the sheet;
|
|
152
|
+
// src/components/dropdown.test.js pins the two to each other.
|
|
153
|
+
const DD_GAP = 9;
|
|
154
|
+
|
|
155
|
+
// A portalled panel is no longer a descendant of its container, so everything
|
|
156
|
+
// below asks the container for its panel rather than querying inside it.
|
|
157
|
+
const ddPanelOf = (dd) => dd.__ddPanel || dd.querySelector('[data-dropdown-panel]');
|
|
158
|
+
|
|
159
|
+
function ddGap(panel) {
|
|
160
|
+
const declared = parseFloat(getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
|
|
161
|
+
return Number.isFinite(declared) ? declared : DD_GAP;
|
|
162
|
+
}
|
|
163
|
+
|
|
132
164
|
function ddItemsOf(dd) {
|
|
133
|
-
const panel = dd
|
|
165
|
+
const panel = ddPanelOf(dd);
|
|
134
166
|
if (!panel) return [];
|
|
135
167
|
return Array.from(panel.querySelectorAll('[data-dd-item]'))
|
|
136
168
|
.filter((el) => el.getAttribute('aria-disabled') !== 'true');
|
|
137
169
|
}
|
|
138
170
|
|
|
171
|
+
// `auto` is the only direction the wiring decides; `up` and the default are the
|
|
172
|
+
// panel's own class, set once at render. Flip only when below is too tight AND
|
|
173
|
+
// above is roomier, so a panel that fits nowhere still opens the way it says.
|
|
174
|
+
function ddResolveDirection(dd, panel) {
|
|
175
|
+
if (dd.getAttribute('data-dropdown-direction') !== 'auto') return;
|
|
176
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
177
|
+
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
|
|
178
|
+
const t = trigger.getBoundingClientRect();
|
|
179
|
+
const below = window.innerHeight - t.bottom;
|
|
180
|
+
panel.classList.toggle('is-up', below < panel.offsetHeight + ddGap(panel) && t.top > below);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Nothing lays a portalled panel out any more, so these four inline values are
|
|
184
|
+
// its layout. Inline, so no rule in any sheet can pin the opposite edge.
|
|
185
|
+
function positionPortalPanel(dd, panel) {
|
|
186
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
187
|
+
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
|
|
188
|
+
const t = trigger.getBoundingClientRect();
|
|
189
|
+
const gap = ddGap(panel);
|
|
190
|
+
const s = panel.style;
|
|
191
|
+
if (panel.classList.contains('is-up')) {
|
|
192
|
+
s.top = 'auto';
|
|
193
|
+
s.bottom = `${window.innerHeight - t.top + gap}px`;
|
|
194
|
+
} else {
|
|
195
|
+
s.bottom = 'auto';
|
|
196
|
+
s.top = `${t.bottom + gap}px`;
|
|
197
|
+
}
|
|
198
|
+
if (panel.classList.contains('is-end')) {
|
|
199
|
+
s.left = 'auto';
|
|
200
|
+
s.right = `${window.innerWidth - t.right}px`;
|
|
201
|
+
} else {
|
|
202
|
+
s.right = 'auto';
|
|
203
|
+
s.left = `${t.left}px`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A panel left on <body> outlives the container that owned it — a re-render
|
|
208
|
+
// replaces the container and the old panel has nothing pointing at it.
|
|
209
|
+
function sweepOrphanPanels() {
|
|
210
|
+
document.querySelectorAll('body > [data-dropdown-panel][data-dropdown-portal]')
|
|
211
|
+
.forEach((p) => { if (!p.__ddOwner || !p.__ddOwner.isConnected) p.remove(); });
|
|
212
|
+
}
|
|
213
|
+
|
|
139
214
|
function closeDropdown(dd) {
|
|
140
215
|
if (!dd.classList.contains('open')) return;
|
|
141
216
|
dd.classList.remove('open');
|
|
217
|
+
ddPanelOf(dd)?.classList.remove('is-open');
|
|
142
218
|
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
143
219
|
}
|
|
144
220
|
|
|
@@ -148,6 +224,11 @@ function closeAllDropdowns(except) {
|
|
|
148
224
|
|
|
149
225
|
function openDropdown(dd, focusIdx) {
|
|
150
226
|
closeAllDropdowns(dd);
|
|
227
|
+
const panel = ddPanelOf(dd);
|
|
228
|
+
if (panel) {
|
|
229
|
+
ddResolveDirection(dd, panel);
|
|
230
|
+
if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
|
|
231
|
+
}
|
|
151
232
|
dd.classList.add('open');
|
|
152
233
|
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
|
|
153
234
|
if (focusIdx != null) {
|
|
@@ -161,7 +242,7 @@ function openDropdown(dd, focusIdx) {
|
|
|
161
242
|
function selectOption(dd, item) {
|
|
162
243
|
if (!dd.hasAttribute('data-dropdown-select')) return;
|
|
163
244
|
ddItemsOf(dd).forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
|
|
164
|
-
dd
|
|
245
|
+
ddPanelOf(dd)?.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
|
|
165
246
|
item.classList.add('is-selected');
|
|
166
247
|
const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
|
|
167
248
|
const label = item.querySelector('.ui-dropdown__label');
|
|
@@ -177,6 +258,23 @@ export function wireDropdown(root = document) {
|
|
|
177
258
|
const panel = dd.querySelector('[data-dropdown-panel]');
|
|
178
259
|
if (!trigger) return;
|
|
179
260
|
|
|
261
|
+
// Portal: lift the panel onto <body>. An ancestor whose overflow is not
|
|
262
|
+
// `visible` clips it on both axes, and one that is `position: sticky` opens
|
|
263
|
+
// a stacking context whatever z-index the panel carries — the app rail is
|
|
264
|
+
// both at once. why: docs/specification.md#the-dropdown-panel
|
|
265
|
+
if (panel && dd.hasAttribute('data-dropdown-portal')) {
|
|
266
|
+
sweepOrphanPanels();
|
|
267
|
+
panel.setAttribute('data-dropdown-portal', '');
|
|
268
|
+
panel.__ddOwner = dd;
|
|
269
|
+
dd.__ddPanel = panel;
|
|
270
|
+
document.body.appendChild(panel);
|
|
271
|
+
if (dd.classList.contains('open')) {
|
|
272
|
+
ddResolveDirection(dd, panel);
|
|
273
|
+
positionPortalPanel(dd, panel);
|
|
274
|
+
panel.classList.add('is-open');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
180
278
|
trigger.addEventListener('click', (e) => {
|
|
181
279
|
e.stopPropagation();
|
|
182
280
|
if (dd.classList.contains('open')) closeDropdown(dd);
|
|
@@ -196,7 +294,7 @@ export function wireDropdown(root = document) {
|
|
|
196
294
|
});
|
|
197
295
|
}
|
|
198
296
|
|
|
199
|
-
|
|
297
|
+
const onKeydown = (e) => {
|
|
200
298
|
const open = dd.classList.contains('open');
|
|
201
299
|
const onTrigger = e.target === trigger;
|
|
202
300
|
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && (onTrigger || open)) {
|
|
@@ -217,7 +315,22 @@ export function wireDropdown(root = document) {
|
|
|
217
315
|
} else if (e.key === 'Tab' && open) {
|
|
218
316
|
closeDropdown(dd);
|
|
219
317
|
}
|
|
220
|
-
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
dd.addEventListener('keydown', onKeydown);
|
|
321
|
+
if (dd.__ddPanel) {
|
|
322
|
+
// A portalled panel is no longer inside the container, so a keystroke on
|
|
323
|
+
// an item never bubbles to it. Bound here only, or it would fire twice.
|
|
324
|
+
dd.__ddPanel.addEventListener('keydown', onKeydown);
|
|
325
|
+
// It is also placed once, on open, and a trigger whose box changes after
|
|
326
|
+
// that — a webfont arriving, a longer label — leaves it adrift. Scroll
|
|
327
|
+
// and resize do not see a reflow; this does.
|
|
328
|
+
if (typeof ResizeObserver === 'function') {
|
|
329
|
+
new ResizeObserver(() => {
|
|
330
|
+
if (dd.classList.contains('open')) positionPortalPanel(dd, dd.__ddPanel);
|
|
331
|
+
}).observe(trigger);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
221
334
|
});
|
|
222
335
|
|
|
223
336
|
if (!_ddGlobalWired) {
|
|
@@ -228,5 +341,14 @@ export function wireDropdown(root = document) {
|
|
|
228
341
|
const open = document.querySelector('[data-dropdown].open');
|
|
229
342
|
if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
|
|
230
343
|
});
|
|
344
|
+
// Viewport coordinates go stale the moment anything scrolls. Capture, so a
|
|
345
|
+
// scroll inside the rail the panel was lifted out of counts too.
|
|
346
|
+
const reposition = () => {
|
|
347
|
+
document.querySelectorAll('[data-dropdown].open').forEach((dd) => {
|
|
348
|
+
if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
window.addEventListener('scroll', reposition, true);
|
|
352
|
+
window.addEventListener('resize', reposition);
|
|
231
353
|
}
|
|
232
354
|
}
|
package/src/styles/base.css
CHANGED
package/src/styles/dropdown.css
CHANGED
|
@@ -41,8 +41,12 @@
|
|
|
41
41
|
|
|
42
42
|
/* Panel — the popover surface */
|
|
43
43
|
.ui-dropdown__panel {
|
|
44
|
+
/* One number for the trigger-to-panel offset, read by both edges here and by
|
|
45
|
+
the portal's JS, so an upward panel cannot drift from a downward one.
|
|
46
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
47
|
+
--ui-dropdown-gap: 9px;
|
|
44
48
|
position: absolute;
|
|
45
|
-
top: calc(100% +
|
|
49
|
+
top: calc(100% + var(--ui-dropdown-gap));
|
|
46
50
|
left: 0;
|
|
47
51
|
min-width: 240px;
|
|
48
52
|
background: var(--surface-2);
|
|
@@ -63,8 +67,33 @@
|
|
|
63
67
|
}
|
|
64
68
|
.ui-dropdown__panel.is-end { left: auto; right: 0; }
|
|
65
69
|
.ui-dropdown__panel.is-scroll { max-height: 300px; overflow-y: auto; }
|
|
70
|
+
|
|
71
|
+
/* Opening upward. The kit releases its own `top` here so that nothing outside
|
|
72
|
+
the kit has to: an absolute box with both edges pinned is stretched between
|
|
73
|
+
them, and a consumer who set `bottom` and left our `top` standing got a
|
|
74
|
+
fourteen-pixel panel. The entry travel is mirrored too, so the panel arrives
|
|
75
|
+
from the trigger whichever way it opens.
|
|
76
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
77
|
+
.ui-dropdown__panel.is-up {
|
|
78
|
+
top: auto;
|
|
79
|
+
bottom: calc(100% + var(--ui-dropdown-gap));
|
|
80
|
+
transform: translateY(6px);
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
.ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
67
84
|
|
|
85
|
+
/* Portalled panel — wireDropdown() moves it onto <body>, clear of every
|
|
86
|
+
ancestor's overflow and every ancestor's stacking context. The descendant
|
|
87
|
+
selector above stops matching the moment it leaves the trigger's subtree, so
|
|
88
|
+
its open state is a class of its own; the wiring writes the viewport
|
|
89
|
+
coordinates inline, and these `auto`s are what it writes over.
|
|
90
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
91
|
+
.ui-dropdown__panel--portal {
|
|
92
|
+
position: fixed;
|
|
93
|
+
top: auto; right: auto; bottom: auto; left: auto;
|
|
94
|
+
}
|
|
95
|
+
.ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
96
|
+
|
|
68
97
|
/* Item row — [icon] [main: label + desc] [badge] [tick] */
|
|
69
98
|
.ui-dropdown__item {
|
|
70
99
|
display: flex;
|
package/src/tokens/accents.css
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* Accent sub-themes — an orthogonal `data-accent` dimension on top of
|
|
2
|
-
* `data-theme`.
|
|
2
|
+
* `data-theme`. On <html>, with or without a theme:
|
|
3
3
|
*
|
|
4
4
|
* <html data-theme="dark" data-accent="phoenix">
|
|
5
|
+
* <html data-accent="phoenix"> <!-- default theme, same accent -->
|
|
5
6
|
*
|
|
6
7
|
* Each sub-theme only re-points the accent family. Surfaces, text and signal
|
|
7
8
|
* colours (green = live, pink = danger) stay put, so every accent works in
|
|
@@ -10,12 +11,24 @@
|
|
|
10
11
|
* --ring is NOT here: it is declared once in tokens.css as var(--accent), so
|
|
11
12
|
* re-pointing --accent re-points the focus ring too.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* Every dark cell is a two-line selector list — a bare `:root[data-accent]`
|
|
15
|
+
* above the stamped form — which is the shape tokens.css gives the dark theme
|
|
16
|
+
* one file earlier, and for the same reason: the default theme needs no
|
|
17
|
+
* attribute, so neither may the accent. Written stamped-only, `data-accent`
|
|
18
|
+
* paints nothing at all in the state hosts use to mean "follow the system",
|
|
19
|
+
* where data-theme is simply absent. See issue #250.
|
|
20
|
+
*
|
|
21
|
+
* That bare line ties on specificity with :root[data-theme="light"] and wins on
|
|
22
|
+
* import order, so LIGHT is what the cascade has to be careful about: each light
|
|
23
|
+
* cell carries three attributes to out-specify it, and declares the same
|
|
24
|
+
* properties as its dark twin so none of the dark ramp leaks past it. Adding a
|
|
25
|
+
* property to one half of a pair and not the other is what breaks that, and
|
|
26
|
+
* stories/accent-without-theme.test.js is the gate that says so.
|
|
15
27
|
*
|
|
16
28
|
* why: docs/specification.md#the-focus-ring */
|
|
17
29
|
|
|
18
30
|
/* ---- Phoenix — ember / rising fire (the strategy's namesake) ------------- */
|
|
31
|
+
:root[data-accent="phoenix"],
|
|
19
32
|
:root[data-theme="dark"][data-accent="phoenix"] {
|
|
20
33
|
--purple: #e0531f;
|
|
21
34
|
--purple-light: #ff8a5c;
|
|
@@ -45,6 +58,7 @@
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
/* ---- Ocean — azure ------------------------------------------------------- */
|
|
61
|
+
:root[data-accent="ocean"],
|
|
48
62
|
:root[data-theme="dark"][data-accent="ocean"] {
|
|
49
63
|
--purple: #1f7fe0;
|
|
50
64
|
--purple-light: #5ab0ff;
|
|
@@ -77,6 +91,7 @@
|
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
/* ---- Emerald — jade ------------------------------------------------------ */
|
|
94
|
+
:root[data-accent="emerald"],
|
|
80
95
|
:root[data-theme="dark"][data-accent="emerald"] {
|
|
81
96
|
--purple: #0fa876;
|
|
82
97
|
--purple-light: #3ad9a0;
|
package/src/tokens/tokens.css
CHANGED
|
@@ -157,7 +157,6 @@
|
|
|
157
157
|
--accent: #b479ff;
|
|
158
158
|
--accent-strong: #7c3aed; /* button bg — white text clears WCAG AA at 5.70:1 */
|
|
159
159
|
--accent-contrast: #ffffff;
|
|
160
|
-
--ink: #e9e7f0;
|
|
161
160
|
|
|
162
161
|
/* Ink that reads on a SIGNAL colour once that colour becomes a fill — the glyph in a
|
|
163
162
|
success circle, or on a toast's status circle. One near-black ink clears all five in
|
|
@@ -267,7 +266,6 @@
|
|
|
267
266
|
--accent: #6a2dcc;
|
|
268
267
|
--accent-strong: #6a2dcc; /* already dark enough for white text */
|
|
269
268
|
--accent-contrast: #ffffff;
|
|
270
|
-
--ink: #1e232b;
|
|
271
269
|
|
|
272
270
|
--signal-contrast: #0c0c0c;
|
|
273
271
|
--danger-contrast: #ffffff;
|