@eifi1/ui-kit 0.10.0 → 0.11.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 (162) hide show
  1. package/README.md +20 -16
  2. package/dist/components/alert-banner.d.ts +26 -1
  3. package/dist/components/alert-banner.js +10 -7
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -0
  6. package/dist/components/bulk-action-bar.d.ts +51 -5
  7. package/dist/components/bulk-action-bar.js +88 -41
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +58 -3
  10. package/dist/components/button-group.js +56 -5
  11. package/dist/components/button-group.js.map +1 -1
  12. package/dist/components/calculator.d.ts +2 -0
  13. package/dist/components/calendar-heatmap.d.ts +124 -0
  14. package/dist/components/calendar-heatmap.js +295 -0
  15. package/dist/components/calendar-heatmap.js.map +1 -0
  16. package/dist/components/choice-card.d.ts +34 -2
  17. package/dist/components/choice-card.js +49 -0
  18. package/dist/components/choice-card.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +1 -0
  20. package/dist/components/data-table.js +2 -0
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/field.d.ts +43 -0
  23. package/dist/components/field.js +52 -0
  24. package/dist/components/field.js.map +1 -0
  25. package/dist/components/file-button.d.ts +1 -0
  26. package/dist/components/file-dropzone.d.ts +1 -0
  27. package/dist/components/floating-panel.d.ts +147 -4
  28. package/dist/components/floating-panel.js +188 -28
  29. package/dist/components/floating-panel.js.map +1 -1
  30. package/dist/components/list.d.ts +14 -1
  31. package/dist/components/list.js +7 -1
  32. package/dist/components/list.js.map +1 -1
  33. package/dist/components/menu-item.d.ts +11 -1
  34. package/dist/components/menu-item.js +6 -1
  35. package/dist/components/menu-item.js.map +1 -1
  36. package/dist/components/mini-calendar.d.ts +70 -5
  37. package/dist/components/mini-calendar.js +166 -62
  38. package/dist/components/mini-calendar.js.map +1 -1
  39. package/dist/components/nav-pills.d.ts +66 -0
  40. package/dist/components/nav-pills.js +65 -0
  41. package/dist/components/nav-pills.js.map +1 -0
  42. package/dist/components/number-field.d.ts +2 -0
  43. package/dist/components/number-input.d.ts +2 -0
  44. package/dist/components/numpad-sheet.d.ts +2 -0
  45. package/dist/components/progress-bar.d.ts +31 -2
  46. package/dist/components/progress-bar.js +73 -7
  47. package/dist/components/progress-bar.js.map +1 -1
  48. package/dist/components/series-chart.d.ts +16 -0
  49. package/dist/components/series-chart.js +4 -1
  50. package/dist/components/series-chart.js.map +1 -1
  51. package/dist/components/settings-fields.d.ts +1 -0
  52. package/dist/components/status-dot.d.ts +6 -2
  53. package/dist/components/status-dot.js +6 -1
  54. package/dist/components/status-dot.js.map +1 -1
  55. package/dist/components/table.d.ts +40 -8
  56. package/dist/components/table.js +41 -7
  57. package/dist/components/table.js.map +1 -1
  58. package/dist/components/text.d.ts +8 -1
  59. package/dist/components/text.js +1 -0
  60. package/dist/components/text.js.map +1 -1
  61. package/dist/components/time-input.d.ts +1 -0
  62. package/dist/components/toggle-group.d.ts +15 -0
  63. package/dist/components/toggle-group.js +23 -3
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/tooltip.d.ts +11 -3
  66. package/dist/components/tooltip.js +2 -0
  67. package/dist/components/tooltip.js.map +1 -1
  68. package/dist/components/ui.d.ts +55 -4
  69. package/dist/components/ui.js +84 -7
  70. package/dist/components/ui.js.map +1 -1
  71. package/dist/components/user-avatar.d.ts +25 -3
  72. package/dist/components/user-avatar.js +32 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +1 -0
  75. package/dist/i18n/defaults.d.ts +2 -0
  76. package/dist/i18n/defaults.js +2 -0
  77. package/dist/i18n/defaults.js.map +1 -1
  78. package/dist/i18n/kit-labels.d.ts +3 -0
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  81. package/dist/i18n/locales/de-CH.d.ts +2 -0
  82. package/dist/i18n/locales/de-informal.d.ts +2 -0
  83. package/dist/i18n/locales/de.d.ts +2 -0
  84. package/dist/i18n/locales/de.js +9 -1
  85. package/dist/i18n/locales/de.js.map +1 -1
  86. package/dist/i18n/locales/es.d.ts +2 -0
  87. package/dist/i18n/locales/es.js +9 -1
  88. package/dist/i18n/locales/es.js.map +1 -1
  89. package/dist/i18n/locales/fr.d.ts +2 -0
  90. package/dist/i18n/locales/fr.js +9 -1
  91. package/dist/i18n/locales/fr.js.map +1 -1
  92. package/dist/i18n/locales/hu.d.ts +2 -0
  93. package/dist/i18n/locales/hu.js +9 -1
  94. package/dist/i18n/locales/hu.js.map +1 -1
  95. package/dist/i18n/locales/it.d.ts +2 -0
  96. package/dist/i18n/locales/it.js +9 -1
  97. package/dist/i18n/locales/it.js.map +1 -1
  98. package/dist/i18n/locales/zh.d.ts +2 -0
  99. package/dist/i18n/locales/zh.js +9 -1
  100. package/dist/i18n/locales/zh.js.map +1 -1
  101. package/dist/index.d.ts +14 -10
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/lib/clipping.d.ts +21 -5
  105. package/dist/lib/clipping.js +3 -0
  106. package/dist/lib/clipping.js.map +1 -1
  107. package/dist/lib/dates.d.ts +17 -1
  108. package/dist/lib/dates.js +19 -0
  109. package/dist/lib/dates.js.map +1 -1
  110. package/dist/rhf/form.d.ts +1 -0
  111. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  112. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  113. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  114. package/dist/rhf.d.ts +2 -0
  115. package/dist/rhf.js +1 -0
  116. package/dist/rhf.js.map +1 -1
  117. package/dist/shell/topbar-action-menu.d.ts +73 -3
  118. package/dist/shell/topbar-action-menu.js +100 -27
  119. package/dist/shell/topbar-action-menu.js.map +1 -1
  120. package/dist/shell.d.ts +2 -1
  121. package/dist/wizard/stepper-nav.d.ts +1 -0
  122. package/dist/wizard/wizard-context.d.ts +7 -1
  123. package/dist/wizard/wizard-context.js +4 -0
  124. package/dist/wizard/wizard-context.js.map +1 -1
  125. package/dist/wizard.d.ts +2 -1
  126. package/package.json +1 -1
  127. package/src/components/alert-banner.tsx +36 -7
  128. package/src/components/bulk-action-bar.tsx +163 -57
  129. package/src/components/button-group.tsx +124 -4
  130. package/src/components/calendar-heatmap.tsx +504 -0
  131. package/src/components/choice-card.tsx +97 -1
  132. package/src/components/data-table.tsx +7 -0
  133. package/src/components/field.tsx +137 -0
  134. package/src/components/floating-panel.tsx +366 -15
  135. package/src/components/list.tsx +28 -2
  136. package/src/components/menu-item.tsx +22 -1
  137. package/src/components/mini-calendar.tsx +245 -54
  138. package/src/components/nav-pills.tsx +145 -0
  139. package/src/components/progress-bar.tsx +129 -11
  140. package/src/components/series-chart.tsx +21 -0
  141. package/src/components/status-dot.tsx +10 -2
  142. package/src/components/table.tsx +85 -11
  143. package/src/components/text.tsx +9 -1
  144. package/src/components/toggle-group.tsx +48 -3
  145. package/src/components/tooltip.tsx +17 -3
  146. package/src/components/ui.tsx +149 -11
  147. package/src/components/user-avatar.tsx +57 -3
  148. package/src/i18n/defaults.ts +2 -0
  149. package/src/i18n/kit-labels.tsx +2 -0
  150. package/src/i18n/locales/de.ts +9 -0
  151. package/src/i18n/locales/es.ts +9 -0
  152. package/src/i18n/locales/fr.ts +9 -0
  153. package/src/i18n/locales/hu.ts +9 -0
  154. package/src/i18n/locales/it.ts +9 -0
  155. package/src/i18n/locales/zh.ts +9 -0
  156. package/src/index.ts +5 -0
  157. package/src/lib/clipping.ts +22 -4
  158. package/src/lib/dates.ts +37 -0
  159. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  160. package/src/rhf.ts +2 -0
  161. package/src/shell/topbar-action-menu.tsx +239 -49
  162. package/src/wizard/wizard-context.tsx +9 -0
