@c9up/nebula 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 +256 -0
- package/dist/adapters/css.d.ts +33 -0
- package/dist/adapters/css.js +67 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/tailwind.d.ts +25 -0
- package/dist/adapters/tailwind.js +122 -0
- package/dist/adapters/types.d.ts +49 -0
- package/dist/adapters/types.js +21 -0
- package/dist/adapters/unocss.d.ts +17 -0
- package/dist/adapters/unocss.js +107 -0
- package/dist/atoms/AspectRatio.d.ts +17 -0
- package/dist/atoms/AspectRatio.js +19 -0
- package/dist/atoms/Avatar.d.ts +23 -0
- package/dist/atoms/Avatar.js +46 -0
- package/dist/atoms/Badge.d.ts +25 -0
- package/dist/atoms/Badge.js +28 -0
- package/dist/atoms/Button.d.ts +58 -0
- package/dist/atoms/Button.js +67 -0
- package/dist/atoms/Checkbox.d.ts +41 -0
- package/dist/atoms/Checkbox.js +47 -0
- package/dist/atoms/Input.d.ts +36 -0
- package/dist/atoms/Input.js +50 -0
- package/dist/atoms/Kbd.d.ts +14 -0
- package/dist/atoms/Kbd.js +17 -0
- package/dist/atoms/Label.d.ts +24 -0
- package/dist/atoms/Label.js +26 -0
- package/dist/atoms/Marker.d.ts +42 -0
- package/dist/atoms/Marker.js +55 -0
- package/dist/atoms/NativeSelect.d.ts +43 -0
- package/dist/atoms/NativeSelect.js +81 -0
- package/dist/atoms/Progress.d.ts +21 -0
- package/dist/atoms/Progress.js +48 -0
- package/dist/atoms/ScrollArea.d.ts +28 -0
- package/dist/atoms/ScrollArea.js +37 -0
- package/dist/atoms/Separator.d.ts +16 -0
- package/dist/atoms/Separator.js +21 -0
- package/dist/atoms/Skeleton.d.ts +13 -0
- package/dist/atoms/Skeleton.js +18 -0
- package/dist/atoms/Slider.d.ts +30 -0
- package/dist/atoms/Slider.js +40 -0
- package/dist/atoms/Spinner.d.ts +17 -0
- package/dist/atoms/Spinner.js +20 -0
- package/dist/atoms/Switch.d.ts +24 -0
- package/dist/atoms/Switch.js +41 -0
- package/dist/atoms/Textarea.d.ts +28 -0
- package/dist/atoms/Textarea.js +36 -0
- package/dist/atoms/Toggle.d.ts +38 -0
- package/dist/atoms/Toggle.js +45 -0
- package/dist/atoms/index.d.ts +32 -0
- package/dist/atoms/index.js +32 -0
- package/dist/cli/add.d.ts +44 -0
- package/dist/cli/add.js +99 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.js +83 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.js +43 -0
- package/dist/configure.d.ts +34 -0
- package/dist/configure.js +85 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/lib/children.d.ts +28 -0
- package/dist/lib/children.js +27 -0
- package/dist/lib/cn.d.ts +13 -0
- package/dist/lib/cn.js +13 -0
- package/dist/lib/cva.d.ts +83 -0
- package/dist/lib/cva.js +178 -0
- package/dist/lib/icons.d.ts +44 -0
- package/dist/lib/icons.js +79 -0
- package/dist/lib/id.d.ts +31 -0
- package/dist/lib/id.js +41 -0
- package/dist/lib/index.d.ts +12 -0
- package/dist/lib/index.js +12 -0
- package/dist/lib/motion.d.ts +31 -0
- package/dist/lib/motion.js +39 -0
- package/dist/lib/props.d.ts +48 -0
- package/dist/lib/props.js +59 -0
- package/dist/lib/styled.d.ts +31 -0
- package/dist/lib/styled.js +39 -0
- package/dist/molecules/Accordion.d.ts +35 -0
- package/dist/molecules/Accordion.js +117 -0
- package/dist/molecules/Alert.d.ts +30 -0
- package/dist/molecules/Alert.js +35 -0
- package/dist/molecules/Attachment.d.ts +39 -0
- package/dist/molecules/Attachment.js +103 -0
- package/dist/molecules/Breadcrumb.d.ts +29 -0
- package/dist/molecules/Breadcrumb.js +73 -0
- package/dist/molecules/Bubble.d.ts +56 -0
- package/dist/molecules/Bubble.js +92 -0
- package/dist/molecules/ButtonGroup.d.ts +23 -0
- package/dist/molecules/ButtonGroup.js +30 -0
- package/dist/molecules/Card.d.ts +17 -0
- package/dist/molecules/Card.js +17 -0
- package/dist/molecules/Collapsible.d.ts +40 -0
- package/dist/molecules/Collapsible.js +71 -0
- package/dist/molecules/Empty.d.ts +16 -0
- package/dist/molecules/Empty.js +16 -0
- package/dist/molecules/Field.d.ts +54 -0
- package/dist/molecules/Field.js +82 -0
- package/dist/molecules/InputGroup.d.ts +33 -0
- package/dist/molecules/InputGroup.js +35 -0
- package/dist/molecules/InputOTP.d.ts +33 -0
- package/dist/molecules/InputOTP.js +140 -0
- package/dist/molecules/Item.d.ts +41 -0
- package/dist/molecules/Item.js +48 -0
- package/dist/molecules/Message.d.ts +43 -0
- package/dist/molecules/Message.js +82 -0
- package/dist/molecules/Pagination.d.ts +41 -0
- package/dist/molecules/Pagination.js +144 -0
- package/dist/molecules/RadioGroup.d.ts +32 -0
- package/dist/molecules/RadioGroup.js +65 -0
- package/dist/molecules/Resizable.d.ts +37 -0
- package/dist/molecules/Resizable.js +136 -0
- package/dist/molecules/Table.d.ts +37 -0
- package/dist/molecules/Table.js +78 -0
- package/dist/molecules/Tabs.d.ts +37 -0
- package/dist/molecules/Tabs.js +109 -0
- package/dist/molecules/ToggleGroup.d.ts +37 -0
- package/dist/molecules/ToggleGroup.js +68 -0
- package/dist/molecules/Typography.d.ts +26 -0
- package/dist/molecules/Typography.js +67 -0
- package/dist/molecules/index.d.ts +37 -0
- package/dist/molecules/index.js +37 -0
- package/dist/organisms/AlertDialog.d.ts +40 -0
- package/dist/organisms/AlertDialog.js +104 -0
- package/dist/organisms/Calendar.d.ts +96 -0
- package/dist/organisms/Calendar.js +353 -0
- package/dist/organisms/Carousel.d.ts +29 -0
- package/dist/organisms/Carousel.js +128 -0
- package/dist/organisms/Chart.d.ts +55 -0
- package/dist/organisms/Chart.js +189 -0
- package/dist/organisms/Combobox.d.ts +41 -0
- package/dist/organisms/Combobox.js +102 -0
- package/dist/organisms/Command.d.ts +46 -0
- package/dist/organisms/Command.js +171 -0
- package/dist/organisms/CommandDialog.d.ts +42 -0
- package/dist/organisms/CommandDialog.js +116 -0
- package/dist/organisms/ContextMenu.d.ts +29 -0
- package/dist/organisms/ContextMenu.js +102 -0
- package/dist/organisms/DataTable.d.ts +50 -0
- package/dist/organisms/DataTable.js +257 -0
- package/dist/organisms/DatePicker.d.ts +34 -0
- package/dist/organisms/DatePicker.js +115 -0
- package/dist/organisms/DateRangePicker.d.ts +34 -0
- package/dist/organisms/DateRangePicker.js +122 -0
- package/dist/organisms/Dialog.d.ts +50 -0
- package/dist/organisms/Dialog.js +116 -0
- package/dist/organisms/Drawer.d.ts +40 -0
- package/dist/organisms/Drawer.js +131 -0
- package/dist/organisms/DropdownMenu.d.ts +30 -0
- package/dist/organisms/DropdownMenu.js +91 -0
- package/dist/organisms/Form.d.ts +114 -0
- package/dist/organisms/Form.js +151 -0
- package/dist/organisms/HoverCard.d.ts +30 -0
- package/dist/organisms/HoverCard.js +68 -0
- package/dist/organisms/Menubar.d.ts +28 -0
- package/dist/organisms/Menubar.js +147 -0
- package/dist/organisms/MessageScroller.d.ts +36 -0
- package/dist/organisms/MessageScroller.js +124 -0
- package/dist/organisms/NavigationMenu.d.ts +41 -0
- package/dist/organisms/NavigationMenu.js +139 -0
- package/dist/organisms/Popover.d.ts +33 -0
- package/dist/organisms/Popover.js +58 -0
- package/dist/organisms/Questionnaire.d.ts +74 -0
- package/dist/organisms/Questionnaire.js +235 -0
- package/dist/organisms/Select.d.ts +47 -0
- package/dist/organisms/Select.js +250 -0
- package/dist/organisms/Sheet.d.ts +34 -0
- package/dist/organisms/Sheet.js +107 -0
- package/dist/organisms/Sidebar.d.ts +100 -0
- package/dist/organisms/Sidebar.js +228 -0
- package/dist/organisms/Toaster.d.ts +67 -0
- package/dist/organisms/Toaster.js +189 -0
- package/dist/organisms/Tooltip.d.ts +39 -0
- package/dist/organisms/Tooltip.js +94 -0
- package/dist/organisms/index.d.ts +40 -0
- package/dist/organisms/index.js +40 -0
- package/dist/primitives/controllable.d.ts +38 -0
- package/dist/primitives/controllable.js +43 -0
- package/dist/primitives/dismissable.d.ts +53 -0
- package/dist/primitives/dismissable.js +158 -0
- package/dist/primitives/floating.d.ts +116 -0
- package/dist/primitives/floating.js +289 -0
- package/dist/primitives/floatingSurface.d.ts +56 -0
- package/dist/primitives/floatingSurface.js +142 -0
- package/dist/primitives/focusTrap.d.ts +47 -0
- package/dist/primitives/focusTrap.js +101 -0
- package/dist/primitives/focusable.d.ts +53 -0
- package/dist/primitives/focusable.js +109 -0
- package/dist/primitives/index.d.ts +27 -0
- package/dist/primitives/index.js +27 -0
- package/dist/primitives/menu.d.ts +102 -0
- package/dist/primitives/menu.js +356 -0
- package/dist/primitives/modalSurface.d.ts +54 -0
- package/dist/primitives/modalSurface.js +146 -0
- package/dist/primitives/portal.d.ts +34 -0
- package/dist/primitives/portal.js +42 -0
- package/dist/primitives/presence.d.ts +45 -0
- package/dist/primitives/presence.js +152 -0
- package/dist/primitives/rovingFocus.d.ts +47 -0
- package/dist/primitives/rovingFocus.js +176 -0
- package/dist/primitives/scrollLock.d.ts +21 -0
- package/dist/primitives/scrollLock.js +75 -0
- package/dist/primitives/typeahead.d.ts +39 -0
- package/dist/primitives/typeahead.js +123 -0
- package/dist/templates/AppShell.d.ts +30 -0
- package/dist/templates/AppShell.js +42 -0
- package/dist/templates/AuthLayout.d.ts +23 -0
- package/dist/templates/AuthLayout.js +42 -0
- package/dist/templates/SettingsLayout.d.ts +27 -0
- package/dist/templates/SettingsLayout.js +42 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.js +16 -0
- package/nebula.css +2 -0
- package/package.json +92 -0
- package/registry.json +1107 -0
- package/src/adapters/css.ts +75 -0
- package/src/adapters/index.ts +40 -0
- package/src/adapters/tailwind.ts +134 -0
- package/src/adapters/types.ts +53 -0
- package/src/adapters/unocss.ts +116 -0
- package/src/atoms/AspectRatio.ts +28 -0
- package/src/atoms/Avatar.ts +67 -0
- package/src/atoms/Badge.ts +46 -0
- package/src/atoms/Button.ts +96 -0
- package/src/atoms/Checkbox.ts +76 -0
- package/src/atoms/Input.ts +76 -0
- package/src/atoms/Kbd.ts +28 -0
- package/src/atoms/Label.ts +38 -0
- package/src/atoms/Marker.ts +79 -0
- package/src/atoms/NativeSelect.ts +115 -0
- package/src/atoms/Progress.ts +64 -0
- package/src/atoms/ScrollArea.ts +53 -0
- package/src/atoms/Separator.ts +36 -0
- package/src/atoms/Skeleton.ts +24 -0
- package/src/atoms/Slider.ts +65 -0
- package/src/atoms/Spinner.ts +28 -0
- package/src/atoms/Switch.ts +56 -0
- package/src/atoms/Textarea.ts +58 -0
- package/src/atoms/Toggle.ts +67 -0
- package/src/atoms/index.ts +62 -0
- package/src/cli/add.ts +126 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/registry.ts +116 -0
- package/src/config.ts +102 -0
- package/src/configure.ts +127 -0
- package/src/index.ts +53 -0
- package/src/lib/children.ts +43 -0
- package/src/lib/cn.ts +14 -0
- package/src/lib/cva.ts +249 -0
- package/src/lib/icons.ts +138 -0
- package/src/lib/id.ts +44 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/motion.ts +48 -0
- package/src/lib/props.ts +83 -0
- package/src/lib/styled.ts +60 -0
- package/src/molecules/Accordion.ts +161 -0
- package/src/molecules/Alert.ts +58 -0
- package/src/molecules/Attachment.ts +132 -0
- package/src/molecules/Breadcrumb.ts +107 -0
- package/src/molecules/Bubble.ts +134 -0
- package/src/molecules/ButtonGroup.ts +55 -0
- package/src/molecules/Card.ts +42 -0
- package/src/molecules/Collapsible.ts +89 -0
- package/src/molecules/Empty.ts +43 -0
- package/src/molecules/Field.ts +131 -0
- package/src/molecules/InputGroup.ts +62 -0
- package/src/molecules/InputOTP.ts +166 -0
- package/src/molecules/Item.ts +89 -0
- package/src/molecules/Message.ts +127 -0
- package/src/molecules/Pagination.ts +197 -0
- package/src/molecules/RadioGroup.ts +104 -0
- package/src/molecules/Resizable.ts +166 -0
- package/src/molecules/Table.ts +118 -0
- package/src/molecules/Tabs.ts +148 -0
- package/src/molecules/ToggleGroup.ts +109 -0
- package/src/molecules/Typography.ts +94 -0
- package/src/molecules/index.ts +144 -0
- package/src/organisms/AlertDialog.ts +133 -0
- package/src/organisms/Calendar.ts +470 -0
- package/src/organisms/Carousel.ts +161 -0
- package/src/organisms/Chart.ts +257 -0
- package/src/organisms/Combobox.ts +140 -0
- package/src/organisms/Command.ts +211 -0
- package/src/organisms/CommandDialog.ts +149 -0
- package/src/organisms/ContextMenu.ts +125 -0
- package/src/organisms/DataTable.ts +328 -0
- package/src/organisms/DatePicker.ts +141 -0
- package/src/organisms/DateRangePicker.ts +149 -0
- package/src/organisms/Dialog.ts +162 -0
- package/src/organisms/Drawer.ts +167 -0
- package/src/organisms/DropdownMenu.ts +112 -0
- package/src/organisms/Form.ts +217 -0
- package/src/organisms/HoverCard.ts +92 -0
- package/src/organisms/Menubar.ts +167 -0
- package/src/organisms/MessageScroller.ts +145 -0
- package/src/organisms/NavigationMenu.ts +184 -0
- package/src/organisms/Popover.ts +84 -0
- package/src/organisms/Questionnaire.ts +322 -0
- package/src/organisms/Select.ts +286 -0
- package/src/organisms/Sheet.ts +144 -0
- package/src/organisms/Sidebar.ts +336 -0
- package/src/organisms/Toaster.ts +246 -0
- package/src/organisms/Tooltip.ts +124 -0
- package/src/organisms/index.ts +134 -0
- package/src/primitives/controllable.ts +64 -0
- package/src/primitives/dismissable.ts +195 -0
- package/src/primitives/floating.ts +453 -0
- package/src/primitives/floatingSurface.ts +209 -0
- package/src/primitives/focusTrap.ts +132 -0
- package/src/primitives/focusable.ts +108 -0
- package/src/primitives/index.ts +88 -0
- package/src/primitives/menu.ts +491 -0
- package/src/primitives/modalSurface.ts +199 -0
- package/src/primitives/portal.ts +61 -0
- package/src/primitives/presence.ts +185 -0
- package/src/primitives/rovingFocus.ts +212 -0
- package/src/primitives/scrollLock.ts +79 -0
- package/src/primitives/typeahead.ts +156 -0
- package/src/templates/AppShell.ts +56 -0
- package/src/templates/AuthLayout.ts +66 -0
- package/src/templates/SettingsLayout.ts +63 -0
- package/src/templates/index.ts +21 -0
- package/theme.css +151 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar — a month grid for picking a date.
|
|
3
|
+
*
|
|
4
|
+
* shadcn wraps `react-day-picker`, which brings `date-fns` with it. nebula
|
|
5
|
+
* uses `Date` and `Intl`, which cover everything this needs: month lengths and
|
|
6
|
+
* leap years come from `Date`, and weekday and month names come from
|
|
7
|
+
* `Intl.DateTimeFormat` in the user's locale for free — which is more than a
|
|
8
|
+
* hardcoded English array would give.
|
|
9
|
+
*
|
|
10
|
+
* Dates are handled at local midnight throughout. A calendar cell means "this
|
|
11
|
+
* day", not "this instant", and mixing the two is how a date picker ends up
|
|
12
|
+
* off by one for users west of UTC: `new Date("2026-03-14")` parses as UTC
|
|
13
|
+
* midnight, which is the 13th in New York.
|
|
14
|
+
*
|
|
15
|
+
* The keyboard model is the WAI-ARIA grid pattern, and it is what makes the
|
|
16
|
+
* control usable at all without a pointer: arrows move a day, PageUp/PageDown
|
|
17
|
+
* move a month, Home and End jump to the ends of the week. Only one cell is
|
|
18
|
+
* ever tabbable — the focused day — so Tab leaves the grid instead of walking
|
|
19
|
+
* through thirty-one buttons.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { component, html, signal } from "@c9up/aurora";
|
|
23
|
+
import { buttonVariants } from "../atoms/Button.js";
|
|
24
|
+
import type { Child } from "../lib/children.js";
|
|
25
|
+
import { cn } from "../lib/cn.js";
|
|
26
|
+
import { ChevronLeftIcon, ChevronRightIcon } from "../lib/icons.js";
|
|
27
|
+
import { uid } from "../lib/id.js";
|
|
28
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A span of days. `to` is absent while the second end is still being picked —
|
|
32
|
+
* that half-open state is what the hover preview renders against, so it is
|
|
33
|
+
* part of the type rather than something the component hides internally.
|
|
34
|
+
*/
|
|
35
|
+
export interface DateRange {
|
|
36
|
+
readonly from: Date;
|
|
37
|
+
readonly to?: Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CalendarProps {
|
|
41
|
+
/**
|
|
42
|
+
* `"range"` picks a span in two clicks. Default `"single"`.
|
|
43
|
+
*
|
|
44
|
+
* The two modes carry their value in separate props rather than one union.
|
|
45
|
+
* A union would make every read of `value` a narrowing exercise for the
|
|
46
|
+
* caller as much as for this file, and the two `onChange` shapes genuinely
|
|
47
|
+
* differ — a range handler that receives a bare `Date` has no way to say
|
|
48
|
+
* which end moved.
|
|
49
|
+
*/
|
|
50
|
+
mode?: "single" | "range";
|
|
51
|
+
/** Selected day, in `"single"` mode. Local midnight. */
|
|
52
|
+
value?: Reactive<Date | undefined>;
|
|
53
|
+
defaultValue?: Date;
|
|
54
|
+
/** Selected span, in `"range"` mode. */
|
|
55
|
+
range?: Reactive<DateRange | undefined>;
|
|
56
|
+
defaultRange?: DateRange;
|
|
57
|
+
/** Month shown at first render. Defaults to the selection, then today. */
|
|
58
|
+
defaultMonth?: Date;
|
|
59
|
+
min?: Date;
|
|
60
|
+
max?: Date;
|
|
61
|
+
/** Rule out individual days — weekends, holidays, taken slots. */
|
|
62
|
+
disabled?: (date: Date) => boolean;
|
|
63
|
+
/** `0` Sunday, `1` Monday. Defaults to the locale's own first day. */
|
|
64
|
+
weekStartsOn?: number;
|
|
65
|
+
locale?: string;
|
|
66
|
+
class?: Reactive<string>;
|
|
67
|
+
/** `"single"` mode. */
|
|
68
|
+
onValueChange?: (date: Date) => void;
|
|
69
|
+
/** `"range"` mode. Fires on both clicks — `to` is absent after the first. */
|
|
70
|
+
onRangeChange?: (range: DateRange) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── date helpers ────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Local midnight of the given day — the canonical form for a calendar date. */
|
|
76
|
+
export function startOfDay(date: Date): Date {
|
|
77
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isSameDay(a: Date, b: Date): boolean {
|
|
81
|
+
return (
|
|
82
|
+
a.getFullYear() === b.getFullYear() &&
|
|
83
|
+
a.getMonth() === b.getMonth() &&
|
|
84
|
+
a.getDate() === b.getDate()
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function addDays(date: Date, days: number): Date {
|
|
89
|
+
// Day-of-month arithmetic through the Date constructor, which normalises
|
|
90
|
+
// overflow — the 32nd of March becomes the 1st of April, leap years and
|
|
91
|
+
// month lengths included. Adding milliseconds would not: a day is not
|
|
92
|
+
// always 86 400 000 ms across a daylight-saving boundary.
|
|
93
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function addMonths(date: Date, months: number): Date {
|
|
97
|
+
const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
|
|
98
|
+
// Clamp the day: 31 January plus one month is 28 February, not 3 March.
|
|
99
|
+
const lastDay = new Date(
|
|
100
|
+
target.getFullYear(),
|
|
101
|
+
target.getMonth() + 1,
|
|
102
|
+
0,
|
|
103
|
+
).getDate();
|
|
104
|
+
return new Date(
|
|
105
|
+
target.getFullYear(),
|
|
106
|
+
target.getMonth(),
|
|
107
|
+
Math.min(date.getDate(), lastDay),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The six-week grid covering a month.
|
|
113
|
+
*
|
|
114
|
+
* Always six weeks, always 42 cells, even when five would do. A grid that
|
|
115
|
+
* changes height between months makes the whole popover jump as you page
|
|
116
|
+
* through it.
|
|
117
|
+
*/
|
|
118
|
+
export function monthGrid(month: Date, weekStartsOn: number): Date[] {
|
|
119
|
+
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
120
|
+
const lead = (first.getDay() - weekStartsOn + 7) % 7;
|
|
121
|
+
const start = addDays(first, -lead);
|
|
122
|
+
return Array.from({ length: 42 }, (_unused, index) => addDays(start, index));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `Intl.Locale.getWeekInfo` — present at runtime in current engines, absent
|
|
127
|
+
* from the TypeScript DOM lib. Declared here and reached through a guard, so
|
|
128
|
+
* the call is checked rather than asserted.
|
|
129
|
+
*/
|
|
130
|
+
interface WithWeekInfo {
|
|
131
|
+
getWeekInfo(): { firstDay: number };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasWeekInfo(value: object): value is WithWeekInfo {
|
|
135
|
+
return "getWeekInfo" in value && typeof value.getWeekInfo === "function";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The locale's first day of the week, or Monday where it cannot be read. */
|
|
139
|
+
function localeWeekStart(locale: string | undefined): number {
|
|
140
|
+
const resolved = new Intl.Locale(locale ?? navigator.language);
|
|
141
|
+
if (!hasWeekInfo(resolved)) return 1;
|
|
142
|
+
// `firstDay` is 1–7 with 7 for Sunday; the Date API uses 0–6 with 0 Sunday.
|
|
143
|
+
return resolved.getWeekInfo().firstDay % 7;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── range logic (pure) ──────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Where a day sits within a span. `"single"` is a span of one day. */
|
|
149
|
+
export type RangePosition = "single" | "start" | "end" | "middle" | "none";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Place a day relative to a span.
|
|
153
|
+
*
|
|
154
|
+
* `end` is passed in rather than read off the range because it may be the
|
|
155
|
+
* *preview* end — wherever the pointer is, before the second click. Keeping
|
|
156
|
+
* that decision at the call site is what lets this stay a pure function over
|
|
157
|
+
* three dates, which is the part worth testing exhaustively.
|
|
158
|
+
*/
|
|
159
|
+
export function rangePosition(
|
|
160
|
+
date: Date,
|
|
161
|
+
range: DateRange | undefined,
|
|
162
|
+
end: Date | undefined,
|
|
163
|
+
): RangePosition {
|
|
164
|
+
if (range === undefined) return "none";
|
|
165
|
+
if (end === undefined) {
|
|
166
|
+
return isSameDay(date, range.from) ? "single" : "none";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ordered, because the preview end can sit before the fixed start while the
|
|
170
|
+
// pointer sweeps backwards.
|
|
171
|
+
const [lo, hi] = range.from <= end ? [range.from, end] : [end, range.from];
|
|
172
|
+
// A one-day span is both ends at once, and squaring either side of it would
|
|
173
|
+
// leave a lone cell with a flat edge against nothing.
|
|
174
|
+
if (isSameDay(lo, hi)) return isSameDay(date, lo) ? "single" : "none";
|
|
175
|
+
if (isSameDay(date, lo)) return "start";
|
|
176
|
+
if (isSameDay(date, hi)) return "end";
|
|
177
|
+
return date > lo && date < hi ? "middle" : "none";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The span after clicking `date`.
|
|
182
|
+
*
|
|
183
|
+
* A click before the fixed start restarts the span rather than producing an
|
|
184
|
+
* inverted one. Silently swapping the ends would be the other option, and it
|
|
185
|
+
* is worse: the user who clicked the 3rd after the 10th almost always meant to
|
|
186
|
+
* start again, not to select the week between them.
|
|
187
|
+
*/
|
|
188
|
+
export function nextRange(
|
|
189
|
+
current: DateRange | undefined,
|
|
190
|
+
date: Date,
|
|
191
|
+
): DateRange {
|
|
192
|
+
if (
|
|
193
|
+
current === undefined ||
|
|
194
|
+
current.to !== undefined ||
|
|
195
|
+
date < current.from
|
|
196
|
+
) {
|
|
197
|
+
return { from: date };
|
|
198
|
+
}
|
|
199
|
+
return { from: current.from, to: date };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── component ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export const Calendar = component<CalendarProps>((props) => {
|
|
205
|
+
const gridId = uid("calendar-grid");
|
|
206
|
+
const labelId = uid("calendar-label");
|
|
207
|
+
const locale = props.locale;
|
|
208
|
+
const weekStartsOn = props.weekStartsOn ?? safeWeekStart(locale);
|
|
209
|
+
|
|
210
|
+
const isRange = props.mode === "range";
|
|
211
|
+
const today = startOfDay(new Date());
|
|
212
|
+
const initial =
|
|
213
|
+
props.defaultValue ??
|
|
214
|
+
props.defaultRange?.from ??
|
|
215
|
+
props.defaultMonth ??
|
|
216
|
+
today;
|
|
217
|
+
const month = signal(new Date(initial.getFullYear(), initial.getMonth(), 1));
|
|
218
|
+
/** The day the keyboard is on. Only this cell is tabbable. */
|
|
219
|
+
const cursor = signal(startOfDay(initial));
|
|
220
|
+
|
|
221
|
+
const selected = (): Date | undefined =>
|
|
222
|
+
read(props.value) ?? props.defaultValue;
|
|
223
|
+
|
|
224
|
+
/** The span being built, when the caller does not control it. */
|
|
225
|
+
const draft = signal<DateRange | undefined>(props.defaultRange);
|
|
226
|
+
/** The day under the pointer, for the preview half of an open range. */
|
|
227
|
+
const hovered = signal<Date | undefined>(undefined);
|
|
228
|
+
|
|
229
|
+
const activeRange = (): DateRange | undefined => read(props.range) ?? draft();
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The far end of the span as currently drawn.
|
|
233
|
+
*
|
|
234
|
+
* While only one end is fixed, that is wherever the pointer is — which is
|
|
235
|
+
* what makes the range visibly follow the cursor before the second click.
|
|
236
|
+
* Keyboard users get no preview, and that is fine: for them the span
|
|
237
|
+
* appears on Enter, and a preview tied to the roving cursor would announce
|
|
238
|
+
* a selection that has not happened.
|
|
239
|
+
*/
|
|
240
|
+
function rangeEnd(): Date | undefined {
|
|
241
|
+
const current = activeRange();
|
|
242
|
+
if (current === undefined) return undefined;
|
|
243
|
+
return current.to ?? hovered();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type RangePosition = "single" | "start" | "end" | "middle" | "none";
|
|
247
|
+
|
|
248
|
+
function rangePositionOf(date: Date): RangePosition {
|
|
249
|
+
const current = activeRange();
|
|
250
|
+
if (current === undefined) return "none";
|
|
251
|
+
|
|
252
|
+
const end = rangeEnd();
|
|
253
|
+
if (end === undefined) {
|
|
254
|
+
return isSameDay(date, current.from) ? "single" : "none";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Ordered, because the preview end can sit before the fixed start while
|
|
258
|
+
// the pointer sweeps backwards.
|
|
259
|
+
const [lo, hi] =
|
|
260
|
+
current.from <= end ? [current.from, end] : [end, current.from];
|
|
261
|
+
// A one-day span is both ends at once, and squaring either side of it
|
|
262
|
+
// would leave a lone cell with a flat edge against nothing.
|
|
263
|
+
if (isSameDay(lo, hi)) return isSameDay(date, lo) ? "single" : "none";
|
|
264
|
+
if (isSameDay(date, lo)) return "start";
|
|
265
|
+
if (isSameDay(date, hi)) return "end";
|
|
266
|
+
return date > lo && date < hi ? "middle" : "none";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const monthLabel = new Intl.DateTimeFormat(locale, {
|
|
270
|
+
month: "long",
|
|
271
|
+
year: "numeric",
|
|
272
|
+
});
|
|
273
|
+
const weekdayLabel = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
274
|
+
const fullDate = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
|
|
275
|
+
|
|
276
|
+
function isDisabled(date: Date): boolean {
|
|
277
|
+
if (props.min !== undefined && date < startOfDay(props.min)) return true;
|
|
278
|
+
if (props.max !== undefined && date > startOfDay(props.max)) return true;
|
|
279
|
+
return props.disabled?.(date) === true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Show the month containing `date`, if it is not already showing.
|
|
284
|
+
*
|
|
285
|
+
* The guard is load-bearing. `month` is a signal the whole grid is derived
|
|
286
|
+
* from, so writing a fresh `Date` for the month already on screen rebuilds
|
|
287
|
+
* all 42 cells — destroying the one the user just clicked, and with it the
|
|
288
|
+
* focus that was on it.
|
|
289
|
+
*/
|
|
290
|
+
function showMonthOf(date: Date): void {
|
|
291
|
+
const current = month();
|
|
292
|
+
if (
|
|
293
|
+
current.getFullYear() === date.getFullYear() &&
|
|
294
|
+
current.getMonth() === date.getMonth()
|
|
295
|
+
) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
month(new Date(date.getFullYear(), date.getMonth(), 1));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function choose(date: Date): void {
|
|
302
|
+
if (isDisabled(date)) return;
|
|
303
|
+
cursor(date);
|
|
304
|
+
showMonthOf(date);
|
|
305
|
+
if (isRange) chooseInRange(date);
|
|
306
|
+
else props.onValueChange?.(date);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function chooseInRange(date: Date): void {
|
|
310
|
+
const next = nextRange(activeRange(), date);
|
|
311
|
+
draft(next);
|
|
312
|
+
// Drop the preview: it belonged to the span that just closed, and leaving
|
|
313
|
+
// it would paint a band to wherever the pointer happens to rest.
|
|
314
|
+
hovered(undefined);
|
|
315
|
+
props.onRangeChange?.(next);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function moveCursor(next: Date): void {
|
|
319
|
+
cursor(next);
|
|
320
|
+
// Follow the cursor across a month boundary, otherwise arrowing off the
|
|
321
|
+
// end of the grid moves focus to a cell that is not on screen.
|
|
322
|
+
if (
|
|
323
|
+
next.getMonth() !== month().getMonth() ||
|
|
324
|
+
next.getFullYear() !== month().getFullYear()
|
|
325
|
+
) {
|
|
326
|
+
month(new Date(next.getFullYear(), next.getMonth(), 1));
|
|
327
|
+
}
|
|
328
|
+
focusCursor();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function focusCursor(): void {
|
|
332
|
+
// After the signal write, so the new cell exists and carries tabindex 0.
|
|
333
|
+
queueMicrotask(() => {
|
|
334
|
+
const grid = document.getElementById(gridId);
|
|
335
|
+
grid
|
|
336
|
+
?.querySelector<HTMLElement>("[data-cursor]")
|
|
337
|
+
?.focus({ preventScroll: true });
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
342
|
+
const from = cursor();
|
|
343
|
+
const moves: Record<string, () => Date> = {
|
|
344
|
+
ArrowLeft: () => addDays(from, -1),
|
|
345
|
+
ArrowRight: () => addDays(from, 1),
|
|
346
|
+
ArrowUp: () => addDays(from, -7),
|
|
347
|
+
ArrowDown: () => addDays(from, 7),
|
|
348
|
+
PageUp: () => addMonths(from, -1),
|
|
349
|
+
PageDown: () => addMonths(from, 1),
|
|
350
|
+
Home: () => addDays(from, -((from.getDay() - weekStartsOn + 7) % 7)),
|
|
351
|
+
End: () => addDays(from, 6 - ((from.getDay() - weekStartsOn + 7) % 7)),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const move = moves[event.key];
|
|
355
|
+
if (move !== undefined) {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
moveCursor(move());
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
choose(from);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const weekdayNames = Array.from({ length: 7 }, (_unused, index) =>
|
|
367
|
+
weekdayLabel.format(new Date(2024, 0, 7 + ((weekStartsOn + index) % 7))),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return html`<div
|
|
371
|
+
data-slot="calendar"
|
|
372
|
+
class="${() => cn("bg-background w-fit rounded-md p-3", read(props.class))}"
|
|
373
|
+
>
|
|
374
|
+
<div class="flex items-center justify-between pb-4">
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
aria-label="Previous month"
|
|
378
|
+
class="${buttonVariants({ variant: "outline", size: "icon", class: "size-7" })}"
|
|
379
|
+
@click="${() => month(addMonths(month(), -1))}"
|
|
380
|
+
>${ChevronLeftIcon({ class: "size-4" })}</button>
|
|
381
|
+
<div id="${labelId}" aria-live="polite" class="text-sm font-medium">
|
|
382
|
+
${() => monthLabel.format(month())}
|
|
383
|
+
</div>
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
aria-label="Next month"
|
|
387
|
+
class="${buttonVariants({ variant: "outline", size: "icon", class: "size-7" })}"
|
|
388
|
+
@click="${() => month(addMonths(month(), 1))}"
|
|
389
|
+
>${ChevronRightIcon({ class: "size-4" })}</button>
|
|
390
|
+
</div>
|
|
391
|
+
<div
|
|
392
|
+
role="grid"
|
|
393
|
+
id="${gridId}"
|
|
394
|
+
aria-labelledby="${labelId}"
|
|
395
|
+
class="grid grid-cols-7 gap-1"
|
|
396
|
+
@keydown="${onKeyDown}"
|
|
397
|
+
>
|
|
398
|
+
${weekdayNames.map(
|
|
399
|
+
(name) => html`<div
|
|
400
|
+
role="columnheader"
|
|
401
|
+
aria-label="${name}"
|
|
402
|
+
class="text-muted-foreground flex size-8 items-center justify-center text-[0.8rem] font-normal"
|
|
403
|
+
>${name}</div>`,
|
|
404
|
+
)}
|
|
405
|
+
${() => monthGrid(month(), weekStartsOn).map(renderDay)}
|
|
406
|
+
</div>
|
|
407
|
+
</div>`;
|
|
408
|
+
|
|
409
|
+
function renderDay(date: Date): Child {
|
|
410
|
+
const outside = date.getMonth() !== month().getMonth();
|
|
411
|
+
const position = (): RangePosition =>
|
|
412
|
+
isRange ? rangePositionOf(date) : "none";
|
|
413
|
+
const chosen = (): boolean => {
|
|
414
|
+
if (isRange) return position() !== "none";
|
|
415
|
+
const current = selected();
|
|
416
|
+
return current !== undefined && isSameDay(current, date);
|
|
417
|
+
};
|
|
418
|
+
const onCursor = (): boolean => isSameDay(cursor(), date);
|
|
419
|
+
const disabled = isDisabled(date);
|
|
420
|
+
|
|
421
|
+
return html`<button
|
|
422
|
+
type="button"
|
|
423
|
+
role="gridcell"
|
|
424
|
+
data-slot="calendar-day"
|
|
425
|
+
data-cursor="${() => (onCursor() ? "" : undefined)}"
|
|
426
|
+
data-outside="${outside ? "" : undefined}"
|
|
427
|
+
data-today="${isSameDay(date, today) ? "" : undefined}"
|
|
428
|
+
data-range="${() => {
|
|
429
|
+
const at = position();
|
|
430
|
+
return at === "none" ? undefined : at;
|
|
431
|
+
}}"
|
|
432
|
+
aria-label="${fullDate.format(date)}"
|
|
433
|
+
aria-selected="${() => (chosen() ? "true" : "false")}"
|
|
434
|
+
aria-current="${isSameDay(date, today) ? "date" : undefined}"
|
|
435
|
+
tabindex="${() => (onCursor() ? 0 : -1)}"
|
|
436
|
+
?disabled="${disabled}"
|
|
437
|
+
class="${() =>
|
|
438
|
+
cn(
|
|
439
|
+
"flex size-8 items-center justify-center rounded-md p-0 text-sm font-normal transition-colors outline-none",
|
|
440
|
+
"hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
441
|
+
"aria-selected:bg-primary aria-selected:text-primary-foreground aria-selected:hover:bg-primary",
|
|
442
|
+
"data-[today]:bg-accent data-[today]:aria-selected:bg-primary",
|
|
443
|
+
// The interior of a span is a flat band: square, and in accent
|
|
444
|
+
// rather than primary, so the two ends stay the emphasis.
|
|
445
|
+
"data-[range=middle]:bg-accent data-[range=middle]:text-accent-foreground data-[range=middle]:rounded-none",
|
|
446
|
+
"data-[range=start]:rounded-r-none data-[range=end]:rounded-l-none",
|
|
447
|
+
outside ? "text-muted-foreground opacity-50" : "",
|
|
448
|
+
disabled ? "pointer-events-none opacity-30" : "",
|
|
449
|
+
)}"
|
|
450
|
+
@click="${() => choose(date)}"
|
|
451
|
+
@pointerenter="${() => {
|
|
452
|
+
if (isRange && !disabled) hovered(date);
|
|
453
|
+
}}"
|
|
454
|
+
>${date.getDate()}</button>`;
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `Intl.Locale.getWeekInfo` is not available everywhere yet, and neither is
|
|
460
|
+
* `navigator` on the server. Monday is the fallback — the ISO week start, and
|
|
461
|
+
* the right answer for most of the world.
|
|
462
|
+
*/
|
|
463
|
+
function safeWeekStart(locale: string | undefined): number {
|
|
464
|
+
try {
|
|
465
|
+
if (typeof navigator === "undefined" && locale === undefined) return 1;
|
|
466
|
+
return localeWeekStart(locale);
|
|
467
|
+
} catch {
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel — a horizontally scrolling strip of slides.
|
|
3
|
+
*
|
|
4
|
+
* shadcn wraps `embla-carousel`, which animates a transform with its own
|
|
5
|
+
* requestAnimationFrame loop and reimplements drag physics on top. nebula uses
|
|
6
|
+
* CSS scroll-snap and `scrollBy`.
|
|
7
|
+
*
|
|
8
|
+
* That is not only smaller, it is better behaved. Native scrolling brings
|
|
9
|
+
* touch momentum, trackpad gestures, the platform's own overscroll, and
|
|
10
|
+
* keyboard scrolling — all things a transform-based carousel has to rebuild
|
|
11
|
+
* and usually gets partly wrong. `scroll-behavior: smooth` handles the
|
|
12
|
+
* animation, and `prefers-reduced-motion` turns it off without a branch here.
|
|
13
|
+
*
|
|
14
|
+
* The strip is a `region` with `aria-roledescription="carousel"`, and slides
|
|
15
|
+
* are `group`s labelled "N of M". A screen reader user otherwise has no way to
|
|
16
|
+
* tell how much is off-screen — the visual affordance of a strip running off
|
|
17
|
+
* the edge carries none of that.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
|
|
21
|
+
import { buttonVariants } from "../atoms/Button.js";
|
|
22
|
+
import type { Child } from "../lib/children.js";
|
|
23
|
+
import { cn } from "../lib/cn.js";
|
|
24
|
+
import { ArrowLeftIcon, ArrowRightIcon } from "../lib/icons.js";
|
|
25
|
+
import { uid } from "../lib/id.js";
|
|
26
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
27
|
+
|
|
28
|
+
export interface CarouselProps {
|
|
29
|
+
slides: readonly Child[];
|
|
30
|
+
/** Announced before the slides. */
|
|
31
|
+
label?: string;
|
|
32
|
+
orientation?: "horizontal" | "vertical";
|
|
33
|
+
class?: Reactive<string>;
|
|
34
|
+
slideClass?: Reactive<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Carousel = component<CarouselProps>((props) => {
|
|
38
|
+
const trackId = uid("carousel-track");
|
|
39
|
+
const horizontal = props.orientation !== "vertical";
|
|
40
|
+
const atStart = signal(true);
|
|
41
|
+
const atEnd = signal(false);
|
|
42
|
+
|
|
43
|
+
function track(): HTMLElement | null {
|
|
44
|
+
return document.getElementById(trackId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recompute whether either end is reached.
|
|
49
|
+
*
|
|
50
|
+
* The one-pixel tolerance is load-bearing: with fractional device pixel
|
|
51
|
+
* ratios, `scrollLeft` at the far end lands a fraction short of
|
|
52
|
+
* `scrollWidth - clientWidth`, and an exact comparison leaves the "next"
|
|
53
|
+
* button enabled on a carousel that cannot scroll any further.
|
|
54
|
+
*/
|
|
55
|
+
function syncEdges(): void {
|
|
56
|
+
const element = track();
|
|
57
|
+
if (element === null) return;
|
|
58
|
+
const position = horizontal ? element.scrollLeft : element.scrollTop;
|
|
59
|
+
const total = horizontal ? element.scrollWidth : element.scrollHeight;
|
|
60
|
+
const visible = horizontal ? element.clientWidth : element.clientHeight;
|
|
61
|
+
atStart(position <= 1);
|
|
62
|
+
atEnd(position + visible >= total - 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scrollByPage(direction: 1 | -1): void {
|
|
66
|
+
const element = track();
|
|
67
|
+
if (element === null) return;
|
|
68
|
+
const distance =
|
|
69
|
+
(horizontal ? element.clientWidth : element.clientHeight) * direction;
|
|
70
|
+
element.scrollBy(
|
|
71
|
+
horizontal
|
|
72
|
+
? { left: distance, behavior: "smooth" }
|
|
73
|
+
: { top: distance, behavior: "smooth" },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
78
|
+
const back = horizontal ? "ArrowLeft" : "ArrowUp";
|
|
79
|
+
const forward = horizontal ? "ArrowRight" : "ArrowDown";
|
|
80
|
+
if (event.key === back) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
scrollByPage(-1);
|
|
83
|
+
} else if (event.key === forward) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
scrollByPage(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onMount(() => {
|
|
90
|
+
syncEdges();
|
|
91
|
+
const element = track();
|
|
92
|
+
element?.addEventListener("scroll", syncEdges, { passive: true });
|
|
93
|
+
window.addEventListener("resize", syncEdges);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
onUnmount(() => {
|
|
97
|
+
track()?.removeEventListener("scroll", syncEdges);
|
|
98
|
+
window.removeEventListener("resize", syncEdges);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const count = props.slides.length;
|
|
102
|
+
|
|
103
|
+
return html`<div
|
|
104
|
+
data-slot="carousel"
|
|
105
|
+
role="region"
|
|
106
|
+
aria-roledescription="carousel"
|
|
107
|
+
aria-label="${props.label ?? "Carousel"}"
|
|
108
|
+
class="${() => cn("relative", read(props.class))}"
|
|
109
|
+
@keydown="${onKeyDown}"
|
|
110
|
+
>
|
|
111
|
+
<div
|
|
112
|
+
data-slot="carousel-track"
|
|
113
|
+
id="${trackId}"
|
|
114
|
+
tabindex="0"
|
|
115
|
+
class="${cn(
|
|
116
|
+
"flex snap-mandatory gap-4 overflow-auto scroll-smooth outline-none motion-reduce:scroll-auto",
|
|
117
|
+
horizontal
|
|
118
|
+
? "snap-x flex-row [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
119
|
+
: "snap-y max-h-96 flex-col [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
120
|
+
)}"
|
|
121
|
+
>
|
|
122
|
+
${props.slides.map(
|
|
123
|
+
(child, index) => html`<div
|
|
124
|
+
data-slot="carousel-slide"
|
|
125
|
+
role="group"
|
|
126
|
+
aria-roledescription="slide"
|
|
127
|
+
aria-label="${`${index + 1} of ${count}`}"
|
|
128
|
+
class="${() => cn("min-w-0 shrink-0 grow-0 basis-full snap-start", read(props.slideClass))}"
|
|
129
|
+
>${child}</div>`,
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
data-slot="carousel-previous"
|
|
135
|
+
aria-label="Previous slide"
|
|
136
|
+
?disabled="${() => atStart()}"
|
|
137
|
+
class="${cn(
|
|
138
|
+
buttonVariants({ variant: "outline", size: "icon" }),
|
|
139
|
+
"absolute size-8 rounded-full",
|
|
140
|
+
horizontal
|
|
141
|
+
? "top-1/2 -left-12 -translate-y-1/2"
|
|
142
|
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
143
|
+
)}"
|
|
144
|
+
@click="${() => scrollByPage(-1)}"
|
|
145
|
+
>${ArrowLeftIcon({ class: "size-4" })}</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
data-slot="carousel-next"
|
|
149
|
+
aria-label="Next slide"
|
|
150
|
+
?disabled="${() => atEnd()}"
|
|
151
|
+
class="${cn(
|
|
152
|
+
buttonVariants({ variant: "outline", size: "icon" }),
|
|
153
|
+
"absolute size-8 rounded-full",
|
|
154
|
+
horizontal
|
|
155
|
+
? "top-1/2 -right-12 -translate-y-1/2"
|
|
156
|
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
157
|
+
)}"
|
|
158
|
+
@click="${() => scrollByPage(1)}"
|
|
159
|
+
>${ArrowRightIcon({ class: "size-4" })}</button>
|
|
160
|
+
</div>`;
|
|
161
|
+
});
|