staffa 0.1.0 → 0.3.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 +161 -97
- package/dist/components/autocomplete.js +21 -20
- package/dist/components/box.d.ts +8 -6
- package/dist/components/box.js +16 -12
- package/dist/components/button.d.ts +21 -35
- package/dist/components/button.js +46 -39
- package/dist/components/buttonChooser.d.ts +41 -0
- package/dist/components/buttonChooser.js +38 -0
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +18 -21
- package/dist/components/checkbox.js +7 -7
- package/dist/components/dialog.d.ts +20 -25
- package/dist/components/dialog.js +81 -91
- package/dist/components/field.d.ts +8 -6
- package/dist/components/field.js +16 -18
- package/dist/components/form.d.ts +3 -3
- package/dist/components/form.js +4 -4
- package/dist/components/main.d.ts +41 -14
- package/dist/components/main.js +193 -53
- package/dist/components/menu.d.ts +118 -0
- package/dist/components/menu.js +218 -0
- package/dist/components/select.d.ts +1 -1
- package/dist/components/select.js +5 -5
- package/dist/components/tabs.d.ts +5 -5
- package/dist/components/tabs.js +15 -22
- package/dist/components/textarea.js +4 -4
- package/dist/components/textline.js +1 -1
- package/dist/components/toast.d.ts +37 -0
- package/dist/components/toast.js +79 -0
- package/dist/components/tooltip.d.ts +32 -0
- package/dist/components/tooltip.js +130 -0
- package/dist/core.d.ts +26 -39
- package/dist/core.js +6 -5
- package/dist/icons-helpers.d.ts +46 -0
- package/dist/icons-helpers.js +44 -0
- package/dist/icons.d.ts +1960 -0
- package/dist/icons.js +1972 -0
- package/dist/index.d.ts +21 -8
- package/dist/index.js +17 -8
- package/dist/staffa.esm.js +1 -0
- package/dist/theme.d.ts +9 -75
- package/dist/theme.js +279 -82
- package/package.json +12 -5
- package/src/components/autocomplete.ts +21 -20
- package/src/components/box.ts +21 -15
- package/src/components/button.ts +59 -75
- package/src/components/buttonChooser.ts +65 -0
- package/src/components/buttonGroup.ts +18 -21
- package/src/components/checkbox.ts +7 -7
- package/src/components/dialog.ts +101 -102
- package/src/components/field.ts +22 -22
- package/src/components/form.ts +7 -7
- package/src/components/main.ts +212 -52
- package/src/components/menu.ts +288 -0
- package/src/components/select.ts +4 -4
- package/src/components/tabs.ts +20 -27
- package/src/components/textarea.ts +4 -4
- package/src/components/textline.ts +1 -1
- package/src/components/toast.ts +115 -0
- package/src/components/tooltip.ts +139 -0
- package/src/core.ts +26 -40
- package/src/icons-helpers.ts +90 -0
- package/src/icons.ts +1977 -0
- package/src/index.ts +21 -8
- package/src/theme.ts +300 -135
- package/dist/components/modal.d.ts +0 -2
- package/dist/components/modal.js +0 -2
- package/dist/skye.esm.js +0 -1
package/dist/theme.js
CHANGED
|
@@ -1,65 +1,89 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* stands out of the box.
|
|
3
|
+
* Theming and global base styles for Staffa.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
* and, while dark mode is active, the change flows straight into the CSS
|
|
8
|
-
* variables. Use this to retheme dark and {@link lightTheme} independently.
|
|
9
|
-
*/
|
|
10
|
-
export const darkTheme = A.proxy({
|
|
11
|
-
sBg: "#0e1015",
|
|
12
|
-
sSurface: "#181b22",
|
|
13
|
-
sSurfaceHi: "#222632",
|
|
14
|
-
sFg: "#e8eaf0",
|
|
15
|
-
sFgMuted: "#a6acba",
|
|
16
|
-
sFgFaint: "#6b7280",
|
|
17
|
-
sBorder: "#2c313c",
|
|
18
|
-
sBorderStrong: "#3c4352",
|
|
19
|
-
sPrimary: "#8b7bff",
|
|
20
|
-
sPrimaryHover: "#a99dff",
|
|
21
|
-
sPrimaryFg: "#0c0a1a",
|
|
22
|
-
sDanger: "#ff6b6b",
|
|
23
|
-
sSuccess: "#46d39a",
|
|
24
|
-
sWarning: "#fbbf24",
|
|
25
|
-
sFocus: "rgba(139, 123, 255, 0.45)",
|
|
26
|
-
sRadius: "10px",
|
|
27
|
-
sRadiusLg: "16px",
|
|
28
|
-
sShadow: "0 8px 30px rgba(0, 0, 0, 0.45)",
|
|
29
|
-
});
|
|
30
|
-
/**
|
|
31
|
-
* The light Skye theme — the same lavender brand, retuned for a bright,
|
|
32
|
-
* modern surface: white cards on a soft grey page, a deeper primary so it
|
|
33
|
-
* reads well on light backgrounds, and a softer elevation shadow.
|
|
5
|
+
* # The surface model
|
|
34
6
|
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
7
|
+
* A Staffa app is a tree of **surfaces**. A surface is anything with its own
|
|
8
|
+
* background and the text colour that goes on it — the page, a card, a raised
|
|
9
|
+
* header, a coloured button. Mark an element as a surface with the `.s-s` class,
|
|
10
|
+
* then add modifier classes to colour it. A surface carries two colours:
|
|
11
|
+
*
|
|
12
|
+
* - `--s-a` — its default **foreground** (ink)
|
|
13
|
+
* - `--s-b` — its default **background** (fill)
|
|
14
|
+
*
|
|
15
|
+
* set by a **level** modifier — `.base` (the page), `.panel` (the default card),
|
|
16
|
+
* `.raised` (elevated chrome) — or an **accent role** modifier — `.primary`,
|
|
17
|
+
* `.secondary`, `.gradient` (the brand sweep), `.neutral`, `.danger`,
|
|
18
|
+
* `.success`, `.warning`.
|
|
19
|
+
*
|
|
20
|
+
* A **variant** modifier — `.filled` (the default), `.tonal` or `.outlined` —
|
|
21
|
+
* decides how `--s-a`/`--s-b` map onto the tokens widgets read
|
|
22
|
+
* (`--s-fg`/`--s-bg`). A shared rule then derives muted/faint/border from that
|
|
23
|
+
* pair and paints the element. Because the derivation reads `$s-fg`/`$s-bg`,
|
|
24
|
+
* every surface gets its *own* legible secondary colours: drop a widget on any
|
|
25
|
+
* surface and it adapts. (`:root` is an implicit filled surface, so the page
|
|
26
|
+
* renders without extra classes.)
|
|
27
|
+
*
|
|
28
|
+
* Components build surfaces by combining classes, e.g.
|
|
29
|
+
* `A("div.s-s.panel.outlined", opts.attrs)` — and because `opts.attrs` is the
|
|
30
|
+
* caller's escape hatch, passing e.g. `.filled` or `.danger` overrides the
|
|
31
|
+
* default look.
|
|
32
|
+
*
|
|
33
|
+
* | token | meaning |
|
|
34
|
+
* | ---------------- | ---------------------------------------- |
|
|
35
|
+
* | `--s-fg` | default text |
|
|
36
|
+
* | `--s-bg` | background |
|
|
37
|
+
* | `--s-fg-muted` | secondary text (subtitles, help) |
|
|
38
|
+
* | `--s-fg-faint` | placeholders, disabled |
|
|
39
|
+
* | `--s-border` / `--s-border-strong` | borders |
|
|
40
|
+
* | `--s-accent` | brand "pop" colour (active indicators, etc.) |
|
|
41
|
+
* | `--s-gradient` | primary→secondary brand sweep (mark, primary buttons, active nav) |
|
|
42
|
+
* | `--s-glow` | soft coloured shadow for lit brand elements |
|
|
43
|
+
* | `--s-link` / `--s-focus` | link & focus-ring colours |
|
|
44
|
+
* | `--s-radius` / `--s-radius-lg` / `--s-shadow` | shape tokens |
|
|
45
|
+
*
|
|
46
|
+
* `--s-accent` and `--s-link` default to the brand / link colour, but on a
|
|
47
|
+
* bright coloured surface (`.primary`, `.danger`, …) they fall back to that
|
|
48
|
+
* surface's own ink so they stay legible.
|
|
49
|
+
*
|
|
50
|
+
* # The palette
|
|
51
|
+
*
|
|
52
|
+
* All colours come from a small set of named **palette** tokens on `:root`, set
|
|
53
|
+
* per mode — the only place colours live, and the single place to re-skin:
|
|
54
|
+
* `--s-primary`, `--s-secondary` (the two ends of `--s-gradient`), `--s-danger`,
|
|
55
|
+
* `--s-success`, `--s-warning` (accent fills, which
|
|
56
|
+
* double as semantic *ink* on neutral surfaces), `--s-neutral`, `--s-page`,
|
|
57
|
+
* `--s-panel`, `--s-raised` (neutral fills), `--s-ink` (text on neutral) and
|
|
58
|
+
* `--s-on-accent` (text on accent fills), plus `--s-link`/`--s-focus` and the
|
|
59
|
+
* shape tokens. Every surface rule is wired to these, so they adapt with the
|
|
60
|
+
* mode and with any override.
|
|
61
|
+
*
|
|
62
|
+
* # Variants
|
|
63
|
+
*
|
|
64
|
+
* Because the variant decides how `--s-a`/`--s-b` become `--s-fg`/`--s-bg`, the
|
|
65
|
+
* three looks are generic and work on *any* role: `.tonal` reads the fill colour
|
|
66
|
+
* as ink over a soft self-tint; `.outlined` reads it as ink over a transparent
|
|
67
|
+
* fill with a coloured edge (inheriting the parent's background, so its derived
|
|
68
|
+
* tokens read the real surroundings). Inside any of them `--s-fg`/`--s-bg` still
|
|
69
|
+
* describe the real, rendered colours.
|
|
70
|
+
*
|
|
71
|
+
* # Customising
|
|
72
|
+
*
|
|
73
|
+
* There's no JS theme object — the colours live in the palette `insertGlobalCss`
|
|
74
|
+
* call below, branched on {@link getDarkMode}. Re-skin from your app by
|
|
75
|
+
* overriding palette tokens (per mode if you like), or give a surface an
|
|
76
|
+
* image/gradient background (set `--s-b` to the dominant fallback colour so
|
|
77
|
+
* derived tokens stay sensible). Staffa uses global, `s-`-prefixed classes, so
|
|
78
|
+
* nothing is scoped away from you.
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* A(() => A.insertGlobalCss({ ":root": getDarkMode() ? "--s-primary:#28c4a0" : "--s-primary:#1f9d6b" }));
|
|
82
|
+
* A.insertGlobalCss({ ".s-s.panel": "background: url(paper.png); --s-b: #efe9dd" });
|
|
83
|
+
* ```
|
|
62
84
|
*/
|
|
85
|
+
const STORAGE_KEY = "staffa:darkMode";
|
|
86
|
+
/** The explicit dark-mode choice; `undefined` follows the OS via {@link A.darkMode}. */
|
|
63
87
|
const $override = A.proxy({ value: readStoredOverride() });
|
|
64
88
|
/** Read the persisted dark-mode override from localStorage (defensively). */
|
|
65
89
|
function readStoredOverride() {
|
|
@@ -77,8 +101,7 @@ function readStoredOverride() {
|
|
|
77
101
|
}
|
|
78
102
|
/**
|
|
79
103
|
* Force dark mode (`true`), light mode (`false`), or follow the OS preference
|
|
80
|
-
* (`undefined`). Takes effect immediately and is persisted to localStorage
|
|
81
|
-
* the choice survives reloads.
|
|
104
|
+
* (`undefined`). Takes effect immediately and is persisted to localStorage.
|
|
82
105
|
*/
|
|
83
106
|
export function setDarkMode(value) {
|
|
84
107
|
$override.value = value;
|
|
@@ -96,40 +119,214 @@ export function setDarkMode(value) {
|
|
|
96
119
|
* Whether dark mode is currently active. Reactive — read it inside a scope to
|
|
97
120
|
* re-run on changes.
|
|
98
121
|
*
|
|
99
|
-
* @param allowAuto - When `true`, returns `undefined` (rather than
|
|
100
|
-
*
|
|
101
|
-
*
|
|
122
|
+
* @param allowAuto - When `true`, returns `undefined` (rather than a boolean) if
|
|
123
|
+
* the user is following the OS preference, so a dark/light/auto control can
|
|
124
|
+
* tell the three states apart.
|
|
102
125
|
*/
|
|
103
126
|
export function getDarkMode(allowAuto = false) {
|
|
104
127
|
const v = $override.value;
|
|
105
128
|
return v === undefined && !allowAuto ? A.darkMode() : v;
|
|
106
129
|
}
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// so
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Reactive palette — the ONE place colours live, and the only thing that
|
|
132
|
+
// differs between light and dark. Everything else is wired to these named
|
|
133
|
+
// tokens, so an app can re-skin by overriding just a few of them. The accent
|
|
134
|
+
// names (--s-primary/-danger/-success/-warning) double as semantic *ink*
|
|
135
|
+
// colours, legible as text/borders on neutral surfaces.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
114
137
|
A(() => {
|
|
115
|
-
|
|
138
|
+
if (getDarkMode()) {
|
|
139
|
+
A.insertGlobalCss({
|
|
140
|
+
":root": "--s-primary:#8b7bff --s-secondary:#ef7fd0 --s-danger:#ff6b6b --s-success:#46d39a --s-warning:#fbbf24 " +
|
|
141
|
+
"--s-neutral:#3c4352 --s-page:#0e1015 --s-panel:#181b22 --s-raised:#222632 " +
|
|
142
|
+
"--s-ink:#e8eaf0 --s-on-accent:#0c0a14 --s-focus:rgba(139,123,255,0.45) " +
|
|
143
|
+
"--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 34px rgba(0,0,0,0.5);",
|
|
144
|
+
// Contextual link, restored across the neutral group (see static block).
|
|
145
|
+
":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#6db3ff",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
A.insertGlobalCss({
|
|
150
|
+
":root": "--s-primary:#6c5ce7 --s-secondary:#d6459e --s-danger:#e23b3b --s-success:#1f9d6b --s-warning:#d97706 " +
|
|
151
|
+
"--s-neutral:#c7ccda --s-page:#f3f4f8 --s-panel:#ffffff --s-raised:#eceef4 " +
|
|
152
|
+
"--s-ink:#1b1e27 --s-on-accent:#0c0a14 --s-focus:rgba(108,92,231,0.35) " +
|
|
153
|
+
"--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 30px rgba(20,24,40,0.13);",
|
|
154
|
+
":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#2563eb",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
116
157
|
});
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Static structure — inserted once, all wired to the palette above.
|
|
160
|
+
//
|
|
161
|
+
// A surface is marked with `.s-s`. Its level/role modifier sets two anchors
|
|
162
|
+
// (--s-a ink, --s-b fill); its variant modifier (`.filled` default, `.tonal`,
|
|
163
|
+
// `.outlined`) decides how those anchors map onto the rendered fg/bg. `:root`
|
|
164
|
+
// (the page) is an implicit filled surface. Every `.s-s` then runs the same
|
|
165
|
+
// derive+paint step, reading the resolved fg/bg, so each one gets its own
|
|
166
|
+
// legible secondary tokens regardless of variant.
|
|
167
|
+
//
|
|
168
|
+
// Rule order matters: the variant/role rules below all share specificity, so a
|
|
169
|
+
// later one wins. `.filled` therefore comes *last* among the variants — that's
|
|
170
|
+
// what lets a caller's `attrs: ".filled"` override a component's default
|
|
171
|
+
// `.tonal`/`.outlined`.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
A.setSpacingCssVars();
|
|
174
|
+
A.insertGlobalCss({
|
|
175
|
+
// Derived brand tokens. These only reference the per-mode palette colours, so
|
|
176
|
+
// they're defined once here and track the active mode (and any re-skin):
|
|
177
|
+
// `--s-gradient` is the primary→secondary brand sweep used for the headline
|
|
178
|
+
// mark, primary buttons and the active nav pill; `--s-glow` is a soft coloured
|
|
179
|
+
// shadow that makes those same elements feel lit; `--s-page-bg` is a faint
|
|
180
|
+
// twin-corner aurora wash painted on the page surface.
|
|
181
|
+
":root": "--s-gradient: linear-gradient(135deg, $s-primary, $s-secondary); " +
|
|
182
|
+
"--s-glow: 0 8px 24px color-mix(in srgb, $s-primary 32%, transparent); " +
|
|
183
|
+
"--s-page-bg: radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, $s-secondary, transparent 86%), transparent 56%), radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, $s-primary, transparent 87%), transparent 56%), $s-page;",
|
|
184
|
+
// Level/role modifier → anchors. Levels (neutral elevations) use the shared
|
|
185
|
+
// ink; accent roles use the on-accent ink over their named fill. The page
|
|
186
|
+
// (`.base`) additionally paints the aurora wash; `--s-b` keeps the solid
|
|
187
|
+
// fallback so every derived token stays sensible.
|
|
188
|
+
":root, .s-s.base": "--s-a:$s-ink --s-b:$s-page",
|
|
189
|
+
".s-s.base": "background: $s-page-bg;",
|
|
190
|
+
".s-s.panel": "--s-a:$s-ink --s-b:$s-panel",
|
|
191
|
+
".s-s.raised": "--s-a:$s-ink --s-b:$s-raised",
|
|
192
|
+
".s-s.neutral": "--s-a:$s-ink --s-b:$s-neutral",
|
|
193
|
+
".s-s.primary": "--s-a:$s-on-accent --s-b:$s-primary",
|
|
194
|
+
".s-s.secondary": "--s-a:$s-on-accent --s-b:$s-secondary",
|
|
195
|
+
".s-s.danger": "--s-a:$s-on-accent --s-b:$s-danger",
|
|
196
|
+
".s-s.success": "--s-a:$s-on-accent --s-b:$s-success",
|
|
197
|
+
".s-s.warning": "--s-a:$s-on-accent --s-b:$s-warning",
|
|
198
|
+
// `.gradient` is an accent role whose fill is the brand sweep. Its `--s-b`
|
|
199
|
+
// anchor stays the solid `--s-primary` so the derived tokens (muted ink,
|
|
200
|
+
// border, …) remain sensible; the gradient itself is painted further down,
|
|
201
|
+
// only in the filled context (tonal/outlined read the solid fallback instead).
|
|
202
|
+
".s-s.gradient": "--s-a:$s-on-accent --s-b:$s-primary",
|
|
203
|
+
// Filled default (bare `.s-s` and `:root`): map the anchors to fg/bg, derive
|
|
204
|
+
// the secondary tokens from that pair, then paint. var() resolves at use time,
|
|
205
|
+
// so the tonal/outlined remaps below feed back into the derived tokens.
|
|
206
|
+
":root, .s-s": "--s-fg:$s-a --s-bg:$s-b " +
|
|
207
|
+
"--s-fg-muted: color-mix(in oklab, $s-fg, $s-bg 42%); " +
|
|
208
|
+
"--s-fg-faint: color-mix(in oklab, $s-fg, $s-bg 64%); " +
|
|
209
|
+
"--s-border: color-mix(in oklab, $s-fg, $s-bg 82%); " +
|
|
210
|
+
"--s-border-strong: color-mix(in oklab, $s-fg, $s-bg 68%); " +
|
|
211
|
+
// Themed scrollbars (standard properties, honoured by Firefox and modern
|
|
212
|
+
// Chromium): the thumb uses the surface's derived border token, so the bar
|
|
213
|
+
// tracks the active mode/re-skin and a nested surface's own colours.
|
|
214
|
+
"scrollbar-width:thin scrollbar-color: $s-border-strong transparent; " +
|
|
215
|
+
"background:$s-bg color:$s-fg",
|
|
216
|
+
// WebKit/older-Chromium counterpart to the standard `scrollbar-*` props above:
|
|
217
|
+
// a transparent track and a rounded thumb (border-clipped to leave breathing
|
|
218
|
+
// room) that brightens on hover.
|
|
219
|
+
".s-s::-webkit-scrollbar, .s-s ::-webkit-scrollbar": "width:10px height:10px",
|
|
220
|
+
".s-s::-webkit-scrollbar-track, .s-s ::-webkit-scrollbar-track": "background:transparent",
|
|
221
|
+
".s-s::-webkit-scrollbar-thumb, .s-s ::-webkit-scrollbar-thumb": "background:$s-border-strong border-radius:99px border: 2px solid transparent; background-clip:padding-box",
|
|
222
|
+
".s-s::-webkit-scrollbar-thumb:hover, .s-s ::-webkit-scrollbar-thumb:hover": "background:$s-fg-faint background-clip:padding-box",
|
|
223
|
+
// Tonal: the fill colour becomes the ink, over a soft tint of itself.
|
|
224
|
+
".s-s.tonal": "--s-fg:$s-b --s-bg: color-mix(in srgb, $s-b 16%, transparent);",
|
|
225
|
+
// Outlined: the fill colour is the ink; --s-bg *inherits* the parent's bg (the
|
|
226
|
+
// token the derivations read, so the edge mixes ink with the real surroundings)
|
|
227
|
+
// while the painted background is transparent, letting that parent fill — even
|
|
228
|
+
// a gradient or image — show through.
|
|
229
|
+
".s-s.outlined": "--s-fg:$s-b --s-bg:inherit background:transparent --s-border: color-mix(in srgb, $s-fg 55%, $s-bg);",
|
|
230
|
+
// Filled, explicit — last among the variants so a caller's `attrs: ".filled"`
|
|
231
|
+
// overrides a component's default `.tonal`/`.outlined` (resetting both the
|
|
232
|
+
// anchors and the painted background).
|
|
233
|
+
".s-s.filled": "--s-fg:$s-a --s-bg:$s-b background:$s-bg;",
|
|
234
|
+
// Paint the brand sweep for a filled `.gradient` surface. Sits after the
|
|
235
|
+
// variant rules and is keyed on `:not(.tonal):not(.outlined)`, so those
|
|
236
|
+
// variants keep their solid-primary tint/edge.
|
|
237
|
+
".s-s.gradient:not(.tonal):not(.outlined)": "background: $s-gradient;",
|
|
238
|
+
// Contextual accent: the brand pop colour on neutral surfaces. Declared on the
|
|
239
|
+
// whole neutral group so re-entering a neutral surface under a coloured one
|
|
240
|
+
// restores it. (--s-link gets the same treatment in the reactive block, where
|
|
241
|
+
// its per-mode literal lives — it can't reference the palette, as the palette
|
|
242
|
+
// source and the contextual token share the name --s-link.)
|
|
243
|
+
":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-accent:$s-primary",
|
|
244
|
+
// On a bright coloured surface those wouldn't be legible, so they fall back to
|
|
245
|
+
// the surface's own ink. (Keyed on the role modifier, so it holds across
|
|
246
|
+
// variants — and tracks the tonal/outlined fg remap.)
|
|
247
|
+
".s-s.primary, .s-s.secondary, .s-s.danger, .s-s.success, .s-s.warning, .s-s.gradient": "--s-accent:$s-fg --s-link:$s-fg",
|
|
248
|
+
});
|
|
249
|
+
// A deliberately light reset. Colours/shape come from the contextual tokens, so
|
|
250
|
+
// rich content (e.g. markdown-to-HTML) adapts to whatever surface holds it. The
|
|
251
|
+
// vertical rhythm / typography of block elements is handled separately, below.
|
|
120
252
|
A.insertGlobalCss({
|
|
121
253
|
"*, *::before, *::after": "box-sizing:border-box",
|
|
122
254
|
html: "text-size-adjust:100%",
|
|
123
|
-
body: "m:0
|
|
124
|
-
a: "
|
|
125
|
-
"a:hover": "
|
|
255
|
+
body: "m:0 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased",
|
|
256
|
+
a: "color: $s-link; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
|
|
257
|
+
"a:hover": "filter: brightness(1.15)",
|
|
126
258
|
"input, button, textarea, select": "font:inherit color:inherit",
|
|
127
259
|
"code, kbd, samp, pre": "font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;",
|
|
128
|
-
code: "bg
|
|
129
|
-
pre: "bg
|
|
130
|
-
"pre code": "
|
|
260
|
+
code: "background: color-mix(in oklab, $s-fg, $s-bg 86%); padding: 0.12em 0.34em; r:4px font-size:0.9em",
|
|
261
|
+
pre: "background: color-mix(in oklab, $s-fg, $s-bg 92%); p:$3 r: $s-radius; overflow:auto",
|
|
262
|
+
"pre code": "background:transparent p:0",
|
|
131
263
|
"img, svg, video, canvas": "max-width:100% h:auto",
|
|
132
|
-
hr: "border:0 border-top: 1px solid $
|
|
133
|
-
"::placeholder": "fg
|
|
134
|
-
":focus-visible": "outline: 2px solid $
|
|
264
|
+
hr: "border:0 border-top: 1px solid $s-border;",
|
|
265
|
+
"::placeholder": "color: $s-fg-faint; opacity:1",
|
|
266
|
+
":focus-visible": "outline: 2px solid $s-focus; outline-offset:2px",
|
|
267
|
+
small: "color:$s-fg-muted font-size:0.9em",
|
|
268
|
+
// Respect users who prefer less motion: keep transitions essentially instant.
|
|
269
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
270
|
+
"*, *::before, *::after": "transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; scroll-behavior: auto !important;",
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
// ── Suppress transitions during the initial load ─────────────────────────────
|
|
274
|
+
// Buttons (and links) transition their colours, so a light↔dark switch animates
|
|
275
|
+
// smoothly. On a *cold* load that's a liability: elements mount and paint in the
|
|
276
|
+
// active theme in one pass, but a transitioning element animates from its
|
|
277
|
+
// default (unstyled) colours into the theme — so a fresh button visibly slides
|
|
278
|
+
// from light to dark while the rest of the page is already correct. Tag <html>
|
|
279
|
+
// until the first frame has painted and hard-disable transitions under that tag,
|
|
280
|
+
// so the initial render snaps straight to the right colours. Two rAFs: the first
|
|
281
|
+
// runs before the paint that shows the themed UI, the second clears the tag just
|
|
282
|
+
// after it — later theme switches then animate as normal.
|
|
283
|
+
A.insertGlobalCss({
|
|
284
|
+
".s-preload, .s-preload *, .s-preload *::before, .s-preload *::after": "transition: none !important; animation: none !important;",
|
|
285
|
+
});
|
|
286
|
+
if (typeof document !== "undefined" && typeof requestAnimationFrame === "function") {
|
|
287
|
+
const root = document.documentElement;
|
|
288
|
+
root.classList.add("s-preload");
|
|
289
|
+
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("s-preload")));
|
|
290
|
+
}
|
|
291
|
+
// ── Flow content: vertical rhythm & light typography ─────────────────────────
|
|
292
|
+
// Sensible block defaults for *any* content — your own UI just as much as
|
|
293
|
+
// markdown-rendered HTML. The rhythm: strip the browser's block margins, then
|
|
294
|
+
// give every block a *top* margin only when it isn't its parent's first child.
|
|
295
|
+
// Content sits flush against its container's edges, and space appears solely
|
|
296
|
+
// *between* siblings. These are intentionally low-specificity defaults — a
|
|
297
|
+
// component or utility that sets its own margin (or an inline `mt:` shortcut)
|
|
298
|
+
// always wins, so a class only ever changes what it actually names.
|
|
299
|
+
const BLOCK = "p, ul, ol, dl, blockquote, pre, table, figure, hr, h1, h2, h3, h4, h5, h6";
|
|
300
|
+
A.insertGlobalCss({
|
|
301
|
+
[`${BLOCK}`]: {
|
|
302
|
+
"&": "margin:0",
|
|
303
|
+
"&:not(:first-child)": "margin-top:$3",
|
|
304
|
+
},
|
|
305
|
+
// Headings: bold, tight, balanced. Big levels get slightly negative tracking,
|
|
306
|
+
// small levels become spaced "labels" — so adjacent levels stay distinct. The
|
|
307
|
+
// em-based top margin gives larger headings a little more room above.
|
|
308
|
+
"h1, h2, h3, h4, h5, h6": {
|
|
309
|
+
"&": "line-height:1.15 font-weight:700 text-wrap:balance",
|
|
310
|
+
"&:not(:first-child)": "margin-top:1.4em",
|
|
311
|
+
},
|
|
312
|
+
h1: "font-size:2em font-weight:800 letter-spacing:-0.022em",
|
|
313
|
+
h2: "font-size:1.55em letter-spacing:-0.018em",
|
|
314
|
+
h3: "font-size:1.3em letter-spacing:-0.011em",
|
|
315
|
+
h4: "font-size:1.1em",
|
|
316
|
+
h5: "font-size:0.95em letter-spacing:0.005em",
|
|
317
|
+
h6: "font-size:0.8em fg:$s-fg-muted text-transform:uppercase letter-spacing:0.07em",
|
|
318
|
+
// Lists: markers, a sensible indent, gently spaced items, tight nesting.
|
|
319
|
+
"ul, ol": {
|
|
320
|
+
"&": "padding-left:1.5em",
|
|
321
|
+
"> li:not(:first-child), li > &:not(:first-child)": "margin-top:$1",
|
|
322
|
+
},
|
|
323
|
+
// Blockquote, tables, definition lists, figure captions.
|
|
324
|
+
blockquote: "border-left: 3px solid $s-border; padding-left: $3; fg: $s-fg-muted",
|
|
325
|
+
table: "border-collapse:collapse",
|
|
326
|
+
"th, td": "text-align:left padding: $1 $2; border-bottom: 1px solid $s-border; vertical-align:top",
|
|
327
|
+
th: "font-weight:600",
|
|
328
|
+
"thead th": "border-bottom: 2px solid $s-border-strong;",
|
|
329
|
+
dt: "font-weight:600",
|
|
330
|
+
dd: "margin-left: 1.5em",
|
|
331
|
+
figcaption: "fg:$s-fg-muted font-size:0.9em margin-top:$1 text-align:center",
|
|
135
332
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staffa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "An opinionated component library for the Aberdeen reactive UI library.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
+
"./icons.js": {
|
|
16
|
+
"types": "./dist/icons.d.ts",
|
|
17
|
+
"default": "./dist/icons.js"
|
|
18
|
+
},
|
|
15
19
|
"./all.js": "./dist/staffa.esm.js"
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
@@ -19,17 +23,20 @@
|
|
|
19
23
|
"src"
|
|
20
24
|
],
|
|
21
25
|
"scripts": {
|
|
22
|
-
"build": "
|
|
26
|
+
"build:icons": "node scripts/generate-icons.mjs",
|
|
27
|
+
"build": "npm run build:icons && tsc && tsc -p demo && esbuild src/index.ts --bundle --external:aberdeen --minify --format=esm --outfile=dist/staffa.esm.js",
|
|
23
28
|
"typecheck": "tsc --noEmit && tsc -p demo --noEmit",
|
|
24
|
-
"smoke": "tsc && node smoke.mjs"
|
|
29
|
+
"smoke": "tsc && node smoke.mjs",
|
|
30
|
+
"prepublish": "npm run build"
|
|
25
31
|
},
|
|
26
32
|
"peerDependencies": {
|
|
27
|
-
"aberdeen": "^1.
|
|
33
|
+
"aberdeen": "^1.17.1"
|
|
28
34
|
},
|
|
29
35
|
"devDependencies": {
|
|
30
|
-
"aberdeen": "^1.
|
|
36
|
+
"aberdeen": "^1.17.1",
|
|
31
37
|
"esbuild": "^0.28.0",
|
|
32
38
|
"jsdom": "^29.1.1",
|
|
39
|
+
"lucide-static": "^1.17.0",
|
|
33
40
|
"typescript": "^5.9.3"
|
|
34
41
|
}
|
|
35
42
|
}
|
|
@@ -33,21 +33,22 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
A.insertGlobalCss({
|
|
36
|
-
".
|
|
36
|
+
".s-ac": {
|
|
37
37
|
"&": "position:relative",
|
|
38
|
-
"> .
|
|
39
|
-
"> .
|
|
40
|
-
"> .
|
|
41
|
-
"&[aria-invalid=true] > .
|
|
42
|
-
".
|
|
43
|
-
".
|
|
44
|
-
".
|
|
38
|
+
"> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
39
|
+
"> .s-control:hover": "border-color:$s-border-strong",
|
|
40
|
+
"> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
|
|
41
|
+
"&[aria-invalid=true] > .s-control": "border-color:$s-danger",
|
|
42
|
+
".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$s-raised border: 1px solid $s-border; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
|
|
43
|
+
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
44
|
+
".s-chip > button:hover": "fg:$s-fg background:$s-border",
|
|
45
45
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
46
|
-
"> .
|
|
47
|
-
".
|
|
48
|
-
".
|
|
49
|
-
".
|
|
50
|
-
".
|
|
46
|
+
"> .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 bg:$s-panel border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow",
|
|
47
|
+
"> .s-menu li": "margin:0",
|
|
48
|
+
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
49
|
+
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-fg 10%, transparent);",
|
|
50
|
+
".s-add": "fg:$s-accent font-style:italic",
|
|
51
|
+
".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
|
|
51
52
|
},
|
|
52
53
|
});
|
|
53
54
|
|
|
@@ -123,19 +124,19 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
123
124
|
};
|
|
124
125
|
|
|
125
126
|
drawField(opts, (id, isInvalid) => {
|
|
126
|
-
A("div.
|
|
127
|
+
A("div.s-ac", opts.inputAttrs, () => {
|
|
127
128
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
128
129
|
|
|
129
130
|
let inputEl: HTMLInputElement | undefined;
|
|
130
131
|
|
|
131
|
-
A("div.
|
|
132
|
+
A("div.s-control", () => {
|
|
132
133
|
A("click=", () => inputEl?.focus());
|
|
133
134
|
|
|
134
135
|
// Chips for multi-select.
|
|
135
136
|
A(() => {
|
|
136
137
|
if (!opts.multi) return;
|
|
137
138
|
for (const value of selectedValues()) {
|
|
138
|
-
A("span.
|
|
139
|
+
A("span.s-chip", () => {
|
|
139
140
|
A("span #", A.peek(() => labelFor(value)));
|
|
140
141
|
A("button type=button aria-label=", `Remove ${value}`, () => {
|
|
141
142
|
A("#×");
|
|
@@ -183,9 +184,9 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
183
184
|
const q = $st.query.trim();
|
|
184
185
|
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
185
186
|
|
|
186
|
-
A("ul.
|
|
187
|
+
A("ul.s-menu role=listbox", `id=${menuId}`, () => {
|
|
187
188
|
list.forEach((option, i) => {
|
|
188
|
-
A("li.
|
|
189
|
+
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
189
190
|
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
190
191
|
A("#", option.label);
|
|
191
192
|
A("mousedown=", (e: Event) => e.preventDefault());
|
|
@@ -196,14 +197,14 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
196
197
|
});
|
|
197
198
|
});
|
|
198
199
|
if (showAdd) {
|
|
199
|
-
A("li.
|
|
200
|
+
A("li.s-option.s-add role=option", () => {
|
|
200
201
|
A("#", `Add "${q}"`);
|
|
201
202
|
A("mousedown=", (e: Event) => e.preventDefault());
|
|
202
203
|
A("click=", () => commit(q, inputEl));
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
if (list.length === 0 && !showAdd) {
|
|
206
|
-
A("li.
|
|
207
|
+
A("li.s-empty #No matches");
|
|
207
208
|
}
|
|
208
209
|
});
|
|
209
210
|
});
|
package/src/components/box.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { type Content, type ContentOptions, type Slot, type
|
|
2
|
+
import { type Content, type ContentOptions, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
3
|
|
|
4
4
|
/** Options for {@link box}. */
|
|
5
5
|
export interface BoxOptions extends ContentOptions {
|
|
@@ -7,18 +7,24 @@ export interface BoxOptions extends ContentOptions {
|
|
|
7
7
|
header?: Slot;
|
|
8
8
|
/** Footer content, drawn in a styled bar below the body. */
|
|
9
9
|
footer?: Slot;
|
|
10
|
+
/** Aberdeen attr/style string applied to the body (content-holding) element. */
|
|
11
|
+
contentAttrs?: Attributes;
|
|
10
12
|
/** Aberdeen attr/style string applied to the header bar. */
|
|
11
|
-
|
|
13
|
+
headerAttrs?: Attributes;
|
|
12
14
|
/** Aberdeen attr/style string applied to the footer bar. */
|
|
13
|
-
|
|
15
|
+
footerAttrs?: Attributes;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
// The box itself is a `.panel` surface; its header/footer are `.raised`
|
|
19
|
+
// surfaces (classes set on the elements in `box()` below). Colours and borders
|
|
20
|
+
// come from the contextual tokens, so a box stays legible on whatever surface
|
|
21
|
+
// it's nested in.
|
|
16
22
|
A.insertGlobalCss({
|
|
17
|
-
".
|
|
18
|
-
"&": "display:flex flex-direction:column
|
|
19
|
-
"
|
|
20
|
-
">
|
|
21
|
-
|
|
23
|
+
".s-box": {
|
|
24
|
+
"&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
|
|
25
|
+
"&:not(:first-child)": "margin-top: $3",
|
|
26
|
+
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
|
|
27
|
+
"> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
|
|
22
28
|
"> div": "p:$3 gap:$3",
|
|
23
29
|
},
|
|
24
30
|
});
|
|
@@ -28,14 +34,14 @@ A.insertGlobalCss({
|
|
|
28
34
|
* header and footer, and a padded body that holds {@link ContentOptions.content}.
|
|
29
35
|
*
|
|
30
36
|
* The body gets default `padding` and matching `gap`; add `display:flex` via
|
|
31
|
-
* {@link
|
|
32
|
-
* flex container.
|
|
37
|
+
* {@link BoxOptions.contentAttrs | contentAttrs} if you want its children laid
|
|
38
|
+
* out as a flex container.
|
|
33
39
|
*
|
|
34
40
|
* Shortcut: pass a function to use it directly as the body content.
|
|
35
41
|
*
|
|
36
42
|
* @example
|
|
37
43
|
* ```ts
|
|
38
|
-
* S.box({ header: "Profile",
|
|
44
|
+
* S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
|
|
39
45
|
* S.textline({ label: "Name", bind: A.ref($user, "name") });
|
|
40
46
|
* }});
|
|
41
47
|
* S.box(() => A("p#Just some content")); // shorthand
|
|
@@ -44,19 +50,19 @@ A.insertGlobalCss({
|
|
|
44
50
|
export function box(opts: BoxOptions | Content = {}): void {
|
|
45
51
|
const o: BoxOptions = typeof opts === "function" ? { content: opts } : opts;
|
|
46
52
|
|
|
47
|
-
A("section.
|
|
53
|
+
A("section.s-box.s-s.panel", o.attrs, () => {
|
|
48
54
|
// Header and footer get their own scopes so toggling them doesn't recreate
|
|
49
55
|
// the body (which may hold focused inputs / lots of content).
|
|
50
56
|
A(() => {
|
|
51
|
-
if (o.header != null) A("header", o.
|
|
57
|
+
if (o.header != null) A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
|
|
52
58
|
});
|
|
53
59
|
|
|
54
|
-
A("div", o.
|
|
60
|
+
A("div", o.contentAttrs, () => {
|
|
55
61
|
if (o.content) o.content();
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
A(() => {
|
|
59
|
-
if (o.footer != null) A("footer", o.
|
|
65
|
+
if (o.footer != null) A("footer.s-s.raised", o.footerAttrs, () => drawSlot(o.footer));
|
|
60
66
|
});
|
|
61
67
|
});
|
|
62
68
|
}
|