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.
Files changed (51) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +2 -0
  3. package/dist/actual.css +356 -124
  4. package/dist/actual.js +2 -2
  5. package/dist/actual.js.map +13 -13
  6. package/dist/actual.min.css +1 -1
  7. package/docs/README.md +2 -0
  8. package/docs/components.md +2 -2
  9. package/docs/design-notes/browser-support.md +9 -0
  10. package/docs/design-notes/enhancement-contract.md +104 -0
  11. package/docs/design-notes/naming.md +1 -0
  12. package/docs/design-notes/widget-primitives.md +83 -0
  13. package/docs/forms.md +4 -4
  14. package/docs/javascript.md +104 -34
  15. package/docs/patterns.md +89 -0
  16. package/docs/ui.md +22 -30
  17. package/llms.txt +6 -0
  18. package/package.json +14 -8
  19. package/src/css/base.css +0 -4
  20. package/src/css/components/alert.css +88 -2
  21. package/src/css/components/badge.css +30 -7
  22. package/src/css/components/busy.css +1 -0
  23. package/src/css/components/button.css +6 -8
  24. package/src/css/components/flyout.css +0 -1
  25. package/src/css/components/join.css +1 -2
  26. package/src/css/components/scrollspy.css +2 -2
  27. package/src/css/components/spinner.css +4 -4
  28. package/src/css/components/status-bar.css +3 -4
  29. package/src/css/components/tab.css +1 -0
  30. package/src/css/focus.css +37 -0
  31. package/src/css/forms/choice-card.css +1 -2
  32. package/src/css/forms/choice.css +1 -2
  33. package/src/css/forms/control.css +1 -10
  34. package/src/css/forms/index.css +1 -0
  35. package/src/css/forms/native.css +8 -32
  36. package/src/css/forms/range.css +95 -0
  37. package/src/css/forms/switch.css +1 -2
  38. package/src/css/optional/scroller.css +6 -0
  39. package/src/css/theme.css +9 -9
  40. package/src/css/themes/edge.css +3 -27
  41. package/src/css/tokens.css +9 -3
  42. package/src/js/context-menu.js +21 -34
  43. package/src/js/enhance.js +26 -0
  44. package/src/js/flyout.js +12 -19
  45. package/src/js/menu.js +15 -14
  46. package/src/js/scrollspy.js +125 -62
  47. package/src/js/selectors.js +14 -24
  48. package/src/js/status.js +13 -13
  49. package/src/js/surface.js +57 -12
  50. package/src/js/tab.js +10 -13
  51. package/src/js/validation.js +11 -43
@@ -79,38 +79,14 @@
79
79
  }
80
80
 
81
81
  /* Focus recipe override — the base recipe (control.css) flips --control-border
82
- to --focus on top of the outline + ring, which triples up with the lower-edge
83
- accent already visible here. Edge drops the border-color flip and thickens
84
- the accent edge instead; the ring stays untouched. */
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%));
@@ -29,7 +29,8 @@
29
29
  /* Shape */
30
30
  --border-width: 1px;
31
31
  --radius-full: 9999px;
