@marianmeres/stuic 3.168.0 → 3.169.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/dist/README.md CHANGED
@@ -43,7 +43,7 @@ npm install @marianmeres/stuic
43
43
  - **FieldRadios** - Radio button group
44
44
  - **FieldFile** - File upload input
45
45
  - **FieldAssets** - Multi-file upload with preview
46
- - **FieldOptions** - Modal-based multi-select picker
46
+ - **FieldOptions** - Modal-based multi-select picker (optional inline `chips` display)
47
47
  - **FieldKeyValues** - Key-value pairs editor with JSON serialization
48
48
  - **FieldSwitch** - Toggle switch within a form
49
49
  - **Fieldset** - Group of form fields with legend
@@ -20,6 +20,7 @@
20
20
  type ValidationResult,
21
21
  } from "../../actions/validate.svelte.js";
22
22
  import type { TranslateFn } from "../../types.js";
23
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
23
24
  import { getId } from "../../utils/get-id.js";
24
25
  import { isPlainObject } from "../../utils/is-plain-object.js";
25
26
  import { maybeJsonParse } from "../../utils/maybe-json-parse.js";
@@ -36,6 +37,7 @@
36
37
  import X from "../X/X.svelte";
37
38
  import InputWrap from "./_internal/InputWrap.svelte";
38
39
  import FieldLikeButton from "./FieldLikeButton.svelte";
40
+ import FieldLikeChips, { type FieldLikeChip } from "./_internal/FieldLikeChips.svelte";
39
41
  import ListItemButton from "../ListItemButton/ListItemButton.svelte";
40
42
  import type { InputWrapClassProps } from "./types.js";
41
43
 
@@ -90,6 +92,18 @@
90
92
  * serialized to `value` on submit. No-op for single-select. Default `false`.
91
93
  */
92
94
  ordered?: boolean;
95
+ /**
96
+ * Opt-in: render the current selection as inline, individually removable chips
97
+ * (`Pill`s) instead of the comma-joined text button. Picking still happens in the
98
+ * modal (opened by the trailing button, or by clicking the empty part of the row);
99
+ * removing a chip writes `value` immediately, without the modal. `renderValue` is
100
+ * ignored in this mode (labels come from `renderOptionLabel`). Default `false`.
101
+ */
102
+ chips?: boolean;
103
+ /** Classes for each chip (`chips` mode only) */
104
+ classChip?: string;
105
+ /** Pill intent of the chips (`chips` mode only) */
106
+ chipIntent?: IntentColorKey;
93
107
  showIconsCheckbox?: boolean;
94
108
  showIconsRadio?: boolean;
95
109
  searchPlaceholder?: string;
@@ -124,6 +138,10 @@
124
138
  no_results: "No results found.",
125
139
  add_new: 'Add "{{value}}"...',
126
140
  click_add_new: "You must add the value to continue",
141
+ // chips display mode
142
+ chips_placeholder: "Nothing selected",
143
+ chips_open: "Choose...",
144
+ chips_remove: "Remove {{value}}",
127
145
  //
128
146
  pick_tab: "Pick",
129
147
  arrange_tab: "Arrange ({{value}})",
@@ -214,6 +232,9 @@
214
232
  renderOptionGroup = (s: string) => `${s}`.replaceAll("_", " "),
215
233
  allowUnknown = false,
216
234
  ordered = false,
235
+ chips = false,
236
+ classChip,
237
+ chipIntent,
217
238
  showIconsCheckbox = true,
218
239
  showIconsRadio = false,
219
240
  searchPlaceholder,
@@ -229,8 +250,9 @@
229
250
  modal = modalDialog;
230
251
  });
231
252
 
232
- // Imperative API delegates to the inner FieldLikeButton trigger.
233
- let triggerRef: FieldLikeButton | undefined = $state();
253
+ // Imperative API delegates to the inner trigger (FieldLikeButton, or FieldLikeChips in
254
+ // `chips` mode) both expose the same methods.
255
+ let triggerRef: FieldLikeButton | FieldLikeChips | undefined = $state();
234
256
 
235
257
  /** Trigger validation now. Renders the inline message if invalid. */
236
258
  export function validate(): ValidationResult | undefined {
@@ -604,6 +626,38 @@
604
626
  });
605
627
  }
606
628
 
629
+ // --- chips (inline display of the selection) ---
630
+
631
+ // straight from `value`, in value order — so with `ordered` the arranged order is what
632
+ // the user sees; labels go through the same renderer as the modal's options
633
+ let chipItems: FieldLikeChip[] = $derived.by(() => {
634
+ if (!chips) return [];
635
+ const parsed = maybeJsonParse(value || "[]");
636
+ if (!Array.isArray(parsed)) return [];
637
+ return parsed
638
+ .filter((item) => item != null)
639
+ .map((item: Item, i: number) => {
640
+ const label = _renderOptionLabel(item);
641
+ return {
642
+ // index-suffixed so a (malformed) duplicate id can't break the keyed each
643
+ key: `${item[itemIdPropName] ?? ""}__${i}`,
644
+ label,
645
+ removeLabel: t("chips_remove", { value: label }),
646
+ };
647
+ });
648
+ });
649
+
650
+ // removing a chip is an immediate, modal-less edit of `value` + the same change
651
+ // notification the modal submit does (so validation re-runs)
652
+ function removeChip(_chip: FieldLikeChip, index: number) {
653
+ const parsed = maybeJsonParse(value || "[]");
654
+ if (!Array.isArray(parsed)) return;
655
+ const items = parsed.filter((item) => item != null);
656
+ value = JSON.stringify(items.filter((_, i) => i !== index));
657
+ _dispatch_change_to_owner();
658
+ onChange?.(value);
659
+ }
660
+
607
661
  // "outer" submit - will set the outer bound value (always string) and close modal...
608
662
  // further process is left on the consumer
