@happyvertical/smrt-ui 0.34.4 → 0.34.6

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 (36) hide show
  1. package/AGENTS.md +15 -5
  2. package/dist/components/forms/Form.svelte +51 -0
  3. package/dist/components/forms/Form.svelte.d.ts +29 -0
  4. package/dist/components/forms/Form.svelte.d.ts.map +1 -0
  5. package/dist/components/forms/FormGroup.svelte +86 -0
  6. package/dist/components/forms/FormGroup.svelte.d.ts +13 -0
  7. package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -0
  8. package/dist/components/forms/Input.svelte +83 -0
  9. package/dist/components/forms/Input.svelte.d.ts +9 -0
  10. package/dist/components/forms/Input.svelte.d.ts.map +1 -0
  11. package/dist/components/forms/Select.svelte +83 -0
  12. package/dist/components/forms/Select.svelte.d.ts +11 -0
  13. package/dist/components/forms/Select.svelte.d.ts.map +1 -0
  14. package/dist/components/forms/Textarea.svelte +85 -0
  15. package/dist/components/forms/Textarea.svelte.d.ts +10 -0
  16. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -0
  17. package/dist/components/forms/Toggle.svelte +217 -0
  18. package/dist/components/forms/Toggle.svelte.d.ts +37 -0
  19. package/dist/components/forms/Toggle.svelte.d.ts.map +1 -0
  20. package/dist/components/forms/__tests__/Form.test.js +49 -0
  21. package/dist/components/forms/__tests__/FormGroup.test.js +48 -0
  22. package/dist/components/forms/__tests__/Input.test.js +49 -0
  23. package/dist/components/forms/__tests__/Select.test.js +37 -0
  24. package/dist/components/forms/__tests__/Textarea.test.js +39 -0
  25. package/dist/components/forms/__tests__/Toggle.test.js +87 -0
  26. package/dist/components/forms/__tests__/form-group-input.fixture.svelte +16 -0
  27. package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts +9 -0
  28. package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts.map +1 -0
  29. package/dist/components/forms/form-group-context.d.ts +13 -0
  30. package/dist/components/forms/form-group-context.d.ts.map +1 -0
  31. package/dist/components/forms/form-group-context.js +28 -0
  32. package/dist/components/forms/index.d.ts +21 -0
  33. package/dist/components/forms/index.d.ts.map +1 -0
  34. package/dist/components/forms/index.js +20 -0
  35. package/dist/components/ui/Button.svelte +16 -0
  36. package/package.json +8 -2
package/AGENTS.md CHANGED
@@ -14,11 +14,20 @@ SMRT has one shared set of UI primitives, split across two packages by concern:
14
14
 
15
15
  - **`smrt-ui` (here) owns the domain-agnostic VISUAL primitives** — `Button`,
16
16
  `Card`, `Modal`/`ConfirmDialog`, `Badge`, `Avatar`, `Chip`, `Dropdown`,
17
- `Tooltip`, `Skeleton`, `Tree`, `Pagination`, `DataTable`, … (it has **no**
18
- form-input components those carry i18n/voice logic and belong above the leaf).
19
- - **`smrt-svelte` owns the FORM primitives** — `Input`, `Textarea`, `Select`,
20
- `Checkbox`/`Toggle`, `Form`, and the specialized date/measurement/address/file
21
- inputs.
17
+ `Tooltip`, `Skeleton`, `Tree`, `Pagination`, `DataTable`, … **plus the
18
+ Provider-free base FORM primitives** under `./forms` (`Form`, `Input`,
19
+ `Select`, `Textarea`, `Toggle`, `FormGroup`), relocated here in #1589's
20
+ deferred-forms phase so domain packages can adopt them without pulling in the
21
+ smrt-svelte Provider or closing a build-graph cycle. These are dependency-free:
22
+ no Provider, no i18n, no spoken-input logic.
23
+ - **`smrt-svelte` owns the Provider-REQUIRED form primitives** — `CheckboxInput`,
24
+ the rich `Form` (field registration + voice), `TextInput`, `MoneyInput`, and
25
+ the specialized date/measurement/address/file inputs (they call `useAppState`
26
+ / the AI hooks and carry i18n + spoken-input logic). It re-exports the base
27
+ **input** primitives (`Input`, `Select`, `Textarea`, `Toggle`, `FormGroup`)
28
+ from here so `@happyvertical/smrt-svelte/forms` stays the full barrel — but
29
+ **not** `Form`: that barrel's `Form` is the rich Provider-backed one, so the
30
+ Provider-free `Form` is only importable from `@happyvertical/smrt-ui/forms`.
22
31
 
23
32
  **Domain packages import visual primitives from `smrt-ui` and form primitives
24
33
  from `smrt-svelte`, and must not hand-roll raw `<button>` / `<input>` /
@@ -38,6 +47,7 @@ components are exempt — they *are* the primitives.
38
47
  | `./layout` | `Container`, `Grid`, `Header`, `Footer`, `PageHeader`, `EmptyState`, … |
39
48
  | `./calendar` | `Calendar`, `DayView` |
40
49
  | `./chat` | `MessageBubble`, `ReactionPicker`, `TypingIndicator` |
50
+ | `./forms` | Provider-free base form primitives: `Form`, `Input`, `Select`, `Textarea`, `Toggle`, `FormGroup` (+ the FormGroup a11y context helpers) |
41
51
  | `./i18n` | i18n **client**: `useI18n`, `<Trans>`, `defineMessages`, `renderTemplate` (no `smrt-languages` import — the server resolver stays in `smrt-svelte/i18n/server`) |
42
52
  | `./registry` | `ModuleUIRegistry` for cross-package component discovery |
