@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.
- package/README.md +20 -16
- package/dist/components/alert-banner.d.ts +26 -1
- package/dist/components/alert-banner.js +10 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -0
- package/dist/components/bulk-action-bar.d.ts +51 -5
- package/dist/components/bulk-action-bar.js +88 -41
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +58 -3
- package/dist/components/button-group.js +56 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +2 -0
- package/dist/components/calendar-heatmap.d.ts +124 -0
- package/dist/components/calendar-heatmap.js +295 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/choice-card.d.ts +34 -2
- package/dist/components/choice-card.js +49 -0
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -0
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/field.d.ts +43 -0
- package/dist/components/field.js +52 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +1 -0
- package/dist/components/file-dropzone.d.ts +1 -0
- package/dist/components/floating-panel.d.ts +147 -4
- package/dist/components/floating-panel.js +188 -28
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/list.d.ts +14 -1
- package/dist/components/list.js +7 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/menu-item.d.ts +11 -1
- package/dist/components/menu-item.js +6 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +70 -5
- package/dist/components/mini-calendar.js +166 -62
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/nav-pills.d.ts +66 -0
- package/dist/components/nav-pills.js +65 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +2 -0
- package/dist/components/number-input.d.ts +2 -0
- package/dist/components/numpad-sheet.d.ts +2 -0
- package/dist/components/progress-bar.d.ts +31 -2
- package/dist/components/progress-bar.js +73 -7
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -0
- package/dist/components/status-dot.d.ts +6 -2
- package/dist/components/status-dot.js +6 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/table.d.ts +40 -8
- package/dist/components/table.js +41 -7
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +8 -1
- package/dist/components/text.js +1 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -0
- package/dist/components/toggle-group.d.ts +15 -0
- package/dist/components/toggle-group.js +23 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +11 -3
- package/dist/components/tooltip.js +2 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +55 -4
- package/dist/components/ui.js +84 -7
- package/dist/components/ui.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -0
- package/dist/i18n/defaults.d.ts +2 -0
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
- package/dist/i18n/locales/de-CH.d.ts +2 -0
- package/dist/i18n/locales/de-informal.d.ts +2 -0
- package/dist/i18n/locales/de.d.ts +2 -0
- package/dist/i18n/locales/de.js +9 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -0
- package/dist/i18n/locales/es.js +9 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -0
- package/dist/i18n/locales/fr.js +9 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -0
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -0
- package/dist/i18n/locales/it.js +9 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -0
- package/dist/i18n/locales/zh.js +9 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +14 -10
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +21 -5
- package/dist/lib/clipping.js +3 -0
- package/dist/lib/clipping.js.map +1 -1
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/rhf.js +1 -0
- package/dist/rhf.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +73 -3
- package/dist/shell/topbar-action-menu.js +100 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +1 -0
- package/dist/wizard/wizard-context.d.ts +7 -1
- package/dist/wizard/wizard-context.js +4 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +36 -7
- package/src/components/bulk-action-bar.tsx +163 -57
- package/src/components/button-group.tsx +124 -4
- package/src/components/calendar-heatmap.tsx +504 -0
- package/src/components/choice-card.tsx +97 -1
- package/src/components/data-table.tsx +7 -0
- package/src/components/field.tsx +137 -0
- package/src/components/floating-panel.tsx +366 -15
- package/src/components/list.tsx +28 -2
- package/src/components/menu-item.tsx +22 -1
- package/src/components/mini-calendar.tsx +245 -54
- package/src/components/nav-pills.tsx +145 -0
- package/src/components/progress-bar.tsx +129 -11
- package/src/components/series-chart.tsx +21 -0
- package/src/components/status-dot.tsx +10 -2
- package/src/components/table.tsx +85 -11
- package/src/components/text.tsx +9 -1
- package/src/components/toggle-group.tsx +48 -3
- package/src/components/tooltip.tsx +17 -3
- package/src/components/ui.tsx +149 -11
- package/src/components/user-avatar.tsx +57 -3
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -0
- package/src/i18n/locales/de.ts +9 -0
- package/src/i18n/locales/es.ts +9 -0
- package/src/i18n/locales/fr.ts +9 -0
- package/src/i18n/locales/hu.ts +9 -0
- package/src/i18n/locales/it.ts +9 -0
- package/src/i18n/locales/zh.ts +9 -0
- package/src/index.ts +5 -0
- package/src/lib/clipping.ts +22 -4
- package/src/lib/dates.ts +37 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +2 -0
- package/src/shell/topbar-action-menu.tsx +239 -49
- 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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
460
|
-
{
|
|
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)
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
+
}
|