@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 +1 -1
- package/dist/components/Input/FieldOptions.svelte +92 -2
- package/dist/components/Input/FieldOptions.svelte.d.ts +13 -0
- package/dist/components/Input/README.md +66 -31
- package/dist/components/Input/_internal/FieldLikeChips.svelte +286 -0
- package/dist/components/Input/_internal/FieldLikeChips.svelte.d.ts +65 -0
- package/dist/components/Input/index.css +58 -0
- package/dist/components/Pill/Pill.svelte +8 -2
- package/dist/components/Pill/Pill.svelte.d.ts +2 -0
- package/dist/components/Pill/README.md +35 -22
- package/dist/components/Pill/index.css +57 -18
- package/docs/maybe-todo.md +32 -11
- package/docs/upgrading.md +6 -0
- package/package.json +1 -1
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
|
|
233
|
-
|
|
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
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
| `
|
|
429
|
-
| `
|
|
430
|
-
| `
|
|
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
|
|
446
|
-
|
|
|
447
|
-
| `--stuic-field-options-divider`
|
|
448
|
-
| `--stuic-field-options-control-text`
|
|
449
|
-
| `--stuic-field-options-control-text-hover`
|
|
450
|
-
| `--stuic-field-options-control-ring`
|
|
451
|
-
| `--stuic-field-options-muted-text`
|
|
452
|
-
| `--stuic-field-options-optgroup-text`
|
|
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=
|
|
115
|
+
aria-label={dismissLabel}
|
|
113
116
|
onclick={handleDismiss}
|
|
114
117
|
{disabled}
|
|
115
118
|
>
|
|
116
|
-
|
|
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
|
|
8
|
-
| --------------- | ------------------------------------------------------------------ |
|
|
9
|
-
| `intent` | `"primary" \| "accent" \| "destructive" \| "warning" \| "success"` | -
|
|
10
|
-
| `variant` | `"solid" \| "outline" \| "ghost" \| "soft" \| "link"` | `"soft"`
|
|
11
|
-
| `size` | `"sm" \| "md" \| "lg"` | `"md"`
|
|
12
|
-
| `muted` | `boolean` | `false`
|
|
13
|
-
| `active` | `boolean` | `false`
|
|
14
|
-
| `roundedFull` | `boolean` | `true`
|
|
15
|
-
| `block` | `boolean` | `false`
|
|
16
|
-
| `unstyled` | `boolean` | `false`
|
|
17
|
-
| `href` | `string` | -
|
|
18
|
-
| `target` | `string` | -
|
|
19
|
-
| `onclick` | `(e: MouseEvent) => void` | -
|
|
20
|
-
| `disabled` | `boolean` | -
|
|
21
|
-
| `dismissible` | `boolean` | `false`
|
|
22
|
-
| `ondismiss` | `(e: MouseEvent) => void` | -
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
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
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.stuic-pill[data-dismissible="true"]
|
|
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:
|
|
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:
|
|
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
|
-
.
|
|
484
|
-
|
|
485
|
-
|
|
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
|
}
|
package/docs/maybe-todo.md
CHANGED
|
@@ -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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|