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/skill/addTooltip.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
## addTooltip · function
|
|
2
2
|
|
|
3
|
-
Attaches a tooltip to the current element
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
element's bounding rect and automatically flips when near the viewport edge.
|
|
3
|
+
Attaches a tooltip to the current element, shown on hover or keyboard focus.
|
|
4
|
+
The tip panel is portalled into `document.body`, so `overflow:hidden` ancestors
|
|
5
|
+
never clip it; it is placed from the element's bounding rect, flipping to the
|
|
6
|
+
opposite side when near the viewport edge.
|
|
8
7
|
|
|
9
8
|
**Signature:** `(opts: TooltipOptions) => void`
|
|
10
9
|
|
package/skill/bindKey.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
## bindKey · function
|
|
2
|
+
|
|
3
|
+
Bind a keyboard shortcut, for as long as the calling scope lives.
|
|
4
|
+
|
|
5
|
+
**The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
|
|
6
|
+
(`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
|
|
7
|
+
`mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
|
|
8
|
+
`"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
|
|
9
|
+
Shift itself types is written as that character — `"?"`, never `"shift+/"` —
|
|
10
|
+
so a combination works on every keyboard layout. No other modifiers are
|
|
11
|
+
offered: Alt and the ⊞ key belong to the browser and the OS, which also
|
|
12
|
+
keep some `mod` combinations for themselves — T, N, W, Q and the digits
|
|
13
|
+
among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
|
|
14
|
+
takes a `key` option is spelled this way, and `formatKey` turns it
|
|
15
|
+
back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
|
|
16
|
+
|
|
17
|
+
`description` is what the shortcut overview (see `showKeyHelp`) lists
|
|
18
|
+
the binding as — a rich-text string or draw function; without one the
|
|
19
|
+
binding stays out of the overview. Omit `press` to merely *describe* a key
|
|
20
|
+
your app handles by other means, so the overview can still tell the user
|
|
21
|
+
about it.
|
|
22
|
+
|
|
23
|
+
A handler of the app's own that ran `preventDefault()` first always wins,
|
|
24
|
+
and keystrokes the focused element owns (typing into a field, Enter on a
|
|
25
|
+
link) are left to it. Otherwise `mode` says who else can reach the binding:
|
|
26
|
+
|
|
27
|
+
- `"normal"` (the default): works app-wide, but is silenced while a modal
|
|
28
|
+
dialog from outside it is up. Binding the same combination again shadows
|
|
29
|
+
the earlier binding until the new scope dies — so a state can take a key
|
|
30
|
+
over temporarily.
|
|
31
|
+
- `"global"`: keeps working even over a modal.
|
|
32
|
+
- `"local"`: only fires while the keyboard focus is inside the current
|
|
33
|
+
element — for a shortcut that belongs to one row or panel of many.
|
|
34
|
+
- an `Element`: like `"local"`, but for that element rather than the
|
|
35
|
+
current one.
|
|
36
|
+
|
|
37
|
+
**Signature:** `(spec: string, description?: Slot, press?: (e: KeyboardEvent) => void, mode?: "normal" | "global" | "local" | Element) => void`
|
|
38
|
+
|
|
39
|
+
**Parameters:**
|
|
40
|
+
|
|
41
|
+
- `spec: string`
|
|
42
|
+
- `description?: Slot`
|
|
43
|
+
- `press?: (e: KeyboardEvent) => void`
|
|
44
|
+
- `mode: "normal" | "global" | "local" | Element` (optional)
|
|
45
|
+
|
|
46
|
+
**Examples:**
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
S.bindKey("mod+k", "Search", openSearch);
|
|
50
|
+
S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
|
|
51
|
+
```
|
package/skill/box.md
CHANGED
package/skill/form.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
## form · function
|
|
2
2
|
|
|
3
|
-
An opinionated `<form>` wrapper
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
in as `ContentOptions.content`. Submission is wired so the browser's
|
|
9
|
-
native validation runs, but the page never reloads.
|
|
3
|
+
An opinionated `<form>` wrapper: fields in a single column by default, or a
|
|
4
|
+
responsive grid, plus a standard action bar. Field components
|
|
5
|
+
(("./textline").textline et al.) drop straight in as
|
|
6
|
+
`ContentOptions.content`; the browser's native validation runs on submit,
|
|
7
|
+
but the page never reloads.
|
|
10
8
|
|
|
11
9
|
**Signature:** `(opts?: Slot | FormOptions) => void`
|
|
12
10
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
## formatKey · function
|
|
2
|
+
|
|
3
|
+
Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
|
|
4
|
+
`"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
|
|
5
|
+
use it for the same hint elsewhere in your app, so both spell the shortcut the
|
|
6
|
+
way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
|
|
7
|
+
spelling instead: full modifier names and real key names,
|
|
8
|
+
`"Meta+Shift+K"`/`"Control+Shift+K"`.
|
|
9
|
+
|
|
10
|
+
**Signature:** `(spec: string, aria?: boolean) => string`
|
|
11
|
+
|
|
12
|
+
**Parameters:**
|
|
13
|
+
|
|
14
|
+
- `spec: string`
|
|
15
|
+
- `aria: any` (optional)
|
|
16
|
+
|
|
17
|
+
**Examples:**
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
|
|
21
|
+
```
|
package/skill/iconButton.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
## iconButton · function
|
|
2
2
|
|
|
3
3
|
A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
4
|
-
hover.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
| page's actions.
|
|
4
|
+
hover. For chrome that has to sit beside something more important without
|
|
5
|
+
competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
|
|
6
|
+
the verbs in a | page's actions.
|
|
8
7
|
|
|
9
8
|
Reach for `button` instead whenever the thing has a name worth reading;
|
|
10
|
-
an icon alone is only
|
|
9
|
+
an icon alone is unambiguous only for a handful of universal actions.
|
|
11
10
|
|
|
12
11
|
**Signature:** `(opts: IconButtonOptions) => void`
|
|
13
12
|
|
package/skill/scrollStrip.md
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
## scrollStrip · function
|
|
2
2
|
|
|
3
3
|
A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
|
|
4
|
-
button appearing over whichever end still has something left to reach —
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the buttons scroll it by most of a width at a time.
|
|
4
|
+
button appearing over whichever end still has something left to reach — so it
|
|
5
|
+
isn't just a swipe target. The row's own scrollbar is hidden, and the buttons
|
|
6
|
+
scroll it by most of a width at a time.
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
say — call `revealInStrip` with that child.
|
|
8
|
+
`tabs` puts its tab strip in one, and the routed `main` shell its
|
|
9
|
+
breadcrumb stack. Reach for it for any row of chrome that can outgrow its
|
|
10
|
+
space: a filter bar, a row of chips, a toolbar. `revealInStrip` brings
|
|
11
|
+
one of its children into view.
|
|
14
12
|
|
|
15
13
|
**Signature:** `(opts: ScrollStripOptions) => void`
|
|
16
14
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Open a floating dropdown menu anchored to an element. Portals to
|
|
4
4
|
`document.body` (never clipped), positions itself (flipping up when there's
|
|
5
|
-
no room below), and closes on Escape, Tab, item selection,
|
|
6
|
-
outside the panel and anchor. Returns a `close()` function.
|
|
5
|
+
no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
6
|
+
any click outside the panel and anchor. Returns a `close()` function.
|
|
7
7
|
|
|
8
8
|
Menus are usually opened through `menuButton` or
|
|
9
9
|
`addContextMenu`; reach for this primitive when you need to trigger a
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
## showKeyHelp · function
|
|
2
|
+
|
|
3
|
+
The shortcut overview: a dialog listing what a keypress could do *right now*
|
|
4
|
+
— every described binding the keyboard focus and any open modal leave in
|
|
5
|
+
effect: buttons under their label, menu items under theirs, `bindKey`
|
|
6
|
+
bindings under the description they were given.
|
|
7
|
+
|
|
8
|
+
It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
|
|
9
|
+
keypress closes it *and* still lands — so the key you just looked up can be
|
|
10
|
+
pressed right there. That is also what keeps the listing honest: focus cannot
|
|
11
|
+
move while it is up (a click lands on the backdrop, a key closes it), so what
|
|
12
|
+
was true when it opened stays true. Bound to `?` (and `mod+?`, which also
|
|
13
|
+
works while typing in a field) by default — see `setKeyHelp`. Calling
|
|
14
|
+
this while the overview is already up closes it, which is what lets that `?`
|
|
15
|
+
toggle.
|
|
16
|
+
|
|
17
|
+
**Signature:** `() => void`
|
package/skill/tabs.md
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
|
|
4
4
|
for the selected tab. Supports keyboard navigation (left/right/home/end).
|
|
5
5
|
|
|
6
|
-
More tabs than fit make the strip scroll sideways
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
(arrow keys, or a `bind` written from elsewhere) scrolls it back in.
|
|
6
|
+
More tabs than fit make the strip scroll sideways (see `scrollStrip`);
|
|
7
|
+
selecting a tab that's out of view — with the arrow keys, or a `bind` written
|
|
8
|
+
from elsewhere — scrolls it back in.
|
|
10
9
|
|
|
11
10
|
**Signature:** `(opts: TabsOptions) => void`
|
|
12
11
|
|
package/skill/textline.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
## textline · function
|
|
2
2
|
|
|
3
|
-
A single-line text input —
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Renders inside the standard `drawField` chrome (label, control,
|
|
7
|
-
help/error), so it aligns cleanly inside a `form`.
|
|
3
|
+
A single-line text input — text, passwords, numbers, email, dates and the other
|
|
4
|
+
line-oriented `<input>` types. Renders inside the standard `drawField`
|
|
5
|
+
chrome (label, control, help/error), so it aligns cleanly inside a `form`.
|
|
8
6
|
|
|
9
7
|
**Signature:** `(opts?: TextlineOptions) => void`
|
|
10
8
|
|
|
@@ -44,8 +44,8 @@ A.insertGlobalCss({
|
|
|
44
44
|
".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",
|
|
45
45
|
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
46
46
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
47
|
-
//
|
|
48
|
-
//
|
|
47
|
+
// Background, border, radius and elevation come from the popup's
|
|
48
|
+
// `.s-s.neutral.shadow` surface (see below).
|
|
49
49
|
"> .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",
|
|
50
50
|
"> .s-menu li": "margin:0",
|
|
51
51
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
@@ -261,9 +261,8 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
261
261
|
$st.open = false;
|
|
262
262
|
}
|
|
263
263
|
} else if (e.key === "Escape") {
|
|
264
|
-
// Only consume Escape while the list is showing: it dismisses the
|
|
265
|
-
//
|
|
266
|
-
// list already closed, let it pass through to the dialog/nav handlers.
|
|
264
|
+
// Only consume Escape while the list is showing: it dismisses the innermost
|
|
265
|
+
// layer, so a surrounding dialog closes on the next press, not this one.
|
|
267
266
|
if ($st.open) {
|
|
268
267
|
e.preventDefault();
|
|
269
268
|
$st.open = false;
|
package/src/components/box.ts
CHANGED
|
@@ -28,15 +28,10 @@ export interface BoxOptions extends ContentOptions {
|
|
|
28
28
|
footerAttrs?: Attributes;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
|
|
36
|
-
// the surface itself (see theme.ts), not from here. `.s-box` only does layout and
|
|
37
|
-
// the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
|
|
38
|
-
// deeper, for the raised shade), so we cancel their full surface border down to a
|
|
39
|
-
// single divider.
|
|
31
|
+
// Colours, border and shadow come from the `.s-s.neutral.shadow` surface itself
|
|
32
|
+
// (see theme.ts); `.s-box` only does layout. Header/footer are `.neutral` surfaces
|
|
33
|
+
// one level deeper (for the raised shade), with their surface border cancelled
|
|
34
|
+
// down to a single divider.
|
|
40
35
|
A.insertGlobalCss({
|
|
41
36
|
".s-box": {
|
|
42
37
|
// position:relative so a headerless box can hang its ✕ in the corner.
|
|
@@ -45,11 +40,8 @@ A.insertGlobalCss({
|
|
|
45
40
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
46
41
|
"> 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",
|
|
47
42
|
"> div": "p:$3 gap:$3",
|
|
48
|
-
// The ✕
|
|
49
|
-
// In a header row it parks at the far end...
|
|
43
|
+
// The ✕ parks at the end of a header row, or floats over the body when there is none.
|
|
50
44
|
"> header > .s-box-close": "margin-left:auto",
|
|
51
|
-
// ...and without a header there is no row to sit in, so it floats over the
|
|
52
|
-
// body's top-right corner instead.
|
|
53
45
|
"> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
|
|
54
46
|
},
|
|
55
47
|
});
|
|
@@ -78,12 +70,11 @@ export function box(opts: BoxOptions | Slot = {}): void {
|
|
|
78
70
|
const o: BoxOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
79
71
|
|
|
80
72
|
A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
|
|
81
|
-
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
82
|
-
// the body (which may hold focused inputs
|
|
73
|
+
// Header and footer get their own scopes, so toggling them doesn't recreate
|
|
74
|
+
// the body (which may hold focused inputs).
|
|
83
75
|
A(() => {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// but do nothing, which is worse than not rendering at all.
|
|
76
|
+
// typeof-guarded: v0.9's removed `close: true`, reaching us from unchecked
|
|
77
|
+
// JS, would otherwise render a ✕ that does nothing.
|
|
87
78
|
if (o.header != null) {
|
|
88
79
|
A("header.s-s.neutral", o.headerAttrs, () => {
|
|
89
80
|
drawSlot(o.header);
|
|
@@ -105,9 +96,8 @@ export function box(opts: BoxOptions | Slot = {}): void {
|
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
/**
|
|
108
|
-
* The box's ✕: one definition, so
|
|
109
|
-
*
|
|
110
|
-
* The `.s-box-close` class is only a hook for the placement rules above.
|
|
99
|
+
* The box's ✕: one definition, so it is identical in a header row and floating
|
|
100
|
+
* over a headerless body. `.s-box-close` is only a hook for the placement rules.
|
|
111
101
|
*/
|
|
112
102
|
function drawCloseButton(close: () => void): void {
|
|
113
103
|
iconButton({
|
package/src/components/button.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
+
import { bindKey, formatKey } from "../keys.js";
|
|
4
|
+
import { addTooltip } from "./tooltip.js";
|
|
3
5
|
|
|
4
6
|
/** Options for {@link iconButton}. */
|
|
5
7
|
export interface IconButtonOptions {
|
|
@@ -9,6 +11,12 @@ export interface IconButtonOptions {
|
|
|
9
11
|
ariaLabel: string;
|
|
10
12
|
/** Click handler. */
|
|
11
13
|
click?: (event: Event) => void;
|
|
14
|
+
/**
|
|
15
|
+
* A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
|
|
16
|
+
* The tooltip shows it after the `ariaLabel` (a glyph is worth naming there
|
|
17
|
+
* anyway), and the `?` overview lists it under that label too.
|
|
18
|
+
*/
|
|
19
|
+
key?: string;
|
|
12
20
|
/** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
|
|
13
21
|
href?: string;
|
|
14
22
|
/** Disables it. */
|
|
@@ -37,6 +45,16 @@ export interface ButtonOptions {
|
|
|
37
45
|
href?: string;
|
|
38
46
|
/** Accessible label, when the button has only an icon. */
|
|
39
47
|
ariaLabel?: string;
|
|
48
|
+
/**
|
|
49
|
+
* A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
|
|
50
|
+
* {@link bindKey} for the spelling and which keystrokes are yours to take.
|
|
51
|
+
* It works from anywhere while the button is drawn, shows in a tooltip,
|
|
52
|
+
* reaches screen readers as `aria-keyshortcuts`, and does exactly what a
|
|
53
|
+
* click does: a `type: "submit"` submits its form, an `href` navigates. The
|
|
54
|
+
* `?` overview ({@link showKeyHelp}) lists it under the button's text (or
|
|
55
|
+
* its `ariaLabel`).
|
|
56
|
+
*/
|
|
57
|
+
key?: string;
|
|
40
58
|
/**
|
|
41
59
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
42
60
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
@@ -50,9 +68,8 @@ export interface ButtonOptions {
|
|
|
50
68
|
attrs?: Attributes;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// This rule only handles layout, focus, hover and sizing.
|
|
71
|
+
// Colours, border and radius come from the `.s-s` surface classes in theme.ts;
|
|
72
|
+
// this rule only does layout, focus, hover and sizing.
|
|
56
73
|
A.insertGlobalCss({
|
|
57
74
|
".s-btn": {
|
|
58
75
|
"&":
|
|
@@ -60,52 +77,34 @@ A.insertGlobalCss({
|
|
|
60
77
|
"font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
61
78
|
"padding: $m2 $m3; " +
|
|
62
79
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
63
|
-
// Focus ring via `outline
|
|
64
|
-
// (which hard-clears box-shadow). Modern browsers round it to the border-radius.
|
|
80
|
+
// Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
|
|
65
81
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
66
|
-
// The button carries `.shadow` (added in button() below); on a filled accent
|
|
67
|
-
// surface that resolves to the signature self-coloured glow, on a neutral
|
|
68
|
-
// `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
|
|
69
82
|
"&:hover": "filter: brightness(1.06)",
|
|
70
|
-
// Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
|
|
71
|
-
// button (which is already near-white) darkens toward its ink instead.
|
|
72
83
|
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
84
|
+
// A `.neutral` button is already near-white, so it darkens toward its ink
|
|
85
|
+
// instead of brightening.
|
|
73
86
|
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
74
|
-
// The button sizes its glyph
|
|
75
|
-
//
|
|
76
|
-
// here makes every icon in a row come out alike. It rides the font size,
|
|
77
|
-
// so a `.small`/`.large` button scales its icon with its text.
|
|
87
|
+
// The button sizes its glyph rather than trusting the caller: only a rule here
|
|
88
|
+
// makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
|
|
78
89
|
"> svg": "width:1.25em height:1.25em",
|
|
79
|
-
// Subtle press feedback.
|
|
80
90
|
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
81
|
-
//
|
|
82
|
-
//
|
|
91
|
+
// Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
|
|
92
|
+
// container can size all its buttons at once.
|
|
83
93
|
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
84
94
|
"&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
|
|
85
95
|
},
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// title (a ✕, a ☰) should read as an affordance on the bar, not as
|
|
89
|
-
// another button competing with it, and a filled or outlined box around a
|
|
90
|
-
// 16px glyph is exactly what makes a top bar look busy.
|
|
96
|
+
// Deliberately *not* a `.s-s` surface: chrome sitting beside a title (a ✕, a ☰)
|
|
97
|
+
// should read as an affordance on the bar, not as another button competing with it.
|
|
91
98
|
".s-icon-btn": {
|
|
92
99
|
"&":
|
|
93
100
|
"display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
|
|
94
101
|
"width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
|
|
95
102
|
"fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
|
|
96
103
|
"transition: color 0.12s, background 0.12s;",
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// others. CSS beats the `width`/`height` attributes the icon set writes, so
|
|
101
|
-
// `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
|
|
102
|
-
// `attrs` override still wins over this, being an inline style. The same
|
|
103
|
-
// rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
|
|
104
|
-
// one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
|
|
105
|
-
// of it — see the note there.)
|
|
104
|
+
// As on `.s-btn`: the container sizes the glyph so a row of icon buttons reads
|
|
105
|
+
// as a row. CSS beats the `width`/`height` attributes the icon set writes; an
|
|
106
|
+
// `attrs` override still wins over this, being an inline style.
|
|
106
107
|
"> svg": "width:1.25em height:1.25em",
|
|
107
|
-
// The ink resolves against whatever surface it sits on, so one treatment
|
|
108
|
-
// works on the page, in a box header, and on a coloured bar alike.
|
|
109
108
|
"&:hover:not(:disabled):not([aria-disabled=true])":
|
|
110
109
|
"fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
111
110
|
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
|
|
@@ -117,13 +116,12 @@ A.insertGlobalCss({
|
|
|
117
116
|
|
|
118
117
|
/**
|
|
119
118
|
* A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
120
|
-
* hover.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* {@link Panel.actions | page's actions}.
|
|
119
|
+
* hover. For chrome that has to sit beside something more important without
|
|
120
|
+
* competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
|
|
121
|
+
* the verbs in a {@link Panel.actions | page's actions}.
|
|
124
122
|
*
|
|
125
123
|
* Reach for {@link button} instead whenever the thing has a name worth reading;
|
|
126
|
-
* an icon alone is only
|
|
124
|
+
* an icon alone is unambiguous only for a handful of universal actions.
|
|
127
125
|
*
|
|
128
126
|
* @example
|
|
129
127
|
* ```ts
|
|
@@ -140,16 +138,16 @@ export function iconButton(opts: IconButtonOptions): void {
|
|
|
140
138
|
A(`${tag}.s-icon-btn`, opts.attrs, () => {
|
|
141
139
|
applyActionBehavior(opts);
|
|
142
140
|
A("aria-label=", opts.ariaLabel);
|
|
141
|
+
if (opts.key) applyKey(opts.key, opts.ariaLabel, undefined, opts.disabled);
|
|
143
142
|
drawSlot(opts.icon);
|
|
144
143
|
});
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
/**
|
|
148
|
-
* The link-or-button plumbing {@link button} and {@link iconButton} share
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* the `<button>` form's real `disabled` attribute.
|
|
147
|
+
* The link-or-button plumbing {@link button} and {@link iconButton} share. A
|
|
148
|
+
* disabled link keeps `role=button` and `aria-disabled` but loses its `href`: an
|
|
149
|
+
* anchor without one is out of the tab order and follows nothing, which is what
|
|
150
|
+
* makes it as disabled as a `<button>`'s real `disabled` attribute.
|
|
153
151
|
*/
|
|
154
152
|
function applyActionBehavior(o: {
|
|
155
153
|
href?: string;
|
|
@@ -168,6 +166,30 @@ function applyActionBehavior(o: {
|
|
|
168
166
|
if (o.click && !o.disabled) A("click=", o.click);
|
|
169
167
|
}
|
|
170
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The shortcut plumbing {@link button} and {@link iconButton} share: bind it,
|
|
171
|
+
* announce it as `aria-keyshortcuts`, and hint at it in a tooltip — the only
|
|
172
|
+
* place a button can say what its key is without shouting it beside the label.
|
|
173
|
+
*
|
|
174
|
+
* Pressing it clicks the element rather than calling `click` directly, so a
|
|
175
|
+
* `type=submit` still submits its form and an `href` still navigates. Call this
|
|
176
|
+
* inside the button's own element scope, whose element it takes and whose life
|
|
177
|
+
* the binding follows.
|
|
178
|
+
*/
|
|
179
|
+
function applyKey(key: string, label: string | undefined, content: Slot | undefined, disabled?: boolean): void {
|
|
180
|
+
const el = A() as HTMLElement;
|
|
181
|
+
const tip = label ? `${label} · ${formatKey(key)}` : formatKey(key);
|
|
182
|
+
// A draw function, not a string: a key like `*` is markup to rich text.
|
|
183
|
+
addTooltip({ tip: () => A("#", tip) });
|
|
184
|
+
// Bound only while it can be pressed — a disabled button would otherwise
|
|
185
|
+
// swallow the combination rather than leave it to whoever else wants it. The
|
|
186
|
+
// overview names it by its visible text, or its aria label failing that.
|
|
187
|
+
if (!disabled) {
|
|
188
|
+
A("aria-keyshortcuts=", formatKey(key, true));
|
|
189
|
+
bindKey(key, typeof content === "string" ? content : label, () => el.click());
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
171
193
|
/**
|
|
172
194
|
* A button. Tonal and outlined variants show a border; filled variants rely on
|
|
173
195
|
* their solid background for affordance.
|
|
@@ -197,13 +219,14 @@ export function button(opts: ButtonOptions | Slot = {}): void {
|
|
|
197
219
|
|
|
198
220
|
const tag = o.href != null ? "a" : "button";
|
|
199
221
|
|
|
200
|
-
// A bare `.s-s` is a filled
|
|
201
|
-
//
|
|
202
|
-
// role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
|
|
203
|
-
// role detection needed, since the default lives in CSS, not here.
|
|
222
|
+
// A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
|
|
223
|
+
// detection here: `attrs` just names another role or variant.
|
|
204
224
|
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
205
225
|
applyActionBehavior(o);
|
|
206
226
|
if (o.ariaLabel) A("aria-label=", o.ariaLabel);
|
|
227
|
+
// Before the content, so a tooltip the caller adds in there is the later of
|
|
228
|
+
// the two and wins the hover.
|
|
229
|
+
if (o.key) applyKey(o.key, o.ariaLabel, o.content, o.disabled);
|
|
207
230
|
|
|
208
231
|
drawSlot(o.icon);
|
|
209
232
|
drawSlot(o.content);
|
|
@@ -49,8 +49,7 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
|
49
49
|
attrs: opts.attrs,
|
|
50
50
|
buttons: Object.entries(opts.options).map(([id, label]) => ({
|
|
51
51
|
content: label,
|
|
52
|
-
// Icon-only
|
|
53
|
-
// accessible name; plain-text labels speak for themselves.
|
|
52
|
+
// Icon-only (draw-function) labels get the id as their accessible name.
|
|
54
53
|
ariaLabel: typeof label === "function" ? id : undefined,
|
|
55
54
|
attrs: selected === id ? ".primary" : ".neutral",
|
|
56
55
|
click: () => {
|
|
@@ -61,7 +60,6 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
|
|
|
61
60
|
});
|
|
62
61
|
|
|
63
62
|
if (opts.name) {
|
|
64
|
-
// Hidden input carries the value into native form submission.
|
|
65
63
|
A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
|
|
66
64
|
}
|
|
67
65
|
}
|
|
@@ -19,8 +19,7 @@ A.insertGlobalCss({
|
|
|
19
19
|
"&": "display:flex flex-direction:column gap:$1",
|
|
20
20
|
"> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
|
|
21
21
|
"> label:has(input:disabled)": "cursor:not-allowed opacity:0.45 filter:saturate(0.6)",
|
|
22
|
-
//
|
|
23
|
-
// just strip the margin and let it inherit the label's cursor (pointer / not-allowed).
|
|
22
|
+
// Size and accent-color come from the CSS reset; here, the margin and the label's cursor.
|
|
24
23
|
"input": "cursor:inherit m:0",
|
|
25
24
|
},
|
|
26
25
|
});
|