@@ -43,7 +43,17 @@ interface MenuItemBaseProps {
43
43
  icon?: LucideIcon;
44
44
  /** Anything else before the label — an avatar, a flag — when a Lucide icon will not do. */
45
45
  leading?: ReactNode;
46
- /** After the label, at the row's end: a shortcut, a count, a badge. */
46
+ /**
47
+ * Right after the label text, before the gap that pushes {@link trailing} to the
48
+ * row's end: keksdose's budget switcher tags a budget "Guest" or "Shared" with a
49
+ * `Chip` that belongs to the NAME (budget-switcher.tsx:55/59), not to the row's end
50
+ * where a count or a check mark sits. Hand-placed inside `children` it was truncated
51
+ * with the name — a long budget name ate the chip first. Here the label truncates
52
+ * and the badge never shrinks, so it stays visible beside what is left of the name.
53
+ * It is part of the row's accessible name ("Household Shared"), so keep it text.
54
+ */
55
+ badge?: ReactNode;
56
+ /** After the label, at the row's end: a shortcut, a count. (A tag that belongs to the name is {@link badge}.) */
47
57
  trailing?: ReactNode;
48
58
  /** `danger` for the row that destroys or signs out. Colour is not the whole message:
49
59
  * the label must still say what the row does. */
@@ -123,6 +133,7 @@ export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem
123
133
  children,
124
134
  icon: Icon,
125
135
  leading,
136
+ badge,
126
137
  trailing,
127
138
  tone = "default",
128
139
  disabled = false,
@@ -151,6 +162,16 @@ export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem
151
162
  {Icon && <Icon className="size-4 shrink-0" aria-hidden />}
152
163
  {leading}
153
164
  <span className="min-w-0 truncate">{children}</span>
165
+ {badge != null && badge !== false && (
166
+ <>
167
+ {/* A space between the name and the badge in the accessible name ("Household
168
+ Shared", not "HouseholdShared"); whitespace between flex items is not
169
+ laid out, so it costs nothing on screen. */}{" "}
170
+ <span data-menu-item-badge="" className="flex shrink-0 items-center">
171
+ {badge}
172
+ </span>
173
+ </>
174
+ )}
154
175
  </span>
155
176
  {(trailing != null || on) && (
156
177
  <span className="flex shrink-0 items-center gap-2">
@@ -1,10 +1,17 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, KeyboardEvent } from "react";
2
+ import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
3
3
  import { ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useAnnounce } from "../hooks/use-announce";
7
- import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
7
+ import {
8
+ addMonthsClamped,
9
+ localeWeekStart,
10
+ monthKey,
11
+ parseIsoDate,
12
+ sameYmd,
13
+ toLocalIso,
14
+ } from "../lib/dates";
8
15
  import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
9
16
 
10
17
  /**
@@ -80,8 +87,10 @@ const DAY_NAME_FORMAT: Intl.DateTimeFormatOptions = {
80
87
  * before this, a closed prop list dropped both (audit §api-design).
81
88
  */
82
89
  export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">, "onSelect"> {
83
- from: string;
84
- to: string;
90
+ /** The selection's start as "YYYY-MM-DD"; `""` (the default) for none. */
91
+ from?: string;
92
+ /** The selection's end; `""` (the default) for none. */
93
+ to?: string;
85
94
  /**
86
95
  * BCP 47 tag for the month caption, the weekday heads, the day numbers and the
87
96
  * spoken dates. Optional since the kit grew `<UiKitProvider locale>`: a calendar
@@ -122,47 +131,92 @@ export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">,
122
131
  * merely by existing.
123
132
  */
124
133
  focusOnOpen?: boolean;
134
+ /**
135
+ * `"sm"` (default) is the compact picker: 32px round days, the size the date
136
+ * pickers and the data-table filter draw. `"lg"` is a MONTH VIEW for a page — tall
137
+ * cells in a ruled grid, the day number in the top corner and {@link renderDay}'s
138
+ * content under it — the calendar a planner page hand-built before this existed.
139
+ * Both are the same grid underneath: the same roles, roving tabindex, keys, RTL
140
+ * mirroring and week start.
141
+ */
142
+ size?: "sm" | "lg";
143
+ /**
144
+ * Custom content for one day: events, an amount, a dot. Called for every day of the
145
+ * month on show; return `null` for none.
146
+ *
147
+ * ⚠️ **It renders INSIDE the day's button** — a dot under the number at `"sm"`, a
148
+ * column under it at `"lg"` — so it must not be interactive: no links, no buttons,
149
+ * no inputs. A control inside a control is invalid HTML, and a screen reader lands
150
+ * on the day and never reaches what is inside it. Events that are themselves
151
+ * clickable are shown here as plain text, and the DAY is what is clicked: pass
152
+ * `mode="single"` and let `onSelect` open a day panel (a drawer, a popover) that
153
+ * lists that day's events as real links.
154
+ *
155
+ * The day's accessible name stays the full date; this content is attached to it as
156
+ * its DESCRIPTION (`aria-describedby`), so "Monday, 14 September 2026" is read
157
+ * first and "Rent due · Plumber" after it. Anything decorative (a coloured dot)
158
+ * should be `aria-hidden` or carry an `sr-only` word for what it means.
159
+ */
160
+ renderDay?: (day: Date, state: MiniCalendarDayState) => ReactNode;
161
+ /**
162
+ * The month on show, as "YYYY-MM". FOLLOWED rather than controlled, exactly like
163
+ * `from`/`to`: when it changes the grid moves there, and in between the user pages
164
+ * freely — every move to another month is reported through {@link onMonthChange}.
165
+ * For a month page whose own header (a month picker, a Today button) drives the
166
+ * calendar. Left out, the grid opens on the selection or today.
167
+ */
168
+ month?: string;
169
+ /** Called with "YYYY-MM" whenever the USER moves the grid to another month (the
170
+ * arrows, PageUp/PageDown, arrowing off the edge). */
171
+ onMonthChange?: (month: string) => void;
172
+ /**
173
+ * Hide the built-in previous/next arrows and month caption, for a page that draws
174
+ * its own. The caption is kept for assistive technology (visually hidden), because
175
+ * it is what names the grid.
176
+ */
177
+ hideNavigation?: boolean;
125
178
  /** Extra classes for the calendar's root. */
126
179
  className?: string;
127
180
  }
128
181
 
129
- export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
130
-
131
- /**
132
- * The first day of the week in `locale`, as a `Date#getDay` index.
133
- *
134
- * `Intl.Locale#getWeekInfo()` is the standard spelling; V8 shipped it first as the
135
- * `weekInfo` accessor, and Firefox has neither yet — hence both reads and a Monday
136
- * fallback, which is ISO 8601 and what this grid did unconditionally before. Its
137
- * `firstDay` counts 1 = Monday … 7 = Sunday, so `% 7` maps it onto `getDay`.
138
- */
139
- function localeWeekStart(locale: string | undefined): WeekDay {
140
- try {
141
- const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale;
142
- const loc = new Intl.Locale(tag) as Intl.Locale & {
143
- getWeekInfo?: () => { firstDay: number };
144
- weekInfo?: { firstDay: number };
145
- };
146
- const firstDay = (loc.getWeekInfo?.() ?? loc.weekInfo)?.firstDay;
147
- if (typeof firstDay === "number") return (firstDay % 7) as WeekDay;
148
- } catch {
149
- // A malformed tag throws from `Intl.Locale`; the calendar still has to render.
150
- }
151
- return 1;
182
+ /** What {@link MiniCalendarProps.renderDay} is told about the day it draws. */
183
+ export interface MiniCalendarDayState {
184
+ /** The day as "YYYY-MM-DD". */
185
+ iso: string;
186
+ /** The day number as drawn, in the calendar's locale. */
187
+ label: string;
188
+ today: boolean;
189
+ /** Outside `min`/`max`. */
190
+ disabled: boolean;
191
+ /** Part of the selection — an end or a day between. */
192
+ selected: boolean;
193
+ rangeStart: boolean;
194
+ rangeEnd: boolean;
195
+ /** Strictly between the two ends of a range. */
196
+ inRange: boolean;
197
+ /** The day that holds the tab stop. */
198
+ active: boolean;
199
+ size: "sm" | "lg";
152
200
  }
153
201
 
202
+ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
203
+
154
204
  /** Where `d` sits in a week that starts on `weekStart` (the first column = 0). */
155
205
  const weekdayIndex = (d: Date, weekStart: WeekDay) => (d.getDay() - weekStart + 7) % 7;
156
206
 
157
- /** Move `d` by whole months, keeping the day of the month where the target has one.
158
- * `setMonth` alone ROLLS OVER — 31 January + 1 month is 3 March — which would skip
159
- * February entirely for anyone paging through the year with PageDown. */
160
- function stepMonths(d: Date, months: number): Date {
161
- const day = d.getDate();
162
- const out = new Date(d.getFullYear(), d.getMonth() + months, 1);
163
- const lastOfTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();
164
- out.setDate(Math.min(day, lastOfTarget));
165
- return out;
207
+ /** Move `d` by whole months without rolling over (see {@link addMonthsClamped}). */
208
+ const stepMonths = addMonthsClamped;
209
+
210
+ /** A month-view cell: tall enough for two or three event lines, shorter on a phone. */
211
+ const LG_CELL_HEIGHT = "min-h-16 md:min-h-24";
212
+
213
+ /** The 1st of a "YYYY-MM" month, or null when absent or malformed. */
214
+ const monthStart = (key: string | undefined) => (key ? parseIsoDate(`${key}-01`) : null);
215
+
216
+ /** `day`'s day of the month in `first`'s month, clamped to that month's length. */
217
+ function sameDayIn(first: Date, day: Date): Date {
218
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
219
+ return new Date(first.getFullYear(), first.getMonth(), Math.min(day.getDate(), last));
166
220
  }
167
221
 
168
222
  /**
@@ -187,8 +241,8 @@ function stepMonths(d: Date, months: number): Date {
187
241
  * readable, and the click handler is what refuses them.
188
242
  */
189
243
  export function MiniCalendar({
190
- from,
191
- to,
244
+ from = "",
245
+ to = "",
192
246
  locale: localeProp,
193
247
  weekStartsOn,
194
248
  onSelect,
@@ -197,9 +251,15 @@ export function MiniCalendar({
197
251
  max,
198
252
  labels: labelsProp,
199
253
  focusOnOpen,
254
+ size = "sm",
255
+ renderDay,
256
+ month,
257
+ onMonthChange,
258
+ hideNavigation,
200
259
  className,
201
260
  ...rest
202
261
  }: MiniCalendarProps) {
262
+ const lg = size === "lg";
203
263
  const labels = useKitLabels("miniCalendar", DEFAULT_MINI_CALENDAR_LABELS, labelsProp);
204
264
  const locale = useKitLocale(localeProp);
205
265
  const providerWeekStart = useKitWeekStart();
@@ -232,7 +292,11 @@ export function MiniCalendar({
232
292
  * so a bounded calendar opens on a day it will actually let you pick.
233
293
  */
234
294
  const [activeIso, setActiveIso] = useState(() => {
235
- const start = fromDate ?? toDate ?? new Date();
295
+ let start = fromDate ?? toDate ?? new Date();
296
+ // A `month` wins over the selection's month: the page asked for THAT month. The
297
+ // selection (or today) is kept when it falls inside it, else the 1st.
298
+ const first = monthStart(month);
299
+ if (first && monthKey(start) !== month) start = first;
236
300
  if (minDate && start < minDate) return toLocalIso(minDate);
237
301
  if (maxDate && start > maxDate) return toLocalIso(maxDate);
238
302
  return toLocalIso(start);
@@ -253,6 +317,19 @@ export function MiniCalendar({
253
317
  if (next && toLocalIso(next) !== activeIso) setActiveIso(toLocalIso(next));
254
318
  }
255
319
 
320
+ // …and the caller's month, the same way. Only a CHANGE moves the grid, so paging
321
+ // with the arrows is not snapped back by a parent that ignores `onMonthChange`.
322
+ const [lastMonth, setLastMonth] = useState(month);
323
+ if (lastMonth !== month) {
324
+ setLastMonth(month);
325
+ const first = monthStart(month);
326
+ const current = parseIsoDate(activeIso) ?? new Date();
327
+ if (first && monthKey(current) !== month) {
328
+ // Keep the day of the month, clamped: the 31st in a 30-day month is the 30th.
329
+ setActiveIso(toLocalIso(sameDayIn(first, current)));
330
+ }
331
+ }
332
+
256
333
  const activeDate = parseIsoDate(activeIso) ?? new Date();
257
334
  const view = { year: activeDate.getFullYear(), month: activeDate.getMonth() };
258
335
 
@@ -281,6 +358,7 @@ export function MiniCalendar({
281
358
  d.setDate(ref.getDate() + i);
282
359
  return {
283
360
  narrow: d.toLocaleDateString(locale, { weekday: "narrow" }),
361
+ short: d.toLocaleDateString(locale, { weekday: "short" }),
284
362
  long: d.toLocaleDateString(locale, { weekday: "long" }),
285
363
  };
286
364
  });
@@ -312,6 +390,8 @@ export function MiniCalendar({
312
390
  if (iso === activeIso) return;
313
391
  if (focus) pendingFocus.current = iso;
314
392
  setActiveIso(iso);
393
+ const key = monthKey(d);
394
+ if (key !== monthKey(activeDate)) onMonthChange?.(key);
315
395
  };
316
396
 
317
397
  /**
@@ -414,7 +494,13 @@ export function MiniCalendar({
414
494
  // `className` does everywhere else in the kit; `...rest` first, as in the rest of
415
495
  // this wave, so nothing from outside can take the grid's own wiring away.
416
496
  <div {...rest} className={cn("select-none", className)}>
417
- <div className="flex items-center justify-between pb-1">
497
+ {hideNavigation ? (
498
+ // Still in the tree: the caption is what names the grid.
499
+ <div id={monthId} className="sr-only-fixed">
500
+ {monthLabel}
501
+ </div>
502
+ ) : (
503
+ <div className={cn("flex items-center justify-between", lg ? "pb-2" : "pb-1")}>
418
504
  <button
419
505
  type="button"
420
506
  onClick={() => moveMonth(-1)}
@@ -431,7 +517,12 @@ export function MiniCalendar({
431
517
  <div
432
518
  id={monthId}
433
519
  aria-live="polite"
434
- className="text-xs font-medium capitalize text-[var(--text-secondary)]"
520
+ className={cn(
521
+ "capitalize",
522
+ lg
523
+ ? "text-base font-semibold text-[var(--text-primary)]"
524
+ : "text-xs font-medium text-[var(--text-secondary)]",
525
+ )}
435
526
  >
436
527
  {monthLabel}
437
528
  </div>
@@ -444,31 +535,57 @@ export function MiniCalendar({
444
535
  <ChevronRight className="size-4 rtl:-scale-x-100" aria-hidden />
445
536
  </button>
446
537
  </div>
538
+ )}
447
539
  <div
448
540
  ref={gridRef}
449
541
  role="grid"
450
542
  aria-labelledby={monthId}
451
543
  aria-describedby={mode === "range" ? hintId : undefined}
452
- className="grid gap-y-0.5"
544
+ className={cn(
545
+ "grid",
546
+ // The month view is RULED: a 1px gap over a border-coloured ground draws the
547
+ // lines between the cells, the way a wall calendar does.
548
+ lg
549
+ ? "gap-px overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--border)]"
550
+ : "gap-y-0.5",
551
+ )}
453
552
  >
454
553
  <div
455
554
  role="row"
456
- className="grid grid-cols-7 text-center text-[10px] font-medium uppercase text-[var(--text-placeholder)]"
555
+ className={cn(
556
+ "grid grid-cols-7 text-center font-medium",
557
+ lg
558
+ ? "gap-px text-xs text-[var(--text-muted)]"
559
+ : "text-[10px] uppercase text-[var(--text-placeholder)]",
560
+ )}
457
561
  >
458
562
  {weekdayLabels.map((w, i) => (
459
- <div key={i} role="columnheader" aria-label={w.long} className="py-0.5">
460
- {w.narrow}
563
+ <div
564
+ key={i}
565
+ role="columnheader"
566
+ aria-label={w.long}
567
+ className={lg ? "bg-[var(--bg-surface)] py-2" : "py-0.5"}
568
+ >
569
+ {lg ? w.short : w.narrow}
461
570
  </div>
462
571
  ))}
463
572
  </div>
464
573
  {weeks.map((week, wi) => (
465
574
  // No column gap: the days of a range join into ONE band. With a gap each day
466
575
  // was its own tinted box, and a selected fortnight read as fourteen chips.
467
- <div key={wi} role="row" className="grid grid-cols-7">
576
+ <div key={wi} role="row" className={cn("grid grid-cols-7", lg && "gap-px")}>
468
577
  {week.map((d, di) => {
469
578
  // The padding cells still have to BE cells, or the rows are ragged and
470
579
  // a screen reader's "column 3" stops meaning Wednesday.
471
- if (!d) return <div key={di} role="gridcell" className="h-8" />;
580
+ if (!d) {
581
+ return (
582
+ <div
583
+ key={di}
584
+ role="gridcell"
585
+ className={lg ? cn(LG_CELL_HEIGHT, "bg-[var(--bg-surface-2)]") : "h-8"}
586
+ />
587
+ );
588
+ }
472
589
  const iso = toLocalIso(d);
473
590
  const disabled = isDisabled(d);
474
591
  const isStart = !disabled && fromDate && sameYmd(d, fromDate);
@@ -476,12 +593,29 @@ export function MiniCalendar({
476
593
  const isToday = sameYmd(d, today);
477
594
  const isInRange = !disabled && inRange(d) && !isStart && !isEnd;
478
595
  const isActive = iso === activeIso;
596
+ const isSelected = Boolean(isStart || isEnd || isInRange);
597
+ const label = dayNumber.format(d.getDate());
598
+ const content = renderDay?.(d, {
599
+ iso,
600
+ label,
601
+ today: isToday,
602
+ disabled,
603
+ selected: isSelected,
604
+ rangeStart: Boolean(isStart),
605
+ rangeEnd: Boolean(isEnd),
606
+ inRange: isInRange,
607
+ active: isActive,
608
+ size,
609
+ });
610
+ const hasContent = content != null && content !== false && content !== "";
611
+ const contentId = `${id}-day-${iso}`;
479
612
  // The band behind the day: full width between the ends, half width at an
480
613
  // end (so the round endpoint sits ON the band's tip), rounded where a row
481
614
  // or the month breaks it. Only when both ends exist and differ — a lone
482
- // start is a dot, not a band of one.
615
+ // start is a dot, not a band of one. The month view tints whole cells
616
+ // instead, so it has no band.
483
617
  const banded =
484
- hasBand && !disabled && (isStart || isEnd || isInRange);
618
+ !lg && hasBand && !disabled && (isStart || isEnd || isInRange);
485
619
  const lastOfMonth = d.getDate() === daysInMonth(d);
486
620
  const band = banded
487
621
  ? cn(
@@ -494,7 +628,14 @@ export function MiniCalendar({
494
628
  )
495
629
  : null;
496
630
  return (
497
- <div key={di} role="none" className="relative flex h-8 items-center justify-center">
631
+ <div
632
+ key={di}
633
+ role="none"
634
+ className={cn(
635
+ "relative flex",
636
+ lg ? "min-w-0" : "h-8 items-center justify-center",
637
+ )}
638
+ >
498
639
  {band && <span aria-hidden className={band} />}
499
640
  <button
500
641
  key={di}
@@ -506,12 +647,28 @@ export function MiniCalendar({
506
647
  // control, which is the same reason the pattern exists.
507
648
  tabIndex={isActive ? 0 : -1}
508
649
  aria-label={labels.day(formatDay(d))}
509
- aria-selected={Boolean(isStart || isEnd || isInRange)}
650
+ // The custom content is the day's DESCRIPTION: `aria-label` replaces
651
+ // the button's text as its name, so without this link a screen
652
+ // reader would hear the date and never the events drawn under it.
653
+ aria-describedby={hasContent ? contentId : undefined}
654
+ aria-selected={isSelected}
510
655
  aria-disabled={disabled || undefined}
511
656
  aria-current={isToday ? "date" : undefined}
512
657
  onClick={() => handleClick(d)}
513
658
  onKeyDown={onDayKeyDown}
514
- className={cn(
659
+ className={
660
+ lg
661
+ ? cn(
662
+ "flex w-full min-w-0 flex-col items-stretch gap-1 overflow-hidden p-1.5 text-start text-xs transition-colors",
663
+ LG_CELL_HEIGHT,
664
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
665
+ disabled
666
+ ? "cursor-not-allowed bg-[var(--bg-surface)] text-[var(--text-placeholder)]"
667
+ : isSelected
668
+ ? "bg-[var(--brand-bg)] text-[var(--text-primary)] hover:bg-[var(--brand-bg-hover)]"
669
+ : "bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
670
+ )
671
+ : cn(
515
672
  "relative size-8 rounded-full text-xs tabular-nums transition-colors",
516
673
  // A grid you can walk with the keyboard needs to show where the
517
674
  // keyboard IS. Inset because the cells sit half a pixel apart and
@@ -537,9 +694,43 @@ export function MiniCalendar({
537
694
  ? "font-medium text-[var(--brand-muted)] hover:bg-[var(--brand-bg-hover)]"
538
695
  : "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
539
696
  isToday && !isStart && !isEnd && !isInRange && !disabled && "ring-1 ring-inset ring-[var(--border-strong)]",
540
- )}
697
+ )
698
+ }
541
699
  >
542
- {dayNumber.format(d.getDate())}
700
+ {lg ? (
701
+ <>
702
+ <span
703
+ className={cn(
704
+ "inline-flex size-6 shrink-0 items-center justify-center self-start rounded-full tabular-nums",
705
+ isStart || isEnd
706
+ ? "bg-[var(--brand)] font-semibold text-[var(--brand-contrast)]"
707
+ : isToday && !disabled
708
+ ? "font-semibold text-[var(--brand)] ring-1 ring-inset ring-[var(--brand)]"
709
+ : isInRange && "font-medium text-[var(--brand-muted)]",
710
+ )}
711
+ >
712
+ {label}
713
+ </span>
714
+ {hasContent && (
715
+ <span id={contentId} className="flex min-w-0 flex-col gap-0.5">
716
+ {content}
717
+ </span>
718
+ )}
719
+ </>
720
+ ) : (
721
+ <>
722
+ {label}
723
+ {/* Under the number, inside the circle: room for a dot or two. */}
724
+ {hasContent && (
725
+ <span
726
+ id={contentId}
727
+ className="pointer-events-none absolute inset-x-0 bottom-0.5 flex justify-center gap-px leading-none"
728
+ >
729
+ {content}
730
+ </span>
731
+ )}
732
+ </>
733
+ )}
543
734
  </button>
544
735
  </div>
545
736
  );
@@ -0,0 +1,145 @@
1
+ import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactElement, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export interface NavPillItem<T extends string> {
5
+ value: T;
6
+ label: ReactNode;
7
+ /** Where the pill leads. With one it is a link (see `renderLink`); without, a button
8
+ * that calls `onSelect`. */
9
+ href?: string;
10
+ /** A Lucide icon (or any component taking a `className`), before the label. */
11
+ icon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
12
+ /** Buttons only — a link cannot be disabled. */
13
+ disabled?: boolean;
14
+ }
15
+
16
+ /** What {@link NavPillsProps.renderLink} is handed. Spread it onto your router's link
17
+ * — `({ href, ...p }) => <Link to={href} {...p} />`. */
18
+ export interface NavPillLinkProps {
19
+ href: string;
20
+ className: string;
21
+ children: ReactNode;
22
+ "aria-current": NavPillsCurrent | undefined;
23
+ onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
24
+ }
25
+
26
+ /** The `aria-current` token the current pill carries. */
27
+ export type NavPillsCurrent = "page" | "step" | "location" | "true";
28
+
29
+ export interface NavPillsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"nav">, "children" | "onSelect"> {
30
+ items: NavPillItem<T>[];
31
+ /** The pill that is current. `null` or a value not in `items`: none is. */
32
+ current: T | null;
33
+ /** Called with the pill's value on a click — of a button, and of a link too (before
34
+ * the router follows it), for a caller that keeps the choice in state as well. */
35
+ onSelect?: (value: T) => void;
36
+ /** Your router's link. Default `<a>`. The API `Breadcrumbs`, `Chip` and `ListItem` share. */
37
+ renderLink?: (props: NavPillLinkProps) => ReactElement;
38
+ /** What `aria-current` says on the current pill. Default `page` where the items
39
+ * are links and `true` where they are buttons (keksdose's swipe-surface picker
40
+ * switches a panel on the same page, which is not a page). */
41
+ currentType?: NavPillsCurrent;
42
+ /** `sm`: 12px pills — the compact size of a {@link ToggleGroup}. Default `md`. */
43
+ size?: "sm" | "md";
44
+ /** Render a `<nav>` landmark (default) or a plain `<div role="group">` for a picker
45
+ * that is not the page's navigation. Name either with `aria-label`. */
46
+ landmark?: boolean;
47
+ /** Classes for every pill. */
48
+ itemClassName?: string;
49
+ }
50
+
51
+ // The pill is a ToggleGroup segment's (`rounded`, `px-3 py-1.5`, the inverse fill for
52
+ // the current one), so a strip of these beside a segmented control reads as the same
53
+ // family. Unlike the segments, a pill never truncates: the strip WRAPS instead.
54
+ const PILL =
55
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-strong)] [&_svg]:size-4 [&_svg]:shrink-0";
56
+ const PILL_SM = "gap-1 px-2 py-1 text-xs [&_svg]:size-3.5";
57
+ const PILL_CURRENT = "bg-[var(--bg-inverse)] text-[var(--text-inverse)]";
58
+ const PILL_IDLE = "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
59
+ const PILL_DISABLED = "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-[var(--text-secondary)]";
60
+
61
+ /**
62
+ * A set of links or buttons that WRAPS onto as many rows as it needs, the current one
63
+ * filled and marked `aria-current`.
64
+ *
65
+ * keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four
66
+ * surface names that do not fit a 406px phone, and a sideways scroll would hide the
67
+ * ones that do not fit behind a gesture nothing announces (live #332: *"Have the tabs
68
+ * in more rows, rather than scrolling sideways"*). It is not {@link Tabs} with `wrap`
69
+ * because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
70
+ * stop and arrow keys, and a set of links to pages (or of buttons that switch the rest
71
+ * of a card) promises none of that. So every pill is its own tab stop, the set is a
72
+ * list (a screen reader hears "list, 4 items"), and "current" is `aria-current` — the
73
+ * attribute a link to where you already are carries.
74
+ */
75
+ export function NavPills<T extends string>({
76
+ items,
77
+ current,
78
+ onSelect,
79
+ renderLink,
80
+ currentType,
81
+ size = "md",
82
+ landmark = true,
83
+ itemClassName,
84
+ className,
85
+ ...rest
86
+ }: NavPillsProps<T>) {
87
+ const Root = landmark ? "nav" : "div";
88
+ return (
89
+ <Root {...rest} role={landmark ? undefined : "group"} className={cn("min-w-0", className)}>
90
+ <ul className="m-0 flex list-none flex-wrap gap-1 p-0">
91
+ {items.map((item) => {
92
+ const isCurrent = item.value === current;
93
+ const Icon = item.icon;
94
+ const pillClass = cn(
95
+ PILL,
96
+ size === "sm" && PILL_SM,
97
+ isCurrent ? PILL_CURRENT : PILL_IDLE,
98
+ item.disabled && item.href === undefined && PILL_DISABLED,
99
+ itemClassName,
100
+ );
101
+ const children = (
102
+ <>
103
+ {Icon && <Icon aria-hidden />}
104
+ {item.label}
105
+ </>
106
+ );
107
+ let pill: ReactNode;
108
+ if (item.href !== undefined) {
109
+ const props: NavPillLinkProps = {
110
+ href: item.href,
111
+ className: pillClass,
112
+ children,
113
+ "aria-current": isCurrent ? (currentType ?? "page") : undefined,
114
+ onClick: () => onSelect?.(item.value),
115
+ };
116
+ pill = renderLink ? (
117
+ renderLink(props)
118
+ ) : (
119
+ <a href={props.href} className={props.className} aria-current={props["aria-current"]} onClick={props.onClick}>
120
+ {props.children}
121
+ </a>
122
+ );
123
+ } else {
124
+ pill = (
125
+ <button
126
+ type="button"
127
+ disabled={item.disabled}
128
+ aria-current={isCurrent ? (currentType ?? "true") : undefined}
129
+ onClick={() => onSelect?.(item.value)}
130
+ className={pillClass}
131
+ >
132
+ {children}
133
+ </button>
134
+ );
135
+ }
136
+ return (
137
+ <li key={item.value} className="flex">
138
+ {pill}
139
+ </li>
140
+ );
141
+ })}
142
+ </ul>
143
+ </Root>
144
+ );
145
+ }