staffa 0.20.0 → 0.22.1
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 +10 -0
- package/dist/components/autocomplete.d.ts +30 -1
- package/dist/components/autocomplete.js +63 -12
- package/dist/components/dialog.d.ts +3 -2
- package/dist/components/dialog.js +33 -6
- package/dist/components/field.d.ts +38 -0
- package/dist/components/field.js +70 -0
- package/dist/components/form.js +5 -0
- package/dist/components/textarea.d.ts +31 -4
- package/dist/components/textarea.js +54 -24
- package/dist/components/textline.d.ts +5 -2
- package/dist/components/textline.js +19 -14
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/staffa.esm.js +1 -1
- package/package.json +2 -2
- package/skill/AutocompleteOptions.md +15 -1
- package/skill/InsetOptions.md +23 -0
- package/skill/SKILL.md +26 -0
- package/skill/TextareaOptions.md +7 -2
- package/skill/autocomplete.md +4 -0
- package/skill/dialog.md +3 -2
- package/skill/matchWords.md +17 -0
- package/skill/textarea.md +18 -0
- package/skill/textline.md +3 -0
- package/src/components/autocomplete.ts +78 -12
- package/src/components/dialog.ts +33 -6
- package/src/components/field.ts +92 -0
- package/src/components/form.ts +5 -0
- package/src/components/textarea.ts +61 -22
- package/src/components/textline.ts +15 -10
- package/src/index.ts +2 -2
package/skill/SKILL.md
CHANGED
|
@@ -254,6 +254,16 @@ S.iconButton({ icon: trash2, tooltip: "Delete" }); // says "Delete" on hover,
|
|
|
254
254
|
|
|
255
255
|
A tooltip appears only where one is asked for; on an icon button, a string `tooltip` doubles as the `ariaLabel` when that is left out, so naming a glyph takes one option rather than two. A `key` is appended to whatever the tip says (`tooltip: false` keeps even that quiet), and — unlike a tooltip on a plain disabled `<button>`, which the browser gives no hover events — these show while disabled, which is where a tooltip earns its keep.
|
|
256
256
|
|
|
257
|
+
`S.textline` and `S.textarea` take a `prefix` and a `suffix` slot that put content *inside* the control — a search glyph, a unit, a clear or send button. The control's text keeps clear of whatever they hold, and on a textarea they ride the bottom edge, which is where a send button belongs:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
|
|
261
|
+
S.textarea({ placeholder: "Message…", bind: A.ref($chat, "text"),
|
|
262
|
+
suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: post }) });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A plain glyph there lets the click through to the field it decorates; a button or a link takes it. Tell the field how tall to be (`attrs: "h:20rem"`) and the textarea fills it, insets and all — a composer that fills its panel needs nothing else.
|
|
266
|
+
|
|
257
267
|
`src/index.ts` is the authoritative list of exports.
|
|
258
268
|
|
|
259
269
|
### Keyboard shortcuts
|
|
@@ -408,6 +418,15 @@ A combobox with type-ahead filtering. Supports single or multi-select (chips),
|
|
|
408
418
|
optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
409
419
|
backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
410
420
|
|
|
421
|
+
## [matchWords](matchWords.md) · function
|
|
422
|
+
|
|
423
|
+
The default type-ahead test: every whitespace-separated term of the query has
|
|
424
|
+
to match the label from a word start on, and no two terms may claim the same
|
|
425
|
+
word. Order doesn't matter, so "se pal" finds "Palette search" just as
|
|
426
|
+
"pal se" does — but "ette" finds nothing, as you type the beginnings of
|
|
427
|
+
words. A term may run past its word's end ("typescript" finds "TypeScript",
|
|
428
|
+
"c++" finds "C++"), so typing a label out in full always finds it.
|
|
429
|
+
|
|
411
430
|
## [AutocompleteOptions](AutocompleteOptions.md) · interface
|
|
412
431
|
|
|
413
432
|
Options for `autocomplete`.
|
|
@@ -803,6 +822,13 @@ autocomplete, ...). Every field lays out the same way — optional label,
|
|
|
803
822
|
control, optional help/error below — which is what lets `form` align
|
|
804
823
|
groups of them.
|
|
805
824
|
|
|
825
|
+
## [InsetOptions](InsetOptions.md) · interface
|
|
826
|
+
|
|
827
|
+
Options for a field that can carry content *inside* its control box — an
|
|
828
|
+
icon against the leading edge, a button against the trailing one. Added by
|
|
829
|
+
("./textline").textline and
|
|
830
|
+
("./textarea").textarea.
|
|
831
|
+
|
|
806
832
|
## [ContentOptions](ContentOptions.md) · interface
|
|
807
833
|
|
|
808
834
|
Options for components that wrap a single block of caller-provided content,
|
package/skill/TextareaOptions.md
CHANGED
|
@@ -28,13 +28,18 @@ Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled
|
|
|
28
28
|
|
|
29
29
|
### textareaOptions.resize · member
|
|
30
30
|
|
|
31
|
-
Whether the textarea may be resized by the user. Defaults to `"vertical"
|
|
31
|
+
Whether the textarea may be resized by the user. Defaults to `"vertical"`,
|
|
32
|
+
or to `"none"` when a | suffix is given — the
|
|
33
|
+
resize grip and the inset both want the bottom-right corner. Ignored when
|
|
34
|
+
`autoGrow` is enabled.
|
|
32
35
|
|
|
33
36
|
**Type:** `"none" | "vertical" | "horizontal" | "both"`
|
|
34
37
|
|
|
35
38
|
### textareaOptions.autoGrow · member
|
|
36
39
|
|
|
37
|
-
Auto-grow the textarea to fit its content. Defaults to `true`.
|
|
40
|
+
Auto-grow the textarea to fit its content. Defaults to `true`. Ignored when
|
|
41
|
+
the field is told how tall to be (| attrs
|
|
42
|
+
`"h:20rem"`), as that height wins over the one the text asks for.
|
|
38
43
|
|
|
39
44
|
**Type:** `boolean`
|
|
40
45
|
|
package/skill/autocomplete.md
CHANGED
|
@@ -4,6 +4,10 @@ A combobox with type-ahead filtering. Supports single or multi-select (chips),
|
|
|
4
4
|
optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
5
5
|
backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
6
6
|
|
|
7
|
+
What you type is matched against the start of the label's words, a term at a
|
|
8
|
+
time and in any order — "pal se" finds "Palette search" — see
|
|
9
|
+
`matchWords`, or `AutocompleteOptions.match` to filter your own way.
|
|
10
|
+
|
|
7
11
|
The suggestion list is portalled to `document.body`, so a dialog or a
|
|
8
12
|
scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
9
13
|
off whichever side of the field has the room, and follows it as things move.
|
package/skill/dialog.md
CHANGED
|
@@ -5,8 +5,9 @@ that fades in and out. Returns a `Promise<void>` that resolves when the dialog
|
|
|
5
5
|
closes. Lifecycle is also tied to the parent reactive scope — when that scope
|
|
6
6
|
is cleaned up the dialog disappears and the promise resolves.
|
|
7
7
|
|
|
8
|
-
Multiple dialogs stack
|
|
9
|
-
|
|
8
|
+
Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
|
|
9
|
+
z-index than the one it covers, while older dialogs are pushed behind their
|
|
10
|
+
covering backdrop.
|
|
10
11
|
|
|
11
12
|
**Signature:** `(opts: DialogOptions) => Promise<void>`
|
|
12
13
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
## matchWords · function
|
|
2
|
+
|
|
3
|
+
The default type-ahead test: every whitespace-separated term of the query has
|
|
4
|
+
to match the label from a word start on, and no two terms may claim the same
|
|
5
|
+
word. Order doesn't matter, so "se pal" finds "Palette search" just as
|
|
6
|
+
"pal se" does — but "ette" finds nothing, as you type the beginnings of
|
|
7
|
+
words. A term may run past its word's end ("typescript" finds "TypeScript",
|
|
8
|
+
"c++" finds "C++"), so typing a label out in full always finds it.
|
|
9
|
+
|
|
10
|
+
Pass it to `AutocompleteOptions.match` to compose with it.
|
|
11
|
+
|
|
12
|
+
**Signature:** `(label: string, query: string) => boolean`
|
|
13
|
+
|
|
14
|
+
**Parameters:**
|
|
15
|
+
|
|
16
|
+
- `label: string`
|
|
17
|
+
- `query: string`
|
package/skill/textarea.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
A multi-line text input. Shares the field chrome and styling of
|
|
4
4
|
`textline`.
|
|
5
5
|
|
|
6
|
+
It sizes itself to its text by default. Give the *field* a height instead
|
|
7
|
+
(| attrs `"h:20rem"`, or `"h:100%"` inside a column
|
|
8
|
+
that has one) and the textarea fills it, whatever else the field holds — a
|
|
9
|
+
label, help text, a `prefix`/`suffix` inset.
|
|
10
|
+
|
|
6
11
|
**Signature:** `(opts?: TextareaOptions) => void`
|
|
7
12
|
|
|
8
13
|
**Parameters:**
|
|
@@ -14,4 +19,17 @@ A multi-line text input. Shares the field chrome and styling of
|
|
|
14
19
|
```ts
|
|
15
20
|
const $user = A.proxy({bio: ""});
|
|
16
21
|
S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
|
|
22
|
+
|
|
23
|
+
// A chat box: the send button sits in the bottom-right corner, inside the field.
|
|
24
|
+
const $chat = A.proxy({text: ""});
|
|
25
|
+
S.textarea({
|
|
26
|
+
placeholder: "Message…",
|
|
27
|
+
bind: A.ref($chat, "text"),
|
|
28
|
+
suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: () => post($chat.text) }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Told how tall to be, the textarea fills the field instead of growing with
|
|
32
|
+
// its text — `h:100%` in a column that has a height makes a composer that
|
|
33
|
+
// fills whatever room it is given.
|
|
34
|
+
S.textarea({ label: "Notes", attrs: "h:20rem", bind: A.ref($chat, "text") });
|
|
17
35
|
```
|
package/skill/textline.md
CHANGED
|
@@ -15,4 +15,7 @@ chrome (label, control, help/error), so it aligns cleanly inside a `form`.
|
|
|
15
15
|
```ts
|
|
16
16
|
const $user = A.proxy({email: "test@example.com"});
|
|
17
17
|
S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
|
|
18
|
+
|
|
19
|
+
// Content inside the field: a glyph against its left edge, a button against its right.
|
|
20
|
+
S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
|
|
18
21
|
```
|
|
@@ -26,8 +26,22 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
26
26
|
bind?: Bindable<string | string[]>;
|
|
27
27
|
/** Allow selecting several values, shown as removable chips. */
|
|
28
28
|
multi?: boolean;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Allow committing free text that isn't in the options list. Defaults to
|
|
31
|
+
* `true`, and includes no text at all: emptying a single-select field clears
|
|
32
|
+
* its selection (`required` is what makes that an error). With
|
|
33
|
+
* `allowCustom: false` only the options can be committed, so anything else
|
|
34
|
+
* springs back to the current selection when the field loses focus.
|
|
35
|
+
*/
|
|
30
36
|
allowCustom?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The type-ahead test, run for each option against what has been typed.
|
|
39
|
+
* Defaults to {@link matchWords}. Pass your own to filter differently — say
|
|
40
|
+
* `(label, q) => label.toLowerCase().includes(q.toLowerCase())` for plain
|
|
41
|
+
* substring matching, or something that also looks at an option's other
|
|
42
|
+
* fields. Matching never reorders: options are shown as given.
|
|
43
|
+
*/
|
|
44
|
+
match?: (label: string, query: string) => boolean;
|
|
31
45
|
/** Placeholder for the text input. */
|
|
32
46
|
placeholder?: string;
|
|
33
47
|
}
|
|
@@ -110,6 +124,41 @@ mountPortal(() => {
|
|
|
110
124
|
sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
|
|
111
125
|
});
|
|
112
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Where each word of a label begins: runs of letters or digits, split again at
|
|
129
|
+
* camelCase humps — "TypeScript" starts a word at `T` and at `S`.
|
|
130
|
+
*/
|
|
131
|
+
function wordStarts(label: string): number[] {
|
|
132
|
+
const starts: number[] = [];
|
|
133
|
+
for (const m of label.matchAll(/\p{N}+|\p{Lu}+(?=\p{Lu}\p{Ll})|\p{Lu}?\p{Ll}+|\p{Lu}+/gu)) starts.push(m.index);
|
|
134
|
+
return starts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The default type-ahead test: every whitespace-separated term of the query has
|
|
139
|
+
* to match the label from a word start on, and no two terms may claim the same
|
|
140
|
+
* word. Order doesn't matter, so "se pal" finds "Palette search" just as
|
|
141
|
+
* "pal se" does — but "ette" finds nothing, as you type the beginnings of
|
|
142
|
+
* words. A term may run past its word's end ("typescript" finds "TypeScript",
|
|
143
|
+
* "c++" finds "C++"), so typing a label out in full always finds it.
|
|
144
|
+
*
|
|
145
|
+
* Pass it to {@link AutocompleteOptions.match} to compose with it.
|
|
146
|
+
*/
|
|
147
|
+
export function matchWords(label: string, query: string): boolean {
|
|
148
|
+
// Longest term first: it is the most constrained, so the greedy claim below
|
|
149
|
+
// doesn't let a short term take the word a long one needed.
|
|
150
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
151
|
+
if (!terms.length) return true;
|
|
152
|
+
const starts = wordStarts(label);
|
|
153
|
+
const used: boolean[] = [];
|
|
154
|
+
return terms.every((t) => {
|
|
155
|
+
const i = starts.findIndex((s, i) => !used[i] && label.slice(s, s + t.length).toLowerCase() === t);
|
|
156
|
+
if (i < 0) return false;
|
|
157
|
+
used[i] = true;
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
function normOption(o: AutocompleteOptionInput): AcOption {
|
|
114
163
|
return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
|
|
115
164
|
}
|
|
@@ -119,6 +168,10 @@ function normOption(o: AutocompleteOptionInput): AcOption {
|
|
|
119
168
|
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
120
169
|
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
121
170
|
*
|
|
171
|
+
* What you type is matched against the start of the label's words, a term at a
|
|
172
|
+
* time and in any order — "pal se" finds "Palette search" — see
|
|
173
|
+
* {@link matchWords}, or {@link AutocompleteOptions.match} to filter your own way.
|
|
174
|
+
*
|
|
122
175
|
* The suggestion list is portalled to `document.body`, so a dialog or a
|
|
123
176
|
* scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
124
177
|
* off whichever side of the field has the room, and follows it as things move.
|
|
@@ -154,6 +207,10 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
154
207
|
return Array.isArray(v) ? v : [v];
|
|
155
208
|
};
|
|
156
209
|
const labelFor = (value: string): string => getOptions().find((o) => o.value === value)?.label ?? value;
|
|
210
|
+
// What committing free text means: the option the text names, or the text itself.
|
|
211
|
+
// Typing a label out in full is picking that option, not inventing a value.
|
|
212
|
+
const valueForText = (text: string): string =>
|
|
213
|
+
text ? getOptions().find((o) => o.label.toLowerCase() === text.toLowerCase())?.value ?? text : "";
|
|
157
214
|
|
|
158
215
|
// Seed the input with the current single-selection's label.
|
|
159
216
|
if (!opts.multi) {
|
|
@@ -165,8 +222,9 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
165
222
|
const sel = new Set(selectedValues());
|
|
166
223
|
let list = getOptions();
|
|
167
224
|
if (opts.multi) list = list.filter((o) => !sel.has(o.value));
|
|
168
|
-
|
|
169
|
-
|
|
225
|
+
// Cased as typed: the matcher splits on camelCase humps, and lowercases itself.
|
|
226
|
+
const q = $st.query.trim();
|
|
227
|
+
if (q) list = list.filter((o) => (opts.match ?? matchWords)(o.label, q));
|
|
170
228
|
return list;
|
|
171
229
|
};
|
|
172
230
|
|
|
@@ -316,21 +374,27 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
316
374
|
} else if (e.key === "Enter") {
|
|
317
375
|
// Always prevent default to avoid accidental form submission.
|
|
318
376
|
e.preventDefault();
|
|
319
|
-
const
|
|
377
|
+
const q = $st.query.trim();
|
|
378
|
+
// Only a row of a list that is up: with it hidden there is no highlight to
|
|
379
|
+
// be seen, so Enter takes what has been typed instead.
|
|
380
|
+
const chosen = $st.open ? list[$st.active] : undefined;
|
|
320
381
|
if (chosen) {
|
|
321
382
|
commit(chosen.value, inputEl);
|
|
322
|
-
} else if (opts.allowCustom !== false &&
|
|
323
|
-
commit
|
|
383
|
+
} else if (opts.allowCustom !== false && (q || !opts.multi)) {
|
|
384
|
+
// No text is a commit too, in single mode: it clears the selection.
|
|
385
|
+
// (In multi mode there is nothing to clear, and no chip to make.)
|
|
386
|
+
commit(valueForText(q), inputEl);
|
|
324
387
|
} else if ($st.open) {
|
|
388
|
+
// Nothing to commit (the options are all there is, and none match).
|
|
325
389
|
$st.open = false;
|
|
326
390
|
}
|
|
327
391
|
} else if (e.key === "Escape") {
|
|
328
|
-
//
|
|
329
|
-
// layer,
|
|
392
|
+
// Escape hides the list and leaves what was typed standing — it dismisses
|
|
393
|
+
// a layer, it doesn't undo an edit. Only consumed while the list is up, so
|
|
394
|
+
// a surrounding dialog closes on the next press, not this one.
|
|
330
395
|
if ($st.open) {
|
|
331
396
|
e.preventDefault();
|
|
332
397
|
$st.open = false;
|
|
333
|
-
if (!opts.multi) $st.query = labelFor(selectedValues()[0] ?? "");
|
|
334
398
|
}
|
|
335
399
|
} else if (e.key === "Backspace" && opts.multi && $st.query === "") {
|
|
336
400
|
const sel = selectedValues();
|
|
@@ -342,10 +406,12 @@ export function autocomplete(opts: AutocompleteOptions): void {
|
|
|
342
406
|
$st.open = false;
|
|
343
407
|
if (opts.multi) {
|
|
344
408
|
$st.query = "";
|
|
345
|
-
} else if (opts.allowCustom !== false
|
|
346
|
-
|
|
409
|
+
} else if (opts.allowCustom !== false) {
|
|
410
|
+
// Free text stands as typed — nothing at all included, which is how a
|
|
411
|
+
// single selection is cleared. (`required` is what makes empty an error.)
|
|
412
|
+
commit(valueForText($st.query.trim()));
|
|
347
413
|
} else {
|
|
348
|
-
//
|
|
414
|
+
// Only the options exist, so anything else reverts to the selected one.
|
|
349
415
|
$st.query = labelFor(selectedValues()[0] ?? "");
|
|
350
416
|
}
|
|
351
417
|
}
|
package/src/components/dialog.ts
CHANGED
|
@@ -51,6 +51,9 @@ export interface DialogOptions {
|
|
|
51
51
|
onClose?: () => void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** The layer the bottom dialog's backdrop is painted on; each dialog adds two. */
|
|
55
|
+
const BASE_Z = 200;
|
|
56
|
+
|
|
54
57
|
A.insertGlobalCss({
|
|
55
58
|
".s-backdrop": {
|
|
56
59
|
"&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
|
|
@@ -80,6 +83,8 @@ A.insertGlobalCss({
|
|
|
80
83
|
const dialogs = A.proxy({} as Record<string,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
|
|
81
84
|
let dialogCount = 0;
|
|
82
85
|
|
|
86
|
+
// `Object.keys` orders integer-like keys numerically, so the last one is the
|
|
87
|
+
// newest dialog — the one on top.
|
|
83
88
|
const topDialogId = A.derive(() => {
|
|
84
89
|
const keys = Object.keys(dialogs);
|
|
85
90
|
if (keys.length) return keys[keys.length-1];
|
|
@@ -91,6 +96,9 @@ export function isDialogOpen(): boolean {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
mountPortal(() => {
|
|
99
|
+
// Sorted numerically: the keys are numbers, and left to `onEach`'s default
|
|
100
|
+
// string ordering "10" would sort before "2", putting the tenth dialog
|
|
101
|
+
// before (hence behind) a still-open second one.
|
|
94
102
|
A.onEach(dialogs, ({resolve, opts}, dialogId) => {
|
|
95
103
|
const close = () => { delete dialogs[dialogId]; };
|
|
96
104
|
|
|
@@ -109,9 +117,9 @@ mountPortal(() => {
|
|
|
109
117
|
|
|
110
118
|
// Backdrop - hide when not the top dialog
|
|
111
119
|
const overlaid = A.derive(() => topDialogId.value != dialogId);
|
|
112
|
-
A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
|
|
120
|
+
const backdropEl = A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
|
|
113
121
|
if (opts.allowCancel !== false) close();
|
|
114
|
-
});
|
|
122
|
+
}) as HTMLElement;
|
|
115
123
|
|
|
116
124
|
// Derived from the dialog's own key rather than a second counter: there is
|
|
117
125
|
// exactly one of these per dialog, for as long as the dialog exists.
|
|
@@ -164,10 +172,28 @@ mountPortal(() => {
|
|
|
164
172
|
});
|
|
165
173
|
}) as HTMLElement;
|
|
166
174
|
|
|
175
|
+
// Stacking is stated, not left to the order the elements happen to sit in
|
|
176
|
+
// the DOM: a dialog sits a layer above its own backdrop, and that pair a
|
|
177
|
+
// layer above the dialog it covers. (A closing dialog has left the stack
|
|
178
|
+
// already; it keeps the layer it had while it fades out.)
|
|
179
|
+
let depth = 0;
|
|
180
|
+
A(() => {
|
|
181
|
+
const index = Object.keys(dialogs).indexOf(dialogId);
|
|
182
|
+
if (index >= 0) depth = index;
|
|
183
|
+
backdropEl.style.zIndex = `${BASE_Z + 2*depth}`;
|
|
184
|
+
dialogEl.style.zIndex = `${BASE_Z + 2*depth + 1}`;
|
|
185
|
+
});
|
|
186
|
+
|
|
167
187
|
// Once laid out, move focus into the dialog so it's keyboard-ready and focus
|
|
168
188
|
// doesn't linger on whatever opened it.
|
|
169
|
-
requestAnimationFrame(() => {
|
|
170
|
-
|
|
189
|
+
requestAnimationFrame(() => {
|
|
190
|
+
// Only the top dialog claims focus: one opened *under* another (the
|
|
191
|
+
// covering dialog opened straight after it) must not pull focus into
|
|
192
|
+
// fields the user can't see.
|
|
193
|
+
if (A.peek(() => topDialogId.value) !== dialogId) return;
|
|
194
|
+
if (document.body.contains(dialogEl)) focusFirst(dialogEl);
|
|
195
|
+
});
|
|
196
|
+
}, (_value, dialogId) => +dialogId);
|
|
171
197
|
})
|
|
172
198
|
|
|
173
199
|
/**
|
|
@@ -192,8 +218,9 @@ function trapTab(dialogEl: HTMLElement, event: KeyboardEvent): void {
|
|
|
192
218
|
* closes. Lifecycle is also tied to the parent reactive scope — when that scope
|
|
193
219
|
* is cleaned up the dialog disappears and the promise resolves.
|
|
194
220
|
*
|
|
195
|
-
* Multiple dialogs stack
|
|
196
|
-
*
|
|
221
|
+
* Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
|
|
222
|
+
* z-index than the one it covers, while older dialogs are pushed behind their
|
|
223
|
+
* covering backdrop.
|
|
197
224
|
*
|
|
198
225
|
* @example
|
|
199
226
|
* ```ts
|
package/src/components/field.ts
CHANGED
|
@@ -31,6 +31,28 @@ export interface FieldOptions {
|
|
|
31
31
|
inputAttrs?: Attributes;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Options for a field that can carry content *inside* its control box — an
|
|
36
|
+
* icon against the leading edge, a button against the trailing one. Added by
|
|
37
|
+
* {@link import("./textline").textline} and
|
|
38
|
+
* {@link import("./textarea").textarea}.
|
|
39
|
+
*/
|
|
40
|
+
export interface InsetOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Content drawn inside the control, against its leading edge — typically a
|
|
43
|
+
* unit, a currency sign or a search glyph. The control's text is indented to
|
|
44
|
+
* keep clear of it, however wide it turns out to be.
|
|
45
|
+
*/
|
|
46
|
+
prefix?: Slot;
|
|
47
|
+
/**
|
|
48
|
+
* Content drawn inside the control, against its trailing edge: a *send*
|
|
49
|
+
* button on a chat box, a clear or reveal button, a character count. Sits
|
|
50
|
+
* against the bottom on a (growing) textarea, and vertically centred on a
|
|
51
|
+
* single-line input.
|
|
52
|
+
*/
|
|
53
|
+
suffix?: Slot;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
A.insertGlobalCss({
|
|
35
57
|
".s-field": {
|
|
36
58
|
"&": "display:flex flex-direction:column gap:$1",
|
|
@@ -47,6 +69,34 @@ A.insertGlobalCss({
|
|
|
47
69
|
"&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
|
|
48
70
|
"&[aria-invalid=true]": "border-color:$s-danger",
|
|
49
71
|
},
|
|
72
|
+
// Insets sit *over* the control rather than beside it, so the control keeps its
|
|
73
|
+
// own border, focus ring and full width; only its text padding gets out of the
|
|
74
|
+
// way, by however much each inset measures (see `drawInsets`).
|
|
75
|
+
".s-inset": {
|
|
76
|
+
// `display:contents` until an inset is actually there: the wrapper then has
|
|
77
|
+
// no box of its own, leaving the control the direct flex/grid child of the
|
|
78
|
+
// field that it was before insets existed — so a caller stretching the field
|
|
79
|
+
// (`attrs: "h:100%"` with `inputAttrs: "flex:1"`) still reaches the control.
|
|
80
|
+
"&": "display:contents --s-inset-start:0px --s-inset-end:0px",
|
|
81
|
+
"&:has(> .s-inset_start), &:has(> .s-inset_end)": "position:relative display:grid",
|
|
82
|
+
"> .s-input": "padding-inline-start: calc(0.7em + var(--s-inset-start)); padding-inline-end: calc(0.7em + var(--s-inset-end));",
|
|
83
|
+
"> .s-inset_start, > .s-inset_end": "position:absolute top:0 bottom:0 display:flex align-items:center gap:$1 fg:$s-muted",
|
|
84
|
+
"> .s-inset_start": "left:0.35em",
|
|
85
|
+
"> .s-inset_end": "right:0.35em",
|
|
86
|
+
// On a textarea the insets ride the bottom edge, so a growing box keeps its
|
|
87
|
+
// send button where the caret is rather than floating it mid-paragraph.
|
|
88
|
+
"&.s-inset-bottom > .s-inset_start, &.s-inset-bottom > .s-inset_end": "top:auto padding-bottom:0.35em",
|
|
89
|
+
"> .s-inset_start > svg, > .s-inset_end > svg": "width:1.15em height:1.15em",
|
|
90
|
+
// A number input's spinner wants the very corner a trailing inset is in, and
|
|
91
|
+
// it is the inset that was asked for. (Firefox's is `appearance`-controlled.)
|
|
92
|
+
"&:has(> .s-inset_end) > input[type=number]": "appearance:textfield",
|
|
93
|
+
"&:has(> .s-inset_end) > input[type=number]::-webkit-inner-spin-button": "appearance:none margin:0",
|
|
94
|
+
},
|
|
95
|
+
// A plain glyph or a counter shouldn't eat the click that focuses the field;
|
|
96
|
+
// anything the user can actually operate does.
|
|
97
|
+
".s-inset_start, .s-inset_end": "pointer-events:none",
|
|
98
|
+
".s-inset_start :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
|
|
99
|
+
".s-inset_end :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
|
|
50
100
|
});
|
|
51
101
|
|
|
52
102
|
/**
|
|
@@ -111,3 +161,45 @@ export function applyControlAttrs(
|
|
|
111
161
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
112
162
|
if (bind) A("bind=", bind);
|
|
113
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wrap a control in the {@link InsetOptions} box: the control as drawn by
|
|
167
|
+
* `drawControl`, plus the `prefix`/`suffix` slots laid over its leading and
|
|
168
|
+
* trailing edges.
|
|
169
|
+
*
|
|
170
|
+
* Each inset is measured (it may hold anything) and its width published to the
|
|
171
|
+
* wrapper as a CSS variable, from which the control takes its text padding — so
|
|
172
|
+
* the text never runs under the inset, whatever is in it. Each slot gets its own
|
|
173
|
+
* reactive scope, so appearing, changing or going away never touches the control
|
|
174
|
+
* element (which would lose focus and selection).
|
|
175
|
+
*
|
|
176
|
+
* @param opts The inset slots.
|
|
177
|
+
* @param bottom Align the insets with the control's bottom edge instead of
|
|
178
|
+
* centring them — what a growing textarea wants.
|
|
179
|
+
* @param drawControl Draws the control element itself, marked `s-input`.
|
|
180
|
+
*/
|
|
181
|
+
export function drawInsets(opts: InsetOptions, bottom: boolean, drawControl: () => void): void {
|
|
182
|
+
A("div.s-inset", () => {
|
|
183
|
+
if (bottom) A(".s-inset-bottom");
|
|
184
|
+
drawControl();
|
|
185
|
+
drawInset(() => opts.prefix, "s-inset_start", "--s-inset-start");
|
|
186
|
+
drawInset(() => opts.suffix, "s-inset_end", "--s-inset-end");
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function drawInset(get: () => Slot | undefined, cls: string, cssVar: string): void {
|
|
191
|
+
A(() => {
|
|
192
|
+
const slot = get();
|
|
193
|
+
if (slot == null) return;
|
|
194
|
+
// `.small` sizes the buttons inside (as a buttonGroup does), so something
|
|
195
|
+
// inset into a field can never stretch the field.
|
|
196
|
+
const box = A(`div.${cls}.small`, () => drawSlot(slot)) as HTMLElement;
|
|
197
|
+
const wrap = box.parentElement;
|
|
198
|
+
const ro = new ResizeObserver(() => wrap?.style.setProperty(cssVar, `${box.offsetWidth}px`));
|
|
199
|
+
ro.observe(box);
|
|
200
|
+
A.clean(() => {
|
|
201
|
+
ro.disconnect();
|
|
202
|
+
wrap?.style.setProperty(cssVar, "0px");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
package/src/components/form.ts
CHANGED
|
@@ -30,6 +30,11 @@ A.insertGlobalCss({
|
|
|
30
30
|
"&": "display:flex flex-direction:column gap:$3",
|
|
31
31
|
"&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
|
|
32
32
|
"&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
|
|
33
|
+
// Fields hug their content rather than being stretched to the tallest in
|
|
34
|
+
// their row. Invisible either way — a field has no fill of its own — but it
|
|
35
|
+
// keeps "this field has a height" meaning "the caller asked for one", which
|
|
36
|
+
// is what a textarea reads to decide whether to fill it.
|
|
37
|
+
"&.grid > .s-field": "align-self: start",
|
|
33
38
|
"> footer": "display:flex align-items:center justify-content:flex-end gap:$2 flex-wrap:wrap margin-top:$1",
|
|
34
39
|
},
|
|
35
40
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import type { Bindable } from "../core.js";
|
|
3
|
-
import { type FieldOptions, applyControlAttrs, drawField } from "./field.js";
|
|
3
|
+
import { type FieldOptions, type InsetOptions, applyControlAttrs, drawField, drawInsets } from "./field.js";
|
|
4
4
|
|
|
5
5
|
/** Options for {@link textarea}. */
|
|
6
|
-
export interface TextareaOptions extends FieldOptions {
|
|
6
|
+
export interface TextareaOptions extends FieldOptions, InsetOptions {
|
|
7
7
|
/** Placeholder text. */
|
|
8
8
|
placeholder?: string;
|
|
9
9
|
/** Two-way binding target. */
|
|
@@ -12,9 +12,18 @@ export interface TextareaOptions extends FieldOptions {
|
|
|
12
12
|
value?: string;
|
|
13
13
|
/** Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled. */
|
|
14
14
|
rows?: number;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Whether the textarea may be resized by the user. Defaults to `"vertical"`,
|
|
17
|
+
* or to `"none"` when a {@link InsetOptions.suffix | suffix} is given — the
|
|
18
|
+
* resize grip and the inset both want the bottom-right corner. Ignored when
|
|
19
|
+
* `autoGrow` is enabled.
|
|
20
|
+
*/
|
|
16
21
|
resize?: "none" | "vertical" | "horizontal" | "both";
|
|
17
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Auto-grow the textarea to fit its content. Defaults to `true`. Ignored when
|
|
24
|
+
* the field is told how tall to be ({@link FieldOptions.attrs | attrs}
|
|
25
|
+
* `"h:20rem"`), as that height wins over the one the text asks for.
|
|
26
|
+
*/
|
|
18
27
|
autoGrow?: boolean;
|
|
19
28
|
/** Fired on every `input` event. */
|
|
20
29
|
input?: (event: Event) => void;
|
|
@@ -23,6 +32,14 @@ export interface TextareaOptions extends FieldOptions {
|
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
A.insertGlobalCss({
|
|
35
|
+
// `flex-basis:100%` with nothing to stop it shrinking means "take the field's
|
|
36
|
+
// height, then give back whatever the label and help need" — so a field told
|
|
37
|
+
// how tall to be (`attrs: "h:8rem"`) hands that height to the textarea, while a
|
|
38
|
+
// field left to size itself (every ordinary one) has no definite height for the
|
|
39
|
+
// basis to resolve against and the textarea is content-sized exactly as before.
|
|
40
|
+
// The same rule rides the inset box below, which stands in for the control once
|
|
41
|
+
// a prefix/suffix gives it one.
|
|
42
|
+
"textarea.s-input, .s-inset.s-inset-bottom": "flex: 0 1 100%; min-height:0",
|
|
26
43
|
"textarea.s-input": "resize:vertical min-height:3em line-height:1.45",
|
|
27
44
|
"textarea.s-input.s-autoGrow": "resize:none min-height:2.5em overflow-y:hidden",
|
|
28
45
|
});
|
|
@@ -31,35 +48,57 @@ A.insertGlobalCss({
|
|
|
31
48
|
* A multi-line text input. Shares the field chrome and styling of
|
|
32
49
|
* {@link textline}.
|
|
33
50
|
*
|
|
51
|
+
* It sizes itself to its text by default. Give the *field* a height instead
|
|
52
|
+
* ({@link FieldOptions.attrs | attrs} `"h:20rem"`, or `"h:100%"` inside a column
|
|
53
|
+
* that has one) and the textarea fills it, whatever else the field holds — a
|
|
54
|
+
* label, help text, a `prefix`/`suffix` inset.
|
|
55
|
+
*
|
|
34
56
|
* @example
|
|
35
57
|
* ```ts
|
|
36
58
|
* const $user = A.proxy({bio: ""});
|
|
37
59
|
* S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
|
|
60
|
+
*
|
|
61
|
+
* // A chat box: the send button sits in the bottom-right corner, inside the field.
|
|
62
|
+
* const $chat = A.proxy({text: ""});
|
|
63
|
+
* S.textarea({
|
|
64
|
+
* placeholder: "Message…",
|
|
65
|
+
* bind: A.ref($chat, "text"),
|
|
66
|
+
* suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: () => post($chat.text) }),
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* // Told how tall to be, the textarea fills the field instead of growing with
|
|
70
|
+
* // its text — `h:100%` in a column that has a height makes a composer that
|
|
71
|
+
* // fills whatever room it is given.
|
|
72
|
+
* S.textarea({ label: "Notes", attrs: "h:20rem", bind: A.ref($chat, "text") });
|
|
38
73
|
* ```
|
|
39
74
|
*/
|
|
40
75
|
export function textarea(opts: TextareaOptions = {}): void {
|
|
41
76
|
const grow = opts.autoGrow !== false;
|
|
42
77
|
|
|
43
78
|
drawField(opts, (id, isInvalid) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
// Insets ride the bottom edge here: on a growing box that keeps a send
|
|
80
|
+
// button beside the caret rather than floating it mid-paragraph.
|
|
81
|
+
drawInsets(opts, true, () => {
|
|
82
|
+
const el = A("textarea.s-input", opts.inputAttrs, () => {
|
|
83
|
+
if (grow) {
|
|
84
|
+
A(".s-autoGrow");
|
|
85
|
+
A("input=", (e: Event) => {
|
|
86
|
+
fitToContent(e.currentTarget as HTMLTextAreaElement);
|
|
87
|
+
if (opts.input) opts.input(e);
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
A("rows=", opts.rows ?? 4);
|
|
91
|
+
A("resize:", opts.resize ?? (opts.suffix ? "none" : "vertical"));
|
|
92
|
+
if (opts.input) A("input=", opts.input);
|
|
93
|
+
}
|
|
94
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
95
|
+
if (opts.value != null && !opts.bind) A("value=", opts.value);
|
|
96
|
+
if (opts.change) A("change=", opts.change);
|
|
97
|
+
applyControlAttrs(opts, id, isInvalid, opts.bind);
|
|
98
|
+
}) as HTMLTextAreaElement;
|
|
61
99
|
|
|
62
|
-
|
|
100
|
+
if (grow) requestAnimationFrame(() => fitToContent(el));
|
|
101
|
+
});
|
|
63
102
|
});
|
|
64
103
|
}
|
|
65
104
|
|