actual-css 0.1.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 +11 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/actual.css +4929 -0
- package/dist/actual.js +3 -0
- package/dist/actual.js.map +31 -0
- package/dist/actual.min.css +1 -0
- package/docs/README.md +26 -0
- package/docs/components.md +860 -0
- package/docs/design-notes/accessibility-decisions.md +32 -0
- package/docs/design-notes/browser-support.md +93 -0
- package/docs/design-notes/cascade-layer.md +52 -0
- package/docs/design-notes/css-anchor.md +33 -0
- package/docs/design-notes/naming.md +37 -0
- package/docs/design-notes/previous-work.md +5 -0
- package/docs/design-notes/references.md +324 -0
- package/docs/forms.md +1265 -0
- package/docs/javascript.md +426 -0
- package/docs/layout.md +480 -0
- package/docs/mental-model.md +73 -0
- package/docs/patterns.md +415 -0
- package/docs/reserved.md +7 -0
- package/docs/tokens.md +455 -0
- package/docs/typography.md +159 -0
- package/docs/ui.md +937 -0
- package/docs/utilities.md +384 -0
- package/llms.txt +17 -0
- package/package.json +101 -0
- package/src/css/actual.css +17 -0
- package/src/css/actual.full.css +2 -0
- package/src/css/actual.layer.css +3 -0
- package/src/css/base.css +70 -0
- package/src/css/components/accordion.css +97 -0
- package/src/css/components/actions.css +21 -0
- package/src/css/components/alert.css +137 -0
- package/src/css/components/avatar.css +199 -0
- package/src/css/components/badge.css +143 -0
- package/src/css/components/breadcrumb.css +56 -0
- package/src/css/components/busy.css +44 -0
- package/src/css/components/button.css +216 -0
- package/src/css/components/card.css +140 -0
- package/src/css/components/drawer.css +122 -0
- package/src/css/components/flyout.css +243 -0
- package/src/css/components/index.css +41 -0
- package/src/css/components/join.css +106 -0
- package/src/css/components/key.css +25 -0
- package/src/css/components/meter.css +81 -0
- package/src/css/components/modal.css +162 -0
- package/src/css/components/navbar.css +98 -0
- package/src/css/components/overline.css +49 -0
- package/src/css/components/pagination.css +30 -0
- package/src/css/components/progress.css +126 -0
- package/src/css/components/scrollspy.css +27 -0
- package/src/css/components/skeleton.css +67 -0
- package/src/css/components/spinner.css +60 -0
- package/src/css/components/status-bar.css +82 -0
- package/src/css/components/surface.css +48 -0
- package/src/css/components/tab.css +128 -0
- package/src/css/components/table.css +64 -0
- package/src/css/components/tooltip.css +107 -0
- package/src/css/dialog-fallback.css +56 -0
- package/src/css/focus.css +13 -0
- package/src/css/forms/choice-card.css +158 -0
- package/src/css/forms/choice.css +196 -0
- package/src/css/forms/control.css +106 -0
- package/src/css/forms/custom-select.css +93 -0
- package/src/css/forms/form-actions.css +43 -0
- package/src/css/forms/form.css +57 -0
- package/src/css/forms/index.css +15 -0
- package/src/css/forms/input-icon.css +36 -0
- package/src/css/forms/native.css +68 -0
- package/src/css/forms/select.css +36 -0
- package/src/css/forms/switch.css +127 -0
- package/src/css/forms/validation.css +63 -0
- package/src/css/grid.css +95 -0
- package/src/css/intents.css +33 -0
- package/src/css/layout.css +80 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +55 -0
- package/src/css/optional/scroll-snap.css +51 -0
- package/src/css/optional/scroller.css +51 -0
- package/src/css/optional/typography-fluid.css +48 -0
- package/src/css/optional/utilities-extra.css +289 -0
- package/src/css/print.css +47 -0
- package/src/css/prose.css +187 -0
- package/src/css/reset.css +56 -0
- package/src/css/theme.css +186 -0
- package/src/css/themes/corporate.css +60 -0
- package/src/css/themes/cyberpunk.css +60 -0
- package/src/css/themes/dark.css +32 -0
- package/src/css/themes/dim.css +33 -0
- package/src/css/themes/edge.css +143 -0
- package/src/css/themes/forest.css +65 -0
- package/src/css/themes/gradient.css +97 -0
- package/src/css/themes/index.css +43 -0
- package/src/css/themes/lavender.css +66 -0
- package/src/css/themes/mono.css +60 -0
- package/src/css/themes/neon.css +66 -0
- package/src/css/themes/ocean.css +60 -0
- package/src/css/themes/petrol.css +64 -0
- package/src/css/themes/spruce.css +64 -0
- package/src/css/themes/square.css +68 -0
- package/src/css/themes/sunset.css +66 -0
- package/src/css/tokens.css +122 -0
- package/src/css/utilities.css +162 -0
- package/src/css/variants.css +118 -0
- package/src/js/command.js +165 -0
- package/src/js/context-menu.js +291 -0
- package/src/js/dialog-fallback.js +163 -0
- package/src/js/dialog.js +503 -0
- package/src/js/enhance.js +208 -0
- package/src/js/events.js +22 -0
- package/src/js/filter.js +122 -0
- package/src/js/floating.js +462 -0
- package/src/js/flyout.js +187 -0
- package/src/js/focus.js +35 -0
- package/src/js/index.js +26 -0
- package/src/js/input.js +47 -0
- package/src/js/keys.js +30 -0
- package/src/js/mask.js +184 -0
- package/src/js/menu.js +88 -0
- package/src/js/password.js +98 -0
- package/src/js/scrollspy.js +121 -0
- package/src/js/selectors.js +59 -0
- package/src/js/status.js +158 -0
- package/src/js/surface.js +358 -0
- package/src/js/tab.js +173 -0
- package/src/js/tooltip.js +374 -0
- package/src/js/validation.js +419 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Small, single-purpose utilities for common escape hatches.
|
|
3
|
+
*
|
|
4
|
+
* These support components and layout primitives without becoming a
|
|
5
|
+
* utility-first API.
|
|
6
|
+
*/
|
|
7
|
+
.sr-only {
|
|
8
|
+
position: absolute;
|
|
9
|
+
inline-size: 1px;
|
|
10
|
+
block-size: 1px;
|
|
11
|
+
padding: 0;
|
|
12
|
+
margin: -1px;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
clip: rect(0, 0, 0, 0);
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
border: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.overflow-auto {
|
|
20
|
+
overflow: auto;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.fit {
|
|
24
|
+
inline-size: fit-content;
|
|
25
|
+
max-inline-size: 100%;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.grow {
|
|
29
|
+
flex: 1 1 auto;
|
|
30
|
+
min-inline-size: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nowrap {
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.truncate {
|
|
38
|
+
min-inline-size: 0;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
text-overflow: ellipsis;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.items-start {
|
|
45
|
+
--items-align: start;
|
|
46
|
+
align-items: start;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.items-center {
|
|
50
|
+
--items-align: center;
|
|
51
|
+
align-items: center;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.items-end {
|
|
55
|
+
--items-align: end;
|
|
56
|
+
align-items: end;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.muted {
|
|
60
|
+
color: var(--text-muted);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Circle: forces a fully round shape without mutating global radius tokens.
|
|
64
|
+
Works on any component because utilities load after components. .join is
|
|
65
|
+
the one exception: it writes corner longhands on its children, so
|
|
66
|
+
.join.circle keeps its own --join-radius hook (join.css). */
|
|
67
|
+
.circle {
|
|
68
|
+
border-radius: var(--radius-full);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Text alignment — uses logical properties so it follows writing direction. */
|
|
72
|
+
.text-start {
|
|
73
|
+
--text-align: start;
|
|
74
|
+
text-align: start;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.text-center {
|
|
78
|
+
--text-align: center;
|
|
79
|
+
text-align: center;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.text-end {
|
|
83
|
+
--text-align: end;
|
|
84
|
+
text-align: end;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Text wrapping utilities. Unsupported values are ignored by the browser. */
|
|
88
|
+
.text-balance {
|
|
89
|
+
text-wrap: balance;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.text-pretty {
|
|
93
|
+
text-wrap: pretty;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Generic list chrome reset. Use for any list (tags, footer columns, related
|
|
97
|
+
items). For navigation lists, prefer .nav-list for semantic intent.
|
|
98
|
+
components/{actions,navbar}.css apply their own resets where needed. */
|
|
99
|
+
.list-reset {
|
|
100
|
+
margin: 0;
|
|
101
|
+
padding: 0;
|
|
102
|
+
list-style: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Separator dot for inline metadata rows (byline, timestamp, read time,
|
|
106
|
+
breadcrumb-like facts). Pair with a flex/cluster container; flex: none
|
|
107
|
+
keeps a flex parent from shrinking it to nothing. */
|
|
108
|
+
.dot {
|
|
109
|
+
flex: none;
|
|
110
|
+
inline-size: 0.25rem;
|
|
111
|
+
block-size: 0.25rem;
|
|
112
|
+
border-radius: var(--radius-full);
|
|
113
|
+
background: var(--text-subtle);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Link variants for chrome that should not look like prose links. Use in
|
|
117
|
+
navigation, tags, footer, tables of contents, and similar surfaces. */
|
|
118
|
+
.link-plain {
|
|
119
|
+
color: inherit;
|
|
120
|
+
text-decoration: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.link-muted {
|
|
124
|
+
color: var(--text-muted);
|
|
125
|
+
text-decoration: none;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@media (hover: hover) {
|
|
129
|
+
.link-muted:hover {
|
|
130
|
+
color: var(--text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Spacing step helpers — semantic scale (sm / default / lg).
|
|
135
|
+
Names map to logical properties for writing-direction safety. The default
|
|
136
|
+
step uses --space-4, which matches the framework's built-in padding on
|
|
137
|
+
cards, dialogs, and form-actions. */
|
|
138
|
+
|
|
139
|
+
/* Gap */
|
|
140
|
+
.gap-none {
|
|
141
|
+
gap: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Padding — block (vertical) */
|
|
145
|
+
.py {
|
|
146
|
+
padding-block: var(--variant-space);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Padding — inline (horizontal) */
|
|
150
|
+
.px {
|
|
151
|
+
padding-inline: var(--variant-space);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Margin — block-start */
|
|
155
|
+
.mbs {
|
|
156
|
+
margin-block-start: var(--variant-space);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Margin — block-end */
|
|
160
|
+
.mbe {
|
|
161
|
+
margin-block-end: var(--variant-space);
|
|
162
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Cross-cutting variant token mapping.
|
|
3
|
+
*
|
|
4
|
+
* Variant scope: .solid/.soft/.outline set --ui-* for components that opt into
|
|
5
|
+
* shared variants. Component-specific defaults (.btn solid-by-default, .badge
|
|
6
|
+
* solid-by-default, .alert soft-by-default) live with their components:
|
|
7
|
+
* components/button.css — .btn default solid + .btn.ghost + .btn.link
|
|
8
|
+
* components/badge.css — .badge default solid
|
|
9
|
+
* components/alert.css — .alert default + .alert.solid/.alert.outline
|
|
10
|
+
*
|
|
11
|
+
* .sm/.lg set shared size tokens. Controls consume --control-*; non-control
|
|
12
|
+
* components may consume --variant-* when the variant should affect their
|
|
13
|
+
* local density. Compact components may consume --variant-compact-*.
|
|
14
|
+
* Components with bespoke sizing (spinner, avatar) define local .sm/.lg;
|
|
15
|
+
* applying .sm/.lg to card, dialog, accordion, navbar, table is
|
|
16
|
+
* a no-op.
|
|
17
|
+
*
|
|
18
|
+
* Intent list source of truth: intents.css. Components consume --intent* tokens
|
|
19
|
+
* instead of re-listing intent classes.
|
|
20
|
+
*/
|
|
21
|
+
.solid {
|
|
22
|
+
--ui-bg: var(--intent, var(--neutral));
|
|
23
|
+
--ui-fg: var(--intent-fg, var(--neutral-fg));
|
|
24
|
+
--ui-border: transparent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.soft {
|
|
28
|
+
--ui-bg: var(--surface-subtle);
|
|
29
|
+
--ui-border: var(--border);
|
|
30
|
+
--ui-fg: var(--intent, var(--text));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.outline {
|
|
34
|
+
--ui-bg: transparent;
|
|
35
|
+
--ui-fg: var(--intent, var(--text));
|
|
36
|
+
--ui-border: var(--intent, var(--border));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.sm {
|
|
40
|
+
--gap: var(--space-2);
|
|
41
|
+
--variant-space: var(--space-2);
|
|
42
|
+
--control-size: var(--control-size-sm);
|
|
43
|
+
--control-font-size: var(--font-size-sm);
|
|
44
|
+
--variant-font-size: var(--font-size-sm);
|
|
45
|
+
--variant-icon-size: 1rem;
|
|
46
|
+
--variant-pad-block: var(--space-3);
|
|
47
|
+
--variant-compact-size: 1.25rem;
|
|
48
|
+
--variant-compact-font-size: 0.75rem;
|
|
49
|
+
}
|
|
50
|
+
.lg {
|
|
51
|
+
--gap: var(--space-5);
|
|
52
|
+
--variant-space: var(--space-5);
|
|
53
|
+
--control-size: var(--control-size-lg);
|
|
54
|
+
--control-font-size: var(--font-size-lg);
|
|
55
|
+
--variant-font-size: var(--font-size-lg);
|
|
56
|
+
--variant-icon-size: 1.5rem;
|
|
57
|
+
--variant-pad-block: var(--space-5);
|
|
58
|
+
--variant-compact-size: 1.875rem;
|
|
59
|
+
--variant-compact-font-size: var(--font-size-sm);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Modern color enhancement. Baseline variant values above remain usable
|
|
63
|
+
everywhere. Interactive states live in the components that consume them. */
|
|
64
|
+
@supports (color: color-mix(in oklch, red, white)) {
|
|
65
|
+
.soft {
|
|
66
|
+
/* @sync soft-recipe */
|
|
67
|
+
--ui-bg: color-mix(in oklch, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
|
|
68
|
+
--ui-border: color-mix(
|
|
69
|
+
in oklch,
|
|
70
|
+
var(--surface) var(--soft-border-mix),
|
|
71
|
+
var(--intent, var(--neutral))
|
|
72
|
+
);
|
|
73
|
+
/* @sync soft-recipe */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Interactive hover recipe shared by .btn and .badge. Declared outside :hover
|
|
78
|
+
on purpose: --ui-hover-bg is only consumed inside their @media (hover: hover)
|
|
79
|
+
rules, so these values are inert until hovered. :is() carries real
|
|
80
|
+
specificity so the components' :where() intent boundaries
|
|
81
|
+
(--ui-hover-bg: initial) cannot override the recipe. The solid/soft overlay
|
|
82
|
+
darkening lives in the consuming components. */
|
|
83
|
+
:is(.btn, a.badge, button.badge):is(.outline, .ghost) {
|
|
84
|
+
--ui-hover-bg: var(--surface-subtle);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@supports (color: color-mix(in oklch, red, white)) {
|
|
88
|
+
/* Solid has no literal class requirement: bare .btn/.badge default to solid,
|
|
89
|
+
so this must match anything that isn't one of the other variants rather
|
|
90
|
+
than an explicit .solid class. Stacks with the box-shadow overlay the
|
|
91
|
+
consuming components apply on top for extra depth. */
|
|
92
|
+
:is(.btn, a.badge, button.badge):not(:where(.soft, .outline, .ghost, .link)) {
|
|
93
|
+
--ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Same recipe as the resting .soft bg with the surface share reduced by
|
|
97
|
+
--soft-hover-alpha: stays opaque, so it reads on any backdrop instead of
|
|
98
|
+
letting the container show through on hover. */
|
|
99
|
+
:is(.btn, a.badge, button.badge):is(.soft) {
|
|
100
|
+
--ui-hover-bg: color-mix(
|
|
101
|
+
in oklch,
|
|
102
|
+
var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
|
|
103
|
+
var(--intent, var(--neutral))
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Mixed against the opaque --surface-subtle rather than transparent: a
|
|
108
|
+
transparent-based tint reads clean on light surfaces but goes muddy and
|
|
109
|
+
low-contrast on dark ones, since the same low alpha lands very
|
|
110
|
+
differently over a light vs. a near-black backdrop. */
|
|
111
|
+
:is(.btn, a.badge, button.badge):is(.outline) {
|
|
112
|
+
--ui-hover-bg: color-mix(in oklch, var(--surface-subtle) 92%, var(--intent, var(--neutral)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
:is(.btn, a.badge, button.badge):is(.ghost) {
|
|
116
|
+
--ui-hover-bg: color-mix(in oklch, var(--intent, var(--text)) 8%, transparent);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Command — stateless command/commandfor routing.
|
|
3
|
+
*
|
|
4
|
+
* The registry tracks command handlers, never triggers or targets. One click
|
|
5
|
+
* listener per document reads the current DOM at event time, resolves the
|
|
6
|
+
* current commandfor target, and routes the action. Newly inserted triggers,
|
|
7
|
+
* late targets, changed commandfor values, and same-id replacements therefore
|
|
8
|
+
* work immediately without an observer, scan, refresh call, or per-element
|
|
9
|
+
* listener.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* One registry per document owns its delegated listener and command handlers.
|
|
13
|
+
* The WeakMap does not retain discarded documents; registry entries contain
|
|
14
|
+
* behavior definitions only, never DOM triggers or resolved targets. */
|
|
15
|
+
const registries = new WeakMap();
|
|
16
|
+
|
|
17
|
+
function commandNames(commands, caller) {
|
|
18
|
+
const names = Array.isArray(commands) ? commands : [commands];
|
|
19
|
+
if (!names.length || names.some((name) => typeof name !== "string" || !name)) {
|
|
20
|
+
throw new TypeError(`${caller} requires one or more command names.`);
|
|
21
|
+
}
|
|
22
|
+
return [...new Set(names)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Native command keywords are ASCII case-insensitive. Custom commands keep
|
|
26
|
+
// their exact spelling, as required by the command invoker contract.
|
|
27
|
+
function commandKey(command) {
|
|
28
|
+
return command.startsWith("--")
|
|
29
|
+
? command
|
|
30
|
+
: command.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a trigger's current `commandfor` target in the same document or
|
|
35
|
+
* shadow root.
|
|
36
|
+
*
|
|
37
|
+
* @param {Element} trigger Element carrying the `commandfor` attribute.
|
|
38
|
+
* @returns {Element | null} The current target, or `null` when it cannot be resolved.
|
|
39
|
+
*/
|
|
40
|
+
export function targetFor(trigger) {
|
|
41
|
+
const id = trigger.getAttribute("commandfor");
|
|
42
|
+
if (!id) return null;
|
|
43
|
+
|
|
44
|
+
const root = trigger.getRootNode();
|
|
45
|
+
return root.getElementById?.(id) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the button selector corresponding to one or more command names.
|
|
50
|
+
*
|
|
51
|
+
* @param {string | string[]} commands Command name or names to include.
|
|
52
|
+
* @returns {string} A selector matching command buttons with `commandfor`.
|
|
53
|
+
* @throws {TypeError} When no valid command name is provided.
|
|
54
|
+
*/
|
|
55
|
+
export function commandSelector(commands) {
|
|
56
|
+
const attributes = commandNames(commands, "commandSelector()").map(
|
|
57
|
+
(name) => `[command="${name.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`,
|
|
58
|
+
);
|
|
59
|
+
return `button[commandfor]:is(${attributes.join(", ")})`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function triggerFromEvent(event, doc) {
|
|
63
|
+
const ElementClass = doc.defaultView?.Element;
|
|
64
|
+
if (!ElementClass) return null;
|
|
65
|
+
|
|
66
|
+
for (const node of event.composedPath?.() ?? [event.target]) {
|
|
67
|
+
if (node instanceof ElementClass && node.matches("button[commandfor][command]")) {
|
|
68
|
+
return node;
|
|
69
|
+
}
|
|
70
|
+
if (node === doc) break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createRegistry(doc) {
|
|
77
|
+
const commands = new Map();
|
|
78
|
+
|
|
79
|
+
function route(event) {
|
|
80
|
+
if (event.defaultPrevented) return;
|
|
81
|
+
|
|
82
|
+
const trigger = triggerFromEvent(event, doc);
|
|
83
|
+
if (!trigger || trigger.disabled) return;
|
|
84
|
+
|
|
85
|
+
const command = commandKey(trigger.getAttribute("command"));
|
|
86
|
+
const registration = commands.get(command);
|
|
87
|
+
if (!registration) return;
|
|
88
|
+
|
|
89
|
+
const target = registration.resolve(trigger);
|
|
90
|
+
if (!target) return;
|
|
91
|
+
|
|
92
|
+
registration.prepare?.(trigger, target, command);
|
|
93
|
+
registration.handle(event, trigger, target, command);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
doc.addEventListener("click", route);
|
|
97
|
+
return { commands, route };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register one behavior for one or more command names.
|
|
102
|
+
*
|
|
103
|
+
* `prepare`, when present, runs immediately before `handle` on every matching
|
|
104
|
+
* action. It must be idempotent; it is useful for semantics derived from the
|
|
105
|
+
* resolved target, without introducing a connection lifecycle.
|
|
106
|
+
*
|
|
107
|
+
* @param {string | string[]} commands Command name or names owned by this behavior.
|
|
108
|
+
* @param {object} options Behavior callbacks.
|
|
109
|
+
* @param {(trigger: HTMLButtonElement) => Element | null} [options.resolve=targetFor]
|
|
110
|
+
* Resolver evaluated for every action.
|
|
111
|
+
* @param {(trigger: HTMLButtonElement, target: Element, command: string) => void} [options.prepare]
|
|
112
|
+
* Optional idempotent callback run immediately before the handler.
|
|
113
|
+
* @param {(event: MouseEvent, trigger: HTMLButtonElement, target: Element, command: string) => void} options.handle
|
|
114
|
+
* Command handler.
|
|
115
|
+
* @returns {{ disconnect: () => void }} An idempotent registration teardown handle.
|
|
116
|
+
* @throws {TypeError} When command names or callbacks are invalid.
|
|
117
|
+
* @throws {Error} When a command already has an owner in the current document.
|
|
118
|
+
*/
|
|
119
|
+
export function registerCommands(commands, { resolve = targetFor, prepare, handle } = {}) {
|
|
120
|
+
const names = [
|
|
121
|
+
...new Set(commandNames(commands, "registerCommands()").map((name) => commandKey(name))),
|
|
122
|
+
];
|
|
123
|
+
if (
|
|
124
|
+
typeof resolve !== "function" ||
|
|
125
|
+
(prepare !== undefined && typeof prepare !== "function") ||
|
|
126
|
+
typeof handle !== "function"
|
|
127
|
+
) {
|
|
128
|
+
throw new TypeError("registerCommands() requires function callbacks.");
|
|
129
|
+
}
|
|
130
|
+
if (typeof document === "undefined") return { disconnect() {} };
|
|
131
|
+
|
|
132
|
+
const doc = document;
|
|
133
|
+
let registry = registries.get(doc);
|
|
134
|
+
if (!registry) {
|
|
135
|
+
registry = createRegistry(doc);
|
|
136
|
+
registries.set(doc, registry);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const conflict = names.find((name) => registry.commands.has(name));
|
|
140
|
+
if (conflict) {
|
|
141
|
+
throw new Error(`Command "${conflict}" is already registered in this document.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const registration = { resolve, prepare, handle };
|
|
145
|
+
for (const name of names) registry.commands.set(name, registration);
|
|
146
|
+
|
|
147
|
+
let connected = true;
|
|
148
|
+
return {
|
|
149
|
+
disconnect() {
|
|
150
|
+
if (!connected) return;
|
|
151
|
+
connected = false;
|
|
152
|
+
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
if (registry.commands.get(name) === registration) {
|
|
155
|
+
registry.commands.delete(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!registry.commands.size) {
|
|
160
|
+
doc.removeEventListener("click", registry.route);
|
|
161
|
+
registries.delete(doc);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|