staffa 0.9.0 → 0.10.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.
Files changed (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -0,0 +1,28 @@
1
+ ## scrollStrip · function
2
+
3
+ A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
4
+ button appearing over whichever end still has something left to reach — a
5
+ bare scroll area says nothing about itself to a mouse, and a scrollbar under
6
+ a row of chrome reads as a mistake. The row's own scrollbar is hidden, and
7
+ the buttons scroll it by most of a width at a time.
8
+
9
+ This is what `tabs` puts its tab strip in, and what the routed
10
+ `main` shell puts its breadcrumb stack in. Reach for it for any row of
11
+ chrome that can outgrow its space: a filter bar, a row of chips, a toolbar.
12
+ To bring one of its children into view — after selecting it from elsewhere,
13
+ say — call `revealInStrip` with that child.
14
+
15
+ **Signature:** `(opts: ScrollStripOptions) => void`
16
+
17
+ **Parameters:**
18
+
19
+ - `opts: ScrollStripOptions`
20
+
21
+ **Examples:**
22
+
23
+ ```ts
24
+ S.scrollStrip({
25
+ attrs: "gap:$1",
26
+ content: () => { for (const tag of tags) S.button({ content: tag, attrs: ".small" }); },
27
+ });
28
+ ```
@@ -161,7 +161,7 @@ export function autocomplete(opts: AutocompleteOptions): void {
161
161
  });
162
162
 
