@happyvertical/smrt-ui 0.54.4 → 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.
Files changed (78) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +113 -5
  3. package/dist/components/forms/CodeSelect.svelte +12 -2
  4. package/dist/components/forms/CodeSelect.svelte.d.ts.map +1 -1
  5. package/dist/components/forms/Combobox.svelte +50 -7
  6. package/dist/components/forms/Combobox.svelte.d.ts +26 -0
  7. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  8. package/dist/components/forms/DictationButton.svelte +110 -3
  9. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -1
  10. package/dist/components/forms/DictationStatus.svelte +33 -5
  11. package/dist/components/forms/DictationStatus.svelte.d.ts +2 -0
  12. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/FieldLabel.svelte +44 -0
  14. package/dist/components/forms/FieldLabel.svelte.d.ts +20 -0
  15. package/dist/components/forms/FieldLabel.svelte.d.ts.map +1 -0
  16. package/dist/components/forms/FormGroup.svelte +2 -17
  17. package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/RelationInput.svelte +378 -0
  19. package/dist/components/forms/RelationInput.svelte.d.ts +75 -0
  20. package/dist/components/forms/RelationInput.svelte.d.ts.map +1 -0
  21. package/dist/components/forms/code-select-options.d.ts +8 -1
  22. package/dist/components/forms/code-select-options.d.ts.map +1 -1
  23. package/dist/components/forms/code-select-options.js +46 -5
  24. package/dist/components/forms/code-select-types.d.ts +4 -0
  25. package/dist/components/forms/code-select-types.d.ts.map +1 -1
  26. package/dist/components/forms/dictation.svelte.d.ts +84 -2
  27. package/dist/components/forms/dictation.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/dictation.svelte.js +236 -2
  29. package/dist/components/forms/hands-free-capture.d.ts +70 -0
  30. package/dist/components/forms/hands-free-capture.d.ts.map +1 -0
  31. package/dist/components/forms/hands-free-capture.js +19 -0
  32. package/dist/components/forms/hands-free-vad.d.ts +5 -0
  33. package/dist/components/forms/hands-free-vad.d.ts.map +1 -0
  34. package/dist/components/forms/hands-free-vad.js +80 -0
  35. package/dist/components/forms/index.d.ts +4 -1
  36. package/dist/components/forms/index.d.ts.map +1 -1
  37. package/dist/components/forms/index.js +3 -0
  38. package/dist/components/forms/types.d.ts +9 -0
  39. package/dist/components/forms/types.d.ts.map +1 -1
  40. package/dist/components/layout/ActionGroup.svelte +55 -0
  41. package/dist/components/layout/ActionGroup.svelte.d.ts +18 -0
  42. package/dist/components/layout/ActionGroup.svelte.d.ts.map +1 -0
  43. package/dist/components/layout/Container.svelte +1 -0
  44. package/dist/components/layout/Grid.svelte +2 -1
  45. package/dist/components/layout/PageHeader.svelte +3 -3
  46. package/dist/components/layout/PageHeader.svelte.d.ts +3 -3
  47. package/dist/components/layout/PageLayout.svelte +61 -0
  48. package/dist/components/layout/PageLayout.svelte.d.ts +18 -0
  49. package/dist/components/layout/PageLayout.svelte.d.ts.map +1 -0
  50. package/dist/components/layout/index.d.ts +2 -0
  51. package/dist/components/layout/index.d.ts.map +1 -1
  52. package/dist/components/layout/index.js +2 -0
  53. package/dist/components/permissions/PermissionPicker.svelte +213 -0
  54. package/dist/components/permissions/PermissionPicker.svelte.d.ts +27 -0
  55. package/dist/components/permissions/PermissionPicker.svelte.d.ts.map +1 -0
  56. package/dist/currency.d.ts +3 -0
  57. package/dist/currency.d.ts.map +1 -0
  58. package/dist/currency.js +2 -0
  59. package/dist/i18n/strings.ui.d.ts +7 -0
  60. package/dist/i18n/strings.ui.d.ts.map +1 -1
  61. package/dist/i18n/strings.ui.js +7 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/svelte/playground/InteractiveControlsPreview.svelte +22 -0
  66. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  67. package/dist/svelte/playground/PermissionPickerPreview.svelte +27 -0
  68. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts +19 -0
  69. package/dist/svelte/playground/PermissionPickerPreview.svelte.d.ts.map +1 -0
  70. package/dist/svelte/playground.d.ts +14 -2
  71. package/dist/svelte/playground.d.ts.map +1 -1
  72. package/dist/svelte/playground.js +10 -0
  73. package/dist/themes/README.md +5 -0
  74. package/dist/themes/styles/base.css +31 -0
  75. package/dist/themes/styles/index.d.ts +2 -0
  76. package/dist/themes/styles/index.d.ts.map +1 -1
  77. package/dist/themes/styles/index.js +2 -0
  78. 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
- dictation.state === 'starting' ||
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 dictation.state === 'starting'}
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":"AAcA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,uBAAuB,CAAC;AAG3E,MAAM,WAAW,KAAK;IACpB,2EAA2E;IAC3E,SAAS,EAAE,SAAS,CAAC;IACrB,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwED,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
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
- <label for={fieldId} class="form-label">
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;AAQ1E,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;AAgED,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,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
+ &times;
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
- /** Build localized choices from the shared ISO currency metadata. */
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[];