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.
@@ -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
- /** Whether the textarea may be resized by the user. Defaults to `"vertical"`. Ignored when `autoGrow` is enabled. */
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
- const el = A("textarea.s-input", opts.inputAttrs, () => {
45
- if (grow) {
46
- A(".s-autoGrow");
47
- A("input=", (e: Event) => {
48
- fitToContent(e.currentTarget as HTMLTextAreaElement);
49
- if (opts.input) opts.input(e);
50
- });
51
- } else {
52
- A("rows=", opts.rows ?? 4);
53
- A("resize:", opts.resize ?? "vertical");
54
- if (opts.input) A("input=", opts.input);
55
- }
56
- if (opts.placeholder != null) A("placeholder=", opts.placeholder);
57
- if (opts.value != null && !opts.bind) A("value=", opts.value);
58
- if (opts.change) A("change=", opts.change);
59
- applyControlAttrs(opts, id, isInvalid, opts.bind);
60
- }) as HTMLTextAreaElement;
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
- if (grow) requestAnimationFrame(() => fitToContent(el));
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
- A("input.s-input", opts.inputAttrs, () => {
56
- A("type=", opts.type ?? "text");
57
- if (opts.placeholder != null) A("placeholder=", opts.placeholder);
58
- if (opts.autocomplete != null) A("autocomplete=", opts.autocomplete);
59
- if (opts.value != null && !opts.bind) A("value=", opts.value);
60
- if (opts.input) A("input=", opts.input);
61
- if (opts.change) A("change=", opts.change);
62
- applyControlAttrs(opts, id, isInvalid, opts.bind);
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";