43
53
  | `./theme` | simple `ThemeProvider` + context (`useTheme` consumes this from `smrt-svelte`) |
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Form — the Provider-free base `<form>` primitive.
4
+ *
5
+ * A thin, dependency-free wrapper around the native `<form>` element so domain
6
+ * components have a primitive to adopt instead of hand-rolling raw `<form>`
7
+ * markup (issue #1589). It forwards every native form attribute (including
8
+ * `onsubmit`) and renders its children — no Provider, no i18n, no spoken-input
9
+ * logic.
10
+ *
11
+ * For the rich, Provider-backed form with field registration and voice input,
12
+ * use `Form` from `@happyvertical/smrt-svelte/forms` instead. This one is the
13
+ * leaf-level building block; that one composes app state on top.
14
+ *
15
+ * `preventDefault` (default `true`) calls `event.preventDefault()` before
16
+ * invoking the consumer's `onsubmit`, so a click/Enter submit runs the handler
17
+ * without a full-page navigation — the near-universal SPA pattern. Pass
18
+ * `preventDefault={false}` to keep native submission (e.g. a GET/POST `action`).
19
+ */
20
+ import type { Snippet } from 'svelte';
21
+ import type { HTMLFormAttributes } from 'svelte/elements';
22
+
23
+ export interface Props extends Omit<HTMLFormAttributes, 'class'> {
24
+ class?: string;
25
+ preventDefault?: boolean;
26
+ children: Snippet;
27
+ }
28
+
29
+ let {
30
+ class: className = '',
31
+ preventDefault = true,
32
+ onsubmit,
33
+ children,
34
+ ...rest
35
+ }: Props = $props();
36
+
37
+ function handleSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
38
+ if (preventDefault) event.preventDefault();
39
+ onsubmit?.(event);
40
+ }
41
+ </script>
42
+
43
+ <form class="form {className}" onsubmit={handleSubmit} {...rest}>
44
+ {@render children()}
45
+ </form>
46
+
47
+ <style>
48
+ .form {
49
+ display: block;
50
+ }
51
+ </style>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Form — the Provider-free base `<form>` primitive.
3
+ *
4
+ * A thin, dependency-free wrapper around the native `<form>` element so domain
5
+ * components have a primitive to adopt instead of hand-rolling raw `<form>`
6
+ * markup (issue #1589). It forwards every native form attribute (including
7
+ * `onsubmit`) and renders its children — no Provider, no i18n, no spoken-input
8
+ * logic.
9
+ *
10
+ * For the rich, Provider-backed form with field registration and voice input,
11
+ * use `Form` from `@happyvertical/smrt-svelte/forms` instead. This one is the
12
+ * leaf-level building block; that one composes app state on top.
13
+ *
14
+ * `preventDefault` (default `true`) calls `event.preventDefault()` before
15
+ * invoking the consumer's `onsubmit`, so a click/Enter submit runs the handler
16
+ * without a full-page navigation — the near-universal SPA pattern. Pass
17
+ * `preventDefault={false}` to keep native submission (e.g. a GET/POST `action`).
18
+ */
19
+ import type { Snippet } from 'svelte';
20
+ import type { HTMLFormAttributes } from 'svelte/elements';
21
+ export interface Props extends Omit<HTMLFormAttributes, 'class'> {
22
+ class?: string;
23
+ preventDefault?: boolean;
24
+ children: Snippet;
25
+ }
26
+ declare const Form: import("svelte").Component<Props, {}, "">;
27
+ type Form = ReturnType<typeof Form>;
28
+ export default Form;
29
+ //# sourceMappingURL=Form.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Form.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Form.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAG1D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,kBAAkB,EAAE,OAAO,CAAC;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;CACnB;AA6BD,QAAA,MAAM,IAAI,2CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import {
4
+ type FormGroupContextValue,
5
+ nextFieldId,
6
+ setFormGroupContext,
7
+ } from './form-group-context.js';
8
+
9
+ export interface Props {
10
+ label: string;
11
+ id?: string;
12
+ error?: string;
13
+ hint?: string;
14
+ required?: boolean;
15
+ children: Snippet;
16
+ }
17
+
18
+ const { label, id, error, hint, required = false, children }: Props = $props();
19
+
20
+ // Stable id so the label's `for` and the wrapped input's `id` agree even when
21
+ // the consumer doesn't pass one.
22
+ const fallbackId = nextFieldId();
23
+ const fieldId = $derived(id ?? fallbackId);
24
+ const hintId = $derived(hint && !error ? `${fieldId}-hint` : undefined);
25
+ const errorId = $derived(error ? `${fieldId}-error` : undefined);
26
+ const describedBy = $derived(
27
+ [hintId, errorId].filter(Boolean).join(' ') || undefined,
28
+ );
29
+
30
+ // Publish the wiring a base input auto-applies (getter stays reactive as
31
+ // hint/error change).
32
+ setFormGroupContext(
33
+ (): FormGroupContextValue => ({
34
+ inputId: fieldId,
35
+ describedBy,
36
+ invalid: !!error,
37
+ }),
38
+ );
39
+ </script>
40
+
41
+ <div class="form-group">
42
+ <label for={fieldId} class="form-label">
43
+ {label}
44
+ {#if required}
45
+ <span class="required" aria-hidden="true">*</span>
46
+ {/if}
47
+ </label>
48
+ {@render children()}
49
+ {#if hintId}
50
+ <p id={hintId} class="form-hint">{hint}</p>
51
+ {/if}
52
+ {#if errorId}
53
+ <p id={errorId} class="form-error" role="alert">{error}</p>
54
+ {/if}
55
+ </div>
56
+
57
+ <style>
58
+ .form-group {
59
+ margin-bottom: 1rem;
60
+ }
61
+
62
+ .form-label {
63
+ display: block;
64
+ font-size: var(--smrt-typography-label-large-size, 0.875rem);
65
+ font-weight: var(--smrt-typography-weight-medium, 500);
66
+ color: var(--smrt-color-on-surface, #374151);
67
+ margin-bottom: 0.375rem;
68
+ }
69
+
70
+ .required {
71
+ color: var(--smrt-color-error, #ba1a1a);
72
+ margin-left: 0.125rem;
73
+ }
74
+
75
+ .form-hint {
76
+ margin: 0.25rem 0 0;
77
+ font-size: var(--smrt-typography-body-small-size, 0.75rem);
78
+ color: var(--smrt-color-on-surface-variant, #6b7280);
79
+ }
80
+
81
+ .form-error {
82
+ margin: 0.25rem 0 0;
83
+ font-size: var(--smrt-typography-body-small-size, 0.75rem);
84
+ color: var(--smrt-color-error, #ba1a1a);
85
+ }
86
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ export interface Props {
3
+ label: string;
4
+ id?: string;
5
+ error?: string;
6
+ hint?: string;
7
+ required?: boolean;
8
+ children: Snippet;
9
+ }
10
+ declare const FormGroup: import("svelte").Component<Props, {}, "">;
11
+ type FormGroup = ReturnType<typeof FormGroup>;
12
+ export default FormGroup;
13
+ //# sourceMappingURL=FormGroup.svelte.d.ts.map
@@ -0,0 +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;AAQtC,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiDD,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { tryGetFormGroupContext } from './form-group-context.js';
4
+
5
+ export interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
6
+ value?: string | number;
7
+ class?: string;
8
+ }
9
+
10
+ let {
11
+ id,
12
+ type = 'text',
13
+ value = $bindable(''),
14
+ placeholder,
15
+ disabled = false,
16
+ readonly = false,
17
+ required = false,
18
+ name,
19
+ class: className = '',
20
+ 'aria-describedby': ariaDescribedby,
21
+ 'aria-invalid': ariaInvalid,
22
+ ...rest
23
+ }: Props = $props();
24
+
25
+ // When wrapped in a <FormGroup>, inherit the id (so its <label for> resolves),
26
+ // the hint/error association, and the error state — unless set explicitly.
27
+ const formGroup = tryGetFormGroupContext();
28
+ const resolvedId = $derived(id ?? formGroup?.().inputId);
29
+ const resolvedDescribedBy = $derived(
30
+ ariaDescribedby ?? formGroup?.().describedBy,
31
+ );
32
+ const resolvedInvalid = $derived(
33
+ ariaInvalid ?? (formGroup?.().invalid ? 'true' : undefined),
34
+ );
35
+ </script>
36
+
37
+ <input
38
+ id={resolvedId}
39
+ {type}
40
+ bind:value
41
+ {placeholder}
42
+ {disabled}
43
+ {readonly}
44
+ {required}
45
+ {name}
46
+ aria-describedby={resolvedDescribedBy}
47
+ aria-invalid={resolvedInvalid}
48
+ class="input {className}"
49
+ {...rest}
50
+ />
51
+
52
+ <style>
53
+ .input {
54
+ display: block;
55
+ width: 100%;
56
+ padding: 0.5rem 0.75rem;
57
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
58
+ line-height: var(--smrt-typography-body-medium-line-height, 1.5);
59
+ color: var(--smrt-color-on-surface, #1f2937);
60
+ background-color: var(--smrt-color-surface, #fff);
61
+ border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
62
+ border-radius: var(--smrt-radius-small, 0.375rem);
63
+ transition:
64
+ border-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease-in-out),
65
+ box-shadow var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease-in-out);
66
+ }
67
+
68
+ .input:focus {
69
+ outline: none;
70
+ border-color: var(--smrt-color-primary, #005ac1);
71
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--smrt-color-primary, #005ac1) 10%, transparent);
72
+ }
73
+
74
+ .input:disabled {
75
+ background-color: var(--smrt-color-surface-container-high, #f3f4f6);
76
+ cursor: not-allowed;
77
+ opacity: 0.7;
78
+ }
79
+
80
+ .input::placeholder {
81
+ color: var(--smrt-color-on-surface-variant, #9ca3af);
82
+ }
83
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ export interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
3
+ value?: string | number;
4
+ class?: string;
5
+ }
6
+ declare const Input: import("svelte").Component<Props, {}, "value">;
7
+ type Input = ReturnType<typeof Input>;
8
+ export default Input;
9
+ //# sourceMappingURL=Input.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Input.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Input.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAI3D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,mBAAmB,EAAE,OAAO,GAAG,OAAO,CAAC;IACzE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAuCD,QAAA,MAAM,KAAK,gDAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLSelectAttributes } from 'svelte/elements';
4
+ import { tryGetFormGroupContext } from './form-group-context.js';
5
+
6
+ export interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value'> {
7
+ value?: string;
8
+ class?: string;
9
+ children: Snippet;
10
+ }
11
+
12
+ let {
13
+ id,
14
+ value = $bindable(''),
15
+ disabled = false,
16
+ required = false,
17
+ name,
18
+ class: className = '',
19
+ children,
20
+ 'aria-describedby': ariaDescribedby,
21
+ 'aria-invalid': ariaInvalid,
22
+ ...rest
23
+ }: Props = $props();
24
+
25
+ // Inherit id / hint+error association / error state from a wrapping FormGroup.
26
+ const formGroup = tryGetFormGroupContext();
27
+ const resolvedId = $derived(id ?? formGroup?.().inputId);
28
+ const resolvedDescribedBy = $derived(
29
+ ariaDescribedby ?? formGroup?.().describedBy,
30
+ );
31
+ const resolvedInvalid = $derived(
32
+ ariaInvalid ?? (formGroup?.().invalid ? 'true' : undefined),
33
+ );
34
+ </script>
35
+
36
+ <select
37
+ id={resolvedId}
38
+ bind:value
39
+ {disabled}
40
+ {required}
41
+ {name}
42
+ aria-describedby={resolvedDescribedBy}
43
+ aria-invalid={resolvedInvalid}
44
+ class="select {className}"
45
+ {...rest}
46
+ >
47
+ {@render children()}
48
+ </select>
49
+
50
+ <style>
51
+ .select {
52
+ display: block;
53
+ width: 100%;
54
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
55
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
56
+ line-height: var(--smrt-typography-body-medium-line-height, 1.5);
57
+ color: var(--smrt-color-on-surface, #1f2937);
58
+ background-color: var(--smrt-color-surface, #fff);
59
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2379747e' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
60
+ background-position: right 0.5rem center;
61
+ background-repeat: no-repeat;
62
+ background-size: 1.5em 1.5em;
63
+ border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
64
+ border-radius: 0.375rem;
65
+ appearance: none;
66
+ cursor: pointer;
67
+ transition:
68
+ border-color 0.15s ease-in-out,
69
+ box-shadow 0.15s ease-in-out;
70
+ }
71
+
72
+ .select:focus {
73
+ outline: none;
74
+ border-color: var(--smrt-color-primary, #005ac1);
75
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--smrt-color-primary, #005ac1) 10%, transparent);
76
+ }
77
+
78
+ .select:disabled {
79
+ background-color: var(--smrt-color-surface-container-high, #f3f4f6);
80
+ cursor: not-allowed;
81
+ opacity: 0.7;
82
+ }
83
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLSelectAttributes } from 'svelte/elements';
3
+ export interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value'> {
4
+ value?: string;
5
+ class?: string;
6
+ children: Snippet;
7
+ }
8
+ declare const Select: import("svelte").Component<Props, {}, "value">;
9
+ type Select = ReturnType<typeof Select>;
10
+ export default Select;
11
+ //# sourceMappingURL=Select.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Select.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Select.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAI5D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,OAAO,CAAC;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAuCD,QAAA,MAAM,MAAM,gDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
3
+ import { tryGetFormGroupContext } from './form-group-context.js';
4
+
5
+ export interface Props extends Omit<HTMLTextareaAttributes, 'class' | 'value'> {
6
+ value?: string;
7
+ rows?: number;
8
+ class?: string;
9
+ }
10
+
11
+ let {
12
+ id,
13
+ value = $bindable(''),
14
+ placeholder,
15
+ disabled = false,
16
+ readonly = false,
17
+ required = false,
18
+ name,
19
+ rows = 4,
20
+ class: className = '',
21
+ 'aria-describedby': ariaDescribedby,
22
+ 'aria-invalid': ariaInvalid,
23
+ ...rest
24
+ }: Props = $props();
25
+
26
+ // Inherit id / hint+error association / error state from a wrapping FormGroup.
27
+ const formGroup = tryGetFormGroupContext();
28
+ const resolvedId = $derived(id ?? formGroup?.().inputId);
29
+ const resolvedDescribedBy = $derived(
30
+ ariaDescribedby ?? formGroup?.().describedBy,
31
+ );
32
+ const resolvedInvalid = $derived(
33
+ ariaInvalid ?? (formGroup?.().invalid ? 'true' : undefined),
34
+ );
35
+ </script>
36
+
37
+ <textarea
38
+ id={resolvedId}
39
+ bind:value
40
+ {placeholder}
41
+ {disabled}
42
+ {readonly}
43
+ {required}
44
+ {name}
45
+ {rows}
46
+ aria-describedby={resolvedDescribedBy}
47
+ aria-invalid={resolvedInvalid}
48
+ class="textarea {className}"
49
+ {...rest}
50
+ ></textarea>
51
+
52
+ <style>
53
+ .textarea {
54
+ display: block;
55
+ width: 100%;
56
+ padding: 0.5rem 0.75rem;
57
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
58
+ line-height: var(--smrt-typography-body-medium-line-height, 1.5);
59
+ color: var(--smrt-color-on-surface, #1f2937);
60
+ background-color: var(--smrt-color-surface, #fff);
61
+ border: 1px solid var(--smrt-color-outline-variant, #d1d5db);
62
+ border-radius: 0.375rem;
63
+ resize: vertical;
64
+ min-height: 80px;
65
+ transition:
66
+ border-color 0.15s ease-in-out,
67
+ box-shadow 0.15s ease-in-out;
68
+ }
69
+
70
+ .textarea:focus {
71
+ outline: none;
72
+ border-color: var(--smrt-color-primary, #005ac1);
73
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--smrt-color-primary, #005ac1) 10%, transparent);
74
+ }
75
+
76
+ .textarea:disabled {
77
+ background-color: var(--smrt-color-surface-container-high, #f3f4f6);
78
+ cursor: not-allowed;
79
+ opacity: 0.7;
80
+ }
81
+
82
+ .textarea::placeholder {
83
+ color: var(--smrt-color-on-surface-variant, #9ca3af);
84
+ }
85
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { HTMLTextareaAttributes } from 'svelte/elements';
2
+ export interface Props extends Omit<HTMLTextareaAttributes, 'class' | 'value'> {
3
+ value?: string;
4
+ rows?: number;
5
+ class?: string;
6
+ }
7
+ declare const Textarea: import("svelte").Component<Props, {}, "value">;
8
+ type Textarea = ReturnType<typeof Textarea>;
9
+ export default Textarea;
10
+ //# sourceMappingURL=Textarea.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Textarea.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Textarea.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAI9D,MAAM,WAAW,KAAM,SAAQ,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,OAAO,CAAC;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsCD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SMRTToggle - An accessible toggle/switch component
4
+ *
5
+ * Features:
6
+ * - Native checkbox semantics for accessibility
7
+ * - Bindable checked state
8
+ * - Disabled state
9
+ * - Labels on either side
10
+ * - Size variants
11
+ * - Material 3 styling
12
+ */
13
+
14
+ export interface Props {
15
+ /** Whether the toggle is checked */
16
+ checked?: boolean;
17
+ /** Whether the toggle is disabled */
18
+ disabled?: boolean;
19
+ /** Name attribute for form submission */
20
+ name?: string;
21
+ /** Value attribute for form submission */
22
+ value?: string;
23
+ /** Label text */
24
+ label?: string;
25
+ /** Position of the label */
26
+ labelPosition?: 'left' | 'right';
27
+ /** Size variant */
28
+ size?: 'sm' | 'md' | 'lg';
29
+ /** ID for the input element */
30
+ id?: string;
31
+ /** ARIA label for accessibility */
32
+ ariaLabel?: string;
33
+ /** Change callback */
34
+ onchange?: (checked: boolean) => void;
35
+ }
36
+
37
+ let {
38
+ checked = $bindable(false),
39
+ disabled = false,
40
+ name,
41
+ value,
42
+ label,
43
+ labelPosition = 'right',
44
+ size = 'md',
45
+ id,
46
+ ariaLabel,
47
+ onchange,
48
+ }: Props = $props();
49
+
50
+ function handleChange(event: Event) {
51
+ const target = event.target as HTMLInputElement;
52
+ checked = target.checked;
53
+ onchange?.(checked);
54
+ }
55
+
56
+ const sizeClasses = {
57
+ sm: 'toggle--sm',
58
+ md: 'toggle--md',
59
+ lg: 'toggle--lg',
60
+ };
61
+ </script>
62
+
63
+ <label class="toggle {sizeClasses[size]}" class:toggle--disabled={disabled}>
64
+ {#if label && labelPosition === 'left'}
65
+ <span class="toggle__label toggle__label--left">{label}</span>
66
+ {/if}
67
+
68
+ <span class="toggle__track">
69
+ <input
70
+ type="checkbox"
71
+ class="toggle__input"
72
+ {id}
73
+ {name}
74
+ {value}
75
+ {disabled}
76
+ {checked}
77
+ onchange={handleChange}
78
+ aria-label={ariaLabel ?? label ?? ''}
79
+ />
80
+ <span class="toggle__thumb"></span>
81
+ </span>
82
+
83
+ {#if label && labelPosition === 'right'}
84
+ <span class="toggle__label toggle__label--right">{label}</span>
85
+ {/if}
86
+ </label>
87
+
88
+ <style>
89
+ .toggle {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ gap: var(--smrt-spacing-2, 0.5rem);
93
+ cursor: pointer;
94
+ user-select: none;
95
+ }
96
+
97
+ .toggle--disabled {
98
+ cursor: not-allowed;
99
+ opacity: 0.5;
100
+ }
101
+
102
+ .toggle__label {
103
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
104
+ color: var(--smrt-color-on-surface, #111827);
105
+ line-height: 1.5;
106
+ }
107
+
108
+ .toggle__track {
109
+ position: relative;
110
+ display: inline-flex;
111
+ align-items: center;
112
+ flex-shrink: 0;
113
+ }
114
+
115
+ .toggle__input {
116
+ position: absolute;
117
+ width: 1px;
118
+ height: 1px;
119
+ padding: 0;
120
+ margin: -1px;
121
+ overflow: hidden;
122
+ clip: rect(0, 0, 0, 0);
123
+ white-space: nowrap;
124
+ border: 0;
125
+ }
126
+
127
+ .toggle__thumb {
128
+ position: relative;
129
+ background: var(--smrt-color-surface-container-highest, #e5e7eb);
130
+ border-radius: var(--smrt-radius-full, 9999px);
131
+ transition: background-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
132
+ }
133
+
134
+ .toggle__thumb::after {
135
+ content: '';
136
+ position: absolute;
137
+ background: var(--smrt-color-surface, #ffffff);
138
+ border-radius: var(--smrt-radius-full, 9999px);
139
+ box-shadow: var(--smrt-elevation-1, 0 1px 3px color-mix(in srgb, var(--smrt-color-shadow) 20%, transparent));
140
+ transition: transform var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
141
+ }
142
+
143
+ /* Checked state */
144
+ .toggle__input:checked + .toggle__thumb {
145
+ background: var(--smrt-color-primary, #005ac1);
146
+ }
147
+
148
+ /* Focus state */
149
+ .toggle__input:focus-visible + .toggle__thumb {
150
+ outline: 2px solid var(--smrt-color-primary, #005ac1);
151
+ outline-offset: 2px;
152
+ }
153
+
154
+ /* Hover state */
155
+ .toggle:not(.toggle--disabled):hover .toggle__thumb {
156
+ background: var(--smrt-color-surface-container-high, #d1d5db);
157
+ }
158
+
159
+ .toggle:not(.toggle--disabled):hover .toggle__input:checked + .toggle__thumb {
160
+ background: var(--smrt-color-primary, #2563eb);
161
+ }
162
+
163
+ /* Size: Small */
164
+ .toggle--sm .toggle__thumb {
165
+ width: 32px;
166
+ height: 18px;
167
+ }
168
+
169
+ .toggle--sm .toggle__thumb::after {
170
+ width: 14px;
171
+ height: 14px;
172
+ top: 2px;
173
+ left: 2px;
174
+ }
175
+
176
+ .toggle--sm .toggle__input:checked + .toggle__thumb::after {
177
+ transform: translateX(14px);
178
+ }
179
+
180
+ /* Size: Medium (default) */
181
+ .toggle--md .toggle__thumb {
182
+ width: 44px;
183
+ height: 24px;
184
+ }
185
+
186
+ .toggle--md .toggle__thumb::after {
187
+ width: 20px;
188
+ height: 20px;
189
+ top: 2px;
190
+ left: 2px;
191
+ }
192
+
193
+ .toggle--md .toggle__input:checked + .toggle__thumb::after {
194
+ transform: translateX(20px);
195
+ }
196
+
197
+ /* Size: Large */
198
+ .toggle--lg .toggle__thumb {
199
+ width: 56px;
200
+ height: 30px;
201
+ }
202
+
203
+ .toggle--lg .toggle__thumb::after {
204
+ width: 26px;
205
+ height: 26px;
206
+ top: 2px;
207
+ left: 2px;
208
+ }
209
+
210
+ .toggle--lg .toggle__input:checked + .toggle__thumb::after {
211
+ transform: translateX(26px);
212
+ }
213
+
214
+ .toggle--lg .toggle__label {
215
+ font-size: var(--smrt-typography-body-large-size, 1rem);
216
+ }
217
+ </style>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * SMRTToggle - An accessible toggle/switch component
3
+ *
4
+ * Features:
5
+ * - Native checkbox semantics for accessibility
6
+ * - Bindable checked state
7
+ * - Disabled state
8
+ * - Labels on either side
9
+ * - Size variants
10
+ * - Material 3 styling
11
+ */
12
+ export interface Props {
13
+ /** Whether the toggle is checked */
14
+ checked?: boolean;
15
+ /** Whether the toggle is disabled */
16
+ disabled?: boolean;
17
+ /** Name attribute for form submission */
18
+ name?: string;
19
+ /** Value attribute for form submission */
20
+ value?: string;
21
+ /** Label text */
22
+ label?: string;
23
+ /** Position of the label */
24
+ labelPosition?: 'left' | 'right';
25
+ /** Size variant */
26
+ size?: 'sm' | 'md' | 'lg';
27
+ /** ID for the input element */
28
+ id?: string;
29
+ /** ARIA label for accessibility */
30
+ ariaLabel?: string;
31
+ /** Change callback */
32
+ onchange?: (checked: boolean) => void;
33
+ }
34
+ declare const Toggle: import("svelte").Component<Props, {}, "checked">;
35
+ type Toggle = ReturnType<typeof Toggle>;
36
+ export default Toggle;
37
+ //# sourceMappingURL=Toggle.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Toggle.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Toggle.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,KAAK;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iBAAiB;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4BAA4B;IAC5B,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACjC,mBAAmB;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,+BAA+B;IAC/B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,mCAAmC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sBAAsB;IACtB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAiDD,QAAA,MAAM,MAAM,kDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Golden test for the Provider-free Form primitive (#1589 deferred-forms phase).
3
+ *
4
+ * Form is the leaf `<form>` wrapper domain packages adopt instead of raw markup:
5
+ * it forwards native attributes, renders children, and (by default) prevents the
6
+ * native submit so the consumer's `onsubmit` runs without a page navigation.
7
+ */
8
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
9
+ import { render, screen } from '@testing-library/svelte';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { createRawSnippet } from 'svelte';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import Form from '../Form.svelte';
14
+ const children = createRawSnippet(() => ({
15
+ render: () => '<button type="submit">Save</button>',
16
+ }));
17
+ describe('Form', () => {
18
+ it('renders a <form> with forwarded attributes and its children', () => {
19
+ const { container } = render(Form, {
20
+ props: { name: 'profile', class: 'profile-form', children },
21
+ });
22
+ const form = container.querySelector('form');
23
+ expect(form).not.toBeNull();
24
+ expect(form).toHaveAttribute('name', 'profile');
25
+ expect(form).toHaveClass('form', 'profile-form');
26
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
27
+ });
28
+ it('prevents the native submit and calls onsubmit by default', async () => {
29
+ const onsubmit = vi.fn();
30
+ render(Form, { props: { onsubmit, children } });
31
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
32
+ expect(onsubmit).toHaveBeenCalledTimes(1);
33
+ const event = onsubmit.mock.calls[0][0];
34
+ expect(event.defaultPrevented).toBe(true);
35
+ });
36
+ it('leaves native submission intact when preventDefault is false', async () => {
37
+ const onsubmit = vi.fn();
38
+ render(Form, { props: { onsubmit, preventDefault: false, children } });
39
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
40
+ expect(onsubmit).toHaveBeenCalledTimes(1);
41
+ expect(onsubmit.mock.calls[0][0].defaultPrevented).toBe(false);
42
+ });
43
+ it('is axe-clean', async () => {
44
+ const { container } = render(Form, {
45
+ props: { 'aria-label': 'Profile form', children },
46
+ });
47
+ await expectNoA11yViolations(container);
48
+ });
49
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Golden test for FormGroup accessibility wiring (Sweep L1, #1420).
3
+ *
4
+ * FormGroup must make a wrapped base input programmatically accessible with no
5
+ * extra wiring: a real <label> association, hint/error linked via
6
+ * aria-describedby, and aria-invalid in the error state.
7
+ */
8
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
9
+ import { render, screen } from '@testing-library/svelte';
10
+ import { describe, expect, it } from 'vitest';
11
+ import Fixture from './form-group-input.fixture.svelte';
12
+ describe('FormGroup a11y wiring', () => {
13
+ it('associates its label with the wrapped input', () => {
14
+ render(Fixture, {});
15
+ // getByLabelText only resolves if the label is programmatically associated.
16
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
17
+ });
18
+ it('links a hint via aria-describedby', () => {
19
+ render(Fixture, { props: { hint: 'Work address preferred' } });
20
+ const input = screen.getByLabelText('Email');
21
+ const describedBy = input.getAttribute('aria-describedby');
22
+ expect(describedBy).toBeTruthy();
23
+ const hint = document.getElementById(describedBy);
24
+ expect(hint).toHaveTextContent('Work address preferred');
25
+ });
26
+ it('sets aria-invalid and links the error in the error state', () => {
27
+ render(Fixture, { props: { error: 'Email is required' } });
28
+ const input = screen.getByLabelText('Email');
29
+ expect(input).toHaveAttribute('aria-invalid', 'true');
30
+ const describedBy = input.getAttribute('aria-describedby');
31
+ const error = document.getElementById(describedBy);
32
+ expect(error).toHaveTextContent('Email is required');
33
+ // error is announced
34
+ expect(error).toHaveAttribute('role', 'alert');
35
+ });
36
+ it('is axe-clean (labelled control, associated hint)', async () => {
37
+ const { container } = render(Fixture, {
38
+ props: { hint: 'Work address preferred' },
39
+ });
40
+ await expectNoA11yViolations(container);
41
+ });
42
+ it('is axe-clean in the error state', async () => {
43
+ const { container } = render(Fixture, {
44
+ props: { error: 'Email is required' },
45
+ });
46
+ await expectNoA11yViolations(container);
47
+ });
48
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Golden test for Input (Sweep L4, #1423).
3
+ *
4
+ * Input is the low-level form primitive: a bare <input> whose accessible name
5
+ * comes from a wrapping label/FormGroup, not from itself. Programmatic-label +
6
+ * axe-clean coverage for the form primitives is L1's deliverable (#1420, "with
7
+ * L4"); here we cover render + interaction + state behavior.
8
+ */
9
+ import { render, screen } from '@testing-library/svelte';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { describe, expect, it } from 'vitest';
12
+ import Input from '../Input.svelte';
13
+ describe('Input', () => {
14
+ it('renders an <input> with the given id/name/type', () => {
15
+ render(Input, { props: { id: 'first', name: 'first', type: 'text' } });
16
+ const input = screen.getByRole('textbox');
17
+ expect(input).toHaveAttribute('id', 'first');
18
+ expect(input).toHaveAttribute('name', 'first');
19
+ expect(input).toHaveAttribute('type', 'text');
20
+ });
21
+ it('accepts typed input', async () => {
22
+ render(Input, { props: { id: 'q', name: 'q' } });
23
+ const input = screen.getByRole('textbox');
24
+ await userEvent.type(input, 'hello');
25
+ expect(input).toHaveValue('hello');
26
+ });
27
+ it('renders the placeholder', () => {
28
+ render(Input, { props: { id: 'q', placeholder: 'Search…' } });
29
+ expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument();
30
+ });
31
+ it('does not accept input when disabled', async () => {
32
+ render(Input, { props: { id: 'q', disabled: true } });
33
+ const input = screen.getByRole('textbox');
34
+ expect(input).toBeDisabled();
35
+ await userEvent.type(input, 'nope');
36
+ expect(input).toHaveValue('');
37
+ });
38
+ it('is read-only when readonly is set', async () => {
39
+ render(Input, { props: { id: 'q', readonly: true, value: 'fixed' } });
40
+ const input = screen.getByRole('textbox');
41
+ expect(input).toHaveAttribute('readonly');
42
+ await userEvent.type(input, 'x');
43
+ expect(input).toHaveValue('fixed');
44
+ });
45
+ it('marks required inputs', () => {
46
+ render(Input, { props: { id: 'q', required: true } });
47
+ expect(screen.getByRole('textbox')).toBeRequired();
48
+ });
49
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Golden test for Select (Sweep L1, #1420).
3
+ *
4
+ * Select forwards aria attributes (so it can be labelled / error-associated)
5
+ * and inherits id / describedby / invalid from a wrapping FormGroup via context.
6
+ */
7
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
8
+ import { render, screen } from '@testing-library/svelte';
9
+ import { createRawSnippet } from 'svelte';
10
+ import { describe, expect, it } from 'vitest';
11
+ import Select from '../Select.svelte';
12
+ function options() {
13
+ return createRawSnippet(() => ({
14
+ render: () => `<option value="a">A</option><option value="b">B</option>`,
15
+ }));
16
+ }
17
+ describe('Select', () => {
18
+ it('forwards aria-label / aria-describedby / aria-invalid', () => {
19
+ render(Select, {
20
+ props: {
21
+ 'aria-label': 'Country',
22
+ 'aria-describedby': 'country-hint',
23
+ 'aria-invalid': 'true',
24
+ children: options(),
25
+ },
26
+ });
27
+ const select = screen.getByRole('combobox', { name: 'Country' });
28
+ expect(select).toHaveAttribute('aria-describedby', 'country-hint');
29
+ expect(select).toHaveAttribute('aria-invalid', 'true');
30
+ });
31
+ it('is axe-clean when labelled', async () => {
32
+ const { container } = render(Select, {
33
+ props: { 'aria-label': 'Country', children: options() },
34
+ });
35
+ await expectNoA11yViolations(container);
36
+ });
37
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Golden test for Textarea (Sweep L1, #1420).
3
+ *
4
+ * Textarea forwards aria attributes and inherits id / describedby / invalid from
5
+ * a wrapping FormGroup via context.
6
+ */
7
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
8
+ import { render, screen } from '@testing-library/svelte';
9
+ import userEvent from '@testing-library/user-event';
10
+ import { describe, expect, it } from 'vitest';
11
+ import Textarea from '../Textarea.svelte';
12
+ describe('Textarea', () => {
13
+ it('forwards aria-label / aria-describedby / aria-invalid', () => {
14
+ render(Textarea, {
15
+ props: {
16
+ 'aria-label': 'Notes',
17
+ 'aria-describedby': 'notes-hint',
18
+ 'aria-invalid': 'true',
19
+ },
20
+ });
21
+ const textarea = screen.getByRole('textbox', { name: 'Notes' });
22
+ expect(textarea).toHaveAttribute('aria-describedby', 'notes-hint');
23
+ expect(textarea).toHaveAttribute('aria-invalid', 'true');
24
+ });
25
+ it('accepts typed input', async () => {
26
+ render(Textarea, { props: { 'aria-label': 'Notes' } });
27
+ const textarea = screen.getByRole('textbox', {
28
+ name: 'Notes',
29
+ });
30
+ await userEvent.type(textarea, 'hello');
31
+ expect(textarea).toHaveValue('hello');
32
+ });
33
+ it('is axe-clean when labelled', async () => {
34
+ const { container } = render(Textarea, {
35
+ props: { 'aria-label': 'Notes' },
36
+ });
37
+ await expectNoA11yViolations(container);
38
+ });
39
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Component test for Toggle (Sweep S11, #1416).
3
+ *
4
+ * Follows the golden pattern (src/components/ui/__tests__/Button.test.ts):
5
+ * render → assert role/name/state → drive with user-event → prove axe-clean.
6
+ * Toggle renders native checkbox semantics, so it surfaces as a `checkbox` role.
7
+ */
8
+ import { expectNoA11yViolations } from '../../../test-support/a11y';
9
+ import { render, screen } from '@testing-library/svelte';
10
+ import userEvent from '@testing-library/user-event';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import Toggle from '../Toggle.svelte';
13
+ describe('Toggle', () => {
14
+ it('renders a checkbox with its label as the accessible name', () => {
15
+ render(Toggle, { props: { label: 'Email notifications' } });
16
+ const toggle = screen.getByRole('checkbox', {
17
+ name: 'Email notifications',
18
+ });
19
+ expect(toggle).toBeInTheDocument();
20
+ expect(toggle).not.toBeChecked();
21
+ });
22
+ it('prefers ariaLabel over label for the accessible name', () => {
23
+ render(Toggle, { props: { label: 'Visible', ariaLabel: 'Wifi' } });
24
+ expect(screen.getByRole('checkbox', { name: 'Wifi' })).toBeInTheDocument();
25
+ });
26
+ it('reflects the checked prop', () => {
27
+ render(Toggle, { props: { ariaLabel: 'Active', checked: true } });
28
+ expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked();
29
+ });
30
+ it('reflects the disabled prop', () => {
31
+ render(Toggle, { props: { ariaLabel: 'Active', disabled: true } });
32
+ expect(screen.getByRole('checkbox', { name: 'Active' })).toBeDisabled();
33
+ });
34
+ it('forwards name, value, and id to the input for form submission', () => {
35
+ render(Toggle, {
36
+ props: { ariaLabel: 'Sub', name: 'sub', value: 'on', id: 'sub-toggle' },
37
+ });
38
+ const toggle = screen.getByRole('checkbox', {
39
+ name: 'Sub',
40
+ });
41
+ expect(toggle).toHaveAttribute('name', 'sub');
42
+ expect(toggle).toHaveAttribute('id', 'sub-toggle');
43
+ // Svelte binds `value` as the DOM property on checkboxes (not a reflected attribute).
44
+ expect(toggle.value).toBe('on');
45
+ });
46
+ it('toggles checked and fires onchange when clicked', async () => {
47
+ const onchange = vi.fn();
48
+ render(Toggle, { props: { ariaLabel: 'Active', onchange } });
49
+ const toggle = screen.getByRole('checkbox', { name: 'Active' });
50
+ await userEvent.click(toggle);
51
+ expect(toggle).toBeChecked();
52
+ expect(onchange).toHaveBeenCalledWith(true);
53
+ await userEvent.click(toggle);
54
+ expect(toggle).not.toBeChecked();
55
+ expect(onchange).toHaveBeenLastCalledWith(false);
56
+ });
57
+ it('is keyboard-activatable with Space when focused', async () => {
58
+ const onchange = vi.fn();
59
+ render(Toggle, { props: { ariaLabel: 'Active', onchange } });
60
+ const toggle = screen.getByRole('checkbox', { name: 'Active' });
61
+ toggle.focus();
62
+ expect(toggle).toHaveFocus();
63
+ await userEvent.keyboard(' ');
64
+ expect(toggle).toBeChecked();
65
+ expect(onchange).toHaveBeenCalledWith(true);
66
+ });
67
+ it('does not fire onchange when disabled', async () => {
68
+ const onchange = vi.fn();
69
+ render(Toggle, {
70
+ props: { ariaLabel: 'Active', disabled: true, onchange },
71
+ });
72
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Active' }));
73
+ expect(onchange).not.toHaveBeenCalled();
74
+ });
75
+ it('is axe-clean with a label', async () => {
76
+ const { container } = render(Toggle, {
77
+ props: { label: 'Accessible toggle' },
78
+ });
79
+ await expectNoA11yViolations(container);
80
+ });
81
+ it('is axe-clean in the checked + disabled state', async () => {
82
+ const { container } = render(Toggle, {
83
+ props: { label: 'Locked on', checked: true, disabled: true },
84
+ });
85
+ await expectNoA11yViolations(container);
86
+ });
87
+ });
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ /** Test fixture (L1 #1420): an Input wrapped in a FormGroup, to exercise the
3
+ * FormGroup → base-input accessibility auto-wiring. */
4
+ import FormGroup from '../FormGroup.svelte';
5
+ import Input from '../Input.svelte';
6
+
7
+ let {
8
+ error = undefined,
9
+ hint = undefined,
10
+ required = false,
11
+ }: { error?: string; hint?: string; required?: boolean } = $props();
12
+ </script>
13
+
14
+ <FormGroup label="Email" {error} {hint} {required}>
15
+ <Input type="email" name="email" />
16
+ </FormGroup>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ error?: string;
3
+ hint?: string;
4
+ required?: boolean;
5
+ };
6
+ declare const FormGroupInput: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type FormGroupInput = ReturnType<typeof FormGroupInput>;
8
+ export default FormGroupInput;
9
+ //# sourceMappingURL=form-group-input.fixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-group-input.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/__tests__/form-group-input.fixture.svelte.ts"],"names":[],"mappings":"AAQC,KAAK,gBAAgB,GAAI;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAmBhF,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -0,0 +1,13 @@
1
+ export interface FormGroupContextValue {
2
+ /** Stable id for the field control; matches the FormGroup label's `for`. */
3
+ inputId: string;
4
+ /** Space-joined ids of the visible hint/error text, or undefined if none. */
5
+ describedBy: string | undefined;
6
+ /** Whether the field is currently showing an error. */
7
+ invalid: boolean;
8
+ }
9
+ export declare function setFormGroupContext(get: () => FormGroupContextValue): void;
10
+ export declare function tryGetFormGroupContext(): (() => FormGroupContextValue) | undefined;
11
+ /** Deterministic, collision-resistant id for a FormGroup that wasn't given one. */
12
+ export declare function nextFieldId(): string;
13
+ //# sourceMappingURL=form-group-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-group-context.d.ts","sourceRoot":"","sources":["../../../src/components/forms/form-group-context.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,qBAAqB;IACpC,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,uDAAuD;IACvD,OAAO,EAAE,OAAO,CAAC;CAClB;AAID,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,qBAAqB,GAAG,IAAI,CAE1E;AAED,wBAAgB,sBAAsB,IAClC,CAAC,MAAM,qBAAqB,CAAC,GAC7B,SAAS,CAEZ;AAID,mFAAmF;AACnF,wBAAgB,WAAW,IAAI,MAAM,CAGpC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * FormGroup accessibility context (Sweep L1, #1420).
3
+ *
4
+ * `FormGroup` publishes the wiring a wrapped input needs to be programmatically
5
+ * accessible — the input's `id` (so the `<label for>` resolves), the
6
+ * space-joined ids of the hint/error text (for `aria-describedby`), and whether
7
+ * the field is currently in an error state (`aria-invalid`). The base form
8
+ * primitives (`Input`, `Select`, `Textarea`) read this and auto-apply those
9
+ * attributes when the consumer hasn't set them explicitly, so an input dropped
10
+ * inside a `<FormGroup>` is accessible with no extra wiring.
11
+ *
12
+ * The value is a getter so the consuming input always reads the *current*
13
+ * reactive state (id is stable; describedBy/invalid change as hint/error do).
14
+ */
15
+ import { getContext, setContext } from 'svelte';
16
+ const FORM_GROUP_KEY = Symbol('smrt-form-group');
17
+ export function setFormGroupContext(get) {
18
+ setContext(FORM_GROUP_KEY, get);
19
+ }
20
+ export function tryGetFormGroupContext() {
21
+ return getContext(FORM_GROUP_KEY);
22
+ }
23
+ let autoIdCounter = 0;
24
+ /** Deterministic, collision-resistant id for a FormGroup that wasn't given one. */
25
+ export function nextFieldId() {
26
+ autoIdCounter += 1;
27
+ return `smrt-field-${autoIdCounter}`;
28
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Form primitives — subpath `@happyvertical/smrt-ui/forms`.
3
+ *
4
+ * The Provider-FREE base form primitives, relocated here from
5
+ * `@happyvertical/smrt-svelte/forms` (issue #1589 deferred-forms phase) so domain
6
+ * packages can adopt them without pulling in the smrt-svelte Provider or closing
7
+ * a build-graph cycle. smrt-ui is the leaf everyone may depend on.
8
+ *
9
+ * `Form`, `Input`, `Select`, `Textarea`, `Toggle`, `FormGroup` are generic,
10
+ * tokenised, a11y-checked building blocks with no Provider/i18n/spoken-input
11
+ * dependency. The Provider-REQUIRED inputs (`CheckboxInput`, `TextInput`,
12
+ * `MoneyInput`, the rich `Form`, …) stay in `@happyvertical/smrt-svelte/forms`.
13
+ */
14
+ export { default as Form } from './Form.svelte';
15
+ export { default as FormGroup } from './FormGroup.svelte';
16
+ export { default as Input } from './Input.svelte';
17
+ export { default as Select } from './Select.svelte';
18
+ export { default as Textarea } from './Textarea.svelte';
19
+ export { default as Toggle } from './Toggle.svelte';
20
+ export { type FormGroupContextValue, nextFieldId, setFormGroupContext, tryGetFormGroupContext, } from './form-group-context.js';
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/forms/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEpD,OAAO,EACL,KAAK,qBAAqB,EAC1B,WAAW,EACX,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Form primitives — subpath `@happyvertical/smrt-ui/forms`.
3
+ *
4
+ * The Provider-FREE base form primitives, relocated here from
5
+ * `@happyvertical/smrt-svelte/forms` (issue #1589 deferred-forms phase) so domain
6
+ * packages can adopt them without pulling in the smrt-svelte Provider or closing
7
+ * a build-graph cycle. smrt-ui is the leaf everyone may depend on.
8
+ *
9
+ * `Form`, `Input`, `Select`, `Textarea`, `Toggle`, `FormGroup` are generic,
10
+ * tokenised, a11y-checked building blocks with no Provider/i18n/spoken-input
11
+ * dependency. The Provider-REQUIRED inputs (`CheckboxInput`, `TextInput`,
12
+ * `MoneyInput`, the rich `Form`, …) stay in `@happyvertical/smrt-svelte/forms`.
13
+ */
14
+ export { default as Form } from './Form.svelte';
15
+ export { default as FormGroup } from './FormGroup.svelte';
16
+ export { default as Input } from './Input.svelte';
17
+ export { default as Select } from './Select.svelte';
18
+ export { default as Textarea } from './Textarea.svelte';
19
+ export { default as Toggle } from './Toggle.svelte';
20
+ export { nextFieldId, setFormGroupContext, tryGetFormGroupContext, } from './form-group-context.js';
@@ -247,6 +247,22 @@ const linkProps = $derived(() => {
247
247
  to { transform: translate(-50%, -50%) rotate(360deg); }
248
248
  }
249
249
 
250
+ /* Lay out the button's own children. The children render inside this wrapper
251
+ span, so the button-level `gap` never reaches them — without making the
252
+ wrapper itself a flex row, an icon + label (`<Button><svg/>Save</Button>`)
253
+ would render with no gap. Making `.content` a centered flex row lays out
254
+ multi-child buttons (icon+text, label+count) correctly after the #1589
255
+ migration, instead of needing per-button CSS to reach into `.content`. The
256
+ wrapper is shrink-to-fit, so a caller wanting a spread layout must also give
257
+ it width — e.g. a full-width button with
258
+ `.x :global(.content) { width: 100%; justify-content: space-between }`. */
259
+ .content {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ gap: var(--smrt-spacing-2);
264
+ }
265
+
250
266
  .content.loading {
251
267
  opacity: 0;
252
268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.34.4",
3
+ "version": "0.34.6",
4
4
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -43,6 +43,12 @@
43
43
  "import": "./dist/components/chat/index.js",
44
44
  "default": "./dist/components/chat/index.js"
45
45
  },
46
+ "./forms": {
47
+ "types": "./dist/components/forms/index.d.ts",
48
+ "svelte": "./dist/components/forms/index.js",
49
+ "import": "./dist/components/forms/index.js",
50
+ "default": "./dist/components/forms/index.js"
51
+ },
46
52
  "./i18n": {
47
53
  "types": "./dist/i18n/index.d.ts",
48
54
  "svelte": "./dist/i18n/index.js",
@@ -108,7 +114,7 @@
108
114
  },
109
115
  "dependencies": {
110
116
  "esm-env": "^1.2.2",
111
- "@happyvertical/smrt-types": "0.34.4"
117
+ "@happyvertical/smrt-types": "0.34.6"
112
118
  },
113
119
  "peerDependencies": {
114
120
  "svelte": "^5.18.2"