609
663
  function submit() {
@@ -712,6 +766,42 @@
712
766
  <div>
713
767
  {#if trigger}
714
768
  {@render trigger({ value, modal: modalDialog })}
769
+ {:else if chips}
770
+ <FieldLikeChips
771
+ bind:this={triggerRef}
772
+ bind:value
773
+ bind:input={parentHiddenInputEl}
774
+ chips={chipItems}
775
+ {name}
776
+ class={classProp}
777
+ {label}
778
+ {description}
779
+ {labelLeft}
780
+ {labelAfter}
781
+ {below}
782
+ {labelLeftWidth}
783
+ {labelLeftBreakpoint}
784
+ {classLabel}
785
+ {classLabelBox}
786
+ {classInputBox}
787
+ {classInputBoxWrap}
788
+ {classInputBoxWrapInvalid}
789
+ {classDescBox}
790
+ {classDescBoxToggle}
791
+ {classBelowBox}
792
+ {classValidationBox}
793
+ {style}
794
+ validate={wrappedValidate}
795
+ {required}
796
+ {disabled}
797
+ {tabindex}
798
+ {classChip}
799
+ {chipIntent}
800
+ placeholder={t("chips_placeholder")}
801
+ openLabel={t("chips_open")}
802
+ onOpen={() => modalDialog?.open()}
803
+ onRemove={removeChip}
804
+ />
715
805
  {:else}
716
806
  <FieldLikeButton
717
807
  bind:this={triggerRef}
@@ -2,6 +2,7 @@ import { type Item } from "@marianmeres/item-collection";
2
2
  import { type Snippet } from "svelte";
3
3
  import { type ValidateOptions, type ValidationResult } from "../../actions/validate.svelte.js";
4
4
  import type { TranslateFn } from "../../types.js";
5
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
5
6
  import { ModalDialog } from "../ModalDialog/index.js";
6
7
  import { NotificationsStack } from "../Notifications/index.js";
7
8
  import type { THC } from "../Thc/Thc.svelte";
@@ -62,6 +63,18 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
62
63
  * serialized to `value` on submit. No-op for single-select. Default `false`.
63
64
  */
64
65
  ordered?: boolean;
66
+ /**
67
+ * Opt-in: render the current selection as inline, individually removable chips
68
+ * (`Pill`s) instead of the comma-joined text button. Picking still happens in the
69
+ * modal (opened by the trailing button, or by clicking the empty part of the row);
70
+ * removing a chip writes `value` immediately, without the modal. `renderValue` is
71
+ * ignored in this mode (labels come from `renderOptionLabel`). Default `false`.
72
+ */
73
+ chips?: boolean;
74
+ /** Classes for each chip (`chips` mode only) */
75
+ classChip?: string;
76
+ /** Pill intent of the chips (`chips` mode only) */
77
+ chipIntent?: IntentColorKey;
65
78
  showIconsCheckbox?: boolean;
66
79
  showIconsRadio?: boolean;
67
80
  searchPlaceholder?: string;
@@ -408,48 +408,54 @@ Components use data attributes for CSS styling:
408
408
 
409
409
  ## FieldOptions
410
410
 
411
- A modal-based multi-select/single-select component with search functionality, typeahead support, and option grouping.
411
+ A modal-based multi-select/single-select component with search functionality, typeahead support, and option grouping. The closed field can show the selection as inline removable chips (`chips`).
412
412
 
413
413
  ### Props
414
414
 
415
- | Prop | Type | Default | Description |
416
- | ------------------- | ---------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- |
417
- | `value` | `string` | `"[]"` | JSON array of selected items (bindable) |
418
- | `name` | `string` | - | Form field name |
419
- | `getOptions` | `(q: string, current: Item[]) => Promise<{found: Item[]}>` | - | Async function to fetch options |
420
- | `cardinality` | `number` | `Infinity` | Max selections (-1 for unlimited) |
421
- | `allowUnknown` | `boolean` | `false` | Allow typing custom values |
422
- | `ordered` | `boolean` | `false` | Opt-in: add an "Arrange" screen to manually order the selection (multi-select only) |
423
- | `renderOptionLabel` | `(item: Item) => string` | - | Custom option label renderer |
424
- | `renderOptionGroup` | `(s: string) => string` | - | Custom optgroup label renderer |
425
- | `renderValue` | `(stringifiedItems: string) => string` | - | Custom value display renderer |
426
- | `showIconsCheckbox` | `boolean` | `true` | Show checkbox icons in multi-select |
427
- | `showIconsRadio` | `boolean` | `false` | Show radio icons in single-select |
428
- | `searchPlaceholder` | `string` | - | Custom search placeholder |
429
- | `itemIdPropName` | `string` | `"id"` | Property name for item ID |
430
- | `notifications` | `NotificationsStack` | - | Notification handler for errors |
415
+ | Prop | Type | Default | Description |
416
+ | ------------------- | ---------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- |
417
+ | `value` | `string` | `"[]"` | JSON array of selected items (bindable) |
418
+ | `name` | `string` | - | Form field name |
419
+ | `getOptions` | `(q: string, current: Item[]) => Promise<{found: Item[]}>` | - | Async function to fetch options |
420
+ | `cardinality` | `number` | `Infinity` | Max selections (-1 for unlimited) |
421
+ | `allowUnknown` | `boolean` | `false` | Allow typing custom values |
422
+ | `ordered` | `boolean` | `false` | Opt-in: add an "Arrange" screen to manually order the selection (multi-select only) |
423
+ | `chips` | `boolean` | `false` | Opt-in: show the selection as inline removable chips; picking still happens in the modal |
424
+ | `classChip` | `string` | - | Classes for each chip (`chips` mode) |
425
+ | `chipIntent` | `IntentColorKey` | - | Pill intent of the chips (`chips` mode) |
426
+ | `renderOptionLabel` | `(item: Item) => string` | - | Custom option label renderer |
427
+ | `renderOptionGroup` | `(s: string) => string` | - | Custom optgroup label renderer |
428
+ | `renderValue` | `(stringifiedItems: string) => string` | - | Custom value display renderer |
429
+ | `showIconsCheckbox` | `boolean` | `true` | Show checkbox icons in multi-select |
430
+ | `showIconsRadio` | `boolean` | `false` | Show radio icons in single-select |
431
+ | `searchPlaceholder` | `string` | - | Custom search placeholder |
432
+ | `itemIdPropName` | `string` | `"id"` | Property name for item ID |
433
+ | `notifications` | `NotificationsStack` | - | Notification handler for errors |
431
434
 
432
435
  ### Class Props
433
436
 
434
- | Prop | Target |
435
- | ------------------- | ---------------------------- |
436
- | `classOption` | Option item (ListItemButton) |
437
- | `classOptionActive` | Active/selected option |
438
- | `classOptgroup` | Option group label |
439
- | `classModalField` | Modal field wrapper |
437
+ | Prop | Target |
438
+ | ------------------- | -------------------------------- |
439
+ | `classOption` | Option item (ListItemButton) |
440
+ | `classOptionActive` | Active/selected option |
441
+ | `classOptgroup` | Option group label |
442
+ | `classModalField` | Modal field wrapper |
443
+ | `classChip` | Each chip (Pill) in `chips` mode |
440
444
 
441
445
  ### CSS Variables
442
446
 
443
447
  #### Component Tokens
444
448
 
445
- | Variable | Default | Description |
446
- | ------------------------------------------ | -------------------------------- | ------------------------------- |
447
- | `--stuic-field-options-divider` | `--stuic-color-border` | Divider/separator color |
448
- | `--stuic-field-options-control-text` | `--stuic-color-muted-foreground` | Control button text color |
449
- | `--stuic-field-options-control-text-hover` | `--stuic-color-foreground` | Control button hover text color |
450
- | `--stuic-field-options-control-ring` | `--stuic-color-ring` | Control button focus ring |
451
- | `--stuic-field-options-muted-text` | `--stuic-color-muted-foreground` | Muted/secondary text color |
452
- | `--stuic-field-options-optgroup-text` | `--stuic-color-muted-foreground` | Option group label color |
449
+ | Variable | Default | Description |
450
+ | ---------------------------------------------- | -------------------------------- | ----------------------------------------- |
451
+ | `--stuic-field-options-divider` | `--stuic-color-border` | Divider/separator color |
452
+ | `--stuic-field-options-control-text` | `--stuic-color-muted-foreground` | Control button text color |
453
+ | `--stuic-field-options-control-text-hover` | `--stuic-color-foreground` | Control button hover text color |
454
+ | `--stuic-field-options-control-ring` | `--stuic-color-ring` | Control button focus ring |
455
+ | `--stuic-field-options-muted-text` | `--stuic-color-muted-foreground` | Muted/secondary text color |
456
+ | `--stuic-field-options-optgroup-text` | `--stuic-color-muted-foreground` | Option group label color |
457
+ | `--stuic-field-options-chips-gap` | `0.25rem` | Gap between chips (`chips` mode) |
458
+ | `--stuic-field-options-chips-placeholder-text` | `--stuic-input-placeholder` | Placeholder color of an empty `chips` row |
453
459
 
454
460
  ### Usage
455
461
 
@@ -503,6 +509,35 @@ on submit (and round-trips on reopen). Single-select fields ignore the prop.
503
509
  > default contract, so the Arrange list can render selected items even when they aren't in
504
510
  > the current search results.
505
511
 
512
+ ### Chips display (`chips`)
513
+
514
+ By default the closed field is a button showing the selection as comma-joined text. Opt in
515
+ with `chips` to show it as inline, individually removable `Pill` chips instead — the tags form
516
+ factor. Picking still happens in the same modal: open it with the trailing button, or by
517
+ clicking the empty part of the row. Each chip's × removes that item immediately (writes
518
+ `value`, fires `onChange`, re-runs validation) without opening the modal, and keyboard focus
519
+ stays in the field. Chips render in `value` order, so with `ordered` they mirror the arranged
520
+ order. `renderValue` is ignored in this mode; labels come from `renderOptionLabel`.
521
+
522
+ ```svelte
523
+ <FieldOptions
524
+ label="Tags"
525
+ name="tags"
526
+ bind:value
527
+ {getOptions}
528
+ cardinality={-1}
529
+ allowUnknown
530
+ chips
531
+ chipIntent="primary"
532
+ />
533
+ ```
534
+
535
+ Each chip's × is a full-height 24px square (the Pill's own touch-target floor,
536
+ `--stuic-pill-dismiss-min-size`), so no extra hit-area padding is needed on touch devices.
537
+ Typing directly into the field (an inline combobox) is deliberately not part of this mode — the
538
+ modal remains the single place where options are searched and picked, which is what keeps the
539
+ field usable under a soft keyboard.
540
+
506
541
  ### Customization Examples
