staffa 0.14.0 → 0.16.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/README.md +99 -271
- package/dist/components/autocomplete.js +4 -5
- package/dist/components/box.js +11 -21
- package/dist/components/button.d.ts +20 -5
- package/dist/components/button.js +55 -47
- package/dist/components/buttonChooser.js +1 -3
- package/dist/components/checkbox.js +1 -2
- package/dist/components/dialog.d.ts +9 -2
- package/dist/components/dialog.js +29 -35
- package/dist/components/field.d.ts +5 -8
- package/dist/components/field.js +4 -6
- package/dist/components/form.d.ts +5 -7
- package/dist/components/form.js +6 -9
- package/dist/components/keyhelp.d.ts +22 -0
- package/dist/components/keyhelp.js +91 -0
- package/dist/components/main.js +190 -318
- package/dist/components/menu.d.ts +36 -9
- package/dist/components/menu.js +193 -144
- package/dist/components/panels.d.ts +152 -232
- package/dist/components/panels.js +341 -556
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +40 -63
- package/dist/components/textline.d.ts +3 -5
- package/dist/components/textline.js +3 -5
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +3 -6
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +13 -22
- package/dist/core.d.ts +17 -39
- package/dist/core.js +13 -35
- package/dist/icons-helpers.d.ts +3 -3
- package/dist/icons-helpers.js +6 -11
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -4
- package/dist/keys.d.ts +92 -0
- package/dist/keys.js +279 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +4 -10
- package/dist/theme.js +58 -123
- package/package.json +2 -2
- package/skill/ButtonOptions.md +12 -0
- package/skill/DialogOptions.md +11 -2
- package/skill/FieldOptions.md +3 -5
- package/skill/IconButtonOptions.md +8 -0
- package/skill/MenuItem.md +22 -3
- package/skill/Panel.md +8 -0
- package/skill/SKILL.md +161 -294
- package/skill/addTooltip.md +4 -5
- package/skill/bindKey.md +51 -0
- package/skill/box.md +1 -1
- package/skill/form.md +5 -7
- package/skill/formatKey.md +21 -0
- package/skill/iconButton.md +4 -5
- package/skill/scrollStrip.md +7 -9
- package/skill/showFloatingMenu.md +2 -2
- package/skill/showKeyHelp.md +17 -0
- package/skill/tabs.md +3 -4
- package/skill/textline.md +3 -5
- package/src/components/autocomplete.ts +4 -5
- package/src/components/box.ts +11 -21
- package/src/components/button.ts +70 -47
- package/src/components/buttonChooser.ts +1 -3
- package/src/components/checkbox.ts +1 -2
- package/src/components/dialog.ts +39 -37
- package/src/components/field.ts +7 -11
- package/src/components/form.ts +6 -9
- package/src/components/keyhelp.ts +96 -0
- package/src/components/main.ts +194 -318
- package/src/components/menu.ts +209 -146
- package/src/components/panels.ts +389 -618
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +40 -63
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +4 -9
- package/src/components/tooltip.ts +13 -22
- package/src/core.ts +17 -43
- package/src/icons-helpers.ts +6 -11
- package/src/index.ts +5 -4
- package/src/keys.ts +300 -0
- package/src/theme.ts +58 -123
- package/skill/Attributes.md +0 -10
package/src/keys.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import type { Slot } from "./core.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keyboard shortcuts: a registry of live bindings served by one document-level
|
|
6
|
+
* `keydown` listener, and writing a combination back out the way this platform
|
|
7
|
+
* writes it. The spelling of a combination is documented on {@link bindKey}.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether this is an Apple platform, where the modifier is ⌘ rather than Ctrl.
|
|
12
|
+
* `platform` is deprecated but frozen rather than removed, and the user agent
|
|
13
|
+
* behind it says Macintosh anyway — an iPad asking for the desktop site
|
|
14
|
+
* included, which is the right answer here.
|
|
15
|
+
*/
|
|
16
|
+
const IS_APPLE = typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);
|
|
17
|
+
|
|
18
|
+
/** Spellings people reach for that aren't `KeyboardEvent.key` values. */
|
|
19
|
+
const ALIASES: Record<string, string> = { esc: "escape", space: " " };
|
|
20
|
+
|
|
21
|
+
/** How a key is written in a hint, where its canonical name won't do. */
|
|
22
|
+
const GLYPHS: Record<string, string> = { " ": "Space", escape: "Esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
|
|
23
|
+
|
|
24
|
+
/** One registered shortcut, as {@link getActiveKeyBindings} hands them out. */
|
|
25
|
+
export interface KeyBinding {
|
|
26
|
+
/**
|
|
27
|
+
* What it does — a rich-text string or draw function, shown in the shortcut
|
|
28
|
+
* overview. Without one, the binding stays out of the overview.
|
|
29
|
+
*/
|
|
30
|
+
description?: Slot;
|
|
31
|
+
/**
|
|
32
|
+
* Runs on the keystroke, after `preventDefault()`. Without one, the binding
|
|
33
|
+
* only *describes* the key (which is handled elsewhere) — it is listed and
|
|
34
|
+
* shadows same-key bindings further out, but the keystroke passes untouched.
|
|
35
|
+
*/
|
|
36
|
+
press?: (e: KeyboardEvent) => void;
|
|
37
|
+
/** Keeps working while a modal owns the keyboard. */
|
|
38
|
+
global?: boolean;
|
|
39
|
+
/** The same-key binding this one shadows, restored when this one is removed. */
|
|
40
|
+
prev?: KeyBinding;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Live bindings, kept per element per canonical key string. The element a
|
|
45
|
+
* binding is stored at decides when it applies: the keydown handler walks up
|
|
46
|
+
* the tree from the focused element and takes the first match.
|
|
47
|
+
*/
|
|
48
|
+
const bindings = new WeakMap<Element, Map<string, KeyBinding>>();
|
|
49
|
+
|
|
50
|
+
/** Elements that claimed the keyboard, in claiming order. The last one rules. */
|
|
51
|
+
const modalStack: Element[] = [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Give the current element the keyboard, until the returned release function is
|
|
55
|
+
* called: normal bindings drawn inside it register at it, so they die with it
|
|
56
|
+
* and never fire once focus (and the walk up from it) has moved to a later
|
|
57
|
+
* claim — while bindings from outside any claim are silenced, the `global`
|
|
58
|
+
* ones excepted. What a modal dialog does while it is up.
|
|
59
|
+
*/
|
|
60
|
+
export function claimKeyboard(): () => void {
|
|
61
|
+
const el = A() as Element | undefined;
|
|
62
|
+
if (!el) throw new Error("Staffa: claimKeyboard needs a current element");
|
|
63
|
+
modalStack.push(el);
|
|
64
|
+
return () => {
|
|
65
|
+
const i = modalStack.indexOf(el);
|
|
66
|
+
if (i >= 0) modalStack.splice(i, 1);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A spec reduced to its canonical form, the registry's index — simply the spec
|
|
72
|
+
* lower-cased, aliases resolved: an optional `mod+`, an optional `shift+`,
|
|
73
|
+
* then the `KeyboardEvent.key` value. Throws on anything else, loudly: a
|
|
74
|
+
* shortcut is invisible until it fails to fire, so a typo must not wait for
|
|
75
|
+
* the keystroke that needed it.
|
|
76
|
+
*/
|
|
77
|
+
function canonKey(spec: string): string {
|
|
78
|
+
const [, mod, shift, name] = /^(mod\+)?(shift\+)?(.*)$/i.exec(spec)!;
|
|
79
|
+
let key = name.toLowerCase();
|
|
80
|
+
key = ALIASES[key] ?? key;
|
|
81
|
+
if (!key || (key.length > 1 && /[-+]/.test(key))) {
|
|
82
|
+
throw new Error(`Staffa: can't parse key "${spec}" — write "k", "f2", "mod+k" or "mod+shift+f2"`);
|
|
83
|
+
}
|
|
84
|
+
// The typed character is a combination's one name: `?` is what shift-/ types.
|
|
85
|
+
if (shift && key.toUpperCase() === key) {
|
|
86
|
+
throw new Error(`Staffa: "${spec}" — write the shifted character itself ("?", not "shift+/")`);
|
|
87
|
+
}
|
|
88
|
+
return (mod ? "mod+" : "") + (shift ? "shift+" : "") + key;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The canonical key string for a keystroke, or `null` for one that can't be a shortcut. */
|
|
92
|
+
function canonEvent(e: KeyboardEvent): string | null {
|
|
93
|
+
// Alt is never bound, nor is Ctrl on a Mac: a keystroke holding one down
|
|
94
|
+
// belongs to the app, the browser or the OS — not to us.
|
|
95
|
+
if (e.altKey || (IS_APPLE ? e.ctrlKey : e.metaKey)) return null;
|
|
96
|
+
const key = e.key.toLowerCase();
|
|
97
|
+
// Shift counts only where it doesn't already shape the typed character: it
|
|
98
|
+
// turns k into K and holds during F2, but *is* the difference between / and
|
|
99
|
+
// ? — and Caps Lock's capitals don't register.
|
|
100
|
+
const shift = e.shiftKey && (key.length > 1 || e.key.toUpperCase() !== key);
|
|
101
|
+
return ((IS_APPLE ? e.metaKey : e.ctrlKey) ? "mod+" : "") + (shift ? "shift+" : "") + key;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether the focused element keeps this keystroke for itself: anything being
|
|
106
|
+
* typed into keeps the unmodified keys (Escape excepted — it never types), a
|
|
107
|
+
* button-like control keeps its activation keys, and a link keeps Enter even
|
|
108
|
+
* modified — that one is the keyboard's own open-in-a-new-tab, the counterpart
|
|
109
|
+
* of a ctrl-click. The one answer the matcher and the `?` overview share, so
|
|
110
|
+
* what is listed and what fires can never disagree.
|
|
111
|
+
*/
|
|
112
|
+
function keptByTarget(keyStr: string, target: Element | null): boolean {
|
|
113
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
114
|
+
const mod = keyStr.startsWith("mod+");
|
|
115
|
+
// Modifiers stripped: a shifted Enter is still the link's new-window Enter.
|
|
116
|
+
const key = keyStr.replace(/^(mod\+)?(shift\+)?/, "");
|
|
117
|
+
if (key === "enter" && target.closest("a[href]") != null) return true;
|
|
118
|
+
if (!mod && (key === "enter" || key === " ") && target.closest("button, summary, [role=button]") != null) return true;
|
|
119
|
+
const tag = target.tagName;
|
|
120
|
+
return !mod && key !== "escape" &&
|
|
121
|
+
(tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether a binding found at `el` applies, given the current keyboard claim. */
|
|
125
|
+
function reachable(el: Element, b: KeyBinding): boolean {
|
|
126
|
+
const modal = modalStack[modalStack.length - 1];
|
|
127
|
+
return b.global === true || !modal || modal.contains(el);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The innermost claiming element containing `el`, if any. */
|
|
131
|
+
function claimFor(el: Element): Element | undefined {
|
|
132
|
+
for (let i = modalStack.length - 1; i >= 0; i--) {
|
|
133
|
+
if (modalStack[i].contains(el)) return modalStack[i];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The element whose claim owns the keyboard right now: the top claim, or the body. */
|
|
138
|
+
export function keyboardOwner(): Element {
|
|
139
|
+
return modalStack[modalStack.length - 1] ?? document.body;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Where the binding search starts: the focused element — or the claiming modal
|
|
144
|
+
* itself when focus has strayed outside it (a dialog holding nothing
|
|
145
|
+
* focusable, a click that blurred to the body), so a modal never loses its own
|
|
146
|
+
* keys.
|
|
147
|
+
*/
|
|
148
|
+
function walkStart(target: Element | null): Element {
|
|
149
|
+
const modal = modalStack[modalStack.length - 1];
|
|
150
|
+
return modal && !(target && modal.contains(target)) ? modal : target ?? document.body;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let listening = false;
|
|
154
|
+
|
|
155
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
156
|
+
// Something already answered for this keystroke — a handler of the app's own,
|
|
157
|
+
// a menu's or an autocomplete's; element listeners run before this one.
|
|
158
|
+
if (e.defaultPrevented || e.repeat || e.isComposing) return;
|
|
159
|
+
const keyStr = canonEvent(e);
|
|
160
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
161
|
+
if (keyStr == null || keptByTarget(keyStr, target)) return;
|
|
162
|
+
for (let el: Element | null = walkStart(target); el; el = el.parentElement) {
|
|
163
|
+
const b = bindings.get(el)?.get(keyStr);
|
|
164
|
+
if (b && reachable(el, b)) {
|
|
165
|
+
// A describe-only binding still ends the search: the key is somebody
|
|
166
|
+
// else's, and the keystroke passes untouched.
|
|
167
|
+
if (b.press) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
b.press(e);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What a keypress aimed at `target` — the focused element, typically — could do
|
|
178
|
+
* right now: for each combination, the binding the walk up from `target` would
|
|
179
|
+
* find, minus the keystrokes `target` keeps for itself. Innermost first, as
|
|
180
|
+
* `[keyStr, binding]` pairs. A snapshot, not reactive.
|
|
181
|
+
*/
|
|
182
|
+
export function getActiveKeyBindings(target: Element | null): Array<[string, KeyBinding]> {
|
|
183
|
+
const found = new Map<string, KeyBinding>();
|
|
184
|
+
for (let el: Element | null = walkStart(target); el; el = el.parentElement) {
|
|
185
|
+
const map = bindings.get(el);
|
|
186
|
+
if (map) {
|
|
187
|
+
for (const [keyStr, b] of map) {
|
|
188
|
+
if (!found.has(keyStr) && reachable(el, b) && !keptByTarget(keyStr, target)) found.set(keyStr, b);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return [...found];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Bind a keyboard shortcut, for as long as the calling scope lives.
|
|
197
|
+
*
|
|
198
|
+
* **The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
|
|
199
|
+
* (`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
|
|
200
|
+
* `mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
|
|
201
|
+
* `"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
|
|
202
|
+
* Shift itself types is written as that character — `"?"`, never `"shift+/"` —
|
|
203
|
+
* so a combination works on every keyboard layout. No other modifiers are
|
|
204
|
+
* offered: Alt and the ⊞ key belong to the browser and the OS, which also
|
|
205
|
+
* keep some `mod` combinations for themselves — T, N, W, Q and the digits
|
|
206
|
+
* among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
|
|
207
|
+
* takes a `key` option is spelled this way, and {@link formatKey} turns it
|
|
208
|
+
* back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
|
|
209
|
+
*
|
|
210
|
+
* `description` is what the shortcut overview (see {@link showKeyHelp}) lists
|
|
211
|
+
* the binding as — a rich-text string or draw function; without one the
|
|
212
|
+
* binding stays out of the overview. Omit `press` to merely *describe* a key
|
|
213
|
+
* your app handles by other means, so the overview can still tell the user
|
|
214
|
+
* about it.
|
|
215
|
+
*
|
|
216
|
+
* A handler of the app's own that ran `preventDefault()` first always wins,
|
|
217
|
+
* and keystrokes the focused element owns (typing into a field, Enter on a
|
|
218
|
+
* link) are left to it. Otherwise `mode` says who else can reach the binding:
|
|
219
|
+
*
|
|
220
|
+
* - `"normal"` (the default): works app-wide, but is silenced while a modal
|
|
221
|
+
* dialog from outside it is up. Binding the same combination again shadows
|
|
222
|
+
* the earlier binding until the new scope dies — so a state can take a key
|
|
223
|
+
* over temporarily.
|
|
224
|
+
* - `"global"`: keeps working even over a modal.
|
|
225
|
+
* - `"local"`: only fires while the keyboard focus is inside the current
|
|
226
|
+
* element — for a shortcut that belongs to one row or panel of many.
|
|
227
|
+
* - an `Element`: like `"local"`, but for that element rather than the
|
|
228
|
+
* current one.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```ts
|
|
232
|
+
* S.bindKey("mod+k", "Search", openSearch);
|
|
233
|
+
* S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
export function bindKey(spec: string, description?: Slot, press?: (e: KeyboardEvent) => void, mode: "normal" | "global" | "local" | Element = "normal"): void {
|
|
237
|
+
const cur = A() as Element | undefined;
|
|
238
|
+
// A normal binding is anchored by containment, not by whatever claim is top
|
|
239
|
+
// at call time: a scope redrawn elsewhere while a dialog is up must not
|
|
240
|
+
// hitch its keys to that dialog and die with it.
|
|
241
|
+
const el = mode === "global" ? document.body
|
|
242
|
+
: mode === "local" ? cur
|
|
243
|
+
: mode === "normal" ? (cur && claimFor(cur)) ?? document.body
|
|
244
|
+
: mode;
|
|
245
|
+
if (!el) throw new Error("Staffa: a local key binding needs a current element");
|
|
246
|
+
const keyStr = canonKey(spec);
|
|
247
|
+
let map = bindings.get(el);
|
|
248
|
+
if (!map) bindings.set(el, map = new Map());
|
|
249
|
+
// Shadow (not replace) any same-key binding already at this element; the
|
|
250
|
+
// scope's cleanup below restores it.
|
|
251
|
+
const binding: KeyBinding = { description, press, global: mode === "global", prev: map.get(keyStr) };
|
|
252
|
+
map.set(keyStr, binding);
|
|
253
|
+
if (!listening) {
|
|
254
|
+
listening = true;
|
|
255
|
+
document.addEventListener("keydown", onKeydown);
|
|
256
|
+
}
|
|
257
|
+
A.clean(() => {
|
|
258
|
+
// Unlink, wherever in the shadow chain the binding sits by now.
|
|
259
|
+
let b = map.get(keyStr);
|
|
260
|
+
if (b === binding) {
|
|
261
|
+
if (binding.prev) map.set(keyStr, binding.prev);
|
|
262
|
+
else map.delete(keyStr);
|
|
263
|
+
} else {
|
|
264
|
+
for (; b; b = b.prev) {
|
|
265
|
+
if (b.prev === binding) { b.prev = binding.prev; break; }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
|
|
273
|
+
* `"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
|
|
274
|
+
* use it for the same hint elsewhere in your app, so both spell the shortcut the
|
|
275
|
+
* way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
|
|
276
|
+
* spelling instead: full modifier names and real key names,
|
|
277
|
+
* `"Meta+Shift+K"`/`"Control+Shift+K"`.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export function formatKey(spec: string, aria = false): string {
|
|
285
|
+
const keyStr = canonKey(spec);
|
|
286
|
+
const mod = keyStr.startsWith("mod+");
|
|
287
|
+
const rest = mod ? keyStr.slice(4) : keyStr;
|
|
288
|
+
const shift = rest.startsWith("shift+");
|
|
289
|
+
const key = shift ? rest.slice(6) : rest;
|
|
290
|
+
const cap = key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1);
|
|
291
|
+
if (aria) {
|
|
292
|
+
const name = key === " " ? "Space" : shift || key.length > 1 ? cap : key;
|
|
293
|
+
return (mod ? (IS_APPLE ? "Meta+" : "Control+") : "") + (shift ? "Shift+" : "") + name;
|
|
294
|
+
}
|
|
295
|
+
// Apple writes ⇧ before ⌘, and nothing between the glyphs.
|
|
296
|
+
const name = GLYPHS[key] ?? cap;
|
|
297
|
+
return IS_APPLE
|
|
298
|
+
? (shift ? "⇧" : "") + (mod ? "⌘" : "") + name
|
|
299
|
+
: (mod ? "Ctrl+" : "") + (shift ? "Shift+" : "") + name;
|
|
300
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -3,54 +3,23 @@ import A from "aberdeen";
|
|
|
3
3
|
/**
|
|
4
4
|
* Theming and global base styles for Staffa.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* A Staffa app is a tree of **surfaces** (`.s-s`), in two families:
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - **Neutral** — `.neutral`, and the implicit page at `:root`. Its shade steps
|
|
9
|
+
* with nesting depth (capped). No `tonal`/`outlined` variants.
|
|
10
|
+
* - **Accent** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare
|
|
11
|
+
* `.s-s` is primary): a bright fill with white ink, `.tonal`/`.outlined`
|
|
12
|
+
* variants supported. A surface nested *inside* one is forced back to filled,
|
|
13
|
+
* so it can't bleed into the vivid parent.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* is always rendered filled, so it can't bleed into the vivid parent.
|
|
21
|
-
*
|
|
22
|
-
* # Contextual tokens
|
|
23
|
-
*
|
|
24
|
-
* Inside any surface (including `:root`) these inherited custom properties are
|
|
25
|
-
* defined, so widgets adapt to wherever they're nested:
|
|
26
|
-
*
|
|
27
|
-
* | token | meaning |
|
|
28
|
-
* | ------------ | --------------------------------------------------------- |
|
|
29
|
-
* | `--s-bg` | the surface background |
|
|
30
|
-
* | `--s-text` | default ink (also applied as `color`) |
|
|
31
|
-
* | `--s-muted` | secondary text (subtitles, help) |
|
|
32
|
-
* | `--s-accent` | the surface's "pop" — the brand primary on neutral surfaces, the ink on accent surfaces |
|
|
33
|
-
* | `--s-faint` | hairline, derived from text/bg |
|
|
34
|
-
*
|
|
35
|
-
* The brand/semantic colours are mode-independent and settable: `--s-primary`
|
|
36
|
-
* (the one brand colour — it tints the neutrals and defines `.s-s.primary`),
|
|
37
|
-
* `--s-danger`, `--s-success`, `--s-warning`, and `--s-link` (the link colour,
|
|
38
|
-
* also the fill of the `.s-s.link` surface). Links render in `--s-link` on
|
|
39
|
-
* neutral surfaces and in the ink on accent surfaces.
|
|
40
|
-
*
|
|
41
|
-
* # Borders & shadows
|
|
42
|
-
*
|
|
43
|
-
* Neutral surfaces carry a subtle hairline border by default (so a card reads as a
|
|
44
|
-
* card with no component help); it's applied through `:where()`, so a bar that
|
|
45
|
-
* wants only a divider overrides it trivially. Any surface can opt into elevation
|
|
46
|
-
* with `.shadow` or `.extra-shadow`, or drop a component's built-in shadow with
|
|
47
|
-
* `.no-shadow` (e.g. `S.button({ attrs: ".no-shadow" })`).
|
|
48
|
-
*
|
|
49
|
-
* # Customising
|
|
50
|
-
*
|
|
51
|
-
* Re-skin by overriding the colour tokens (e.g. `--s-primary`). To add your own
|
|
52
|
-
* accent surface, just set `--s-bg` (and, if needed, `--s-text`) — the gradient
|
|
53
|
-
* and the rest of the tokens follow automatically:
|
|
15
|
+
* Every surface (and `:root`) defines the inherited tokens widgets style against,
|
|
16
|
+
* so they adapt to wherever they're nested: `--s-bg`, `--s-text` (also applied as
|
|
17
|
+
* `color`), `--s-muted` (secondary text), `--s-accent` (the surface's "pop" — the
|
|
18
|
+
* brand primary on neutral surfaces, the ink on accent ones) and `--s-faint`
|
|
19
|
+
* (hairline). The brand/semantic colours — `--s-primary`, `--s-danger`,
|
|
20
|
+
* `--s-success`, `--s-warning`, `--s-link` — are mode-independent and settable;
|
|
21
|
+
* overriding them re-skins the app. A custom accent surface needs only `--s-bg`
|
|
22
|
+
* (and, if needed, `--s-text`); gradient and tokens follow:
|
|
54
23
|
*
|
|
55
24
|
* ```ts
|
|
56
25
|
* A.insertGlobalCss({ ".s-s.brand": "--s-bg:#ef6b00 --s-text:#fff" });
|
|
@@ -60,10 +29,9 @@ import A from "aberdeen";
|
|
|
60
29
|
|
|
61
30
|
/**
|
|
62
31
|
* The subtle single-colour wash a surface is painted with, as a `background:`
|
|
63
|
-
* declaration, at the given angle.
|
|
64
|
-
* property
|
|
65
|
-
*
|
|
66
|
-
* with the page's wash instead of its own `$s-bg`'s.
|
|
32
|
+
* declaration, at the given angle. A shared constant rather than a `--s-sheen`
|
|
33
|
+
* custom property: `var()`s inside a custom property resolve where it is
|
|
34
|
+
* *defined*, so every surface would get the page's wash instead of its own.
|
|
67
35
|
*/
|
|
68
36
|
const sheen = (angle: string) =>
|
|
69
37
|
`background: linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
|
|
@@ -72,16 +40,10 @@ const sheen = (angle: string) =>
|
|
|
72
40
|
export const SURFACE_SHEEN = sheen("170deg");
|
|
73
41
|
|
|
74
42
|
/**
|
|
75
|
-
* The same wash, straight down — for panels.ts, where the routed columns and
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* height, so a 430px column and the 1720px region behind it would paint
|
|
80
|
-
* different slices of the same wash and meet at a visible step. Straight down,
|
|
81
|
-
* the extent is the height alone — which every column shares exactly with the
|
|
82
|
-
* region (a column is `top:0 bottom:0` in it) and so with every other column.
|
|
83
|
-
* The 10° of tilt is worth losing there; it buys the one place in the app where
|
|
84
|
-
* boxes of *different widths* must be seamless.
|
|
43
|
+
* The same wash, straight down — for panels.ts, where the routed columns and the
|
|
44
|
+
* ground beside them have to look like one continuous surface. An angled gradient
|
|
45
|
+
* takes its extent from the box's *width* as well as its height, so boxes of
|
|
46
|
+
* different widths would paint different slices of it and meet at a visible step.
|
|
85
47
|
*/
|
|
86
48
|
export const PANEL_SHEEN = sheen("180deg");
|
|
87
49
|
|
|
@@ -129,13 +91,9 @@ export function getDarkMode(allowAuto = false): boolean | undefined {
|
|
|
129
91
|
return v === undefined && !allowAuto ? A.darkMode() : v;
|
|
130
92
|
}
|
|
131
93
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// the page (depth 0); each nested `.neutral` steps one shade up, capped at the
|
|
136
|
-
// `.neutral .neutral` rule. The accent (coloured) surfaces and everything else are
|
|
137
|
-
// mode-independent and live in the static block below.
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
94
|
+
// The only mode-dependent thing: the neutral shades and their ink, written
|
|
95
|
+
// straight onto the surfaces (no intermediate palette vars). `:root` is the page
|
|
96
|
+
// (depth 0); each nested `.neutral` steps a shade up, capped at the second level.
|
|
139
97
|
A(() => {
|
|
140
98
|
if (getDarkMode()) {
|
|
141
99
|
A.insertGlobalCss({
|
|
@@ -152,37 +110,29 @@ A(() => {
|
|
|
152
110
|
}
|
|
153
111
|
});
|
|
154
112
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// tokens are single values kept only because they're reused across components.
|
|
159
|
-
// Rule order matters: role fills come after the `:not(.neutral)` default, so a
|
|
160
|
-
// caller's `attrs` override wins at equal specificity.
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
113
|
+
// Static structure — inserted once, mode-independent. Rule order matters: role
|
|
114
|
+
// fills come after the `:not(.neutral)` default, so a caller's `attrs` override
|
|
115
|
+
// wins at equal specificity.
|
|
162
116
|
|
|
163
117
|
A.setSpacingCssVars(1.1);
|
|
164
118
|
|
|
165
119
|
A.insertGlobalCss({
|
|
166
|
-
//
|
|
120
|
+
// A lightweight reset: bare semantic HTML, with less ugly defaults.
|
|
167
121
|
"*, *::before, *::after": "box-sizing:border-box",
|
|
168
122
|
html: "text-size-adjust:100%",
|
|
169
123
|
body: "m:0 p:$3 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased background-color:$s-bg text:$s-text",
|
|
170
|
-
//
|
|
171
|
-
// surfaces, the ink on accent surfaces.
|
|
124
|
+
// The contextual link colour: `--s-link` on neutral surfaces, the ink on accent ones.
|
|
172
125
|
a: "color: $s-link-fg; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
|
|
173
126
|
"a:hover": "filter: brightness(1.15)",
|
|
174
127
|
"input, button, textarea, select, optgroup": "font:inherit color:inherit",
|
|
175
|
-
// Bare text-like fields get a calm bordered box derived from the surface.
|
|
176
|
-
//
|
|
177
|
-
// higher specificity, so this only governs otherwise-unstyled HTML. `:where()`
|
|
178
|
-
// keeps it at element specificity, so a component class always wins.
|
|
128
|
+
// Bare text-like fields get a calm bordered box derived from the surface.
|
|
129
|
+
// `:where()` keeps it at element specificity, so a component class always wins.
|
|
179
130
|
"input:where(:not([type=checkbox],[type=radio],[type=range],[type=file],[type=color],[type=image],[type=submit],[type=button],[type=reset],[type=hidden])), textarea, select":
|
|
180
131
|
"background:$s-bg border: 1px solid $s-faint; r:$s-radius-sm padding: 0.45em 0.65em; max-width:100%",
|
|
181
|
-
// Checkboxes/radios: a touch larger with a pointer cursor. (
|
|
182
|
-
//
|
|
132
|
+
// Checkboxes/radios: a touch larger, with a pointer cursor. (`accent-color` is
|
|
133
|
+
// inherited from the surface, see the `:root, .s-s` rule.)
|
|
183
134
|
"input:where([type=checkbox],[type=radio])": "width:1.15em height:1.15em cursor:pointer",
|
|
184
|
-
// Range: a thin pill track
|
|
185
|
-
// with a round brand thumb — no native groove/outline.
|
|
135
|
+
// Range: a thin faint pill track with a round brand thumb — no native groove.
|
|
186
136
|
"input[type=range]": "appearance:none background:transparent cursor:pointer vertical-align:middle",
|
|
187
137
|
"input[type=range]::-webkit-slider-runnable-track": "height:4px r:99px background:$s-faint",
|
|
188
138
|
"input[type=range]::-moz-range-track": "height:4px r:99px background:$s-faint",
|
|
@@ -223,12 +173,11 @@ A.insertGlobalCss({
|
|
|
223
173
|
// Brand sweep for the headline mark, the active nav pill, the selected tab.
|
|
224
174
|
"--s-gradient: linear-gradient(135deg, color-mix(in oklab, $s-primary, white 16%), color-mix(in oklab, $s-primary, black 14%));",
|
|
225
175
|
|
|
226
|
-
// Neutral surfaces (and the page)
|
|
227
|
-
// links use the link colour. (The bg/ink come from the mode block above.)
|
|
176
|
+
// Neutral surfaces (and the page); their bg/ink come from the mode block above.
|
|
228
177
|
":root, .s-s.neutral": "--s-accent:$s-primary --s-link-fg:$s-link",
|
|
229
178
|
|
|
230
|
-
// Accent surfaces: bright fill, white ink. The bare `:not(.neutral)`
|
|
231
|
-
// shared defaults
|
|
179
|
+
// Accent surfaces: bright fill, white ink. The bare `:not(.neutral)` holds the
|
|
180
|
+
// shared defaults; each role below names its own fill.
|
|
232
181
|
".s-s:not(.neutral)":
|
|
233
182
|
"--s-bg:$s-primary " + // Default .s-s to .primary
|
|
234
183
|
"border:0 " +
|
|
@@ -241,8 +190,8 @@ A.insertGlobalCss({
|
|
|
241
190
|
".s-s.link": "--s-bg:$s-link",
|
|
242
191
|
".s-s.primary": "--s-bg:$s-primary",
|
|
243
192
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
193
|
+
// Every surface (and the page) derives its muted ink + hairline from the
|
|
194
|
+
// text/bg pair it resolved to above.
|
|
246
195
|
":root, .s-s":
|
|
247
196
|
"--s-muted: color-mix(in oklab, $s-text, $s-bg 42%); " +
|
|
248
197
|
"--s-faint: color-mix(in oklab, $s-text, $s-bg 80%); " +
|
|
@@ -250,55 +199,44 @@ A.insertGlobalCss({
|
|
|
250
199
|
// Subtle single-colour gradient sheen, painted on every surface (and the page).
|
|
251
200
|
".s-s, body": SURFACE_SHEEN,
|
|
252
201
|
".s-s": "r:$s-radius",
|
|
253
|
-
//
|
|
254
|
-
// any component help
|
|
255
|
-
//
|
|
256
|
-
// it with a single plain rule. Accent (filled) surfaces don't get it — their fill
|
|
257
|
-
// is the edge. Buttons keep their own `border:0`.
|
|
202
|
+
// A neutral surface owns a hairline border, so a card reads as a card without
|
|
203
|
+
// any component help. `:where()` keeps it zero-specificity, so a bar that wants
|
|
204
|
+
// only a divider overrides it with a single plain rule.
|
|
258
205
|
":where(.s-s.neutral)": "border: 1px solid $s-faint;",
|
|
259
206
|
".s-s::-webkit-scrollbar, .s-s ::-webkit-scrollbar": "width:10px height:10px",
|
|
260
207
|
".s-s::-webkit-scrollbar-track, .s-s ::-webkit-scrollbar-track": "background:transparent",
|
|
261
208
|
".s-s::-webkit-scrollbar-thumb, .s-s ::-webkit-scrollbar-thumb":
|
|
262
209
|
"background:$s-faint border-radius:99px border: 2px solid transparent; background-clip:padding-box",
|
|
263
210
|
|
|
264
|
-
// Elevation
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// • tonal/outlined surface → ignored (a translucent body has nothing to lift)
|
|
269
|
-
// A `.neutral` button stays flat (so segmented groups gain no stray
|
|
270
|
-
// shadows). `.no-shadow` is a hard override of any of the above — place it last
|
|
271
|
-
// and make it !important so it beats the higher-specificity glow rule.
|
|
211
|
+
// Elevation: `.shadow`/`.extra-shadow` give a neutral surface a drop shadow and
|
|
212
|
+
// an accent one a self-coloured glow; tonal/outlined have nothing to lift. A
|
|
213
|
+
// `.neutral` button stays flat, so segmented groups gain no stray shadows.
|
|
214
|
+
// `.no-shadow` comes last and needs `!important` to beat the glow rules.
|
|
272
215
|
".s-s.shadow.neutral:not(.s-btn)": "box-shadow: 0 4px 14px rgba(0,0,0,0.13);",
|
|
273
216
|
".s-s.extra-shadow.neutral:not(.s-btn)": "box-shadow: 0 18px 50px rgba(0,0,0,0.28);",
|
|
274
217
|
".s-s.shadow:not(.neutral):not(.tonal):not(.outlined)": "box-shadow: 0 4px 14px color-mix(in srgb, $s-bg 30%, transparent);",
|
|
275
218
|
".s-s.extra-shadow:not(.neutral):not(.tonal):not(.outlined)": "box-shadow: 0 14px 40px color-mix(in srgb, $s-bg 40%, transparent);",
|
|
276
219
|
".s-s.no-shadow": "box-shadow: none !important;",
|
|
277
220
|
|
|
278
|
-
// Accent variants
|
|
279
|
-
//
|
|
280
|
-
// with a colour edge. (Neutral surfaces ignore these.)
|
|
221
|
+
// Accent variants: the fill colour becomes the ink, over a soft self-tint
|
|
222
|
+
// (`tonal`) or a transparent body with a colour edge (`outlined`).
|
|
281
223
|
".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined":
|
|
282
224
|
"--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
|
|
283
225
|
".s-s:not(.neutral).tonal":
|
|
284
226
|
"background: color-mix(in srgb, $s-bg 15%, transparent); border: 1px solid $s-faint;",
|
|
285
227
|
".s-s:not(.neutral).outlined":
|
|
286
228
|
"background: transparent; border: 1px solid color-mix(in srgb, $s-bg 45%, transparent);",
|
|
287
|
-
// A surface
|
|
288
|
-
//
|
|
289
|
-
// Specificity (4 classes) beats the 2-class variant rules — no !important.
|
|
229
|
+
// A surface inside an accent surface is forced back to filled: a translucent
|
|
230
|
+
// body would bleed into the vivid parent. 4 classes beats the variant rules.
|
|
290
231
|
".s-s:not(.neutral) .s-s.tonal, .s-s:not(.neutral) .s-s.outlined":
|
|
291
232
|
"--s-text:#fff --s-accent:#fff --s-link-fg:#fff " +
|
|
292
233
|
SURFACE_SHEEN + " border-color: transparent;",
|
|
293
234
|
});
|
|
294
235
|
|
|
295
236
|
// ── Suppress transitions during the initial load ─────────────────────────────
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// default (unstyled) colours into the theme. Tag <html> until the first frame
|
|
300
|
-
// has painted and hard-disable transitions under that tag, so the initial render
|
|
301
|
-
// snaps straight to the right colours.
|
|
237
|
+
// Colour transitions make a light↔dark switch smooth, but on a cold load they'd
|
|
238
|
+
// animate from the unstyled colours into the theme. Tag <html> until the first
|
|
239
|
+
// frame has painted, so the initial render snaps to the right colours.
|
|
302
240
|
A.insertGlobalCss({
|
|
303
241
|
".s-preload, .s-preload *, .s-preload *::before, .s-preload *::after":
|
|
304
242
|
"transition: none !important; animation: none !important;",
|
|
@@ -310,19 +248,16 @@ if (typeof document !== "undefined" && typeof requestAnimationFrame === "functio
|
|
|
310
248
|
}
|
|
311
249
|
|
|
312
250
|
// ── Disabled region ───────────────────────────────────────────────────────────
|
|
313
|
-
// aria-disabled="true" on any container dims it and blocks pointer events on
|
|
314
|
-
//
|
|
315
|
-
// access to focusable descendants is unaffected — add the `inert` attribute too
|
|
316
|
-
// if you need that.
|
|
251
|
+
// aria-disabled="true" on any container dims it and blocks pointer events on it
|
|
252
|
+
// and all descendants. Keyboard access is unaffected — add `inert` for that.
|
|
317
253
|
A.insertGlobalCss({
|
|
318
254
|
":disabled, [aria-disabled=true]": "opacity:0.45 filter:saturate(0.6) user-select:none",
|
|
319
255
|
":disabled, [aria-disabled=true], :disabled *, [aria-disabled=true] *": "pointer-events:none cursor:not-allowed",
|
|
320
256
|
});
|
|
321
257
|
|
|
322
258
|
// ── Flow content: vertical rhythm & light typography ─────────────────────────
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// give every block a *top* margin only when it isn't its parent's first child.
|
|
259
|
+
// Block defaults for *any* content, markdown-rendered or your own: no browser
|
|
260
|
+
// block margins, but a *top* margin unless the block is its parent's first child.
|
|
326
261
|
const BLOCK = "p, ul, ol, dl, blockquote, pre, table, figure, hr, h1, h2, h3, h4, h5, h6";
|
|
327
262
|
|
|
328
263
|
A.insertGlobalCss({
|
package/skill/Attributes.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
## Attributes · type
|
|
2
|
-
|
|
3
|
-
Shared building blocks for the Staffa component library.
|
|
4
|
-
|
|
5
|
-
Every component in Staffa is "just an Aberdeen draw function": a plain function
|
|
6
|
-
that takes a single, strongly typed options object and emits DOM through
|
|
7
|
-
Aberdeen's `A` function. This module defines the option-type hierarchy
|
|
8
|
-
that all components build on, plus a couple of tiny helpers.
|
|
9
|
-
|
|
10
|
-
**Type:** `string`
|