@happyvertical/smrt-ui 0.55.0 → 0.55.10
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/AGENTS.md +2 -1
- package/README.md +113 -5
- package/dist/components/forms/CodeSelect.svelte +12 -2
- package/dist/components/forms/CodeSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +50 -7
- package/dist/components/forms/Combobox.svelte.d.ts +26 -0
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/DictationButton.svelte +110 -3
- package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/DictationStatus.svelte +33 -5
- package/dist/components/forms/DictationStatus.svelte.d.ts +2 -0
- package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -1
- package/dist/components/forms/FieldLabel.svelte +44 -0
- package/dist/components/forms/FieldLabel.svelte.d.ts +20 -0
- package/dist/components/forms/FieldLabel.svelte.d.ts.map +1 -0
- package/dist/components/forms/FormGroup.svelte +2 -17
- package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/RelationInput.svelte +378 -0
- package/dist/components/forms/RelationInput.svelte.d.ts +75 -0
- package/dist/components/forms/RelationInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/code-select-options.d.ts +8 -1
- package/dist/components/forms/code-select-options.d.ts.map +1 -1
- package/dist/components/forms/code-select-options.js +46 -5
- package/dist/components/forms/code-select-types.d.ts +4 -0
- package/dist/components/forms/code-select-types.d.ts.map +1 -1
- package/dist/components/forms/dictation.svelte.d.ts +84 -2
- package/dist/components/forms/dictation.svelte.d.ts.map +1 -1
- package/dist/components/forms/dictation.svelte.js +236 -2
- package/dist/components/forms/hands-free-capture.d.ts +70 -0
- package/dist/components/forms/hands-free-capture.d.ts.map +1 -0
- package/dist/components/forms/hands-free-capture.js +19 -0
- package/dist/components/forms/hands-free-vad.d.ts +5 -0
- package/dist/components/forms/hands-free-vad.d.ts.map +1 -0
- package/dist/components/forms/hands-free-vad.js +80 -0
- package/dist/components/forms/index.d.ts +4 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +3 -0
- package/dist/components/forms/types.d.ts +9 -0
- package/dist/components/forms/types.d.ts.map +1 -1
- package/dist/components/layout/ActionGroup.svelte +55 -0
- package/dist/components/layout/ActionGroup.svelte.d.ts +18 -0
- package/dist/components/layout/ActionGroup.svelte.d.ts.map +1 -0
- package/dist/components/layout/Container.svelte +1 -0
- package/dist/components/layout/Grid.svelte +2 -1
- package/dist/components/layout/PageHeader.svelte +3 -3
- package/dist/components/layout/PageHeader.svelte.d.ts +3 -3
- package/dist/components/layout/PageLayout.svelte +61 -0
- package/dist/components/layout/PageLayout.svelte.d.ts +18 -0
- package/dist/components/layout/PageLayout.svelte.d.ts.map +1 -0
- package/dist/components/layout/index.d.ts +2 -0
- package/dist/components/layout/index.d.ts.map +1 -1
- package/dist/components/layout/index.js +2 -0
- package/dist/components/permissions/PermissionPicker.svelte +213 -0
- package/dist/components/permissions/PermissionPicker.svelte.d.ts +27 -0
- package/dist/components/permissions/PermissionPicker.svelte.d.ts.map +1 -0
- package/dist/currency.d.ts +3 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +2 -0
- package/dist/i18n/strings.ui.d.ts +7 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte +22 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
- package/dist/svelte/playground/PermissionPickerPreview.svelte +27 -0
- package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts +19 -0
- package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts +14 -2
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +10 -0
- package/dist/themes/README.md +5 -0
- package/dist/themes/styles/base.css +31 -0
- package/dist/themes/styles/index.d.ts +2 -0
- package/dist/themes/styles/index.d.ts.map +1 -1
- package/dist/themes/styles/index.js +2 -0
- package/package.json +17 -2
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* DictationStatus — what dictation is doing, in words (see `Dictation`).
|
|
4
4
|
*
|
|
5
|
-
* "Listening…" (with the words heard so far) while the microphone is on
|
|
5
|
+
* "Listening…" (with the words heard so far) while the microphone is on.
|
|
6
|
+
* Hands-free is announced, not shown: the line is visually hidden and says
|
|
7
|
+
* "Listening", "Hearing you…" while someone is speaking and "Writing it down…"
|
|
8
|
+
* while sentences are being written down (the long help text is the
|
|
9
|
+
* microphone's tooltip) and "Paused while the assistant speaks" while
|
|
10
|
+
* listening is suspended. "Sending…" (`sending`) is the one short hands-free
|
|
11
|
+
* hint that stays visible, since a message is about to leave.
|
|
6
12
|
* "Writing it down…" while a recording is turned into text, and
|
|
7
13
|
* a plain explanation when it cannot listen: the browser cannot turn speech
|
|
8
14
|
* into text, the microphone is blocked, or nothing was heard. Nothing while
|
|
@@ -15,20 +21,28 @@ import type { Dictation, DictationErrorKind } from './dictation.svelte.js';
|
|
|
15
21
|
export interface Props {
|
|
16
22
|
/** The dictation session whose listening state and messages this shows. */
|
|
17
23
|
dictation: Dictation;
|
|
24
|
+
/** A message is about to be sent (hands-free send on pause): shows "Sending…". */
|
|
25
|
+
sending?: boolean;
|
|
18
26
|
/** Extra class names. */
|
|
19
27
|
class?: string;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
let { dictation, class: className = '' }: Props = $props();
|
|
30
|
+
let { dictation, sending = false, class: className = '' }: Props = $props();
|
|
23
31
|
|
|
24
32
|
const { t } = useI18n();
|
|
25
33
|
const showLine = $derived(
|
|
26
|
-
|
|
34
|
+
sending ||
|
|
35
|
+
dictation.state === 'starting' ||
|
|
27
36
|
dictation.state === 'listening' ||
|
|
28
37
|
dictation.state === 'stopping' ||
|
|
29
38
|
dictation.state === 'transcribing',
|
|
30
39
|
);
|
|
31
40
|
|
|
41
|
+
// Hands-free states are announced to screen readers only.
|
|
42
|
+
const announceOnly = $derived(
|
|
43
|
+
!sending && dictation.handsFree && dictation.state === 'listening',
|
|
44
|
+
);
|
|
45
|
+
|
|
32
46
|
function errorText(kind: DictationErrorKind | null): string {
|
|
33
47
|
switch (kind) {
|
|
34
48
|
case 'unsupported':
|
|
@@ -47,6 +61,8 @@ function errorText(kind: DictationErrorKind | null): string {
|
|
|
47
61
|
return t(M['ui.dictation.not_transcribed']);
|
|
48
62
|
case 'unavailable':
|
|
49
63
|
return t(M['ui.dictation.unavailable']);
|
|
64
|
+
case 'model-missing':
|
|
65
|
+
return t(M['ui.dictation.model_missing']);
|
|
50
66
|
case 'forbidden':
|
|
51
67
|
return t(M['ui.dictation.forbidden']);
|
|
52
68
|
default:
|
|
@@ -58,12 +74,24 @@ function errorText(kind: DictationErrorKind | null): string {
|
|
|
58
74
|
<div class={`smrt-dictation-status ${className}`.trim()}>
|
|
59
75
|
<p
|
|
60
76
|
class="smrt-dictation-status-line"
|
|
61
|
-
class:smrt-dictation-status--empty={!showLine}
|
|
77
|
+
class:smrt-dictation-status--empty={!showLine || announceOnly}
|
|
62
78
|
role="status"
|
|
63
79
|
aria-live="polite"
|
|
64
80
|
>
|
|
65
|
-
{#if
|
|
81
|
+
{#if sending}
|
|
82
|
+
{t(M['ui.dictation.sending'])}
|
|
83
|
+
{:else if dictation.state === 'starting'}
|
|
66
84
|
{t(M['ui.dictation.starting'])}
|
|
85
|
+
{:else if dictation.handsFree && dictation.state === 'listening'}
|
|
86
|
+
{#if dictation.suspended}
|
|
87
|
+
{t(M['ui.dictation.paused_speaking'])}
|
|
88
|
+
{:else if dictation.queued > 0}
|
|
89
|
+
{t(M['ui.dictation.transcribing'])}
|
|
90
|
+
{:else if dictation.speaking}
|
|
91
|
+
{t(M['ui.dictation.hearing'])}
|
|
92
|
+
{:else}
|
|
93
|
+
{t(M['ui.dictation.listening_short'])}
|
|
94
|
+
{/if}
|
|
67
95
|
{:else if dictation.state === 'listening' || dictation.state === 'stopping'}
|
|
68
96
|
<span class="smrt-dictation-status-dot" aria-hidden="true"></span>
|
|
69
97
|
{t(M['ui.dictation.listening'])}
|
|
@@ -2,6 +2,8 @@ import type { Dictation } from './dictation.svelte.js';
|
|
|
2
2
|
export interface Props {
|
|
3
3
|
/** The dictation session whose listening state and messages this shows. */
|
|
4
4
|
dictation: Dictation;
|
|
5
|
+
/** A message is about to be sent (hands-free send on pause): shows "Sending…". */
|
|
6
|
+
sending?: boolean;
|
|
5
7
|
/** Extra class names. */
|
|
6
8
|
class?: string;
|
|
7
9
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DictationStatus.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DictationStatus.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"DictationStatus.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DictationStatus.svelte.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,uBAAuB,CAAC;AAG3E,MAAM,WAAW,KAAK;IACpB,2EAA2E;IAC3E,SAAS,EAAE,SAAS,CAAC;IACrB,kFAAkF;IAClF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA4FD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The one label treatment for generated form fields: a plain label above its
|
|
4
|
+
* control. Every field component (text, money, date, select, relation, …) and
|
|
5
|
+
* every read-only field display renders this, so size, weight, colour and
|
|
6
|
+
* spacing cannot drift between field types.
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Label text. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Id of the labelled control (`for`). Omit for read-only displays. */
|
|
12
|
+
for?: string;
|
|
13
|
+
/** Id of the label element, for `aria-labelledby` on the control. */
|
|
14
|
+
id?: string;
|
|
15
|
+
/** Shows the required marker. */
|
|
16
|
+
required?: boolean;
|
|
17
|
+
}
|
|
18
|
+
let { label, for: forId, id, required = false }: Props = $props();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
{#if forId}
|
|
22
|
+
<label {id} for={forId} class="smrt-field-label">{label}{#if required}<span class="required" aria-hidden="true">*</span>{/if}</label>
|
|
23
|
+
{:else}
|
|
24
|
+
<span {id} class="smrt-field-label">{label}{#if required}<span class="required" aria-hidden="true">*</span>{/if}</span>
|
|
25
|
+
{/if}
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.smrt-field-label {
|
|
29
|
+
display: block;
|
|
30
|
+
margin-bottom: var(--smrt-spacing-1, 4px);
|
|
31
|
+
font-size: var(--smrt-typography-label-large-size, 0.875rem);
|
|
32
|
+
font-weight: var(--smrt-typography-weight-medium, 500);
|
|
33
|
+
line-height: var(--smrt-typography-label-large-line-height, 1.4);
|
|
34
|
+
letter-spacing: normal;
|
|
35
|
+
text-transform: none;
|
|
36
|
+
font-family: inherit;
|
|
37
|
+
color: var(--smrt-color-on-surface, #374151);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.required {
|
|
41
|
+
color: var(--smrt-color-error, #ba1a1a);
|
|
42
|
+
margin-left: var(--smrt-spacing-1, 4px);
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one label treatment for generated form fields: a plain label above its
|
|
3
|
+
* control. Every field component (text, money, date, select, relation, …) and
|
|
4
|
+
* every read-only field display renders this, so size, weight, colour and
|
|
5
|
+
* spacing cannot drift between field types.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Label text. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Id of the labelled control (`for`). Omit for read-only displays. */
|
|
11
|
+
for?: string;
|
|
12
|
+
/** Id of the label element, for `aria-labelledby` on the control. */
|
|
13
|
+
id?: string;
|
|
14
|
+
/** Shows the required marker. */
|
|
15
|
+
required?: boolean;
|
|
16
|
+
}
|
|
17
|
+
declare const FieldLabel: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type FieldLabel = ReturnType<typeof FieldLabel>;
|
|
19
|
+
export default FieldLabel;
|
|
20
|
+
//# sourceMappingURL=FieldLabel.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldLabel.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FieldLabel.svelte.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,UAAU,KAAK;IACb,kBAAkB;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAgBD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { ControlInteractionOptions } from './control-interaction.js';
|
|
4
|
+
import FieldLabel from './FieldLabel.svelte';
|
|
4
5
|
import {
|
|
5
6
|
type FormGroupContextValue,
|
|
6
7
|
nextFieldId,
|
|
@@ -62,12 +63,7 @@ setFormGroupContext(
|
|
|
62
63
|
</script>
|
|
63
64
|
|
|
64
65
|
<div class="form-group">
|
|
65
|
-
<
|
|
66
|
-
{label}
|
|
67
|
-
{#if required}
|
|
68
|
-
<span class="required" aria-hidden="true">*</span>
|
|
69
|
-
{/if}
|
|
70
|
-
</label>
|
|
66
|
+
<FieldLabel for={fieldId} {label} {required} />
|
|
71
67
|
{@render children()}
|
|
72
68
|
{#if hintId}
|
|
73
69
|
<p id={hintId} class="form-hint">{hint}</p>
|
|
@@ -82,18 +78,7 @@ setFormGroupContext(
|
|
|
82
78
|
margin-bottom: 1rem;
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
.form-label {
|
|
86
|
-
display: block;
|
|
87
|
-
font-size: var(--smrt-typography-label-large-size, 0.875rem);
|
|
88
|
-
font-weight: var(--smrt-typography-weight-medium, 500);
|
|
89
|
-
color: var(--smrt-color-on-surface, #374151);
|
|
90
|
-
margin-bottom: 0.375rem;
|
|
91
|
-
}
|
|
92
81
|
|
|
93
|
-
.required {
|
|
94
|
-
color: var(--smrt-color-error, #ba1a1a);
|
|
95
|
-
margin-left: 0.125rem;
|
|
96
|
-
}
|
|
97
82
|
|
|
98
83
|
.form-hint {
|
|
99
84
|
margin: 0.25rem 0 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FormGroup.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FormGroup.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"FormGroup.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/FormGroup.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAS1E,MAAM,WAAW,KAAK;IACpB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD,6DAA6D;IAC7D,QAAQ,EAAE,OAAO,CAAC;CACnB;AA4DD,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import Button from '../ui/Button.svelte';
|
|
4
|
+
import Combobox from './Combobox.svelte';
|
|
5
|
+
import { emitControlChange } from './control-dom.js';
|
|
6
|
+
import type { ControlInteractionOptions } from './control-interaction.js';
|
|
7
|
+
import {
|
|
8
|
+
recordControlUserEdit,
|
|
9
|
+
tryGetControlInteractionContext,
|
|
10
|
+
} from './control-interaction-context.js';
|
|
11
|
+
import type { RelationOption } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* RelationInput - searchable single-select for relation (foreign key) fields.
|
|
15
|
+
*
|
|
16
|
+
* Presentation only: the caller supplies `search` (and optionally `resolve`),
|
|
17
|
+
* so the same lookup an assistant uses to turn "Acme" into a record id backs
|
|
18
|
+
* the picker. Built on the smrt-ui `Combobox`, which supplies the ARIA 1.2
|
|
19
|
+
* combobox pattern (arrow keys, Enter, Escape, `aria-activedescendant`) and
|
|
20
|
+
* registers with the rich Form's control registry. The form posts the
|
|
21
|
+
* committed id under `name`, never the visible label.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```svelte
|
|
25
|
+
* <RelationInput
|
|
26
|
+
* name="customerId"
|
|
27
|
+
* label="Customer"
|
|
28
|
+
* bind:value={customerId}
|
|
29
|
+
* search={(q) => customers.search(q)}
|
|
30
|
+
* resolve={(id) => customers.byId(id)}
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export interface Props {
|
|
35
|
+
/** Field name; the selected id posts under it. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Visible label. */
|
|
38
|
+
label: string;
|
|
39
|
+
/** Selected record id (bindable). Empty string means no selection. */
|
|
40
|
+
value?: string;
|
|
41
|
+
/** Required field: hides the clear button and flags an empty value. */
|
|
42
|
+
required?: boolean;
|
|
43
|
+
/** Disabled state. */
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Validation error message, shown below the field. */
|
|
46
|
+
error?: string;
|
|
47
|
+
/** Placeholder shown when nothing is selected. */
|
|
48
|
+
placeholder?: string;
|
|
49
|
+
/** Hint announced with the field. */
|
|
50
|
+
description?: string;
|
|
51
|
+
/** Look up records matching the text; called with '' when the list opens. */
|
|
52
|
+
search: (query: string) => Promise<RelationOption[]>;
|
|
53
|
+
/** Look up the current value's label when it has not been searched yet. */
|
|
54
|
+
resolve?: (id: string) => Promise<{ id: string; label: string } | null>;
|
|
55
|
+
/**
|
|
56
|
+
* "New ..." action. Receives the text most recently searched for. May
|
|
57
|
+
* return the created record, which is then selected.
|
|
58
|
+
*/
|
|
59
|
+
onCreate?: (
|
|
60
|
+
query: string,
|
|
61
|
+
) => void | RelationOption | Promise<void | RelationOption>;
|
|
62
|
+
/** Milliseconds to wait after typing before searching. */
|
|
63
|
+
debounceMs?: number;
|
|
64
|
+
/** Combobox interaction options (stable id, subject) for agents. */
|
|
65
|
+
interaction?: ControlInteractionOptions | false;
|
|
66
|
+
/** Text for the "New ..." button. */
|
|
67
|
+
createLabel?: string;
|
|
68
|
+
/** Accessible name for the clear button. */
|
|
69
|
+
clearLabel?: string;
|
|
70
|
+
/** Status text while searching. */
|
|
71
|
+
loadingText?: string;
|
|
72
|
+
/** Status text when nothing matches. */
|
|
73
|
+
emptyText?: string;
|
|
74
|
+
/** Status text when the search fails. */
|
|
75
|
+
errorText?: string;
|
|
76
|
+
/** Status text announcing how many results are listed. */
|
|
77
|
+
resultsText?: (count: number) => string;
|
|
78
|
+
/** Called when the selected id changes ('' when cleared). */
|
|
79
|
+
onchange?: (value: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let {
|
|
83
|
+
name,
|
|
84
|
+
label,
|
|
85
|
+
value = $bindable(''),
|
|
86
|
+
required = false,
|
|
87
|
+
disabled = false,
|
|
88
|
+
error,
|
|
89
|
+
placeholder,
|
|
90
|
+
description,
|
|
91
|
+
search,
|
|
92
|
+
resolve,
|
|
93
|
+
onCreate,
|
|
94
|
+
debounceMs = 250,
|
|
95
|
+
interaction,
|
|
96
|
+
createLabel,
|
|
97
|
+
clearLabel,
|
|
98
|
+
loadingText = 'Searching…',
|
|
99
|
+
emptyText = 'No matches',
|
|
100
|
+
errorText = 'Search failed. Try again.',
|
|
101
|
+
resultsText = (count: number) =>
|
|
102
|
+
`${count} ${count === 1 ? 'result' : 'results'} available`,
|
|
103
|
+
onchange,
|
|
104
|
+
}: Props = $props();
|
|
105
|
+
|
|
106
|
+
const instanceId = $props.id();
|
|
107
|
+
const errorId = `${instanceId}-error`;
|
|
108
|
+
const hintId = `${instanceId}-hint`;
|
|
109
|
+
const interactionContext = tryGetControlInteractionContext();
|
|
110
|
+
|
|
111
|
+
let rootEl = $state<HTMLDivElement | null>(null);
|
|
112
|
+
let results = $state<RelationOption[]>([]);
|
|
113
|
+
let loading = $state(false);
|
|
114
|
+
let failed = $state(false);
|
|
115
|
+
let selectedLabel = $state('');
|
|
116
|
+
let creating = $state(false);
|
|
117
|
+
|
|
118
|
+
// Plain bookkeeping, deliberately not reactive.
|
|
119
|
+
let labelledId = '';
|
|
120
|
+
let resolvingId = '';
|
|
121
|
+
let searchSeq = 0;
|
|
122
|
+
let lastQuery = '';
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
|
|
125
|
+
const options = $derived(
|
|
126
|
+
results.map((item) => ({ value: item.id, label: item.label })),
|
|
127
|
+
);
|
|
128
|
+
const detailById = $derived(
|
|
129
|
+
new Map(results.map((item) => [item.id, item.detail])),
|
|
130
|
+
);
|
|
131
|
+
const status = $derived(
|
|
132
|
+
loading
|
|
133
|
+
? loadingText
|
|
134
|
+
: failed
|
|
135
|
+
? errorText
|
|
136
|
+
: results.length === 0
|
|
137
|
+
? emptyText
|
|
138
|
+
: resultsText(results.length),
|
|
139
|
+
);
|
|
140
|
+
const describedby = $derived(
|
|
141
|
+
[error ? errorId : '', description ? hintId : ''].filter(Boolean).join(' ') ||
|
|
142
|
+
undefined,
|
|
143
|
+
);
|
|
144
|
+
const controlId = $derived(
|
|
145
|
+
interaction === false ? undefined : (interaction?.id ?? name),
|
|
146
|
+
);
|
|
147
|
+
const canClear = $derived(!required && !disabled && value !== '');
|
|
148
|
+
const newLabel = $derived(createLabel ?? `New ${label.toLowerCase()}`);
|
|
149
|
+
|
|
150
|
+
async function runSearch(query: string) {
|
|
151
|
+
const seq = ++searchSeq;
|
|
152
|
+
loading = true;
|
|
153
|
+
failed = false;
|
|
154
|
+
try {
|
|
155
|
+
const found = await search(query);
|
|
156
|
+
if (seq !== searchSeq) return;
|
|
157
|
+
results = found;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (seq !== searchSeq) return;
|
|
160
|
+
results = [];
|
|
161
|
+
failed = true;
|
|
162
|
+
console.warn('[smrt-ui] RelationInput search failed', {
|
|
163
|
+
error: String(err),
|
|
164
|
+
});
|
|
165
|
+
} finally {
|
|
166
|
+
if (seq === searchSeq) loading = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleQuery(query: string) {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
lastQuery = query;
|
|
173
|
+
if (query === '') {
|
|
174
|
+
// Opening the list: no typing to wait for.
|
|
175
|
+
void runSearch('');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Show "Searching" straight away and drop any in-flight older request. The
|
|
179
|
+
// listed options answered an older query, so none stay selectable (Enter or
|
|
180
|
+
// a click would otherwise pick a record that no longer matches the text).
|
|
181
|
+
searchSeq++;
|
|
182
|
+
loading = true;
|
|
183
|
+
failed = false;
|
|
184
|
+
results = [];
|
|
185
|
+
timer = setTimeout(() => void runSearch(query), debounceMs);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleValueChange(next: string) {
|
|
189
|
+
lastQuery = '';
|
|
190
|
+
const hit = results.find((item) => item.id === next);
|
|
191
|
+
if (hit) {
|
|
192
|
+
selectedLabel = hit.label;
|
|
193
|
+
labelledId = next;
|
|
194
|
+
}
|
|
195
|
+
onchange?.(next);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function focusInput() {
|
|
199
|
+
rootEl?.querySelector<HTMLInputElement>('input[role="combobox"]')?.focus();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clear() {
|
|
203
|
+
if (!canClear) return;
|
|
204
|
+
value = '';
|
|
205
|
+
selectedLabel = '';
|
|
206
|
+
labelledId = '';
|
|
207
|
+
lastQuery = '';
|
|
208
|
+
onchange?.('');
|
|
209
|
+
recordControlUserEdit(
|
|
210
|
+
interactionContext,
|
|
211
|
+
controlId,
|
|
212
|
+
interaction === false ? undefined : interaction?.subject,
|
|
213
|
+
);
|
|
214
|
+
if (rootEl) emitControlChange(rootEl);
|
|
215
|
+
focusInput();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function create() {
|
|
219
|
+
if (!onCreate || creating || disabled) return;
|
|
220
|
+
// The field has already lost focus (and reset its text) by now, so use the
|
|
221
|
+
// text most recently searched for.
|
|
222
|
+
const typed = lastQuery;
|
|
223
|
+
creating = true;
|
|
224
|
+
try {
|
|
225
|
+
const made = await onCreate(typed);
|
|
226
|
+
if (made && typeof made === 'object') {
|
|
227
|
+
results = [made, ...results.filter((item) => item.id !== made.id)];
|
|
228
|
+
selectedLabel = made.label;
|
|
229
|
+
labelledId = made.id;
|
|
230
|
+
value = made.id;
|
|
231
|
+
onchange?.(made.id);
|
|
232
|
+
recordControlUserEdit(
|
|
233
|
+
interactionContext,
|
|
234
|
+
controlId,
|
|
235
|
+
interaction === false ? undefined : interaction?.subject,
|
|
236
|
+
);
|
|
237
|
+
if (rootEl) emitControlChange(rootEl);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.warn('[smrt-ui] RelationInput onCreate failed', {
|
|
241
|
+
error: String(err),
|
|
242
|
+
});
|
|
243
|
+
} finally {
|
|
244
|
+
creating = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Keep the visible label in step with `value` however it changed (initial
|
|
249
|
+
// value, parent rebind, form reset): known from a search, else resolved.
|
|
250
|
+
$effect(() => {
|
|
251
|
+
const id = value;
|
|
252
|
+
const known = results.find((item) => item.id === id);
|
|
253
|
+
untrack(() => {
|
|
254
|
+
if (!id) {
|
|
255
|
+
selectedLabel = '';
|
|
256
|
+
labelledId = '';
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (id === labelledId) return;
|
|
260
|
+
if (known) {
|
|
261
|
+
selectedLabel = known.label;
|
|
262
|
+
labelledId = id;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// A different id than the one labelled: never keep the previous label.
|
|
266
|
+
selectedLabel = '';
|
|
267
|
+
labelledId = '';
|
|
268
|
+
if (!resolve || resolvingId === id) return;
|
|
269
|
+
resolvingId = id;
|
|
270
|
+
resolve(id)
|
|
271
|
+
.then((record) => {
|
|
272
|
+
if (value === id && record) {
|
|
273
|
+
selectedLabel = record.label;
|
|
274
|
+
labelledId = id;
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
.catch((err) => {
|
|
278
|
+
console.warn('[smrt-ui] RelationInput resolve failed', {
|
|
279
|
+
error: String(err),
|
|
280
|
+
});
|
|
281
|
+
})
|
|
282
|
+
.finally(() => {
|
|
283
|
+
if (resolvingId === id) resolvingId = '';
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
$effect(() => () => clearTimeout(timer));
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<div class="relation-input" bind:this={rootEl}>
|
|
292
|
+
<div class="field">
|
|
293
|
+
<Combobox
|
|
294
|
+
{name}
|
|
295
|
+
{label}
|
|
296
|
+
{options}
|
|
297
|
+
{placeholder}
|
|
298
|
+
{disabled}
|
|
299
|
+
{required}
|
|
300
|
+
{interaction}
|
|
301
|
+
{status}
|
|
302
|
+
{describedby}
|
|
303
|
+
bind:value
|
|
304
|
+
valueLabel={selectedLabel}
|
|
305
|
+
filter={false}
|
|
306
|
+
busy={loading}
|
|
307
|
+
invalid={!!error}
|
|
308
|
+
onquery={handleQuery}
|
|
309
|
+
onvaluechange={handleValueChange}
|
|
310
|
+
>
|
|
311
|
+
{#snippet optionContent(option)}
|
|
312
|
+
<span class="option-label">{option.label}</span>
|
|
313
|
+
{#if detailById.get(String(option.value))}
|
|
314
|
+
<span class="option-detail">{detailById.get(String(option.value))}</span>
|
|
315
|
+
{/if}
|
|
316
|
+
{/snippet}
|
|
317
|
+
</Combobox>
|
|
318
|
+
{#if canClear}
|
|
319
|
+
<Button
|
|
320
|
+
variant="ghost"
|
|
321
|
+
size="sm"
|
|
322
|
+
class="clear"
|
|
323
|
+
aria-label={clearLabel ?? `Clear ${label}`}
|
|
324
|
+
onclick={clear}
|
|
325
|
+
>
|
|
326
|
+
×
|
|
327
|
+
</Button>
|
|
328
|
+
{/if}
|
|
329
|
+
</div>
|
|
330
|
+
{#if description}<p id={hintId} class="hint">{description}</p>{/if}
|
|
331
|
+
{#if onCreate}
|
|
332
|
+
<Button
|
|
333
|
+
variant="ghost"
|
|
334
|
+
size="sm"
|
|
335
|
+
class="create"
|
|
336
|
+
disabled={disabled || creating}
|
|
337
|
+
onclick={create}
|
|
338
|
+
>
|
|
339
|
+
+ {newLabel}
|
|
340
|
+
</Button>
|
|
341
|
+
{/if}
|
|
342
|
+
{#if error}<p id={errorId} class="error" role="alert">{error}</p>{/if}
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<style>
|
|
346
|
+
.relation-input {
|
|
347
|
+
display: grid;
|
|
348
|
+
gap: var(--smrt-spacing-1, 4px);
|
|
349
|
+
min-width: 0;
|
|
350
|
+
}
|
|
351
|
+
.field {
|
|
352
|
+
position: relative;
|
|
353
|
+
}
|
|
354
|
+
.field :global(.clear) {
|
|
355
|
+
position: absolute;
|
|
356
|
+
right: var(--smrt-spacing-1, 4px);
|
|
357
|
+
bottom: var(--smrt-spacing-1, 4px);
|
|
358
|
+
}
|
|
359
|
+
.option-label,
|
|
360
|
+
.option-detail {
|
|
361
|
+
display: block;
|
|
362
|
+
}
|
|
363
|
+
.option-detail {
|
|
364
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
365
|
+
color: var(--smrt-color-on-surface-variant);
|
|
366
|
+
}
|
|
367
|
+
.hint,
|
|
368
|
+
.error {
|
|
369
|
+
margin: 0;
|
|
370
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
371
|
+
}
|
|
372
|
+
.hint {
|
|
373
|
+
color: var(--smrt-color-on-surface-variant);
|
|
374
|
+
}
|
|
375
|
+
.error {
|
|
376
|
+
color: var(--smrt-color-error);
|
|
377
|
+
}
|
|
378
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ControlInteractionOptions } from './control-interaction.js';
|
|
2
|
+
import type { RelationOption } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* RelationInput - searchable single-select for relation (foreign key) fields.
|
|
5
|
+
*
|
|
6
|
+
* Presentation only: the caller supplies `search` (and optionally `resolve`),
|
|
7
|
+
* so the same lookup an assistant uses to turn "Acme" into a record id backs
|
|
8
|
+
* the picker. Built on the smrt-ui `Combobox`, which supplies the ARIA 1.2
|
|
9
|
+
* combobox pattern (arrow keys, Enter, Escape, `aria-activedescendant`) and
|
|
10
|
+
* registers with the rich Form's control registry. The form posts the
|
|
11
|
+
* committed id under `name`, never the visible label.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```svelte
|
|
15
|
+
* <RelationInput
|
|
16
|
+
* name="customerId"
|
|
17
|
+
* label="Customer"
|
|
18
|
+
* bind:value={customerId}
|
|
19
|
+
* search={(q) => customers.search(q)}
|
|
20
|
+
* resolve={(id) => customers.byId(id)}
|
|
21
|
+
* />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface Props {
|
|
25
|
+
/** Field name; the selected id posts under it. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Visible label. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Selected record id (bindable). Empty string means no selection. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Required field: hides the clear button and flags an empty value. */
|
|
32
|
+
required?: boolean;
|
|
33
|
+
/** Disabled state. */
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** Validation error message, shown below the field. */
|
|
36
|
+
error?: string;
|
|
37
|
+
/** Placeholder shown when nothing is selected. */
|
|
38
|
+
placeholder?: string;
|
|
39
|
+
/** Hint announced with the field. */
|
|
40
|
+
description?: string;
|
|
41
|
+
/** Look up records matching the text; called with '' when the list opens. */
|
|
42
|
+
search: (query: string) => Promise<RelationOption[]>;
|
|
43
|
+
/** Look up the current value's label when it has not been searched yet. */
|
|
44
|
+
resolve?: (id: string) => Promise<{
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
} | null>;
|
|
48
|
+
/**
|
|
49
|
+
* "New ..." action. Receives the text most recently searched for. May
|
|
50
|
+
* return the created record, which is then selected.
|
|
51
|
+
*/
|
|
52
|
+
onCreate?: (query: string) => void | RelationOption | Promise<void | RelationOption>;
|
|
53
|
+
/** Milliseconds to wait after typing before searching. */
|
|
54
|
+
debounceMs?: number;
|
|
55
|
+
/** Combobox interaction options (stable id, subject) for agents. */
|
|
56
|
+
interaction?: ControlInteractionOptions | false;
|
|
57
|
+
/** Text for the "New ..." button. */
|
|
58
|
+
createLabel?: string;
|
|
59
|
+
/** Accessible name for the clear button. */
|
|
60
|
+
clearLabel?: string;
|
|
61
|
+
/** Status text while searching. */
|
|
62
|
+
loadingText?: string;
|
|
63
|
+
/** Status text when nothing matches. */
|
|
64
|
+
emptyText?: string;
|
|
65
|
+
/** Status text when the search fails. */
|
|
66
|
+
errorText?: string;
|
|
67
|
+
/** Status text announcing how many results are listed. */
|
|
68
|
+
resultsText?: (count: number) => string;
|
|
69
|
+
/** Called when the selected id changes ('' when cleared). */
|
|
70
|
+
onchange?: (value: string) => void;
|
|
71
|
+
}
|
|
72
|
+
declare const RelationInput: import("svelte").Component<Props, {}, "value">;
|
|
73
|
+
type RelationInput = ReturnType<typeof RelationInput>;
|
|
74
|
+
export default RelationInput;
|
|
75
|
+
//# sourceMappingURL=RelationInput.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RelationInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/RelationInput.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAK1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,KAAK;IACpB,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;IACrD,2EAA2E;IAC3E,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACxE;;;OAGG;IACH,QAAQ,CAAC,EAAE,CACT,KAAK,EAAE,MAAM,KACV,IAAI,GAAG,cAAc,GAAG,OAAO,CAAC,IAAI,GAAG,cAAc,CAAC,CAAC;IAC5D,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mCAAmC;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACxC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAyPD,QAAA,MAAM,aAAa,gDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { CodeSelectOption } from './code-select-types.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* ISO 4217 codes that are not circulating currencies: funds codes, precious
|
|
4
|
+
* metals, the IMF unit and bond-market units, and the testing / "no currency"
|
|
5
|
+
* codes. They stay valid stored values (the select retains an unknown value)
|
|
6
|
+
* but are not offered as choices.
|
|
7
|
+
*/
|
|
8
|
+
export declare const NON_CIRCULATING_CURRENCY_CODES: ReadonlySet<string>;
|
|
9
|
+
/** Build localized choices for circulating currencies from the shared ISO metadata. */
|
|
3
10
|
export declare function currencyOptions(locale: string): CodeSelectOption[];
|
|
4
11
|
/** Build localized ISO alpha-2 country choices. */
|
|
5
12
|
export declare function countryOptions(locale: string): CodeSelectOption[];
|