163
163
  inputEl = A("input type=text role=combobox autocomplete=off", () => {
164
- A(`id=${id} aria-controls=${menuId} aria-autocomplete=list`);
164
+ A("id=", id, `aria-controls=${menuId} aria-autocomplete=list`);
165
165
  if (opts.placeholder != null) A("placeholder=", opts.placeholder);
166
166
  if (opts.disabled) A("disabled=true");
167
167
  if (opts.required) A("aria-required=true");
@@ -1,6 +1,7 @@
1
1
  import A from "aberdeen";
2
2
  import { type ContentOptions, type Slot, type Attributes, drawSlot } from "../core.js";
3
- import { closeContainingPanel } from "./panels.js";
3
+ import { x as closeIcon } from "../icons.js";
4
+ import { iconButton } from "./button.js";
4
5
 
5
6
  /** Options for {@link box}. */
6
7
  export interface BoxOptions extends ContentOptions {
@@ -9,21 +10,16 @@ export interface BoxOptions extends ContentOptions {
9
10
  /** Footer content, drawn in a styled bar below the body. */
10
11
  footer?: Slot;
11
12
  /**
12
- * Draws a small ✕ button in the box's top-right corner: in the header row when
13
+ * Draws a small ✕ button in the box's top-right corner — in the header row when
13
14
  * there is a {@link BoxOptions.header | header}, floating over the body when
14
- * there isn't.
15
+ * there isn't — and runs this when it's clicked.
15
16
  *
16
- * `true` closes the panel the box is drawn in, which is how a screen of a
17
- * routed `S.main()` gives the user a way back (the shell draws no back
18
- * arrows or ✕ of its own). Which panel that is gets worked out from the DOM
19
- * when it's clicked, so the box needs no `$page` handed to it and works from
20
- * any column, top of the stack or not. A box in a column further left closes
21
- * just that column and leaves the others alone. Outside a routed shell it
22
- * does nothing but warn.
23
- *
24
- * Pass a function to run that instead, for a dismissal of your own.
17
+ * It is plain furniture: a box that happens to sit in a page of a routed
18
+ * `S.main()` does **not** close that page — the shell's breadcrumbs are the
19
+ * way out of those. Wire it to `$panel.close()` yourself if a box really is
20
+ * the whole page and wants its own ✕.
25
21
  */
26
- close?: boolean | (() => void);
22
+ close?: () => void;
27
23
  /** Aberdeen attr/style string applied to the body (content-holding) element. */
28
24
  contentAttrs?: Attributes;
29
25
  /** Aberdeen attr/style string applied to the header bar. */
@@ -49,16 +45,11 @@ A.insertGlobalCss({
49
45
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
50
46
  "> 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",
51
47
  "> div": "p:$3 gap:$3",
52
- // The ✕: quiet until you're near it, and drawn in the surface's own tokens
53
- // so it works on whatever the box was recoloured to. `margin-left:auto`
54
- // parks it at the far end of the header's flex row.
55
- ".s-box-close":
56
- "flex-shrink:0 margin-left:auto display:flex align-items:center justify-content:center " +
57
- "width:1.6rem height:1.6rem p:0 border:0 background:transparent cursor:pointer " +
58
- "fg:$s-muted font-size:0.95rem line-height:1 r:$s-radius-sm " +
59
- "transition: color 0.12s, background 0.12s;",
60
- ".s-box-close:hover": "fg:$s-text background: color-mix(in srgb, $s-text 8%, transparent);",
61
- // Without a header there is no row to sit in, so it floats over the body.
48
+ // The ✕ is an `S.iconButton` (see `drawCloseButton`); this only places it.
49
+ // In a header row it parks at the far end...
50
+ "> header > .s-box-close": "margin-left:auto",
51
+ // ...and without a header there is no row to sit in, so it floats over the
52
+ // body's top-right corner instead.
62
53
  "> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
63
54
  },
64
55
  });
@@ -73,9 +64,6 @@ A.insertGlobalCss({
73
64
  *
74
65
  * Shortcut: pass a function to use it directly as the body content.
75
66
  *
76
- * {@link BoxOptions.close | `close: true`} adds a ✕ that closes the panel the box
77
- * is drawn in: the usual way back out of a screen in a routed `S.main()`.
78
- *
79
67
  * @example
80
68
  * ```ts
81
69
  * const $user = A.proxy({name: "Kvothe"});
@@ -83,7 +71,7 @@ A.insertGlobalCss({
83
71
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
84
72
  * }});
85
73
  * S.box(() => A("p#Just some content")); // shorthand
86
- * S.box({ header: "Task 42", close: true, content: drawTask }); // ✕ closes this panel
74
+ * S.box({ header: "Draft", close: () => discard(), content: drawDraft }); // ✕ runs discard()
87
75
  * ```
88
76
  */
89
77
  export function box(opts: BoxOptions | Slot = {}): void {
@@ -93,12 +81,15 @@ export function box(opts: BoxOptions | Slot = {}): void {
93
81
  // Header and footer get their own scopes so toggling them doesn't recreate
94
82
  // the body (which may hold focused inputs / lots of content).
95
83
  A(() => {
84
+ // The typeof guards against v0.9's `close: true` (removed API) reaching
85
+ // us from unchecked JS: a ✕ whose handler isn't a function would render
86
+ // but do nothing, which is worse than not rendering at all.
96
87
  if (o.header != null) {
97
88
  A("header.s-s.neutral", o.headerAttrs, () => {
98
89
  drawSlot(o.header);
99
- if (o.close) drawCloseButton(o.close);
90
+ if (typeof o.close === "function") drawCloseButton(o.close);
100
91
  });
101
- } else if (o.close) {
92
+ } else if (typeof o.close === "function") {
102
93
  drawCloseButton(o.close);
103
94
  }
104
95
  });
@@ -114,16 +105,15 @@ export function box(opts: BoxOptions | Slot = {}): void {
114
105
  }
115
106
 
116
107
  /**
117
- * The box's ✕. With `close: true` the panel to close is resolved from the DOM at
118
- * click time — so one box can close whichever column it happens to be drawn in,
119
- * and a box outside a routed shell simply warns.
108
+ * The box's ✕: one definition, so the glyph, the label and the hit area are
109
+ * identical whether it sits in the header row or floats over a headerless body.
110
+ * The `.s-box-close` class is only a hook for the placement rules above.
120
111
  */
121
- function drawCloseButton(close: boolean | (() => void)): void {
122
- A("button.s-box-close type=button aria-label=Close", () => {
123
- A("click=", (e: Event) => {
124
- if (typeof close === "function") close();
125
- else void closeContainingPanel(e.currentTarget as HTMLElement);
126
- });
127
- A("span aria-hidden=true #✕");
112
+ function drawCloseButton(close: () => void): void {
113
+ iconButton({
114
+ icon: closeIcon,
115
+ ariaLabel: "Close",
116
+ click: close,
117
+ attrs: ".s-box-close",
128
118
  });
129
119
  }
@@ -1,6 +1,26 @@
1
1
  import A from "aberdeen";
2
2
  import { type Slot, type Attributes, drawSlot } from "../core.js";
3
3
 
4
+ /** Options for {@link iconButton}. */
5
+ export interface IconButtonOptions {
6
+ /** The glyph, usually one of the `staffa/icons` draw functions. */
7
+ icon: Slot;
8
+ /** What it does, for screen readers. Required: there is no visible text to read. */
9
+ ariaLabel: string;
10
+ /** Click handler. */
11
+ click?: (event: Event) => void;
12
+ /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
13
+ href?: string;
14
+ /** Disables it. */
15
+ disabled?: boolean;
16
+ /**
17
+ * Aberdeen attr/style string applied to the button. `.small` and `.large`
18
+ * size the hit area (medium is the default and needs no class); a `.small`
19
+ * or `.large` parent sizes the ones inside it, as with {@link button}.
20
+ */
21
+ attrs?: Attributes;
22
+ }
23
+
4
24
  /** Options for {@link button}. */
5
25
  export interface ButtonOptions {
6
26
  /** Button content: a string for plain text, or a function for custom markup. */
@@ -51,6 +71,11 @@ A.insertGlobalCss({
51
71
  // button (which is already near-white) darkens toward its ink instead.
52
72
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
53
73
  "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
74
+ // The button sizes its glyph, for the same reason `.s-icon-btn` does below:
75
+ // a caller can't know what the button beside it passed, and only a rule
76
+ // here makes every icon in a row come out alike. It rides the font size,
77
+ // so a `.small`/`.large` button scales its icon with its text.
78
+ "> svg": "width:1.25em height:1.25em",
54
79
  // Subtle press feedback.
55
80
  "&:active:not(:disabled)": "transform: translateY(1px)",
56
81
  // Size: set on the button itself, or inherited from a `.small`/`.large`
@@ -58,8 +83,91 @@ A.insertGlobalCss({
58
83
  "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
59
84
  "&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
60
85
  },
86
+ // A bare glyph in a square hit area: no fill and no edge, just ink that lifts
87
+ // on hover. Deliberately *not* a `.s-s` surface — chrome that sits beside a
88
+ // title (a ✕, a ☰) should read as an affordance on the bar, not as
89
+ // another button competing with it, and a filled or outlined box around a
90
+ // 16px glyph is exactly what makes a top bar look busy.
91
+ ".s-icon-btn": {
92
+ "&":
93
+ "display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
94
+ "width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
95
+ "fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
96
+ "transition: color 0.12s, background 0.12s;",
97
+ // The container sizes the glyph, rather than trusting whatever the caller
98
+ // passed: a row of icon buttons only reads as a row when every glyph in it
99
+ // is the same size, and the caller of one of them can't know about the
100
+ // others. CSS beats the `width`/`height` attributes the icon set writes, so
101
+ // `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
102
+ // `attrs` override still wins over this, being an inline style. The same
103
+ // rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
104
+ // one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
105
+ // of it — see the note there.)
106
+ "> svg": "width:1.25em height:1.25em",
107
+ // The ink resolves against whatever surface it sits on, so one treatment
108
+ // works on the page, in a box header, and on a coloured bar alike.
109
+ "&:hover:not(:disabled):not([aria-disabled=true])":
110
+ "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
111
+ "&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
112
+ // The glyph rides the font size, so it scales with the hit area.
113
+ "&.small, .small > &": "width:1.6rem height:1.6rem font-size:0.8rem",
114
+ "&.large, .large > &": "width:2.4rem height:2.4rem font-size:1.2rem",
115
+ },
61
116
  });
62
117
 
118
+ /**
119
+ * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
120
+ * hover. The quiet end of the button family, for chrome that has to sit beside
121
+ * something more important without competing with it: a ✕ on a box, the ☰ a
122
+ * routed `S.main()` puts in its top bar, the verbs in a
123
+ * {@link Panel.actions | page's actions}.
124
+ *
125
+ * Reach for {@link button} instead whenever the thing has a name worth reading;
126
+ * an icon alone is only unambiguous for a handful of universal actions.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * import { trash2, share2 } from "staffa/icons";
131
+ *
132
+ * $panel.actions = () => {
133
+ * S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
134
+ * S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
135
+ * };
136
+ * ```
137
+ */
138
+ export function iconButton(opts: IconButtonOptions): void {
139
+ const tag = opts.href != null ? "a" : "button";
140
+ A(`${tag}.s-icon-btn`, opts.attrs, () => {
141
+ applyActionBehavior(opts);
142
+ A("aria-label=", opts.ariaLabel);
143
+ drawSlot(opts.icon);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * The link-or-button plumbing {@link button} and {@link iconButton} share:
149
+ * href/type, disabling, label and click. A disabled link keeps `role=button`
150
+ * and `aria-disabled` but loses its `href` — an anchor without one is out of
151
+ * the tab order and follows nothing, which is what makes it as disabled as
152
+ * the `<button>` form's real `disabled` attribute.
153
+ */
154
+ function applyActionBehavior(o: {
155
+ href?: string;
156
+ disabled?: boolean;
157
+ click?: (event: Event) => void;
158
+ type?: string;
159
+ }): void {
160
+ if (o.href != null) {
161
+ A("role=button");
162
+ if (o.disabled) A("aria-disabled=true");
163
+ else A("href=", o.href);
164
+ } else {
165
+ A("type=", o.type ?? "button");
166
+ if (o.disabled) A("disabled=true");
167
+ }
168
+ if (o.click && !o.disabled) A("click=", o.click);
169
+ }
170
+
63
171
  /**
64
172
  * A button. Tonal and outlined variants show a border; filled variants rely on
65
173
  * their solid background for affordance.
@@ -94,15 +202,8 @@ export function button(opts: ButtonOptions | Slot = {}): void {
94
202
  // role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
95
203
  // role detection needed, since the default lives in CSS, not here.
96
204
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
97
- if (o.href != null) {
98
- A(`href=${o.href} role=button`);
99
- if (o.disabled) A("aria-disabled=true");
100
- } else {
101
- A("type=", o.type ?? "button");
102
- if (o.disabled) A("disabled=true");
103
- }
205
+ applyActionBehavior(o);
104
206
  if (o.ariaLabel) A("aria-label=", o.ariaLabel);
105
- if (o.click) A("click=", o.click);
106
207
 
107
208
  drawSlot(o.icon);
108
209
  drawSlot(o.content);
@@ -62,6 +62,6 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
62
62
 
63
63
  if (opts.name) {
64
64
  // Hidden input carries the value into native form submission.
65
- A(() => A(`input type=hidden name=${opts.name} value=`, opts.bind.value ?? ""));
65
+ A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
66
66
  }
67
67
  }
@@ -40,10 +40,10 @@ export function checkbox(opts: CheckboxOptions = {}): void {
40
40
  const id = opts.id ?? uniqueId("check");
41
41
 
42
42
  A("div.s-check", opts.attrs, () => {
43
- A(`label for=${id}`, () => {
43
+ A("label for=", id, () => {
44
44
  A("input type=checkbox", opts.inputAttrs, () => {
45
- A(`id=${id}`);
46
- if (opts.name) A(`name=${opts.name}`);
45
+ A("id=", id);
46
+ if (opts.name) A("name=", opts.name);
47
47
  // `checked` is a boolean attribute: only set it when actually true.
48
48
  if (opts.checked && !opts.bind) A("checked=true");
49
49
  if (opts.change) A("change=", opts.change);
@@ -75,7 +75,7 @@ export function drawField(
75
75
  A("div.s-field", opts.attrs, () => {
76
76
  A(() => {
77
77
  if (opts.label != null) {
78
- A(`label for=${id}`, () => {
78
+ A("label for=", id, () => {
79
79
  drawSlot(opts.label);
80
80
  if (opts.required) A("span.s-req aria-hidden=true #*");
81
81
  });
@@ -104,8 +104,8 @@ export function applyControlAttrs(
104
104
  isInvalid: () => boolean,
105
105
  bind?: Bindable<unknown>,
106
106
  ): void {
107
- A(`id=${id}`);
108
- if (opts.name) A(`name=${opts.name}`);
107
+ A("id=", id);
108
+ if (opts.name) A("name=", opts.name);
109
109
  A(() => {
110
110
  if (opts.disabled) A("disabled=true");
111
111
  });