@aiaiai-pt/design-system 0.45.0 → 0.46.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/components/ActionFormRenderer.svelte +396 -40
- package/components/ActionFormRenderer.svelte.d.ts +11 -0
- package/components/DateRangePicker.svelte.d.ts +1 -1
- package/components/JsonEditor.svelte +152 -0
- package/components/JsonEditor.svelte.d.ts +44 -0
- package/components/MoneyInput.svelte +219 -0
- package/components/MoneyInput.svelte.d.ts +52 -0
- package/components/MultiSelectCombobox.svelte +140 -0
- package/components/MultiSelectCombobox.svelte.d.ts +62 -0
- package/components/Tag.svelte +4 -0
- package/components/action-form-renderer-layouts.d.ts +6 -3
- package/components/action-form-renderer-layouts.ts +11 -8
- package/components/action-form-renderer-payload.d.ts +12 -0
- package/components/action-form-renderer-payload.ts +46 -3
- package/components/action-form-renderer-relationships.d.ts +20 -0
- package/components/action-form-renderer-relationships.ts +77 -0
- package/components/action-form-renderer-widgets.d.ts +39 -0
- package/components/action-form-renderer-widgets.ts +127 -0
- package/components/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/components/json-editor-contract.d.ts +11 -0
- package/components/json-editor-contract.ts +33 -0
- package/package.json +13 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component JsonEditor
|
|
3
|
+
|
|
4
|
+
JSON field over the DS CodeEditor (#38, atelier#669 V1) with an explicit
|
|
5
|
+
invalid-state contract: invalid text NEVER silently drops or half-parses —
|
|
6
|
+
the field surfaces the parse error, emits NOTHING, and reports invalidity
|
|
7
|
+
through `oninvalid` so the host form can block submit. Empty and `{}` are
|
|
8
|
+
distinct (empty → null).
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
<JsonEditor
|
|
12
|
+
label="METADATA"
|
|
13
|
+
value={{ kind: 'sensor' }}
|
|
14
|
+
onchange={(v) => save(v)}
|
|
15
|
+
oninvalid={(message) => (blocked = message !== null)}
|
|
16
|
+
/>
|
|
17
|
+
-->
|
|
18
|
+
<script module>
|
|
19
|
+
let _jsonEditorUid = 0;
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script>
|
|
23
|
+
import CodeEditor from './CodeEditor.svelte';
|
|
24
|
+
import { evaluateJsonDraft, formatJsonValue } from './json-editor-contract';
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
/** @type {unknown} — the current JSON value (object/array/scalar/null) */
|
|
28
|
+
value = null,
|
|
29
|
+
/** @type {string | undefined} */
|
|
30
|
+
label = undefined,
|
|
31
|
+
/** @type {string | undefined} */
|
|
32
|
+
help = undefined,
|
|
33
|
+
/** @type {string | undefined} — EXTERNAL (server) error; parse errors
|
|
34
|
+
* render through the same chrome automatically */
|
|
35
|
+
error = undefined,
|
|
36
|
+
/** @type {boolean} */
|
|
37
|
+
disabled = false,
|
|
38
|
+
/** @type {boolean} */
|
|
39
|
+
readonly = false,
|
|
40
|
+
/** @type {((value: unknown) => void) | undefined} — fires ONLY on a
|
|
41
|
+
* valid parse (or empty → null); never fires for invalid text */
|
|
42
|
+
onchange = undefined,
|
|
43
|
+
/** @type {((message: string | null) => void) | undefined} — parse-state
|
|
44
|
+
* transitions: a message while the draft is invalid, null when it
|
|
45
|
+
* becomes valid again. The host blocks submit while non-null. */
|
|
46
|
+
oninvalid = undefined,
|
|
47
|
+
/** @type {string} */
|
|
48
|
+
class: className = '',
|
|
49
|
+
...rest
|
|
50
|
+
} = $props();
|
|
51
|
+
|
|
52
|
+
const editorId = `json-editor-${_jsonEditorUid++}`;
|
|
53
|
+
const hintId = `${editorId}-hint`;
|
|
54
|
+
|
|
55
|
+
let text = $state(formatJsonValue(value));
|
|
56
|
+
let parseError = $state(/** @type {string | null} */ (null));
|
|
57
|
+
|
|
58
|
+
// Watch the CodeEditor-bound text: evaluate on every draft change. The
|
|
59
|
+
// pipeline itself is the pure json-editor-contract module (CodeMirror is
|
|
60
|
+
// not edit-drivable under jsdom, so the contract carries the test weight).
|
|
61
|
+
let lastEvaluated = text;
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (text === lastEvaluated) return;
|
|
64
|
+
lastEvaluated = text;
|
|
65
|
+
const result = evaluateJsonDraft(text);
|
|
66
|
+
if (result.ok) {
|
|
67
|
+
parseError = null;
|
|
68
|
+
oninvalid?.(null);
|
|
69
|
+
onchange?.(result.value);
|
|
70
|
+
} else {
|
|
71
|
+
parseError = result.error;
|
|
72
|
+
oninvalid?.(result.error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const shownError = $derived(error ?? parseError ?? undefined);
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<div class="json-editor {className}" {...rest}>
|
|
80
|
+
{#if label}
|
|
81
|
+
<span class="json-editor-label" id={`${editorId}-label`}>{label}</span>
|
|
82
|
+
{/if}
|
|
83
|
+
<div
|
|
84
|
+
class="json-editor-surface"
|
|
85
|
+
class:json-editor-error={!!shownError}
|
|
86
|
+
class:json-editor-disabled={disabled}
|
|
87
|
+
aria-labelledby={label ? `${editorId}-label` : undefined}
|
|
88
|
+
aria-describedby={shownError || help ? hintId : undefined}
|
|
89
|
+
>
|
|
90
|
+
<CodeEditor
|
|
91
|
+
bind:value={text}
|
|
92
|
+
language="json"
|
|
93
|
+
readonly={readonly || disabled}
|
|
94
|
+
lineNumbers={false}
|
|
95
|
+
minLines={4}
|
|
96
|
+
maxLines={16}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
{#if shownError}
|
|
100
|
+
<span id={hintId} class="json-editor-error-text" role="alert">{shownError}</span>
|
|
101
|
+
{:else if help}
|
|
102
|
+
<span id={hintId} class="json-editor-help">{help}</span>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
.json-editor {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: var(--input-label-gap);
|
|
111
|
+
width: 100%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.json-editor-label {
|
|
115
|
+
font-family: var(--input-label-font);
|
|
116
|
+
font-size: var(--input-label-size);
|
|
117
|
+
letter-spacing: var(--input-label-tracking);
|
|
118
|
+
color: var(--input-label-color);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.json-editor-surface {
|
|
122
|
+
border: var(--input-border);
|
|
123
|
+
border-radius: var(--input-radius);
|
|
124
|
+
background: var(--input-bg);
|
|
125
|
+
padding: var(--space-xs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.json-editor-surface:focus-within {
|
|
129
|
+
border: var(--input-border-focus);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.json-editor-error {
|
|
133
|
+
border-color: var(--input-error-border-color);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.json-editor-disabled {
|
|
137
|
+
opacity: 0.5;
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.json-editor-help {
|
|
142
|
+
font-family: var(--input-help-font);
|
|
143
|
+
font-size: var(--input-help-size);
|
|
144
|
+
color: var(--input-help-color);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.json-editor-error-text {
|
|
148
|
+
font-family: var(--input-help-font);
|
|
149
|
+
font-size: var(--input-help-size);
|
|
150
|
+
color: var(--input-error-text);
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default JsonEditor;
|
|
2
|
+
type JsonEditor = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* JsonEditor
|
|
8
|
+
*
|
|
9
|
+
* JSON field over the DS CodeEditor (#38, atelier#669 V1) with an explicit
|
|
10
|
+
* invalid-state contract: invalid text NEVER silently drops or half-parses —
|
|
11
|
+
* the field surfaces the parse error, emits NOTHING, and reports invalidity
|
|
12
|
+
* through `oninvalid` so the host form can block submit. Empty and `{}` are
|
|
13
|
+
* distinct (empty → null).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <JsonEditor
|
|
17
|
+
* label="METADATA"
|
|
18
|
+
* value={{ kind: 'sensor' }}
|
|
19
|
+
* onchange={(v) => save(v)}
|
|
20
|
+
* oninvalid={(message) => (blocked = message !== null)}
|
|
21
|
+
* />
|
|
22
|
+
*/
|
|
23
|
+
declare const JsonEditor: import("svelte").Component<{
|
|
24
|
+
value?: any;
|
|
25
|
+
label?: any;
|
|
26
|
+
help?: any;
|
|
27
|
+
error?: any;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
readonly?: boolean;
|
|
30
|
+
onchange?: any;
|
|
31
|
+
oninvalid?: any;
|
|
32
|
+
class?: string;
|
|
33
|
+
} & Record<string, any>, {}, "">;
|
|
34
|
+
type $$ComponentProps = {
|
|
35
|
+
value?: any;
|
|
36
|
+
label?: any;
|
|
37
|
+
help?: any;
|
|
38
|
+
error?: any;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
readonly?: boolean;
|
|
41
|
+
onchange?: any;
|
|
42
|
+
oninvalid?: any;
|
|
43
|
+
class?: string;
|
|
44
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component MoneyInput
|
|
3
|
+
|
|
4
|
+
Number-semantics input with a currency affordance (#35, atelier#669 V1).
|
|
5
|
+
The WIRE value is always a raw number (or null when empty — empty and 0
|
|
6
|
+
are distinct); locale-aware currency formatting appears while the field is
|
|
7
|
+
NOT being edited; focus switches to the raw editable number.
|
|
8
|
+
Consumes --input-* tokens (mirrors Input's field chrome).
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
<MoneyInput label="BUDGET" value={1234.5} onchange={(n) => save(n)} />
|
|
12
|
+
|
|
13
|
+
@example Non-default currency
|
|
14
|
+
<MoneyInput label="FEE" currency="USD" locale="en-US" />
|
|
15
|
+
-->
|
|
16
|
+
<script module>
|
|
17
|
+
let _moneyUid = 0;
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
let {
|
|
22
|
+
/** @type {number | null} — the raw amount; null = empty */
|
|
23
|
+
value = null,
|
|
24
|
+
/** @type {string} — ISO 4217 code for the display affordance */
|
|
25
|
+
currency = 'EUR',
|
|
26
|
+
/** @type {string | undefined} — BCP 47; defaults to the runtime locale */
|
|
27
|
+
locale = undefined,
|
|
28
|
+
/** @type {string | undefined} */
|
|
29
|
+
label = undefined,
|
|
30
|
+
/** @type {string | undefined} */
|
|
31
|
+
placeholder = undefined,
|
|
32
|
+
/** @type {string | undefined} */
|
|
33
|
+
help = undefined,
|
|
34
|
+
/** @type {string | undefined} */
|
|
35
|
+
error = undefined,
|
|
36
|
+
/** @type {'sm' | 'md' | 'lg'} */
|
|
37
|
+
size = 'md',
|
|
38
|
+
/** @type {boolean} */
|
|
39
|
+
disabled = false,
|
|
40
|
+
/** @type {boolean} */
|
|
41
|
+
readonly = false,
|
|
42
|
+
/** @type {string | undefined} */
|
|
43
|
+
id = undefined,
|
|
44
|
+
/** @type {string | undefined} */
|
|
45
|
+
name = undefined,
|
|
46
|
+
/** @type {((value: number | null) => void) | undefined} — fires with the
|
|
47
|
+
* RAW number (never a formatted string); null when cleared/unparseable */
|
|
48
|
+
onchange = undefined,
|
|
49
|
+
/** @type {string} */
|
|
50
|
+
class: className = '',
|
|
51
|
+
...rest
|
|
52
|
+
} = $props();
|
|
53
|
+
|
|
54
|
+
const fallbackId = `money-input-${_moneyUid++}`;
|
|
55
|
+
const inputId = $derived(id ?? fallbackId);
|
|
56
|
+
const hintId = $derived(`${inputId}-hint`);
|
|
57
|
+
const hasHint = $derived(!!error || !!help);
|
|
58
|
+
|
|
59
|
+
let focused = $state(false);
|
|
60
|
+
let draft = $state('');
|
|
61
|
+
|
|
62
|
+
const formatter = $derived(
|
|
63
|
+
new Intl.NumberFormat(locale, { style: 'currency', currency }),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const display = $derived(
|
|
67
|
+
focused
|
|
68
|
+
? draft
|
|
69
|
+
: value === null || value === undefined
|
|
70
|
+
? ''
|
|
71
|
+
: formatter.format(Number(value)),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse the operator's raw text onto a number: strips grouping spaces,
|
|
76
|
+
* accepts a comma decimal. Empty/unparseable → null (never NaN, never a
|
|
77
|
+
* silent 0 — empty and 0 stay distinct).
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @returns {number | null}
|
|
80
|
+
*/
|
|
81
|
+
function parseAmount(text) {
|
|
82
|
+
const trimmed = text.trim().replace(/\s+/g, '');
|
|
83
|
+
if (!trimmed) return null;
|
|
84
|
+
const normalized =
|
|
85
|
+
trimmed.includes(',') && !trimmed.includes('.')
|
|
86
|
+
? trimmed.replace(',', '.')
|
|
87
|
+
: trimmed;
|
|
88
|
+
const parsed = Number(normalized);
|
|
89
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleFocus() {
|
|
93
|
+
if (readonly || disabled) return;
|
|
94
|
+
draft = value === null || value === undefined ? '' : String(value);
|
|
95
|
+
focused = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {Event} event */
|
|
99
|
+
function handleInput(event) {
|
|
100
|
+
draft = /** @type {HTMLInputElement} */ (event.currentTarget).value;
|
|
101
|
+
const parsed = parseAmount(draft);
|
|
102
|
+
value = parsed;
|
|
103
|
+
onchange?.(parsed);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleBlur() {
|
|
107
|
+
focused = false;
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<div class="money-input input-group {className}">
|
|
112
|
+
{#if label}
|
|
113
|
+
<label class="input-label" for={inputId}>{label}</label>
|
|
114
|
+
{/if}
|
|
115
|
+
|
|
116
|
+
<input
|
|
117
|
+
id={inputId}
|
|
118
|
+
{name}
|
|
119
|
+
type="text"
|
|
120
|
+
inputmode="decimal"
|
|
121
|
+
class="input input-{size}"
|
|
122
|
+
class:input-error={!!error}
|
|
123
|
+
class:input-readonly={readonly}
|
|
124
|
+
aria-invalid={error ? true : undefined}
|
|
125
|
+
aria-describedby={hasHint ? hintId : undefined}
|
|
126
|
+
{placeholder}
|
|
127
|
+
{disabled}
|
|
128
|
+
{readonly}
|
|
129
|
+
value={display}
|
|
130
|
+
onfocus={handleFocus}
|
|
131
|
+
oninput={handleInput}
|
|
132
|
+
onblur={handleBlur}
|
|
133
|
+
autocomplete="off"
|
|
134
|
+
{...rest}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{#if error}
|
|
138
|
+
<span id={hintId} class="input-error-text" role="alert">{error}</span>
|
|
139
|
+
{:else if help}
|
|
140
|
+
<span id={hintId} class="input-help">{help}</span>
|
|
141
|
+
{/if}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
.money-input {
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-direction: column;
|
|
148
|
+
gap: var(--input-label-gap);
|
|
149
|
+
width: 100%;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.input-label {
|
|
153
|
+
font-family: var(--input-label-font);
|
|
154
|
+
font-size: var(--input-label-size);
|
|
155
|
+
letter-spacing: var(--input-label-tracking);
|
|
156
|
+
color: var(--input-label-color);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.input {
|
|
160
|
+
font-family: var(--input-font);
|
|
161
|
+
font-size: var(--input-font-size);
|
|
162
|
+
border: var(--input-border);
|
|
163
|
+
border-radius: var(--input-radius);
|
|
164
|
+
background: var(--input-bg);
|
|
165
|
+
color: var(--input-text);
|
|
166
|
+
transition: border var(--input-transition);
|
|
167
|
+
width: 100%;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.input-sm {
|
|
171
|
+
height: var(--input-sm-height);
|
|
172
|
+
padding: 0 var(--input-sm-padding-x);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.input-md {
|
|
176
|
+
height: var(--input-md-height);
|
|
177
|
+
padding: 0 var(--input-md-padding-x);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.input-lg {
|
|
181
|
+
height: var(--input-lg-height);
|
|
182
|
+
padding: 0 var(--input-lg-padding-x);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.input::placeholder {
|
|
186
|
+
color: var(--input-placeholder);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.input:focus {
|
|
190
|
+
outline: none;
|
|
191
|
+
border: var(--input-border-focus);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.input:disabled {
|
|
195
|
+
opacity: 0.5;
|
|
196
|
+
cursor: not-allowed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.input-readonly {
|
|
200
|
+
background: var(--color-surface-secondary);
|
|
201
|
+
cursor: default;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.input-error {
|
|
205
|
+
border-color: var(--input-error-border-color);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.input-help {
|
|
209
|
+
font-family: var(--input-help-font);
|
|
210
|
+
font-size: var(--input-help-size);
|
|
211
|
+
color: var(--input-help-color);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.input-error-text {
|
|
215
|
+
font-family: var(--input-help-font);
|
|
216
|
+
font-size: var(--input-help-size);
|
|
217
|
+
color: var(--input-error-text);
|
|
218
|
+
}
|
|
219
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export default MoneyInput;
|
|
2
|
+
type MoneyInput = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* MoneyInput
|
|
8
|
+
*
|
|
9
|
+
* Number-semantics input with a currency affordance (#35, atelier#669 V1).
|
|
10
|
+
* The WIRE value is always a raw number (or null when empty — empty and 0
|
|
11
|
+
* are distinct); locale-aware currency formatting appears while the field is
|
|
12
|
+
* NOT being edited; focus switches to the raw editable number.
|
|
13
|
+
* Consumes --input-* tokens (mirrors Input's field chrome).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <MoneyInput label="BUDGET" value={1234.5} onchange={(n) => save(n)} />
|
|
17
|
+
*
|
|
18
|
+
* @example Non-default currency
|
|
19
|
+
* <MoneyInput label="FEE" currency="USD" locale="en-US" />
|
|
20
|
+
*/
|
|
21
|
+
declare const MoneyInput: import("svelte").Component<{
|
|
22
|
+
value?: any;
|
|
23
|
+
currency?: string;
|
|
24
|
+
locale?: any;
|
|
25
|
+
label?: any;
|
|
26
|
+
placeholder?: any;
|
|
27
|
+
help?: any;
|
|
28
|
+
error?: any;
|
|
29
|
+
size?: string;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
readonly?: boolean;
|
|
32
|
+
id?: any;
|
|
33
|
+
name?: any;
|
|
34
|
+
onchange?: any;
|
|
35
|
+
class?: string;
|
|
36
|
+
} & Record<string, any>, {}, "">;
|
|
37
|
+
type $$ComponentProps = {
|
|
38
|
+
value?: any;
|
|
39
|
+
currency?: string;
|
|
40
|
+
locale?: any;
|
|
41
|
+
label?: any;
|
|
42
|
+
placeholder?: any;
|
|
43
|
+
help?: any;
|
|
44
|
+
error?: any;
|
|
45
|
+
size?: string;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
readonly?: boolean;
|
|
48
|
+
id?: any;
|
|
49
|
+
name?: any;
|
|
50
|
+
onchange?: any;
|
|
51
|
+
class?: string;
|
|
52
|
+
} & Record<string, any>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component MultiSelectCombobox
|
|
3
|
+
|
|
4
|
+
Multi-value picking: removable Tag chips over a search-to-add Combobox
|
|
5
|
+
(the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
|
|
6
|
+
the parent owns the selection list AND the async item source — the widget
|
|
7
|
+
never holds the selection in its input (a single-pick replace of a
|
|
8
|
+
multi-value field is impossible by construction).
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
<MultiSelectCombobox
|
|
12
|
+
label="INSPECTORS"
|
|
13
|
+
items={searchResults}
|
|
14
|
+
selected={[{ value: 'u-1', label: 'Ana Silva' }]}
|
|
15
|
+
onsearch={(q) => search(q)}
|
|
16
|
+
onadd={(value) => add(value)}
|
|
17
|
+
onremove={(value) => remove(value)}
|
|
18
|
+
/>
|
|
19
|
+
-->
|
|
20
|
+
<script module>
|
|
21
|
+
let _mscUid = 0;
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script>
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ value: string, label: string, group?: string, description?: string }} ComboboxItem
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import Combobox from './Combobox.svelte';
|
|
30
|
+
import Tag from './Tag.svelte';
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
/** @type {ComboboxItem[]} — the search-result pool (parent-owned; async via onsearch) */
|
|
34
|
+
items = [],
|
|
35
|
+
/** @type {ComboboxItem[]} — the current selection, WITH display labels */
|
|
36
|
+
selected = [],
|
|
37
|
+
/** @type {string | undefined} */
|
|
38
|
+
label = undefined,
|
|
39
|
+
/** @type {string | undefined} */
|
|
40
|
+
placeholder = 'Search...',
|
|
41
|
+
/** @type {boolean} */
|
|
42
|
+
disabled = false,
|
|
43
|
+
/** @type {boolean} */
|
|
44
|
+
loading = false,
|
|
45
|
+
/** @type {string | undefined} */
|
|
46
|
+
error = undefined,
|
|
47
|
+
/** @type {string | undefined} */
|
|
48
|
+
help = undefined,
|
|
49
|
+
/** @type {boolean} */
|
|
50
|
+
required = false,
|
|
51
|
+
/** @type {string} */
|
|
52
|
+
size = 'md',
|
|
53
|
+
/** @type {((value: string) => void) | undefined} — a NEW value was picked */
|
|
54
|
+
onadd = undefined,
|
|
55
|
+
/** @type {((value: string) => void) | undefined} — a chip was removed */
|
|
56
|
+
onremove = undefined,
|
|
57
|
+
/** @type {((query: string) => void) | undefined} — async search; parent owns items */
|
|
58
|
+
onsearch = undefined,
|
|
59
|
+
/** @type {string} */
|
|
60
|
+
class: className = '',
|
|
61
|
+
...rest
|
|
62
|
+
} = $props();
|
|
63
|
+
|
|
64
|
+
const mscId = `msc-${_mscUid++}`;
|
|
65
|
+
|
|
66
|
+
// The draft is WIDGET-internal: it exists only to drive the Combobox and is
|
|
67
|
+
// cleared after every pick, so the input never displays a "selection".
|
|
68
|
+
let draft = $state('');
|
|
69
|
+
|
|
70
|
+
// An already-picked value never re-offers (no double-add by construction).
|
|
71
|
+
const available = $derived(
|
|
72
|
+
items.filter((item) => !selected.some((entry) => entry.value === item.value)),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** @param {string} value */
|
|
76
|
+
function handlePick(value) {
|
|
77
|
+
if (value && !selected.some((entry) => entry.value === value)) {
|
|
78
|
+
onadd?.(value);
|
|
79
|
+
}
|
|
80
|
+
draft = '';
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<div
|
|
85
|
+
class="msc {className}"
|
|
86
|
+
role="group"
|
|
87
|
+
aria-labelledby={label ? `${mscId}-label` : undefined}
|
|
88
|
+
{...rest}
|
|
89
|
+
>
|
|
90
|
+
{#if label}
|
|
91
|
+
<span id={`${mscId}-label`} class="msc-label"
|
|
92
|
+
>{label}{required ? ' (required)' : ''}</span
|
|
93
|
+
>
|
|
94
|
+
{/if}
|
|
95
|
+
|
|
96
|
+
{#if selected.length}
|
|
97
|
+
<div class="msc-chips">
|
|
98
|
+
{#each selected as entry (entry.value)}
|
|
99
|
+
<Tag removable={!disabled} onremove={() => onremove?.(entry.value)}>
|
|
100
|
+
{entry.label}
|
|
101
|
+
</Tag>
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
<Combobox
|
|
107
|
+
items={available}
|
|
108
|
+
bind:value={draft}
|
|
109
|
+
{placeholder}
|
|
110
|
+
{disabled}
|
|
111
|
+
{loading}
|
|
112
|
+
{error}
|
|
113
|
+
{help}
|
|
114
|
+
{size}
|
|
115
|
+
onsearch={onsearch}
|
|
116
|
+
onchange={handlePick}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.msc {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
gap: var(--space-xs);
|
|
125
|
+
width: 100%;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.msc-label {
|
|
129
|
+
font-family: var(--input-label-font);
|
|
130
|
+
font-size: var(--input-label-size);
|
|
131
|
+
letter-spacing: var(--input-label-tracking);
|
|
132
|
+
color: var(--input-label-color);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.msc-chips {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-wrap: wrap;
|
|
138
|
+
gap: var(--space-xs);
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export default MultiSelectCombobox;
|
|
2
|
+
export type ComboboxItem = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
group?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
};
|
|
8
|
+
type MultiSelectCombobox = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* MultiSelectCombobox
|
|
14
|
+
*
|
|
15
|
+
* Multi-value picking: removable Tag chips over a search-to-add Combobox
|
|
16
|
+
* (the Carbon MultiSelect / Polaris tag-combobox pattern). Presentation-only:
|
|
17
|
+
* the parent owns the selection list AND the async item source — the widget
|
|
18
|
+
* never holds the selection in its input (a single-pick replace of a
|
|
19
|
+
* multi-value field is impossible by construction).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <MultiSelectCombobox
|
|
23
|
+
* label="INSPECTORS"
|
|
24
|
+
* items={searchResults}
|
|
25
|
+
* selected={[{ value: 'u-1', label: 'Ana Silva' }]}
|
|
26
|
+
* onsearch={(q) => search(q)}
|
|
27
|
+
* onadd={(value) => add(value)}
|
|
28
|
+
* onremove={(value) => remove(value)}
|
|
29
|
+
* />
|
|
30
|
+
*/
|
|
31
|
+
declare const MultiSelectCombobox: import("svelte").Component<{
|
|
32
|
+
items?: any[];
|
|
33
|
+
selected?: any[];
|
|
34
|
+
label?: any;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
error?: any;
|
|
39
|
+
help?: any;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
size?: string;
|
|
42
|
+
onadd?: any;
|
|
43
|
+
onremove?: any;
|
|
44
|
+
onsearch?: any;
|
|
45
|
+
class?: string;
|
|
46
|
+
} & Record<string, any>, {}, "">;
|
|
47
|
+
type $$ComponentProps = {
|
|
48
|
+
items?: any[];
|
|
49
|
+
selected?: any[];
|
|
50
|
+
label?: any;
|
|
51
|
+
placeholder?: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
loading?: boolean;
|
|
54
|
+
error?: any;
|
|
55
|
+
help?: any;
|
|
56
|
+
required?: boolean;
|
|
57
|
+
size?: string;
|
|
58
|
+
onadd?: any;
|
|
59
|
+
onremove?: any;
|
|
60
|
+
onsearch?: any;
|
|
61
|
+
class?: string;
|
|
62
|
+
} & Record<string, any>;
|
package/components/Tag.svelte
CHANGED
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
{@render children()}
|
|
34
34
|
{/if}
|
|
35
35
|
{#if removable}
|
|
36
|
+
<!-- type="button": inside a <form> (e.g. ActionFormRenderer m2m chips) a
|
|
37
|
+
typeless button is a SUBMIT button — removing a chip must never
|
|
38
|
+
submit the form. -->
|
|
36
39
|
<button
|
|
40
|
+
type="button"
|
|
37
41
|
class="tag-remove"
|
|
38
42
|
aria-label="Remove tag"
|
|
39
43
|
onclick={(e) => {
|
|
@@ -47,9 +47,12 @@ export interface LayoutSection {
|
|
|
47
47
|
/**
|
|
48
48
|
* #634 S3 — whether a parameter's cell spans the full row inside a
|
|
49
49
|
* `columns: 2` section. An explicit `span: "full"` on the parameter wins;
|
|
50
|
-
* wide
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* wide WIDGET KINDS (map canvas, upload list, textarea, json editor —
|
|
51
|
+
* resolved widget-first per #36, so both the field summary's `widget` and
|
|
52
|
+
* a bare legacy `type` clamp) go full regardless; everything else flows
|
|
53
|
+
* half-width (so a bare `columns: 2` declaration visibly two-columns its
|
|
54
|
+
* fields). In a single-column section every cell is trivially full.
|
|
55
|
+
* Mirrors the CRUD form-surface compilers' FULL_WIDTH_WIDGETS clamp.
|
|
53
56
|
*/
|
|
54
57
|
export declare function fieldSpansFull(parameter: Record<string, unknown>, columns: number | undefined): boolean;
|
|
55
58
|
import type { Snippet } from "svelte";
|