@sixthshift/design-system 0.1.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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/styles.css +2 -0
- package/dist/theme.generated.css +438 -0
- package/package.json +238 -0
- package/src/components/Avatar/Avatar.tsx +19 -0
- package/src/components/Avatar/index.ts +1 -0
- package/src/components/Badge/Badge.tsx +59 -0
- package/src/components/Badge/index.ts +1 -0
- package/src/components/BarChart/BarChart.tsx +51 -0
- package/src/components/BarChart/index.ts +1 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +47 -0
- package/src/components/Breadcrumb/index.ts +5 -0
- package/src/components/Button/Button.tsx +157 -0
- package/src/components/Button/index.ts +1 -0
- package/src/components/Calendar/Calendar.tsx +433 -0
- package/src/components/Calendar/calendar.hooks.ts +329 -0
- package/src/components/Calendar/calendar.types.ts +117 -0
- package/src/components/Calendar/index.ts +28 -0
- package/src/components/Card/Card.tsx +47 -0
- package/src/components/Card/index.ts +1 -0
- package/src/components/Checkbox/Checkbox.tsx +144 -0
- package/src/components/Checkbox/index.ts +1 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +173 -0
- package/src/components/CheckboxGroup/index.ts +5 -0
- package/src/components/Code/Editor/Editor.tsx +161 -0
- package/src/components/Code/Editor/index.ts +2 -0
- package/src/components/Code/Editor/semanticTokenProvider.ts +69 -0
- package/src/components/Code/Editor/themes.ts +112 -0
- package/src/components/Code/ValidationStatus/ValidationStatus.tsx +81 -0
- package/src/components/Code/ValidationStatus/index.ts +1 -0
- package/src/components/Code/Workspace/StatusBar.tsx +47 -0
- package/src/components/Code/Workspace/Toolbar.tsx +47 -0
- package/src/components/Code/Workspace/Workspace.tsx +109 -0
- package/src/components/Code/Workspace/index.ts +3 -0
- package/src/components/ColorDot/ColorDot.tsx +50 -0
- package/src/components/ColorDot/index.ts +1 -0
- package/src/components/DatePicker/DatePicker.tsx +236 -0
- package/src/components/DatePicker/datepicker.hooks.ts +381 -0
- package/src/components/DatePicker/datepicker.types.ts +140 -0
- package/src/components/DatePicker/index.ts +12 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +127 -0
- package/src/components/DateRangePicker/daterangepicker.types.ts +53 -0
- package/src/components/DateRangePicker/index.ts +2 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +399 -0
- package/src/components/DateTimePicker/datetimepicker.types.ts +48 -0
- package/src/components/DateTimePicker/index.ts +2 -0
- package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +679 -0
- package/src/components/DateTimeRangePicker/datetimerangepicker.types.ts +62 -0
- package/src/components/DateTimeRangePicker/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.tsx +29 -0
- package/src/components/EmptyState/index.ts +1 -0
- package/src/components/Field/Field.tsx +60 -0
- package/src/components/Field/index.ts +1 -0
- package/src/components/FormField/FormField.tsx +55 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/HeatMap/HeatMapCalendar.tsx +127 -0
- package/src/components/HeatMap/HeatMapMatrix.tsx +75 -0
- package/src/components/HeatMap/heat-map.utils.ts +15 -0
- package/src/components/HeatMap/index.ts +3 -0
- package/src/components/HoverCard/HoverCard.tsx +83 -0
- package/src/components/HoverCard/components/HoverCardContent.tsx +30 -0
- package/src/components/HoverCard/components/HoverCardContext.tsx +22 -0
- package/src/components/HoverCard/components/HoverCardTrigger.tsx +32 -0
- package/src/components/HoverCard/index.ts +3 -0
- package/src/components/Input/Input.tsx +48 -0
- package/src/components/Input/index.ts +1 -0
- package/src/components/LineChart/LineChart.tsx +251 -0
- package/src/components/LineChart/index.ts +1 -0
- package/src/components/Markdown/Markdown.tsx +40 -0
- package/src/components/Markdown/index.ts +1 -0
- package/src/components/Message/Message.tsx +59 -0
- package/src/components/Message/components/MessageBody.tsx +9 -0
- package/src/components/Message/components/MessageDescription.tsx +9 -0
- package/src/components/Message/components/MessageIcon.tsx +13 -0
- package/src/components/Message/components/MessageTitle.tsx +9 -0
- package/src/components/Message/index.ts +10 -0
- package/src/components/MetricList/MetricList.tsx +13 -0
- package/src/components/MetricList/MetricRow.tsx +30 -0
- package/src/components/MetricList/index.ts +2 -0
- package/src/components/Modal/Modal.tsx +146 -0
- package/src/components/Modal/components/ModalBody.tsx +9 -0
- package/src/components/Modal/components/ModalContext.tsx +27 -0
- package/src/components/Modal/components/ModalFooter.tsx +9 -0
- package/src/components/Modal/components/ModalHeader.tsx +29 -0
- package/src/components/Modal/components/index.ts +9 -0
- package/src/components/Modal/index.ts +13 -0
- package/src/components/NavBottom/NavBottom.tsx +53 -0
- package/src/components/NavBottom/index.ts +3 -0
- package/src/components/NavSide/NavItem.tsx +39 -0
- package/src/components/NavSide/NavSide.tsx +40 -0
- package/src/components/NavSide/index.ts +2 -0
- package/src/components/NavSide/types.ts +36 -0
- package/src/components/Pagination/Pagination.tsx +86 -0
- package/src/components/Pagination/index.ts +2 -0
- package/src/components/Popover/Popover.tsx +68 -0
- package/src/components/Popover/components/PopoverBody.tsx +32 -0
- package/src/components/Popover/components/PopoverClose.tsx +26 -0
- package/src/components/Popover/components/PopoverContext.tsx +24 -0
- package/src/components/Popover/components/PopoverTrigger.tsx +31 -0
- package/src/components/Popover/index.ts +10 -0
- package/src/components/ProgressBar/ProgressBar.tsx +42 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/RadioButton/RadioButton.tsx +89 -0
- package/src/components/RadioButton/index.ts +1 -0
- package/src/components/RadioButtonGroup/RadioButtonGroup.tsx +193 -0
- package/src/components/RadioButtonGroup/index.ts +5 -0
- package/src/components/SearchInput/SearchInput.tsx +45 -0
- package/src/components/SearchInput/index.ts +2 -0
- package/src/components/Select/Select.tsx +289 -0
- package/src/components/Select/SelectDropdown.tsx +90 -0
- package/src/components/Select/SelectTrigger.tsx +130 -0
- package/src/components/Select/index.ts +3 -0
- package/src/components/Select/useSelectKeyboard.ts +111 -0
- package/src/components/Separator/Separator.tsx +20 -0
- package/src/components/Separator/index.ts +1 -0
- package/src/components/Sheet/Sheet.tsx +136 -0
- package/src/components/Sheet/components/SheetBody.tsx +9 -0
- package/src/components/Sheet/components/SheetContext.tsx +23 -0
- package/src/components/Sheet/components/SheetFooter.tsx +9 -0
- package/src/components/Sheet/components/SheetHeader.tsx +29 -0
- package/src/components/Sheet/components/index.ts +9 -0
- package/src/components/Sheet/index.ts +13 -0
- package/src/components/Skeleton/Skeleton.tsx +7 -0
- package/src/components/Skeleton/index.ts +1 -0
- package/src/components/Sparkline/Sparkline.tsx +93 -0
- package/src/components/Sparkline/index.ts +1 -0
- package/src/components/Spinner/Spinner.tsx +41 -0
- package/src/components/Spinner/index.ts +1 -0
- package/src/components/StatsCard/StatsCard.tsx +40 -0
- package/src/components/StatsCard/index.ts +1 -0
- package/src/components/Switch/Switch.tsx +128 -0
- package/src/components/Switch/index.ts +1 -0
- package/src/components/Tabs/Tabs.tsx +49 -0
- package/src/components/Tabs/components/TabsContext.tsx +35 -0
- package/src/components/Tabs/components/TabsList.tsx +98 -0
- package/src/components/Tabs/components/TabsPanels.tsx +26 -0
- package/src/components/Tabs/components/TabsTrigger.tsx +69 -0
- package/src/components/Tabs/index.ts +3 -0
- package/src/components/TagChip/TagChip.tsx +46 -0
- package/src/components/TagChip/index.ts +1 -0
- package/src/components/TagInput/TagInput.tsx +60 -0
- package/src/components/TagInput/index.ts +1 -0
- package/src/components/Text/Text.tsx +35 -0
- package/src/components/Text/index.ts +1 -0
- package/src/components/TextInline/TextInline.tsx +35 -0
- package/src/components/TextInline/index.ts +1 -0
- package/src/components/Textarea/Textarea.tsx +20 -0
- package/src/components/Textarea/index.ts +1 -0
- package/src/components/TimePicker/PeriodSelector.tsx +28 -0
- package/src/components/TimePicker/TimeColumn.tsx +62 -0
- package/src/components/TimePicker/TimePicker.tsx +323 -0
- package/src/components/TimePicker/index.ts +7 -0
- package/src/components/TimePicker/timepicker.hooks.ts +173 -0
- package/src/components/TimePicker/timepicker.types.ts +101 -0
- package/src/components/Toast/Toast.tsx +111 -0
- package/src/components/Toast/index.ts +1 -0
- package/src/components/Toggle/Toggle.tsx +72 -0
- package/src/components/Toggle/index.ts +1 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +14 -0
- package/src/components/ToggleGroup/ToggleGroupContainer.tsx +19 -0
- package/src/components/ToggleGroup/ToggleGroupItem.tsx +51 -0
- package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +61 -0
- package/src/components/ToggleGroup/ToggleGroupSingle.tsx +105 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/ToggleGroup/toggleGroup.types.ts +55 -0
- package/src/components/Tooltip/Tooltip.tsx +66 -0
- package/src/components/Tooltip/components/TooltipBody.tsx +30 -0
- package/src/components/Tooltip/components/TooltipContext.tsx +22 -0
- package/src/components/Tooltip/components/TooltipTrigger.tsx +27 -0
- package/src/components/Tooltip/index.ts +9 -0
- package/src/date-time/arithmetic.ts +101 -0
- package/src/date-time/boundaries.ts +91 -0
- package/src/date-time/compare.ts +85 -0
- package/src/date-time/duration.ts +63 -0
- package/src/date-time/elapsed.ts +18 -0
- package/src/date-time/format.ts +722 -0
- package/src/date-time/index.ts +279 -0
- package/src/date-time/now.ts +70 -0
- package/src/date-time/parse.ts +170 -0
- package/src/date-time/serialize.ts +90 -0
- package/src/date-time/timezone.ts +47 -0
- package/src/date-time/validate.ts +278 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useCollapsible.ts +16 -0
- package/src/hooks/useControllableState.ts +54 -0
- package/src/hooks/useDebouncedCallback.ts +21 -0
- package/src/hooks/useDebouncedState.ts +20 -0
- package/src/hooks/useDeviceFormFactor.ts +49 -0
- package/src/hooks/useLocalStorage.ts +67 -0
- package/src/hooks/useMergedFloatingRef.ts +29 -0
- package/src/hooks/usePresence.ts +127 -0
- package/src/hooks/useStack.ts +99 -0
- package/src/hooks/useTheme.ts +105 -0
- package/src/internal/Slot.tsx +79 -0
- package/src/internal/index.ts +2 -0
- package/src/internal/rovingFocus.ts +39 -0
- package/src/internal/types.ts +10 -0
- package/src/lib/EmptyBoundary.tsx +12 -0
- package/src/lib/ErrorBoundary.tsx +48 -0
- package/src/lib/contexts/ComponentsContext/ComponentsContext.tsx +34 -0
- package/src/lib/contexts/ComponentsContext/index.ts +1 -0
- package/src/lib/contexts/OverlayContext/OverlayContext.tsx +93 -0
- package/src/lib/contexts/OverlayContext/hooks/useModal.tsx +125 -0
- package/src/lib/contexts/OverlayContext/hooks/useToast.ts +80 -0
- package/src/lib/contexts/OverlayContext/index.ts +6 -0
- package/src/lib/format.ts +51 -0
- package/src/lib/utils.ts +10 -0
- package/src/lib/withEmpty.tsx +14 -0
- package/src/lib/withErrorBoundary.tsx +16 -0
- package/src/lib/withSuspense.tsx +16 -0
- package/src/lib/withSuspenseAndErrorBoundary.tsx +26 -0
- package/src/styles/base.css +41 -0
- package/src/theme/index.ts +37 -0
- package/src/theme/palette.json +100 -0
- package/src/theme/palette.schema.json +40 -0
- package/src/theme/schema.ts +122 -0
- package/src/theme/theme.json +349 -0
- package/src/theme/theme.schema.json +876 -0
- package/src/theme/typography.json +7 -0
- package/src/theme/typography.schema.json +36 -0
- package/src/typography/Body.tsx +8 -0
- package/src/typography/Caption.tsx +8 -0
- package/src/typography/Code.tsx +8 -0
- package/src/typography/Display.tsx +8 -0
- package/src/typography/Emphasis.tsx +8 -0
- package/src/typography/Heading.tsx +23 -0
- package/src/typography/Label.tsx +10 -0
- package/src/typography/Lead.tsx +8 -0
- package/src/typography/Mono.tsx +8 -0
- package/src/typography/Muted.tsx +8 -0
- package/src/typography/SectionTitle.tsx +8 -0
- package/src/typography/Subtitle.tsx +8 -0
- package/src/typography/Timestamp.tsx +8 -0
- package/src/typography/index.ts +10 -0
- package/tailwind.config.ts +261 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { cn } from "@sixthshift/design-system/utils";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
addDays,
|
|
6
|
+
addMonths,
|
|
7
|
+
endOfWeek,
|
|
8
|
+
formatMonthYear,
|
|
9
|
+
isPlainDate,
|
|
10
|
+
isSameMonth,
|
|
11
|
+
parseDate,
|
|
12
|
+
startOfMonth,
|
|
13
|
+
startOfWeek,
|
|
14
|
+
type Temporal,
|
|
15
|
+
today,
|
|
16
|
+
} from "../../date-time";
|
|
17
|
+
import { Button } from "../Button";
|
|
18
|
+
import { Separator } from "../Separator";
|
|
19
|
+
import {
|
|
20
|
+
defaultFormatDisplay,
|
|
21
|
+
getDateSelectionState,
|
|
22
|
+
isBeforeTemporal,
|
|
23
|
+
isDateDisabled,
|
|
24
|
+
isSameDateTemporal,
|
|
25
|
+
temporalToISO,
|
|
26
|
+
useCalendarDays,
|
|
27
|
+
} from "./calendar.hooks";
|
|
28
|
+
import type { CalendarProps, DateRangeValue, DisabledDateMatcher, PresetOption, RangePosition } from "./calendar.types";
|
|
29
|
+
|
|
30
|
+
export type { CalendarProps };
|
|
31
|
+
|
|
32
|
+
const DAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
|
|
33
|
+
|
|
34
|
+
function getDayLabels(weekStartsOn: number): string[] {
|
|
35
|
+
return [...DAY_LABELS.slice(weekStartsOn), ...DAY_LABELS.slice(0, weekStartsOn)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getRangePositionClasses(rangePosition: RangePosition, isSelected: boolean): string {
|
|
39
|
+
if (!isSelected) return "";
|
|
40
|
+
|
|
41
|
+
switch (rangePosition) {
|
|
42
|
+
case "start":
|
|
43
|
+
return "rounded-l-full rounded-r-none bg-bg-brand text-fg-on-brand";
|
|
44
|
+
case "end":
|
|
45
|
+
return "rounded-r-full rounded-l-none bg-bg-brand text-fg-on-brand";
|
|
46
|
+
case "middle":
|
|
47
|
+
return "rounded-none bg-bg-brand-subtle text-fg-normal";
|
|
48
|
+
case "single":
|
|
49
|
+
return "bg-bg-brand text-fg-on-brand";
|
|
50
|
+
default:
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const Calendar = (props: CalendarProps) => {
|
|
56
|
+
const {
|
|
57
|
+
mode,
|
|
58
|
+
month,
|
|
59
|
+
onMonthChange,
|
|
60
|
+
minDate,
|
|
61
|
+
maxDate,
|
|
62
|
+
disabled,
|
|
63
|
+
weekStartsOn = 0,
|
|
64
|
+
showFooter = false,
|
|
65
|
+
showToday = true,
|
|
66
|
+
onApply,
|
|
67
|
+
onCancel,
|
|
68
|
+
className,
|
|
69
|
+
} = props;
|
|
70
|
+
|
|
71
|
+
// Get mode-specific props
|
|
72
|
+
const value = "value" in props ? props.value : undefined;
|
|
73
|
+
const onSelect = "onSelect" in props ? props.onSelect : undefined;
|
|
74
|
+
const presets = "presets" in props ? props.presets : undefined;
|
|
75
|
+
const max = mode === "multiple" && "max" in props ? props.max : undefined;
|
|
76
|
+
|
|
77
|
+
// Selection handler
|
|
78
|
+
const handleSelect = useCallback(
|
|
79
|
+
(temporalDate: Temporal.PlainDate) => {
|
|
80
|
+
if (!onSelect) return;
|
|
81
|
+
|
|
82
|
+
if (mode === "single") {
|
|
83
|
+
const currentValue = value as Temporal.PlainDate | undefined;
|
|
84
|
+
if (currentValue && isSameDateTemporal(temporalDate, currentValue)) {
|
|
85
|
+
(onSelect as (date: Temporal.PlainDate | undefined) => void)(undefined);
|
|
86
|
+
} else {
|
|
87
|
+
(onSelect as (date: Temporal.PlainDate | undefined) => void)(temporalDate);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (mode === "multiple") {
|
|
93
|
+
const currentValue = (value as Temporal.PlainDate[]) || [];
|
|
94
|
+
const existingIndex = currentValue.findIndex((d) => isSameDateTemporal(temporalDate, d));
|
|
95
|
+
|
|
96
|
+
if (existingIndex >= 0) {
|
|
97
|
+
(onSelect as (dates: Temporal.PlainDate[]) => void)(currentValue.filter((_, i) => i !== existingIndex));
|
|
98
|
+
} else if (!max || currentValue.length < max) {
|
|
99
|
+
(onSelect as (dates: Temporal.PlainDate[]) => void)([...currentValue, temporalDate]);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Range mode
|
|
105
|
+
const currentRange = (value as DateRangeValue) || {
|
|
106
|
+
from: undefined,
|
|
107
|
+
to: undefined,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (!currentRange.from) {
|
|
111
|
+
(onSelect as (range: DateRangeValue | undefined) => void)({ from: temporalDate, to: undefined });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!currentRange.to) {
|
|
116
|
+
if (isBeforeTemporal(temporalDate, currentRange.from)) {
|
|
117
|
+
(onSelect as (range: DateRangeValue | undefined) => void)({ from: temporalDate, to: currentRange.from });
|
|
118
|
+
} else {
|
|
119
|
+
(onSelect as (range: DateRangeValue | undefined) => void)({ from: currentRange.from, to: temporalDate });
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
(onSelect as (range: DateRangeValue | undefined) => void)({ from: temporalDate, to: undefined });
|
|
125
|
+
},
|
|
126
|
+
[mode, value, onSelect, max]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Today button
|
|
130
|
+
const handleToday = useCallback(() => {
|
|
131
|
+
if (!onSelect) return;
|
|
132
|
+
|
|
133
|
+
const todayDate = today();
|
|
134
|
+
if (mode === "single") {
|
|
135
|
+
(onSelect as (date: Temporal.PlainDate | undefined) => void)(todayDate);
|
|
136
|
+
} else if (mode === "multiple") {
|
|
137
|
+
const current = (value as Temporal.PlainDate[]) || [];
|
|
138
|
+
if (!current.some((d) => isSameDateTemporal(d, todayDate))) {
|
|
139
|
+
if (!max || current.length < max) {
|
|
140
|
+
(onSelect as (dates: Temporal.PlainDate[]) => void)([...current, todayDate]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
onMonthChange(todayDate);
|
|
145
|
+
}, [mode, value, onSelect, max, onMonthChange]);
|
|
146
|
+
|
|
147
|
+
// Preset selection
|
|
148
|
+
const handlePresetClick = useCallback(
|
|
149
|
+
(preset: PresetOption<unknown>) => {
|
|
150
|
+
if (!onSelect) return;
|
|
151
|
+
|
|
152
|
+
if (mode === "single" && isPlainDate(preset.value)) {
|
|
153
|
+
(onSelect as (date: Temporal.PlainDate | undefined) => void)(preset.value);
|
|
154
|
+
} else if (mode === "range" && typeof preset.value === "object" && preset.value !== null && "from" in preset.value) {
|
|
155
|
+
(onSelect as (range: DateRangeValue | undefined) => void)(preset.value as DateRangeValue);
|
|
156
|
+
} else if (mode === "multiple" && Array.isArray(preset.value)) {
|
|
157
|
+
(onSelect as (dates: Temporal.PlainDate[]) => void)(preset.value as Temporal.PlainDate[]);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[mode, onSelect]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Calendar navigation
|
|
164
|
+
const handlePrevMonth = () => onMonthChange(addMonths(month, -1));
|
|
165
|
+
const handleNextMonth = () => onMonthChange(addMonths(month, 1));
|
|
166
|
+
|
|
167
|
+
const days = useCalendarDays(month, weekStartsOn as 0 | 1 | 2 | 3 | 4 | 5 | 6);
|
|
168
|
+
const dayLabels = getDayLabels(weekStartsOn);
|
|
169
|
+
|
|
170
|
+
const handleDayClick = (date: Temporal.PlainDate) => {
|
|
171
|
+
if (!isDateDisabled(date, disabled, minDate, maxDate)) {
|
|
172
|
+
handleSelect(date);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Roving focus
|
|
178
|
+
//
|
|
179
|
+
// A grid is a single tab stop: exactly one cell carries tabIndex={0} and the
|
|
180
|
+
// arrow keys move between cells. `focusedDate` is null until the user actually
|
|
181
|
+
// navigates, so we never steal focus on mount.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
const [focusedDate, setFocusedDate] = useState<Temporal.PlainDate | null>(null);
|
|
184
|
+
const gridRef = useRef<HTMLTableElement>(null);
|
|
185
|
+
|
|
186
|
+
const firstSelected = useMemo((): Temporal.PlainDate | undefined => {
|
|
187
|
+
if (mode === "single") return isPlainDate(value) ? value : undefined;
|
|
188
|
+
if (mode === "multiple") return (value as Temporal.PlainDate[] | undefined)?.[0];
|
|
189
|
+
return (value as DateRangeValue | undefined)?.from;
|
|
190
|
+
}, [mode, value]);
|
|
191
|
+
|
|
192
|
+
const activeDate = useMemo(() => {
|
|
193
|
+
const isUsable = (date: Temporal.PlainDate) =>
|
|
194
|
+
isSameMonth(date, month) && !isDateDisabled(date, disabled as DisabledDateMatcher | DisabledDateMatcher[] | undefined, minDate, maxDate);
|
|
195
|
+
|
|
196
|
+
// Prefer where the user last was, then their selection, then today.
|
|
197
|
+
for (const candidate of [focusedDate, firstSelected, today()]) {
|
|
198
|
+
if (candidate && isUsable(candidate)) return candidate;
|
|
199
|
+
}
|
|
200
|
+
// Otherwise the first day of the month that can actually take focus, so the
|
|
201
|
+
// grid never ends up with zero tab stops.
|
|
202
|
+
const first = startOfMonth(month);
|
|
203
|
+
for (let i = 0; i < first.daysInMonth; i++) {
|
|
204
|
+
const candidate = addDays(first, i);
|
|
205
|
+
if (isUsable(candidate)) return candidate;
|
|
206
|
+
}
|
|
207
|
+
return first;
|
|
208
|
+
}, [focusedDate, firstSelected, month, disabled, minDate, maxDate]);
|
|
209
|
+
|
|
210
|
+
const moveFocus = useCallback(
|
|
211
|
+
(next: Temporal.PlainDate) => {
|
|
212
|
+
setFocusedDate(next);
|
|
213
|
+
if (!isSameMonth(next, month)) onMonthChange(next);
|
|
214
|
+
},
|
|
215
|
+
[month, onMonthChange]
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const handleGridKeyDown = useCallback(
|
|
219
|
+
(event: React.KeyboardEvent<HTMLTableElement>) => {
|
|
220
|
+
// Navigate from the cell that actually has focus, not from the derived
|
|
221
|
+
// tab stop — otherwise focusing a day without selecting it would make the
|
|
222
|
+
// arrow keys jump back to today.
|
|
223
|
+
const focusedIso = (event.target as HTMLElement | null)?.closest?.("[data-date]")?.getAttribute("data-date");
|
|
224
|
+
const origin = focusedIso ? parseDate(focusedIso) : activeDate;
|
|
225
|
+
|
|
226
|
+
const dayStep: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
|
|
227
|
+
const step = dayStep[event.key];
|
|
228
|
+
|
|
229
|
+
if (step !== undefined) {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
moveFocus(addDays(origin, step));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch (event.key) {
|
|
236
|
+
case "Home":
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
moveFocus(startOfWeek(origin, weekStartsOn));
|
|
239
|
+
break;
|
|
240
|
+
case "End":
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
moveFocus(endOfWeek(origin, weekStartsOn));
|
|
243
|
+
break;
|
|
244
|
+
case "PageUp":
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
moveFocus(addMonths(origin, -1));
|
|
247
|
+
break;
|
|
248
|
+
case "PageDown":
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
moveFocus(addMonths(origin, 1));
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
[activeDate, moveFocus, weekStartsOn]
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Follow the roving tabIndex with real DOM focus, but only once the user has
|
|
260
|
+
// started navigating.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!focusedDate) return;
|
|
263
|
+
gridRef.current?.querySelector<HTMLButtonElement>(`[data-date="${temporalToISO(focusedDate)}"]`)?.focus();
|
|
264
|
+
}, [focusedDate]);
|
|
265
|
+
|
|
266
|
+
// useCalendarDays always returns 42 days, so this is always 6 rows of 7.
|
|
267
|
+
const weeks = useMemo(() => {
|
|
268
|
+
const chunked: (typeof days)[] = [];
|
|
269
|
+
for (let i = 0; i < days.length; i += 7) chunked.push(days.slice(i, i + 7));
|
|
270
|
+
return chunked;
|
|
271
|
+
}, [days]);
|
|
272
|
+
|
|
273
|
+
// Check if a preset is currently active
|
|
274
|
+
const isPresetActive = useCallback(
|
|
275
|
+
(preset: PresetOption<unknown>): boolean => {
|
|
276
|
+
if (mode === "single" && isPlainDate(preset.value)) {
|
|
277
|
+
return isPlainDate(value) && isSameDateTemporal(value, preset.value);
|
|
278
|
+
}
|
|
279
|
+
if (mode === "range" && typeof preset.value === "object" && preset.value !== null && "from" in preset.value) {
|
|
280
|
+
const draft = value as DateRangeValue | undefined;
|
|
281
|
+
const presetVal = preset.value as DateRangeValue;
|
|
282
|
+
const fromMatch = (!draft?.from && !presetVal.from) || (draft?.from && presetVal.from && isSameDateTemporal(draft.from, presetVal.from));
|
|
283
|
+
const toMatch = (!draft?.to && !presetVal.to) || (draft?.to && presetVal.to && isSameDateTemporal(draft.to, presetVal.to));
|
|
284
|
+
return Boolean(fromMatch && toMatch);
|
|
285
|
+
}
|
|
286
|
+
if (mode === "multiple" && Array.isArray(preset.value)) {
|
|
287
|
+
const draft = (value as Temporal.PlainDate[]) || [];
|
|
288
|
+
const presetVal = preset.value as Temporal.PlainDate[];
|
|
289
|
+
return draft.length === presetVal.length && draft.every((d) => presetVal.some((p) => isSameDateTemporal(d, p)));
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
},
|
|
293
|
+
[mode, value]
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div className={cn("flex", className)}>
|
|
298
|
+
{/* Presets Sidebar */}
|
|
299
|
+
{presets && presets.length > 0 && (
|
|
300
|
+
<div className="mr-3 flex flex-col gap-1 border-border-normal border-r pr-3">
|
|
301
|
+
{presets.map((preset) => (
|
|
302
|
+
<button
|
|
303
|
+
key={preset.label}
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={() => handlePresetClick(preset)}
|
|
306
|
+
className={cn(
|
|
307
|
+
`rounded-md px-3 py-1.5 text-left text-sm transition-colors`,
|
|
308
|
+
`hover:bg-bg-subtle focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset`,
|
|
309
|
+
isPresetActive(preset) && "bg-bg-brand-subtle text-fg-brand"
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
{preset.label}
|
|
313
|
+
</button>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{/* Calendar */}
|
|
319
|
+
<div className="flex flex-col gap-3">
|
|
320
|
+
{/* Header with navigation */}
|
|
321
|
+
<div className="flex items-center justify-between">
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
onClick={handlePrevMonth}
|
|
325
|
+
className="rounded-lg p-2 text-fg-subtle hover:bg-bg-subtle hover:text-fg-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
326
|
+
aria-label="Previous month"
|
|
327
|
+
>
|
|
328
|
+
<ChevronLeft className="h-5 w-5" />
|
|
329
|
+
</button>
|
|
330
|
+
|
|
331
|
+
<span className="font-semibold text-sm">{formatMonthYear(month)}</span>
|
|
332
|
+
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
onClick={handleNextMonth}
|
|
336
|
+
className="rounded-lg p-2 text-fg-subtle hover:bg-bg-subtle hover:text-fg-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
337
|
+
aria-label="Next month"
|
|
338
|
+
>
|
|
339
|
+
<ChevronRight className="h-5 w-5" />
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Day grid — the ARIA grid pattern on real table semantics, so <tr>/<th>/<td>
|
|
344
|
+
supply row/columnheader/gridcell roles implicitly. One tab stop, arrow-key
|
|
345
|
+
navigation, and aria-selected on the cell (where it is valid) rather than
|
|
346
|
+
on the button (where it is not). */}
|
|
347
|
+
{/* biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: the ARIA grid
|
|
348
|
+
pattern is defined on <table>; a date picker is an interactive composite widget,
|
|
349
|
+
not a static table. Unlike the suppression this replaced, nothing here is being
|
|
350
|
+
hidden — the axe checks in the story tests verify the resulting ARIA for real. */}
|
|
351
|
+
<table ref={gridRef} role="grid" aria-label={formatMonthYear(month)} className="border-separate border-spacing-1" onKeyDown={handleGridKeyDown}>
|
|
352
|
+
<thead>
|
|
353
|
+
<tr>
|
|
354
|
+
{dayLabels.map((label) => (
|
|
355
|
+
<th key={label} scope="col" className="h-10 w-10 font-medium text-fg-subtle text-sm">
|
|
356
|
+
{label}
|
|
357
|
+
</th>
|
|
358
|
+
))}
|
|
359
|
+
</tr>
|
|
360
|
+
</thead>
|
|
361
|
+
<tbody>
|
|
362
|
+
{weeks.map((week) => (
|
|
363
|
+
<tr key={temporalToISO(week[0]!.date)}>
|
|
364
|
+
{week.map((day) => {
|
|
365
|
+
const isDisabledDay = isDateDisabled(day.date, disabled as DisabledDateMatcher | DisabledDateMatcher[] | undefined, minDate, maxDate);
|
|
366
|
+
const selectionState = getDateSelectionState(day.date, mode, value);
|
|
367
|
+
|
|
368
|
+
if (!day.isCurrentMonth) {
|
|
369
|
+
return <td key={temporalToISO(day.date)} className="h-10 w-10" />;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
// Biome resolves <td> to role "cell" because it does not track the
|
|
374
|
+
// ancestor role="grid", under which a <td> is a gridcell and
|
|
375
|
+
// aria-selected is valid. axe, which does track it, passes.
|
|
376
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: <td> in a grid is a gridcell
|
|
377
|
+
<td key={temporalToISO(day.date)} aria-selected={selectionState.isSelected} className="p-0">
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
data-date={temporalToISO(day.date)}
|
|
381
|
+
onClick={() => handleDayClick(day.date)}
|
|
382
|
+
disabled={isDisabledDay}
|
|
383
|
+
tabIndex={isSameDateTemporal(day.date, activeDate) ? 0 : -1}
|
|
384
|
+
className={cn(
|
|
385
|
+
`flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-sm transition-colors`,
|
|
386
|
+
`hover:bg-bg-brand-strong hover:text-fg-on-brand-strong focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset`,
|
|
387
|
+
day.isToday && !selectionState.isSelected && "font-medium ring-1 ring-border-normal ring-inset",
|
|
388
|
+
isDisabledDay && "cursor-not-allowed opacity-50",
|
|
389
|
+
getRangePositionClasses(selectionState.rangePosition, selectionState.isSelected)
|
|
390
|
+
)}
|
|
391
|
+
aria-label={defaultFormatDisplay(day.date)}
|
|
392
|
+
>
|
|
393
|
+
{day.date.day}
|
|
394
|
+
</button>
|
|
395
|
+
</td>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</tr>
|
|
399
|
+
))}
|
|
400
|
+
</tbody>
|
|
401
|
+
</table>
|
|
402
|
+
|
|
403
|
+
{/* Footer */}
|
|
404
|
+
{showFooter && (
|
|
405
|
+
<>
|
|
406
|
+
<Separator />
|
|
407
|
+
<div className="flex items-center justify-between">
|
|
408
|
+
<div>
|
|
409
|
+
{showToday && mode !== "range" && (
|
|
410
|
+
<Button type="button" variant="ghost" size="sm" onClick={handleToday}>
|
|
411
|
+
Today
|
|
412
|
+
</Button>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
<div className="flex gap-2">
|
|
416
|
+
{onCancel && (
|
|
417
|
+
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
|
418
|
+
Cancel
|
|
419
|
+
</Button>
|
|
420
|
+
)}
|
|
421
|
+
{onApply && (
|
|
422
|
+
<Button type="button" variant="solid" size="sm" onClick={onApply}>
|
|
423
|
+
Apply
|
|
424
|
+
</Button>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
};
|