507
542
 
508
543
  ```css
@@ -0,0 +1,286 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { ValidateOptions } from "../../../actions/validate.svelte.js";
4
+ import type { IntentColorKey } from "../../../utils/design-tokens.js";
5
+ import type { THC } from "../../Thc/Thc.svelte";
6
+ import type { InputWrapClassProps } from "../types.js";
7
+
8
+ type SnippetWithId = Snippet<[{ id: string }]>;
9
+
10
+ /** One rendered chip. `key` must be unique within the row. */
11
+ export interface FieldLikeChip {
12
+ key: string | number;
13
+ label: string;
14
+ /** Accessible name of the chip's remove (×) button, e.g. "Remove Foo" */
15
+ removeLabel: string;
16
+ }
17
+
18
+ /**
19
+ * Internal: the `chips` trigger of `FieldOptions`. The FieldLikeButton counterpart for a
20
+ * selection shown as inline, individually removable Pills — same InputWrap shell, same
21
+ * hidden input + validate wiring, same imperative API.
22
+ */
23
+ export interface Props extends InputWrapClassProps {
24
+ /** The hidden input carrying `value` + `name` (bindable) */
25
+ input?: HTMLInputElement;
26
+ value: string;
27
+ /** The chips to render (the parent derives them from `value`) */
28
+ chips: FieldLikeChip[];
29
+ label?: SnippetWithId | THC;
30
+ description?: SnippetWithId | THC;
31
+ class?: string;
32
+ id?: string;
33
+ tabindex?: number;
34
+ renderSize?: "sm" | "md" | "lg" | string;
35
+ name?: string;
36
+ required?: boolean;
37
+ disabled?: boolean;
38
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
39
+ labelAfter?: SnippetWithId | THC;
40
+ below?: SnippetWithId | THC;
41
+ labelLeft?: boolean;
42
+ labelLeftWidth?: "normal" | "wide";
43
+ labelLeftBreakpoint?: number;
44
+ style?: string;
45
+ /** Shown in the row while there are no chips */
46
+ placeholder?: string;
47
+ /** Accessible name (and tooltip) of the trailing "open" button */
48
+ openLabel: string;
49
+ /** Classes for each chip (Pill) */
50
+ classChip?: string;
51
+ /** Pill intent of the chips */
52
+ chipIntent?: IntentColorKey;
53
+ /** Open the picker (trailing button, or a click on the empty part of the row) */
54
+ onOpen: () => void;
55
+ /** A chip's × was pressed */
56
+ onRemove: (chip: FieldLikeChip, index: number) => void;
57
+ }
58
+ </script>
59
+
60
+ <script lang="ts">
61
+ import { tick } from "svelte";
62
+ import { iconSearch } from "../../../icons/index.js";
63
+ import {
64
+ validate as validateAction,
65
+ type ValidationResult,
66
+ } from "../../../actions/validate.svelte.js";
67
+ import { getId } from "../../../utils/get-id.js";
68
+ import Button from "../../Button/Button.svelte";
69
+ import Pill from "../../Pill/Pill.svelte";
70
+ import InputWrap from "./InputWrap.svelte";
71
+
72
+ let {
73
+ input = $bindable(),
74
+ value = $bindable(),
75
+ chips,
76
+ label = "",
77
+ id = getId(),
78
+ tabindex = 0,
79
+ description,
80
+ class: classProp,
81
+ renderSize = "md",
82
+ name,
83
+ //
84
+ required = false,
85
+ disabled = false,
86
+ //
87
+ // Renamed local binding to avoid collision with `export function validate()` below.
88
+ validate: validateProp,
89
+ //
90
+ labelAfter,
91
+ below,
92
+ //
93
+ labelLeft = false,
94
+ labelLeftWidth = "normal",
95
+ labelLeftBreakpoint = 480,
96
+ //
97
+ classLabel,
98
+ classLabelBox,
99
+ classInputBox,
100
+ classInputBoxWrap,
101
+ classInputBoxWrapInvalid,
102
+ classDescBox,
103
+ classDescBoxToggle,
104
+ classBelowBox,
105
+ classValidationBox,
106
+ style = "",
107
+ //
108
+ placeholder,
109
+ openLabel,
110
+ classChip,
111
+ chipIntent,
112
+ onOpen,
113
+ onRemove,
114
+ }: Props = $props();
115
+
116
+ // chips sit one step smaller than the field they live in
117
+ const PILL_SIZE: Record<string, string> = { sm: "sm", md: "sm", lg: "md" };
118
+ let pillSize = $derived(PILL_SIZE[renderSize] ?? "sm");
119
+
120
+ let validation: ValidationResult | undefined = $state();
121
+ const setValidationResult = (res: ValidationResult) => (validation = res);
122
+
123
+ let _doValidate: (() => void) | undefined = $state();
124
+ let rowEl: HTMLDivElement | undefined = $state();
125
+ let openBtnEl: HTMLElement | undefined = $state();
126
+
127
+ /** Trigger validation now. Renders the inline message if invalid. */
128
+ export function validate(): ValidationResult | undefined {
129
+ _doValidate?.();
130
+ return validation;
131
+ }
132
+
133
+ /** Clear the inline validation message and reset `setCustomValidity`. */
134
+ export function clearValidation(): void {
135
+ validation = undefined;
136
+ input?.setCustomValidity?.("");
137
+ }
138
+
139
+ /** Current validation state, or undefined if validator has never run. */
140
+ export function getValidation(): ValidationResult | undefined {
141
+ return validation;
142
+ }
143
+
144
+ /** Focus the trailing "open" button (the hidden input cannot be focused). */
145
+ export function focus(): void {
146
+ openBtnEl?.focus?.();
147
+ }
148
+
149
+ /** Scroll the field into view. Defaults to smooth + center. */
150
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
151
+ rowEl?.scrollIntoView?.({
152
+ behavior: "smooth",
153
+ block: "center",
154
+ ...opts,
155
+ });
156
+ }
157
+
158
+ // the empty part of the row is a pointer convenience: chips own their own clicks and
159
+ // the trailing button is the keyboard-reachable opener
160
+ function onRowClick(e: MouseEvent) {
161
+ if (disabled) return;
162
+ if ((e.target as Element | null)?.closest?.(".stuic-pill")) return;
163
+ onOpen();
164
+ }
165
+
166
+ // keep keyboard focus inside the field after a removal: the × now sitting at the same
167
+ // index, else the trailing button
168
+ function remove(chip: FieldLikeChip, index: number) {
169
+ onRemove(chip, index);
170
+ tick().then(() => {
171
+ const btns =
172
+ rowEl?.querySelectorAll<HTMLButtonElement>(".stuic-pill-dismiss") ?? [];
173
+ const next = btns[Math.min(index, btns.length - 1)];
174
+ (next ?? openBtnEl)?.focus?.();
175
+ });
176
+ }
177
+ </script>
178
+
179
+ <InputWrap
180
+ {description}
181
+ class={classProp}
182
+ size={renderSize}
183
+ {id}
184
+ {label}
185
+ {labelAfter}
186
+ {below}
187
+ {required}
188
+ {disabled}
189
+ {labelLeft}
190
+ {labelLeftWidth}
191
+ {labelLeftBreakpoint}
192
+ {classLabel}
193
+ {classLabelBox}
194
+ {classInputBox}
195
+ {classInputBoxWrap}
196
+ {classInputBoxWrapInvalid}
197
+ {classDescBox}
198
+ {classDescBoxToggle}
199
+ {classBelowBox}
200
+ {classValidationBox}
201
+ {validation}
202
+ {style}
203
+ >
204
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
205
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
206
+ <div
207
+ bind:this={rowEl}
208
+ class="stuic-field-options-chips"
209
+ data-size={renderSize}
210
+ data-empty={chips.length ? undefined : "true"}
211
+ onclick={onRowClick}
212
+ >
213
+ {#each chips as chip, i (chip.key)}
214
+ <Pill
215
+ dismissible
216
+ size={pillSize}
217
+ intent={chipIntent}
218
+ class={classChip}
219
+ dismissLabel={chip.removeLabel}
220
+ ondismiss={() => remove(chip, i)}
221
+ {disabled}
222
+ title={chip.label}
223
+ >
224
+ <!-- own span so an over-long label truncates (the Pill body is a flex row) -->
225
+ <span class="stuic-field-options-chip-label">{chip.label}</span>
226
+ </Pill>
227
+ {/each}
228
+ {#if !chips.length && placeholder}
229
+ <span class="stuic-field-options-chips-placeholder">{placeholder}</span>
230
+ {/if}
231
+ </div>
232
+
233
+ {#snippet inputAfter()}
234
+ <div class="flex items-center pr-1">
235
+ <Button
236
+ bind:el={openBtnEl}
237
+ iconButton
238
+ variant="ghost"
239
+ roundedFull
240
+ type="button"
241
+ size={renderSize === "lg" ? "md" : "sm"}
242
+ aria-label={openLabel}
243
+ tooltip={openLabel}
244
+ {disabled}
245
+ {tabindex}
246
+ onclick={() => onOpen()}
247
+ >
248
+ {@html iconSearch({ size: 18 })}
249
+ </Button>
250
+ </div>
251
+ {/snippet}
252
+
253
+ <input
254
+ bind:value
255
+ bind:this={input}
256
+ type="hidden"
257
+ {id}
258
+ {name}
259
+ use:validateAction={() => ({
260
+ enabled: validateProp !== false,
261
+ ...(typeof validateProp === "boolean"
262
+ ? {
263
+ // Return actual messages (not reason names) because hidden inputs
264
+ // don't support el.validationMessage - the validate action preserves
265
+ // our return value and uses it directly as the error message.
266
+ customValidator(val, ctx, el) {
267
+ if (required && !val)
268
+ return "This field requires attention. Please review and try again.";
269
+
270
+ // also, by default, JSON validation is built in
271
+ try {
272
+ JSON.parse(val as string);
273
+ return "";
274
+ } catch (e) {
275
+ return "This field is invalid. Please review and try again.";
276
+ }
277
+ },
278
+ }
279
+ : validateProp),
280
+ setValidationResult,
281
+ setDoValidate: (fn) => (_doValidate = fn),
282
+ })}
283
+ {required}
284
+ {disabled}
285
+ />
286
+ </InputWrap>
@@ -0,0 +1,65 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { ValidateOptions } from "../../../actions/validate.svelte.js";
3
+ import type { IntentColorKey } from "../../../utils/design-tokens.js";
4
+ import type { THC } from "../../Thc/Thc.svelte";
5
+ import type { InputWrapClassProps } from "../types.js";
6
+ type SnippetWithId = Snippet<[{
7
+ id: string;
8
+ }]>;
9
+ /** One rendered chip. `key` must be unique within the row. */
10
+ export interface FieldLikeChip {
11
+ key: string | number;
12
+ label: string;
13
+ /** Accessible name of the chip's remove (×) button, e.g. "Remove Foo" */
14
+ removeLabel: string;
15
+ }
16
+ /**
17
+ * Internal: the `chips` trigger of `FieldOptions`. The FieldLikeButton counterpart for a
18
+ * selection shown as inline, individually removable Pills — same InputWrap shell, same
19
+ * hidden input + validate wiring, same imperative API.
20
+ */
21
+ export interface Props extends InputWrapClassProps {
22
+ /** The hidden input carrying `value` + `name` (bindable) */
23
+ input?: HTMLInputElement;
24
+ value: string;
25
+ /** The chips to render (the parent derives them from `value`) */
26
+ chips: FieldLikeChip[];
27
+ label?: SnippetWithId | THC;
28
+ description?: SnippetWithId | THC;
29
+ class?: string;
30
+ id?: string;
31
+ tabindex?: number;
32
+ renderSize?: "sm" | "md" | "lg" | string;
33
+ name?: string;
34
+ required?: boolean;
35
+ disabled?: boolean;
36
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
37
+ labelAfter?: SnippetWithId | THC;
38
+ below?: SnippetWithId | THC;
39
+ labelLeft?: boolean;
40
+ labelLeftWidth?: "normal" | "wide";
41
+ labelLeftBreakpoint?: number;
42
+ style?: string;
43
+ /** Shown in the row while there are no chips */
44
+ placeholder?: string;
45
+ /** Accessible name (and tooltip) of the trailing "open" button */
46
+ openLabel: string;
47
+ /** Classes for each chip (Pill) */
48
+ classChip?: string;
49
+ /** Pill intent of the chips */
50
+ chipIntent?: IntentColorKey;
51
+ /** Open the picker (trailing button, or a click on the empty part of the row) */
52
+ onOpen: () => void;
53
+ /** A chip's × was pressed */
54
+ onRemove: (chip: FieldLikeChip, index: number) => void;
55
+ }
56
+ import { type ValidationResult } from "../../../actions/validate.svelte.js";
57
+ declare const FieldLikeChips: import("svelte").Component<Props, {
58
+ validate: () => ValidationResult | undefined;
59
+ clearValidation: () => void;
60
+ getValidation: () => ValidationResult | undefined;
61
+ focus: () => void;
62
+ scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
63
+ }, "value" | "input">;
64
+ type FieldLikeChips = ReturnType<typeof FieldLikeChips>;
65
+ export default FieldLikeChips;
@@ -81,6 +81,8 @@
81
81
  --stuic-field-options-control-ring: var(--stuic-color-ring);
82
82
  --stuic-field-options-muted-text: var(--stuic-color-muted-foreground);
83
83
  --stuic-field-options-optgroup-text: var(--stuic-color-muted-foreground);
84
+ /* FieldOptions `chips` display mode */
85
+ --stuic-field-options-chips-gap: 0.25rem;
84
86
  }
85
87
 
86
88
  @layer components {
@@ -561,6 +563,62 @@
561
563
  opacity: 1;
562
564
  }
563
565
 
566
+ /* `chips` display mode: the selection as inline, removable Pills (FieldLikeChips) */
567
+ .stuic-field-options-chips {
568
+ display: flex;
569
+ flex: 1 1 0%;
570
+ min-width: 0;
571
+ flex-wrap: wrap;
572
+ align-items: center;
573
+ gap: var(--stuic-field-options-chips-gap);
574
+ cursor: pointer;
575
+ }
576
+
577
+ .stuic-input.disabled .stuic-field-options-chips {
578
+ cursor: not-allowed;
579
+ }
580
+
581
+ /* An over-long label must not push the chip out of the field: cap the chip at the row
582
+ width and let the label span truncate (the × keeps its size) */
583
+ .stuic-field-options-chips .stuic-pill {
584
+ max-width: 100%;
585
+ }
586
+
587
+ .stuic-field-options-chip-label {
588
+ min-width: 0;
589
+ overflow: hidden;
590
+ text-overflow: ellipsis;
591
+ white-space: nowrap;
592
+ }
593
+
594
+ /* Row metrics follow the input size tokens so the field lines up with its siblings */
595
+ .stuic-field-options-chips[data-size="sm"] {
596
+ padding: calc(var(--stuic-input-padding-y-sm) / 2) var(--stuic-input-padding-x-sm);
597
+ font-size: var(--stuic-input-font-size-sm);
598
+ min-height: var(--stuic-input-min-height-sm);
599
+ }
600
+
601
+ .stuic-field-options-chips[data-size="md"],
602
+ .stuic-field-options-chips:not([data-size]) {
603
+ padding: calc(var(--stuic-input-padding-y-md) / 2) var(--stuic-input-padding-x-md);
604
+ font-size: var(--stuic-input-font-size-md);
605
+ min-height: var(--stuic-input-min-height-md);
606
+ }
607
+
608
+ .stuic-field-options-chips[data-size="lg"] {
609
+ padding: calc(var(--stuic-input-padding-y-lg) / 2) var(--stuic-input-padding-x-lg);
610
+ font-size: var(--stuic-input-font-size-lg);
611
+ min-height: var(--stuic-input-min-height-lg);
612
+ }
613
+
614
+ .stuic-field-options-chips-placeholder {
615
+ color: var(
616
+ --stuic-field-options-chips-placeholder-text,
617
+ var(--stuic-input-placeholder)
618
+ );
619
+ user-select: none;
620
+ }
621
+
564
622
  /* ============================================================================
565
623
  FIELD INPUT LOCALIZED
566
624
  ============================================================================ */
@@ -42,6 +42,8 @@
42
42
  dismissible?: boolean;
43
43
  /** Called when X is clicked. Stops propagation so parent onclick is unaffected. */
44
44
  ondismiss?: (e: MouseEvent) => void;
45
+ /** Accessible name of the built-in X dismiss button */
46
+ dismissLabel?: string;
45
47
  /** Status dot rendered before content (uses current intent color) */
46
48
  dot?: boolean;
47
49
  /** Content rendered before children */
@@ -76,6 +78,7 @@
76
78
  disabled,
77
79
  dismissible = false,
78
80
  ondismiss,
81
+ dismissLabel = "Dismiss",
79
82
  dot = false,
80
83
  contentBefore,
81
84
  contentAfter,
@@ -109,11 +112,14 @@
109
112
  <button
110
113
  type="button"
111
114
  class="stuic-pill-dismiss"
112
- aria-label="Dismiss"
115
+ aria-label={dismissLabel}
113
116
  onclick={handleDismiss}
114
117
  {disabled}
115
118
  >
116
- <X strokeWidth={2} />
119
+ <!-- The icon box is sized in CSS (tokens); the X itself just fills it. -->
120
+ <span class="stuic-pill-dismiss-icon" aria-hidden="true">
121
+ <X class="size-full" strokeWidth={2.5} />
122
+ </span>
117
123
  </button>
118
124
  {/snippet}
119
125
 
@@ -35,6 +35,8 @@ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children"> {
35
35
  dismissible?: boolean;
36
36
  /** Called when X is clicked. Stops propagation so parent onclick is unaffected. */
37
37
  ondismiss?: (e: MouseEvent) => void;
38
+ /** Accessible name of the built-in X dismiss button */
39
+ dismissLabel?: string;
38
40
  /** Status dot rendered before content (uses current intent color) */
39
41
  dot?: boolean;
40
42
  /** Content rendered before children */
@@ -4,27 +4,28 @@ A small rounded inline element for tags, badges, status indicators, and filter c
4
4
 
5
5
  ## Props
6
6
 
7
- | Prop | Type | Default | Description |
8
- | --------------- | ------------------------------------------------------------------ | -------- | --------------------------------------------------------------------- |
9
- | `intent` | `"primary" \| "accent" \| "destructive" \| "warning" \| "success"` | - | Semantic color intent |
10
- | `variant` | `"solid" \| "outline" \| "ghost" \| "soft" \| "link"` | `"soft"` | Visual variant (how colors are applied) |
11
- | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Pill size |
12
- | `muted` | `boolean` | `false` | Reduce emphasis (lower opacity) |
13
- | `active` | `boolean` | `false` | Selected/active state (filter-chip behavior) |
14
- | `roundedFull` | `boolean` | `true` | Fully rounded corners (9999px). Set `false` to use the element radius |
15
- | `block` | `boolean` | `false` | Render as block-level flex (full width). `inline-flex` by default |
16
- | `unstyled` | `boolean` | `false` | Skip all default styling |
17
- | `href` | `string` | - | Render as `<a>` with this URL |
18
- | `target` | `string` | - | Link target (only when `href` is set) |
19
- | `onclick` | `(e: MouseEvent) => void` | - | Render as `<button>` with this handler (when no `href`) |
20
- | `disabled` | `boolean` | - | Disabled state (interactive variants only) |
21
- | `dismissible` | `boolean` | `false` | Show built-in X dismiss button |
22
- | `ondismiss` | `(e: MouseEvent) => void` | - | Called when X is clicked. Stops propagation |
23
- | `dot` | `boolean` | `false` | Status dot rendered before content |
24
- | `contentBefore` | `THC` | - | Content rendered before children |
25
- | `contentAfter` | `THC` | - | Content rendered after children |
26
- | `el` | `HTMLElement` | - | Element reference (bindable) |
27
- | `class` | `string` | - | Additional CSS classes |
7
+ | Prop | Type | Default | Description |
8
+ | --------------- | ------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------- |
9
+ | `intent` | `"primary" \| "accent" \| "destructive" \| "warning" \| "success"` | - | Semantic color intent |
10
+ | `variant` | `"solid" \| "outline" \| "ghost" \| "soft" \| "link"` | `"soft"` | Visual variant (how colors are applied) |
11
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Pill size |
12
+ | `muted` | `boolean` | `false` | Reduce emphasis (lower opacity) |
13
+ | `active` | `boolean` | `false` | Selected/active state (filter-chip behavior) |
14
+ | `roundedFull` | `boolean` | `true` | Fully rounded corners (9999px). Set `false` to use the element radius |
15
+ | `block` | `boolean` | `false` | Render as block-level flex (full width). `inline-flex` by default |
16
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
17
+ | `href` | `string` | - | Render as `<a>` with this URL |
18
+ | `target` | `string` | - | Link target (only when `href` is set) |
19
+ | `onclick` | `(e: MouseEvent) => void` | - | Render as `<button>` with this handler (when no `href`) |
20
+ | `disabled` | `boolean` | - | Disabled state (interactive variants only) |
21
+ | `dismissible` | `boolean` | `false` | Show built-in X dismiss button |
22
+ | `ondismiss` | `(e: MouseEvent) => void` | - | Called when X is clicked. Stops propagation |
23
+ | `dismissLabel` | `string` | `"Dismiss"` | Accessible name of the X dismiss button |
24
+ | `dot` | `boolean` | `false` | Status dot rendered before content |
25
+ | `contentBefore` | `THC` | - | Content rendered before children |
26
+ | `contentAfter` | `THC` | - | Content rendered after children |
27
+ | `el` | `HTMLElement` | - | Element reference (bindable) |
28
+ | `class` | `string` | - | Additional CSS classes |
28
29
 
29
30
  ## Element Resolution
30
31
 
@@ -172,6 +173,15 @@ A small rounded inline element for tags, badges, status indicators, and filter c
172
173
  | `--stuic-pill-gap` | `0.375rem` | Gap between dot/before/children/after/dismiss |
173
174
  | `--stuic-pill-dot-size` | `0.5rem` | Status dot diameter |
174
175
 
176
+ ### Dismiss Button Tokens
177
+
178
+ | Variable | Default | Description |
179
+ | -------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
180
+ | `--stuic-pill-dismiss-min-size` | `1.5rem` | Touch-target floor for the X button (its square never gets smaller) |
181
+ | `--stuic-pill-dismiss-icon-size` | `1.125em` | Size of the X glyph box (relative to the pill font size) |
182
+ | `--stuic-pill-dismiss-bg-hover` | `color-mix(in srgb, currentColor 12%, transparent)` | Hover background of the whole X square |
183
+ | `--stuic-pill-dismiss-bg-active` | `color-mix(in srgb, currentColor 20%, transparent)` | Pressed background of the whole X square |
184
+
175
185
  ### Size Tokens
176
186
 
177
187
  Each size (sm, md, lg) has corresponding tokens:
@@ -181,7 +191,10 @@ Each size (sm, md, lg) has corresponding tokens:
181
191
  - `--stuic-pill-font-size-{size}`
182
192
  - `--stuic-pill-min-height-{size}`
183
193
 
184
- Dismissible pills override `padding-y` to `0` (the X button defines the height).
194
+ Dismissible pills override `padding-y` and `padding-inline-end` to `0`: the X button is a
195
+ full-height square (side = the pill's `min-height`, floored at `--stuic-pill-dismiss-min-size`)
196
+ sitting flush with the pill's end edge, and the whole square is the hover/press surface — not just
197
+ the glyph. Its corners inherit the pill radius, so on rounded pills the hover surface is the end cap.
185
198
 
186
199
  ### Intent Color Tokens
187
200
 
@@ -21,6 +21,12 @@
21
21
  /* Status dot */
22
22
  --stuic-pill-dot-size: 0.5rem;
23
23
 
24
+ /* Dismiss (X) button: a full-height square flush with the pill's end edge */
25
+ --stuic-pill-dismiss-min-size: 1.5rem; /* touch target floor (24px), whatever the pill size */
26
+ --stuic-pill-dismiss-icon-size: 1.125em; /* the X glyph box, relative to the pill font size */
27
+ --stuic-pill-dismiss-bg-hover: color-mix(in srgb, currentColor 12%, transparent);
28
+ --stuic-pill-dismiss-bg-active: color-mix(in srgb, currentColor 20%, transparent);
29
+
24
30
  /* Size: sm */
25
31
  --stuic-pill-padding-x-sm: 0.5rem;
26
32
  --stuic-pill-padding-y-sm: 0.125rem;
@@ -145,13 +151,36 @@
145
151
  min-height: var(--stuic-pill-min-height-lg);
146
152
  }
147
153
 
148
- /* Dismissible pills: drop vertical padding — the X button already provides
149
- visual height, extra padding-y just makes them oversized. */
150
- .stuic-pill[data-dismissible="true"][data-size="sm"],
151
- .stuic-pill[data-dismissible="true"][data-size="md"],
152
- .stuic-pill[data-dismissible="true"][data-size="lg"] {
154
+ /* Dismissible pills: drop vertical + end padding — the X button spans the full
155
+ height and sits flush with the end edge (its hover surface IS the end cap),
156
+ so any padding there would only make the pill oversized. */
157
+ .stuic-pill[data-dismissible="true"] {
153
158
  padding-top: 0;
154
159
  padding-bottom: 0;
160
+ padding-inline-end: 0;
161
+ }
162
+
163
+ /* The X button is a square whose side follows the pill's min-height, but never
164
+ drops below the touch-target floor (so sm pills keep a 24px X). */
165
+ .stuic-pill[data-dismissible="true"][data-size="sm"] {
166
+ --_dismiss-size: max(
167
+ var(--stuic-pill-min-height-sm),
168
+ var(--stuic-pill-dismiss-min-size)
169
+ );
170
+ }
171
+
172
+ .stuic-pill[data-dismissible="true"][data-size="md"] {
173
+ --_dismiss-size: max(
174
+ var(--stuic-pill-min-height-md),
175
+ var(--stuic-pill-dismiss-min-size)
176
+ );
177
+ }
178
+
179
+ .stuic-pill[data-dismissible="true"][data-size="lg"] {
180
+ --_dismiss-size: max(
181
+ var(--stuic-pill-min-height-lg),
182
+ var(--stuic-pill-dismiss-min-size)
183
+ );
155
184
  }
156
185
 
157
186
  /* ============================================================================
@@ -448,31 +477,45 @@
448
477
  cursor: not-allowed;
449
478
  }
450
479
 
451
- /* Dismiss X button */
480
+ /* Dismiss X button: a full-height square segment flush with the pill's end edge.
481
+ The whole square (not just the glyph) is the hover/press surface — the same
482
+ treatment as SplitButton's secondary trigger. Radius is inherited from the pill,
483
+ so on rounded-full pills the hover surface coincides with the end cap. */
452
484
  .stuic-pill-dismiss {
453
485
  display: inline-flex;
454
486
  align-items: center;
455
487
  justify-content: center;
488
+ align-self: stretch;
489
+ min-width: var(--_dismiss-size, var(--stuic-pill-dismiss-min-size));
490
+ min-height: var(--_dismiss-size, var(--stuic-pill-dismiss-min-size));
456
491
  appearance: none;
457
492
  background: transparent;
458
493
  border: 0;
494
+ border-radius: inherit;
459
495
  padding: 0;
460
496
  margin: 0;
461
497
  color: inherit;
462
498
  cursor: pointer;
463
499
  opacity: 0.7;
464
- transition: opacity var(--stuic-pill-transition, var(--stuic-transition));
500
+ transition:
501
+ opacity var(--stuic-pill-transition, var(--stuic-transition)),
502
+ background var(--stuic-pill-transition, var(--stuic-transition));
465
503
  flex-shrink: 0;
466
504
  }
467
505
 
468
506
  .stuic-pill-dismiss:hover:not(:disabled) {
469
507
  opacity: 1;
508
+ background: var(--stuic-pill-dismiss-bg-hover);
509
+ }
510
+
511
+ .stuic-pill-dismiss:active:not(:disabled) {
512
+ background: var(--stuic-pill-dismiss-bg-active);
470
513
  }
471
514
 
515
+ /* Flush with the pill edge, so the ring goes inside the square */
472
516
  .stuic-pill-dismiss:focus-visible {
473
517
  outline: var(--stuic-pill-ring-width) solid var(--_ring, var(--stuic-pill-ring-color));
474
- outline-offset: 1px;
475
- border-radius: 9999px;
518
+ outline-offset: calc(-1 * var(--stuic-pill-ring-width));
476
519
  }
477
520
 
478
521
  .stuic-pill-dismiss:disabled {
@@ -480,14 +523,10 @@
480
523
  cursor: not-allowed;
481
524
  }
482
525
 
483
- .stuic-pill-dismiss svg {
484
- width: 1em;
485
- height: 1em;
486
- }
487
-
488
- /* Tighten dismiss icon size per pill size */
489
- .stuic-pill[data-size="sm"] .stuic-pill-dismiss svg {
490
- width: 0.875em;
491
- height: 0.875em;
526
+ /* The glyph box — deliberately smaller than the button, which keeps the target. */
527
+ .stuic-pill-dismiss-icon {
528
+ display: inline-flex;
529
+ width: var(--stuic-pill-dismiss-icon-size);
530
+ height: var(--stuic-pill-dismiss-icon-size);
492
531
  }
493
532
  }
@@ -18,7 +18,8 @@ Checked first, to avoid false positives:
18
18
  - **Table pagination** — built into `DataTable` (`showpager`)
19
19
  - **Multi-select / tags input** — `FieldOptions` (async `getOptions` search, typeahead,
20
20
  `cardinality`, option groups, `allowUnknown` for ad-hoc values, `ordered` for manual
21
- arrangement). See the note at the bottom on the inline-chip presentation variant.
21
+ arrangement, `chips` for the inline tag form factor). See the note at the bottom on
22
+ the inline-chip presentation.
22
23
  - **File dropzone** — `actions/file-dropzone` + `FieldFile` / `FieldAssets`
23
24
 
24
25
  ## Tier 1 — staples nearly every comparable library ships
@@ -82,16 +83,36 @@ Checked first, to avoid false positives:
82
83
  ## Note: inline-chip presentation of FieldOptions
83
84
 
84
85
  Initially listed as a Tier-1 "tags input" gap; retracted — `FieldOptions` covers the
85
- capability. What stuic lacks is at most the _inline chip form factor_: chips inside the
86
- field with per-chip remove ×, a text input riding alongside, suggestions in a popover
87
- without leaving the page. Faster for dense desktop/admin work; the existing modal flow
88
- is arguably better on mobile.
89
-
90
- If ever built, the design to argue for: a presentation mode of `FieldOptions` (the
91
- selection model `ItemCollection`, `cardinality`, `allowUnknown` is already there),
92
- not a new component. Inline combobox on pointer devices, delegating to the existing
93
- modal on touch/small screens (`breakpoint.svelte.ts` + `device-pointer.svelte.ts`
94
- enable the switch).
86
+ capability. What stuic lacked was the _inline chip form factor_. That splits into two
87
+ separable halves, and only the second one has a mobile problem:
88
+
89
+ 1. ~~**Chips as the field's display**~~ — ✅ shipped (`FieldOptions` `chips` prop, see
90
+ `Input/README.md`): the closed field shows the selection as removable `Pill` chips
91
+ (per-chip ×, focus kept in the field, `change` dispatched so validation re-runs) plus a
92
+ trailing button that opens the existing modal; the empty part of the row opens it too.
93
+ Internally a `FieldLikeButton` sibling (`Input/_internal/FieldLikeChips.svelte`) the
94
+ chips carry their own buttons, so they cannot live inside the `<button>` trigger. Works
95
+ on every device; the one touch concern (× target size) is handled with a padded hit
96
+ area on `pointer: coarse`.
97
+ 2. **Inline adding** (type into the field, suggestions, Enter commits) — not built. This is
98
+ where every mobile hazard lives (see the reality check below). The design to argue for,
99
+ if ever built:
100
+ - a presentation mode of `FieldOptions`, not a new component — the selection model
101
+ (`ItemCollection`, `cardinality`, `allowUnknown`, `ordered`) is already there. But the
102
+ engine is currently bound to the modal lifecycle (hydrate on open, fetch while
103
+ visible, clear on submit) and would need extracting into a shared `.svelte.ts` class
104
+ rather than more branches in the 1250-line file; that is the real cost
105
+ - no floating listbox popover: reuse the existing `typeahead` action (ghost text, Tab
106
+ accepts, Enter commits, Backspace at position 0 removes the last chip via
107
+ `onDeleteRequest`). No anchored dropdown means the soft-keyboard hazard disappears by
108
+ construction; the modal stays the browse path via the trailing button. A real list
109
+ popover can be added later behind the same prop if ghost text proves insufficient
110
+ - gate it on the `md` breakpoint (`breakpoint.svelte.ts`), not on `DevicePointer` —
111
+ that util uses `any-pointer`, so a touchscreen laptop counts as coarse and an iPad
112
+ with a trackpad as fine; the breakpoint is deterministic and testable in the browser
113
+ suite. If pointer detection is wanted as well, it should be the primary
114
+ `pointer: coarse`
115
+ - below the breakpoint, delegate to the modal — i.e. exactly the shipped `chips` mode
95
116
 
96
117
  Mobile reality check for the inline variant, if it were ever made touch-capable
97
118
  instead of delegating:
package/docs/upgrading.md CHANGED
@@ -99,6 +99,12 @@ interface InputWrapClassProps {
99
99
 
100
100
  Previously most fields only accepted 5–7 of these. You can now pass any of them to **FieldInput, FieldTextarea, FieldSelect, FieldSwitch, FieldFile, FieldLikeButton, FieldObject, FieldOptions, FieldAssets, FieldPhoneNumber, FieldInputLocalized, FieldKeyValues** uniformly. `FieldCheckbox` and `FieldRadios` use bespoke layouts and keep their own narrower class-prop surface.
101
101
 
102
+ ### FieldOptions
103
+
104
+ `chips` prop (opt-in): the closed field shows the selection as inline, removable `Pill` chips plus a trailing button that opens the usual modal. Removing a chip writes `value` and fires `onChange` without the modal. Companion props `classChip` / `chipIntent`; `renderValue` is ignored in this mode. The default rendering is unchanged.
105
+
106
+ `Pill` gained `dismissLabel` — the accessible name of its × button (default `"Dismiss"`).
107
+
102
108
  ### Checkout
103
109
 
104
110
  New util:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.168.0",
3
+ "version": "3.169.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",