@happyvertical/smrt-ui 0.51.38 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -2
- package/README.md +257 -5
- package/dist/actions/__tests__/swipe-dismiss.test.js +69 -0
- package/dist/actions/swipe-dismiss.d.ts +42 -0
- package/dist/actions/swipe-dismiss.d.ts.map +1 -0
- package/dist/actions/swipe-dismiss.js +69 -0
- package/dist/components/calendar/Calendar.svelte +5 -0
- package/dist/components/calendar/Calendar.svelte.d.ts.map +1 -1
- package/dist/components/calendar/CalendarView.svelte +1250 -0
- package/dist/components/calendar/CalendarView.svelte.d.ts +66 -0
- package/dist/components/calendar/CalendarView.svelte.d.ts.map +1 -0
- package/dist/components/calendar/DayView.svelte +5 -0
- package/dist/components/calendar/DayView.svelte.d.ts.map +1 -1
- package/dist/components/calendar/__tests__/CalendarView.test.js +226 -0
- package/dist/components/calendar/__tests__/calendar-model.test.js +138 -0
- package/dist/components/calendar/calendar-model.d.ts +178 -0
- package/dist/components/calendar/calendar-model.d.ts.map +1 -0
- package/dist/components/calendar/calendar-model.js +362 -0
- package/dist/components/calendar/index.d.ts +12 -1
- package/dist/components/calendar/index.d.ts.map +1 -1
- package/dist/components/calendar/index.js +11 -1
- package/dist/components/data/CollectionList.svelte +23 -5
- package/dist/components/data/CollectionList.svelte.d.ts +8 -2
- package/dist/components/data/CollectionList.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTable.svelte +118 -40
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +4 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -1
- package/dist/components/data/DataTableController.js +11 -4
- package/dist/components/data/ListSortSelect.svelte +121 -0
- package/dist/components/data/ListSortSelect.svelte.d.ts +22 -0
- package/dist/components/data/ListSortSelect.svelte.d.ts.map +1 -0
- package/dist/components/data/SortableHeader.svelte +169 -0
- package/dist/components/data/SortableHeader.svelte.d.ts +30 -0
- package/dist/components/data/SortableHeader.svelte.d.ts.map +1 -0
- package/dist/components/data/__tests__/CollectionList.test.js +37 -0
- package/dist/components/data/__tests__/DataTable.test.js +76 -0
- package/dist/components/data/__tests__/DataTableController.test.js +35 -0
- package/dist/components/data/__tests__/SortableHeader.test.js +101 -0
- package/dist/components/data/__tests__/link-step-surface.test.js +172 -0
- package/dist/components/data/__tests__/list-sort-state.svelte.test.js +71 -0
- package/dist/components/data/__tests__/list-sort.test.js +91 -0
- package/dist/components/data/__tests__/surface-navigation.test.js +121 -0
- package/dist/components/data/data-surface.d.ts +1 -0
- package/dist/components/data/data-surface.d.ts.map +1 -1
- package/dist/components/data/data-surface.js +1 -0
- package/dist/components/data/index.d.ts +6 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +6 -0
- package/dist/components/data/link-surface.d.ts +79 -0
- package/dist/components/data/link-surface.d.ts.map +1 -0
- package/dist/components/data/link-surface.js +155 -0
- package/dist/components/data/list-sort-state.svelte.d.ts +39 -0
- package/dist/components/data/list-sort-state.svelte.d.ts.map +1 -0
- package/dist/components/data/list-sort-state.svelte.js +47 -0
- package/dist/components/data/list-sort.d.ts +94 -0
- package/dist/components/data/list-sort.d.ts.map +1 -0
- package/dist/components/data/list-sort.js +182 -0
- package/dist/components/data/step-surface.d.ts +73 -0
- package/dist/components/data/step-surface.d.ts.map +1 -0
- package/dist/components/data/step-surface.js +177 -0
- package/dist/components/data/surface-navigation.d.ts +49 -0
- package/dist/components/data/surface-navigation.d.ts.map +1 -0
- package/dist/components/data/surface-navigation.js +100 -0
- package/dist/components/data/types.d.ts +19 -0
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/components/feedback/PhoneSheet.svelte +224 -0
- package/dist/components/feedback/PhoneSheet.svelte.d.ts +35 -0
- package/dist/components/feedback/PhoneSheet.svelte.d.ts.map +1 -0
- package/dist/components/feedback/WorkingStrip.svelte +426 -0
- package/dist/components/feedback/WorkingStrip.svelte.d.ts +37 -0
- package/dist/components/feedback/WorkingStrip.svelte.d.ts.map +1 -0
- package/dist/components/feedback/__tests__/PhoneSheet.test.js +101 -0
- package/dist/components/feedback/__tests__/WorkingStrip.test.js +172 -0
- package/dist/components/feedback/index.d.ts +4 -0
- package/dist/components/feedback/index.d.ts.map +1 -1
- package/dist/components/feedback/index.js +5 -0
- package/dist/components/feedback/working-status.d.ts +34 -0
- package/dist/components/feedback/working-status.d.ts.map +1 -0
- package/dist/components/feedback/working-status.js +5 -0
- package/dist/components/forms/Checkbox.svelte +26 -2
- package/dist/components/forms/Combobox.svelte +68 -12
- package/dist/components/forms/Combobox.svelte.d.ts +6 -0
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/DictationButton.svelte +128 -0
- package/dist/components/forms/DictationButton.svelte.d.ts +19 -0
- package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -0
- package/dist/components/forms/DictationStatus.svelte +164 -0
- package/dist/components/forms/DictationStatus.svelte.d.ts +11 -0
- package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -0
- package/dist/components/forms/Fieldset.svelte +5 -1
- package/dist/components/forms/Fieldset.svelte.d.ts +2 -0
- package/dist/components/forms/Fieldset.svelte.d.ts.map +1 -1
- package/dist/components/forms/FormActionBar.svelte +84 -0
- package/dist/components/forms/FormActionBar.svelte.d.ts +23 -0
- package/dist/components/forms/FormActionBar.svelte.d.ts.map +1 -0
- package/dist/components/forms/Input.svelte +9 -0
- package/dist/components/forms/Radio.svelte +15 -4
- package/dist/components/forms/SearchInput.svelte +207 -0
- package/dist/components/forms/SearchInput.svelte.d.ts +22 -0
- package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/Select.svelte +9 -0
- package/dist/components/forms/StagedControlReview.svelte +21 -12
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -1
- package/dist/components/forms/Switch.svelte +11 -5
- package/dist/components/forms/TagsInput.svelte +8 -1
- package/dist/components/forms/Textarea.svelte +8 -0
- package/dist/components/forms/__tests__/Combobox.test.js +87 -0
- package/dist/components/forms/__tests__/ControlHitArea.test.js +73 -0
- package/dist/components/forms/__tests__/DictationButton.test.js +176 -0
- package/dist/components/forms/__tests__/Fieldset.test.js +37 -0
- package/dist/components/forms/__tests__/FormActionBar.test.js +22 -0
- package/dist/components/forms/__tests__/SearchInput.test.js +92 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +37 -3
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte +19 -0
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts +11 -0
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte +29 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +19 -1
- package/dist/components/forms/__tests__/control-proposals.test.js +63 -0
- package/dist/components/forms/__tests__/dictation-recording.test.js +582 -0
- package/dist/components/forms/__tests__/dictation.test.js +486 -0
- package/dist/components/forms/__tests__/long-press.test.js +175 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte +33 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/audio-capture.d.ts +69 -0
- package/dist/components/forms/audio-capture.d.ts.map +1 -0
- package/dist/components/forms/audio-capture.js +193 -0
- package/dist/components/forms/control-interaction.d.ts +6 -0
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +12 -1
- package/dist/components/forms/control-proposals.d.ts +55 -0
- package/dist/components/forms/control-proposals.d.ts.map +1 -0
- package/dist/components/forms/control-proposals.js +188 -0
- package/dist/components/forms/dictation-transcribe.d.ts +36 -0
- package/dist/components/forms/dictation-transcribe.d.ts.map +1 -0
- package/dist/components/forms/dictation-transcribe.js +65 -0
- package/dist/components/forms/dictation.svelte.d.ts +200 -0
- package/dist/components/forms/dictation.svelte.d.ts.map +1 -0
- package/dist/components/forms/dictation.svelte.js +686 -0
- package/dist/components/forms/index.d.ts +14 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +14 -1
- package/dist/components/forms/insert-text.d.ts +12 -0
- package/dist/components/forms/insert-text.d.ts.map +1 -0
- package/dist/components/forms/insert-text.js +38 -0
- package/dist/components/forms/long-press.d.ts +88 -0
- package/dist/components/forms/long-press.d.ts.map +1 -0
- package/dist/components/forms/long-press.js +213 -0
- package/dist/components/forms/ready-beep.d.ts +24 -0
- package/dist/components/forms/ready-beep.d.ts.map +1 -0
- package/dist/components/forms/ready-beep.js +82 -0
- package/dist/components/layout/PageHeader.svelte +151 -17
- package/dist/components/layout/PageHeader.svelte.d.ts +47 -7
- package/dist/components/layout/PageHeader.svelte.d.ts.map +1 -1
- package/dist/components/layout/__tests__/PageHeader.test.js +87 -1
- package/dist/components/layout/index.d.ts +1 -0
- package/dist/components/layout/index.d.ts.map +1 -1
- package/dist/components/layout/index.js +1 -0
- package/dist/components/layout/page-header-context.d.ts +25 -0
- package/dist/components/layout/page-header-context.d.ts.map +1 -0
- package/dist/components/layout/page-header-context.js +16 -0
- package/dist/components/nav/Tabs.svelte +241 -0
- package/dist/components/nav/Tabs.svelte.d.ts +15 -0
- package/dist/components/nav/Tabs.svelte.d.ts.map +1 -1
- package/dist/components/nav/__tests__/LinkTabs.test.js +117 -0
- package/dist/components/nav/index.d.ts +1 -0
- package/dist/components/nav/index.d.ts.map +1 -1
- package/dist/components/nav/index.js +1 -0
- package/dist/components/nav/tabs-overflow.d.ts +12 -0
- package/dist/components/nav/tabs-overflow.d.ts.map +1 -0
- package/dist/components/nav/tabs-overflow.js +17 -0
- package/dist/components/nav/types.d.ts +8 -0
- package/dist/components/nav/types.d.ts.map +1 -1
- package/dist/components/ui/Button.svelte +7 -0
- package/dist/components/ui/Card.svelte +14 -5
- package/dist/components/ui/Dropdown.svelte +55 -1
- package/dist/components/ui/Dropdown.svelte.d.ts +8 -0
- package/dist/components/ui/Dropdown.svelte.d.ts.map +1 -1
- package/dist/components/ui/IconToggle.svelte +139 -0
- package/dist/components/ui/IconToggle.svelte.d.ts +32 -0
- package/dist/components/ui/IconToggle.svelte.d.ts.map +1 -0
- package/dist/components/ui/IconToggleGroup.svelte +56 -0
- package/dist/components/ui/IconToggleGroup.svelte.d.ts +21 -0
- package/dist/components/ui/IconToggleGroup.svelte.d.ts.map +1 -0
- package/dist/components/ui/Pagination.svelte +1 -1
- package/dist/components/ui/Popover.svelte +17 -1
- package/dist/components/ui/Popover.svelte.d.ts +8 -0
- package/dist/components/ui/Popover.svelte.d.ts.map +1 -1
- package/dist/components/ui/__tests__/IconToggle.test.js +59 -0
- package/dist/components/ui/__tests__/IconToggleGroup.test.js +30 -0
- package/dist/components/ui/__tests__/Popover.test.js +44 -0
- package/dist/components/ui/__tests__/dropdown-popover-icon.test.js +125 -0
- package/dist/components/ui/__tests__/phone-touch-targets.test.js +39 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/i18n/strings.ui.d.ts +41 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +46 -0
- package/dist/svelte/__tests__/playground.test.js +1 -0
- package/dist/svelte/playground/CalendarPreview.svelte +140 -0
- package/dist/svelte/playground/CalendarPreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/CalendarPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +10 -0
- package/dist/types-generic.d.ts +2 -0
- package/dist/types-generic.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import WorkingStrip from '../WorkingStrip.svelte';
|
|
5
|
+
describe('WorkingStrip', () => {
|
|
6
|
+
it('renders nothing but an empty live region while idle', () => {
|
|
7
|
+
const { container } = render(WorkingStrip, {
|
|
8
|
+
props: { status: { phase: 'idle', label: null } },
|
|
9
|
+
});
|
|
10
|
+
expect(screen.queryByTestId('working-strip')).toBeNull();
|
|
11
|
+
const live = container.querySelector('[aria-live="polite"]');
|
|
12
|
+
expect(live?.textContent?.trim()).toBe('');
|
|
13
|
+
});
|
|
14
|
+
it('announces the status and reopens the surface from the strip', async () => {
|
|
15
|
+
const onopen = vi.fn();
|
|
16
|
+
const onstop = vi.fn();
|
|
17
|
+
const { container } = render(WorkingStrip, {
|
|
18
|
+
props: {
|
|
19
|
+
status: { phase: 'working', label: 'Writing the summary' },
|
|
20
|
+
canStop: true,
|
|
21
|
+
onopen,
|
|
22
|
+
onstop,
|
|
23
|
+
openLabel: 'Open the assistant',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
expect(container.querySelector('[aria-live="polite"]')?.textContent).toContain('Writing the summary');
|
|
27
|
+
await userEvent.click(screen.getByRole('button', {
|
|
28
|
+
name: 'Writing the summary. Open the assistant',
|
|
29
|
+
}));
|
|
30
|
+
expect(onopen).toHaveBeenCalledTimes(1);
|
|
31
|
+
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
|
32
|
+
expect(onstop).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
it('falls back to default labels and hides Stop unless it can stop', () => {
|
|
35
|
+
render(WorkingStrip, {
|
|
36
|
+
props: { status: { phase: 'working', label: null } },
|
|
37
|
+
});
|
|
38
|
+
expect(screen.getByTestId('working-strip').textContent).toContain('Working…');
|
|
39
|
+
expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
it('shows Done without a spinner or Stop', () => {
|
|
42
|
+
const { container } = render(WorkingStrip, {
|
|
43
|
+
props: {
|
|
44
|
+
status: { phase: 'done', label: null },
|
|
45
|
+
canStop: true,
|
|
46
|
+
variant: 'inline',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const strip = screen.getByTestId('working-strip');
|
|
50
|
+
expect(strip).toHaveAttribute('data-phase', 'done');
|
|
51
|
+
expect(strip.textContent).toContain('Done');
|
|
52
|
+
expect(container.querySelector('.working-strip__spinner')).toBeNull();
|
|
53
|
+
expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
it('goes green with a check once done, red with a cross once failed', () => {
|
|
56
|
+
const { rerender } = render(WorkingStrip, {
|
|
57
|
+
props: {
|
|
58
|
+
status: { phase: 'done', label: null, goal: 'Show me meetings' },
|
|
59
|
+
variant: 'floating',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const strip = screen.getByTestId('working-strip');
|
|
63
|
+
expect(strip).toHaveAttribute('data-phase', 'done');
|
|
64
|
+
expect(strip.textContent).toContain('Show me meetings');
|
|
65
|
+
expect(strip.textContent).toContain('✓');
|
|
66
|
+
rerender({
|
|
67
|
+
status: { phase: 'failed', label: null, goal: 'Show me meetings' },
|
|
68
|
+
variant: 'floating',
|
|
69
|
+
});
|
|
70
|
+
expect(screen.getByTestId('working-strip')).toHaveAttribute('data-phase', 'failed');
|
|
71
|
+
expect(screen.getByTestId('working-strip').textContent).toContain("Couldn't finish");
|
|
72
|
+
});
|
|
73
|
+
it('offers Pause while working, Continue while paused, and Esc pauses', async () => {
|
|
74
|
+
const onpause = vi.fn();
|
|
75
|
+
const onresume = vi.fn();
|
|
76
|
+
const onstop = vi.fn();
|
|
77
|
+
const { rerender } = render(WorkingStrip, {
|
|
78
|
+
props: {
|
|
79
|
+
status: { phase: 'working', label: 'Opening Events' },
|
|
80
|
+
variant: 'floating',
|
|
81
|
+
canStop: true,
|
|
82
|
+
onpause,
|
|
83
|
+
onresume,
|
|
84
|
+
onstop,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
await userEvent.click(screen.getByRole('button', { name: 'Pause' }));
|
|
88
|
+
expect(onpause).toHaveBeenCalledTimes(1);
|
|
89
|
+
screen.getByRole('button', { name: 'Pause' }).focus();
|
|
90
|
+
await userEvent.keyboard('{Escape}');
|
|
91
|
+
expect(onpause).toHaveBeenCalledTimes(2);
|
|
92
|
+
rerender({
|
|
93
|
+
status: { phase: 'paused', label: null },
|
|
94
|
+
variant: 'floating',
|
|
95
|
+
canStop: true,
|
|
96
|
+
onpause,
|
|
97
|
+
onresume,
|
|
98
|
+
onstop,
|
|
99
|
+
});
|
|
100
|
+
expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull();
|
|
101
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
|
102
|
+
expect(onresume).toHaveBeenCalledTimes(1);
|
|
103
|
+
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
|
104
|
+
expect(onstop).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
it('asks for the person assertively while waiting, with Review', async () => {
|
|
107
|
+
const onreview = vi.fn();
|
|
108
|
+
const { container } = render(WorkingStrip, {
|
|
109
|
+
props: {
|
|
110
|
+
status: {
|
|
111
|
+
phase: 'waiting',
|
|
112
|
+
label: 'A change is ready for you',
|
|
113
|
+
goal: 'Fill the event form',
|
|
114
|
+
},
|
|
115
|
+
variant: 'floating',
|
|
116
|
+
onreview,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
expect(container.querySelector('[aria-live="assertive"]')?.textContent).toContain('A change is ready for you');
|
|
120
|
+
expect(container.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe('');
|
|
121
|
+
await userEvent.click(screen.getByRole('button', { name: 'Review' }));
|
|
122
|
+
expect(onreview).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
it('never takes focus by itself', async () => {
|
|
125
|
+
const outside = document.createElement('button');
|
|
126
|
+
document.body.append(outside);
|
|
127
|
+
outside.focus();
|
|
128
|
+
const { rerender } = render(WorkingStrip, {
|
|
129
|
+
props: {
|
|
130
|
+
status: { phase: 'working', label: 'Step one' },
|
|
131
|
+
variant: 'floating',
|
|
132
|
+
onpause: vi.fn(),
|
|
133
|
+
onopen: vi.fn(),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
rerender({
|
|
137
|
+
status: { phase: 'waiting', label: 'Your turn' },
|
|
138
|
+
variant: 'floating',
|
|
139
|
+
onreview: vi.fn(),
|
|
140
|
+
onopen: vi.fn(),
|
|
141
|
+
});
|
|
142
|
+
expect(document.activeElement).toBe(outside);
|
|
143
|
+
outside.remove();
|
|
144
|
+
});
|
|
145
|
+
it('throttles polite step announcements', async () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
const { container, rerender } = render(WorkingStrip, {
|
|
149
|
+
props: {
|
|
150
|
+
status: { phase: 'working', label: 'Step one' },
|
|
151
|
+
announceIntervalMs: 2000,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const live = () => container.querySelector('[aria-live="polite"]')?.textContent?.trim();
|
|
155
|
+
expect(live()).toBe('Step one');
|
|
156
|
+
await rerender({
|
|
157
|
+
status: { phase: 'working', label: 'Step two' },
|
|
158
|
+
announceIntervalMs: 2000,
|
|
159
|
+
});
|
|
160
|
+
await rerender({
|
|
161
|
+
status: { phase: 'working', label: 'Step three' },
|
|
162
|
+
announceIntervalMs: 2000,
|
|
163
|
+
});
|
|
164
|
+
expect(live()).toBe('Step one');
|
|
165
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
166
|
+
expect(live()).toBe('Step three');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
vi.useRealTimers();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Feedback components - User feedback and status indicators
|
|
3
3
|
*/
|
|
4
|
+
export { SWIPE_DISMISS_THRESHOLD, type SwipeDirection, type SwipeDismissOptions, swipeDismiss, swipeDismisses, } from '../../actions/swipe-dismiss.js';
|
|
4
5
|
export { default as Alert } from './Alert.svelte';
|
|
5
6
|
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
|
|
6
7
|
export { default as Drawer, default as Sheet } from './Drawer.svelte';
|
|
7
8
|
export { default as LoadingOverlay } from './LoadingOverlay.svelte';
|
|
8
9
|
export { default as Meter } from './Meter.svelte';
|
|
9
10
|
export { default as Modal } from './Modal.svelte';
|
|
11
|
+
export { default as PhoneSheet } from './PhoneSheet.svelte';
|
|
10
12
|
export { default as Progress } from './Progress.svelte';
|
|
11
13
|
export { default as ProgressBar } from './ProgressBar.svelte';
|
|
12
14
|
export { default as Spinner } from './Spinner.svelte';
|
|
13
15
|
export { default as ToastViewport } from './ToastViewport.svelte';
|
|
14
16
|
export type { Toast, ToastAction, Toaster, ToastInput, ToastVariant, } from './toast.js';
|
|
15
17
|
export { createToaster, toaster } from './toast.js';
|
|
18
|
+
export { default as WorkingStrip } from './WorkingStrip.svelte';
|
|
19
|
+
export { WORKING_ASSERTIVE_PHASES, type WorkingPhase, type WorkingStatus, } from './working-status.js';
|
|
16
20
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/index.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EACL,uBAAuB,EACvB,KAAK,cAAc,EACnB,KAAK,mBAAmB,EACxB,YAAY,EACZ,cAAc,GACf,MAAM,gCAAgC,CAAC;AAIxC,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,YAAY,EACV,KAAK,EACL,WAAW,EACX,OAAO,EACP,UAAU,EACV,YAAY,GACb,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EACL,wBAAwB,EACxB,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,qBAAqB,CAAC"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Feedback components - User feedback and status indicators
|
|
3
3
|
*/
|
|
4
|
+
// Swipe-to-dismiss (used by PhoneSheet; phone drawers use it too).
|
|
5
|
+
export { SWIPE_DISMISS_THRESHOLD, swipeDismiss, swipeDismisses, } from '../../actions/swipe-dismiss.js';
|
|
4
6
|
// Note: Component prop types (ConfirmDialogProps, etc.) are available
|
|
5
7
|
// via svelte-package build output but cannot be re-exported here because
|
|
6
8
|
// tsc --noEmit cannot resolve type exports from .svelte files.
|
|
@@ -10,8 +12,11 @@ export { default as Drawer, default as Sheet } from './Drawer.svelte';
|
|
|
10
12
|
export { default as LoadingOverlay } from './LoadingOverlay.svelte';
|
|
11
13
|
export { default as Meter } from './Meter.svelte';
|
|
12
14
|
export { default as Modal } from './Modal.svelte';
|
|
15
|
+
export { default as PhoneSheet } from './PhoneSheet.svelte';
|
|
13
16
|
export { default as Progress } from './Progress.svelte';
|
|
14
17
|
export { default as ProgressBar } from './ProgressBar.svelte';
|
|
15
18
|
export { default as Spinner } from './Spinner.svelte';
|
|
16
19
|
export { default as ToastViewport } from './ToastViewport.svelte';
|
|
17
20
|
export { createToaster, toaster } from './toast.js';
|
|
21
|
+
export { default as WorkingStrip } from './WorkingStrip.svelte';
|
|
22
|
+
export { WORKING_ASSERTIVE_PHASES, } from './working-status.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic "something is working" status rendered by `WorkingStrip`.
|
|
3
|
+
*
|
|
4
|
+
* - `idle`: nothing is shown;
|
|
5
|
+
* - `working`: a spinner and what is happening ("Writing the summary…");
|
|
6
|
+
* - `paused`: the work is on hold until the person continues it;
|
|
7
|
+
* - `waiting`: the work needs the person (a change to review, a choice to
|
|
8
|
+
* make, a question to answer) — shown prominently, announced assertively;
|
|
9
|
+
* - `done`: a short confirmation ("Done · 2 changes") the host clears after
|
|
10
|
+
* a few seconds;
|
|
11
|
+
* - `failed`: the work stopped on a problem — announced assertively;
|
|
12
|
+
* - `cancelled`: the person stopped it.
|
|
13
|
+
*
|
|
14
|
+
* Transport-neutral: an assistant run, an upload, or a background job can
|
|
15
|
+
* all feed it.
|
|
16
|
+
*/
|
|
17
|
+
export type WorkingPhase = 'idle' | 'working' | 'paused' | 'waiting' | 'done' | 'failed' | 'cancelled';
|
|
18
|
+
export interface WorkingStatus {
|
|
19
|
+
/** Lifecycle phase. */
|
|
20
|
+
phase: WorkingPhase;
|
|
21
|
+
/**
|
|
22
|
+
* What to say: the current step while working ("Opening Events"), the
|
|
23
|
+
* outcome otherwise. `null` falls back to a default for the phase.
|
|
24
|
+
*/
|
|
25
|
+
label: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* What the work is for, in the person's own words ("Show me upcoming
|
|
28
|
+
* meetings"). Shown above the step on the floating pill; optional.
|
|
29
|
+
*/
|
|
30
|
+
goal?: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** Phases whose change is announced assertively (the person is needed). */
|
|
33
|
+
export declare const WORKING_ASSERTIVE_PHASES: readonly WorkingPhase[];
|
|
34
|
+
//# sourceMappingURL=working-status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"working-status.d.ts","sourceRoot":"","sources":["../../../src/components/feedback/working-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,SAAS,GACT,QAAQ,GACR,SAAS,GACT,MAAM,GACN,QAAQ,GACR,WAAW,CAAC;AAEhB,MAAM,WAAW,aAAa;IAC5B,uBAAuB;IACvB,KAAK,EAAE,YAAY,CAAC;IACpB;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,2EAA2E;AAC3E,eAAO,MAAM,wBAAwB,EAAE,SAAS,YAAY,EAG3D,CAAC"}
|
|
@@ -123,6 +123,7 @@ useControlRegistration(() => {
|
|
|
123
123
|
<label
|
|
124
124
|
class="checkbox {className}"
|
|
125
125
|
class:checkbox--disabled={disabled}
|
|
126
|
+
data-smrt-hit-target
|
|
126
127
|
data-smrt-control={controlId}
|
|
127
128
|
data-smrt-form={interactionContext?.formId}
|
|
128
129
|
data-smrt-subject-type={resolvedInteraction === false ? undefined : resolvedInteraction.subject?.type}
|
|
@@ -150,14 +151,37 @@ useControlRegistration(() => {
|
|
|
150
151
|
</label>
|
|
151
152
|
|
|
152
153
|
<style>
|
|
153
|
-
.
|
|
154
|
+
/* The visible box stays 18px. A transparent 44x44 hit area (WCAG 2.5.8 / M3 touch target) is
|
|
155
|
+
centred on it with ::before, so clicks nearby still land on the wrapping <label>. It adds no
|
|
156
|
+
layout. The box and the label text sit above every hit area (z-index 1 over 0), so one
|
|
157
|
+
control's hit area never steals a click from another control's box or text.
|
|
158
|
+
The hit area is painted above plain content around it, so it only grows sideways when the
|
|
159
|
+
checkbox stands alone in its parent. Beside anything else (a row title, a link, other text)
|
|
160
|
+
it keeps to the box's own column and grows vertically only. Resize it with
|
|
161
|
+
--smrt-control-hit-size (0px turns it off). */
|
|
162
|
+
.checkbox { --_box: 1.125rem; --_hit: max(var(--smrt-control-hit-size, 2.75rem), var(--_box)); position: relative; display: inline-flex; align-items: center; gap: var(--smrt-spacing-2, .5rem); cursor: pointer; color: var(--smrt-color-on-surface); }
|
|
163
|
+
.checkbox::before { content: ''; position: absolute; z-index: 0; inset-block-start: 50%; inset-inline-start: calc(var(--_box) / 2); inline-size: var(--_hit); block-size: var(--_hit); margin-block-start: calc(var(--_hit) / -2); margin-inline-start: calc(var(--_hit) / -2); }
|
|
154
164
|
.checkbox--disabled { opacity: .5; cursor: not-allowed; }
|
|
155
165
|
input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
156
|
-
.checkbox__box { width: 1.125rem; height: 1.125rem; display: grid; place-items: center; border: 2px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-extra-small, 3px); background: var(--smrt-color-surface); transition: background var(--smrt-duration-short2), border-color var(--smrt-duration-short2); }
|
|
166
|
+
.checkbox__box { position: relative; z-index: 1; width: 1.125rem; height: 1.125rem; display: grid; place-items: center; border: 2px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-extra-small, 3px); background: var(--smrt-color-surface); transition: background var(--smrt-duration-short2), border-color var(--smrt-duration-short2); }
|
|
167
|
+
.checkbox__label { position: relative; z-index: 1; }
|
|
157
168
|
input:checked + .checkbox__box, input:indeterminate + .checkbox__box { background: var(--smrt-color-primary); border-color: var(--smrt-color-primary); color: var(--smrt-color-on-primary); }
|
|
158
169
|
input:focus-visible + .checkbox__box { outline: 2px solid var(--smrt-color-primary); outline-offset: 3px; }
|
|
159
170
|
.checkbox__check { font-size: .8rem; font-weight: 800; line-height: 1; }
|
|
160
171
|
.checkbox__dash { width: .6rem; height: 2px; background: currentColor; }
|
|
172
|
+
.checkbox:not(:only-child)::before { inline-size: var(--_box); margin-inline-start: calc(var(--_box) / -2); }
|
|
161
173
|
.checkbox[data-smrt-highlighted='true'] { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; border-radius: var(--smrt-radius-small); }
|
|
174
|
+
|
|
175
|
+
/* Table cells: neighbouring cells hold links and other controls, so the hit area never leaves
|
|
176
|
+
the cell's column. It grows vertically only (box wide)... */
|
|
177
|
+
:global(:is(td, th)) .checkbox::before { inline-size: var(--_box); margin-inline-start: calc(var(--_box) / -2); }
|
|
178
|
+
/* ...unless the checkbox is the cell's only content: then the whole cell, clipped to its own
|
|
179
|
+
edges, is the hit area (the 44px target the header and every row share). The cell only needs
|
|
180
|
+
a containing block, so :where() keeps that rule at zero specificity: a sticky header or a
|
|
181
|
+
pinned column's own position always wins (a sticky cell contains the hit area too). */
|
|
182
|
+
:global(:where(td:has(> [data-smrt-hit-target]:only-child), th:has(> [data-smrt-hit-target]:only-child))) { position: relative; }
|
|
183
|
+
:global(:is(td, th):has(> [data-smrt-hit-target]:only-child)) > .checkbox { position: static; }
|
|
184
|
+
:global(:is(td, th):has(> [data-smrt-hit-target]:only-child)) > .checkbox::before { inset: 0; inline-size: auto; block-size: auto; margin: 0; }
|
|
185
|
+
|
|
162
186
|
@media (prefers-reduced-motion: reduce) { .checkbox__box { transition: none; } }
|
|
163
187
|
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
2
3
|
import {
|
|
3
4
|
emitControlChange,
|
|
4
5
|
highlightControl,
|
|
@@ -36,6 +37,12 @@ export interface Props {
|
|
|
36
37
|
allowCustom?: boolean;
|
|
37
38
|
/** Interaction options or false to disable all. */
|
|
38
39
|
interaction?: ControlInteractionOptions | false;
|
|
40
|
+
/**
|
|
41
|
+
* Label for `value` while its option is not in `options` yet (options load
|
|
42
|
+
* asynchronously, or only the current record is known). Without it the
|
|
43
|
+
* field stays empty rather than flashing the raw value (an id).
|
|
44
|
+
*/
|
|
45
|
+
valueLabel?: string;
|
|
39
46
|
/** Callback when the selected value changes. */
|
|
40
47
|
onvaluechange?: (value: string) => void;
|
|
41
48
|
/** CSS class to apply to the combobox container. */
|
|
@@ -51,6 +58,7 @@ let {
|
|
|
51
58
|
required = false,
|
|
52
59
|
allowCustom = false,
|
|
53
60
|
interaction,
|
|
61
|
+
valueLabel,
|
|
54
62
|
onvaluechange,
|
|
55
63
|
class: className = '',
|
|
56
64
|
}: Props = $props();
|
|
@@ -61,12 +69,24 @@ const interactionContext = tryGetControlInteractionContext();
|
|
|
61
69
|
let rootEl = $state<HTMLDivElement | null>(null);
|
|
62
70
|
let inputEl = $state<HTMLInputElement | null>(null);
|
|
63
71
|
let open = $state(false);
|
|
64
|
-
|
|
72
|
+
/** The text for a value: its option's label, never a raw id (see valueLabel). */
|
|
73
|
+
function labelForValue(candidate: string): string {
|
|
74
|
+
if (!candidate) return '';
|
|
75
|
+
const option = options.find((item) => String(item.value) === candidate);
|
|
76
|
+
if (option) return option.label;
|
|
77
|
+
if (valueLabel) return valueLabel;
|
|
78
|
+
return allowCustom ? candidate : '';
|
|
79
|
+
}
|
|
80
|
+
let query = $state(untrack(() => labelForValue(value)));
|
|
81
|
+
/** The person has typed since opening: only then does the list filter. */
|
|
82
|
+
let typed = $state(false);
|
|
65
83
|
let activeIndex = $state(0);
|
|
66
84
|
const filtered = $derived(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
typed
|
|
86
|
+
? options.filter((option) =>
|
|
87
|
+
option.label.toLowerCase().includes(query.toLowerCase()),
|
|
88
|
+
)
|
|
89
|
+
: options,
|
|
70
90
|
);
|
|
71
91
|
const controlId = $derived(
|
|
72
92
|
interaction === false ? undefined : (interaction?.id ?? name ?? inputId),
|
|
@@ -76,6 +96,7 @@ function commit(option: ControlOption, userEdit = false) {
|
|
|
76
96
|
const changed = String(option.value) !== value;
|
|
77
97
|
value = String(option.value);
|
|
78
98
|
query = option.label;
|
|
99
|
+
typed = false;
|
|
79
100
|
open = false;
|
|
80
101
|
onvaluechange?.(value);
|
|
81
102
|
if (userEdit && changed) {
|
|
@@ -94,6 +115,7 @@ function setValue(next: unknown) {
|
|
|
94
115
|
else if (allowCustom) {
|
|
95
116
|
value = candidate;
|
|
96
117
|
query = candidate;
|
|
118
|
+
typed = false;
|
|
97
119
|
onvaluechange?.(candidate);
|
|
98
120
|
}
|
|
99
121
|
}
|
|
@@ -104,8 +126,24 @@ function prepareValue(next: unknown) {
|
|
|
104
126
|
if (allowCustom) return candidate;
|
|
105
127
|
return candidate;
|
|
106
128
|
}
|
|
129
|
+
function openList() {
|
|
130
|
+
if (open) return;
|
|
131
|
+
open = true;
|
|
132
|
+
// Reopening shows every option, starting at the current choice.
|
|
133
|
+
typed = false;
|
|
134
|
+
const selected = options.findIndex(
|
|
135
|
+
(option) => String(option.value) === value,
|
|
136
|
+
);
|
|
137
|
+
activeIndex = selected >= 0 ? selected : 0;
|
|
138
|
+
}
|
|
139
|
+
function closeList() {
|
|
140
|
+
open = false;
|
|
141
|
+
// Leaving without choosing puts the chosen label back.
|
|
142
|
+
typed = false;
|
|
143
|
+
}
|
|
107
144
|
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
|
|
108
145
|
query = event.currentTarget.value;
|
|
146
|
+
typed = true;
|
|
109
147
|
open = true;
|
|
110
148
|
activeIndex = 0;
|
|
111
149
|
if (allowCustom) {
|
|
@@ -116,7 +154,10 @@ function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
|
|
|
116
154
|
function handleKeydown(event: KeyboardEvent) {
|
|
117
155
|
if (event.key === 'ArrowDown') {
|
|
118
156
|
event.preventDefault();
|
|
119
|
-
open
|
|
157
|
+
if (!open) {
|
|
158
|
+
openList();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
120
161
|
const enabled = filtered
|
|
121
162
|
.map((option, index) => (option.disabled ? -1 : index))
|
|
122
163
|
.filter((index) => index >= 0);
|
|
@@ -134,16 +175,16 @@ function handleKeydown(event: KeyboardEvent) {
|
|
|
134
175
|
event.preventDefault();
|
|
135
176
|
commit(filtered[activeIndex], true);
|
|
136
177
|
} else if (event.key === 'Escape') {
|
|
137
|
-
|
|
178
|
+
closeList();
|
|
138
179
|
}
|
|
139
180
|
}
|
|
140
181
|
$effect(() => {
|
|
141
182
|
if (!open) return;
|
|
142
183
|
const dismissPointer = (event: PointerEvent) => {
|
|
143
|
-
if (rootEl && !rootEl.contains(event.target as Node))
|
|
184
|
+
if (rootEl && !rootEl.contains(event.target as Node)) closeList();
|
|
144
185
|
};
|
|
145
186
|
const dismissFocus = (event: FocusEvent) => {
|
|
146
|
-
if (rootEl && !rootEl.contains(event.target as Node))
|
|
187
|
+
if (rootEl && !rootEl.contains(event.target as Node)) closeList();
|
|
147
188
|
};
|
|
148
189
|
document.addEventListener('pointerdown', dismissPointer, true);
|
|
149
190
|
document.addEventListener('focusin', dismissFocus, true);
|
|
@@ -152,9 +193,21 @@ $effect(() => {
|
|
|
152
193
|
document.removeEventListener('focusin', dismissFocus, true);
|
|
153
194
|
};
|
|
154
195
|
});
|
|
196
|
+
// Follow the value (and options that load later) unless the person is typing.
|
|
197
|
+
$effect(() => {
|
|
198
|
+
const label = labelForValue(value);
|
|
199
|
+
if (!typed) query = label;
|
|
200
|
+
});
|
|
201
|
+
// Validity follows the committed `value`, not the visible text: a value whose
|
|
202
|
+
// option has not loaded shows an empty field but is filled in, and text typed
|
|
203
|
+
// without choosing an option is not a value.
|
|
155
204
|
$effect(() => {
|
|
156
|
-
|
|
157
|
-
|
|
205
|
+
if (!inputEl) return;
|
|
206
|
+
inputEl.setCustomValidity(
|
|
207
|
+
required && !disabled && !value && query.trim() !== ''
|
|
208
|
+
? 'Choose an option from the list.'
|
|
209
|
+
: '',
|
|
210
|
+
);
|
|
158
211
|
});
|
|
159
212
|
useControlRegistration(() => {
|
|
160
213
|
const root = rootEl;
|
|
@@ -203,9 +256,12 @@ useControlRegistration(() => {
|
|
|
203
256
|
<div bind:this={rootEl} class="combobox {className}" data-smrt-control={controlId} data-smrt-form={interactionContext?.formId}
|
|
204
257
|
data-smrt-subject-type={interaction === false ? undefined : interaction?.subject?.type}
|
|
205
258
|
data-smrt-subject-id={interaction === false ? undefined : interaction?.subject?.id}>
|
|
206
|
-
<label for={inputId}>{label}</label
|
|
259
|
+
<label for={inputId}>{label}</label>
|
|
260
|
+
<!-- The form posts the committed value (an option id), never the label the person sees. -->
|
|
261
|
+
{#if name}<input type="hidden" {name} {value} {disabled} />{/if}
|
|
262
|
+
<input bind:this={inputEl} id={inputId} role="combobox" autocomplete="off" {placeholder} {disabled} required={required && !value} aria-required={required ? 'true' : undefined} value={query}
|
|
207
263
|
aria-expanded={open} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={open && filtered[activeIndex] ? `${listId}-${activeIndex}` : undefined}
|
|
208
|
-
onfocus={
|
|
264
|
+
onfocus={openList} onclick={openList} oninput={handleInput} onkeydown={handleKeydown} />
|
|
209
265
|
{#if open && filtered.length}<div id={listId} class="options" role="listbox">{#each filtered as option, index (option.value)}<button id={`${listId}-${index}`} type="button" role="option"
|
|
210
266
|
aria-selected={String(option.value) === value} class:active={index === activeIndex} disabled={option.disabled} onpointerdown={(event) => event.preventDefault()} onclick={() => commit(option, true)}>{option.label}</button>{/each}</div>{/if}
|
|
211
267
|
</div>
|
|
@@ -18,6 +18,12 @@ export interface Props {
|
|
|
18
18
|
allowCustom?: boolean;
|
|
19
19
|
/** Interaction options or false to disable all. */
|
|
20
20
|
interaction?: ControlInteractionOptions | false;
|
|
21
|
+
/**
|
|
22
|
+
* Label for `value` while its option is not in `options` yet (options load
|
|
23
|
+
* asynchronously, or only the current record is known). Without it the
|
|
24
|
+
* field stays empty rather than flashing the raw value (an id).
|
|
25
|
+
*/
|
|
26
|
+
valueLabel?: string;
|
|
21
27
|
/** Callback when the selected value changes. */
|
|
22
28
|
onvaluechange?: (value: string) => void;
|
|
23
29
|
/** CSS class to apply to the combobox container. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACd,MAAM,0BAA0B,CAAC;AAWlC,MAAM,WAAW,KAAK;IACpB,uCAAuC;IACvC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiOD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* DictationButton — the microphone next to a text field (see `Dictation`).
|
|
4
|
+
*
|
|
5
|
+
* Tap to start listening, tap again to stop. It is always there, so people
|
|
6
|
+
* who cannot (or do not know to) press and hold the field can still speak.
|
|
7
|
+
* 44px target, named by `aria-label`, `aria-pressed` while listening, and a
|
|
8
|
+
* listening dot that does not rely on color alone (the name changes too).
|
|
9
|
+
* Pair it with `DictationStatus` for the words ("Listening…", errors).
|
|
10
|
+
*/
|
|
11
|
+
import { M } from '../../i18n/strings.ui.js';
|
|
12
|
+
import { useI18n } from '../../i18n/use-i18n.js';
|
|
13
|
+
import Button from '../ui/Button.svelte';
|
|
14
|
+
import type { Dictation } from './dictation.svelte.js';
|
|
15
|
+
import { primeReadyBeep } from './ready-beep.js';
|
|
16
|
+
|
|
17
|
+
export interface Props {
|
|
18
|
+
/** The dictation this button starts and stops. */
|
|
19
|
+
dictation: Dictation;
|
|
20
|
+
/** Disable the button (for example while the form sends). */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Accessible name to start (default "Speak instead of typing"). */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** Accessible name to stop (default "Stop listening"). */
|
|
25
|
+
stopLabel?: string;
|
|
26
|
+
/** Tooltip (default mentions press-and-hold on the text box). */
|
|
27
|
+
title?: string;
|
|
28
|
+
/** Extra class names. */
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
dictation,
|
|
34
|
+
disabled = false,
|
|
35
|
+
label,
|
|
36
|
+
stopLabel,
|
|
37
|
+
title,
|
|
38
|
+
class: className = '',
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const { t } = useI18n();
|
|
42
|
+
const active = $derived(dictation.active);
|
|
43
|
+
// While a recording is being written down, a tap would do nothing.
|
|
44
|
+
const busy = $derived(dictation.state === 'transcribing');
|
|
45
|
+
const name = $derived(
|
|
46
|
+
active
|
|
47
|
+
? (stopLabel ?? t(M['ui.dictation.stop']))
|
|
48
|
+
: (label ?? t(M['ui.dictation.start'])),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function handleClick() {
|
|
52
|
+
// A tap is a user gesture: wake the audio so the ready beep can play.
|
|
53
|
+
primeReadyBeep();
|
|
54
|
+
void dictation.unlock();
|
|
55
|
+
void dictation.toggle();
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="sm"
|
|
63
|
+
class={`smrt-dictation-button${active ? ' smrt-dictation-button--listening' : ''} ${className}`.trim()}
|
|
64
|
+
aria-label={name}
|
|
65
|
+
aria-pressed={active}
|
|
66
|
+
aria-busy={busy || undefined}
|
|
67
|
+
title={active ? name : (title ?? t(M['ui.dictation.start_hint']))}
|
|
68
|
+
disabled={disabled || busy}
|
|
69
|
+
onclick={handleClick}
|
|
70
|
+
data-dictation-state={dictation.state}
|
|
71
|
+
>
|
|
72
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
73
|
+
<rect x="9" y="2" width="6" height="12" rx="3"></rect>
|
|
74
|
+
<path d="M5 10a7 7 0 0 0 14 0"></path>
|
|
75
|
+
<path d="M12 17v4"></path>
|
|
76
|
+
</svg>
|
|
77
|
+
{#if active}
|
|
78
|
+
<span class="smrt-dictation-dot" aria-hidden="true"></span>
|
|
79
|
+
{/if}
|
|
80
|
+
</Button>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
:global(.smrt-dictation-button) {
|
|
84
|
+
position: relative;
|
|
85
|
+
flex-shrink: 0;
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
inline-size: 44px;
|
|
90
|
+
block-size: 44px;
|
|
91
|
+
min-inline-size: 44px;
|
|
92
|
+
padding: 0;
|
|
93
|
+
border-radius: var(--smrt-radius-full, 9999px);
|
|
94
|
+
color: var(--smrt-color-on-surface-variant, #43474e);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
:global(.smrt-dictation-button--listening) {
|
|
98
|
+
background: var(--smrt-color-error-container, #ffdad6);
|
|
99
|
+
color: var(--smrt-color-on-error-container, #410002);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.smrt-dictation-dot {
|
|
103
|
+
position: absolute;
|
|
104
|
+
inset-block-start: 6px;
|
|
105
|
+
inset-inline-end: 6px;
|
|
106
|
+
inline-size: 10px;
|
|
107
|
+
block-size: 10px;
|
|
108
|
+
border-radius: 50%;
|
|
109
|
+
background: var(--smrt-color-error, #ba1a1a);
|
|
110
|
+
animation: smrt-dictation-pulse 1.2s ease-in-out infinite;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@keyframes smrt-dictation-pulse {
|
|
114
|
+
0%,
|
|
115
|
+
100% {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
}
|
|
118
|
+
50% {
|
|
119
|
+
opacity: 0.35;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@media (prefers-reduced-motion: reduce) {
|
|
124
|
+
.smrt-dictation-dot {
|
|
125
|
+
animation: none;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Dictation } from './dictation.svelte.js';
|
|
2
|
+
export interface Props {
|
|
3
|
+
/** The dictation this button starts and stops. */
|
|
4
|
+
dictation: Dictation;
|
|
5
|
+
/** Disable the button (for example while the form sends). */
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
/** Accessible name to start (default "Speak instead of typing"). */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Accessible name to stop (default "Stop listening"). */
|
|
10
|
+
stopLabel?: string;
|
|
11
|
+
/** Tooltip (default mentions press-and-hold on the text box). */
|
|
12
|
+
title?: string;
|
|
13
|
+
/** Extra class names. */
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const DictationButton: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type DictationButton = ReturnType<typeof DictationButton>;
|
|
18
|
+
export default DictationButton;
|
|
19
|
+
//# sourceMappingURL=DictationButton.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DictationButton.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/DictationButton.svelte.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIvD,MAAM,WAAW,KAAK;IACpB,kDAAkD;IAClD,SAAS,EAAE,SAAS,CAAC;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoDD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|