32
- --shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.08);
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: calc(var(--border-width) * 2) solid var(--focus);
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 0.5rem 1.5rem color-mix(in oklch, var(--shadow-color) 8%, transparent);
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,
@@ -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 { CLASSES } from "./selectors.js";
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 = `menu.${CLASSES.flyout}`;
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) contextByMenu.delete(state.menu);
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 connectContextMenu(menu) {
261
- const controller = new AbortController();
262
- menu.addEventListener(
263
- EVENTS.surfaceOpen,
264
- (event) => {
265
- const options = event.detail?.options;
266
- const trigger = options?.trigger;
267
- const context = trigger?.closest?.(CONTEXT_TARGET_SELECTOR);
268
- if (!context || menuFor(context) !== menu) return;
269
- if (!requestContextMenu(context, menu, { origin: trigger, trigger: "button" })) {
270
- event.preventDefault();
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 { CLASSES } from "./selectors.js";
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 = `.${CLASSES.flyout}`;
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
- [FLYOUT_SELECTOR]: (flyout) => {
184
- connectFlyout(flyout);
185
- return () => disconnectFlyout(flyout);
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 MENU_ITEM_TARGET_SELECTORS = ["button", "a", '[role="menuitem"]'];
5
- // Item contract, mirrored from flyout.css: a button, link, or menuitem
6
- // directly in the flyout or directly in any of its <li>. Prefixed with
7
- // .flyout rather than :scope because hasMenuItem() matches it via closest(),
8
- // where :scope would resolve to the target element instead of the flyout.
9
- export const MENU_ITEM_SELECTOR = MENU_ITEM_TARGET_SELECTORS.flatMap((target) => [
10
- `.flyout > ${target}`,
11
- `.flyout li > ${target}`,
12
- ]).join(",");
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 = document.activeElement;
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
- return !!target.closest?.(MENU_ITEM_SELECTOR);
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, document.activeElement, 1, { wrap: true })?.focus();
64
+ nextItem(items, menu.ownerDocument.activeElement, 1, { wrap: true })?.focus();
64
65
  break;
65
66
  case "ArrowUp":
66
67
  e.preventDefault();
67
- nextItem(items, document.activeElement, -1, { wrap: true })?.focus();
68
+ nextItem(items, menu.ownerDocument.activeElement, -1, { wrap: true })?.focus();
68
69
  break;
69
70
  case "Home":
70
71
  e.preventDefault();
@@ -1,22 +1,39 @@
1
1
  /*
2
2
  * Scrollspy — navigation that highlights the active section on scroll.
3
3
  *
4
- * Nav: .scrollspy containing <a href="#section-id"> links
4
+ * Nav: [data-enhance="scrollspy"] containing <a href="#section-id"> links
5
5
  * Target: elements referenced by the href fragments
6
6
  *
7
- * Uses IntersectionObserver. Falls back gracefully: links still work
8
- * even if IntersectionObserver isn't available.
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
- * Optional root: data-scrollspy-root="#scroll-container" observes section
11
- * visibility within a scroll container instead of the viewport.
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
- * Self-registers via enhance: injected .scrollspy navs wire automatically.
14
- * Cleanup disconnects the IntersectionObserver and the small MutationObserver
15
- * that refreshes the link→section map as nav links or sections are injected.
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 enhance from "./enhance.js";
19
- import { CLASSES } from "./selectors.js";
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
- const navState = new WeakMap();
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
- export function refreshScrollspy(nav) {
48
- navState.get(nav)?.rebuild();
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
- if (typeof IntersectionObserver === "undefined") return;
53
- let io = null;
102
+ const controller = new AbortController();
54
103
  let sections = [];
55
- const activeEntries = new Map();
56
- let scheduled = false;
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 rebuild() {
59
- scheduled = false;
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
- io = new IntersectionObserver(
66
- (entries) => {
67
- for (const entry of entries) {
68
- if (entry.isIntersecting) {
69
- activeEntries.set(entry.target, entry);
70
- } else {
71
- activeEntries.delete(entry.target);
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
- io.observe(section);
137
+ if (section.getBoundingClientRect().top <= line) active = section;
97
138
  }
139
+
140
+ activate(active);
98
141
  }
99
142
 
100
- function scheduleRebuild() {
101
- if (scheduled) return;
102
- scheduled = true;
103
- queueMicrotask(rebuild);
143
+ function schedule(run = measure) {
144
+ if (frame) cancelAnimationFrame(frame);
145
+ frame = requestAnimationFrame(run);
104
146
  }
105
147
 
106
- const mo = new MutationObserver(scheduleRebuild);
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: scheduleRebuild });
168
+ navState.set(nav, { rebuild });
109
169
 
110
170
  rebuild();
111
171
 
112
172
  return () => {
113
- io?.disconnect();
173
+ controller.abort();
174
+ if (frame) cancelAnimationFrame(frame);
114
175
  mo.disconnect();
115
176
  navState.delete(nav);
116
177
  };
117
178
  }
118
179
 
119
- enhance({
120
- [`.${CLASSES.scrollspy}`]: setupNav,
121
- });
180
+ export function refreshScrollspy(nav) {
181
+ navState.get(nav)?.rebuild();
182
+ }
183
+
184
+ registerEnhancement("scrollspy", setupNav);
@@ -1,35 +1,29 @@
1
1
  /*
2
- * Selectors — the CSS class names this runtime looks for or writes, gathered
3
- * in one file.
2
+ * Selectors — the state-class vocabulary the runtime writes.
4
3
  *
5
- * Every module that matches against or toggles one of Actual's own class
6
- * names imports its token from here instead of writing the string inline.
7
- * To run this runtime against a different CSS framework, edit this file (or
8
- * alias it in a bundler) — no other module needs to change.
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. A module with no entry here (see
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 `.status-bar[data-status]`. Both are required:
28
- * "status" is a generic domain word, so the class keeps the document-wide
29
- * lookup from matching an unrelated app element that happens to carry its own
30
- * `data-status`. `intent` is applied verbatim as one or more classes on the
31
- * target (space-separated for more than one, e.g. "danger uppercase").
32
- * status.js does not know or validate any specific names — .danger, .success,
33
- * and friends are Actual's own intents.css vocabulary, not something this
34
- * module hardcodes. Each call removes the intent classes the previous call
35
- * added (tracked in data-status-classes on the target itself), then adds the
36
- * current call's, so stale intent classes never accumulate and classes the
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 = `.${CLASSES.statusBar}[data-status]`;
43
+ const STATUS_SELECTOR = '[data-status][role="status"]';
44
44
  const STATUS_CLASSES_ATTR = "statusClasses";
45
45
 
46
46
  let statusTimer;