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
|
@@ -13,8 +13,8 @@ A.insertGlobalCss({
|
|
|
13
13
|
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
14
14
|
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
15
15
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// Background, border, radius and elevation come from the popup's
|
|
17
|
+
// `.s-s.neutral.shadow` surface (see below).
|
|
18
18
|
"> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
|
|
19
19
|
"> .s-menu li": "margin:0",
|
|
20
20
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
@@ -235,9 +235,8 @@ export function autocomplete(opts) {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
else if (e.key === "Escape") {
|
|
238
|
-
// Only consume Escape while the list is showing: it dismisses the
|
|
239
|
-
//
|
|
240
|
-
// list already closed, let it pass through to the dialog/nav handlers.
|
|
238
|
+
// Only consume Escape while the list is showing: it dismisses the innermost
|
|
239
|
+
// layer, so a surrounding dialog closes on the next press, not this one.
|
|
241
240
|
if ($st.open) {
|
|
242
241
|
e.preventDefault();
|
|
243
242
|
$st.open = false;
|
package/dist/components/box.js
CHANGED
|
@@ -2,15 +2,10 @@ import A from "aberdeen";
|
|
|
2
2
|
import { drawSlot } from "../core.js";
|
|
3
3
|
import { x as closeIcon } from "../icons.js";
|
|
4
4
|
import { iconButton } from "./button.js";
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
|
|
10
|
-
// the surface itself (see theme.ts), not from here. `.s-box` only does layout and
|
|
11
|
-
// the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
|
|
12
|
-
// deeper, for the raised shade), so we cancel their full surface border down to a
|
|
13
|
-
// single divider.
|
|
5
|
+
// Colours, border and shadow come from the `.s-s.neutral.shadow` surface itself
|
|
6
|
+
// (see theme.ts); `.s-box` only does layout. Header/footer are `.neutral` surfaces
|
|
7
|
+
// one level deeper (for the raised shade), with their surface border cancelled
|
|
8
|
+
// down to a single divider.
|
|
14
9
|
A.insertGlobalCss({
|
|
15
10
|
".s-box": {
|
|
16
11
|
// position:relative so a headerless box can hang its ✕ in the corner.
|
|
@@ -19,11 +14,8 @@ A.insertGlobalCss({
|
|
|
19
14
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
20
15
|
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
|
|
21
16
|
"> div": "p:$3 gap:$3",
|
|
22
|
-
// The ✕
|
|
23
|
-
// In a header row it parks at the far end...
|
|
17
|
+
// The ✕ parks at the end of a header row, or floats over the body when there is none.
|
|
24
18
|
"> header > .s-box-close": "margin-left:auto",
|
|
25
|
-
// ...and without a header there is no row to sit in, so it floats over the
|
|
26
|
-
// body's top-right corner instead.
|
|
27
19
|
"> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
|
|
28
20
|
},
|
|
29
21
|
});
|
|
@@ -50,12 +42,11 @@ A.insertGlobalCss({
|
|
|
50
42
|
export function box(opts = {}) {
|
|
51
43
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
52
44
|
A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
|
|
53
|
-
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
54
|
-
// the body (which may hold focused inputs
|
|
45
|
+
// Header and footer get their own scopes, so toggling them doesn't recreate
|
|
46
|
+
// the body (which may hold focused inputs).
|
|
55
47
|
A(() => {
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// but do nothing, which is worse than not rendering at all.
|
|
48
|
+
// typeof-guarded: v0.9's removed `close: true`, reaching us from unchecked
|
|
49
|
+
// JS, would otherwise render a ✕ that does nothing.
|
|
59
50
|
if (o.header != null) {
|
|
60
51
|
A("header.s-s.neutral", o.headerAttrs, () => {
|
|
61
52
|
drawSlot(o.header);
|
|
@@ -77,9 +68,8 @@ export function box(opts = {}) {
|
|
|
77
68
|
});
|
|
78
69
|
}
|
|
79
70
|
/**
|
|
80
|
-
* The box's ✕: one definition, so
|
|
81
|
-
*
|
|
82
|
-
* The `.s-box-close` class is only a hook for the placement rules above.
|
|
71
|
+
* The box's ✕: one definition, so it is identical in a header row and floating
|
|
72
|
+
* over a headerless body. `.s-box-close` is only a hook for the placement rules.
|
|
83
73
|
*/
|
|
84
74
|
function drawCloseButton(close) {
|
|
85
75
|
iconButton({
|
|
@@ -7,6 +7,12 @@ export interface IconButtonOptions {
|
|
|
7
7
|
ariaLabel: string;
|
|
8
8
|
/** Click handler. */
|
|
9
9
|
click?: (event: Event) => void;
|
|
10
|
+
/**
|
|
11
|
+
* A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
|
|
12
|
+
* The tooltip shows it after the `ariaLabel` (a glyph is worth naming there
|
|
13
|
+
* anyway), and the `?` overview lists it under that label too.
|
|
14
|
+
*/
|
|
15
|
+
key?: string;
|
|
10
16
|
/** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
|
|
11
17
|
href?: string;
|
|
12
18
|
/** Disables it. */
|
|
@@ -34,6 +40,16 @@ export interface ButtonOptions {
|
|
|
34
40
|
href?: string;
|
|
35
41
|
/** Accessible label, when the button has only an icon. */
|
|
36
42
|
ariaLabel?: string;
|
|
43
|
+
/**
|
|
44
|
+
* A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
|
|
45
|
+
* {@link bindKey} for the spelling and which keystrokes are yours to take.
|
|
46
|
+
* It works from anywhere while the button is drawn, shows in a tooltip,
|
|
47
|
+
* reaches screen readers as `aria-keyshortcuts`, and does exactly what a
|
|
48
|
+
* click does: a `type: "submit"` submits its form, an `href` navigates. The
|
|
49
|
+
* `?` overview ({@link showKeyHelp}) lists it under the button's text (or
|
|
50
|
+
* its `ariaLabel`).
|
|
51
|
+
*/
|
|
52
|
+
key?: string;
|
|
37
53
|
/**
|
|
38
54
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
39
55
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
@@ -48,13 +64,12 @@ export interface ButtonOptions {
|
|
|
48
64
|
}
|
|
49
65
|
/**
|
|
50
66
|
* A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
51
|
-
* hover.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* {@link Panel.actions | page's actions}.
|
|
67
|
+
* hover. For chrome that has to sit beside something more important without
|
|
68
|
+
* competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
|
|
69
|
+
* the verbs in a {@link Panel.actions | page's actions}.
|
|
55
70
|
*
|
|
56
71
|
* Reach for {@link button} instead whenever the thing has a name worth reading;
|
|
57
|
-
* an icon alone is only
|
|
72
|
+
* an icon alone is unambiguous only for a handful of universal actions.
|
|
58
73
|
*
|
|
59
74
|
* @example
|
|
60
75
|
* ```ts
|
|
@@ -1,59 +1,42 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { drawSlot } from "../core.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
3
|
+
import { bindKey, formatKey } from "../keys.js";
|
|
4
|
+
import { addTooltip } from "./tooltip.js";
|
|
5
|
+
// Colours, border and radius come from the `.s-s` surface classes in theme.ts;
|
|
6
|
+
// this rule only does layout, focus, hover and sizing.
|
|
6
7
|
A.insertGlobalCss({
|
|
7
8
|
".s-btn": {
|
|
8
9
|
"&": "display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
9
10
|
"font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
10
11
|
"padding: $m2 $m3; " +
|
|
11
12
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
12
|
-
// Focus ring via `outline
|
|
13
|
-
// (which hard-clears box-shadow). Modern browsers round it to the border-radius.
|
|
13
|
+
// Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
|
|
14
14
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
15
|
-
// The button carries `.shadow` (added in button() below); on a filled accent
|
|
16
|
-
// surface that resolves to the signature self-coloured glow, on a neutral
|
|
17
|
-
// `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
|
|
18
15
|
"&:hover": "filter: brightness(1.06)",
|
|
19
|
-
// Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
|
|
20
|
-
// button (which is already near-white) darkens toward its ink instead.
|
|
21
16
|
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
17
|
+
// A `.neutral` button is already near-white, so it darkens toward its ink
|
|
18
|
+
// instead of brightening.
|
|
22
19
|
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
23
|
-
// The button sizes its glyph
|
|
24
|
-
//
|
|
25
|
-
// here makes every icon in a row come out alike. It rides the font size,
|
|
26
|
-
// so a `.small`/`.large` button scales its icon with its text.
|
|
20
|
+
// The button sizes its glyph rather than trusting the caller: only a rule here
|
|
21
|
+
// makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
|
|
27
22
|
"> svg": "width:1.25em height:1.25em",
|
|
28
|
-
// Subtle press feedback.
|
|
29
23
|
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
30
|
-
//
|
|
31
|
-
//
|
|
24
|
+
// Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
|
|
25
|
+
// container can size all its buttons at once.
|
|
32
26
|
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
33
27
|
"&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
|
|
34
28
|
},
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// title (a ✕, a ☰) should read as an affordance on the bar, not as
|
|
38
|
-
// another button competing with it, and a filled or outlined box around a
|
|
39
|
-
// 16px glyph is exactly what makes a top bar look busy.
|
|
29
|
+
// Deliberately *not* a `.s-s` surface: chrome sitting beside a title (a ✕, a ☰)
|
|
30
|
+
// should read as an affordance on the bar, not as another button competing with it.
|
|
40
31
|
".s-icon-btn": {
|
|
41
32
|
"&": "display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
|
|
42
33
|
"width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
|
|
43
34
|
"fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
|
|
44
35
|
"transition: color 0.12s, background 0.12s;",
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// others. CSS beats the `width`/`height` attributes the icon set writes, so
|
|
49
|
-
// `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
|
|
50
|
-
// `attrs` override still wins over this, being an inline style. The same
|
|
51
|
-
// rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
|
|
52
|
-
// one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
|
|
53
|
-
// of it — see the note there.)
|
|
36
|
+
// As on `.s-btn`: the container sizes the glyph so a row of icon buttons reads
|
|
37
|
+
// as a row. CSS beats the `width`/`height` attributes the icon set writes; an
|
|
38
|
+
// `attrs` override still wins over this, being an inline style.
|
|
54
39
|
"> svg": "width:1.25em height:1.25em",
|
|
55
|
-
// The ink resolves against whatever surface it sits on, so one treatment
|
|
56
|
-
// works on the page, in a box header, and on a coloured bar alike.
|
|
57
40
|
"&:hover:not(:disabled):not([aria-disabled=true])": "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
58
41
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
|
|
59
42
|
// The glyph rides the font size, so it scales with the hit area.
|
|
@@ -63,13 +46,12 @@ A.insertGlobalCss({
|
|
|
63
46
|
});
|
|
64
47
|
/**
|
|
65
48
|
* A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
66
|
-
* hover.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* {@link Panel.actions | page's actions}.
|
|
49
|
+
* hover. For chrome that has to sit beside something more important without
|
|
50
|
+
* competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
|
|
51
|
+
* the verbs in a {@link Panel.actions | page's actions}.
|
|
70
52
|
*
|
|
71
53
|
* Reach for {@link button} instead whenever the thing has a name worth reading;
|
|
72
|
-
* an icon alone is only
|
|
54
|
+
* an icon alone is unambiguous only for a handful of universal actions.
|
|
73
55
|
*
|
|
74
56
|
* @example
|
|
75
57
|
* ```ts
|
|
@@ -86,15 +68,16 @@ export function iconButton(opts) {
|
|
|
86
68
|
A(`${tag}.s-icon-btn`, opts.attrs, () => {
|
|
87
69
|
applyActionBehavior(opts);
|
|
88
70
|
A("aria-label=", opts.ariaLabel);
|
|
71
|
+
if (opts.key)
|
|
72
|
+
applyKey(opts.key, opts.ariaLabel, undefined, opts.disabled);
|
|
89
73
|
drawSlot(opts.icon);
|
|
90
74
|
});
|
|
91
75
|
}
|
|
92
76
|
/**
|
|
93
|
-
* The link-or-button plumbing {@link button} and {@link iconButton} share
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* the `<button>` form's real `disabled` attribute.
|
|
77
|
+
* The link-or-button plumbing {@link button} and {@link iconButton} share. A
|
|
78
|
+
* disabled link keeps `role=button` and `aria-disabled` but loses its `href`: an
|
|
79
|
+
* anchor without one is out of the tab order and follows nothing, which is what
|
|
80
|
+
* makes it as disabled as a `<button>`'s real `disabled` attribute.
|
|
98
81
|
*/
|
|
99
82
|
function applyActionBehavior(o) {
|
|
100
83
|
if (o.href != null) {
|
|
@@ -112,6 +95,29 @@ function applyActionBehavior(o) {
|
|
|
112
95
|
if (o.click && !o.disabled)
|
|
113
96
|
A("click=", o.click);
|
|
114
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The shortcut plumbing {@link button} and {@link iconButton} share: bind it,
|
|
100
|
+
* announce it as `aria-keyshortcuts`, and hint at it in a tooltip — the only
|
|
101
|
+
* place a button can say what its key is without shouting it beside the label.
|
|
102
|
+
*
|
|
103
|
+
* Pressing it clicks the element rather than calling `click` directly, so a
|
|
104
|
+
* `type=submit` still submits its form and an `href` still navigates. Call this
|
|
105
|
+
* inside the button's own element scope, whose element it takes and whose life
|
|
106
|
+
* the binding follows.
|
|
107
|
+
*/
|
|
108
|
+
function applyKey(key, label, content, disabled) {
|
|
109
|
+
const el = A();
|
|
110
|
+
const tip = label ? `${label} · ${formatKey(key)}` : formatKey(key);
|
|
111
|
+
// A draw function, not a string: a key like `*` is markup to rich text.
|
|
112
|
+
addTooltip({ tip: () => A("#", tip) });
|
|
113
|
+
// Bound only while it can be pressed — a disabled button would otherwise
|
|
114
|
+
// swallow the combination rather than leave it to whoever else wants it. The
|
|
115
|
+
// overview names it by its visible text, or its aria label failing that.
|
|
116
|
+
if (!disabled) {
|
|
117
|
+
A("aria-keyshortcuts=", formatKey(key, true));
|
|
118
|
+
bindKey(key, typeof content === "string" ? content : label, () => el.click());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
115
121
|
/**
|
|
116
122
|
* A button. Tonal and outlined variants show a border; filled variants rely on
|
|
117
123
|
* their solid background for affordance.
|
|
@@ -139,14 +145,16 @@ function applyActionBehavior(o) {
|
|
|
139
145
|
export function button(opts = {}) {
|
|
140
146
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
141
147
|
const tag = o.href != null ? "a" : "button";
|
|
142
|
-
// A bare `.s-s` is a filled
|
|
143
|
-
//
|
|
144
|
-
// role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
|
|
145
|
-
// role detection needed, since the default lives in CSS, not here.
|
|
148
|
+
// A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
|
|
149
|
+
// detection here: `attrs` just names another role or variant.
|
|
146
150
|
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
147
151
|
applyActionBehavior(o);
|
|
148
152
|
if (o.ariaLabel)
|
|
149
153
|
A("aria-label=", o.ariaLabel);
|
|
154
|
+
// Before the content, so a tooltip the caller adds in there is the later of
|
|
155
|
+
// the two and wins the hover.
|
|
156
|
+
if (o.key)
|
|
157
|
+
applyKey(o.key, o.ariaLabel, o.content, o.disabled);
|
|
150
158
|
drawSlot(o.icon);
|
|
151
159
|
drawSlot(o.content);
|
|
152
160
|
});
|
|
@@ -23,8 +23,7 @@ export function buttonChooser(opts) {
|
|
|
23
23
|
attrs: opts.attrs,
|
|
24
24
|
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
25
25
|
content: label,
|
|
26
|
-
// Icon-only
|
|
27
|
-
// accessible name; plain-text labels speak for themselves.
|
|
26
|
+
// Icon-only (draw-function) labels get the id as their accessible name.
|
|
28
27
|
ariaLabel: typeof label === "function" ? id : undefined,
|
|
29
28
|
attrs: selected === id ? ".primary" : ".neutral",
|
|
30
29
|
click: () => {
|
|
@@ -34,7 +33,6 @@ export function buttonChooser(opts) {
|
|
|
34
33
|
});
|
|
35
34
|
});
|
|
36
35
|
if (opts.name) {
|
|
37
|
-
// Hidden input carries the value into native form submission.
|
|
38
36
|
A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
|
|
39
37
|
}
|
|
40
38
|
}
|
|
@@ -5,8 +5,7 @@ A.insertGlobalCss({
|
|
|
5
5
|
"&": "display:flex flex-direction:column gap:$1",
|
|
6
6
|
"> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
|
|
7
7
|
"> label:has(input:disabled)": "cursor:not-allowed opacity:0.45 filter:saturate(0.6)",
|
|
8
|
-
//
|
|
9
|
-
// just strip the margin and let it inherit the label's cursor (pointer / not-allowed).
|
|
8
|
+
// Size and accent-color come from the CSS reset; here, the margin and the label's cursor.
|
|
10
9
|
"input": "cursor:inherit m:0",
|
|
11
10
|
},
|
|
12
11
|
});
|
|
@@ -20,10 +20,17 @@ export interface DialogOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
allowCancel?: boolean;
|
|
22
22
|
/**
|
|
23
|
-
* When
|
|
24
|
-
*
|
|
23
|
+
* When `true` (default), the dialog is destroyed when the scope that called
|
|
24
|
+
* `dialog()` is destroyed.
|
|
25
25
|
*/
|
|
26
26
|
cancelWithScope?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Leave the keyboard shortcuts behind this dialog working, instead of owning
|
|
29
|
+
* the keyboard the way a modal does. For overlays that inform rather than
|
|
30
|
+
* interrupt — the `?` shortcut overview is one. Esc still closes it first,
|
|
31
|
+
* whatever it meant before.
|
|
32
|
+
*/
|
|
33
|
+
keyboardTransparent?: boolean;
|
|
27
34
|
/**
|
|
28
35
|
* Dialog body. A {@link Slot} whose draw-function receives a `close()` function
|
|
29
36
|
* — call it to dismiss the dialog programmatically. (A plain string renders as
|
|
@@ -3,20 +3,19 @@ import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
|
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
|
A.insertGlobalCss({
|
|
7
8
|
".s-backdrop": {
|
|
8
9
|
"&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
|
|
9
10
|
"&.hidden": "opacity:0 pointer-events:none",
|
|
10
11
|
},
|
|
11
|
-
//
|
|
12
|
-
//
|
|
12
|
+
// Border, radius and the deep floating shadow come from the panel's
|
|
13
|
+
// `.s-s.neutral.extra-shadow` surface (see theme.ts).
|
|
13
14
|
".s-dialog": {
|
|
14
15
|
"&": "position:fixed z-index:200 top:50% left:50% " +
|
|
15
16
|
"display:flex flex-direction:column " +
|
|
16
17
|
"transform:translate(-50%,-50%) " +
|
|
17
|
-
|
|
18
|
-
// zoom (see `watchScale` in main.ts), and these caps mean the window.
|
|
19
|
-
"min-width:20rem max-width:min(calc(90vw/var(--s-zoom,1)),44rem) max-height:min(calc(88vh/var(--s-zoom,1)),800px) " +
|
|
18
|
+
"min-width:min(20rem,90vw) max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
20
19
|
"r: $s-radius-lg; overflow:hidden " +
|
|
21
20
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
22
21
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
@@ -42,19 +41,39 @@ mountPortal(() => {
|
|
|
42
41
|
A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
|
|
43
42
|
const close = () => { delete dialogs[dialogId]; };
|
|
44
43
|
A.clean(() => {
|
|
45
|
-
// Fires
|
|
46
|
-
// true (normal close) or because the parent reactive scope was cleaned up.
|
|
44
|
+
// Fires on a normal close and on parent-scope teardown alike.
|
|
47
45
|
opts.onClose?.();
|
|
48
46
|
resolve();
|
|
49
47
|
});
|
|
48
|
+
// Return focus to where it was when this dialog opened, so a keyboard
|
|
49
|
+
// user doesn't lose their place.
|
|
50
|
+
const prevFocus = document.activeElement;
|
|
51
|
+
A.clean(() => {
|
|
52
|
+
if (prevFocus instanceof HTMLElement && document.contains(prevFocus))
|
|
53
|
+
prevFocus.focus();
|
|
54
|
+
});
|
|
50
55
|
// Backdrop - hide when not the top dialog
|
|
51
56
|
const overlaid = A.derive(() => topDialogId.value != dialogId);
|
|
52
57
|
A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
|
|
53
58
|
if (opts.allowCancel !== false)
|
|
54
59
|
close();
|
|
55
60
|
});
|
|
56
|
-
// Dialog itself
|
|
57
61
|
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
|
|
62
|
+
// A modal owns the keyboard: claiming makes the shortcuts drawn inside
|
|
63
|
+
// (the content below included) register here and silences the rest. The
|
|
64
|
+
// `?` overview passes `keyboardTransparent` to leave them all working.
|
|
65
|
+
if (!opts.keyboardTransparent)
|
|
66
|
+
A.clean(claimKeyboard());
|
|
67
|
+
// Esc closes the dialog — or, while `allowCancel` forbids it, is
|
|
68
|
+
// swallowed by the no-op press, so nothing below acts on it either.
|
|
69
|
+
// Anchored at whoever owned the keyboard as this dialog opened —
|
|
70
|
+
// itself, or with `keyboardTransparent` the modal beneath (or the page)
|
|
71
|
+
// — so it shadows whatever Esc meant so far, restored on close.
|
|
72
|
+
const escAt = keyboardOwner();
|
|
73
|
+
A(() => {
|
|
74
|
+
const can = opts.allowCancel !== false;
|
|
75
|
+
bindKey("esc", can ? "Close this dialog" : undefined, can ? close : () => { }, escAt);
|
|
76
|
+
});
|
|
58
77
|
A(() => {
|
|
59
78
|
if (opts.header != null) {
|
|
60
79
|
A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
|
|
@@ -69,8 +88,8 @@ mountPortal(() => {
|
|
|
69
88
|
}
|
|
70
89
|
});
|
|
71
90
|
});
|
|
72
|
-
// Once laid out, move focus into the dialog
|
|
73
|
-
//
|
|
91
|
+
// Once laid out, move focus into the dialog so it's keyboard-ready and focus
|
|
92
|
+
// doesn't linger on whatever opened it.
|
|
74
93
|
requestAnimationFrame(() => { if (document.body.contains(dialogEl))
|
|
75
94
|
focusFirst(dialogEl); });
|
|
76
95
|
});
|
|
@@ -97,31 +116,6 @@ mountPortal(() => {
|
|
|
97
116
|
* ```
|
|
98
117
|
*/
|
|
99
118
|
export function dialog(opts) {
|
|
100
|
-
if (!dialogCount) {
|
|
101
|
-
// Install Esc handler the first time we create a dialog
|
|
102
|
-
document.addEventListener("keydown", (e) => {
|
|
103
|
-
// A layer above us that consumed Escape (an open floating menu's capture
|
|
104
|
-
// handler, or a widget inside the dialog like an open autocomplete list)
|
|
105
|
-
// marks it with preventDefault — the dialog must not also act on it.
|
|
106
|
-
if (e.key !== "Escape" || e.defaultPrevented)
|
|
107
|
-
return;
|
|
108
|
-
const ds = A.unproxy(dialogs);
|
|
109
|
-
// Search for top-most dialog
|
|
110
|
-
for (let i = dialogCount; i > 0; i--) {
|
|
111
|
-
if (ds[i]) {
|
|
112
|
-
// A dialog owns Escape, closable or not — handlers beneath it
|
|
113
|
-
// must not also act (main()'s nav jump and cooperative page
|
|
114
|
-
// handlers check defaultPrevented, or S.isDialogOpen()).
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
if (ds[i].opts.allowCancel !== false) {
|
|
117
|
-
// The clean handler should call resolve and onClose
|
|
118
|
-
delete dialogs[i];
|
|
119
|
-
}
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
119
|
const dialogId = ++dialogCount;
|
|
126
120
|
if (opts.cancelWithScope !== false)
|
|
127
121
|
A.clean(() => { delete dialogs[dialogId]; });
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { type Bindable, type Slot, type Attributes } from "../core.js";
|
|
2
2
|
/**
|
|
3
3
|
* Options shared by all *form field* components (textline, textarea, checkbox,
|
|
4
|
-
* autocomplete, ...).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* itself, and optional help/error text below it. {@link form} relies on this
|
|
8
|
-
* shared structure to align groups of fields.
|
|
4
|
+
* autocomplete, ...). Every field lays out the same way — optional label,
|
|
5
|
+
* control, optional help/error below — which is what lets {@link form} align
|
|
6
|
+
* groups of them.
|
|
9
7
|
*/
|
|
10
8
|
export interface FieldOptions {
|
|
11
9
|
/** Aberdeen attr/style string applied to the field's wrapper element. */
|
|
@@ -34,9 +32,8 @@ export interface FieldOptions {
|
|
|
34
32
|
* Render the standard field chrome (label + control + help/error) around a
|
|
35
33
|
* caller-supplied control.
|
|
36
34
|
*
|
|
37
|
-
* Each piece
|
|
38
|
-
*
|
|
39
|
-
* control.
|
|
35
|
+
* Each piece gets its own reactive scope, so flipping `error` on a proxied
|
|
36
|
+
* options object re-renders just the error line, not the control.
|
|
40
37
|
*
|
|
41
38
|
* @param opts The field options.
|
|
42
39
|
* @param drawControl Receives the resolved `id` and the live "invalid" getter,
|
package/dist/components/field.js
CHANGED
|
@@ -9,9 +9,8 @@ A.insertGlobalCss({
|
|
|
9
9
|
".s-help": "font-size:0.82em fg:$s-muted",
|
|
10
10
|
".s-error": "font-size:0.82em fg:$s-danger",
|
|
11
11
|
".s-input": {
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// whatever surface (and mode) holds the field, no fixed input colour needed.
|
|
12
|
+
// Derived from the surrounding surface — its background nudged toward its ink —
|
|
13
|
+
// so the field adapts to any surface and mode, with no fixed input colour.
|
|
15
14
|
"&": "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;",
|
|
16
15
|
"&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
17
16
|
"&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
|
|
@@ -22,9 +21,8 @@ A.insertGlobalCss({
|
|
|
22
21
|
* Render the standard field chrome (label + control + help/error) around a
|
|
23
22
|
* caller-supplied control.
|
|
24
23
|
*
|
|
25
|
-
* Each piece
|
|
26
|
-
*
|
|
27
|
-
* control.
|
|
24
|
+
* Each piece gets its own reactive scope, so flipping `error` on a proxied
|
|
25
|
+
* options object re-renders just the error line, not the control.
|
|
28
26
|
*
|
|
29
27
|
* @param opts The field options.
|
|
30
28
|
* @param drawControl Receives the resolved `id` and the live "invalid" getter,
|
|
@@ -19,13 +19,11 @@ export interface FormOptions extends ContentOptions {
|
|
|
19
19
|
actions?: Slot;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* An opinionated `<form>` wrapper
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* in as {@link ContentOptions.content}. Submission is wired so the browser's
|
|
28
|
-
* native validation runs, but the page never reloads.
|
|
22
|
+
* An opinionated `<form>` wrapper: fields in a single column by default, or a
|
|
23
|
+
* responsive grid, plus a standard action bar. Field components
|
|
24
|
+
* ({@link import("./textline").textline} et al.) drop straight in as
|
|
25
|
+
* {@link ContentOptions.content}; the browser's native validation runs on submit,
|
|
26
|
+
* but the page never reloads.
|
|
29
27
|
*
|
|
30
28
|
* @example
|
|
31
29
|
* ```ts
|
package/dist/components/form.js
CHANGED
|
@@ -9,13 +9,11 @@ A.insertGlobalCss({
|
|
|
9
9
|
},
|
|
10
10
|
});
|
|
11
11
|
/**
|
|
12
|
-
* An opinionated `<form>` wrapper
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* in as {@link ContentOptions.content}. Submission is wired so the browser's
|
|
18
|
-
* native validation runs, but the page never reloads.
|
|
12
|
+
* An opinionated `<form>` wrapper: fields in a single column by default, or a
|
|
13
|
+
* responsive grid, plus a standard action bar. Field components
|
|
14
|
+
* ({@link import("./textline").textline} et al.) drop straight in as
|
|
15
|
+
* {@link ContentOptions.content}; the browser's native validation runs on submit,
|
|
16
|
+
* but the page never reloads.
|
|
19
17
|
*
|
|
20
18
|
* @example
|
|
21
19
|
* ```ts
|
|
@@ -33,8 +31,7 @@ A.insertGlobalCss({
|
|
|
33
31
|
export function form(opts = {}) {
|
|
34
32
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
35
33
|
A(`form.s-form`, o.attrs, () => {
|
|
36
|
-
//
|
|
37
|
-
// the fields (which would lose focus / input state).
|
|
34
|
+
// Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
|
|
38
35
|
A(() => {
|
|
39
36
|
A(".grid=", o.layout === 'grid');
|
|
40
37
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shortcut overview: a dialog listing what a keypress could do *right now*
|
|
3
|
+
* — every described binding the keyboard focus and any open modal leave in
|
|
4
|
+
* effect: buttons under their label, menu items under theirs, {@link bindKey}
|
|
5
|
+
* bindings under the description they were given.
|
|
6
|
+
*
|
|
7
|
+
* It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
|
|
8
|
+
* keypress closes it *and* still lands — so the key you just looked up can be
|
|
9
|
+
* pressed right there. That is also what keeps the listing honest: focus cannot
|
|
10
|
+
* move while it is up (a click lands on the backdrop, a key closes it), so what
|
|
11
|
+
* was true when it opened stays true. Bound to `?` (and `mod+?`, which also
|
|
12
|
+
* works while typing in a field) by default — see {@link setKeyHelp}. Calling
|
|
13
|
+
* this while the overview is already up closes it, which is what lets that `?`
|
|
14
|
+
* toggle.
|
|
15
|
+
*/
|
|
16
|
+
export declare function showKeyHelp(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Turn the default overview shortcuts off (or back on): `?` — free, like any
|
|
19
|
+
* unmodified key, whenever nothing is being typed into — and `mod+?`, which
|
|
20
|
+
* reaches the overview even from inside a text field. On by default.
|
|
21
|
+
*/
|
|
22
|
+
export declare function setKeyHelp(enabled: boolean): void;
|