staffa 0.6.0 → 0.7.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 +36 -16
- package/dist/components/autocomplete.js +12 -9
- package/dist/components/box.js +15 -10
- package/dist/components/button.d.ts +3 -1
- package/dist/components/button.js +21 -28
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +3 -3
- package/dist/components/dialog.d.ts +3 -1
- package/dist/components/dialog.js +20 -10
- package/dist/components/field.js +7 -4
- package/dist/components/main.js +52 -15
- package/dist/components/menu.d.ts +23 -2
- package/dist/components/menu.js +54 -29
- package/dist/components/select.js +1 -1
- package/dist/components/tabs.js +3 -3
- package/dist/components/toast.js +8 -8
- package/dist/components/tooltip.js +4 -3
- package/dist/core.d.ts +14 -2
- package/dist/core.js +23 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +138 -224
- package/package.json +3 -2
- package/skill/Attributes.md +10 -0
- package/skill/AutocompleteOptions.md +37 -0
- package/skill/BoxOptions.md +33 -0
- package/skill/ButtonChooserOptions.md +37 -0
- package/skill/ButtonGroupOptions.md +23 -0
- package/skill/ButtonOptions.md +58 -0
- package/skill/CheckboxOptions.md +27 -0
- package/skill/ContentOptions.md +16 -0
- package/skill/DialogOptions.md +70 -0
- package/skill/FieldOptions.md +63 -0
- package/skill/FloatingMenuOptions.md +21 -0
- package/skill/FormOptions.md +31 -0
- package/skill/MainOptions.md +91 -0
- package/skill/MenuItem.md +52 -0
- package/skill/MenuOptions.md +25 -0
- package/skill/SKILL.md +609 -0
- package/skill/SelectOptions.md +21 -0
- package/skill/Slot.md +13 -0
- package/skill/Tab.md +33 -0
- package/skill/TabsOptions.md +28 -0
- package/skill/TextareaOptions.md +51 -0
- package/skill/TextlineOptions.md +45 -0
- package/skill/TextlineType.md +18 -0
- package/skill/ToastOptions.md +40 -0
- package/skill/TooltipOptions.md +22 -0
- package/skill/addContextMenu.md +28 -0
- package/skill/addTooltip.md +29 -0
- package/skill/alert.md +17 -0
- package/skill/autocomplete.md +29 -0
- package/skill/box.md +26 -0
- package/skill/button.md +31 -0
- package/skill/buttonChooser.md +23 -0
- package/skill/buttonGroup.md +22 -0
- package/skill/checkbox.md +18 -0
- package/skill/confirm.md +17 -0
- package/skill/dialog.md +28 -0
- package/skill/form.md +29 -0
- package/skill/getDarkMode.md +12 -0
- package/skill/main.md +37 -0
- package/skill/menuButton.md +27 -0
- package/skill/prompt.md +19 -0
- package/skill/select.md +18 -0
- package/skill/showFloatingMenu.md +21 -0
- package/skill/tabs.md +19 -0
- package/skill/textarea.md +17 -0
- package/skill/textline.md +20 -0
- package/skill/toast.md +19 -0
- package/src/components/autocomplete.ts +12 -9
- package/src/components/box.ts +15 -10
- package/src/components/button.ts +23 -32
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/buttonGroup.ts +3 -3
- package/src/components/dialog.ts +22 -11
- package/src/components/field.ts +7 -4
- package/src/components/main.ts +47 -16
- package/src/components/menu.ts +73 -40
- package/src/components/select.ts +1 -1
- package/src/components/tabs.ts +3 -3
- package/src/components/toast.ts +8 -8
- package/src/components/tooltip.ts +4 -3
- package/src/core.ts +30 -2
- package/src/theme.ts +152 -234
package/README.md
CHANGED
|
@@ -13,7 +13,10 @@ S.main({
|
|
|
13
13
|
maxWidth: "40rem",
|
|
14
14
|
content: () => {
|
|
15
15
|
S.form({
|
|
16
|
-
submit: () =>
|
|
16
|
+
submit: () => S.dialog({
|
|
17
|
+
header: "Submitted",
|
|
18
|
+
content: () => A.dump($user)
|
|
19
|
+
}),
|
|
17
20
|
content: () => {
|
|
18
21
|
S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
|
|
19
22
|
S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
|
|
@@ -72,20 +75,29 @@ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
|
|
|
72
75
|
|
|
73
76
|
### Surfaces
|
|
74
77
|
|
|
75
|
-
Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens.
|
|
78
|
+
Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. There are two families:
|
|
76
79
|
|
|
77
|
-
- **
|
|
78
|
-
- **
|
|
79
|
-
- **variant**: `.filled`, `.tonal`, `.outlined`
|
|
80
|
+
- **Neutral surfaces** — `.neutral` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (page → panel → raised, capped). Use them for cards, bars, popovers — anything that just holds content. No variants.
|
|
81
|
+
- **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.
|
|
80
82
|
|
|
81
|
-
Components are built from these (`S.button` is a `.s-s.primary
|
|
83
|
+
Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
|
|
82
84
|
|
|
83
85
|
```ts
|
|
84
86
|
S.button({ content: "Delete", attrs: ".danger" });
|
|
85
|
-
S.
|
|
87
|
+
S.button({ content: "Cancel", attrs: ".neutral" }); // neutral button
|
|
88
|
+
S.box({ attrs: ".primary", content: () => { ... } });
|
|
86
89
|
```
|
|
87
90
|
|
|
88
|
-
Inside any surface, CSS variables are defined for
|
|
91
|
+
Inside any surface (including `:root`), CSS variables are defined for the background and a set of safe foreground colors: `$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 surfaces), and `$s-faint`. By using these, components adapt to wherever they're nested.
|
|
92
|
+
|
|
93
|
+
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`, and `$s-link` (the link colour, which is also the fill of the `.s-s.link` surface). Links render in `$s-link` on neutral surfaces and in the ink on accent surfaces.
|
|
94
|
+
|
|
95
|
+
**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`: on a neutral surface that's a neutral drop shadow, on an accent surface it's a self-coloured glow (a lit button is just a `.primary` surface with `.shadow`), and on `.tonal`/`.outlined` it's ignored. `.no-shadow` removes a component's built-in shadow:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
S.box({ attrs: ".extra-shadow", content: () => { ... } }); // a more raised card
|
|
99
|
+
S.button({ content: "Quiet", attrs: ".no-shadow" }); // drop the button glow
|
|
100
|
+
```
|
|
89
101
|
|
|
90
102
|
### Dark and light modes
|
|
91
103
|
|
|
@@ -105,22 +117,20 @@ Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit
|
|
|
105
117
|
|
|
106
118
|
### Theming
|
|
107
119
|
|
|
108
|
-
The first step in theming is just setting some CSS variables
|
|
120
|
+
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:
|
|
109
121
|
|
|
110
122
|
```ts
|
|
111
123
|
A.cssVars["s-primary"] = "#fdda58";
|
|
112
|
-
A.cssVars["s-secondary"] = "#cc5624";
|
|
113
124
|
A.cssVars["s-danger"] = "#ee4422";
|
|
114
125
|
A.cssVars["s-radius"] = "4px";
|
|
115
126
|
```
|
|
116
127
|
|
|
117
128
|
See `src/theme.ts` for what other CSS variables are being used.
|
|
118
129
|
|
|
119
|
-
If you need further customization, just add some CSS to override the default styling. For instance, to add your own surface
|
|
130
|
+
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:
|
|
120
131
|
|
|
121
132
|
```ts
|
|
122
|
-
|
|
123
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
|
|
133
|
+
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
|
|
124
134
|
|
|
125
135
|
S.button({
|
|
126
136
|
content: "You'll want to click me",
|
|
@@ -129,6 +139,8 @@ S.button({
|
|
|
129
139
|
});
|
|
130
140
|
```
|
|
131
141
|
|
|
142
|
+
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.
|
|
143
|
+
|
|
132
144
|
Note that when changing CSS like this, things *may* break if you upgrade Staffa. The recommended update strategy is therefore: don't!
|
|
133
145
|
|
|
134
146
|
If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
|
|
@@ -137,10 +149,10 @@ If you want to make changes that are dependent upon the current light/dark mode
|
|
|
137
149
|
A(() => {
|
|
138
150
|
if (S.getDarkMode()) {
|
|
139
151
|
A.cssVars["s-primary"] = "#aa9944";
|
|
140
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-
|
|
152
|
+
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#444444 --s-text:#fff"});
|
|
141
153
|
} else {
|
|
142
154
|
A.cssVars["s-primary"] = "#fdda58";
|
|
143
|
-
A.insertGlobalCss({".s-s.my-surface": "--s-
|
|
155
|
+
A.insertGlobalCss({".s-s.my-surface": "--s-bg:#cccccc --s-text:#000"});
|
|
144
156
|
}
|
|
145
157
|
});
|
|
146
158
|
```
|
|
@@ -233,7 +245,7 @@ Staffa is designed for extension. A component is simply a plain function taking
|
|
|
233
245
|
|
|
234
246
|
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.
|
|
235
247
|
|
|
236
|
-
4. **Build on surfaces.** Mark elements `.s-s` and add
|
|
248
|
+
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.
|
|
237
249
|
|
|
238
250
|
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.
|
|
239
251
|
|
|
@@ -285,6 +297,14 @@ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
|
|
|
285
297
|
|
|
286
298
|
## Breaking changes
|
|
287
299
|
|
|
300
|
+
- **0.7** — the surface model was reduced to two families: **neutral** (`.neutral`) and **accent** (`.primary`/`.danger`/`.success`/`.warning`/`.link`). Apps that only use the high-level `S.*` components need no changes. Code that uses surface classes or tokens directly must update:
|
|
301
|
+
- **Surface levels gone.** Replace `.base`/`.panel`/`.raised`/`.neutral`/`.nest` with the single `.neutral` class.
|
|
302
|
+
- **`.secondary` and `.gradient` gone.** Drop any `s-secondary` colour override; there's no `s-secondary` anymore. The default button is now `.primary`.
|
|
303
|
+
- **Tokens renamed.** `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`. Removed: `--s-fg-faint`, `--s-border-strong`, `--s-ink`, `--s-on-accent`, `--s-page`/`--s-panel`/`--s-raised`, `--s-neutral`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`. A custom surface now sets `--s-bg`/`--s-text` (was the `--s-a`/`--s-b` anchors).
|
|
304
|
+
- **Borders/shadows moved onto surfaces.** Components no longer draw their own border/shadow. If you relied on `S.box`/`S.dialog`/etc. elevation, it now comes from the surface; pass `.no-shadow` to drop it, or `.shadow`/`.extra-shadow` to add it on any surface.
|
|
305
|
+
|
|
306
|
+
- **0.6**: None.
|
|
307
|
+
|
|
288
308
|
- **0.5**
|
|
289
309
|
- Surfaces (`.s-s`) now apply `border-radius` and — for `.tonal` and `.outlined` variants — `border` automatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations.
|
|
290
310
|
- `border:0` is now applied to `.s-btn` by default (overriding the browser's 2px button border). Custom button-like components built on `.s-btn` that relied on the browser default border should add an explicit border.
|
|
@@ -4,20 +4,23 @@ import { drawField } from "./field.js";
|
|
|
4
4
|
A.insertGlobalCss({
|
|
5
5
|
".s-ac": {
|
|
6
6
|
"&": "position:relative",
|
|
7
|
-
|
|
8
|
-
"> .s-control:
|
|
7
|
+
// Same light inset field as `.s-input` (see field.ts), derived from the surface.
|
|
8
|
+
"> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
9
|
+
"> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
9
10
|
"> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
|
|
10
11
|
"&[aria-invalid=true] > .s-control": "border-color:$s-danger",
|
|
11
|
-
".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg
|
|
12
|
-
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-
|
|
13
|
-
".s-chip > button:hover": "fg:$s-
|
|
12
|
+
".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em background: color-mix(in oklab, $s-bg, $s-text 10%); border: 1px solid $s-faint; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
|
|
13
|
+
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
14
|
+
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
14
15
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
15
|
-
|
|
16
|
+
// The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
|
|
17
|
+
// radius and elevation all come from the surface.
|
|
18
|
+
"> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
|
|
16
19
|
"> .s-menu li": "margin:0",
|
|
17
20
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
18
|
-
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-
|
|
21
|
+
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
19
22
|
".s-add": "fg:$s-accent font-style:italic",
|
|
20
|
-
".s-empty": "padding: 0.45em 0.6em; fg:$s-
|
|
23
|
+
".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
|
|
21
24
|
},
|
|
22
25
|
});
|
|
23
26
|
function normOption(o) {
|
|
@@ -160,7 +163,7 @@ export function autocomplete(opts) {
|
|
|
160
163
|
const list = filtered();
|
|
161
164
|
const q = $st.query.trim();
|
|
162
165
|
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
163
|
-
A("ul.s-menu role=listbox", `id=${menuId}`, () => {
|
|
166
|
+
A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
|
|
164
167
|
list.forEach((option, i) => {
|
|
165
168
|
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
166
169
|
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
package/dist/components/box.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { drawSlot } from "../core.js";
|
|
3
|
-
// The box itself is a `.
|
|
4
|
-
//
|
|
5
|
-
// come from the contextual tokens, so a box
|
|
6
|
-
// it's nested in.
|
|
3
|
+
// The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
|
|
4
|
+
// too — nested one level deeper, so they pick up the next elevation shade
|
|
5
|
+
// automatically. Colours and borders come from the contextual tokens, so a box
|
|
6
|
+
// stays legible on whatever surface it's nested in.
|
|
7
|
+
// The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
|
|
8
|
+
// the surface itself (see theme.ts), not from here. `.s-box` only does layout and
|
|
9
|
+
// the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
|
|
10
|
+
// deeper, for the raised shade), so we cancel their full surface border down to a
|
|
11
|
+
// single divider.
|
|
7
12
|
A.insertGlobalCss({
|
|
8
13
|
".s-box": {
|
|
9
|
-
"&": "display:flex flex-direction:column
|
|
14
|
+
"&": "display:flex flex-direction:column overflow:hidden r: $s-radius-lg;",
|
|
10
15
|
"&:not(:first-child)": "margin-top: $3",
|
|
11
|
-
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-
|
|
12
|
-
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-
|
|
16
|
+
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
|
|
17
|
+
"> 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",
|
|
13
18
|
"> div": "p:$3 gap:$3",
|
|
14
19
|
},
|
|
15
20
|
});
|
|
@@ -34,19 +39,19 @@ A.insertGlobalCss({
|
|
|
34
39
|
*/
|
|
35
40
|
export function box(opts = {}) {
|
|
36
41
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
37
|
-
A("section.s-box.s-s.
|
|
42
|
+
A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
|
|
38
43
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
39
44
|
// the body (which may hold focused inputs / lots of content).
|
|
40
45
|
A(() => {
|
|
41
46
|
if (o.header != null)
|
|
42
|
-
A("header.s-s.
|
|
47
|
+
A("header.s-s.neutral", o.headerAttrs, () => drawSlot(o.header));
|
|
43
48
|
});
|
|
44
49
|
A("div", o.contentAttrs, () => {
|
|
45
50
|
drawSlot(o.content);
|
|
46
51
|
});
|
|
47
52
|
A(() => {
|
|
48
53
|
if (o.footer != null)
|
|
49
|
-
A("footer.s-s.
|
|
54
|
+
A("footer.s-s.neutral", o.footerAttrs, () => drawSlot(o.footer));
|
|
50
55
|
});
|
|
51
56
|
});
|
|
52
57
|
}
|
|
@@ -18,7 +18,8 @@ export interface ButtonOptions {
|
|
|
18
18
|
/**
|
|
19
19
|
* Aberdeen attr/style string applied to the button. A button is a surface, so
|
|
20
20
|
* pass surface modifier classes here to restyle it, e.g. `".danger"`,
|
|
21
|
-
* `".
|
|
21
|
+
* `".danger .outlined"`, or `".neutral"` for a neutral button. Defaults to a
|
|
22
|
+
* filled `.primary` surface.
|
|
22
23
|
*
|
|
23
24
|
* Size is set here too, with `.small` or `.large` (medium is the default and
|
|
24
25
|
* needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
|
|
@@ -44,6 +45,7 @@ export interface ButtonOptions {
|
|
|
44
45
|
* @example
|
|
45
46
|
* ```ts
|
|
46
47
|
* S.button({ content: "Save", click: S.alert("Saved.") });
|
|
48
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
47
49
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
48
50
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
49
51
|
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
@@ -6,35 +6,28 @@ import { drawSlot } from "../core.js";
|
|
|
6
6
|
A.insertGlobalCss({
|
|
7
7
|
".s-btn": {
|
|
8
8
|
"&": "display:inline-flex align-items:center justify-content:center gap:$2 " +
|
|
9
|
-
"font-weight:
|
|
10
|
-
"
|
|
9
|
+
"font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
|
|
10
|
+
"padding: $m2 $m3; " +
|
|
11
11
|
"transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// artifact where a gradient clipped to a transparent rounded border fringes
|
|
24
|
-
// the edge with the gradient's far colour.
|
|
25
|
-
"&.gradient:not(.tonal):not(.outlined)": "box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
|
|
26
|
-
"&.gradient:not(.tonal):not(.outlined):hover": "filter: brightness(1.05); box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), 0 7px 18px color-mix(in srgb, $s-primary 34%, transparent);",
|
|
12
|
+
// Focus ring via `outline` (not box-shadow) so it survives a `.no-shadow`
|
|
13
|
+
// (which hard-clears box-shadow). Modern browsers round it to the border-radius.
|
|
14
|
+
"&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
|
|
15
|
+
// The button carries `.shadow` (added in button() below); on a filled accent
|
|
16
|
+
// surface that resolves to the signature self-coloured glow, on a neutral
|
|
17
|
+
// `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
|
|
18
|
+
"&:hover": "filter: brightness(1.06)",
|
|
19
|
+
// Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
|
|
20
|
+
// button (which is already near-white) darkens toward its ink instead.
|
|
21
|
+
"&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
|
|
22
|
+
"&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
|
|
27
23
|
// Subtle press feedback.
|
|
28
24
|
"&:active:not(:disabled)": "transform: translateY(1px)",
|
|
29
25
|
// Size: set on the button itself, or inherited from a `.small`/`.large`
|
|
30
26
|
// parent (e.g. a buttonGroup), so a container can size all its buttons at once.
|
|
31
|
-
"&.small, .small > &": "padding:
|
|
32
|
-
"&.large, .large > &": "
|
|
27
|
+
"&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
|
|
28
|
+
"&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
|
|
33
29
|
},
|
|
34
30
|
});
|
|
35
|
-
// Surface-role classes a caller may pass in `attrs`. When one is present we skip
|
|
36
|
-
// the default `.gradient` base so the two roles don't stack on one element.
|
|
37
|
-
const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
|
|
38
31
|
/**
|
|
39
32
|
* A button. Tonal and outlined variants show a border; filled variants rely on
|
|
40
33
|
* their solid background for affordance.
|
|
@@ -53,6 +46,7 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
|
|
|
53
46
|
* @example
|
|
54
47
|
* ```ts
|
|
55
48
|
* S.button({ content: "Save", click: S.alert("Saved.") });
|
|
49
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
|
|
56
50
|
* S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
|
|
57
51
|
* S.button("Cancel"); // shorthand for { content: "Cancel" }
|
|
58
52
|
* S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
|
|
@@ -61,12 +55,11 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
|
|
|
61
55
|
export function button(opts = {}) {
|
|
62
56
|
const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
|
|
63
57
|
const tag = o.href != null ? "a" : "button";
|
|
64
|
-
// A
|
|
65
|
-
//
|
|
66
|
-
// `.neutral
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
|
|
58
|
+
// A bare `.s-s` is a filled accent surface defaulting to `.primary` (see
|
|
59
|
+
// theme.ts) — the signature CTA. The caller's `attrs` simply names another
|
|
60
|
+
// role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
|
|
61
|
+
// role detection needed, since the default lives in CSS, not here.
|
|
62
|
+
A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
|
|
70
63
|
if (o.href != null) {
|
|
71
64
|
A(`href=${o.href} role=button`);
|
|
72
65
|
if (o.disabled)
|
|
@@ -26,7 +26,7 @@ export function buttonChooser(opts) {
|
|
|
26
26
|
// Icon-only options (draw-function labels) get the id as their
|
|
27
27
|
// accessible name; plain-text labels speak for themselves.
|
|
28
28
|
ariaLabel: typeof label === "function" ? id : undefined,
|
|
29
|
-
attrs: selected === id ? ".primary" : ".neutral
|
|
29
|
+
attrs: selected === id ? ".primary" : ".neutral",
|
|
30
30
|
click: () => {
|
|
31
31
|
opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
|
|
32
32
|
},
|
|
@@ -24,9 +24,9 @@ export interface ButtonGroupOptions extends ContentOptions {
|
|
|
24
24
|
* @example
|
|
25
25
|
* ```ts
|
|
26
26
|
* S.buttonGroup({ buttons: [
|
|
27
|
-
* { content: "Day", attrs: ".neutral
|
|
28
|
-
* { content: "Week", attrs: ".neutral
|
|
29
|
-
* { content: "Month", attrs: ".neutral
|
|
27
|
+
* { content: "Day", attrs: ".neutral" },
|
|
28
|
+
* { content: "Week", attrs: ".neutral" },
|
|
29
|
+
* { content: "Month", attrs: ".neutral" },
|
|
30
30
|
* ]});
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
@@ -27,9 +27,9 @@ A.insertGlobalCss({
|
|
|
27
27
|
* @example
|
|
28
28
|
* ```ts
|
|
29
29
|
* S.buttonGroup({ buttons: [
|
|
30
|
-
* { content: "Day", attrs: ".neutral
|
|
31
|
-
* { content: "Week", attrs: ".neutral
|
|
32
|
-
* { content: "Month", attrs: ".neutral
|
|
30
|
+
* { content: "Day", attrs: ".neutral" },
|
|
31
|
+
* { content: "Week", attrs: ".neutral" },
|
|
32
|
+
* { content: "Month", attrs: ".neutral" },
|
|
33
33
|
* ]});
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
@@ -37,6 +37,8 @@ export interface DialogOptions {
|
|
|
37
37
|
*/
|
|
38
38
|
onClose?: () => void;
|
|
39
39
|
}
|
|
40
|
+
/** Whether any dialog is currently open (live state, not the lingering DOM). */
|
|
41
|
+
export declare function isDialogOpen(): boolean;
|
|
40
42
|
/**
|
|
41
43
|
* A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
|
|
42
44
|
* that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
@@ -53,7 +55,7 @@ export interface DialogOptions {
|
|
|
53
55
|
* content: (close) => {
|
|
54
56
|
* A("p #Are you sure?");
|
|
55
57
|
* S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
|
|
56
|
-
* S.button({ content: "Cancel", attrs: ".neutral
|
|
58
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: close });
|
|
57
59
|
* },
|
|
58
60
|
* });
|
|
59
61
|
* ```
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { drawSlot, mountPortal } from "../core.js";
|
|
2
|
+
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
3
|
import { button } from "./button.js";
|
|
4
4
|
import { buttonGroup } from "./buttonGroup.js";
|
|
5
5
|
import { textline } from "./textline.js";
|
|
@@ -8,17 +8,19 @@ A.insertGlobalCss({
|
|
|
8
8
|
"&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
|
|
9
9
|
"&.hidden": "opacity:0 pointer-events:none",
|
|
10
10
|
},
|
|
11
|
+
// The dialog panel is a `.s-s.neutral.extra-shadow` surface: border, radius (lg)
|
|
12
|
+
// and the deep floating shadow come from the surface itself (see theme.ts).
|
|
11
13
|
".s-dialog": {
|
|
12
14
|
"&": "position:fixed z-index:200 top:50% left:50% " +
|
|
13
15
|
"display:flex flex-direction:column " +
|
|
14
16
|
"transform:translate(-50%,-50%) " +
|
|
15
17
|
"min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
|
|
16
|
-
"
|
|
18
|
+
"r: $s-radius-lg; overflow:hidden " +
|
|
17
19
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
18
20
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
19
|
-
"border-bottom: 1px solid $s-
|
|
21
|
+
"border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600 flex-shrink:0",
|
|
20
22
|
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
|
|
21
|
-
"border-top: 1px solid $s-
|
|
23
|
+
"border:0 border-top: 1px solid $s-faint; r:0 flex-shrink:0",
|
|
22
24
|
"> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
|
|
23
25
|
"&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
|
|
24
26
|
},
|
|
@@ -30,6 +32,10 @@ const topDialogId = A.derive(() => {
|
|
|
30
32
|
if (keys.length)
|
|
31
33
|
return keys[keys.length - 1];
|
|
32
34
|
});
|
|
35
|
+
/** Whether any dialog is currently open (live state, not the lingering DOM). */
|
|
36
|
+
export function isDialogOpen() {
|
|
37
|
+
return topDialogId.value != null;
|
|
38
|
+
}
|
|
33
39
|
mountPortal(() => {
|
|
34
40
|
A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
|
|
35
41
|
const close = () => { delete dialogs[dialogId]; };
|
|
@@ -46,10 +52,10 @@ mountPortal(() => {
|
|
|
46
52
|
close();
|
|
47
53
|
});
|
|
48
54
|
// Dialog itself
|
|
49
|
-
A("div.s-dialog.s-s.
|
|
55
|
+
const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
|
|
50
56
|
A(() => {
|
|
51
57
|
if (opts.header != null) {
|
|
52
|
-
A("header.s-s.
|
|
58
|
+
A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
|
|
53
59
|
}
|
|
54
60
|
});
|
|
55
61
|
A("div", opts.contentAttrs, () => {
|
|
@@ -57,10 +63,14 @@ mountPortal(() => {
|
|
|
57
63
|
});
|
|
58
64
|
A(() => {
|
|
59
65
|
if (opts.footer != null) {
|
|
60
|
-
A("footer.s-s.
|
|
66
|
+
A("footer.s-s.neutral", opts.footerAttrs, () => drawSlot(opts.footer));
|
|
61
67
|
}
|
|
62
68
|
});
|
|
63
69
|
});
|
|
70
|
+
// Once laid out, move focus into the dialog (first focusable element) so it's
|
|
71
|
+
// keyboard-ready and focus doesn't linger on whatever opened it.
|
|
72
|
+
requestAnimationFrame(() => { if (document.body.contains(dialogEl))
|
|
73
|
+
focusFirst(dialogEl); });
|
|
64
74
|
});
|
|
65
75
|
});
|
|
66
76
|
/**
|
|
@@ -79,7 +89,7 @@ mountPortal(() => {
|
|
|
79
89
|
* content: (close) => {
|
|
80
90
|
* A("p #Are you sure?");
|
|
81
91
|
* S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
|
|
82
|
-
* S.button({ content: "Cancel", attrs: ".neutral
|
|
92
|
+
* S.button({ content: "Cancel", attrs: ".neutral", click: close });
|
|
83
93
|
* },
|
|
84
94
|
* });
|
|
85
95
|
* ```
|
|
@@ -150,7 +160,7 @@ export function confirm(message, opts = {}) {
|
|
|
150
160
|
content: (close) => {
|
|
151
161
|
A("p", () => { A("#", message); });
|
|
152
162
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
153
|
-
button({ content: "Cancel", attrs: ".neutral
|
|
163
|
+
button({ content: "Cancel", attrs: ".neutral", click: close });
|
|
154
164
|
button({ content: "OK", click: () => { confirmed = true; close(); } });
|
|
155
165
|
} });
|
|
156
166
|
},
|
|
@@ -189,7 +199,7 @@ export function prompt(message, defaultValue = "", opts = {}) {
|
|
|
189
199
|
});
|
|
190
200
|
textline({ bind: A.ref($v, "value") });
|
|
191
201
|
buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
|
|
192
|
-
button({ content: "Cancel", attrs: ".neutral
|
|
202
|
+
button({ content: "Cancel", attrs: ".neutral", type: "button", click: close });
|
|
193
203
|
button({ content: "OK", type: "submit" });
|
|
194
204
|
} });
|
|
195
205
|
});
|
package/dist/components/field.js
CHANGED
|
@@ -3,14 +3,17 @@ import { drawSlot, uniqueId } from "../core.js";
|
|
|
3
3
|
A.insertGlobalCss({
|
|
4
4
|
".s-field": {
|
|
5
5
|
"&": "display:flex flex-direction:column gap:$1",
|
|
6
|
-
"> label": "font-weight:600 font-size:0.9em fg:$s-
|
|
6
|
+
"> label": "font-weight:600 font-size:0.9em fg:$s-text user-select:none",
|
|
7
7
|
},
|
|
8
8
|
".s-req": "fg:$s-danger margin-left:2px",
|
|
9
|
-
".s-help": "font-size:0.82em fg:$s-
|
|
9
|
+
".s-help": "font-size:0.82em fg:$s-muted",
|
|
10
10
|
".s-error": "font-size:0.82em fg:$s-danger",
|
|
11
11
|
".s-input": {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// A light inset field, derived from the surrounding surface: the surface
|
|
13
|
+
// background nudged slightly toward its ink, with the ink as text. Adapts to
|
|
14
|
+
// whatever surface (and mode) holds the field, no fixed input colour needed.
|
|
15
|
+
"&": "w:100% background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
16
|
+
"&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
14
17
|
"&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
|
|
15
18
|
"&[aria-invalid=true]": "border-color:$s-danger",
|
|
16
19
|
},
|