@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.
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,1250 @@
1
+ <script lang="ts">
2
+ /**
3
+ * CalendarView — a generic month calendar with a phone agenda.
4
+ *
5
+ * - `month` mode: a month grid (`role="grid"`, roving focus, arrow keys).
6
+ * All-day and multi-day items render as bands across the days they cover;
7
+ * a day that has more than fits shows "+N more", which opens the day in a
8
+ * panel under the grid (never a modal) or follows `dayHref`.
9
+ * - `agenda` mode: a scrollable strip of the month's days above a day-by-day
10
+ * list. `auto` (default) uses the agenda below 48rem, the grid above.
11
+ *
12
+ * Every day is computed in `timeZone` (IANA) and every name comes from
13
+ * `Intl` in `locale`, so the browser's own zone and language never leak in.
14
+ * The visible month and selected day can be controlled (`year`/`month`,
15
+ * `selectedDate`) and are reported through `onNavigate` / `onSelectDate`,
16
+ * so a page can keep them in its URL.
17
+ */
18
+ import type { Snippet } from 'svelte';
19
+ import { tick } from 'svelte';
20
+ import { M } from '../../i18n/strings.ui.js';
21
+ import { useI18n } from '../../i18n/use-i18n.js';
22
+ import {
23
+ addDays,
24
+ addMonthsToKey,
25
+ type CalendarEntry,
26
+ type CalendarItem,
27
+ type CalendarMode,
28
+ type CalendarMonth,
29
+ defaultTimeZone,
30
+ defaultWeekStart,
31
+ entriesOnDay,
32
+ formatKey,
33
+ layoutMonth,
34
+ monthKeys,
35
+ monthOfKey,
36
+ monthWeeks,
37
+ parseKey,
38
+ resolveTimeZone,
39
+ shiftMonth,
40
+ todayKey,
41
+ toEntries,
42
+ toneFor,
43
+ weekdayOfKey,
44
+ } from './calendar-model.js';
45
+
46
+ export interface Props {
47
+ /** Items to show. */
48
+ items?: CalendarItem[];
49
+ /** Visible year (controlled together with `month`). */
50
+ year?: number;
51
+ /** Visible month, 1-12 (controlled together with `year`). */
52
+ month?: number;
53
+ /** Selected day as `YYYY-MM-DD` (controlled). */
54
+ selectedDate?: string | null;
55
+ /** IANA time zone every day is computed in. Defaults to the browser's. */
56
+ timeZone?: string;
57
+ /** BCP 47 locale for month/day names and times. Defaults to the i18n locale. */
58
+ locale?: string;
59
+ /** First weekday column, 0 = Sunday. Defaults to the locale's. */
60
+ weekStartsOn?: number;
61
+ /** `auto` switches to the agenda below 48rem. */
62
+ mode?: CalendarMode;
63
+ /** Rows per day in the month grid, including band rows. */
64
+ maxPerDay?: number;
65
+ /** Heading level of the month title. */
66
+ headingLevel?: 2 | 3 | 4;
67
+ /**
68
+ * "Now", for today highlighting (tests, server rendering). Without it the
69
+ * calendar reads the clock after mount and moves "today" at midnight in
70
+ * `timeZone`; before mount (server rendering) nothing is highlighted as
71
+ * today. Server-rendered pages should pass `timeZone` and `year`/`month`
72
+ * (or `now`) so the server and the browser pick the same month.
73
+ */
74
+ now?: Date;
75
+ /** Called when the visible month changes (prev/next/today/keyboard). */
76
+ onNavigate?: (month: CalendarMonth) => void;
77
+ /** Called when a day is selected. */
78
+ onSelectDate?: (date: string) => void;
79
+ /** Called when an item is activated (also for items with `href`). */
80
+ onItemSelect?: (item: CalendarItem) => void;
81
+ /** Link for a day; days become links and "+N more" follows it. */
82
+ dayHref?: (date: string) => string | undefined;
83
+ /** Custom row content for an item in the day panel and agenda. */
84
+ itemContent?: Snippet<[CalendarItem, { time: string }]>;
85
+ /** Extra class on the root. */
86
+ class?: string;
87
+ }
88
+
89
+ let {
90
+ items = [],
91
+ year,
92
+ month,
93
+ selectedDate = null,
94
+ timeZone: timeZoneProp,
95
+ locale: localeProp,
96
+ weekStartsOn: weekStartsOnProp,
97
+ mode = 'auto',
98
+ maxPerDay = 3,
99
+ headingLevel = 2,
100
+ now,
101
+ onNavigate,
102
+ onSelectDate,
103
+ onItemSelect,
104
+ dayHref,
105
+ itemContent,
106
+ class: className = '',
107
+ }: Props = $props();
108
+
109
+ const i18n = useI18n();
110
+ const t = i18n.t;
111
+ const uid = $props.id();
112
+
113
+ // An invalid zone (a typo, one this runtime lacks) would throw a RangeError
114
+ // from every Intl call in the deriveds below; fall back and say so instead.
115
+ const timeZone = $derived(
116
+ resolveTimeZone(timeZoneProp, (invalid) =>
117
+ console.warn(
118
+ `[CalendarView] unknown time zone "${invalid}"; using ${defaultTimeZone()}`,
119
+ ),
120
+ ),
121
+ );
122
+ const locale = $derived(localeProp || i18n.locale || 'en');
123
+ const weekStartsOn = $derived(
124
+ weekStartsOnProp === undefined
125
+ ? defaultWeekStart(locale)
126
+ : ((Math.floor(weekStartsOnProp) % 7) + 7) % 7,
127
+ );
128
+ // The clock is read after mount, never during server rendering (the server's
129
+ // clock and zone are not the reader's), and re-read each minute so "today"
130
+ // moves at midnight in `timeZone`.
131
+ let clock = $state<Date | undefined>(undefined);
132
+ $effect(() => {
133
+ if (now) return;
134
+ clock = new Date();
135
+ const timer = setInterval(() => {
136
+ const next = new Date();
137
+ if (!clock || todayKey(timeZone, next) !== todayKey(timeZone, clock)) {
138
+ clock = next;
139
+ }
140
+ }, 60_000);
141
+ return () => clearInterval(timer);
142
+ });
143
+ const reference = $derived(now ?? clock);
144
+ /** Today's key, or '' before the clock is known (nothing is "today"). */
145
+ const today = $derived(reference ? todayKey(timeZone, reference) : '');
146
+ /** The month shown when nothing controls it: today's, else the render clock's. */
147
+ const fallbackKey = $derived(today || todayKey(timeZone));
148
+
149
+ // Overridable deriveds: follow the controlling props, and take local
150
+ // navigation until those props change again.
151
+ let current = $derived<CalendarMonth>(
152
+ year !== undefined && month !== undefined
153
+ ? { year, month }
154
+ : selectedDate
155
+ ? monthOfKey(selectedDate)
156
+ : monthOfKey(fallbackKey),
157
+ );
158
+ let selected = $derived<string | null>(selectedDate ?? null);
159
+ let focusKey = $derived(
160
+ selected && sameMonth(selected, current)
161
+ ? selected
162
+ : today && sameMonth(today, current)
163
+ ? today
164
+ : formatKey(current.year, current.month, 1),
165
+ );
166
+
167
+ let isPhone = $state(false);
168
+ $effect(() => {
169
+ if (mode !== 'auto' || typeof window.matchMedia !== 'function') return;
170
+ const query = window.matchMedia('(max-width: 48rem)');
171
+ isPhone = query.matches;
172
+ const onChange = (event: MediaQueryListEvent) => {
173
+ isPhone = event.matches;
174
+ };
175
+ query.addEventListener('change', onChange);
176
+ return () => query.removeEventListener('change', onChange);
177
+ });
178
+ const view = $derived(mode === 'auto' ? (isPhone ? 'agenda' : 'month') : mode);
179
+
180
+ let root: HTMLElement | undefined = $state();
181
+
182
+ const entries = $derived(
183
+ toEntries(items, timeZone, (item) =>
184
+ console.warn(
185
+ `[CalendarView] item "${item.id}" has an invalid start or end and was skipped`,
186
+ ),
187
+ ),
188
+ );
189
+ const weeks = $derived(
190
+ layoutMonth(current, entries, { weekStartsOn, maxPerDay }),
191
+ );
192
+ const daysOfMonth = $derived(monthKeys(current));
193
+ const agendaDays = $derived(
194
+ daysOfMonth
195
+ .map((key) => ({ key, entries: entriesOnDay(entries, key) }))
196
+ .filter((day) => day.entries.length > 0 || day.key === selected),
197
+ );
198
+ const selectedEntries = $derived(
199
+ selected ? entriesOnDay(entries, selected) : [],
200
+ );
201
+
202
+ // ---- Intl formatting (all in `timeZone` / `locale`) ----
203
+
204
+ const monthTitle = $derived(
205
+ new Intl.DateTimeFormat(locale, {
206
+ month: 'long',
207
+ year: 'numeric',
208
+ timeZone: 'UTC',
209
+ }).format(Date.UTC(current.year, current.month - 1, 15)),
210
+ );
211
+ const longDateFormat = $derived(
212
+ new Intl.DateTimeFormat(locale, {
213
+ weekday: 'long',
214
+ month: 'long',
215
+ day: 'numeric',
216
+ year: 'numeric',
217
+ timeZone: 'UTC',
218
+ }),
219
+ );
220
+ const shortDateFormat = $derived(
221
+ new Intl.DateTimeFormat(locale, {
222
+ month: 'short',
223
+ day: 'numeric',
224
+ timeZone: 'UTC',
225
+ }),
226
+ );
227
+ const timeFormat = $derived(
228
+ new Intl.DateTimeFormat(locale, {
229
+ hour: 'numeric',
230
+ minute: '2-digit',
231
+ timeZone,
232
+ }),
233
+ );
234
+ const plural = $derived(new Intl.PluralRules(locale));
235
+ const weekdays = $derived(
236
+ monthWeeks(current, weekStartsOn)[0].map((key) => {
237
+ const date = keyDate(key);
238
+ return {
239
+ short: new Intl.DateTimeFormat(locale, {
240
+ weekday: 'short',
241
+ timeZone: 'UTC',
242
+ }).format(date),
243
+ narrow: new Intl.DateTimeFormat(locale, {
244
+ weekday: 'narrow',
245
+ timeZone: 'UTC',
246
+ }).format(date),
247
+ long: new Intl.DateTimeFormat(locale, {
248
+ weekday: 'long',
249
+ timeZone: 'UTC',
250
+ }).format(date),
251
+ };
252
+ }),
253
+ );
254
+
255
+ function keyDate(key: string): Date {
256
+ const { year: y, month: m, day } = parseKey(key);
257
+ return new Date(Date.UTC(y, m - 1, day, 12));
258
+ }
259
+
260
+ function sameMonth(key: string, value: CalendarMonth): boolean {
261
+ const parts = parseKey(key);
262
+ return parts.year === value.year && parts.month === value.month;
263
+ }
264
+
265
+ function longDate(key: string): string {
266
+ return longDateFormat.format(keyDate(key));
267
+ }
268
+
269
+ function countLabel(count: number): string {
270
+ return plural.select(count) === 'one'
271
+ ? t(M['ui.calendar.items_one'])
272
+ : t(M['ui.calendar.items_other'], { count });
273
+ }
274
+
275
+ function dayLabel(key: string, count: number): string {
276
+ const base = longDate(key);
277
+ return count > 0 ? `${base}, ${countLabel(count)}` : base;
278
+ }
279
+
280
+ function timeLabel(entry: CalendarEntry, dayKey?: string): string {
281
+ if (entry.allDay || (dayKey && entry.startKey !== dayKey)) {
282
+ return t(M['ui.calendar.all_day']);
283
+ }
284
+ return timeFormat.format(entry.startMs);
285
+ }
286
+
287
+ function spanNote(entry: CalendarEntry): string | null {
288
+ if (entry.startKey === entry.endKey) return null;
289
+ return t(M['ui.calendar.until'], {
290
+ date: shortDateFormat.format(keyDate(entry.endKey)),
291
+ });
292
+ }
293
+
294
+ function itemStyle(item: CalendarItem): string {
295
+ return item.color ? `--cv-item: ${item.color};` : '';
296
+ }
297
+
298
+ // ---- Navigation and selection ----
299
+
300
+ function navigate(next: CalendarMonth): void {
301
+ current = next;
302
+ onNavigate?.(next);
303
+ }
304
+
305
+ function goToday(): void {
306
+ const key = today || todayKey(timeZone);
307
+ const target = monthOfKey(key);
308
+ if (target.year !== current.year || target.month !== current.month) {
309
+ navigate(target);
310
+ }
311
+ focusKey = key;
312
+ if (view === 'agenda') selectDay(key);
313
+ }
314
+
315
+ function selectDay(key: string): void {
316
+ selected = key;
317
+ focusKey = key;
318
+ onSelectDate?.(key);
319
+ if (view === 'agenda') void revealAgendaDay(key);
320
+ }
321
+
322
+ function closeDay(): void {
323
+ const key = selected;
324
+ selected = null;
325
+ if (key) void focusDay(key);
326
+ }
327
+
328
+ async function focusDay(key: string): Promise<void> {
329
+ await tick();
330
+ root
331
+ ?.querySelector<HTMLElement>(`[data-cv-day="${key}"]`)
332
+ ?.focus({ preventScroll: false });
333
+ }
334
+
335
+ function reducedMotion(): boolean {
336
+ return (
337
+ typeof window.matchMedia === 'function' &&
338
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
339
+ );
340
+ }
341
+
342
+ async function revealAgendaDay(key: string): Promise<void> {
343
+ await tick();
344
+ const behavior = reducedMotion() ? 'auto' : 'smooth';
345
+ root
346
+ ?.querySelector<HTMLElement>(`[data-cv-agenda-day="${key}"]`)
347
+ ?.scrollIntoView?.({ behavior, block: 'start' });
348
+ }
349
+
350
+ function moveFocus(key: string): void {
351
+ if (!sameMonth(key, current)) navigate(monthOfKey(key));
352
+ focusKey = key;
353
+ void focusDay(key);
354
+ }
355
+
356
+ function onGridKeydown(event: KeyboardEvent): void {
357
+ const target = event.target as HTMLElement | null;
358
+ const key = target?.dataset.cvDay;
359
+ if (!key) return;
360
+ const column = (weekdayOfKey(key) - weekStartsOn + 7) % 7;
361
+ let next: string | null = null;
362
+ switch (event.key) {
363
+ case 'ArrowLeft':
364
+ next = addDays(key, -1);
365
+ break;
366
+ case 'ArrowRight':
367
+ next = addDays(key, 1);
368
+ break;
369
+ case 'ArrowUp':
370
+ next = addDays(key, -7);
371
+ break;
372
+ case 'ArrowDown':
373
+ next = addDays(key, 7);
374
+ break;
375
+ case 'Home':
376
+ next = addDays(key, -column);
377
+ break;
378
+ case 'End':
379
+ next = addDays(key, 6 - column);
380
+ break;
381
+ case 'PageUp':
382
+ next = addMonthsToKey(key, -1);
383
+ break;
384
+ case 'PageDown':
385
+ next = addMonthsToKey(key, 1);
386
+ break;
387
+ default:
388
+ return;
389
+ }
390
+ event.preventDefault();
391
+ moveFocus(next);
392
+ }
393
+
394
+ function onStripKeydown(event: KeyboardEvent): void {
395
+ const key = (event.target as HTMLElement | null)?.dataset.cvDay;
396
+ if (!key) return;
397
+ const last = daysOfMonth[daysOfMonth.length - 1];
398
+ let next: string | null = null;
399
+ if (event.key === 'ArrowLeft') next = addDays(key, -1);
400
+ else if (event.key === 'ArrowRight') next = addDays(key, 1);
401
+ else if (event.key === 'Home') next = daysOfMonth[0];
402
+ else if (event.key === 'End') next = last;
403
+ if (!next) return;
404
+ event.preventDefault();
405
+ moveFocus(next);
406
+ }
407
+
408
+ function activateItem(item: CalendarItem): void {
409
+ onItemSelect?.(item);
410
+ }
411
+
412
+ $effect(() => {
413
+ // Keep the selected (or focused) day visible in the agenda strip.
414
+ if (view !== 'agenda' || !root) return;
415
+ const key = focusKey;
416
+ void tick().then(() => {
417
+ root
418
+ ?.querySelector<HTMLElement>(`.cv-strip [data-cv-day="${key}"]`)
419
+ ?.scrollIntoView?.({ block: 'nearest', inline: 'center' });
420
+ });
421
+ });
422
+ </script>
423
+
424
+ {#snippet itemRow(entry: CalendarEntry, dayKey: string)}
425
+ {@const item = entry.item}
426
+ {@const time = timeLabel(entry, dayKey)}
427
+ {@const note = spanNote(entry)}
428
+ {#if item.href}
429
+ <a
430
+ class="cv-row"
431
+ data-tone={toneFor(item)}
432
+ style={itemStyle(item)}
433
+ href={item.href}
434
+ onclick={() => activateItem(item)}
435
+ >
436
+ {#if itemContent}
437
+ {@render itemContent(item, { time })}
438
+ {:else}
439
+ <span class="cv-row-time">{time}</span>
440
+ <span class="cv-row-main">
441
+ <span class="cv-row-title">{item.title}</span>
442
+ {#if item.label || note}
443
+ <span class="cv-row-meta">
444
+ {#if item.label}<span class="cv-row-label">{item.label}</span>{/if}
445
+ {#if note}<span>{note}</span>{/if}
446
+ </span>
447
+ {/if}
448
+ </span>
449
+ {/if}
450
+ </a>
451
+ {:else}
452
+ <button
453
+ type="button"
454
+ class="cv-row"
455
+ data-tone={toneFor(item)}
456
+ style={itemStyle(item)}
457
+ onclick={() => activateItem(item)}
458
+ >
459
+ {#if itemContent}
460
+ {@render itemContent(item, { time })}
461
+ {:else}
462
+ <span class="cv-row-time">{time}</span>
463
+ <span class="cv-row-main">
464
+ <span class="cv-row-title">{item.title}</span>
465
+ {#if item.label || note}
466
+ <span class="cv-row-meta">
467
+ {#if item.label}<span class="cv-row-label">{item.label}</span>{/if}
468
+ {#if note}<span>{note}</span>{/if}
469
+ </span>
470
+ {/if}
471
+ </span>
472
+ {/if}
473
+ </button>
474
+ {/if}
475
+ {/snippet}
476
+
477
+ <div
478
+ bind:this={root}
479
+ class="cv {className}"
480
+ class:cv--agenda={view === 'agenda'}
481
+ data-view={view}
482
+ >
483
+ <header class="cv-header">
484
+ <svelte:element
485
+ this={`h${headingLevel}`}
486
+ class="cv-title"
487
+ id="cv-title-{uid}"
488
+ aria-live="polite"
489
+ >
490
+ {monthTitle}
491
+ </svelte:element>
492
+ <div class="cv-nav">
493
+ <button
494
+ type="button"
495
+ class="cv-icon-btn"
496
+ aria-label={t(M['ui.calendar.previous_month'])}
497
+ onclick={() => navigate(shiftMonth(current, -1))}
498
+ >
499
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
500
+ <path d="M12 15L7 10L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
501
+ </svg>
502
+ </button>
503
+ <button type="button" class="cv-today-btn" onclick={goToday}>
504
+ {t(M['ui.calendar.today'])}
505
+ </button>
506
+ <button
507
+ type="button"
508
+ class="cv-icon-btn"
509
+ aria-label={t(M['ui.calendar.next_month'])}
510
+ onclick={() => navigate(shiftMonth(current, 1))}
511
+ >
512
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
513
+ <path d="M8 5L13 10L8 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
514
+ </svg>
515
+ </button>
516
+ </div>
517
+ </header>
518
+
519
+ {#if view === 'month'}
520
+ <div
521
+ class="cv-grid"
522
+ role="grid"
523
+ aria-labelledby="cv-title-{uid}"
524
+ tabindex="-1"
525
+ onkeydown={onGridKeydown}
526
+ >
527
+ <div class="cv-weekdays" role="row">
528
+ {#each weekdays as weekday (weekday.long)}
529
+ <div class="cv-weekday" role="columnheader">
530
+ <span aria-hidden="true">{weekday.short}</span>
531
+ <span class="cv-sr">{weekday.long}</span>
532
+ </div>
533
+ {/each}
534
+ </div>
535
+ {#each weeks as week (week.days[0].key)}
536
+ <div
537
+ class="cv-week"
538
+ role="row"
539
+ style="grid-template-rows: var(--cv-daynum-h) {week.lanes > 0
540
+ ? `repeat(${week.lanes}, var(--cv-band-row))`
541
+ : ''} auto;"
542
+ >
543
+ {#each week.days as day, column (day.key)}
544
+ {@const href = dayHref?.(day.key)}
545
+ <div
546
+ class="cv-day"
547
+ class:cv-day--out={!day.inMonth}
548
+ class:cv-day--today={day.key === today}
549
+ class:cv-day--selected={day.key === selected}
550
+ role="gridcell"
551
+ aria-selected={day.key === selected}
552
+ style="grid-column: {column + 1};"
553
+ >
554
+ {#if href}
555
+ <a
556
+ class="cv-daynum"
557
+ {href}
558
+ data-cv-day={day.key}
559
+ tabindex={day.key === focusKey ? 0 : -1}
560
+ aria-label={dayLabel(day.key, day.all.length)}
561
+ aria-current={day.key === today ? 'date' : undefined}
562
+ onclick={() => {
563
+ focusKey = day.key;
564
+ onSelectDate?.(day.key);
565
+ }}
566
+ >
567
+ {day.day}
568
+ </a>
569
+ {:else}
570
+ <button
571
+ type="button"
572
+ class="cv-daynum"
573
+ data-cv-day={day.key}
574
+ tabindex={day.key === focusKey ? 0 : -1}
575
+ aria-label={dayLabel(day.key, day.all.length)}
576
+ aria-current={day.key === today ? 'date' : undefined}
577
+ aria-pressed={day.key === selected}
578
+ onclick={() => selectDay(day.key)}
579
+ >
580
+ {day.day}
581
+ </button>
582
+ {/if}
583
+ <div
584
+ class="cv-day-body"
585
+ style="padding-top: calc({week.lanes} * var(--cv-band-row));"
586
+ aria-hidden="true"
587
+ >
588
+ {#each day.visible as entry (entry.item.id)}
589
+ {#if entry.item.href}
590
+ <a
591
+ class="cv-chip"
592
+ data-tone={toneFor(entry.item)}
593
+ style={itemStyle(entry.item)}
594
+ href={entry.item.href}
595
+ tabindex="-1"
596
+ title={entry.item.title}
597
+ onclick={() => activateItem(entry.item)}
598
+ >
599
+ <span class="cv-chip-time">{timeFormat.format(entry.startMs)}</span>
600
+ <span class="cv-chip-title">{entry.item.title}</span>
601
+ </a>
602
+ {:else}
603
+ <button
604
+ type="button"
605
+ class="cv-chip"
606
+ data-tone={toneFor(entry.item)}
607
+ style={itemStyle(entry.item)}
608
+ tabindex="-1"
609
+ title={entry.item.title}
610
+ onclick={() => activateItem(entry.item)}
611
+ >
612
+ <span class="cv-chip-time">{timeFormat.format(entry.startMs)}</span>
613
+ <span class="cv-chip-title">{entry.item.title}</span>
614
+ </button>
615
+ {/if}
616
+ {/each}
617
+ {#if day.hiddenCount > 0}
618
+ {#if href}
619
+ <a class="cv-more" {href} tabindex="-1">
620
+ <span class="cv-more-long">{t(M['ui.calendar.more'], { count: day.hiddenCount })}</span>
621
+ <span class="cv-more-short">{t(M['ui.calendar.more_short'], { count: day.hiddenCount })}</span>
622
+ </a>
623
+ {:else}
624
+ <button
625
+ type="button"
626
+ class="cv-more"
627
+ tabindex="-1"
628
+ onclick={() => {
629
+ selectDay(day.key);
630
+ void focusDay(day.key);
631
+ }}
632
+ >
633
+ <span class="cv-more-long">{t(M['ui.calendar.more'], { count: day.hiddenCount })}</span>
634
+ <span class="cv-more-short">{t(M['ui.calendar.more_short'], { count: day.hiddenCount })}</span>
635
+ </button>
636
+ {/if}
637
+ {/if}
638
+ </div>
639
+ </div>
640
+ {/each}
641
+ {#each week.bands as band (band.entry.item.id)}
642
+ {@const item = band.entry.item}
643
+ {#if item.href}
644
+ <a
645
+ class="cv-band"
646
+ class:cv-band--before={band.continuesBefore}
647
+ class:cv-band--after={band.continuesAfter}
648
+ data-tone={toneFor(item)}
649
+ style="{itemStyle(item)} grid-column: {band.column + 1} / span {band.span}; grid-row: {band.lane + 2};"
650
+ href={item.href}
651
+ tabindex="-1"
652
+ aria-hidden="true"
653
+ title={item.title}
654
+ onclick={() => activateItem(item)}
655
+ >
656
+ {item.title}
657
+ </a>
658
+ {:else}
659
+ <button
660
+ type="button"
661
+ class="cv-band"
662
+ class:cv-band--before={band.continuesBefore}
663
+ class:cv-band--after={band.continuesAfter}
664
+ data-tone={toneFor(item)}
665
+ style="{itemStyle(item)} grid-column: {band.column + 1} / span {band.span}; grid-row: {band.lane + 2};"
666
+ tabindex="-1"
667
+ aria-hidden="true"
668
+ title={item.title}
669
+ onclick={() => activateItem(item)}
670
+ >
671
+ {item.title}
672
+ </button>
673
+ {/if}
674
+ {/each}
675
+ </div>
676
+ {/each}
677
+ </div>
678
+
679
+ {#if selected && !dayHref && sameMonth(selected, current)}
680
+ <section class="cv-day-panel" aria-labelledby="cv-day-{uid}">
681
+ <div class="cv-day-panel-head">
682
+ <h3 class="cv-day-title" id="cv-day-{uid}">{longDate(selected)}</h3>
683
+ <button
684
+ type="button"
685
+ class="cv-icon-btn"
686
+ aria-label={t(M['ui.calendar.close_day'], { date: longDate(selected) })}
687
+ onclick={closeDay}
688
+ >
689
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
690
+ <path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
691
+ </svg>
692
+ </button>
693
+ </div>
694
+ {#if selectedEntries.length === 0}
695
+ <p class="cv-empty">{t(M['ui.calendar.nothing_day'])}</p>
696
+ {:else}
697
+ <ul class="cv-rows">
698
+ {#each selectedEntries as entry (entry.item.id)}
699
+ <li>{@render itemRow(entry, selected)}</li>
700
+ {/each}
701
+ </ul>
702
+ {/if}
703
+ </section>
704
+ {/if}
705
+ {:else}
706
+ <div
707
+ class="cv-strip"
708
+ role="toolbar"
709
+ tabindex="-1"
710
+ aria-label={t(M['ui.calendar.days_in'], { month: monthTitle })}
711
+ onkeydown={onStripKeydown}
712
+ >
713
+ {#each daysOfMonth as key (key)}
714
+ {@const count = entriesOnDay(entries, key).length}
715
+ {@const weekday = weekdays[(weekdayOfKey(key) - weekStartsOn + 7) % 7]}
716
+ <button
717
+ type="button"
718
+ class="cv-strip-day"
719
+ class:cv-strip-day--today={key === today}
720
+ data-cv-day={key}
721
+ tabindex={key === focusKey ? 0 : -1}
722
+ aria-label={dayLabel(key, count)}
723
+ aria-pressed={key === selected}
724
+ aria-current={key === today ? 'date' : undefined}
725
+ onclick={() => selectDay(key)}
726
+ >
727
+ <span class="cv-strip-weekday" aria-hidden="true">{weekday.narrow}</span>
728
+ <span class="cv-strip-num" aria-hidden="true">{parseKey(key).day}</span>
729
+ <span class="cv-strip-dot" class:cv-strip-dot--on={count > 0} aria-hidden="true"></span>
730
+ </button>
731
+ {/each}
732
+ </div>
733
+
734
+ {#if agendaDays.length === 0}
735
+ <p class="cv-empty">{t(M['ui.calendar.nothing_month'])}</p>
736
+ {:else}
737
+ <ol class="cv-agenda">
738
+ {#each agendaDays as day (day.key)}
739
+ <li
740
+ class="cv-agenda-day"
741
+ class:cv-agenda-day--today={day.key === today}
742
+ data-cv-agenda-day={day.key}
743
+ aria-labelledby="cv-agenda-{uid}-{day.key}"
744
+ >
745
+ <h3 class="cv-agenda-date" id="cv-agenda-{uid}-{day.key}">
746
+ {#if dayHref?.(day.key)}
747
+ <a href={dayHref(day.key)}>{longDate(day.key)}</a>
748
+ {:else}
749
+ {longDate(day.key)}
750
+ {/if}
751
+ </h3>
752
+ {#if day.entries.length === 0}
753
+ <p class="cv-empty">{t(M['ui.calendar.nothing_day'])}</p>
754
+ {:else}
755
+ <ul class="cv-rows">
756
+ {#each day.entries as entry (entry.item.id)}
757
+ <li>{@render itemRow(entry, day.key)}</li>
758
+ {/each}
759
+ </ul>
760
+ {/if}
761
+ </li>
762
+ {/each}
763
+ </ol>
764
+ {/if}
765
+ {/if}
766
+ </div>
767
+
768
+ <style>
769
+ .cv {
770
+ --cv-border: var(--smrt-color-outline-variant, #c4c6d0);
771
+ --cv-surface: var(--smrt-color-surface, #ffffff);
772
+ --cv-muted: var(--smrt-color-on-surface-variant, #44474e);
773
+ --cv-text: var(--smrt-color-on-surface, #1b1b1f);
774
+ --cv-daynum-h: 2.75rem;
775
+ --cv-band-row: 1.625rem;
776
+ --cv-target: 2.75rem;
777
+ color: var(--cv-text);
778
+ background: var(--cv-surface);
779
+ border: 1px solid var(--cv-border);
780
+ border-radius: var(--smrt-radius-large, 12px);
781
+ overflow: hidden;
782
+ min-width: 0;
783
+ container-type: inline-size;
784
+ }
785
+
786
+ [data-tone='primary'] { --cv-tone: var(--smrt-color-primary, #005ac1); --cv-fill: var(--smrt-color-primary-container, #d8e2ff); --cv-ink: var(--smrt-color-on-primary-container, #001a41); }
787
+ [data-tone='secondary'] { --cv-tone: var(--smrt-color-secondary, #575e71); --cv-fill: var(--smrt-color-secondary-container, #dbe2f9); --cv-ink: var(--smrt-color-on-secondary-container, #141b2c); }
788
+ [data-tone='tertiary'] { --cv-tone: var(--smrt-color-tertiary, #715573); --cv-fill: var(--smrt-color-tertiary-container, #fbd7fc); --cv-ink: var(--smrt-color-on-tertiary-container, #29132d); }
789
+ [data-tone='success'] { --cv-tone: var(--smrt-color-success, #1b6d2f); --cv-fill: var(--smrt-color-success-container, #a4f5a9); --cv-ink: var(--smrt-color-on-success-container, #002108); }
790
+ [data-tone='warning'] { --cv-tone: var(--smrt-color-warning, #7a5900); --cv-fill: var(--smrt-color-warning-container, #ffdea3); --cv-ink: var(--smrt-color-on-warning-container, #261900); }
791
+ [data-tone='error'] { --cv-tone: var(--smrt-color-error, #ba1a1a); --cv-fill: var(--smrt-color-error-container, #ffdad6); --cv-ink: var(--smrt-color-on-error-container, #410002); }
792
+ [data-tone='neutral'] { --cv-tone: var(--smrt-color-outline, #74777f); --cv-fill: var(--smrt-color-surface-container-high, #e8e7ec); --cv-ink: var(--smrt-color-on-surface, #1b1b1f); }
793
+
794
+ /* An explicit item color wins over the tone. */
795
+ [style*='--cv-item'] { --cv-tone: var(--cv-item); --cv-fill: color-mix(in srgb, var(--cv-item) 22%, var(--cv-surface)); --cv-ink: var(--cv-text); }
796
+
797
+ .cv-header {
798
+ display: flex;
799
+ align-items: center;
800
+ justify-content: space-between;
801
+ gap: var(--smrt-spacing-2, 0.5rem);
802
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
803
+ background: var(--smrt-color-surface-container-low, #f5f3f7);
804
+ border-bottom: 1px solid var(--cv-border);
805
+ }
806
+
807
+ .cv-title {
808
+ margin: 0;
809
+ overflow-wrap: anywhere;
810
+ font-size: var(--smrt-typography-title-large-size, 1.25rem);
811
+ font-weight: var(--smrt-typography-title-large-weight, 600);
812
+ line-height: 1.2;
813
+ min-width: 0;
814
+ }
815
+
816
+ .cv-nav {
817
+ display: flex;
818
+ align-items: center;
819
+ gap: var(--smrt-spacing-2, 0.5rem);
820
+ flex-shrink: 0;
821
+ }
822
+
823
+ .cv-icon-btn,
824
+ .cv-today-btn {
825
+ display: inline-flex;
826
+ align-items: center;
827
+ justify-content: center;
828
+ min-width: var(--cv-target);
829
+ min-height: var(--cv-target);
830
+ border: 1px solid transparent;
831
+ border-radius: var(--smrt-radius-medium, 8px);
832
+ background: transparent;
833
+ color: var(--cv-muted);
834
+ font: inherit;
835
+ cursor: pointer;
836
+ }
837
+
838
+ .cv-today-btn {
839
+ padding: 0 var(--smrt-spacing-3, 0.75rem);
840
+ border-color: var(--cv-border);
841
+ color: var(--smrt-color-primary, #005ac1);
842
+ font-weight: 500;
843
+ }
844
+
845
+ .cv-icon-btn:hover,
846
+ .cv-today-btn:hover {
847
+ background: var(--smrt-color-surface-container-high, #e8e7ec);
848
+ color: var(--cv-text);
849
+ }
850
+
851
+ .cv-sr {
852
+ position: absolute;
853
+ width: 1px;
854
+ height: 1px;
855
+ margin: -1px;
856
+ padding: 0;
857
+ overflow: hidden;
858
+ clip: rect(0 0 0 0);
859
+ white-space: nowrap;
860
+ border: 0;
861
+ }
862
+
863
+ button:focus-visible,
864
+ a:focus-visible {
865
+ outline: 2px solid var(--smrt-color-primary, #005ac1);
866
+ outline-offset: 2px;
867
+ }
868
+
869
+ /* ---- Month grid ---- */
870
+
871
+ .cv-grid {
872
+ outline: none;
873
+ }
874
+
875
+ .cv-weekdays,
876
+ .cv-week {
877
+ display: grid;
878
+ grid-template-columns: repeat(7, minmax(0, 1fr));
879
+ }
880
+
881
+ .cv-weekday {
882
+ padding: var(--smrt-spacing-2, 0.5rem) 0;
883
+ text-align: center;
884
+ font-size: var(--smrt-typography-label-medium-size, 0.75rem);
885
+ font-weight: 500;
886
+ color: var(--cv-muted);
887
+ border-bottom: 1px solid var(--cv-border);
888
+ }
889
+
890
+ .cv-week {
891
+ position: relative;
892
+ border-bottom: 1px solid var(--cv-border);
893
+ }
894
+
895
+ .cv-week:last-child {
896
+ border-bottom: 0;
897
+ }
898
+
899
+ .cv-day {
900
+ grid-row: 1 / -1;
901
+ display: flex;
902
+ flex-direction: column;
903
+ min-width: 0;
904
+ min-height: 6.5rem;
905
+ border-right: 1px solid var(--cv-border);
906
+ }
907
+
908
+ .cv-day:nth-child(7) {
909
+ border-right: 0;
910
+ }
911
+
912
+ .cv-day--out .cv-daynum {
913
+ color: var(--cv-muted);
914
+ opacity: 0.7;
915
+ }
916
+
917
+ .cv-day--selected {
918
+ background: var(--smrt-color-surface-container, #efedf1);
919
+ box-shadow: inset 0 0 0 2px var(--smrt-color-primary, #005ac1);
920
+ }
921
+
922
+ .cv-daynum {
923
+ display: flex;
924
+ align-items: flex-start;
925
+ justify-content: flex-end;
926
+ height: var(--cv-daynum-h);
927
+ min-height: var(--cv-target);
928
+ padding: var(--smrt-spacing-2, 0.5rem);
929
+ border: 0;
930
+ background: transparent;
931
+ color: inherit;
932
+ font: inherit;
933
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
934
+ text-decoration: none;
935
+ cursor: pointer;
936
+ width: 100%;
937
+ }
938
+
939
+ .cv-day--today .cv-daynum {
940
+ font-weight: 700;
941
+ color: var(--smrt-color-primary, #005ac1);
942
+ }
943
+
944
+ .cv-day-body {
945
+ display: flex;
946
+ flex-direction: column;
947
+ gap: 2px;
948
+ padding: 0 var(--smrt-spacing-1, 0.25rem) var(--smrt-spacing-1, 0.25rem);
949
+ min-width: 0;
950
+ }
951
+
952
+ .cv-chip,
953
+ .cv-more {
954
+ display: flex;
955
+ gap: 0.25rem;
956
+ align-items: baseline;
957
+ min-width: 0;
958
+ padding: 1px var(--smrt-spacing-1, 0.25rem);
959
+ border: 0;
960
+ border-radius: var(--smrt-radius-small, 4px);
961
+ background: transparent;
962
+ color: inherit;
963
+ font: inherit;
964
+ font-size: var(--smrt-typography-label-medium-size, 0.75rem);
965
+ line-height: 1.4;
966
+ text-align: start;
967
+ text-decoration: none;
968
+ cursor: pointer;
969
+ white-space: nowrap;
970
+ }
971
+
972
+ .cv-chip::before {
973
+ content: '';
974
+ flex-shrink: 0;
975
+ align-self: center;
976
+ width: 0.5rem;
977
+ height: 0.5rem;
978
+ border-radius: 50%;
979
+ background: var(--cv-tone);
980
+ }
981
+
982
+ .cv-chip:hover,
983
+ .cv-more:hover {
984
+ background: var(--smrt-color-surface-container-high, #e8e7ec);
985
+ }
986
+
987
+ .cv-chip,
988
+ .cv-more {
989
+ overflow: hidden;
990
+ }
991
+
992
+ .cv-chip-time {
993
+ color: var(--cv-muted);
994
+ flex-shrink: 0;
995
+ }
996
+
997
+ .cv-more-short {
998
+ display: none;
999
+ }
1000
+
1001
+ /* Narrow grids (a phone forced into month mode): dots and titles only. */
1002
+ @container (max-width: 40rem) {
1003
+ .cv-chip-time,
1004
+ .cv-more-long {
1005
+ display: none;
1006
+ }
1007
+
1008
+ .cv-more-short {
1009
+ display: inline;
1010
+ }
1011
+
1012
+ .cv-title {
1013
+ font-size: var(--smrt-typography-title-medium-size, 1rem);
1014
+ }
1015
+ }
1016
+
1017
+ .cv-chip-title {
1018
+ overflow: hidden;
1019
+ text-overflow: ellipsis;
1020
+ }
1021
+
1022
+ .cv-more {
1023
+ color: var(--smrt-color-primary, #005ac1);
1024
+ font-weight: 500;
1025
+ }
1026
+
1027
+ .cv-band {
1028
+ z-index: 1;
1029
+ align-self: center;
1030
+ margin: 0 var(--smrt-spacing-1, 0.25rem);
1031
+ height: calc(var(--cv-band-row) - 4px);
1032
+ padding: 0 var(--smrt-spacing-2, 0.5rem);
1033
+ border: 0;
1034
+ border-radius: var(--smrt-radius-small, 4px);
1035
+ background: var(--cv-fill);
1036
+ color: var(--cv-ink);
1037
+ box-shadow: inset 3px 0 0 var(--cv-tone);
1038
+ font: inherit;
1039
+ font-size: var(--smrt-typography-label-medium-size, 0.75rem);
1040
+ font-weight: 500;
1041
+ line-height: calc(var(--cv-band-row) - 4px);
1042
+ text-align: start;
1043
+ text-decoration: none;
1044
+ white-space: nowrap;
1045
+ overflow: hidden;
1046
+ text-overflow: ellipsis;
1047
+ cursor: pointer;
1048
+ min-width: 0;
1049
+ }
1050
+
1051
+ .cv-band--before {
1052
+ margin-inline-start: 0;
1053
+ border-start-start-radius: 0;
1054
+ border-end-start-radius: 0;
1055
+ box-shadow: none;
1056
+ }
1057
+
1058
+ .cv-band--after {
1059
+ margin-inline-end: 0;
1060
+ border-start-end-radius: 0;
1061
+ border-end-end-radius: 0;
1062
+ }
1063
+
1064
+ /* ---- Day panel and agenda rows ---- */
1065
+
1066
+ .cv-day-panel {
1067
+ border-top: 1px solid var(--cv-border);
1068
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem) var(--smrt-spacing-3, 0.75rem);
1069
+ }
1070
+
1071
+ .cv-day-panel-head {
1072
+ display: flex;
1073
+ align-items: center;
1074
+ justify-content: space-between;
1075
+ gap: var(--smrt-spacing-2, 0.5rem);
1076
+ }
1077
+
1078
+ .cv-day-title,
1079
+ .cv-agenda-date {
1080
+ margin: 0;
1081
+ font-size: var(--smrt-typography-title-small-size, 0.9375rem);
1082
+ font-weight: 600;
1083
+ }
1084
+
1085
+ .cv-agenda-date a {
1086
+ color: inherit;
1087
+ }
1088
+
1089
+ .cv-rows {
1090
+ list-style: none;
1091
+ margin: 0;
1092
+ padding: 0;
1093
+ display: flex;
1094
+ flex-direction: column;
1095
+ gap: var(--smrt-spacing-2, 0.5rem);
1096
+ }
1097
+
1098
+ .cv-row {
1099
+ display: flex;
1100
+ align-items: center;
1101
+ gap: var(--smrt-spacing-3, 0.75rem);
1102
+ width: 100%;
1103
+ min-height: var(--cv-target);
1104
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
1105
+ border: 1px solid var(--cv-border);
1106
+ border-inline-start: 4px solid var(--cv-tone);
1107
+ border-radius: var(--smrt-radius-medium, 8px);
1108
+ background: var(--cv-surface);
1109
+ color: inherit;
1110
+ font: inherit;
1111
+ text-align: start;
1112
+ text-decoration: none;
1113
+ cursor: pointer;
1114
+ }
1115
+
1116
+ .cv-row:hover {
1117
+ background: var(--smrt-color-surface-container-low, #f5f3f7);
1118
+ }
1119
+
1120
+ .cv-row-time {
1121
+ flex: 0 0 4.5rem;
1122
+ color: var(--cv-muted);
1123
+ font-size: var(--smrt-typography-body-small-size, 0.8125rem);
1124
+ }
1125
+
1126
+ .cv-row-main {
1127
+ display: flex;
1128
+ flex-direction: column;
1129
+ min-width: 0;
1130
+ }
1131
+
1132
+ .cv-row-title {
1133
+ font-weight: 500;
1134
+ overflow-wrap: anywhere;
1135
+ }
1136
+
1137
+ .cv-row-meta {
1138
+ display: flex;
1139
+ flex-wrap: wrap;
1140
+ gap: var(--smrt-spacing-2, 0.5rem);
1141
+ color: var(--cv-muted);
1142
+ font-size: var(--smrt-typography-body-small-size, 0.8125rem);
1143
+ }
1144
+
1145
+ /* Tone is a fill with its own ink, never a text color (brand rule). */
1146
+ .cv-row-label {
1147
+ padding: 0 0.375rem;
1148
+ border-radius: var(--smrt-radius-small, 4px);
1149
+ background: var(--cv-fill);
1150
+ color: var(--cv-ink);
1151
+ font-weight: 500;
1152
+ }
1153
+
1154
+ .cv-empty {
1155
+ margin: var(--smrt-spacing-2, 0.5rem) 0;
1156
+ color: var(--cv-muted);
1157
+ }
1158
+
1159
+ /* ---- Agenda ---- */
1160
+
1161
+ .cv-strip {
1162
+ display: flex;
1163
+ gap: var(--smrt-spacing-2, 0.5rem);
1164
+ overflow-x: auto;
1165
+ overscroll-behavior-x: contain;
1166
+ scroll-snap-type: x proximity;
1167
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem);
1168
+ border-bottom: 1px solid var(--cv-border);
1169
+ scrollbar-width: none;
1170
+ }
1171
+
1172
+ .cv-strip-day {
1173
+ flex: 0 0 var(--cv-target);
1174
+ display: flex;
1175
+ flex-direction: column;
1176
+ align-items: center;
1177
+ gap: 2px;
1178
+ min-height: 3.5rem;
1179
+ padding: var(--smrt-spacing-1, 0.25rem) 0;
1180
+ border: 1px solid transparent;
1181
+ border-radius: var(--smrt-radius-medium, 8px);
1182
+ background: transparent;
1183
+ color: inherit;
1184
+ font: inherit;
1185
+ cursor: pointer;
1186
+ scroll-snap-align: center;
1187
+ }
1188
+
1189
+ .cv-strip-day[aria-pressed='true'] {
1190
+ background: var(--smrt-color-primary, #005ac1);
1191
+ color: var(--smrt-color-on-primary, #ffffff);
1192
+ }
1193
+
1194
+ .cv-strip-day--today:not([aria-pressed='true']) {
1195
+ border-color: var(--smrt-color-primary, #005ac1);
1196
+ }
1197
+
1198
+ .cv-strip-weekday {
1199
+ font-size: var(--smrt-typography-label-small-size, 0.6875rem);
1200
+ opacity: 0.8;
1201
+ }
1202
+
1203
+ .cv-strip-num {
1204
+ font-weight: 600;
1205
+ }
1206
+
1207
+ .cv-strip-dot {
1208
+ width: 0.375rem;
1209
+ height: 0.375rem;
1210
+ border-radius: 50%;
1211
+ }
1212
+
1213
+ .cv-strip-dot--on {
1214
+ background: currentColor;
1215
+ }
1216
+
1217
+ .cv-agenda {
1218
+ list-style: none;
1219
+ margin: 0;
1220
+ padding: var(--smrt-spacing-2, 0.5rem) var(--smrt-spacing-3, 0.75rem) var(--smrt-spacing-3, 0.75rem);
1221
+ display: flex;
1222
+ flex-direction: column;
1223
+ gap: var(--smrt-spacing-4, 1rem);
1224
+ }
1225
+
1226
+ .cv-agenda-day {
1227
+ display: flex;
1228
+ flex-direction: column;
1229
+ gap: var(--smrt-spacing-2, 0.5rem);
1230
+ scroll-margin-top: var(--smrt-spacing-4, 1rem);
1231
+ }
1232
+
1233
+ .cv-agenda-day--today .cv-agenda-date {
1234
+ color: var(--smrt-color-primary, #005ac1);
1235
+ }
1236
+
1237
+ .cv--agenda .cv-empty {
1238
+ padding: 0 var(--smrt-spacing-3, 0.75rem);
1239
+ }
1240
+
1241
+ .cv-agenda .cv-empty {
1242
+ padding: 0;
1243
+ }
1244
+
1245
+ @media (prefers-reduced-motion: reduce) {
1246
+ .cv-strip {
1247
+ scroll-behavior: auto;
1248
+ }
1249
+ }
1250
+ </style>