@happyvertical/smrt-ui 0.51.39 → 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.
Files changed (212) hide show
  1. package/AGENTS.md +14 -2
  2. package/README.md +257 -5
  3. package/dist/actions/__tests__/swipe-dismiss.test.js +69 -0
  4. package/dist/actions/swipe-dismiss.d.ts +42 -0
  5. package/dist/actions/swipe-dismiss.d.ts.map +1 -0
  6. package/dist/actions/swipe-dismiss.js +69 -0
  7. package/dist/components/calendar/Calendar.svelte +5 -0
  8. package/dist/components/calendar/Calendar.svelte.d.ts.map +1 -1
  9. package/dist/components/calendar/CalendarView.svelte +1250 -0
  10. package/dist/components/calendar/CalendarView.svelte.d.ts +66 -0
  11. package/dist/components/calendar/CalendarView.svelte.d.ts.map +1 -0
  12. package/dist/components/calendar/DayView.svelte +5 -0
  13. package/dist/components/calendar/DayView.svelte.d.ts.map +1 -1
  14. package/dist/components/calendar/__tests__/CalendarView.test.js +226 -0
  15. package/dist/components/calendar/__tests__/calendar-model.test.js +138 -0
  16. package/dist/components/calendar/calendar-model.d.ts +178 -0
  17. package/dist/components/calendar/calendar-model.d.ts.map +1 -0
  18. package/dist/components/calendar/calendar-model.js +362 -0
  19. package/dist/components/calendar/index.d.ts +12 -1
  20. package/dist/components/calendar/index.d.ts.map +1 -1
  21. package/dist/components/calendar/index.js +11 -1
  22. package/dist/components/data/CollectionList.svelte +23 -5
  23. package/dist/components/data/CollectionList.svelte.d.ts +8 -2
  24. package/dist/components/data/CollectionList.svelte.d.ts.map +1 -1
  25. package/dist/components/data/DataTable.svelte +118 -40
  26. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  27. package/dist/components/data/DataTableController.d.ts +4 -0
  28. package/dist/components/data/DataTableController.d.ts.map +1 -1
  29. package/dist/components/data/DataTableController.js +11 -4
  30. package/dist/components/data/ListSortSelect.svelte +121 -0
  31. package/dist/components/data/ListSortSelect.svelte.d.ts +22 -0
  32. package/dist/components/data/ListSortSelect.svelte.d.ts.map +1 -0
  33. package/dist/components/data/SortableHeader.svelte +169 -0
  34. package/dist/components/data/SortableHeader.svelte.d.ts +30 -0
  35. package/dist/components/data/SortableHeader.svelte.d.ts.map +1 -0
  36. package/dist/components/data/__tests__/CollectionList.test.js +37 -0
  37. package/dist/components/data/__tests__/DataTable.test.js +76 -0
  38. package/dist/components/data/__tests__/DataTableController.test.js +35 -0
  39. package/dist/components/data/__tests__/SortableHeader.test.js +101 -0
  40. package/dist/components/data/__tests__/link-step-surface.test.js +172 -0
  41. package/dist/components/data/__tests__/list-sort-state.svelte.test.js +71 -0
  42. package/dist/components/data/__tests__/list-sort.test.js +91 -0
  43. package/dist/components/data/__tests__/surface-navigation.test.js +121 -0
  44. package/dist/components/data/data-surface.d.ts +1 -0
  45. package/dist/components/data/data-surface.d.ts.map +1 -1
  46. package/dist/components/data/data-surface.js +1 -0
  47. package/dist/components/data/index.d.ts +6 -0
  48. package/dist/components/data/index.d.ts.map +1 -1
  49. package/dist/components/data/index.js +6 -0
  50. package/dist/components/data/link-surface.d.ts +79 -0
  51. package/dist/components/data/link-surface.d.ts.map +1 -0
  52. package/dist/components/data/link-surface.js +155 -0
  53. package/dist/components/data/list-sort-state.svelte.d.ts +39 -0
  54. package/dist/components/data/list-sort-state.svelte.d.ts.map +1 -0
  55. package/dist/components/data/list-sort-state.svelte.js +47 -0
  56. package/dist/components/data/list-sort.d.ts +94 -0
  57. package/dist/components/data/list-sort.d.ts.map +1 -0
  58. package/dist/components/data/list-sort.js +182 -0
  59. package/dist/components/data/step-surface.d.ts +73 -0
  60. package/dist/components/data/step-surface.d.ts.map +1 -0
  61. package/dist/components/data/step-surface.js +177 -0
  62. package/dist/components/data/surface-navigation.d.ts +49 -0
  63. package/dist/components/data/surface-navigation.d.ts.map +1 -0
  64. package/dist/components/data/surface-navigation.js +100 -0
  65. package/dist/components/data/types.d.ts +19 -0
  66. package/dist/components/data/types.d.ts.map +1 -1
  67. package/dist/components/feedback/PhoneSheet.svelte +224 -0
  68. package/dist/components/feedback/PhoneSheet.svelte.d.ts +35 -0
  69. package/dist/components/feedback/PhoneSheet.svelte.d.ts.map +1 -0
  70. package/dist/components/feedback/WorkingStrip.svelte +426 -0
  71. package/dist/components/feedback/WorkingStrip.svelte.d.ts +37 -0
  72. package/dist/components/feedback/WorkingStrip.svelte.d.ts.map +1 -0
  73. package/dist/components/feedback/__tests__/PhoneSheet.test.js +101 -0
  74. package/dist/components/feedback/__tests__/WorkingStrip.test.js +172 -0
  75. package/dist/components/feedback/index.d.ts +4 -0
  76. package/dist/components/feedback/index.d.ts.map +1 -1
  77. package/dist/components/feedback/index.js +5 -0
  78. package/dist/components/feedback/working-status.d.ts +34 -0
  79. package/dist/components/feedback/working-status.d.ts.map +1 -0
  80. package/dist/components/feedback/working-status.js +5 -0
  81. package/dist/components/forms/Checkbox.svelte +26 -2
  82. package/dist/components/forms/Combobox.svelte +68 -12
  83. package/dist/components/forms/Combobox.svelte.d.ts +6 -0
  84. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  85. package/dist/components/forms/DictationButton.svelte +128 -0
  86. package/dist/components/forms/DictationButton.svelte.d.ts +19 -0
  87. package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -0
  88. package/dist/components/forms/DictationStatus.svelte +164 -0
  89. package/dist/components/forms/DictationStatus.svelte.d.ts +11 -0
  90. package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -0
  91. package/dist/components/forms/Fieldset.svelte +5 -1
  92. package/dist/components/forms/Fieldset.svelte.d.ts +2 -0
  93. package/dist/components/forms/Fieldset.svelte.d.ts.map +1 -1
  94. package/dist/components/forms/FormActionBar.svelte +84 -0
  95. package/dist/components/forms/FormActionBar.svelte.d.ts +23 -0
  96. package/dist/components/forms/FormActionBar.svelte.d.ts.map +1 -0
  97. package/dist/components/forms/Input.svelte +9 -0
  98. package/dist/components/forms/Radio.svelte +15 -4
  99. package/dist/components/forms/SearchInput.svelte +207 -0
  100. package/dist/components/forms/SearchInput.svelte.d.ts +22 -0
  101. package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
  102. package/dist/components/forms/Select.svelte +9 -0
  103. package/dist/components/forms/StagedControlReview.svelte +21 -12
  104. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -1
  105. package/dist/components/forms/Switch.svelte +11 -5
  106. package/dist/components/forms/TagsInput.svelte +8 -1
  107. package/dist/components/forms/Textarea.svelte +8 -0
  108. package/dist/components/forms/__tests__/Combobox.test.js +87 -0
  109. package/dist/components/forms/__tests__/ControlHitArea.test.js +73 -0
  110. package/dist/components/forms/__tests__/DictationButton.test.js +176 -0
  111. package/dist/components/forms/__tests__/Fieldset.test.js +37 -0
  112. package/dist/components/forms/__tests__/FormActionBar.test.js +22 -0
  113. package/dist/components/forms/__tests__/SearchInput.test.js +92 -0
  114. package/dist/components/forms/__tests__/StagedControlReview.test.js +37 -3
  115. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte +19 -0
  116. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts +11 -0
  117. package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts.map +1 -0
  118. package/dist/components/forms/__tests__/composite-registration.fixture.svelte +29 -0
  119. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts +8 -0
  120. package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts.map +1 -0
  121. package/dist/components/forms/__tests__/control-interaction.test.js +19 -1
  122. package/dist/components/forms/__tests__/control-proposals.test.js +63 -0
  123. package/dist/components/forms/__tests__/dictation-recording.test.js +582 -0
  124. package/dist/components/forms/__tests__/dictation.test.js +486 -0
  125. package/dist/components/forms/__tests__/long-press.test.js +175 -0
  126. package/dist/components/forms/__tests__/place-picker.fixture.svelte +33 -0
  127. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts +12 -0
  128. package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts.map +1 -0
  129. package/dist/components/forms/audio-capture.d.ts +69 -0
  130. package/dist/components/forms/audio-capture.d.ts.map +1 -0
  131. package/dist/components/forms/audio-capture.js +193 -0
  132. package/dist/components/forms/control-interaction.d.ts +6 -0
  133. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  134. package/dist/components/forms/control-interaction.js +12 -1
  135. package/dist/components/forms/control-proposals.d.ts +55 -0
  136. package/dist/components/forms/control-proposals.d.ts.map +1 -0
  137. package/dist/components/forms/control-proposals.js +188 -0
  138. package/dist/components/forms/dictation-transcribe.d.ts +36 -0
  139. package/dist/components/forms/dictation-transcribe.d.ts.map +1 -0
  140. package/dist/components/forms/dictation-transcribe.js +65 -0
  141. package/dist/components/forms/dictation.svelte.d.ts +200 -0
  142. package/dist/components/forms/dictation.svelte.d.ts.map +1 -0
  143. package/dist/components/forms/dictation.svelte.js +686 -0
  144. package/dist/components/forms/index.d.ts +14 -1
  145. package/dist/components/forms/index.d.ts.map +1 -1
  146. package/dist/components/forms/index.js +14 -1
  147. package/dist/components/forms/insert-text.d.ts +12 -0
  148. package/dist/components/forms/insert-text.d.ts.map +1 -0
  149. package/dist/components/forms/insert-text.js +38 -0
  150. package/dist/components/forms/long-press.d.ts +88 -0
  151. package/dist/components/forms/long-press.d.ts.map +1 -0
  152. package/dist/components/forms/long-press.js +213 -0
  153. package/dist/components/forms/ready-beep.d.ts +24 -0
  154. package/dist/components/forms/ready-beep.d.ts.map +1 -0
  155. package/dist/components/forms/ready-beep.js +82 -0
  156. package/dist/components/layout/PageHeader.svelte +151 -17
  157. package/dist/components/layout/PageHeader.svelte.d.ts +47 -7
  158. package/dist/components/layout/PageHeader.svelte.d.ts.map +1 -1
  159. package/dist/components/layout/__tests__/PageHeader.test.js +87 -1
  160. package/dist/components/layout/index.d.ts +1 -0
  161. package/dist/components/layout/index.d.ts.map +1 -1
  162. package/dist/components/layout/index.js +1 -0
  163. package/dist/components/layout/page-header-context.d.ts +25 -0
  164. package/dist/components/layout/page-header-context.d.ts.map +1 -0
  165. package/dist/components/layout/page-header-context.js +16 -0
  166. package/dist/components/nav/Tabs.svelte +241 -0
  167. package/dist/components/nav/Tabs.svelte.d.ts +15 -0
  168. package/dist/components/nav/Tabs.svelte.d.ts.map +1 -1
  169. package/dist/components/nav/__tests__/LinkTabs.test.js +117 -0
  170. package/dist/components/nav/index.d.ts +1 -0
  171. package/dist/components/nav/index.d.ts.map +1 -1
  172. package/dist/components/nav/index.js +1 -0
  173. package/dist/components/nav/tabs-overflow.d.ts +12 -0
  174. package/dist/components/nav/tabs-overflow.d.ts.map +1 -0
  175. package/dist/components/nav/tabs-overflow.js +17 -0
  176. package/dist/components/nav/types.d.ts +8 -0
  177. package/dist/components/nav/types.d.ts.map +1 -1
  178. package/dist/components/ui/Button.svelte +7 -0
  179. package/dist/components/ui/Card.svelte +14 -5
  180. package/dist/components/ui/Dropdown.svelte +55 -1
  181. package/dist/components/ui/Dropdown.svelte.d.ts +8 -0
  182. package/dist/components/ui/Dropdown.svelte.d.ts.map +1 -1
  183. package/dist/components/ui/IconToggle.svelte +139 -0
  184. package/dist/components/ui/IconToggle.svelte.d.ts +32 -0
  185. package/dist/components/ui/IconToggle.svelte.d.ts.map +1 -0
  186. package/dist/components/ui/IconToggleGroup.svelte +56 -0
  187. package/dist/components/ui/IconToggleGroup.svelte.d.ts +21 -0
  188. package/dist/components/ui/IconToggleGroup.svelte.d.ts.map +1 -0
  189. package/dist/components/ui/Pagination.svelte +1 -1
  190. package/dist/components/ui/Popover.svelte +17 -1
  191. package/dist/components/ui/Popover.svelte.d.ts +8 -0
  192. package/dist/components/ui/Popover.svelte.d.ts.map +1 -1
  193. package/dist/components/ui/__tests__/IconToggle.test.js +59 -0
  194. package/dist/components/ui/__tests__/IconToggleGroup.test.js +30 -0
  195. package/dist/components/ui/__tests__/Popover.test.js +44 -0
  196. package/dist/components/ui/__tests__/dropdown-popover-icon.test.js +125 -0
  197. package/dist/components/ui/__tests__/phone-touch-targets.test.js +39 -0
  198. package/dist/components/ui/index.d.ts +2 -0
  199. package/dist/components/ui/index.d.ts.map +1 -1
  200. package/dist/components/ui/index.js +2 -0
  201. package/dist/i18n/strings.ui.d.ts +41 -0
  202. package/dist/i18n/strings.ui.d.ts.map +1 -1
  203. package/dist/i18n/strings.ui.js +46 -0
  204. package/dist/svelte/__tests__/playground.test.js +1 -0
  205. package/dist/svelte/playground/CalendarPreview.svelte +140 -0
  206. package/dist/svelte/playground/CalendarPreview.svelte.d.ts +4 -0
  207. package/dist/svelte/playground/CalendarPreview.svelte.d.ts.map +1 -0
  208. package/dist/svelte/playground.d.ts.map +1 -1
  209. package/dist/svelte/playground.js +10 -0
  210. package/dist/types-generic.d.ts +2 -0
  211. package/dist/types-generic.d.ts.map +1 -1
  212. 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;AAKH,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,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"}
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"}
@@ -0,0 +1,5 @@
1
+ /** Phases whose change is announced assertively (the person is needed). */
2
+ export const WORKING_ASSERTIVE_PHASES = [
3
+ 'waiting',
4
+ 'failed',
5
+ ];
@@ -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
- .checkbox { display: inline-flex; align-items: center; gap: var(--smrt-spacing-2, .5rem); cursor: pointer; color: var(--smrt-color-on-surface); }
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
- let query = $state(value);
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
- options.filter((option) =>
68
- option.label.toLowerCase().includes(query.toLowerCase()),
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 = true;
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
- open = false;
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)) open = false;
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)) open = false;
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
- const option = options.find((item) => String(item.value) === value);
157
- if (option && document.activeElement !== inputEl) query = option.label;
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><input bind:this={inputEl} id={inputId} {name} role="combobox" autocomplete="off" {placeholder} {disabled} {required} value={query}
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={() => open = true} oninput={handleInput} onkeydown={handleKeydown} />
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":"AAQA,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,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA+KD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
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"}