@nomideusz/svelte-calendar 1.4.0 → 1.4.2
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 +85 -62
- package/dist/calendar/Calendar.svelte +33 -2
- package/dist/core/locale.d.ts +5 -0
- package/dist/core/locale.js +68 -17
- package/dist/primitives/DayHeader.svelte +2 -2
- package/dist/primitives/EmptySlot.svelte +1 -1
- package/dist/theme/auto.js +16 -1
- package/dist/views/agenda/AgendaDay.svelte +13 -4
- package/dist/views/agenda/AgendaWeek.svelte +12 -9
- package/dist/views/mobile/MobileDay.svelte +4 -2
- package/dist/views/mobile/MobileWeek.svelte +11 -14
- package/dist/views/month/MonthGrid.svelte +12 -6
- package/dist/views/planner/PlannerScroll.svelte +9 -4
- package/dist/views/planner/PlannerWeek.svelte +77 -13
- package/package.json +1 -1
- package/widget/widget.js +291 -194
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@nomideusz/svelte-calendar) [](https://github.com/nomideusz/svelte-calendar/blob/main/LICENSE)
|
|
4
4
|
|
|
5
|
-
A themeable **Svelte 5** calendar: day and week **Planner** time grids, a multi-week **Scroll** view, **Agenda** lists, a **Month** grid and touch-first **Mobile** layouts, with drag
|
|
5
|
+
A themeable **Svelte 5** calendar: day and week **Planner** time grids, a multi-week **Scroll** view, **Agenda** lists, a **Month** grid and touch-first **Mobile** layouts, with drag to move, resize and create events, timezones, a headless API, an embeddable web component, and a smart auto-theme that adapts to any page.
|
|
6
6
|
|
|
7
7
|
**[Live demo → svelte-calendar.xyz](https://svelte-calendar.xyz/)**
|
|
8
8
|
|
|
@@ -28,24 +28,37 @@ Requirements:
|
|
|
28
28
|
const today = new Date();
|
|
29
29
|
const at = (hour) => new Date(today.getFullYear(), today.getMonth(), today.getDate(), hour);
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
let events = $state([
|
|
32
32
|
{ id: '1', title: 'Team Sync', start: at(9), end: at(10) },
|
|
33
33
|
{ id: '2', title: 'Lunch', start: at(12), end: at(13) },
|
|
34
34
|
]);
|
|
35
|
+
// A new adapter whenever the events change; the calendar reloads without blanking
|
|
36
|
+
const adapter = $derived(createMemoryAdapter(events));
|
|
37
|
+
|
|
38
|
+
// Drag on empty time (or press Enter on a day column) to create
|
|
39
|
+
function create({ start, end }) {
|
|
40
|
+
events = [...events, { id: crypto.randomUUID(), title: 'New event', start, end }];
|
|
41
|
+
}
|
|
42
|
+
// Drag an event to move it, or its edge to resize it
|
|
43
|
+
function move(event, start, end) {
|
|
44
|
+
events = events.map((e) => (e.id === event.id ? { ...e, start, end } : e));
|
|
45
|
+
}
|
|
35
46
|
</script>
|
|
36
47
|
|
|
37
|
-
<Calendar {adapter} view="week-planner" />
|
|
48
|
+
<Calendar {adapter} view="week-planner" oneventcreate={create} oneventmove={move} />
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
That's
|
|
51
|
+
That's a working, editable calendar. The calendar stores nothing on create: without `oneventcreate`, dragging on empty time does nothing. Moves and resizes write to the adapter by themselves, and `oneventmove` keeps your own data in step, so a move survives the next new adapter. The default `auto` theme probes your page's background, accent color and fonts, so the calendar adapts to any design. Events without a `color` draw in ink; to color them automatically, give the adapter a [`palette`](#event-colors).
|
|
41
52
|
|
|
42
53
|
## Views
|
|
43
54
|
|
|
55
|
+
Without `view` the calendar opens on `day-planner`, the first registered view. The Quick Start, the demo, the widget and `createCalendar` open on `week-planner`, so pass `view="week-planner"` for the week grid.
|
|
56
|
+
|
|
44
57
|
The default registry (`defaultViews`) holds eight views. Six are the ones you choose with `view`; the two `*-mobile` views are what the others swap to in a narrow container (see [Mobile](#mobile)), so the demo's view list shows six.
|
|
45
58
|
|
|
46
59
|
| View ID | Mode | What it is |
|
|
47
60
|
|---------|------|------------|
|
|
48
|
-
| `day-planner` | day | Vertical time grid, one column.
|
|
61
|
+
| `day-planner` | day | Vertical time grid, one column. The default when `view` is unset. |
|
|
49
62
|
| `week-planner` | week | The same time grid, one column per day (`days`, default 7). |
|
|
50
63
|
| `week-scroll` | week | Weeks stacked in one infinite vertical scroller (Hey-Calendar style): each chip sits at the height of its start time, month names run down the side. Drag to move, drop onto a day; it scrolls under a drag. |
|
|
51
64
|
| `day-agenda` | day | Live list for one day: now / up next / done. |
|
|
@@ -112,7 +125,7 @@ The component receives `CalendarViewProps` (plus your `props`); declare only the
|
|
|
112
125
|
|------|------|-------|
|
|
113
126
|
| `events` | `TimelineEvent[]` | Everything loaded for the current range |
|
|
114
127
|
| `style` | `string` | The active `--dt-*` theme string |
|
|
115
|
-
| `height` | `number \| null` |
|
|
128
|
+
| `height` | `number \| null` | Always `null`: the view fills its container, sized by the Calendar's own [`height`](#display) (default `600`) |
|
|
116
129
|
| `mode` | `'day' \| 'week' \| 'month'` | |
|
|
117
130
|
| `mondayStart` | `boolean` | |
|
|
118
131
|
| `locale` | `string \| undefined` | |
|
|
@@ -320,7 +333,7 @@ Disable specific dates:
|
|
|
320
333
|
/>
|
|
321
334
|
```
|
|
322
335
|
|
|
323
|
-
Disabled dates refuse creating or moving events into those days.
|
|
336
|
+
Disabled dates refuse creating or moving events into those days, which suits holidays or fully booked days. In the planner, agenda and mobile views their existing events stay visible and clickable; `month-grid` dims a disabled day, and its chips ignore clicks.
|
|
324
337
|
|
|
325
338
|
Custom day headers and hover previews:
|
|
326
339
|
|
|
@@ -567,55 +580,7 @@ The calendar reads its events through an adapter. Which one:
|
|
|
567
580
|
| [`createCompositeAdapter(adapters)`](#composite-adapter) | One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
|
|
568
581
|
| [Custom `CalendarAdapter`](#custom-adapter) | None of the above fits: implement `fetchEvents`, plus the writes you need. |
|
|
569
582
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
For fixed repeating events (class timetables, office hours):
|
|
573
|
-
|
|
574
|
-
```svelte
|
|
575
|
-
<script>
|
|
576
|
-
import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
|
|
577
|
-
|
|
578
|
-
const adapter = createRecurringAdapter([
|
|
579
|
-
{ id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
|
|
580
|
-
{ id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
|
|
581
|
-
startDate: '2026-03-01', until: '2026-03-31' },
|
|
582
|
-
{ id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
|
|
583
|
-
startTime: '10:00', endTime: '11:00' },
|
|
584
|
-
{ id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
|
|
585
|
-
]);
|
|
586
|
-
</script>
|
|
587
|
-
|
|
588
|
-
<Calendar {adapter} view="week-planner" />
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
<details>
|
|
592
|
-
<summary>RecurringEvent fields</summary>
|
|
593
|
-
|
|
594
|
-
| Field | Type | Default | Description |
|
|
595
|
-
|-------|------|---------|-------------|
|
|
596
|
-
| `id` | `string` | *required* | Rule identifier |
|
|
597
|
-
| `title` | `string` | *required* | Event title |
|
|
598
|
-
| `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
|
|
599
|
-
| `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
|
|
600
|
-
| `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
|
|
601
|
-
| `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
|
|
602
|
-
| `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
|
|
603
|
-
| `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
|
|
604
|
-
| `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
|
|
605
|
-
| `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
|
|
606
|
-
| `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
|
|
607
|
-
| `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
|
|
608
|
-
| `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
|
|
609
|
-
| `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
|
|
610
|
-
| `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
|
|
611
|
-
|
|
612
|
-
</details>
|
|
613
|
-
|
|
614
|
-
Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
|
|
615
|
-
|
|
616
|
-
Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
|
|
617
|
-
|
|
618
|
-
## REST Adapter
|
|
583
|
+
### REST Adapter
|
|
619
584
|
|
|
620
585
|
Connect to any REST API:
|
|
621
586
|
|
|
@@ -640,7 +605,7 @@ const adapter = createRestAdapter({
|
|
|
640
605
|
|
|
641
606
|
The adapter calls `GET {baseUrl}/events?start=…&end=…` (ISO strings), `POST /events`, `PATCH /events/:id` and `DELETE /events/:id`, sending JSON with your `headers`. By default a list response must be an array of events and a single response one event; `start`/`end` are parsed into `Date`s. Override with `mapEvents(data)` for lists and `mapEvent(data)` for single events. A non-2xx response or invalid JSON throws.
|
|
642
607
|
|
|
643
|
-
|
|
608
|
+
### JMAP Adapter
|
|
644
609
|
|
|
645
610
|
Read events from a [JMAP Calendars](https://jmap.io/) server (Fastmail, Stalwart, Cyrus):
|
|
646
611
|
|
|
@@ -665,7 +630,7 @@ const adapter = createJmapAdapter(client, {
|
|
|
665
630
|
|
|
666
631
|
Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
|
|
667
632
|
|
|
668
|
-
|
|
633
|
+
### Mapped Adapter
|
|
669
634
|
|
|
670
635
|
Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a `mapEvent` function for full control) and the adapter handles parsing, colors, tags and status:
|
|
671
636
|
|
|
@@ -708,7 +673,7 @@ const adapter = createMappedAdapter(rawData, {
|
|
|
708
673
|
|
|
709
674
|
Mapped adapters are read-only by default (writes throw a read-only error, so drags reach `oneventmove`). Set `readOnly: false` to allow writes; supply `onMutate: { onCreate, onUpdate, onDelete }` to persist them (each returns the stored source record, which is mapped again), or omit it to keep changes in memory.
|
|
710
675
|
|
|
711
|
-
|
|
676
|
+
### Composite Adapter
|
|
712
677
|
|
|
713
678
|
Merge several adapters into one, e.g. a recurring weekly schedule plus one-off events:
|
|
714
679
|
|
|
@@ -733,7 +698,7 @@ const adapter = createCompositeAdapter([recurring, memory], {
|
|
|
733
698
|
});
|
|
734
699
|
```
|
|
735
700
|
|
|
736
|
-
|
|
701
|
+
### Custom Adapter
|
|
737
702
|
|
|
738
703
|
Implement the `CalendarAdapter` interface to connect any data source. Only `fetchEvents` is required:
|
|
739
704
|
|
|
@@ -765,7 +730,7 @@ const adapter = withInitialEvents(createRestAdapter({ baseUrl: '/api' }), data.e
|
|
|
765
730
|
|
|
766
731
|
The first load is answered synchronously from the seed; every later load (navigation, refresh) goes to the wrapped adapter.
|
|
767
732
|
|
|
768
|
-
|
|
733
|
+
#### Refusing a write
|
|
769
734
|
|
|
770
735
|
The Calendar tells two refusals apart when `updateEvent` throws:
|
|
771
736
|
|
|
@@ -777,6 +742,54 @@ The Calendar tells two refusals apart when `updateEvent` throws:
|
|
|
777
742
|
|
|
778
743
|
An adapter without `updateEvent` counts as read-only. Plain `Error`s whose message contains `read-only` (lower case) or `not found` (any case) are read the same way, so older adapters keep working. `isReadOnlyError(e)` and `isNotFoundError(e)` test both forms.
|
|
779
744
|
|
|
745
|
+
## Recurring Schedules
|
|
746
|
+
|
|
747
|
+
For fixed repeating events (class timetables, office hours):
|
|
748
|
+
|
|
749
|
+
```svelte
|
|
750
|
+
<script>
|
|
751
|
+
import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
|
|
752
|
+
|
|
753
|
+
const adapter = createRecurringAdapter([
|
|
754
|
+
{ id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
|
|
755
|
+
{ id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
|
|
756
|
+
startDate: '2026-03-01', until: '2026-03-31' },
|
|
757
|
+
{ id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
|
|
758
|
+
startTime: '10:00', endTime: '11:00' },
|
|
759
|
+
{ id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
|
|
760
|
+
]);
|
|
761
|
+
</script>
|
|
762
|
+
|
|
763
|
+
<Calendar {adapter} view="week-planner" />
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
<details>
|
|
767
|
+
<summary>RecurringEvent fields</summary>
|
|
768
|
+
|
|
769
|
+
| Field | Type | Default | Description |
|
|
770
|
+
|-------|------|---------|-------------|
|
|
771
|
+
| `id` | `string` | *required* | Rule identifier |
|
|
772
|
+
| `title` | `string` | *required* | Event title |
|
|
773
|
+
| `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
|
|
774
|
+
| `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
|
|
775
|
+
| `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
|
|
776
|
+
| `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
|
|
777
|
+
| `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
|
|
778
|
+
| `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
|
|
779
|
+
| `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
|
|
780
|
+
| `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
|
|
781
|
+
| `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
|
|
782
|
+
| `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
|
|
783
|
+
| `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
|
|
784
|
+
| `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
|
|
785
|
+
| `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
|
|
786
|
+
|
|
787
|
+
</details>
|
|
788
|
+
|
|
789
|
+
Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
|
|
790
|
+
|
|
791
|
+
Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
|
|
792
|
+
|
|
780
793
|
## Headless API
|
|
781
794
|
|
|
782
795
|
For full control over rendering, skip `<Calendar>` and drive everything from reactive state. `createCalendar()` returns computed layouts, navigation actions, drag helpers and the raw engines. Zero DOM: bring your own UI. Call it during component initialisation.
|
|
@@ -1057,7 +1070,7 @@ Every `<Calendar>` prop (`CalendarProps`), grouped by what it controls.
|
|
|
1057
1070
|
| `minDuration` | `number` | — | Minimum duration in minutes; clamps creates and resizes |
|
|
1058
1071
|
| `maxDuration` | `number` | — | Maximum duration in minutes; clamps creates and resizes |
|
|
1059
1072
|
| `blockedSlots` | `BlockedSlot[]` | — | `{ day?, start, end, label? }` hour ranges that can't be booked (hatched in planner views) |
|
|
1060
|
-
| `disabledDates` | `Date[]` | — | Dates that refuse creates/moves; existing events stay visible and clickable |
|
|
1073
|
+
| `disabledDates` | `Date[]` | — | Dates that refuse creates/moves; existing events stay visible and clickable (dimmed and inert in `month-grid`) |
|
|
1061
1074
|
|
|
1062
1075
|
### View and navigation
|
|
1063
1076
|
|
|
@@ -1127,6 +1140,16 @@ Every `<Calendar>` prop (`CalendarProps`), grouped by what it controls.
|
|
|
1127
1140
|
| `oneventhover` | `(event) => void` | — | Pointer enters an event |
|
|
1128
1141
|
| `onerror` | `(error: Error) => void` | — | Load and commit failures |
|
|
1129
1142
|
|
|
1143
|
+
## Troubleshooting
|
|
1144
|
+
|
|
1145
|
+
- **Dragging on empty time does nothing.** Creating needs an `oneventcreate` handler (and no `readOnly`); see the [Quick Start](#quick-start).
|
|
1146
|
+
- **A created event doesn't appear.** The calendar stores nothing on create: add the event to your data and hand the Calendar a new adapter (`$derived(createMemoryAdapter(events))`).
|
|
1147
|
+
- **Moved events jump back after the next change.** A new adapter is built from your array, so write each move into it in `oneventmove`.
|
|
1148
|
+
- **The `auto` theme picked the wrong colours.** It probes the host page around the calendar, and probes again when `<html>`/`<body>` change class, style or `data-theme`, when the system scheme flips and when a background transition ends. Steer it with [`autoTheme`](#smart-auto-theme), or set `theme` to a preset or your own tokens to opt out.
|
|
1149
|
+
- **Events are all one colour.** Since 1.4 events without a `color` draw in ink; give the adapter a [`palette`](#event-colors).
|
|
1150
|
+
- **The calendar stops at 600px.** That's the default `height`; pass a number, or `'auto'` to grow with the content.
|
|
1151
|
+
- **The phone layout shows on a wide page (or the desktop one in a narrow column).** The switch reads the calendar's container, not the window; force it with `mobile={false}` or `mobile={true}`.
|
|
1152
|
+
|
|
1130
1153
|
## Versioning
|
|
1131
1154
|
|
|
1132
1155
|
The package follows [semver](https://semver.org/) from 1.0:
|
|
@@ -623,6 +623,28 @@ function handleShortcuts(e) {
|
|
|
623
623
|
else viewState.prev();
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
|
+
// Each pill group is one tab stop (radio-group pattern): arrows (reading
|
|
627
|
+
// direction), Home and End move the selection and focus with it.
|
|
628
|
+
function pillKeys(e) {
|
|
629
|
+
const radios = [...e.currentTarget.querySelectorAll("[role=\"radio\"]")];
|
|
630
|
+
const i = radios.indexOf(e.target);
|
|
631
|
+
if (i < 0) return;
|
|
632
|
+
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
|
633
|
+
const step = {
|
|
634
|
+
ArrowRight: rtl ? -1 : 1,
|
|
635
|
+
ArrowLeft: rtl ? 1 : -1,
|
|
636
|
+
ArrowDown: 1,
|
|
637
|
+
ArrowUp: -1
|
|
638
|
+
};
|
|
639
|
+
let n;
|
|
640
|
+
if (e.key === "Home") n = 0;
|
|
641
|
+
else if (e.key === "End") n = radios.length - 1;
|
|
642
|
+
else if (e.key in step) n = (i + step[e.key] + radios.length) % radios.length;
|
|
643
|
+
else return;
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
radios[n].focus();
|
|
646
|
+
radios[n].click();
|
|
647
|
+
}
|
|
626
648
|
/** Header context for custom header snippet */
|
|
627
649
|
const headerCtx = $derived({
|
|
628
650
|
dateLabel,
|
|
@@ -671,7 +693,9 @@ const navCtx = $derived({
|
|
|
671
693
|
<span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{noteText || dateLabel}</span>
|
|
672
694
|
{#if showModePills && !useMobile && labelsForMode.length > 1}
|
|
673
695
|
<!-- View-type switcher (e.g. Planner ↔ Agenda) for the current mode -->
|
|
674
|
-
|
|
696
|
+
{@const activeLabel = labelsForMode.includes(activeView?.label ?? '') ? activeView?.label : labelsForMode[0]}
|
|
697
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -- the checked radio is the tab stop; the group only routes its arrow keys -->
|
|
698
|
+
<div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode} onkeydown={pillKeys}>
|
|
675
699
|
{#each labelsForMode as label (label)}
|
|
676
700
|
<button
|
|
677
701
|
type="button"
|
|
@@ -679,6 +703,7 @@ const navCtx = $derived({
|
|
|
679
703
|
class:cal-pill--active={activeView?.label === label}
|
|
680
704
|
role="radio"
|
|
681
705
|
aria-checked={activeView?.label === label}
|
|
706
|
+
tabindex={label === activeLabel ? 0 : -1}
|
|
682
707
|
onclick={() => switchLabel(label)}
|
|
683
708
|
>
|
|
684
709
|
{viewLabel(label)}
|
|
@@ -687,7 +712,9 @@ const navCtx = $derived({
|
|
|
687
712
|
</div>
|
|
688
713
|
{/if}
|
|
689
714
|
{#if showModePills && modes.length > 1}
|
|
690
|
-
|
|
715
|
+
{@const activeMode = modes.includes(viewState.mode) ? viewState.mode : modes[0]}
|
|
716
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -- the checked radio is the tab stop; the group only routes its arrow keys -->
|
|
717
|
+
<div class="cal-pills" role="radiogroup" aria-label={L.viewMode} onkeydown={pillKeys}>
|
|
691
718
|
{#each modes as g (g)}
|
|
692
719
|
<button
|
|
693
720
|
type="button"
|
|
@@ -695,6 +722,7 @@ const navCtx = $derived({
|
|
|
695
722
|
class:cal-pill--active={viewState.mode === g}
|
|
696
723
|
role="radio"
|
|
697
724
|
aria-checked={viewState.mode === g}
|
|
725
|
+
tabindex={g === activeMode ? 0 : -1}
|
|
698
726
|
onclick={() => switchMode(g)}
|
|
699
727
|
>
|
|
700
728
|
{g === 'day' ? L.day : g === 'week' ? L.week : L.month}
|
|
@@ -952,6 +980,9 @@ const navCtx = $derived({
|
|
|
952
980
|
.cal-hd {
|
|
953
981
|
--cal-ctl-h: 44px;
|
|
954
982
|
}
|
|
983
|
+
.cal-pill {
|
|
984
|
+
min-width: 44px;
|
|
985
|
+
}
|
|
955
986
|
}
|
|
956
987
|
|
|
957
988
|
@media (prefers-reduced-motion: reduce) {
|
package/dist/core/locale.d.ts
CHANGED
|
@@ -98,6 +98,10 @@ export declare function setDefaultLocale(tag: string): void;
|
|
|
98
98
|
/** Get the current default locale */
|
|
99
99
|
export declare function getDefaultLocale(): string;
|
|
100
100
|
export declare function is24HourLocale(locale?: string): boolean;
|
|
101
|
+
/** A number in the locale's own digits ("٧" in ar-SA), optionally zero-padded. */
|
|
102
|
+
export declare function fmtNum(n: number, locale?: string, minDigits?: number): string;
|
|
103
|
+
/** Day-of-month in the locale's digits: "19", "١٩". */
|
|
104
|
+
export declare function fmtDayNum(ms: number, locale?: string): string;
|
|
101
105
|
/** Format hour index (0-23) as compact label: 12h ("12a", "1p") or 24h ("0", "13") */
|
|
102
106
|
export declare function fmtH(h: number, locale?: string): string;
|
|
103
107
|
/**
|
|
@@ -137,6 +141,7 @@ export declare function fmtWeekRange(weekStartMs: number, locale?: string, weekE
|
|
|
137
141
|
*
|
|
138
142
|
* 12-hour locales → "9a", "12:30p"
|
|
139
143
|
* 24-hour locales → "9:00", "14:30"
|
|
144
|
+
* other 12-hour locales → Intl's own, in the locale's digits ("٧:٠٠ ص")
|
|
140
145
|
*/
|
|
141
146
|
export declare function fmtTime(d: Date, locale?: string): string;
|
|
142
147
|
/**
|
package/dist/core/locale.js
CHANGED
|
@@ -106,16 +106,61 @@ export function is24HourLocale(locale) {
|
|
|
106
106
|
hourCycleCache.set(loc, is24);
|
|
107
107
|
return is24;
|
|
108
108
|
}
|
|
109
|
+
// ─── Numerals ───────────────────────────────────────────
|
|
110
|
+
const numCache = new Map();
|
|
111
|
+
/** A number in the locale's own digits ("٧" in ar-SA), optionally zero-padded. */
|
|
112
|
+
export function fmtNum(n, locale, minDigits = 1) {
|
|
113
|
+
const loc = locale ?? defaultLocale;
|
|
114
|
+
const key = `${loc}|${minDigits}`;
|
|
115
|
+
let f = numCache.get(key);
|
|
116
|
+
if (!f) {
|
|
117
|
+
f = new Intl.NumberFormat(loc, { useGrouping: false, minimumIntegerDigits: minDigits });
|
|
118
|
+
numCache.set(key, f);
|
|
119
|
+
}
|
|
120
|
+
return f.format(n);
|
|
121
|
+
}
|
|
122
|
+
/** Day-of-month in the locale's digits: "19", "١٩". */
|
|
123
|
+
export function fmtDayNum(ms, locale) {
|
|
124
|
+
return fmtNum(new Date(ms).getDate(), locale);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Whether the compact "9a" / "12:30p" style fits the locale: a 12-hour clock
|
|
128
|
+
* whose day periods are Latin AM/PM. Other 12-hour locales (ar "ص"/"م")
|
|
129
|
+
* get Intl's own day period instead.
|
|
130
|
+
*/
|
|
131
|
+
const compactCache = new Map();
|
|
132
|
+
function isCompact12h(locale) {
|
|
133
|
+
const loc = locale ?? defaultLocale;
|
|
134
|
+
if (is24HourLocale(loc))
|
|
135
|
+
return false;
|
|
136
|
+
let c = compactCache.get(loc);
|
|
137
|
+
if (c === undefined) {
|
|
138
|
+
const period = new Intl.DateTimeFormat(loc, { hour: 'numeric', hour12: true })
|
|
139
|
+
.formatToParts(new Date(2000, 0, 1, 9))
|
|
140
|
+
.find((p) => p.type === 'dayPeriod')?.value ?? '';
|
|
141
|
+
c = /^a\W*m\W*$/i.test(period);
|
|
142
|
+
compactCache.set(loc, c);
|
|
143
|
+
}
|
|
144
|
+
return c;
|
|
145
|
+
}
|
|
146
|
+
const dtfCache = new Map();
|
|
147
|
+
function dtf(locale, withMinutes) {
|
|
148
|
+
const loc = locale ?? defaultLocale;
|
|
149
|
+
const key = `${loc}|${withMinutes}`;
|
|
150
|
+
let f = dtfCache.get(key);
|
|
151
|
+
if (!f) {
|
|
152
|
+
f = new Intl.DateTimeFormat(loc, withMinutes ? { hour: 'numeric', minute: '2-digit' } : { hour: 'numeric' });
|
|
153
|
+
dtfCache.set(key, f);
|
|
154
|
+
}
|
|
155
|
+
return f;
|
|
156
|
+
}
|
|
109
157
|
/** Format hour index (0-23) as compact label: 12h ("12a", "1p") or 24h ("0", "13") */
|
|
110
158
|
export function fmtH(h, locale) {
|
|
111
|
-
if (is24HourLocale(locale))
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (h === 12)
|
|
117
|
-
return '12p';
|
|
118
|
-
return h < 12 ? h + 'a' : h - 12 + 'p';
|
|
159
|
+
if (is24HourLocale(locale))
|
|
160
|
+
return fmtNum(h, locale);
|
|
161
|
+
if (!isCompact12h(locale))
|
|
162
|
+
return dtf(locale, false).format(new Date(2000, 0, 1, h));
|
|
163
|
+
return fmtNum(h % 12 || 12, locale) + (h < 12 ? 'a' : 'p');
|
|
119
164
|
}
|
|
120
165
|
/**
|
|
121
166
|
* Hour index (0-23) split for a timetable gutter: the numeral and the small
|
|
@@ -123,8 +168,15 @@ export function fmtH(h, locale) {
|
|
|
123
168
|
*/
|
|
124
169
|
export function fmtHParts(h, locale) {
|
|
125
170
|
if (is24HourLocale(locale))
|
|
126
|
-
return [
|
|
127
|
-
|
|
171
|
+
return [fmtNum(h, locale), fmtNum(0, locale, 2)];
|
|
172
|
+
if (!isCompact12h(locale)) {
|
|
173
|
+
const parts = dtf(locale, false).formatToParts(new Date(2000, 0, 1, h));
|
|
174
|
+
return [
|
|
175
|
+
parts.find((p) => p.type === 'hour')?.value ?? fmtNum(h % 12 || 12, locale),
|
|
176
|
+
parts.find((p) => p.type === 'dayPeriod')?.value ?? '',
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
return [fmtNum(h % 12 || 12, locale), h < 12 ? 'am' : 'pm'];
|
|
128
180
|
}
|
|
129
181
|
/** Short weekday name for a timestamp: "Mon", "Tue", etc. */
|
|
130
182
|
export function weekdayShort(ms, locale) {
|
|
@@ -198,18 +250,17 @@ export function fmtWeekRange(weekStartMs, locale, weekEndMs) {
|
|
|
198
250
|
*
|
|
199
251
|
* 12-hour locales → "9a", "12:30p"
|
|
200
252
|
* 24-hour locales → "9:00", "14:30"
|
|
253
|
+
* other 12-hour locales → Intl's own, in the locale's digits ("٧:٠٠ ص")
|
|
201
254
|
*/
|
|
202
255
|
export function fmtTime(d, locale) {
|
|
203
|
-
if (is24HourLocale(locale)) {
|
|
204
|
-
const h = d.getHours();
|
|
205
|
-
const m = d.getMinutes();
|
|
206
|
-
return `${h}:${String(m).padStart(2, '0')}`;
|
|
207
|
-
}
|
|
208
256
|
const h = d.getHours();
|
|
209
257
|
const m = d.getMinutes();
|
|
258
|
+
if (is24HourLocale(locale))
|
|
259
|
+
return `${fmtNum(h, locale)}:${fmtNum(m, locale, 2)}`;
|
|
260
|
+
if (!isCompact12h(locale))
|
|
261
|
+
return dtf(locale, true).format(d);
|
|
210
262
|
const suffix = h >= 12 ? 'p' : 'a';
|
|
211
|
-
|
|
212
|
-
return `${h12}:${String(m).padStart(2, '0')}${suffix}`;
|
|
263
|
+
return `${fmtNum(h % 12 || 12, locale)}:${fmtNum(m, locale, 2)}${suffix}`;
|
|
213
264
|
}
|
|
214
265
|
/**
|
|
215
266
|
* Format the duration between two Dates as a compact string.
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
- Relative labels ("Today", "Tomorrow") for day views via fmtDay
|
|
6
6
|
- Short/long weekday + date number for week views
|
|
7
7
|
-->
|
|
8
|
-
<script lang="ts">import { weekdayShort, weekdayLong, monthShort, fmtDay } from "../core/locale.js";
|
|
8
|
+
<script lang="ts">import { weekdayShort, weekdayLong, monthShort, fmtDay, fmtDayNum } from "../core/locale.js";
|
|
9
9
|
import { sod } from "../core/time.js";
|
|
10
10
|
let { dayMs, todayMs = sod(Date.now()), format = "short", isToday = false, isPast = false } = $props();
|
|
11
|
-
const dayNum = $derived(
|
|
11
|
+
const dayNum = $derived(fmtDayNum(dayMs));
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
14
|
<div
|
|
@@ -53,7 +53,7 @@ function handleKeydown(e) {
|
|
|
53
53
|
background: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
|
|
54
54
|
}
|
|
55
55
|
.es:focus-visible {
|
|
56
|
-
outline: 2px solid var(--dt-
|
|
56
|
+
outline: 2px solid var(--dt-text, #0f0f0f);
|
|
57
57
|
outline-offset: 2px;
|
|
58
58
|
border-color: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
|
|
59
59
|
}
|
package/dist/theme/auto.js
CHANGED
|
@@ -588,7 +588,20 @@ export function observeHostTheme(el, callback, options = {}) {
|
|
|
588
588
|
attributes: true,
|
|
589
589
|
attributeFilter: ['class', 'style', 'data-theme', 'data-mode', 'color-scheme'],
|
|
590
590
|
});
|
|
591
|
-
// 3.
|
|
591
|
+
// 3. Hosts often crossfade their ground (`body { transition: background 300ms }`),
|
|
592
|
+
// so the rAF probe above reads a mid-transition colour and keeps the old
|
|
593
|
+
// host's light/dark. Re-probe when a colour transition on <html>/<body> ends.
|
|
594
|
+
const onTransitionEnd = (e) => {
|
|
595
|
+
const t = e.target;
|
|
596
|
+
if (t !== document.documentElement && t !== document.body)
|
|
597
|
+
return;
|
|
598
|
+
const prop = e.propertyName;
|
|
599
|
+
if (prop === undefined || /^(background|color)/.test(prop))
|
|
600
|
+
scheduleUpdate();
|
|
601
|
+
};
|
|
602
|
+
document.addEventListener('transitionend', onTransitionEnd);
|
|
603
|
+
document.addEventListener('animationend', onTransitionEnd);
|
|
604
|
+
// 4. Re-probe once the document settles. A probe during initial load can
|
|
592
605
|
// read pre-stylesheet values (wrong background → wrong light/dark call);
|
|
593
606
|
// late webfonts change computed font stacks. Both are one-shot events.
|
|
594
607
|
if (document.readyState !== 'complete') {
|
|
@@ -602,5 +615,7 @@ export function observeHostTheme(el, callback, options = {}) {
|
|
|
602
615
|
mql?.removeEventListener('change', onScheme);
|
|
603
616
|
window.removeEventListener('load', scheduleUpdate);
|
|
604
617
|
observer.disconnect();
|
|
618
|
+
document.removeEventListener('transitionend', onTransitionEnd);
|
|
619
|
+
document.removeEventListener('animationend', onTransitionEnd);
|
|
605
620
|
};
|
|
606
621
|
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Clean numbered schedule list.
|
|
13
13
|
*/
|
|
14
14
|
import { createClock } from "../../core/clock.svelte.js";
|
|
15
|
-
import { sod, addDaysMs,
|
|
16
|
-
import { weekdayLong, monthLong } from "../../core/locale.js";
|
|
15
|
+
import { sod, addDaysMs, isAllDay } from "../../core/time.js";
|
|
16
|
+
import { weekdayLong, monthLong, fmtDayNum } from "../../core/locale.js";
|
|
17
17
|
import { useCalendarContext } from "../shared/context.svelte.js";
|
|
18
18
|
import EventContent from "../shared/EventContent.svelte";
|
|
19
19
|
import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
|
|
@@ -136,7 +136,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
|
|
|
136
136
|
<!-- ─── In-view date header (only when the chrome doesn't label the day) ─── -->
|
|
137
137
|
{#if !hideDayHead}
|
|
138
138
|
<div class="ag-day-head" class:ag-day-head--today={!equalDays && isToday}>
|
|
139
|
-
<span class="ag-day-head-num">{
|
|
139
|
+
<span class="ag-day-head-num">{fmtDayNum(dayMs, locale)}</span>
|
|
140
140
|
{#if !equalDays && isToday}
|
|
141
141
|
<span class="ag-day-head-badge">{L.today}</span>
|
|
142
142
|
{:else if !equalDays && isTomorrow}
|
|
@@ -562,6 +562,15 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
|
|
|
562
562
|
color: var(--dt-btn-text, #fff);
|
|
563
563
|
cursor: pointer;
|
|
564
564
|
}
|
|
565
|
+
@media (pointer: coarse) {
|
|
566
|
+
.ag-allday-chip {
|
|
567
|
+
min-height: 44px;
|
|
568
|
+
}
|
|
569
|
+
.ag-q-done-item {
|
|
570
|
+
min-height: 44px;
|
|
571
|
+
align-items: center;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
565
574
|
.ag-allday-chip:hover,
|
|
566
575
|
.ag-allday-chip:active {
|
|
567
576
|
filter: brightness(1.08);
|
|
@@ -820,7 +829,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
|
|
|
820
829
|
}
|
|
821
830
|
.ag-q-done-toggle:focus-visible {
|
|
822
831
|
outline: none;
|
|
823
|
-
box-shadow: 0 0 0 2px var(--dt-
|
|
832
|
+
box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
|
|
824
833
|
}
|
|
825
834
|
.ag-q-now-sub {
|
|
826
835
|
font: 400 12.5px/1.2 var(--dt-sans, system-ui, sans-serif);
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Answers: "What's coming up and when do I need to be ready?"
|
|
10
10
|
*/
|
|
11
11
|
import { createClock } from "../../core/clock.svelte.js";
|
|
12
|
-
import { sod, addDaysMs, startOfWeek,
|
|
13
|
-
import { weekdayLong, monthLong } from "../../core/locale.js";
|
|
12
|
+
import { sod, addDaysMs, startOfWeek, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
|
|
13
|
+
import { weekdayLong, monthLong, fmtDayNum } from "../../core/locale.js";
|
|
14
14
|
import { useCalendarContext } from "../shared/context.svelte.js";
|
|
15
15
|
import EventContent from "../shared/EventContent.svelte";
|
|
16
16
|
import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
|
|
@@ -165,7 +165,7 @@ const weekDays = $derived.by(() => {
|
|
|
165
165
|
ms,
|
|
166
166
|
dayName: weekdayLong(ms, locale),
|
|
167
167
|
month: monthLong(ms, locale),
|
|
168
|
-
num:
|
|
168
|
+
num: fmtDayNum(ms, locale),
|
|
169
169
|
tier,
|
|
170
170
|
// A dateless weekly template (showDates=false) has no today to mark.
|
|
171
171
|
isToday: showDates && ms === todayMs,
|
|
@@ -796,9 +796,9 @@ const weekDays = $derived.by(() => {
|
|
|
796
796
|
}
|
|
797
797
|
.ag-wday-head--click:focus-visible,
|
|
798
798
|
.ag-wday-empty--click:focus-visible {
|
|
799
|
-
outline: 2px solid var(--dt-
|
|
799
|
+
outline: 2px solid var(--dt-text, #0f0f0f);
|
|
800
800
|
outline-offset: -2px;
|
|
801
|
-
border-radius:
|
|
801
|
+
border-radius: 0;
|
|
802
802
|
}
|
|
803
803
|
.ag-wday-head {
|
|
804
804
|
display: flex;
|
|
@@ -844,8 +844,11 @@ const weekDays = $derived.by(() => {
|
|
|
844
844
|
.ag-wday .ag-wday-badge--muted {
|
|
845
845
|
color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
|
|
846
846
|
}
|
|
847
|
+
/* Set like the weekday beside it, so the heading reads in one case */
|
|
847
848
|
.ag-wday-date {
|
|
848
|
-
font: 400
|
|
849
|
+
font: 400 12px/1 var(--dt-sans, system-ui, sans-serif);
|
|
850
|
+
letter-spacing: 0.08em;
|
|
851
|
+
text-transform: uppercase;
|
|
849
852
|
color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
|
|
850
853
|
}
|
|
851
854
|
|
|
@@ -908,8 +911,8 @@ const weekDays = $derived.by(() => {
|
|
|
908
911
|
}
|
|
909
912
|
.ag-past-toggle:focus-visible {
|
|
910
913
|
outline: none;
|
|
911
|
-
box-shadow: 0 0 0 2px var(--dt-
|
|
912
|
-
border-radius:
|
|
914
|
+
box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
|
|
915
|
+
border-radius: 0;
|
|
913
916
|
}
|
|
914
917
|
.ag-past-chevron {
|
|
915
918
|
transition: transform 120ms;
|
|
@@ -1081,7 +1084,7 @@ const weekDays = $derived.by(() => {
|
|
|
1081
1084
|
}
|
|
1082
1085
|
.ag-compact-more:focus-visible {
|
|
1083
1086
|
outline: none;
|
|
1084
|
-
box-shadow: 0 0 0 2px var(--dt-
|
|
1087
|
+
box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
|
|
1085
1088
|
border-radius: 0;
|
|
1086
1089
|
}
|
|
1087
1090
|
|
|
@@ -793,7 +793,7 @@ $effect(() => {
|
|
|
793
793
|
}
|
|
794
794
|
.mb-allday-more:focus-visible {
|
|
795
795
|
outline: none;
|
|
796
|
-
box-shadow: 0 0 0 2px var(--dt-
|
|
796
|
+
box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
|
|
797
797
|
}
|
|
798
798
|
|
|
799
799
|
/* ─── Grid ───────────────────────────────────────── */
|
|
@@ -811,7 +811,7 @@ $effect(() => {
|
|
|
811
811
|
.mb--auto .mb-grid { overflow-y: visible; }
|
|
812
812
|
.mb-grid:focus-visible {
|
|
813
813
|
outline: none;
|
|
814
|
-
box-shadow: inset 0 0 0 2px var(--dt-
|
|
814
|
+
box-shadow: inset 0 0 0 2px var(--dt-text, #0f0f0f);
|
|
815
815
|
}
|
|
816
816
|
|
|
817
817
|
.mb-grid-inner {
|
|
@@ -843,6 +843,8 @@ $effect(() => {
|
|
|
843
843
|
align-items: flex-start;
|
|
844
844
|
}
|
|
845
845
|
|
|
846
|
+
/* The first hour has no room above its line: it sits on it, unclipped */
|
|
847
|
+
.mb-hour:first-child .mb-hour-label { top: 0; }
|
|
846
848
|
.mb-hour-label--under {
|
|
847
849
|
visibility: hidden;
|
|
848
850
|
}
|