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/components/dialog.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from ".
|
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
6
|
+
import { bindKey, claimKeyboard, keyboardOwner } from "../keys.js";
|
|
6
7
|
|
|
7
8
|
/** Options for {@link dialog}. */
|
|
8
9
|
export interface DialogOptions {
|
|
@@ -25,10 +26,17 @@ export interface DialogOptions {
|
|
|
25
26
|
*/
|
|
26
27
|
allowCancel?: boolean;
|
|
27
28
|
/**
|
|
28
|
-
* When
|
|
29
|
-
*
|
|
29
|
+
* When `true` (default), the dialog is destroyed when the scope that called
|
|
30
|
+
* `dialog()` is destroyed.
|
|
30
31
|
*/
|
|
31
32
|
cancelWithScope?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Leave the keyboard shortcuts behind this dialog working, instead of owning
|
|
35
|
+
* the keyboard the way a modal does. For overlays that inform rather than
|
|
36
|
+
* interrupt — the `?` shortcut overview is one. Esc still closes it first,
|
|
37
|
+
* whatever it meant before.
|
|
38
|
+
*/
|
|
39
|
+
keyboardTransparent?: boolean;
|
|
32
40
|
/**
|
|
33
41
|
* Dialog body. A {@link Slot} whose draw-function receives a `close()` function
|
|
34
42
|
* — call it to dismiss the dialog programmatically. (A plain string renders as
|
|
@@ -48,16 +56,14 @@ A.insertGlobalCss({
|
|
|
48
56
|
"&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
|
|
49
57
|
"&.hidden": "opacity:0 pointer-events:none",
|
|
50
58
|
},
|
|
51
|
-
//
|
|
52
|
-
//
|
|
59
|
+
// Border, radius and the deep floating shadow come from the panel's
|
|
60
|
+
// `.s-s.neutral.extra-shadow` surface (see theme.ts).
|
|
53
61
|
".s-dialog": {
|
|
54
62
|
"&":
|
|
55
63
|
"position:fixed z-index:200 top:50% left:50% " +
|
|
56
64
|
"display:flex flex-direction:column " +
|
|
57
65
|
"transform:translate(-50%,-50%) " +
|
|
58
|
-
|
|
59
|
-
// zoom (see `watchScale` in main.ts), and these caps mean the window.
|
|
60
|
-
"min-width:20rem max-width:min(calc(90vw/var(--s-zoom,1)),44rem) max-height:min(calc(88vh/var(--s-zoom,1)),800px) " +
|
|
66
|
+
"min-width:min(20rem,90vw) max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
61
67
|
"r: $s-radius-lg; overflow:hidden " +
|
|
62
68
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
63
69
|
"> header":
|
|
@@ -71,7 +77,7 @@ A.insertGlobalCss({
|
|
|
71
77
|
},
|
|
72
78
|
});
|
|
73
79
|
|
|
74
|
-
const dialogs = A.proxy({} as Record<
|
|
80
|
+
const dialogs = A.proxy({} as Record<string,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
|
|
75
81
|
let dialogCount = 0;
|
|
76
82
|
|
|
77
83
|
const topDialogId = A.derive(() => {
|
|
@@ -89,20 +95,40 @@ mountPortal(() => {
|
|
|
89
95
|
const close = () => { delete dialogs[dialogId]; };
|
|
90
96
|
|
|
91
97
|
A.clean(() => {
|
|
92
|
-
// Fires
|
|
93
|
-
// true (normal close) or because the parent reactive scope was cleaned up.
|
|
98
|
+
// Fires on a normal close and on parent-scope teardown alike.
|
|
94
99
|
opts.onClose?.();
|
|
95
100
|
resolve();
|
|
96
101
|
});
|
|
97
102
|
|
|
103
|
+
// Return focus to where it was when this dialog opened, so a keyboard
|
|
104
|
+
// user doesn't lose their place.
|
|
105
|
+
const prevFocus = document.activeElement;
|
|
106
|
+
A.clean(() => {
|
|
107
|
+
if (prevFocus instanceof HTMLElement && document.contains(prevFocus)) prevFocus.focus();
|
|
108
|
+
});
|
|
109
|
+
|
|
98
110
|
// Backdrop - hide when not the top dialog
|
|
99
111
|
const overlaid = A.derive(() => topDialogId.value != dialogId);
|
|
100
112
|
A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
|
|
101
113
|
if (opts.allowCancel !== false) close();
|
|
102
114
|
});
|
|
103
115
|
|
|
104
|
-
// Dialog itself
|
|
105
116
|
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
|
|
117
|
+
// A modal owns the keyboard: claiming makes the shortcuts drawn inside
|
|
118
|
+
// (the content below included) register here and silences the rest. The
|
|
119
|
+
// `?` overview passes `keyboardTransparent` to leave them all working.
|
|
120
|
+
if (!opts.keyboardTransparent) A.clean(claimKeyboard());
|
|
121
|
+
// Esc closes the dialog — or, while `allowCancel` forbids it, is
|
|
122
|
+
// swallowed by the no-op press, so nothing below acts on it either.
|
|
123
|
+
// Anchored at whoever owned the keyboard as this dialog opened —
|
|
124
|
+
// itself, or with `keyboardTransparent` the modal beneath (or the page)
|
|
125
|
+
// — so it shadows whatever Esc meant so far, restored on close.
|
|
126
|
+
const escAt = keyboardOwner();
|
|
127
|
+
A(() => {
|
|
128
|
+
const can = opts.allowCancel !== false;
|
|
129
|
+
bindKey("esc", can ? "Close this dialog" : undefined, can ? close : () => {}, escAt);
|
|
130
|
+
});
|
|
131
|
+
|
|
106
132
|
A(() => {
|
|
107
133
|
if (opts.header != null) {
|
|
108
134
|
A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
|
|
@@ -120,8 +146,8 @@ mountPortal(() => {
|
|
|
120
146
|
});
|
|
121
147
|
}) as HTMLElement;
|
|
122
148
|
|
|
123
|
-
// Once laid out, move focus into the dialog
|
|
124
|
-
//
|
|
149
|
+
// Once laid out, move focus into the dialog so it's keyboard-ready and focus
|
|
150
|
+
// doesn't linger on whatever opened it.
|
|
125
151
|
requestAnimationFrame(() => { if (document.body.contains(dialogEl)) focusFirst(dialogEl); });
|
|
126
152
|
});
|
|
127
153
|
})
|
|
@@ -148,30 +174,6 @@ mountPortal(() => {
|
|
|
148
174
|
* ```
|
|
149
175
|
*/
|
|
150
176
|
export function dialog(opts: DialogOptions): Promise<void> {
|
|
151
|
-
if (!dialogCount) {
|
|
152
|
-
// Install Esc handler the first time we create a dialog
|
|
153
|
-
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
154
|
-
// A layer above us that consumed Escape (an open floating menu's capture
|
|
155
|
-
// handler, or a widget inside the dialog like an open autocomplete list)
|
|
156
|
-
// marks it with preventDefault — the dialog must not also act on it.
|
|
157
|
-
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
158
|
-
const ds = A.unproxy(dialogs);
|
|
159
|
-
// Search for top-most dialog
|
|
160
|
-
for(let i=dialogCount; i>0; i--) {
|
|
161
|
-
if (ds[i]) {
|
|
162
|
-
// A dialog owns Escape, closable or not — handlers beneath it
|
|
163
|
-
// must not also act (main()'s nav jump and cooperative page
|
|
164
|
-
// handlers check defaultPrevented, or S.isDialogOpen()).
|
|
165
|
-
e.preventDefault();
|
|
166
|
-
if (ds[i].opts.allowCancel !== false) {
|
|
167
|
-
// The clean handler should call resolve and onClose
|
|
168
|
-
delete dialogs[i];
|
|
169
|
-
}
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
177
|
const dialogId = ++dialogCount;
|
|
176
178
|
if (opts.cancelWithScope !== false) A.clean(() => { delete dialogs[dialogId]; });
|
|
177
179
|
return new Promise<void>((resolve) => {
|
package/src/components/field.ts
CHANGED
|
@@ -3,11 +3,9 @@ import { type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from ".
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Options shared by all *form field* components (textline, textarea, checkbox,
|
|
6
|
-
* autocomplete, ...).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* itself, and optional help/error text below it. {@link form} relies on this
|
|
10
|
-
* shared structure to align groups of fields.
|
|
6
|
+
* autocomplete, ...). Every field lays out the same way — optional label,
|
|
7
|
+
* control, optional help/error below — which is what lets {@link form} align
|
|
8
|
+
* groups of them.
|
|
11
9
|
*/
|
|
12
10
|
export interface FieldOptions {
|
|
13
11
|
/** Aberdeen attr/style string applied to the field's wrapper element. */
|
|
@@ -42,9 +40,8 @@ A.insertGlobalCss({
|
|
|
42
40
|
".s-help": "font-size:0.82em fg:$s-muted",
|
|
43
41
|
".s-error": "font-size:0.82em fg:$s-danger",
|
|
44
42
|
".s-input": {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// whatever surface (and mode) holds the field, no fixed input colour needed.
|
|
43
|
+
// Derived from the surrounding surface — its background nudged toward its ink —
|
|
44
|
+
// so the field adapts to any surface and mode, with no fixed input colour.
|
|
48
45
|
"&": "w:100% background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
49
46
|
"&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
50
47
|
"&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
|
|
@@ -56,9 +53,8 @@ A.insertGlobalCss({
|
|
|
56
53
|
* Render the standard field chrome (label + control + help/error) around a
|
|
57
54
|
* caller-supplied control.
|
|
58
55
|
*
|
|
59
|
-
* Each piece
|
|
60
|
-
*
|
|
61
|
-
* control.
|
|
56
|
+
* Each piece gets its own reactive scope, so flipping `error` on a proxied
|
|
57
|
+
* options object re-renders just the error line, not the control.
|
|
62
58
|
*
|
|
63
59
|
* @param opts The field options.
|
|
64
60
|
* @param drawControl Receives the resolved `id` and the live "invalid" getter,
|
package/src/components/form.ts
CHANGED
|
@@ -31,13 +31,11 @@ A.insertGlobalCss({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* An opinionated `<form>` wrapper
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* in as {@link ContentOptions.content}. Submission is wired so the browser's
|
|
40
|
-
* native validation runs, but the page never reloads.
|
|
34
|
+
* An opinionated `<form>` wrapper: fields in a single column by default, or a
|
|
35
|
+
* responsive grid, plus a standard action bar. Field components
|
|
36
|
+
* ({@link import("./textline").textline} et al.) drop straight in as
|
|
37
|
+
* {@link ContentOptions.content}; the browser's native validation runs on submit,
|
|
38
|
+
* but the page never reloads.
|
|
41
39
|
*
|
|
42
40
|
* @example
|
|
43
41
|
* ```ts
|
|
@@ -56,8 +54,7 @@ export function form(opts: FormOptions | Slot = {}): void {
|
|
|
56
54
|
const o: FormOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
57
55
|
|
|
58
56
|
A(`form.s-form`, o.attrs, () => {
|
|
59
|
-
//
|
|
60
|
-
// the fields (which would lose focus / input state).
|
|
57
|
+
// Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
|
|
61
58
|
A(() => {
|
|
62
59
|
A(".grid=", o.layout === 'grid');
|
|
63
60
|
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { drawSlot } from "../core.js";
|
|
3
|
+
import { bindKey, formatKey, getActiveKeyBindings } from "../keys.js";
|
|
4
|
+
import { dialog } from "./dialog.js";
|
|
5
|
+
|
|
6
|
+
A.insertGlobalCss({
|
|
7
|
+
".s-keyhelp": {
|
|
8
|
+
"&": "display:flex flex-direction:column gap:$1 min-width:14rem",
|
|
9
|
+
"> div": "display:flex align-items:baseline justify-content:space-between gap:$4",
|
|
10
|
+
"kbd": "font-family:inherit font-size:0.85em fg:$s-muted white-space:nowrap border: 1px solid $s-faint; r:$s-radius-sm padding: 0 0.4em;",
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Non-null while the overview is up; calling it closes the dialog. */
|
|
15
|
+
let closeHelp: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The shortcut overview: a dialog listing what a keypress could do *right now*
|
|
19
|
+
* — every described binding the keyboard focus and any open modal leave in
|
|
20
|
+
* effect: buttons under their label, menu items under theirs, {@link bindKey}
|
|
21
|
+
* bindings under the description they were given.
|
|
22
|
+
*
|
|
23
|
+
* It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
|
|
24
|
+
* keypress closes it *and* still lands — so the key you just looked up can be
|
|
25
|
+
* pressed right there. That is also what keeps the listing honest: focus cannot
|
|
26
|
+
* move while it is up (a click lands on the backdrop, a key closes it), so what
|
|
27
|
+
* was true when it opened stays true. Bound to `?` (and `mod+?`, which also
|
|
28
|
+
* works while typing in a field) by default — see {@link setKeyHelp}. Calling
|
|
29
|
+
* this while the overview is already up closes it, which is what lets that `?`
|
|
30
|
+
* toggle.
|
|
31
|
+
*/
|
|
32
|
+
export function showKeyHelp(): void {
|
|
33
|
+
if (closeHelp) {
|
|
34
|
+
closeHelp();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// What a keypress could do at the moment of asking, snapshotted before the
|
|
38
|
+
// overview's own dialog (whose Esc shadows the Esc row shown here) exists.
|
|
39
|
+
// Focus cannot move while the overview is up, so the listing stays true —
|
|
40
|
+
// except for Esc itself, which dismisses the overview first.
|
|
41
|
+
const entries = getActiveKeyBindings(document.activeElement);
|
|
42
|
+
void dialog({
|
|
43
|
+
header: "Keyboard shortcuts",
|
|
44
|
+
// Usually pressed into being from the global `?` handler, where there is
|
|
45
|
+
// no scope to tie the dialog's life to.
|
|
46
|
+
cancelWithScope: false,
|
|
47
|
+
// The whole point: the keys it lists stay pressable while it is up.
|
|
48
|
+
keyboardTransparent: true,
|
|
49
|
+
onClose: () => { closeHelp = null; },
|
|
50
|
+
content: (close) => {
|
|
51
|
+
closeHelp = close;
|
|
52
|
+
|
|
53
|
+
// Any keypress closes the overview, in the capture phase so the key
|
|
54
|
+
// then travels on to whoever handles it. Except: bare modifiers (the
|
|
55
|
+
// combination they start still needs reading), Esc (the dialog's own
|
|
56
|
+
// close, handled there) and `?` (the toggle binding's).
|
|
57
|
+
const closer = (e: KeyboardEvent) => {
|
|
58
|
+
if (e.repeat || ["Control", "Shift", "Alt", "Meta", "Escape", "?"].includes(e.key)) return;
|
|
59
|
+
close();
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener("keydown", closer, true);
|
|
62
|
+
A.clean(() => document.removeEventListener("keydown", closer, true));
|
|
63
|
+
|
|
64
|
+
A("div.s-keyhelp", () => {
|
|
65
|
+
for (const [keyStr, binding] of entries) {
|
|
66
|
+
if (binding.description === undefined) continue;
|
|
67
|
+
A("div", () => {
|
|
68
|
+
A("span", () => drawSlot(binding.description));
|
|
69
|
+
A("kbd text=", formatKey(keyStr));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the default `?` / `mod+?` bindings are on. */
|
|
78
|
+
const $enabled = A.proxy(true);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn the default overview shortcuts off (or back on): `?` — free, like any
|
|
82
|
+
* unmodified key, whenever nothing is being typed into — and `mod+?`, which
|
|
83
|
+
* reaches the overview even from inside a text field. On by default.
|
|
84
|
+
*/
|
|
85
|
+
export function setKeyHelp(enabled: boolean): void {
|
|
86
|
+
$enabled.value = enabled;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Global bindings, so they keep working while a dialog is up — the overview
|
|
90
|
+
// should answer anywhere. It lists itself under the `mod+?` spelling, the one
|
|
91
|
+
// that is true everywhere, a text field included; the bare `?` stays quiet.
|
|
92
|
+
A(() => {
|
|
93
|
+
if (!$enabled.value) return;
|
|
94
|
+
bindKey("?", undefined, showKeyHelp, "global");
|
|
95
|
+
bindKey("mod+?", "This overview", showKeyHelp, "global");
|
|
96
|
+
});
|