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/SKILL.md
CHANGED
|
@@ -13,29 +13,20 @@ import * as S from "staffa";
|
|
|
13
13
|
|
|
14
14
|
const $user = A.proxy({ name: "", email: "" });
|
|
15
15
|
|
|
16
|
-
S.main({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
|
|
27
|
-
S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
|
|
28
|
-
},
|
|
29
|
-
actions: () => S.button({ content: "Create account", type: "submit" }),
|
|
30
|
-
});
|
|
31
|
-
},
|
|
32
|
-
});
|
|
16
|
+
S.main({ title: "Sign up", maxWidth: "40rem", content: () => {
|
|
17
|
+
S.form({
|
|
18
|
+
submit: () => S.dialog({ header: "Submitted", content: () => A.dump($user) }),
|
|
19
|
+
content: () => {
|
|
20
|
+
S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
|
|
21
|
+
S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
|
|
22
|
+
},
|
|
23
|
+
actions: () => S.button({ content: "Create account", type: "submit" }),
|
|
24
|
+
});
|
|
25
|
+
}});
|
|
33
26
|
```
|
|
34
27
|
|
|
35
28
|
Staffa is made to look decent out of the box, but easily customizable at runtime.
|
|
36
29
|
|
|
37
|
-
## Screenshot
|
|
38
|
-
|
|
39
30
|

|
|
40
31
|
|
|
41
32
|
## Install
|
|
@@ -46,32 +37,25 @@ npm install staffa aberdeen
|
|
|
46
37
|
|
|
47
38
|
Aberdeen is a peer dependency. Staffa is published as ESM with TypeScript types.
|
|
48
39
|
|
|
40
|
+
**Every option of every component is documented in TSDoc**, on its `…Options` interface in `src/` — and, for AI agents, in the generated API reference that ships in `skill/`. This README only covers what those can't tell you.
|
|
41
|
+
|
|
49
42
|
## How it works
|
|
50
43
|
|
|
51
44
|
### Components are functions
|
|
52
45
|
|
|
53
|
-
Every component
|
|
46
|
+
Every component is a plain function taking a single typed options object and drawing DOM via Aberdeen. No classes, no web components. The `S` object collects them all. The options object may be an Aberdeen proxy, in which case mutating it updates the component in place:
|
|
54
47
|
|
|
55
48
|
```ts
|
|
56
|
-
S.button({ content: "Save", disabled: false });
|
|
57
49
|
S.box({ header: "Settings", content: () => { ... } });
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### Options objects are typed and can be reactive
|
|
61
|
-
|
|
62
|
-
All components get their options in a typed object. The object may be an Aberdeen proxy, if you want to update the component in-place.
|
|
63
50
|
|
|
64
|
-
```ts
|
|
65
51
|
const $btn = A.proxy({ content: "Save", disabled: false });
|
|
66
52
|
S.button($btn);
|
|
67
|
-
setTimeout(() => //
|
|
68
|
-
$btn.disabled = true; // button updates instantly
|
|
69
|
-
}, 3000);
|
|
53
|
+
setTimeout(() => { $btn.disabled = true; }, 3000); // button updates instantly
|
|
70
54
|
```
|
|
71
55
|
|
|
72
56
|
### Rich text slots
|
|
73
57
|
|
|
74
|
-
Anywhere a component takes content
|
|
58
|
+
Anywhere a component takes content — a `label`, a `header`, a button's text, a dialog body — you can pass either a string or a `() => void` draw function. Strings render as **rich text**: `*italic*`, `**bold**`, `` `code` ``, `[link](/path)`. All text is safely escaped.
|
|
75
59
|
|
|
76
60
|
```ts
|
|
77
61
|
S.button({ content: "Save **now**" });
|
|
@@ -80,12 +64,12 @@ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
|
|
|
80
64
|
|
|
81
65
|
### Surfaces
|
|
82
66
|
|
|
83
|
-
Staffa builds on **surfaces**: elements marked
|
|
67
|
+
Staffa builds on **surfaces**: elements marked `.s-s` that have their own background and derived text/border tokens. There are two families:
|
|
84
68
|
|
|
85
|
-
- **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth
|
|
86
|
-
- **Accent surfaces** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare `.s-s` defaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a **variant**: `.filled` (default), `.tonal
|
|
69
|
+
- **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth, up to a cap. For cards, bars, popovers — anything that just holds content. No variants.
|
|
70
|
+
- **Accent surfaces** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare `.s-s` defaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a **variant**: `.filled` (default), `.tonal` or `.outlined`. A surface nested *inside* an accent surface is always rendered filled, so it can't bleed into the vivid parent.
|
|
87
71
|
|
|
88
|
-
Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral
|
|
72
|
+
Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral`). Every component's `attrs` option is an Aberdeen `A()` string, so overriding is easy:
|
|
89
73
|
|
|
90
74
|
```ts
|
|
91
75
|
S.button({ content: "Delete", attrs: ".danger" });
|
|
@@ -93,20 +77,20 @@ S.button({ content: "Cancel", attrs: ".neutral" }); // neutral button
|
|
|
93
77
|
S.box({ attrs: ".primary", content: () => { ... } });
|
|
94
78
|
```
|
|
95
79
|
|
|
96
|
-
Inside any surface (including `:root`), CSS variables
|
|
80
|
+
Inside any surface (including `:root`), CSS variables hold the background and a set of safe foreground colours: `$s-bg`, `$s-text` (also applied as `color`), `$s-muted`, `$s-accent` (the surface's "pop" — the brand primary on neutral surfaces, the ink on accent ones) and `$s-faint` (hairlines). Use these and components adapt to wherever they're nested.
|
|
97
81
|
|
|
98
|
-
The colour tokens are mode-independent and settable: `$s-primary` (the one brand colour — it tints the neutrals and defines `.s-s.primary`), `$s-danger`, `$s-success`, `$s-warning
|
|
82
|
+
The colour tokens themselves are mode-independent and settable: `$s-primary` (the one brand colour — it tints the neutrals and defines `.s-s.primary`), `$s-danger`, `$s-success`, `$s-warning` and `$s-link` (also the fill of the `.s-s.link` surface). Links render in `$s-link` on neutral surfaces, in the ink on accent ones.
|
|
99
83
|
|
|
100
|
-
**Borders & shadows.** Neutral surfaces carry a subtle hairline border on their own
|
|
84
|
+
**Borders & shadows.** Neutral surfaces carry a subtle hairline border on their own, so a card looks like a card without any component help. Any surface can be lifted with `.shadow` or `.extra-shadow` — a neutral drop shadow on a neutral surface, a self-coloured glow on an accent one, ignored on `.tonal`/`.outlined`. `.no-shadow` removes a component's built-in shadow:
|
|
101
85
|
|
|
102
86
|
```ts
|
|
103
87
|
S.box({ attrs: ".extra-shadow", content: () => { ... } }); // a more raised card
|
|
104
|
-
S.button({ content: "Quiet", attrs: ".no-shadow" });
|
|
88
|
+
S.button({ content: "Quiet", attrs: ".no-shadow" }); // drop the button glow
|
|
105
89
|
```
|
|
106
90
|
|
|
107
91
|
### Dark and light modes
|
|
108
92
|
|
|
109
|
-
Dark/light mode
|
|
93
|
+
Dark/light mode follows the OS preference by default. Override it (and persist the choice) with:
|
|
110
94
|
|
|
111
95
|
```ts
|
|
112
96
|
S.setDarkMode(true); // force dark
|
|
@@ -114,13 +98,43 @@ S.setDarkMode(false); // force light
|
|
|
114
98
|
S.setDarkMode(undefined); // follow OS
|
|
115
99
|
```
|
|
116
100
|
|
|
117
|
-
|
|
101
|
+
`S.getDarkMode()` reads it back, reactively. A `buttonChooser` is probably the right component for a colour scheme selector.
|
|
118
102
|
|
|
119
|
-
###
|
|
103
|
+
### CSS reset
|
|
104
|
+
|
|
105
|
+
Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better, but unsurprising without additional styling.
|
|
106
|
+
|
|
107
|
+
### Theming
|
|
108
|
+
|
|
109
|
+
Theming usually starts and ends with setting some CSS variables. Everything derives from the single brand colour `s-primary` (the neutral surface shades are tinted toward it too). Set them through CSS directly, or through Aberdeen:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
A.cssVars["s-primary"] = "#fdda58";
|
|
113
|
+
A.cssVars["s-danger"] = "#ee4422";
|
|
114
|
+
A.cssVars["s-radius"] = "4px";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
See `src/theme.ts` for the other variables in use.
|
|
118
|
+
|
|
119
|
+
Beyond that, add CSS to override the default styling. To add your own accent surface, set its background (and, if needed, its ink) — the gradient and the rest of the tokens follow automatically:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
|
|
123
|
+
|
|
124
|
+
S.button({
|
|
125
|
+
content: "You'll want to click me",
|
|
126
|
+
attrs: ".my-surface",
|
|
127
|
+
click: () => S.alert("Good work!", {attrs: ".my-surface"})
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Custom surface class names may be anything other than the built-in modifiers (`.tonal`, `.outlined`, `.small`, `.large`). The `.tonal` and `.outlined` variants work on your surface for free.
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
For styling that differs per mode, wrap the above in `A(() => { if (S.getDarkMode()) ... })`: `getDarkMode()` is reactive, so the scope re-runs when the mode changes.
|
|
122
134
|
|
|
123
|
-
|
|
135
|
+
### Panel-stack navigation
|
|
136
|
+
|
|
137
|
+
Give `S.main()` a `routes` table instead of a `content` slot and it takes over navigation. Each route draws one screen of your app — a **panel** — and the shell shows as many panels as comfortably fit, each in its own **column**: one at a time on a phone, several side by side on a wider screen. The open panels form a **stack**, whose last member is the **current** panel — the one the URL names, and the rightmost column. Your code doesn't know the difference.
|
|
124
138
|
|
|
125
139
|
```ts
|
|
126
140
|
const shell = S.main({
|
|
@@ -139,76 +153,25 @@ function drawProject($panel: S.Panel<{ projectId: string }>) {
|
|
|
139
153
|
$panel.title = `Project ${projectId}`; // the shell puts it wherever it fits
|
|
140
154
|
A(`a href=/projects/${projectId}/tasks/1 #Open the first task`);
|
|
141
155
|
}
|
|
142
|
-
|
|
143
|
-
// Etc..
|
|
144
156
|
```
|
|
145
157
|
|
|
146
|
-
Each handler gets a `$panel`
|
|
147
|
-
|
|
148
|
-
**Route keys.** A segment wrapped in brackets is a param:
|
|
149
|
-
|
|
150
|
-
- `[name]` matches one segment, as a string.
|
|
151
|
-
- `[name=integer]` matches one segment, as a number.
|
|
152
|
-
- `[...name]` matches the rest of the path, as a string. It has to be the last thing in the key, and it needs at least one segment to match.
|
|
158
|
+
Each handler gets a `$panel` proxy: the params from its route, plus what the shell needs to know about the panel — `title`, `actions`, `maxWidth`, `loading`, `pinned`, `unsaved`, `width`, `visible`, `close()`, `open()`, `stack`. Set them whenever you like — long after the handler ran, when your data arrives — and the shell keeps up. Each field is documented in the API reference.
|
|
153
159
|
|
|
154
|
-
The first key that matches wins
|
|
160
|
+
**Route keys.** `[name]` matches one segment as a string, `[name=integer]` one segment as a number, and a trailing `[...name]` the rest of the path as one raw (still percent-encoded) string. The first key that matches wins; a segment a param refuses falls through to a later route, or to `notFound`. TypeScript types each handler's `params` from its own key. `integer` accepts only spellings that survive a round trip back to the same URL, so one record can never have two paths — use a plain `[id]` for ids that aren't safe integers.
|
|
155
161
|
|
|
156
|
-
|
|
162
|
+
**Navigating is just links.** Write ordinary `<a href="/...">` links; the shell handles the clicks (so don't also call Aberdeen's `interceptLinks()`). The three navigations differ only in how much of the link's own context the target keeps:
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
- **push** (the default) opens on top of the panel the link sits in, closing everything after it — which is why clicking a second project replaces the open project instead of adding a third column.
|
|
165
|
+
- **replace** keeps everything under that panel but not the panel itself; what prev/next buttons want.
|
|
166
|
+
- **open** keeps none of it, giving the target its own stack, as a nav item does; for a search hit or a mention, where the panel you clicked from is coincidence, not context.
|
|
159
167
|
|
|
160
|
-
|
|
168
|
+
`data-panel` on the link picks one; `linkNavigation` sets the default for links without it. A link outside any panel (a nav item, one in a dialog) has nothing to build on, so it replaces the stack as a whole, exactly as a cold link to that URL would — panels the new stack also contains staying as they are. A link to a path that's already open returns to it, closing what was stacked on top, rather than opening it twice; the same path is never in the stack twice.
|
|
161
169
|
|
|
162
|
-
The
|
|
170
|
+
**The stack is an object, not a global.** `S.main()` hands back the `PanelStack` — `pushPanel`, `replacePanel`, `openPanelStack`, `closePanel`, and the live, reactive `panels` / `currentPanel` / `currentPanelIndex` — and every panel gets that same object as `$panel.stack`, which is what a route handler uses, since it runs while the `S.main()` call is still going. Every navigation settles asynchronously (closes travel through the browser's history), so each method returns a `Promise<boolean>`. Don't hold a `Panel` across a navigation; read it fresh. To navigate on behalf of one particular screen — a row's click handler — use that panel's own `$panel.open(href, how?)`, which does exactly what a link inside it does; `pushPanel` builds on the *current* panel instead.
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
- A `data-panel` attribute on the link picks a different one of the three navigations, which differ only in how much of the link's own context the target keeps: `push` (the default just described) keeps the link's panel and builds on it; `replace` keeps everything under that panel but not the panel itself — what prev/next buttons want; and `open` keeps none of it, giving the target its own stack exactly as a nav item would — for a search hit or a mention, where the panel you clicked from is coincidence, not context.
|
|
166
|
-
- A plain link to something that's already open goes back to it instead of opening it twice, closing whatever was stacked on top of it; a `replace` or `open` applies its usual shape instead, the open panel moving into place with its state intact. Either way the same path is never in the stack twice.
|
|
167
|
-
- A link that isn't inside a panel (a nav item, or one in a dialog) has no panel to build on, so it replaces the stack as a whole: the panel you asked for, with its ancestor panels opened beneath it (see [below](#ancestors)). Panels that the new stack also contains stay as they are, so clicking the nav item for the section you're already in won't reset it. Clicking a nav item and opening that same URL in a fresh tab therefore give you the same columns.
|
|
172
|
+
**Columns and widths.** The content area is the window minus the nav sidebar, or `S.main({ maxWidth })` of it, centred — the same width whatever is open, so the sidebar, top bar and footer never move. It divides into the narrowest whole number of columns of at least **360px**, capped at **540px** each; `$panel.maxWidth` asks for one, two (the default), three of them or the lot. A column's width depends only on the window, never on what else is open, so opening or closing a panel never resizes another. Its ask is a ceiling, never a floor: aim your layout at 360px and let it degrade gracefully below that. `$panel.width` is the resolved figure in pixels, correct before your handler draws.
|
|
168
173
|
|
|
169
|
-
**
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
shell.pushPanel(path); // on top of the current panel
|
|
173
|
-
shell.replacePanel(path); // in its place
|
|
174
|
-
shell.openPanelStack(path, beneath?); // a whole arrangement, the way a nav item does
|
|
175
|
-
shell.closePanel(path?); // the current panel, or a named one
|
|
176
|
-
|
|
177
|
-
shell.panels; // the open panels, oldest first — the Panel objects themselves
|
|
178
|
-
shell.currentPanelIndex; // which of them the URL is on
|
|
179
|
-
shell.currentPanel; // shorthand for panels[currentPanelIndex]
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Navigations settle asynchronously (closes travel through the browser's history), so each of the four methods returns a `Promise<boolean>`: `true` once it lands, `false` when it doesn't — an unsaved panel refused to close, a route guard said no, or another navigation superseded it. Ignore it unless you care.
|
|
183
|
-
|
|
184
|
-
`panels` is a live view rather than a copy, so writing through it works — `shell.panels[0].pinned = true` is the only way to pin a panel from outside its own handler. All three are reactive on the stack's shape: read one in a scope and it re-runs when panels open, close or the cursor moves. Don't hold a `Panel` across a navigation; read it fresh.
|
|
185
|
-
|
|
186
|
-
Navigating faster than the shell can settle is fine: closing travels through the browser's history, so it takes a moment to land, and anything asked for in the meantime waits for it rather than being dropped. Two quick Escapes (or back gestures) peel two panels, each aimed at the stack the one before it was heading for.
|
|
187
|
-
|
|
188
|
-
**The content area is the window**, minus the nav sidebar — or, when `S.main({ maxWidth })` says so, that much of it, centred. Either way it is the same width whatever is open, so the sidebar, the top bar and the footer never move.
|
|
189
|
-
|
|
190
|
-
The shell divides that area into columns: the narrowest whole number of them that keeps each one at least **360px** wide, and no column ever wider than **540**. A 1080px content area is three columns of 360; a 1520px one is four of 380; below 720px there is a single column — of at most 540, centred. `$panel.maxWidth` counts in those columns:
|
|
191
|
-
|
|
192
|
-
| `maxWidth` | How wide the panel gets | Good for |
|
|
193
|
-
| --- | --- | --- |
|
|
194
|
-
| `"small"` | One column — never above 540px. | lists, detail forms — anything that reads well at phone width |
|
|
195
|
-
| `"medium"` (default) | Two columns — never above 1080px. | ordinary screens; the safe default |
|
|
196
|
-
| `"large"` | Three columns — never above 1620px. | wide tables, dense forms |
|
|
197
|
-
| `"none"` | The whole content area, unbounded. | boards, dashboards |
|
|
198
|
-
|
|
199
|
-
The ask is a promise in both directions: the shell never draws a panel wider than its column count × 540px, and every size is also capped at the content area — so a draw function has to look right from 360px up to its own ceiling, and nowhere past it. There is nothing below 360 to handle either: a narrower window is shown the 360px layout scaled down to fit (dialogs, menus and toasts scaling along), so 360 really is the floor.
|
|
200
|
-
|
|
201
|
-
A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. As many columns as fit are shown, ending at the current panel; when they don't fill the content area they sit centred in it, so an arriving column nudges the others over to share the room.
|
|
202
|
-
|
|
203
|
-
`S.main({ navWidth, maxWidth })` sets the sidebar's width and the cap. Leave `maxWidth` off and the app simply fills the window — worth capping (`"1600px"`, say) if you'd rather it didn't march across a 4K display.
|
|
204
|
-
|
|
205
|
-
Columns tile the area they're given, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
|
|
206
|
-
|
|
207
|
-
The panel is sized before your handler runs, and `$panel.width` is the resolved figure in pixels — so a chart, a virtualised list or a column count has the real width from the first frame, with nothing to measure. Set `maxWidth` at the top of your handler and you draw at the new width; set it later and the panel reflows without being redrawn, so nothing in it is rebuilt or loses its state.
|
|
208
|
-
|
|
209
|
-
<a id="chrome"></a>
|
|
210
|
-
|
|
211
|
-
**A panel declares its chrome; the shell places it.** A screen says what it is called and what it can do; everything else in its column — headings, cards, boxes — is the screen's own content, drawn like any other. Where the chrome ends up depends on how many columns are showing and how wide the shell is, so the shell decides:
|
|
174
|
+
**A panel declares its chrome; the shell places it.** A screen says what it is called and what it can do; everything else in its column — headings, cards, boxes — is the screen's own content, drawn like any other.
|
|
212
175
|
|
|
213
176
|
```ts
|
|
214
177
|
function drawTask($panel: S.Panel<{ taskId: number }>) {
|
|
@@ -218,33 +181,11 @@ function drawTask($panel: S.Panel<{ taskId: number }>) {
|
|
|
218
181
|
}
|
|
219
182
|
```
|
|
220
183
|
|
|
221
|
-
On a wide screen the title becomes the stack's last crumb and
|
|
222
|
-
|
|
223
|
-
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. A crumb is an ordinary link to its panel, so clicking one goes back to that panel and closes what was stacked on top of it — and the browser's back button brings those columns back. The app's name and logo link to the app's home (the `home` option, `/` by default; `null` links neither), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
|
|
224
|
-
|
|
225
|
-
That line is the `subtitle`'s while the stack has nothing to add: one panel open, reachable from a nav item that is already highlighted in a visible sidebar. Otherwise the stack takes it, since it is then the only thing naming the screen.
|
|
226
|
-
|
|
227
|
-
Right-click (or long-press) a crumb for **Close** — which takes just that panel out, wherever it sits in the stack — and **Pin**. A pinned panel — its crumb wears a pin — never closes as a side effect of navigation elsewhere: where a navigation would prune it, it rides along beneath the new panel instead (or parks out of sight, when the panel you went back to was already beneath it), one crumb click away. Pin the reference you keep coming back to, then navigate freely. An *explicit* close (Escape, `close()`, the crumb menu, `data-panel=replace`) still closes it, and it's yours from code as `$panel.pinned`. Because a crumb is a real link whose right-click the menu takes over, the menu also offers **Open in new tab** and **Copy link**.
|
|
228
|
-
|
|
229
|
-
A crumb can also wear a **●**: the panel holds unsaved work, and nothing will close it (see `$panel.unsaved` below).
|
|
230
|
-
|
|
231
|
-
| `$panel` | what it does |
|
|
232
|
-
| --- | --- |
|
|
233
|
-
| `title` | Names the screen: its breadcrumb, and `document.title` while it's the current panel. A panel that sets none borrows the first line of text in its own body — good enough for a crumb, but say it yourself. |
|
|
234
|
-
| `actions` | The screen's buttons or menu. In the column's chrome while several columns fit; in the top bar (taking the app `menu`'s place) once the shell is narrow. A link among them builds on this panel at both widths. |
|
|
235
|
-
|
|
236
|
-
Two deliberate rules there. `actions` are the screen's *verbs* — Save, Delete, Share, a menu — not a second way out: going back is the crumbs' job, at every width, and there is no back button even on a phone. And **`title` names the screen; it does not draw a heading** — a screen that wants its name in its own body writes it there, where it owns the typography.
|
|
184
|
+
On a wide screen the title becomes the stack's last crumb and Save sits in a quiet strip at the top of the column; on a phone the crumb is still there and Save moves into the top bar, where the app menu was. Nothing in your code measures the viewport. Two deliberate rules: `actions` are the screen's *verbs* — Save, Delete, a menu — not a second way out, since going back is the crumbs' job at every width and there is no back button even on a phone (a link among the actions builds on this panel at both widths); and **`title` names the screen, it does not draw a heading** — a screen that wants its name in its own body writes it there, where it owns the typography. A column's body keeps a comfortable `$3` of padding; write `A("p:0")` for edge-to-edge rows, since the draw function's current element *is* the body.
|
|
237
185
|
|
|
238
|
-
|
|
186
|
+
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42`, the ones currently on screen in bold — leaving that line to the app's `subtitle` only while the stack has nothing to add. Each crumb is an ordinary link back to its panel, closing what was stacked on top; the app's name and logo link to `home` (`/` by default, `null` links neither). Right-clicking a crumb offers **Close** and **Pin**: a pinned panel (`$panel.pinned`) survives navigation elsewhere, riding along beneath the new panel or parking out of sight, but not an explicit close. Escape closes the current panel, or steps left when it can't, and at the stack's start jumps to the navigation.
|
|
239
187
|
|
|
240
|
-
**The
|
|
241
|
-
|
|
242
|
-
- `params` and `path`: read-only.
|
|
243
|
-
- `maxWidth`: as above, and live — set it whenever you like and the panel reflows.
|
|
244
|
-
- `loading`: set it while you're fetching. A new panel waits a moment before sliding in, so it can arrive with real content instead of empty, and shows a loading indicator if the wait drags on.
|
|
245
|
-
- `width` and `visible`: read-only and reactive. `width` is this column's width in pixels, for the rare content that genuinely differs by width. `visible` says whether this panel is on screen — not crowded out, not parked, not closing — which is the right question for per-panel floating UI like a FAB, since "am I the current panel?" answers wrongly when two columns are up.
|
|
246
|
-
- `pinned`: the crumb menu's Pin, from code.
|
|
247
|
-
- `unsaved`: set it while the panel holds work that must not be lost — a dirty form, an upload in flight. An unsaved panel **cannot be closed, by anything**: navigation and the back button park it instead (wearing a ● in its crumb), `close()` and the crumb menu's Close refuse, Escape steps left, and closing the browser tab runs into the browser's own are-you-sure. The tab title carries a leading `•` while *any* open panel is unsaved. Only the app clears the flag, which is its explicit "this is now discardable":
|
|
188
|
+
**Unsaved work.** `$panel.unsaved` marks a panel holding work that must not be lost — a dirty form, an upload in flight. It then **cannot be closed, by anything**: navigation and the back button park it instead (its crumb wearing a ●), `close()` and the crumb menu's Close refuse, Escape steps left, and closing the browser tab runs into the browser's own are-you-sure. The tab title carries a leading `•` while *any* open panel is unsaved. Only the app clears the flag:
|
|
248
189
|
|
|
249
190
|
```ts
|
|
250
191
|
A(() => { $panel.unsaved = $form.dirty || undefined; }); // the whole dirty check
|
|
@@ -257,27 +198,9 @@ S.button({ content: "Discard", attrs: ".neutral", click: () => {
|
|
|
257
198
|
|
|
258
199
|
So a panel that can *be* unsaved needs its own way out — a Save or Discard among its `actions`. There is no "discard changes?" dialog anywhere: leaving is never blocked, the work just waits, parked, one crumb away.
|
|
259
200
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
```ts
|
|
263
|
-
S.button({ content: "Cancel", attrs: ".neutral", click: () => $panel.close() });
|
|
264
|
-
$panel.stack.closePanel(); // the current panel
|
|
265
|
-
$panel.stack.closePanel("/projects/7"); // that panel, wherever it is
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
Closing the current panel hands the focus to the panel on its left. Closing one that *isn't* current takes just that one away: the columns around it stay where they are and keep their state, and the URL doesn't change, because the current panel didn't move. Either way it becomes a history entry, so the browser's back button brings the panel back.
|
|
269
|
-
|
|
270
|
-
A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then. Only its element hangs around, inert and frozen, for the length of the exit animation.
|
|
271
|
-
|
|
272
|
-
Escape closes the current panel — or just steps left, when it holds unsaved work or panels sit parked beyond it — and at the stack's start it jumps to the navigation. The browser's back button replays whole arrangements, re-opening what a navigation closed.
|
|
273
|
-
|
|
274
|
-
<a id="ancestors"></a>
|
|
201
|
+
**Cold URLs.** The URL holds the current panel; the rest of the stack — the panels before it, any parked after it, and which are pinned — rides in the browser's history entry, so back and forward step through whole arrangements of columns and a reload brings the same ones back. A URL arriving without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for, so `/projects/7/tasks/42` opens as three columns. A parent path you have no route for is skipped — so if you don't want one screen appearing under another, just don't give it a route.
|
|
275
202
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
A URL that arrives without any of that (a shared link, a bookmark, a new tab) has nothing to restore, so Staffa builds the stack from the path: it walks the parent paths and opens each one you have a route for. With the routes above, `/projects/7/tasks/42` opens as three columns: the project list, project 7, and task 42. A parent path you have no route for is skipped, so if you don't want one screen appearing under another, just don't give it a route.
|
|
279
|
-
|
|
280
|
-
That only works for URLs that spell their own context out. A flat one — `/thread/[id]`, where a push notification lands — has no parent path to walk, so it would open as a lone column with nothing beneath it and nothing for Escape to do. `ancestors` is where you say what belongs under it. It's keyed by the same path templates as `routes`, so each entry gets that key's params, matched and typed:
|
|
203
|
+
That only works for URLs that spell their own context out. A flat one — `/thread/[id]`, where a push notification lands — has no parent path to walk, so `ancestors` is where you say what belongs underneath. It's keyed by the same path templates as `routes`, so each entry gets that key's params, matched and typed:
|
|
281
204
|
|
|
282
205
|
```ts
|
|
283
206
|
S.main({
|
|
@@ -291,107 +214,35 @@ S.main({
|
|
|
291
214
|
});
|
|
292
215
|
```
|
|
293
216
|
|
|
294
|
-
Return the paths shallowest first, or nothing to
|
|
295
|
-
|
|
296
|
-
It has to answer without drawing anything, which is why it lives here rather than on `$panel`: it's consulted while the navigation is still being worked out, before any route handler has run.
|
|
297
|
-
|
|
298
|
-
From code, `openPanelStack(path, beneath?)` opens the same kind of arrangement, either asking `ancestors` for the panels beneath or taking the ones you hand it.
|
|
299
|
-
|
|
300
|
-
Search params and the `#hash` belong to the current panel only. Anything another panel in the stack needs in order to redraw itself has to live in its path. (A panel you browse away from does get its search and hash back when a crumb makes it current again.)
|
|
217
|
+
Return the paths shallowest first, or nothing to fall back to the parent-path walk — which is also what an unlisted route gets. It's asked for every navigation that has no panel to build on, so a nav item and a fresh tab agree, and it is consulted before any handler has run, so it must answer without drawing anything. From code, `openPanelStack(path, beneath?)` opens the same kind of arrangement.
|
|
301
218
|
|
|
302
219
|
**A few more things.**
|
|
303
220
|
|
|
304
|
-
-
|
|
305
|
-
-
|
|
306
|
-
-
|
|
307
|
-
- Only one routed `S.main()` can be mounted at a time; a second one throws
|
|
308
|
-
-
|
|
309
|
-
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback
|
|
310
|
-
|
|
311
|
-
### CSS reset
|
|
312
|
-
|
|
313
|
-
Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit better but unsurprising without additional styling.
|
|
314
|
-
|
|
315
|
-
### Theming
|
|
316
|
-
|
|
317
|
-
The first step in theming is just setting some CSS variables. Everything derives from a single brand colour, `s-primary` (the neutral surface shades are tinted toward it too), so often that's all you need. This can be done through CSS directly, or using Aberdeen:
|
|
318
|
-
|
|
319
|
-
```ts
|
|
320
|
-
A.cssVars["s-primary"] = "#fdda58";
|
|
321
|
-
A.cssVars["s-danger"] = "#ee4422";
|
|
322
|
-
A.cssVars["s-radius"] = "4px";
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
See `src/theme.ts` for what other CSS variables are being used.
|
|
326
|
-
|
|
327
|
-
If you need further customization, just add some CSS to override the default styling. For instance, to add your own accent surface, set its background (and, if needed, its ink) — the subtle gradient and the rest of the tokens follow automatically:
|
|
328
|
-
|
|
329
|
-
```ts
|
|
330
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
|
|
331
|
-
|
|
332
|
-
S.button({
|
|
333
|
-
content: "You'll want to click me",
|
|
334
|
-
attrs: ".my-surface",
|
|
335
|
-
click: () => S.alert("Good work!", {attrs: ".my-surface"})
|
|
336
|
-
});
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
Custom surface class names may be anything (other than the built-in modifiers `.tonal`, `.outlined`, `.small`, `.large`). The `.tonal` and `.outlined` variants work on your surface for free.
|
|
340
|
-
|
|
341
|
-
Note that when changing CSS like this, things *may* break if you upgrade Staffa. The recommended update strategy is therefore: don't!
|
|
342
|
-
|
|
343
|
-
If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
|
|
344
|
-
|
|
345
|
-
```ts
|
|
346
|
-
A(() => {
|
|
347
|
-
if (S.getDarkMode()) {
|
|
348
|
-
A.cssVars["s-primary"] = "#aa9944";
|
|
349
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#444444 --s-text:#fff"});
|
|
350
|
-
} else {
|
|
351
|
-
A.cssVars["s-primary"] = "#fdda58";
|
|
352
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#cccccc --s-text:#000"});
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
```
|
|
221
|
+
- Search params and the `#hash` belong to the current panel only; anything another panel needs in order to redraw itself has to live in its path. (A panel you browse away from does get its search and hash back when a crumb makes it current again.)
|
|
222
|
+
- A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then.
|
|
223
|
+
- `columns: "single"` shows only the current panel however wide the screen — only the display changes. It, `linkNavigation` and `maxWidth` are live: pass a proxied options object and a change is adopted in place, every panel keeping its state.
|
|
224
|
+
- Only one routed `S.main()` can be mounted at a time; a second one throws, since the URL is global. Nothing else is.
|
|
225
|
+
- Aberdeen's own `route.go()` works, but builds the whole stack from the path; prefer the stack's methods. A guard your app registered with `route.setGuard` keeps working — Staffa registers none of its own.
|
|
226
|
+
- Deep links need your static server to serve the app for unknown paths (the usual SPA fallback; `-P` for `http-server`, as in the demo command below).
|
|
356
227
|
|
|
357
228
|
## Components
|
|
358
229
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
### Layout & containers
|
|
362
|
-
|
|
363
|
-
- **`S.main(opts)`**: app shell, a sticky header with `logo`, `title`, `subtitle`, `menu` — plus, in routed mode, the breadcrumbs of the open panels; scrollable content area; footer. Set `maxWidth` to center the content. Give it a `nav` for a sidebar that collapses to a hamburger below 640 px — where the nav becomes a full page sliding in from the left, handing over to the chosen screen with a matching slide in from the right. Its `items` may be a reactive array; adding or removing one redraws just the sidebar, never the content beside it. An item with `items` of its own becomes a collapsible submenu: only the branch holding the current page stays unfolded, and clicking a branch selects its first leaf (expanding a branch doesn't dismiss the phone's full-page nav — only picking a leaf does). A page the menu holds nowhere leaves every fold as it was. An item can also `match` pages beyond its own `href` — a path prefix, or a `(path) => boolean` — claiming the detail screens that have no row of their own: it is then highlighted, and the branches above it stay unfolded, cold deep links included. A sidebar taller than the window scrolls, and follows the highlighted item: navigating to a page whose item sits past the fold scrolls it back into view. A navigation dismisses the collapsed nav by itself, links in your own custom rows included; `S.closeNav()` does it for the rows that *don't* navigate. Instead of a single `content` slot it can take a `routes` table — see [Panel-stack navigation](#panel-stack-navigation).
|
|
364
|
-
- **`S.box(opts | content)`**: surface with optional `header`/`footer` and padded body. Pass a function for shorthand `{ content }`. `close: fn` adds a ✕ that runs your dismissal — in the header row, or floating over the body when there is no header. (It is plain furniture: a routed screen gets its own way out from the shell, see [Panel-declared chrome](#chrome).)
|
|
365
|
-
- **`S.tabs(opts)`**: tablist with live tab panels and keyboard navigation. More tabs than fit make the strip scroll (see `S.scrollStrip`); selecting a tab any other way (the arrow keys, a `bind` written from elsewhere) scrolls it into view.
|
|
366
|
-
- **`S.scrollStrip(opts)`**: a horizontal row that scrolls once its content outgrows it, with a ‹ / › button appearing over whichever end still has something to reach — so it isn't just a swipe target. Its own scrollbar is hidden. `S.tabs` and the routed shell's breadcrumbs are built on it; reach for it for any row of chrome that can outgrow its space. `S.revealInStrip(el)` scrolls one of its children into view.
|
|
367
|
-
- **`S.form(opts | content)`**: form aligning fields in a column or responsive grid, with an `actions` bar. Prevents the default page reload.
|
|
368
|
-
|
|
369
|
-
### Form fields
|
|
230
|
+
Every option of every component is documented in TSDoc on its `…Options` interface. Options share naming conventions: `attrs` (outermost element), `contentAttrs` (the children-holding element), `inputAttrs` (the form control) and `<region>Attrs` (`headerAttrs`, `footerAttrs`, …) — all Aberdeen attr/style strings, applied last so they can override. Form components consistently support `label`, `help`, `error`, `disabled`, `required` and `name`, and two-way binding through `bind: A.ref($obj, "key")`.
|
|
370
231
|
|
|
371
|
-
-
|
|
372
|
-
-
|
|
373
|
-
-
|
|
374
|
-
-
|
|
375
|
-
- **`S.autocomplete(opts)`**: type-ahead combobox with `multi` (chips), `allowCustom` (free text), `required`, and dynamic `options`.
|
|
232
|
+
- **Layout & containers**: `main` (the app shell: sticky header, optional nav sidebar that collapses to a hamburger on narrow screens, scrollable content area or [panel routes](#panel-stack-navigation), footer; `closeNav` dismisses the collapsed nav), `box`, `form`, `tabs`, `scrollStrip` (+ `revealInStrip`).
|
|
233
|
+
- **Form fields**: `textline`, `textarea`, `checkbox`, `select`, `autocomplete`.
|
|
234
|
+
- **Actions**: `button`, `iconButton`, `buttonGroup`, `buttonChooser`.
|
|
235
|
+
- **Overlays & feedback**: `dialog` (+ `alert`, `confirm`, `prompt`), `menu`, `menuButton`, `showFloatingMenu`, `addContextMenu`, `toast`, `addTooltip`.
|
|
376
236
|
|
|
377
|
-
|
|
237
|
+
`src/index.ts` is the authoritative list of exports.
|
|
378
238
|
|
|
379
|
-
|
|
380
|
-
- **`S.alert(msg)` / `S.confirm(msg)` / `S.prompt(msg, initial?)`**: promise-returning shortcuts.
|
|
381
|
-
|
|
382
|
-
### Actions
|
|
383
|
-
|
|
384
|
-
- **`S.button(opts | text)`**: button surface; restyle via `attrs` (e.g. `.danger`, `.outlined`), plus `size`, `disabled`, `icon`, `href` (renders `<a role=button>`). Defaults to filled `.primary`.
|
|
385
|
-
- **`S.iconButton(opts)`**: a bare glyph in a square hit area — no fill, no border, ink that lifts on hover. For chrome that mustn't compete with what it sits beside: the app shell's ✕ and ☰ are made of it, and it's usually what a page's `actions` want.
|
|
386
|
-
- **`S.buttonGroup(opts)`**: groups buttons, `attached` (segmented) or `spaced`.
|
|
387
|
-
- **`S.buttonChooser(opts)`**: single-select segmented control bound to a value.
|
|
239
|
+
### Keyboard shortcuts
|
|
388
240
|
|
|
241
|
+
Menu items and buttons take a `key` option, and `S.bindKey(key, description, press)` binds a shortcut with no button to carry it, for as long as the calling scope lives. A key is spelled `"mod+k"` (⌘ on a Mac, Ctrl elsewhere), `"shift+f2"`, `"mod+shift+b"`, or a bare `"?"` — case doesn't matter, and no modifiers besides `mod` and `shift` are offered. Component shortcuts are announced to screen readers, and keystrokes a focused field or link owns are left to it. While a modal dialog is open only its own shortcuts fire, and binding a taken combination shadows the earlier binding until the new scope dies; `bindKey`'s docs describe the `"global"` and `"local"` modes that bend these rules. `?` pops an overview of exactly what a keypress could do right now, given where focus is — a cheat-sheet, not a modal: any keypress closes it and still lands, and Esc merely dismisses it; `S.setKeyHelp(false)` turns it off. Omit `press` to merely list a key your app handles by other means.
|
|
389
242
|
|
|
390
243
|
### Icons
|
|
391
244
|
|
|
392
|
-
Staffa ships the full [Lucide icon set](https://lucide.dev/icons/) as named exports
|
|
393
|
-
|
|
394
|
-
Each icon is a draw function usable anywhere a slot is accepted (e.g. a button `icon`), or called directly. Customize per call, or globally via `setDefaults()`:
|
|
245
|
+
Staffa ships the full [Lucide icon set](https://lucide.dev/icons/) as named exports from `staffa/icons`. Import only the ones you use, so a bundler tree-shakes the rest (the whole set is ~82 kB gzipped). Each icon is a draw function usable anywhere a slot is accepted, or called directly:
|
|
395
246
|
|
|
396
247
|
```ts
|
|
397
248
|
import * as S from "staffa";
|
|
@@ -400,21 +251,11 @@ S.button({ content: "Save", icon: bell });
|
|
|
400
251
|
sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
|
|
401
252
|
```
|
|
402
253
|
|
|
403
|
-
Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `join`, `attrs`.
|
|
404
|
-
|
|
405
|
-
### Other
|
|
406
|
-
|
|
407
|
-
- **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation. A menu closes itself when the page navigates.
|
|
408
|
-
- **`S.menu(opts)`**: the same menu rows drawn in place — for a nav or settings column of your own. Items with nested `items` form a collapsible tree; `onLeafSelect` fires only when a leaf is picked, never for a branch unfolding.
|
|
409
|
-
- **`S.closeNav()`**: dismisses `S.main`'s navigation when it's showing as an overlay (the full page on a phone, the dropdown on a wider screen). For custom nav rows that act without navigating.
|
|
410
|
-
- **`S.toast(opts)`**: transient notification at the bottom of the viewport.
|
|
411
|
-
- **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
|
|
412
|
-
|
|
413
|
-
Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form fields.
|
|
254
|
+
Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `join`, `attrs` — per call, or globally via `setDefaults()` from `staffa/icons`.
|
|
414
255
|
|
|
415
256
|
## Browser (no bundler)
|
|
416
257
|
|
|
417
|
-
`staffa/all.js` is a pre-built ESM bundle. Use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap):
|
|
258
|
+
`staffa/all.js` is a pre-built ESM bundle with all components, but not the icons. Use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap):
|
|
418
259
|
|
|
419
260
|
```html
|
|
420
261
|
<script type="importmap">
|
|
@@ -434,40 +275,29 @@ Two-way binding uses Aberdeen proxies: pass `bind: A.ref($obj, "key")` to form f
|
|
|
434
275
|
</script>
|
|
435
276
|
```
|
|
436
277
|
|
|
437
|
-
It includes all components, but not the icons.
|
|
438
|
-
|
|
439
278
|
## Extending Staffa
|
|
440
279
|
|
|
441
|
-
Staffa is designed for extension
|
|
280
|
+
Staffa is designed for extension: a component is simply a plain function taking a typed options object and drawing Aberdeen DOM. These principles are how the built-in ones are written, and how yours should be too.
|
|
442
281
|
|
|
443
282
|
### Design principles
|
|
444
283
|
|
|
445
284
|
1. **Components are functions**. They take one typed options object, emit Aberdeen DOM, and *usually* return nothing.
|
|
446
|
-
|
|
447
285
|
2. **Reuse option types.** Define options by extending `ContentOptions` (for layout components) or `FieldOptions` (for form controls) from `src/core.ts` and `src/components/field.ts`. Don't reinvent fields like `attrs`, `label`, `help`, etc.
|
|
448
|
-
|
|
449
286
|
3. **Reach for reactivity deliberately.** Pass option strings straight to `A` as positional args (the caller's scope). Only wrap a dedicated `A(() => ...)` scope where it matters: input elements (recreation loses focus), or large subtrees you don't want to redraw. Use `A.peek(() => ...)` when you need a value but must not subscribe.
|
|
450
|
-
|
|
451
|
-
4. **Build on surfaces.** Mark elements `.s-s` and add `.neutral` or an accent role (`.primary`, `.danger`, …) plus an optional variant. Inside them, use the contextual CSS variables (`$s-text`, `$s-bg`, `$s-muted`, `$s-accent`, `$s-faint`, ...) so components adapt to wherever they're nested. Hard-coding colors in components shouldn't be needed, but if you must, make sure you set *both* foreground and background.
|
|
452
|
-
|
|
287
|
+
4. **Build on surfaces.** Mark elements `.s-s` and add `.neutral` or an accent role (`.primary`, `.danger`, …) plus an optional variant. Inside them, use the contextual CSS variables (`$s-text`, `$s-bg`, `$s-muted`, `$s-accent`, `$s-faint`, ...) so components adapt to wherever they're nested. Hard-coding colours shouldn't be needed, but if you must, set *both* foreground and background.
|
|
453
288
|
5. **No outer margins.** Components don't margin themselves; spacing is the parent's job. Content components set default `padding` on the content element; `contentAttrs` overrides it.
|
|
454
|
-
|
|
455
|
-
6. **Make everything styleable.** Provide `attrs`, `contentAttrs`, `inputAttrs`, and `<region>Attrs` hooks so callers can customize. Apply `attrs` last so it can override component classes.
|
|
456
|
-
|
|
289
|
+
6. **Make everything styleable.** Provide `attrs`, `contentAttrs`, `inputAttrs` and `<region>Attrs` hooks so callers can customize. Apply `attrs` last so it can override component classes.
|
|
457
290
|
7. **Use semantic HTML and ARIA.** Prefer native elements (`<button>`, `<label>`, `<form>`, `<section>`) and native behaviour. Add ARIA only where semantics fall short (e.g. tabs, combobox).
|
|
458
|
-
|
|
459
|
-
8. **Use CSS.** Use `A.insertGlobalCss({...})` at module top level to provide (nested) CSS styling for your component. Give your top-level element the `s-<component-name>` class. Avoid inventing further classes; lean on nesting (`&` for the element, bare key for descendants) and element/structural selectors.
|
|
460
|
-
|
|
291
|
+
8. **Use CSS.** Use `A.insertGlobalCss({...})` at module top level to provide (nested) CSS styling for your component. Give your top-level element the `s-<component-name>` class. Avoid inventing further classes; lean on nesting (`&` for the element, bare key for descendants) and element/structural selectors.
|
|
461
292
|
9. **Reuse form controls.** Use `drawField()` and call `applyControlAttrs()`.
|
|
462
|
-
|
|
463
293
|
10. **Function over form.** Provide enough contrast. Stick to UI conventions to help users; buttons have a rounded border, links are underlined, text input background is white, etc.
|
|
464
294
|
|
|
465
295
|
### Adding a component to Staffa
|
|
466
296
|
|
|
467
|
-
The
|
|
297
|
+
The principles above are good advice for any project-specific component, but must be followed for one to be included in Staffa. In addition:
|
|
468
298
|
|
|
469
299
|
1. Create `src/components/<name>.ts`.
|
|
470
|
-
2. Define `<Name>Options` extending `ContentOptions`, `FieldOptions`, or a plain interface. Add TSDoc on every option.
|
|
300
|
+
2. Define `<Name>Options` extending `ContentOptions`, `FieldOptions`, or a plain interface. Add TSDoc on every option — that TSDoc *is* the API documentation.
|
|
471
301
|
3. Add a TSDoc `@example` on the function.
|
|
472
302
|
4. Register in `src/index.ts` (the `S` object + type re-export).
|
|
473
303
|
5. Add it to the demo, cover it in the visual tests (`tests/*.spec.ts`), and run `npm run build` and `npm run typecheck`.
|
|
@@ -489,9 +319,7 @@ The visual tests (`tests/*.spec.ts`) need a build first (`npm run build`); they
|
|
|
489
319
|
|
|
490
320
|
## AI skill
|
|
491
321
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
To use this, it is recommended to symlink the skill into your project's `.claude/skills` directory:
|
|
322
|
+
For Claude Code, GitHub Copilot or any other agent that supports Skills, Staffa ships a `skill/` directory holding this README plus the generated API reference. Symlink it into your project:
|
|
495
323
|
|
|
496
324
|
```sh
|
|
497
325
|
mkdir -p .claude/skills
|
|
@@ -502,7 +330,7 @@ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
|
|
|
502
330
|
|
|
503
331
|
What changed in each release, and what to do about the breaking ones, is in [CHANGELOG.md](CHANGELOG.md).
|
|
504
332
|
|
|
505
|
-
*Hint:* the recommended update strategy for a library this young is: don't. Pin it, and read the changelog before you move.
|
|
333
|
+
*Hint:* the recommended update strategy for a library this young is: don't. Pin it, and read the changelog before you move. This goes double if you've overridden Staffa's CSS.
|
|
506
334
|
|
|
507
335
|
# API Reference
|
|
508
336
|
|
|
@@ -522,6 +350,38 @@ Force dark mode (`true`), light mode (`false`), or follow the OS preference
|
|
|
522
350
|
Whether dark mode is currently active. Reactive — read it inside a scope to
|
|
523
351
|
re-run on changes.
|
|
524
352
|
|
|
353
|
+
## [formatKey](formatKey.md) · function
|
|
354
|
+
|
|
355
|
+
Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
|
|
356
|
+
`"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
|
|
357
|
+
use it for the same hint elsewhere in your app, so both spell the shortcut the
|
|
358
|
+
way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
|
|
359
|
+
spelling instead: full modifier names and real key names,
|
|
360
|
+
`"Meta+Shift+K"`/`"Control+Shift+K"`.
|
|
361
|
+
|
|
362
|
+
## [bindKey](bindKey.md) · function
|
|
363
|
+
|
|
364
|
+
Bind a keyboard shortcut, for as long as the calling scope lives.
|
|
365
|
+
|
|
366
|
+
## [showKeyHelp](showKeyHelp.md) · function
|
|
367
|
+
|
|
368
|
+
The shortcut overview: a dialog listing what a keypress could do *right now*
|
|
369
|
+
— every described binding the keyboard focus and any open modal leave in
|
|
370
|
+
effect: buttons under their label, menu items under theirs, `bindKey`
|
|
371
|
+
bindings under the description they were given.
|
|
372
|
+
|
|
373
|
+
## setKeyHelp · function
|
|
374
|
+
|
|
375
|
+
Turn the default overview shortcuts off (or back on): `?` — free, like any
|
|
376
|
+
unmodified key, whenever nothing is being typed into — and `mod+?`, which
|
|
377
|
+
reaches the overview even from inside a text field. On by default.
|
|
378
|
+
|
|
379
|
+
**Signature:** `(enabled: boolean) => void`
|
|
380
|
+
|
|
381
|
+
**Parameters:**
|
|
382
|
+
|
|
383
|
+
- `enabled: boolean`
|
|
384
|
+
|
|
525
385
|
## [autocomplete](autocomplete.md) · function
|
|
526
386
|
|
|
527
387
|
A combobox with type-ahead filtering. Supports single or multi-select (chips),
|
|
@@ -555,10 +415,9 @@ their solid background for affordance.
|
|
|
555
415
|
## [iconButton](iconButton.md) · function
|
|
556
416
|
|
|
557
417
|
A bare glyph in a square hit area — no fill, no border, just ink that lifts on
|
|
558
|
-
hover.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
| page's actions.
|
|
418
|
+
hover. For chrome that has to sit beside something more important without
|
|
419
|
+
competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
|
|
420
|
+
the verbs in a | page's actions.
|
|
562
421
|
|
|
563
422
|
## [ButtonOptions](ButtonOptions.md) · interface
|
|
564
423
|
|
|
@@ -598,9 +457,11 @@ Options for `checkbox`.
|
|
|
598
457
|
|
|
599
458
|
## [form](form.md) · function
|
|
600
459
|
|
|
601
|
-
An opinionated `<form>` wrapper
|
|
602
|
-
|
|
603
|
-
|
|
460
|
+
An opinionated `<form>` wrapper: fields in a single column by default, or a
|
|
461
|
+
responsive grid, plus a standard action bar. Field components
|
|
462
|
+
(("./textline").textline et al.) drop straight in as
|
|
463
|
+
`ContentOptions.content`; the browser's native validation runs on submit,
|
|
464
|
+
but the page never reloads.
|
|
604
465
|
|
|
605
466
|
## [FormOptions](FormOptions.md) · interface
|
|
606
467
|
|
|
@@ -717,8 +578,8 @@ Enter/Space activate the focused item natively.
|
|
|
717
578
|
|
|
718
579
|
Open a floating dropdown menu anchored to an element. Portals to
|
|
719
580
|
`document.body` (never clipped), positions itself (flipping up when there's
|
|
720
|
-
no room below), and closes on Escape, Tab, item selection,
|
|
721
|
-
outside the panel and anchor. Returns a `close()` function.
|
|
581
|
+
no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
582
|
+
any click outside the panel and anchor. Returns a `close()` function.
|
|
722
583
|
|
|
723
584
|
## [addContextMenu](addContextMenu.md) · function
|
|
724
585
|
|
|
@@ -842,10 +703,9 @@ for the selected tab. Supports keyboard navigation (left/right/home/end).
|
|
|
842
703
|
## [scrollStrip](scrollStrip.md) · function
|
|
843
704
|
|
|
844
705
|
A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
|
|
845
|
-
button appearing over whichever end still has something left to reach —
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
the buttons scroll it by most of a width at a time.
|
|
706
|
+
button appearing over whichever end still has something left to reach — so it
|
|
707
|
+
isn't just a swipe target. The row's own scrollbar is hidden, and the buttons
|
|
708
|
+
scroll it by most of a width at a time.
|
|
849
709
|
|
|
850
710
|
## revealInStrip · function
|
|
851
711
|
|
|
@@ -882,8 +742,9 @@ Options for `textarea`.
|
|
|
882
742
|
|
|
883
743
|
## [textline](textline.md) · function
|
|
884
744
|
|
|
885
|
-
A single-line text input —
|
|
886
|
-
|
|
745
|
+
A single-line text input — text, passwords, numbers, email, dates and the other
|
|
746
|
+
line-oriented `<input>` types. Renders inside the standard `drawField`
|
|
747
|
+
chrome (label, control, help/error), so it aligns cleanly inside a `form`.
|
|
887
748
|
|
|
888
749
|
## [TextlineOptions](TextlineOptions.md) · interface
|
|
889
750
|
|
|
@@ -906,11 +767,10 @@ Options for `toast`.
|
|
|
906
767
|
|
|
907
768
|
## [addTooltip](addTooltip.md) · function
|
|
908
769
|
|
|
909
|
-
Attaches a tooltip to the current element
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
element's bounding rect and automatically flips when near the viewport edge.
|
|
770
|
+
Attaches a tooltip to the current element, shown on hover or keyboard focus.
|
|
771
|
+
The tip panel is portalled into `document.body`, so `overflow:hidden` ancestors
|
|
772
|
+
never clip it; it is placed from the element's bounding rect, flipping to the
|
|
773
|
+
opposite side when near the viewport edge.
|
|
914
774
|
|
|
915
775
|
## [TooltipOptions](TooltipOptions.md) · interface
|
|
916
776
|
|
|
@@ -919,7 +779,9 @@ Options for `addTooltip`.
|
|
|
919
779
|
## [FieldOptions](FieldOptions.md) · interface
|
|
920
780
|
|
|
921
781
|
Options shared by all *form field* components (textline, textarea, checkbox,
|
|
922
|
-
autocomplete, ...).
|
|
782
|
+
autocomplete, ...). Every field lays out the same way — optional label,
|
|
783
|
+
control, optional help/error below — which is what lets `form` align
|
|
784
|
+
groups of them.
|
|
923
785
|
|
|
924
786
|
## [ContentOptions](ContentOptions.md) · interface
|
|
925
787
|
|
|
@@ -937,7 +799,12 @@ A reactive "value box", such as the result of `A.proxy(x)` or `A.ref(obj, key)`.
|
|
|
937
799
|
Something that renders a small piece of content: either a plain string or a
|
|
938
800
|
draw function (for icons, badges, custom markup, ...).
|
|
939
801
|
|
|
940
|
-
##
|
|
802
|
+
## Attributes · type
|
|
803
|
+
|
|
804
|
+
Shared building blocks for the Staffa component library: the option-type
|
|
805
|
+
hierarchy every component builds on, plus a couple of tiny helpers. A
|
|
806
|
+
component is just an Aberdeen draw function — one typed options object in,
|
|
807
|
+
DOM out through `A`.
|
|
941
808
|
|
|
942
|
-
|
|
809
|
+
**Type:** `string`
|
|
943
810
|
|