@happyvertical/smrt-ui 0.34.5 → 0.34.7
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 +15 -5
- package/dist/components/chat/MessageBubble.svelte +123 -33
- package/dist/components/chat/MessageBubble.svelte.d.ts +33 -10
- package/dist/components/chat/MessageBubble.svelte.d.ts.map +1 -1
- package/dist/components/chat/ReactionPicker.svelte +50 -18
- package/dist/components/chat/ReactionPicker.svelte.d.ts +8 -8
- package/dist/components/chat/ReactionPicker.svelte.d.ts.map +1 -1
- package/dist/components/chat/TypingIndicator.svelte +42 -25
- package/dist/components/chat/TypingIndicator.svelte.d.ts +12 -5
- package/dist/components/chat/TypingIndicator.svelte.d.ts.map +1 -1
- package/dist/components/chat/__tests__/chat-primitives.test.js +52 -1
- package/dist/components/forms/Form.svelte +53 -0
- package/dist/components/forms/Form.svelte.d.ts +29 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -0
- package/dist/components/forms/FormGroup.svelte +86 -0
- package/dist/components/forms/FormGroup.svelte.d.ts +13 -0
- package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -0
- package/dist/components/forms/Input.svelte +89 -0
- package/dist/components/forms/Input.svelte.d.ts +9 -0
- package/dist/components/forms/Input.svelte.d.ts.map +1 -0
- package/dist/components/forms/Select.svelte +89 -0
- package/dist/components/forms/Select.svelte.d.ts +11 -0
- package/dist/components/forms/Select.svelte.d.ts.map +1 -0
- package/dist/components/forms/Textarea.svelte +91 -0
- package/dist/components/forms/Textarea.svelte.d.ts +10 -0
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -0
- package/dist/components/forms/Toggle.svelte +224 -0
- package/dist/components/forms/Toggle.svelte.d.ts +37 -0
- package/dist/components/forms/Toggle.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/Form.test.js +49 -0
- package/dist/components/forms/__tests__/FormGroup.test.js +48 -0
- package/dist/components/forms/__tests__/Input.test.js +49 -0
- package/dist/components/forms/__tests__/Select.test.js +37 -0
- package/dist/components/forms/__tests__/Textarea.test.js +39 -0
- package/dist/components/forms/__tests__/Toggle.test.js +87 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte +16 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/form-group-context.d.ts +13 -0
- package/dist/components/forms/form-group-context.d.ts.map +1 -0
- package/dist/components/forms/form-group-context.js +28 -0
- package/dist/components/forms/index.d.ts +21 -0
- package/dist/components/forms/index.d.ts.map +1 -0
- package/dist/components/forms/index.js +20 -0
- package/dist/components/ui/Button.svelte +16 -0
- package/dist/i18n/strings.ui.d.ts +2 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
|
|
218
|
+
@media (prefers-reduced-motion: reduce) {
|
|
219
|
+
.toggle__thumb,
|
|
220
|
+
.toggle__thumb::after {
|
|
221
|
+
transition: none;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
</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
|
+
}
|