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
package/src/js/surface.js CHANGED
@@ -1,3 +1,4 @@
1
+ import enhance from "./enhance.js";
1
2
  import { EVENTS } from "./events.js";
2
3
  import { reposition, repositionAt, track } from "./floating.js";
3
4
  import { hasMenuItem, onMenuKeydown } from "./menu.js";
@@ -12,6 +13,35 @@ const BREAKPOINTS = {
12
13
  const openSurfaces = new Set();
13
14
  const surfaceMap = new WeakMap();
14
15
  const mountedSurfaces = new WeakMap();
16
+ const clickBoundDocuments = new WeakSet();
17
+
18
+ // data-actual-surface is written by the runtime, never by an author, and never
19
+ // selected on by CSS. It is namespaced because "data-surface" is a name an
20
+ // application may already own. surface.js reaps its own surfaces so consumers
21
+ // do not have to maintain panel-side lifecycle hooks.
22
+ const SURFACE_MARKER = "data-actual-surface";
23
+ const reapers = new WeakMap();
24
+
25
+ // Registered lazily, per owning document, and never at import time: enhance()
26
+ // binds its observer to the root it is given, so a module-scope
27
+ // enhance() would watch whichever document existed when this module was first
28
+ // imported. Surfaces in any other document (an iframe, or a fresh test
29
+ // document) would then never be swept. Keyed by Document, so nothing is
30
+ // retained once a document is gone.
31
+ function reaperFor(menu) {
32
+ const root = menu.ownerDocument?.documentElement;
33
+ if (!root) return null;
34
+
35
+ let reaper = reapers.get(root);
36
+ if (!reaper) {
37
+ reaper = enhance(
38
+ { [`[${SURFACE_MARKER}]`]: (el) => () => disconnectSurface(el, { restore: false }) },
39
+ root,
40
+ );
41
+ reapers.set(root, reaper);
42
+ }
43
+ return reaper;
44
+ }
15
45
 
16
46
  function waitForAnimations(...elements) {
17
47
  const animations = elements
@@ -170,6 +200,7 @@ function positionSurface(menu) {
170
200
  function ensureSurfaceWired(menu) {
171
201
  if (surfaceMap.has(menu)) return surfaceMap.get(menu);
172
202
 
203
+ ensureDocumentClick(menu);
173
204
  const controller = new AbortController();
174
205
  const state = {
175
206
  controller,
@@ -237,7 +268,7 @@ function ensureSurfaceWired(menu) {
237
268
  return;
238
269
  }
239
270
  if (!state || state.autoClose === "outside" || state.autoClose === "false") return;
240
- if (hasMenuItem(e.target)) closeSurface(menu);
271
+ if (hasMenuItem(menu, e.target)) closeSurface(menu);
241
272
  },
242
273
  { signal: controller.signal },
243
274
  );
@@ -255,6 +286,10 @@ export function prepareSurface(menu) {
255
286
  menu.style.position = "fixed";
256
287
  menu.hidden = true;
257
288
  syncExpanded(menu, false);
289
+ // Both steps are idempotent, so they run unguarded: that also keeps the
290
+ // reaper correct if a surface is ever adopted into another document.
291
+ menu.setAttribute(SURFACE_MARKER, "");
292
+ reaperFor(menu)?.refresh(menu);
258
293
  }
259
294
 
260
295
  export function openSurface(menu, opts = {}) {
@@ -332,8 +367,9 @@ export function closeSurface(menu, opts = {}) {
332
367
  });
333
368
  }
334
369
 
335
- export function disconnectSurface(menu) {
370
+ export function disconnectSurface(menu, { restore = true } = {}) {
336
371
  if (!menu) return;
372
+ if (!restore) mountedSurfaces.delete(menu);
337
373
  closeSurface(menu);
338
374
  const state = surfaceMap.get(menu);
339
375
  if (state) {
@@ -345,14 +381,23 @@ export function disconnectSurface(menu) {
345
381
  restoreSurface(menu);
346
382
  }
347
383
 
348
- // Global outside-click listener runs at import time; keep SSR imports inert.
349
- if (typeof document !== "undefined") {
350
- document.addEventListener("click", (e) => {
351
- for (const menu of openSurfaces) {
352
- const state = surfaceMap.get(menu);
353
- if (!state || state.autoClose === "inside" || state.autoClose === "false") continue;
354
- if (menu.contains(e.target) || state?.trigger?.contains(e.target)) continue;
355
- closeSurface(menu);
356
- }
357
- });
384
+ function onDocumentClick(e) {
385
+ for (const menu of openSurfaces) {
386
+ const state = surfaceMap.get(menu);
387
+ if (!state || state.autoClose === "inside" || state.autoClose === "false") continue;
388
+ if (menu.contains(e.target) || state?.trigger?.contains(e.target)) continue;
389
+ closeSurface(menu);
390
+ }
391
+ }
392
+
393
+ // Bound per owning document on first use, not at import time — same reason as
394
+ // reaperFor(): an import-time listener attaches to whichever document existed
395
+ // then, and stays attached to it. Nothing can need this listener before a
396
+ // surface has been wired, so binding here costs nothing and keeps SSR imports
397
+ // inert without a typeof guard.
398
+ function ensureDocumentClick(menu) {
399
+ const doc = menu.ownerDocument;
400
+ if (!doc || clickBoundDocuments.has(doc)) return;
401
+ clickBoundDocuments.add(doc);
402
+ doc.addEventListener("click", onDocumentClick);
358
403
  }
package/src/js/tab.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Tabs — in-place panel switcher using real tab semantics.
3
3
  *
4
- * Tab list: .tabs[role="tablist"]
4
+ * Tab list: [data-enhance="tabs"] with role="tablist"
5
5
  * Tab: [role="tab"] with aria-selected, aria-controls, id, tabindex
6
6
  * Panel: [role="tabpanel"] with id matching aria-controls
7
7
  *
@@ -9,17 +9,16 @@
9
9
  * ArrowUp/Down for aria-orientation="vertical"
10
10
  * ArrowDown (focus selected panel)
11
11
  *
12
- * Self-registers via enhance: injected tablists wire automatically.
12
+ * Self-registers via registerEnhancement: injected tablists wire automatically.
13
13
  * The tab→panel map is rebuilt from the live tablist on each interaction,
14
14
  * so tabs added after connect are picked up with no extra work.
15
15
  * Cleanup is the AbortController.abort() returned to enhance.
16
16
  */
17
17
 
18
- import enhance from "./enhance.js";
18
+ import { registerEnhancement } from "./enhance.js";
19
19
  import { firstItem, lastItem, nextItem } from "./keys.js";
20
- import { CLASSES } from "./selectors.js";
21
20
 
22
- const TABLIST_SELECTOR = `.${CLASSES.tabs}[role="tablist"]`;
21
+ const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
23
22
 
24
23
  function tabsOf(list) {
25
24
  return [...list.querySelectorAll('[role="tab"]')];
@@ -162,12 +161,10 @@ function onClick(e) {
162
161
  tab.focus();
163
162
  }
164
163
 
165
- enhance({
166
- [TABLIST_SELECTOR]: (list) => {
167
- const controller = new AbortController();
168
- initialize(list);
169
- list.addEventListener("click", onClick, { signal: controller.signal });
170
- list.addEventListener("keydown", onKeydown, { signal: controller.signal });
171
- return () => controller.abort();
172
- },
164
+ registerEnhancement("tabs", (list) => {
165
+ const controller = new AbortController();
166
+ initialize(list);
167
+ list.addEventListener("click", onClick, { signal: controller.signal });
168
+ list.addEventListener("keydown", onKeydown, { signal: controller.signal });
169
+ return () => controller.abort();
173
170
  });
@@ -8,27 +8,22 @@
8
8
  * framework: server / AJAX validation lives in app code and feeds results
9
9
  * back through FormValidator.setErrors().
10
10
  *
11
- * Opt in with the .needs-validation class. Importing the module registers
12
- * the behavior; there is no init call.
11
+ * Opt in with data-enhance="validation". The .needs-validation class
12
+ * remains valid for presentation-only (CSS :user-invalid feedback without
13
+ * the JS behavior). Importing the module registers the behavior; there is
14
+ * no init call.
13
15
  *
14
- * The .needs-validation class is a static, init-time contract: this module
15
- * does not observe attribute mutations, so toggling the class after connect
16
- * only takes effect on the next focusout/input event, not immediately. A
17
- * submit that races that window can still bypass native validation (the
18
- * managed novalidate attribute is still present). Treat the class as fixed
19
- * for a form's lifetime rather than something toggled at runtime. Removing
20
- * the form from the DOM, however, is fully supported: the enhance() sweep
21
- * runs the cleanup below, which aborts all listeners, removes the managed
22
- * novalidate attribute, and releases the form's internal state.
16
+ * The marker is a static, init-time contract. Removing the form from the
17
+ * DOM tears down everything, including the managed novalidate attribute.
23
18
  */
24
19
 
25
- import enhance from "./enhance.js";
20
+ import enhance, { enhancementSelector } from "./enhance.js";
26
21
  import { EVENTS } from "./events.js";
27
22
  import { CLASSES } from "./selectors.js";
28
23
 
29
24
  const NOVALIDATE = "novalidate";
30
25
  const WAS_VALIDATED_CLASS = CLASSES.wasValidated;
31
- const NEEDS_VALIDATION_CLASS = CLASSES.needsValidation;
26
+ const VALIDATION_SELECTOR = enhancementSelector("validation");
32
27
  const FIELD_CLASS = CLASSES.field;
33
28
  const DANGER_CLASS = CLASSES.danger;
34
29
  const MANAGED_NOVALIDATE_ATTR = "validationManagedNovalidate";
@@ -177,7 +172,7 @@ function errorEl(el) {
177
172
  if (!ids) return null;
178
173
  for (const id of ids.split(/\s+/)) {
179
174
  const node = el.ownerDocument.getElementById(id);
180
- if (node?.classList.contains(CLASSES.fieldError)) {
175
+ if (node?.matches(`.${CLASSES.fieldError}, [data-field-error]`)) {
181
176
  return node;
182
177
  }
183
178
  }
@@ -205,14 +200,6 @@ function hydrateServerErrors(form) {
205
200
  }
206
201
  }
207
202
 
208
- function ensureManagedNoValidate(form) {
209
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) return;
210
- if (form.hasAttribute(NOVALIDATE)) return;
211
-
212
- form.setAttribute(NOVALIDATE, "");
213
- form.dataset[MANAGED_NOVALIDATE_ATTR] = "";
214
- }
215
-
216
203
  function releaseManagedNoValidate(form) {
217
204
  if (form.dataset[MANAGED_NOVALIDATE_ATTR] === undefined) return;
218
205
  form.removeAttribute(NOVALIDATE);
@@ -220,8 +207,6 @@ function releaseManagedNoValidate(form) {
220
207
  }
221
208
 
222
209
  function validateField(el, trigger) {
223
- const form = el.form;
224
- if (!form?.classList.contains(NEEDS_VALIDATION_CLASS)) return;
225
210
  if (ignoreField(el)) return;
226
211
 
227
212
  if (trigger === "input" && el.dataset.validationErrors === "server") {
@@ -261,11 +246,6 @@ function connectForm(form) {
261
246
  (event) => {
262
247
  const el = event.target;
263
248
  if (el instanceof Element && el.form === form) {
264
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
265
- releaseManagedNoValidate(form);
266
- return;
267
- }
268
- ensureManagedNoValidate(form);
269
249
  validateField(el, "blur");
270
250
  }
271
251
  },
@@ -277,11 +257,6 @@ function connectForm(form) {
277
257
  (event) => {
278
258
  const el = event.target;
279
259
  if (el instanceof Element && el.form === form) {
280
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
281
- releaseManagedNoValidate(form);
282
- return;
283
- }
284
- ensureManagedNoValidate(form);
285
260
  validateField(el, "input");
286
261
  }
287
262
  },
@@ -306,13 +281,6 @@ function connectForm(form) {
306
281
  (event) => {
307
282
  if (event.submitter?.formNoValidate) return;
308
283
 
309
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
310
- releaseManagedNoValidate(form);
311
- return;
312
- }
313
-
314
- ensureManagedNoValidate(form);
315
-
316
284
  let firstInvalid = null;
317
285
 
318
286
  for (const el of form.elements) {
@@ -373,7 +341,7 @@ export class FormValidator {
373
341
  rules[name] = callback;
374
342
  }
375
343
 
376
- static init(selector = `.${NEEDS_VALIDATION_CLASS}`) {
344
+ static init(selector = VALIDATION_SELECTOR) {
377
345
  if (typeof document === "undefined") return;
378
346
  for (const form of document.querySelectorAll(selector)) {
379
347
  if (isFormElement(form)) connectForm(form);
@@ -412,7 +380,7 @@ export class FormValidator {
412
380
 
413
381
  if (typeof document !== "undefined") {
414
382
  enhance({
415
- [`form.${NEEDS_VALIDATION_CLASS}`]: (form) => connectForm(form),
383
+ [`form${VALIDATION_SELECTOR}`]: (form) => connectForm(form),
416
384
  });
417
385
  }
418
386