staffa 0.19.0 → 0.22.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 +12 -2
- package/dist/components/autocomplete.d.ts +30 -1
- package/dist/components/autocomplete.js +63 -12
- package/dist/components/button.d.ts +24 -13
- package/dist/components/button.js +34 -20
- 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 +65 -0
- package/dist/components/textarea.d.ts +16 -3
- package/dist/components/textarea.js +36 -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/ButtonOptions.md +6 -4
- package/skill/IconButtonOptions.md +10 -7
- package/skill/InsetOptions.md +23 -0
- package/skill/SKILL.md +28 -2
- package/skill/TextareaOptions.md +4 -1
- package/skill/autocomplete.md +4 -0
- package/skill/dialog.md +3 -2
- package/skill/iconButton.md +3 -1
- package/skill/matchWords.md +17 -0
- package/skill/textarea.md +8 -0
- package/skill/textline.md +3 -0
- package/src/components/autocomplete.ts +78 -12
- package/src/components/button.ts +61 -31
- package/src/components/dialog.ts +33 -6
- package/src/components/field.ts +87 -0
- package/src/components/textarea.ts +38 -21
- package/src/components/textline.ts +15 -10
- package/src/index.ts +2 -2
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,29 @@ 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
|
+
"&": "position:relative display:grid --s-inset-start:0px --s-inset-end:0px",
|
|
77
|
+
"> .s-input": "padding-inline-start: calc(0.7em + var(--s-inset-start)); padding-inline-end: calc(0.7em + var(--s-inset-end));",
|
|
78
|
+
"> .s-inset_start, > .s-inset_end": "position:absolute top:0 bottom:0 display:flex align-items:center gap:$1 fg:$s-muted",
|
|
79
|
+
"> .s-inset_start": "left:0.35em",
|
|
80
|
+
"> .s-inset_end": "right:0.35em",
|
|
81
|
+
// On a textarea the insets ride the bottom edge, so a growing box keeps its
|
|
82
|
+
// send button where the caret is rather than floating it mid-paragraph.
|
|
83
|
+
"&.s-inset-bottom > .s-inset_start, &.s-inset-bottom > .s-inset_end": "top:auto padding-bottom:0.35em",
|
|
84
|
+
"> .s-inset_start > svg, > .s-inset_end > svg": "width:1.15em height:1.15em",
|
|
85
|
+
// A number input's spinner wants the very corner a trailing inset is in, and
|
|
86
|
+
// it is the inset that was asked for. (Firefox's is `appearance`-controlled.)
|
|
87
|
+
"&:has(> .s-inset_end) > input[type=number]": "appearance:textfield",
|
|
88
|
+
"&:has(> .s-inset_end) > input[type=number]::-webkit-inner-spin-button": "appearance:none margin:0",
|
|
89
|
+
},
|
|
90
|
+
// A plain glyph or a counter shouldn't eat the click that focuses the field;
|
|
91
|
+
// anything the user can actually operate does.
|
|
92
|
+
".s-inset_start, .s-inset_end": "pointer-events:none",
|
|
93
|
+
".s-inset_start :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
|
|
94
|
+
".s-inset_end :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
|
|
50
95
|
});
|
|
51
96
|
|
|
52
97
|
/**
|
|
@@ -111,3 +156,45 @@ export function applyControlAttrs(
|
|
|
111
156
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
112
157
|
if (bind) A("bind=", bind);
|
|
113
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Wrap a control in the {@link InsetOptions} box: the control as drawn by
|
|
162
|
+
* `drawControl`, plus the `prefix`/`suffix` slots laid over its leading and
|
|
163
|
+
* trailing edges.
|
|
164
|
+
*
|
|
165
|
+
* Each inset is measured (it may hold anything) and its width published to the
|
|
166
|
+
* wrapper as a CSS variable, from which the control takes its text padding — so
|
|
167
|
+
* the text never runs under the inset, whatever is in it. Each slot gets its own
|
|
168
|
+
* reactive scope, so appearing, changing or going away never touches the control
|
|
169
|
+
* element (which would lose focus and selection).
|
|
170
|
+
*
|
|
171
|
+
* @param opts The inset slots.
|
|
172
|
+
* @param bottom Align the insets with the control's bottom edge instead of
|
|
173
|
+
* centring them — what a growing textarea wants.
|
|
174
|
+
* @param drawControl Draws the control element itself, marked `s-input`.
|
|
175
|
+
*/
|
|
176
|
+
export function drawInsets(opts: InsetOptions, bottom: boolean, drawControl: () => void): void {
|
|
177
|
+
A("div.s-inset", () => {
|
|
178
|
+
if (bottom) A(".s-inset-bottom");
|
|
179
|
+
drawControl();
|
|
180
|
+
drawInset(() => opts.prefix, "s-inset_start", "--s-inset-start");
|
|
181
|
+
drawInset(() => opts.suffix, "s-inset_end", "--s-inset-end");
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function drawInset(get: () => Slot | undefined, cls: string, cssVar: string): void {
|
|
186
|
+
A(() => {
|
|
187
|
+
const slot = get();
|
|
188
|
+
if (slot == null) return;
|
|
189
|
+
// `.small` sizes the buttons inside (as a buttonGroup does), so something
|
|
190
|
+
// inset into a field can never stretch the field.
|
|
191
|
+
const box = A(`div.${cls}.small`, () => drawSlot(slot)) as HTMLElement;
|
|
192
|
+
const wrap = box.parentElement;
|
|
193
|
+
const ro = new ResizeObserver(() => wrap?.style.setProperty(cssVar, `${box.offsetWidth}px`));
|
|
194
|
+
ro.observe(box);
|
|
195
|
+
A.clean(() => {
|
|
196
|
+
ro.disconnect();
|
|
197
|
+
wrap?.style.setProperty(cssVar, "0px");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -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,7 +12,12 @@ 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
|
/** Auto-grow the textarea to fit its content. Defaults to `true`. */
|
|
18
23
|
autoGrow?: boolean;
|
|
@@ -35,31 +40,43 @@ A.insertGlobalCss({
|
|
|
35
40
|
* ```ts
|
|
36
41
|
* const $user = A.proxy({bio: ""});
|
|
37
42
|
* S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
|
|
43
|
+
*
|
|
44
|
+
* // A chat box: the send button sits in the bottom-right corner, inside the field.
|
|
45
|
+
* const $chat = A.proxy({text: ""});
|
|
46
|
+
* S.textarea({
|
|
47
|
+
* placeholder: "Message…",
|
|
48
|
+
* bind: A.ref($chat, "text"),
|
|
49
|
+
* suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: () => post($chat.text) }),
|
|
50
|
+
* });
|
|
38
51
|
* ```
|
|
39
52
|
*/
|
|
40
53
|
export function textarea(opts: TextareaOptions = {}): void {
|
|
41
54
|
const grow = opts.autoGrow !== false;
|
|
42
55
|
|
|
43
56
|
drawField(opts, (id, isInvalid) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// Insets ride the bottom edge here: on a growing box that keeps a send
|
|
58
|
+
// button beside the caret rather than floating it mid-paragraph.
|
|
59
|
+
drawInsets(opts, true, () => {
|
|
60
|
+
const el = A("textarea.s-input", opts.inputAttrs, () => {
|
|
61
|
+
if (grow) {
|
|
62
|
+
A(".s-autoGrow");
|
|
63
|
+
A("input=", (e: Event) => {
|
|
64
|
+
fitToContent(e.currentTarget as HTMLTextAreaElement);
|
|
65
|
+
if (opts.input) opts.input(e);
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
A("rows=", opts.rows ?? 4);
|
|
69
|
+
A("resize:", opts.resize ?? (opts.suffix ? "none" : "vertical"));
|
|
70
|
+
if (opts.input) A("input=", opts.input);
|
|
71
|
+
}
|
|
72
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
73
|
+
if (opts.value != null && !opts.bind) A("value=", opts.value);
|
|
74
|
+
if (opts.change) A("change=", opts.change);
|
|
75
|
+
applyControlAttrs(opts, id, isInvalid, opts.bind);
|
|
76
|
+
}) as HTMLTextAreaElement;
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
if (grow) requestAnimationFrame(() => fitToContent(el));
|
|
79
|
+
});
|
|
63
80
|
});
|
|
64
81
|
}
|
|
65
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
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
|
/**
|
|
6
6
|
* The `<input>` types {@link textline} supports. Deliberately excludes types
|
|
@@ -22,7 +22,7 @@ export type TextlineType =
|
|
|
22
22
|
| "week";
|
|
23
23
|
|
|
24
24
|
/** Options for {@link textline}. */
|
|
25
|
-
export interface TextlineOptions extends FieldOptions {
|
|
25
|
+
export interface TextlineOptions extends FieldOptions, InsetOptions {
|
|
26
26
|
/** Input type. Defaults to `"text"`. */
|
|
27
27
|
type?: TextlineType;
|
|
28
28
|
/** Placeholder text. */
|
|
@@ -48,18 +48,23 @@ export interface TextlineOptions extends FieldOptions {
|
|
|
48
48
|
* ```ts
|
|
49
49
|
* const $user = A.proxy({email: "test@example.com"});
|
|
50
50
|
* S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
|
|
51
|
+
*
|
|
52
|
+
* // Content inside the field: a glyph against its left edge, a button against its right.
|
|
53
|
+
* S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
|
|
51
54
|
* ```
|
|
52
55
|
*/
|
|
53
56
|
export function textline(opts: TextlineOptions = {}): void {
|
|
54
57
|
drawField(opts, (id, isInvalid) => {
|
|
55
|
-
|
|
56
|
-
A("
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
drawInsets(opts, false, () => {
|
|
59
|
+
A("input.s-input", opts.inputAttrs, () => {
|
|
60
|
+
A("type=", opts.type ?? "text");
|
|
61
|
+
if (opts.placeholder != null) A("placeholder=", opts.placeholder);
|
|
62
|
+
if (opts.autocomplete != null) A("autocomplete=", opts.autocomplete);
|
|
63
|
+
if (opts.value != null && !opts.bind) A("value=", opts.value);
|
|
64
|
+
if (opts.input) A("input=", opts.input);
|
|
65
|
+
if (opts.change) A("change=", opts.change);
|
|
66
|
+
applyControlAttrs(opts, id, isInvalid, opts.bind);
|
|
67
|
+
});
|
|
63
68
|
});
|
|
64
69
|
});
|
|
65
70
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
export { setDarkMode, getDarkMode } from "./theme.js";
|
|
35
35
|
export { formatKey, bindKey } from "./keys.js";
|
|
36
36
|
export { showKeyHelp, setKeyHelp } from "./components/keyhelp.js";
|
|
37
|
-
export { autocomplete, type AutocompleteOptions, type AutocompleteOptionInput } from "./components/autocomplete.js";
|
|
37
|
+
export { autocomplete, matchWords, type AutocompleteOptions, type AutocompleteOptionInput } from "./components/autocomplete.js";
|
|
38
38
|
export { box, type BoxOptions } from "./components/box.js";
|
|
39
39
|
export { button, iconButton, type ButtonOptions, type IconButtonOptions } from "./components/button.js";
|
|
40
40
|
export { buttonChooser, type ButtonChooserOptions } from "./components/buttonChooser.js";
|
|
@@ -51,7 +51,7 @@ export { textarea, type TextareaOptions } from "./components/textarea.js";
|
|
|
51
51
|
export { textline, type TextlineOptions, type TextlineType } from "./components/textline.js";
|
|
52
52
|
export { toast, type ToastOptions } from "./components/toast.js";
|
|
53
53
|
export { addTooltip, type TooltipOptions } from "./components/tooltip.js";
|
|
54
|
-
export type { FieldOptions } from "./components/field.js";
|
|
54
|
+
export type { FieldOptions, InsetOptions } from "./components/field.js";
|
|
55
55
|
|
|
56
56
|
// Re-export theming and shared types for advanced use.
|
|
57
57
|
export type {ContentOptions, Bindable, Slot, Attributes} from "./core.js";
|