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/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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
});
|
package/src/js/validation.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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?.
|
|
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 =
|
|
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
|
|
383
|
+
[`form${VALIDATION_SELECTOR}`]: (form) => connectForm(form),
|
|
416
384
|
});
|
|
417
385
|
}
|
|
418
386
|
|