actual-css 0.1.0 → 0.2.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/CHANGELOG.md +53 -1
- package/README.md +2 -0
- package/dist/actual.css +356 -124
- package/dist/actual.js +2 -2
- package/dist/actual.js.map +13 -13
- package/dist/actual.min.css +1 -1
- package/docs/README.md +2 -0
- package/docs/components.md +2 -2
- package/docs/design-notes/browser-support.md +9 -0
- package/docs/design-notes/enhancement-contract.md +104 -0
- package/docs/design-notes/naming.md +1 -0
- package/docs/design-notes/widget-primitives.md +83 -0
- package/docs/forms.md +4 -4
- package/docs/javascript.md +104 -34
- package/docs/patterns.md +89 -0
- package/docs/ui.md +22 -30
- package/llms.txt +6 -0
- package/package.json +14 -8
- package/src/css/base.css +0 -4
- package/src/css/components/alert.css +88 -2
- package/src/css/components/badge.css +30 -7
- package/src/css/components/busy.css +1 -0
- package/src/css/components/button.css +6 -8
- package/src/css/components/flyout.css +0 -1
- package/src/css/components/join.css +1 -2
- package/src/css/components/scrollspy.css +2 -2
- package/src/css/components/spinner.css +4 -4
- package/src/css/components/status-bar.css +3 -4
- package/src/css/components/tab.css +1 -0
- package/src/css/focus.css +37 -0
- package/src/css/forms/choice-card.css +1 -2
- package/src/css/forms/choice.css +1 -2
- package/src/css/forms/control.css +1 -10
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +8 -32
- package/src/css/forms/range.css +95 -0
- package/src/css/forms/switch.css +1 -2
- package/src/css/optional/scroller.css +6 -0
- package/src/css/theme.css +9 -9
- package/src/css/themes/edge.css +3 -27
- package/src/css/tokens.css +9 -3
- package/src/js/context-menu.js +21 -34
- package/src/js/enhance.js +26 -0
- package/src/js/flyout.js +12 -19
- package/src/js/menu.js +15 -14
- package/src/js/scrollspy.js +125 -62
- package/src/js/selectors.js +14 -24
- package/src/js/status.js +13 -13
- package/src/js/surface.js +57 -12
- package/src/js/tab.js +10 -13
- package/src/js/validation.js +11 -43
package/src/css/themes/edge.css
CHANGED
|
@@ -79,38 +79,14 @@
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/* Focus recipe override — the base recipe (control.css) flips --control-border
|
|
82
|
-
to --focus
|
|
83
|
-
|
|
84
|
-
the
|
|
82
|
+
to --focus and draws a box-shadow ring. Edge drops the border-color flip and
|
|
83
|
+
thickens the accent edge instead so the intent-coloured lower edge stays
|
|
84
|
+
dominant; the focus ring and its 3px width remain the same as the default. */
|
|
85
85
|
[data-theme="edge"] :is(.input, .textarea, .select):focus-visible {
|
|
86
86
|
--control-border: var(--border);
|
|
87
87
|
--control-border-block-end-width: 3px;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/* The shared 2px-solid/2px-offset outline reads as a second ring on top of
|
|
91
|
-
the accent edge (inputs) or the box-shadow ring (buttons). Edge thins both
|
|
92
|
-
to the border width and tightens the offset so the outline hugs the
|
|
93
|
-
control instead of floating a second halo around it. Gated outside
|
|
94
|
-
forced-colors so that mode's Highlight-colored --focus-outline (theme.css)
|
|
95
|
-
stays in charge for inputs — a direct property on the element would
|
|
96
|
-
otherwise out-specificity that fallback regardless of the media condition
|
|
97
|
-
it was declared under. Buttons don't consume --focus-outline (they compute
|
|
98
|
-
their own outline so it can use --btn-focus-color's intent awareness), so
|
|
99
|
-
they get their own local width token, --btn-focus-outline-width. */
|
|
100
|
-
@media not (forced-colors: active) {
|
|
101
|
-
[data-theme="edge"] :is(.input, .textarea, .select, .btn) {
|
|
102
|
-
--focus-outline-offset: 1px;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
[data-theme="edge"] :is(.input, .textarea, .select) {
|
|
106
|
-
--focus-outline: var(--border-width) solid var(--focus);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
[data-theme="edge"] .btn {
|
|
110
|
-
--btn-focus-outline-width: var(--border-width);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
90
|
@supports (color: light-dark(red, blue)) {
|
|
115
91
|
[data-theme="edge"] {
|
|
116
92
|
--primary: light-dark(hsl(224 72% 42%), hsl(220 80% 76%));
|
package/src/css/tokens.css
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
/* Shape */
|
|
30
30
|
--border-width: 1px;
|
|
31
31
|
--radius-full: 9999px;
|
|
32
|
-
--shadow: 0
|
|
32
|
+
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.04);
|
|
33
|
+
--shadow: 0 1px 3px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
|
|
33
34
|
--shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);
|
|
34
35
|
|
|
35
36
|
/* Motion — see reset.css for the prefers-reduced-motion hard reset.
|
|
@@ -43,8 +44,10 @@
|
|
|
43
44
|
--ease: ease;
|
|
44
45
|
|
|
45
46
|
/* Focus ring — shared by every interactive component's :focus-visible */
|
|
46
|
-
--focus-outline:
|
|
47
|
+
--focus-outline-color: currentColor;
|
|
48
|
+
--focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
|
|
47
49
|
--focus-outline-offset: 2px;
|
|
50
|
+
--focus-ring-width: 3px;
|
|
48
51
|
|
|
49
52
|
/* Status dot positioning — used by .avatar > .badge:empty. The offset is
|
|
50
53
|
the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
|
|
@@ -111,7 +114,10 @@
|
|
|
111
114
|
|
|
112
115
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
113
116
|
:root {
|
|
114
|
-
--shadow: 0
|
|
117
|
+
--shadow-xs: 0 1px 2px color-mix(in oklch, var(--shadow-color) 4%, transparent);
|
|
118
|
+
--shadow:
|
|
119
|
+
0 1px 3px color-mix(in oklch, var(--shadow-color) 5%, transparent),
|
|
120
|
+
0 4px 12px color-mix(in oklch, var(--shadow-color) 4%, transparent);
|
|
115
121
|
--shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
|
|
116
122
|
--backdrop-fill: color-mix(
|
|
117
123
|
in oklch,
|
package/src/js/context-menu.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import enhance from "./enhance.js";
|
|
2
2
|
import { EVENTS } from "./events.js";
|
|
3
3
|
import { focusFirstMenuItem, hasMenuItems } from "./menu.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
closeSurface,
|
|
7
|
-
disconnectSurface,
|
|
8
|
-
isSurfaceOpen,
|
|
9
|
-
openSurface,
|
|
10
|
-
prepareSurface,
|
|
11
|
-
} from "./surface.js";
|
|
4
|
+
import { closeSurface, isSurfaceOpen, openSurface, prepareSurface } from "./surface.js";
|
|
12
5
|
|
|
13
6
|
const LONG_PRESS_MS = 450;
|
|
14
7
|
const MOVE_TOLERANCE = 10;
|
|
15
|
-
const CONTEXT_MENU_SELECTOR =
|
|
8
|
+
const CONTEXT_MENU_SELECTOR = 'menu, [role="menu"]';
|
|
16
9
|
const CONTEXT_TARGET_SELECTOR = "[data-context-menu]";
|
|
17
10
|
const contextMap = new WeakMap();
|
|
18
11
|
const contextByMenu = new WeakMap();
|
|
@@ -253,33 +246,24 @@ function disconnectContextTarget(target) {
|
|
|
253
246
|
if (!state) return;
|
|
254
247
|
clearLongPress(state);
|
|
255
248
|
state.controller.abort();
|
|
256
|
-
if (contextFor(state.menu)?.context === target)
|
|
249
|
+
if (contextFor(state.menu)?.context === target) {
|
|
250
|
+
contextByMenu.delete(state.menu);
|
|
251
|
+
closeSurface(state.menu, { restoreFocus: false });
|
|
252
|
+
}
|
|
257
253
|
contextMap.delete(target);
|
|
258
254
|
}
|
|
259
255
|
|
|
260
|
-
function
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
options.source = context;
|
|
274
|
-
options.restoreFocusTo = trigger;
|
|
275
|
-
},
|
|
276
|
-
{ signal: controller.signal },
|
|
277
|
-
);
|
|
278
|
-
return () => {
|
|
279
|
-
controller.abort();
|
|
280
|
-
contextByMenu.delete(menu);
|
|
281
|
-
disconnectSurface(menu);
|
|
282
|
-
};
|
|
256
|
+
function onSurfaceOpen(event) {
|
|
257
|
+
const options = event.detail?.options;
|
|
258
|
+
const trigger = options?.trigger;
|
|
259
|
+
const context = trigger?.closest?.(CONTEXT_TARGET_SELECTOR);
|
|
260
|
+
if (!context || menuFor(context) !== event.detail?.surface) return;
|
|
261
|
+
if (!requestContextMenu(context, event.detail.surface, { origin: trigger, trigger: "button" })) {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
options.source = context;
|
|
266
|
+
options.restoreFocusTo = trigger;
|
|
283
267
|
}
|
|
284
268
|
|
|
285
269
|
enhance({
|
|
@@ -287,5 +271,8 @@ enhance({
|
|
|
287
271
|
connectContextTarget(target);
|
|
288
272
|
return () => disconnectContextTarget(target);
|
|
289
273
|
},
|
|
290
|
-
[CONTEXT_MENU_SELECTOR]: (menu) => connectContextMenu(menu),
|
|
291
274
|
});
|
|
275
|
+
|
|
276
|
+
if (typeof document !== "undefined") {
|
|
277
|
+
document.addEventListener(EVENTS.surfaceOpen, onSurfaceOpen);
|
|
278
|
+
}
|
package/src/js/enhance.js
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
*
|
|
15
15
|
* enhancers: { [selector]: (el) => cleanup | void }
|
|
16
16
|
* Returns: { refresh, forget, disconnect }
|
|
17
|
+
*
|
|
18
|
+
* Enhancement tokens — the data-enhance layer on top of enhance().
|
|
19
|
+
*
|
|
20
|
+
* enhancementSelector("tabs") returns [data-enhance~="tabs"], the generic
|
|
21
|
+
* opt-in for root-controller behaviors. registerEnhancement() is a stateless
|
|
22
|
+
* wrapper around enhance() — no central registry, no double-registration
|
|
23
|
+
* warning. Third-party behaviors register exactly like built-in ones.
|
|
24
|
+
*
|
|
25
|
+
* See docs/design-notes/enhancement-contract.md.
|
|
17
26
|
*/
|
|
18
27
|
|
|
19
28
|
const registries = new WeakMap();
|
|
@@ -172,6 +181,23 @@ function createRegistry(root) {
|
|
|
172
181
|
};
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
const ENHANCEMENT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
185
|
+
|
|
186
|
+
export function enhancementSelector(name) {
|
|
187
|
+
if (!ENHANCEMENT_NAME.test(name)) {
|
|
188
|
+
throw new TypeError(`Invalid enhancement name: ${name}`);
|
|
189
|
+
}
|
|
190
|
+
return `[data-enhance~="${name}"]`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function hasEnhancement(el, name) {
|
|
194
|
+
return el.matches(enhancementSelector(name));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function registerEnhancement(name, init, root) {
|
|
198
|
+
return enhance({ [enhancementSelector(name)]: init }, root);
|
|
199
|
+
}
|
|
200
|
+
|
|
175
201
|
function registryFor(root) {
|
|
176
202
|
let registry = registries.get(root);
|
|
177
203
|
if (!registry) {
|
package/src/js/flyout.js
CHANGED
|
@@ -5,21 +5,14 @@
|
|
|
5
5
|
* same surface lifecycle without menu semantics.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import enhance from "./enhance.js";
|
|
8
|
+
import enhance, { registerEnhancement } from "./enhance.js";
|
|
9
9
|
import { focusFirstDescendant } from "./focus.js";
|
|
10
10
|
import { focusFirstMenuItem, focusLastMenuItem, getMenuItems } from "./menu.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
closeSurface,
|
|
14
|
-
disconnectSurface,
|
|
15
|
-
isSurfaceOpen,
|
|
16
|
-
openSurface,
|
|
17
|
-
prepareSurface,
|
|
18
|
-
} from "./surface.js";
|
|
11
|
+
import { closeSurface, isSurfaceOpen, openSurface, prepareSurface } from "./surface.js";
|
|
19
12
|
|
|
20
13
|
// trigger -> { flyout, controller }
|
|
21
14
|
const triggerMap = new WeakMap();
|
|
22
|
-
const FLYOUT_SELECTOR =
|
|
15
|
+
const FLYOUT_SELECTOR = '[data-enhance~="flyout"]';
|
|
23
16
|
const FLYOUT_TRIGGER_SELECTOR = "[aria-controls][aria-expanded]";
|
|
24
17
|
|
|
25
18
|
function openFlyout(flyout, trigger) {
|
|
@@ -157,6 +150,9 @@ function disconnectTrigger(trigger) {
|
|
|
157
150
|
const state = triggerMap.get(trigger);
|
|
158
151
|
if (!state) return;
|
|
159
152
|
state.controller.abort();
|
|
153
|
+
if (state.flyout?.isConnected && isSurfaceOpen(state.flyout)) {
|
|
154
|
+
closeSurface(state.flyout, { restoreFocus: false });
|
|
155
|
+
}
|
|
160
156
|
triggerMap.delete(trigger);
|
|
161
157
|
}
|
|
162
158
|
|
|
@@ -170,18 +166,15 @@ function connectFlyout(flyout) {
|
|
|
170
166
|
}
|
|
171
167
|
}
|
|
172
168
|
|
|
173
|
-
function disconnectFlyout(flyout) {
|
|
174
|
-
if (!flyout) return;
|
|
175
|
-
disconnectSurface(flyout);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
169
|
enhance({
|
|
179
170
|
[FLYOUT_TRIGGER_SELECTOR]: (trigger) => {
|
|
180
171
|
connectTrigger(trigger);
|
|
181
172
|
return () => disconnectTrigger(trigger);
|
|
182
173
|
},
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
registerEnhancement("flyout", (flyout) => {
|
|
177
|
+
connectFlyout(flyout);
|
|
178
|
+
// Surface owns its own teardown; the marker enhancer in surface.js
|
|
179
|
+
// calls disconnectSurface(el, { restore: false }) on sweep.
|
|
187
180
|
});
|
package/src/js/menu.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { isElementVisible } from "./focus.js";
|
|
2
2
|
import { firstItem, lastItem, nextItem } from "./keys.js";
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
const MENU_ITEM = 'button, a, [role="menuitem"]';
|
|
5
|
+
// Mirrors the item contract in flyout.css: an item sits directly in the surface,
|
|
6
|
+
// or directly in any of its <li> — bare list, or grouped section > ul > li.
|
|
7
|
+
// `:scope li` must stay a *descendant* combinator for that second form; the
|
|
8
|
+
// grouped flyouts in docs/ui.md lose roving focus with `:scope > li`.
|
|
9
|
+
// Scoping is what keeps a neighbouring surface's items out: this selector only
|
|
10
|
+
// ever runs against one surface, and hasMenuItem() resolves the clicked item
|
|
11
|
+
// through this list rather than through a global closest().
|
|
12
|
+
const MENU_ITEM_SELECTOR = `:scope > :is(${MENU_ITEM}), :scope li > :is(${MENU_ITEM})`;
|
|
13
13
|
|
|
14
14
|
function isUsableMenuItem(item) {
|
|
15
15
|
return (
|
|
@@ -29,7 +29,7 @@ function isAriaMenuItem(item) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function activateCurrentItem(menu) {
|
|
32
|
-
const item =
|
|
32
|
+
const item = menu.ownerDocument.activeElement;
|
|
33
33
|
if (!menu.contains(item) || !isAriaMenuItem(item)) return false;
|
|
34
34
|
|
|
35
35
|
item.click();
|
|
@@ -40,8 +40,9 @@ export function hasMenuItems(menu) {
|
|
|
40
40
|
return getMenuItems(menu).length > 0;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function hasMenuItem(target) {
|
|
44
|
-
|
|
43
|
+
export function hasMenuItem(menu, target) {
|
|
44
|
+
const item = target?.closest?.(MENU_ITEM);
|
|
45
|
+
return !!item && getMenuItems(menu).includes(item);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function focusFirstMenuItem(menu) {
|
|
@@ -60,11 +61,11 @@ export function onMenuKeydown(e, { close }) {
|
|
|
60
61
|
switch (e.key) {
|
|
61
62
|
case "ArrowDown":
|
|
62
63
|
e.preventDefault();
|
|
63
|
-
nextItem(items,
|
|
64
|
+
nextItem(items, menu.ownerDocument.activeElement, 1, { wrap: true })?.focus();
|
|
64
65
|
break;
|
|
65
66
|
case "ArrowUp":
|
|
66
67
|
e.preventDefault();
|
|
67
|
-
nextItem(items,
|
|
68
|
+
nextItem(items, menu.ownerDocument.activeElement, -1, { wrap: true })?.focus();
|
|
68
69
|
break;
|
|
69
70
|
case "Home":
|
|
70
71
|
e.preventDefault();
|
package/src/js/scrollspy.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Scrollspy — navigation that highlights the active section on scroll.
|
|
3
3
|
*
|
|
4
|
-
* Nav:
|
|
4
|
+
* Nav: [data-enhance="scrollspy"] containing <a href="#section-id"> links
|
|
5
5
|
* Target: elements referenced by the href fragments
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Deterministic geometry, not IntersectionObserver: on each scroll (throttled
|
|
8
|
+
* with requestAnimationFrame) the active section is the last one whose top has
|
|
9
|
+
* crossed an activation line. An IntersectionObserver reports *which* sections
|
|
10
|
+
* intersect, not which one is current, so it has to guess whenever several
|
|
11
|
+
* share the viewport — and guesses wrong for sections shorter than the band.
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* activation line = root top + offset (default 20% of the root's height)
|
|
14
|
+
* active section = last section whose top <= activation line
|
|
15
|
+
* scrolled to end = last section, whatever the line says
|
|
16
|
+
* above the first = no active section
|
|
12
17
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Optional root: data-scrollspy-root="#scroll-container" measures against a
|
|
19
|
+
* scroll container instead of the viewport.
|
|
20
|
+
* Optional offset: data-scrollspy-offset="24" | "24px" | "20%" moves the
|
|
21
|
+
* activation line. Invalid values fall back to 20%; negatives clamp to 0.
|
|
22
|
+
*
|
|
23
|
+
* Self-registers via registerEnhancement: injected navs wire automatically.
|
|
24
|
+
* Cleanup removes the scroll/resize listeners and the small MutationObserver
|
|
25
|
+
* that refreshes the link->section map as nav links are injected. New sections
|
|
26
|
+
* outside the nav subtree are not observed — call refreshScrollspy(nav) after
|
|
27
|
+
* those mutations.
|
|
28
|
+
*
|
|
29
|
+
* The .scrollspy class alone still gives :target-current behavior via CSS —
|
|
30
|
+
* a documented no-JS mode, not a fallback remark.
|
|
16
31
|
*/
|
|
17
32
|
|
|
18
|
-
import
|
|
19
|
-
|
|
33
|
+
import { registerEnhancement } from "./enhance.js";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_OFFSET_RATIO = 0.2;
|
|
36
|
+
const navState = new WeakMap();
|
|
20
37
|
|
|
21
38
|
function rootFor(nav) {
|
|
22
39
|
const selector = nav.getAttribute("data-scrollspy-root");
|
|
@@ -42,80 +59,126 @@ function sectionsFor(nav) {
|
|
|
42
59
|
return sections;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
|
-
|
|
62
|
+
/*
|
|
63
|
+
* Viewport-relative measurements for either scroll root. The window branch
|
|
64
|
+
* reads documentElement rather than a captured window so a nav in another
|
|
65
|
+
* document measures its own root — same reason surface.js resolves
|
|
66
|
+
* ownerDocument instead of binding at import time.
|
|
67
|
+
*/
|
|
68
|
+
function measureRoot(nav, root) {
|
|
69
|
+
if (root) {
|
|
70
|
+
return {
|
|
71
|
+
top: root.getBoundingClientRect().top,
|
|
72
|
+
height: root.clientHeight,
|
|
73
|
+
scrollTop: root.scrollTop,
|
|
74
|
+
scrollHeight: root.scrollHeight,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
46
77
|
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
const html = nav.ownerDocument.documentElement;
|
|
79
|
+
return {
|
|
80
|
+
top: 0,
|
|
81
|
+
height: html.clientHeight,
|
|
82
|
+
scrollTop: html.scrollTop,
|
|
83
|
+
scrollHeight: html.scrollHeight,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
* "24" and "24px" are pixels, "20%" is a share of the root's visible height.
|
|
89
|
+
* Percentages resolve per measurement, so a resize needs no rebuild.
|
|
90
|
+
*/
|
|
91
|
+
function activationOffset(nav, height) {
|
|
92
|
+
const raw = (nav.getAttribute("data-scrollspy-offset") ?? "").trim();
|
|
93
|
+
if (!raw) return height * DEFAULT_OFFSET_RATIO;
|
|
94
|
+
|
|
95
|
+
const value = Number.parseFloat(raw);
|
|
96
|
+
if (!Number.isFinite(value)) return height * DEFAULT_OFFSET_RATIO;
|
|
97
|
+
|
|
98
|
+
return Math.max(0, raw.endsWith("%") ? (height * value) / 100 : value);
|
|
49
99
|
}
|
|
50
100
|
|
|
51
101
|
function setupNav(nav) {
|
|
52
|
-
|
|
53
|
-
let io = null;
|
|
102
|
+
const controller = new AbortController();
|
|
54
103
|
let sections = [];
|
|
55
|
-
|
|
56
|
-
let
|
|
104
|
+
let frame = 0;
|
|
105
|
+
let current;
|
|
106
|
+
|
|
107
|
+
function activate(section) {
|
|
108
|
+
if (section === current) return;
|
|
109
|
+
current = section;
|
|
110
|
+
|
|
111
|
+
for (const entry of sections) {
|
|
112
|
+
if (entry.section === section) {
|
|
113
|
+
entry.link.setAttribute("aria-current", "location");
|
|
114
|
+
} else {
|
|
115
|
+
entry.link.removeAttribute("aria-current");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
57
119
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
io?.disconnect();
|
|
61
|
-
sections = sectionsFor(nav);
|
|
62
|
-
activeEntries.clear();
|
|
120
|
+
function measure() {
|
|
121
|
+
frame = 0;
|
|
63
122
|
if (!sections.length) return;
|
|
64
123
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const visible = [...activeEntries.values()].sort(
|
|
76
|
-
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const id = visible[0]?.target.id;
|
|
80
|
-
for (const { link } of sections) {
|
|
81
|
-
if (id && link.getAttribute("href") === `#${id}`) {
|
|
82
|
-
link.setAttribute("aria-current", "location");
|
|
83
|
-
} else {
|
|
84
|
-
link.removeAttribute("aria-current");
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
root: rootFor(nav),
|
|
90
|
-
rootMargin: "-20% 0px -70% 0px",
|
|
91
|
-
threshold: 0,
|
|
92
|
-
},
|
|
93
|
-
);
|
|
124
|
+
const root = rootFor(nav);
|
|
125
|
+
const { top, height, scrollTop, scrollHeight } = measureRoot(nav, root);
|
|
126
|
+
|
|
127
|
+
// Scrolled to the end: the last section wins even when its top never
|
|
128
|
+
// crosses the line. This is the case IntersectionObserver cannot express.
|
|
129
|
+
if (scrollHeight > height && scrollTop + height >= scrollHeight - 1) {
|
|
130
|
+
activate(sections.at(-1).section);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
94
133
|
|
|
134
|
+
const line = top + activationOffset(nav, height);
|
|
135
|
+
let active = null;
|
|
95
136
|
for (const { section } of sections) {
|
|
96
|
-
|
|
137
|
+
if (section.getBoundingClientRect().top <= line) active = section;
|
|
97
138
|
}
|
|
139
|
+
|
|
140
|
+
activate(active);
|
|
98
141
|
}
|
|
99
142
|
|
|
100
|
-
function
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
queueMicrotask(rebuild);
|
|
143
|
+
function schedule(run = measure) {
|
|
144
|
+
if (frame) cancelAnimationFrame(frame);
|
|
145
|
+
frame = requestAnimationFrame(run);
|
|
104
146
|
}
|
|
105
147
|
|
|
106
|
-
|
|
148
|
+
function rebuild() {
|
|
149
|
+
sections = sectionsFor(nav);
|
|
150
|
+
current = undefined;
|
|
151
|
+
measure();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const scrollTarget = rootFor(nav) ?? nav.ownerDocument.defaultView;
|
|
155
|
+
scrollTarget?.addEventListener("scroll", () => schedule(), {
|
|
156
|
+
passive: true,
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
});
|
|
159
|
+
nav.ownerDocument.defaultView?.addEventListener("resize", () => schedule(), {
|
|
160
|
+
passive: true,
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Observer is scoped to the nav subtree — link injection is caught;
|
|
165
|
+
// section-only DOM mutations elsewhere need an explicit refreshScrollspy().
|
|
166
|
+
const mo = new MutationObserver(() => schedule(rebuild));
|
|
107
167
|
mo.observe(nav, { childList: true, subtree: true });
|
|
108
|
-
navState.set(nav, { rebuild
|
|
168
|
+
navState.set(nav, { rebuild });
|
|
109
169
|
|
|
110
170
|
rebuild();
|
|
111
171
|
|
|
112
172
|
return () => {
|
|
113
|
-
|
|
173
|
+
controller.abort();
|
|
174
|
+
if (frame) cancelAnimationFrame(frame);
|
|
114
175
|
mo.disconnect();
|
|
115
176
|
navState.delete(nav);
|
|
116
177
|
};
|
|
117
178
|
}
|
|
118
179
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
180
|
+
export function refreshScrollspy(nav) {
|
|
181
|
+
navState.get(nav)?.rebuild();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
registerEnhancement("scrollspy", setupNav);
|
package/src/js/selectors.js
CHANGED
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Selectors — the
|
|
3
|
-
* in one file.
|
|
2
|
+
* Selectors — the state-class vocabulary the runtime writes.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* This file no longer lists discovery selectors: behavior discovery moved
|
|
5
|
+
* to data-enhance tokens in 0.2. What remains are the class names modules
|
|
6
|
+
* write as state (is-open, was-validated, …) and validation's two read-side
|
|
7
|
+
* presentation adapters (field, fieldError). To run this runtime against a
|
|
8
|
+
* different CSS framework, edit this file (or alias it in a bundler).
|
|
9
9
|
*
|
|
10
10
|
* data-* attributes and ARIA are not listed: they are each module's own
|
|
11
11
|
* behavior API, not CSS vocabulary, so they stay framework-neutral by
|
|
12
|
-
* construction and never need remapping.
|
|
13
|
-
* status.js) has no CSS class dependency at all.
|
|
12
|
+
* construction and never need remapping.
|
|
14
13
|
*/
|
|
14
|
+
|
|
15
15
|
export const CLASSES = {
|
|
16
|
-
// flyout.js, context-menu.js — opt-in class on the panel/menu element.
|
|
17
|
-
flyout: "flyout",
|
|
18
|
-
// tab.js — opt-in class on the tablist element.
|
|
19
|
-
tabs: "tabs",
|
|
20
|
-
// scrollspy.js — opt-in class on the nav element.
|
|
21
|
-
scrollspy: "scrollspy",
|
|
22
|
-
// validation.js — opt-in class on the <form>.
|
|
23
|
-
needsValidation: "needs-validation",
|
|
24
16
|
// validation.js — written on <form> after the first submit attempt.
|
|
25
17
|
wasValidated: "was-validated",
|
|
26
|
-
// validation.js — read on the element an aria-describedby id resolves to.
|
|
27
|
-
fieldError: "field-error",
|
|
28
|
-
// validation.js — ancestor container searched for aria-invalid/danger state.
|
|
29
|
-
field: "field",
|
|
30
18
|
// validation.js — intents.css vocabulary class toggled on the field
|
|
31
19
|
// container while any control inside it is invalid.
|
|
32
20
|
danger: "danger",
|
|
21
|
+
// validation.js — read-side presentation adapter: error slot among
|
|
22
|
+
// aria-describedby targets. [data-field-error] is an accepted alias (D6).
|
|
23
|
+
fieldError: "field-error",
|
|
24
|
+
// validation.js — read-side presentation adapter: ancestor container
|
|
25
|
+
// for aria-invalid/danger state. Optional — degrades to no-op.
|
|
26
|
+
field: "field",
|
|
33
27
|
// surface.js — state class on the open flyout/menu/dialog surface.
|
|
34
28
|
open: "is-open",
|
|
35
29
|
// surface.js — state class while the surface renders as a mobile sheet.
|
|
@@ -52,8 +46,4 @@ export const CLASSES = {
|
|
|
52
46
|
hadScrollbar: "had-scrollbar",
|
|
53
47
|
// tooltip.js — class name written on a shorthand-generated tooltip element.
|
|
54
48
|
tooltip: "tooltip",
|
|
55
|
-
// status.js — required alongside [data-status]. "status" is a common
|
|
56
|
-
// domain word (order status, task status, …); the class keeps a
|
|
57
|
-
// document-wide querySelector from matching an unrelated app element.
|
|
58
|
-
statusBar: "status-bar",
|
|
59
49
|
};
|
package/src/js/status.js
CHANGED
|
@@ -24,23 +24,23 @@
|
|
|
24
24
|
* - Any other code, without importing this module: dispatch `actual:status`
|
|
25
25
|
* directly — `detail: { message, intent, duration }`, or `{}` to clear.
|
|
26
26
|
*
|
|
27
|
-
* The element contract is
|
|
28
|
-
* "status" is a generic domain word, so
|
|
29
|
-
* lookup from matching an unrelated app element that happens to
|
|
30
|
-
* `data-status`. `intent` is applied verbatim as one or more
|
|
31
|
-
* target (space-separated for more than one, e.g. "danger
|
|
32
|
-
* status.js does not know or validate any specific names —
|
|
33
|
-
* and friends are Actual's own intents.css vocabulary, not
|
|
34
|
-
* module hardcodes. Each call removes the intent classes the
|
|
35
|
-
* added (tracked in data-status-classes on the target itself),
|
|
36
|
-
* current call's, so stale intent classes never accumulate and
|
|
37
|
-
* app added independently are left alone.
|
|
27
|
+
* The element contract is `[data-status][role="status"]`. Both are required:
|
|
28
|
+
* "status" is a generic domain word, so `role="status"` keeps the
|
|
29
|
+
* document-wide lookup from matching an unrelated app element that happens to
|
|
30
|
+
* carry its own `data-status`. `intent` is applied verbatim as one or more
|
|
31
|
+
* classes on the target (space-separated for more than one, e.g. "danger
|
|
32
|
+
* uppercase"). status.js does not know or validate any specific names —
|
|
33
|
+
* .danger, .success, and friends are Actual's own intents.css vocabulary, not
|
|
34
|
+
* something this module hardcodes. Each call removes the intent classes the
|
|
35
|
+
* previous call added (tracked in data-status-classes on the target itself),
|
|
36
|
+
* then adds the current call's, so stale intent classes never accumulate and
|
|
37
|
+
* classes the app added independently are left alone.
|
|
38
38
|
*/
|
|
39
|
+
|
|
39
40
|
import { registerCommands, targetFor } from "./command.js";
|
|
40
41
|
import { EVENTS } from "./events.js";
|
|
41
|
-
import { CLASSES } from "./selectors.js";
|
|
42
42
|
|
|
43
|
-
const STATUS_SELECTOR =
|
|
43
|
+
const STATUS_SELECTOR = '[data-status][role="status"]';
|
|
44
44
|
const STATUS_CLASSES_ATTR = "statusClasses";
|
|
45
45
|
|
|
46
46
|
let statusTimer;
|