@nomideusz/svelte-calendar 1.3.0 → 1.4.1

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.
@@ -95,16 +95,16 @@ export interface MappedAdapterOptions<T = Record<string, unknown>> {
95
95
  */
96
96
  includeData?: string[] | '*';
97
97
  /**
98
- * When true (default), ignore source colors and auto-assign from
99
- * the palette grouped by category / title. This keeps the calendar
100
- * visually consistent regardless of the upstream source.
101
- * Set to `false` to preserve the original colors from the data.
102
- * @default true
98
+ * When true, ignore source colors and auto-assign from the palette
99
+ * (`VIVID_PALETTE` unless `palette` is given) grouped by category / title.
100
+ * By default source colors are kept, and events without one draw in
101
+ * `--dt-event` (ink) unless a `palette` is given to fill them.
102
+ * @default false (since 1.4; was true)
103
103
  */
104
104
  autoColor?: boolean;
105
105
  /**
106
- * Color palette used for auto-coloring.
107
- * Defaults to VIVID_PALETTE.
106
+ * Color palette used to fill events without a source color
107
+ * (or every event when `autoColor: true`). Off by default.
108
108
  */
109
109
  palette?: readonly string[];
110
110
  /**
@@ -139,15 +139,18 @@ function collectData(raw, mappedKeys, include) {
139
139
  * ```
140
140
  */
141
141
  export function createMappedAdapter(sourceData, options = {}) {
142
- const { fields, mapEvent: customMapper, includeData = '*', autoColor = true, palette = VIVID_PALETTE, readOnly = true, } = options;
142
+ const { fields, mapEvent: customMapper, includeData = '*', autoColor = false, palette = autoColor ? VIVID_PALETTE : undefined, readOnly = true, } = options;
143
143
  // Build the internal event list
144
144
  const events = [];
145
145
  const colorAssignments = new Map();
146
146
  let colorIndex = 0;
147
147
  function resolveColor(ev) {
148
- // When autoColor is on, always assign from palette (ignore source color)
148
+ // autoColor: always assign from the palette (ignore source color).
149
+ // Otherwise keep the source color; fill only when a palette is given.
149
150
  if (!autoColor && ev.color)
150
151
  return ev.color;
152
+ if (!palette?.length)
153
+ return ev.color;
151
154
  const key = ev.category ?? ev.title;
152
155
  if (!colorAssignments.has(key)) {
153
156
  colorAssignments.set(key, palette[colorIndex % palette.length]);
@@ -2,21 +2,22 @@
2
2
  * In-memory adapter — the default for demos and testing.
3
3
  *
4
4
  * Events are stored in a plain array. No persistence across page loads.
5
- * Events without a `color` are auto-assigned one from a palette,
6
- * grouped by `category` or `title` so related events share a color.
5
+ * Events without a `color` stay uncoloured and draw in `--dt-event` (ink).
6
+ * Pass `palette` to auto-assign colours instead, grouped by `category` or
7
+ * `title` so related events share one.
7
8
  *
8
9
  * Usage:
9
10
  * import { createMemoryAdapter } from './';
10
11
  * const adapter = createMemoryAdapter(initialEvents);
11
- * const adapter = createMemoryAdapter(initialEvents, { palette: myColors });
12
+ * const adapter = createMemoryAdapter(initialEvents, { palette: VIVID_PALETTE });
12
13
  */
13
14
  import type { TimelineEvent } from '../core/types.js';
14
15
  import type { CalendarAdapter } from './types.js';
15
16
  export interface MemoryAdapterOptions {
16
17
  /**
17
- * Custom color palette for auto-coloring events.
18
- * Defaults to VIVID_PALETTE. Pass `generatePalette(accent)` to
19
- * make event colors adapt to your theme.
18
+ * Colour palette for auto-colouring events that have no `color`.
19
+ * Off by default (events draw in `--dt-event`). Pass `VIVID_PALETTE`
20
+ * for the pre-1.4 look, or `generatePalette(accent)` for theme-harmonious colours.
20
21
  */
21
22
  palette?: readonly string[];
22
23
  }
@@ -1,4 +1,3 @@
1
- import { VIVID_PALETTE } from '../core/palette.js';
2
1
  import { overlapsRange } from '../core/time.js';
3
2
  import { EventNotFoundError } from './errors.js';
4
3
  let counter = 0;
@@ -7,12 +6,12 @@ function uid() {
7
6
  }
8
7
  export function createMemoryAdapter(initial = [], options) {
9
8
  const events = [...initial];
10
- const palette = options?.palette ?? VIVID_PALETTE;
11
- // Auto-color: assign from vivid palette by category/title
9
+ const palette = options?.palette;
10
+ // Opt-in auto-color: assign from the palette by category/title
12
11
  const colorAssignments = new Map();
13
12
  let colorIndex = 0;
14
13
  function resolveColor(ev) {
15
- if (ev.color)
14
+ if (ev.color || !palette?.length)
16
15
  return ev.color;
17
16
  const key = ev.category ?? ev.title;
18
17
  if (!colorAssignments.has(key)) {
@@ -74,10 +74,10 @@ export interface RecurringAdapterOptions {
74
74
  /** Start weeks on Monday (default: true) */
75
75
  mondayStart?: boolean;
76
76
  /**
77
- * Custom color palette for auto-coloring events.
78
- * Pass `generatePalette('#yourAccent')` to get theme-harmonious colors,
79
- * or provide your own array of hex strings.
80
- * Defaults to the built-in vivid palette.
77
+ * Colour palette for auto-colouring events that have no `color`.
78
+ * Off by default (events draw in `--dt-event`). Pass `VIVID_PALETTE`
79
+ * for the pre-1.4 look, `generatePalette('#yourAccent')` for
80
+ * theme-harmonious colours, or your own array of colours.
81
81
  */
82
82
  palette?: readonly string[];
83
83
  /**
@@ -95,8 +95,8 @@ export interface RecurringAdapterOptions {
95
95
  * Create a CalendarAdapter that projects recurring events onto concrete
96
96
  * dates for whatever range the calendar requests.
97
97
  *
98
- * Events without a `color` are auto-assigned one from a vivid palette,
99
- * grouped by `category` or `title` so related events share a color.
98
+ * Events without a `color` draw in `--dt-event` (ink) unless `palette` is
99
+ * given; then they are auto-assigned one, grouped by `category` or `title`.
100
100
  *
101
101
  * Read-only by default — create/update/delete throw.
102
102
  */
@@ -1,5 +1,4 @@
1
1
  import { startOfWeek, addDaysMs, diffDays, DAY_MS, overlapsRange } from '../core/time.js';
2
- import { VIVID_PALETTE } from '../core/palette.js';
3
2
  import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
4
3
  // ── Helpers ─────────────────────────────────────────────
5
4
  /** Parse "HH:MM" into [hours, minutes] */
@@ -234,32 +233,31 @@ function projectMonthly(rec, range, startDate, effectiveUntil, out) {
234
233
  }
235
234
  }
236
235
  // ── Adapter factory ─────────────────────────────────────
237
- /** Default palette for auto-coloring */
238
- const PALETTE = VIVID_PALETTE;
239
236
  /**
240
237
  * Create a CalendarAdapter that projects recurring events onto concrete
241
238
  * dates for whatever range the calendar requests.
242
239
  *
243
- * Events without a `color` are auto-assigned one from a vivid palette,
244
- * grouped by `category` or `title` so related events share a color.
240
+ * Events without a `color` draw in `--dt-event` (ink) unless `palette` is
241
+ * given; then they are auto-assigned one, grouped by `category` or `title`.
245
242
  *
246
243
  * Read-only by default — create/update/delete throw.
247
244
  */
248
245
  export function createRecurringAdapter(schedule, options = {}) {
249
246
  const { mondayStart = true, palette, movable = false } = options;
250
- const colors = palette?.length ? palette : PALETTE;
247
+ const colors = palette?.length ? palette : undefined;
251
248
  // Auto-color: assign from palette by category/title
252
249
  const colorAssignments = new Map();
253
250
  let colorIndex = 0;
254
- for (const rec of schedule) {
255
- if (rec.color)
256
- continue;
257
- const key = rec.category ?? rec.title;
258
- if (!colorAssignments.has(key)) {
259
- colorAssignments.set(key, colors[colorIndex % colors.length]);
260
- colorIndex++;
251
+ if (colors)
252
+ for (const rec of schedule) {
253
+ if (rec.color)
254
+ continue;
255
+ const key = rec.category ?? rec.title;
256
+ if (!colorAssignments.has(key)) {
257
+ colorAssignments.set(key, colors[colorIndex % colors.length]);
258
+ colorIndex++;
259
+ }
261
260
  }
262
- }
263
261
  function resolveColor(rec) {
264
262
  if (rec.color)
265
263
  return rec.color;
@@ -376,6 +376,7 @@ setContext("calendar", {
376
376
  get labels() {
377
377
  return mergedLabels;
378
378
  },
379
+ announce,
379
380
  // Load range (read/write)
380
381
  get loadRange() {
381
382
  return viewLoadRange;
@@ -479,6 +480,17 @@ const dateLabel = $derived.by(() => {
479
480
  year: "numeric"
480
481
  });
481
482
  });
483
+ // ── Polite announcements (views say why an action did nothing) ──
484
+ // Shares the header's status line; a note lasts until the range changes.
485
+ let note = $state(null);
486
+ function announce(text) {
487
+ // A repeated message must change the text node to be read again.
488
+ note = {
489
+ text: note?.text === text ? `${text}\u00a0` : text,
490
+ label: dateLabel
491
+ };
492
+ }
493
+ const noteText = $derived(note && note.label === dateLabel ? note.text : "");
482
494
  // ── Period title (the built-in header's big line) ──
483
495
  // Names the month the user is looking at: "October 2026"; a week across two
484
496
  // months reads "Oct – Nov 2026"; a day gives its date. `dateLabel` keeps the
@@ -611,6 +623,28 @@ function handleShortcuts(e) {
611
623
  else viewState.prev();
612
624
  }
613
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
+ }
614
648
  /** Header context for custom header snippet */
615
649
  const headerCtx = $derived({
616
650
  dateLabel,
@@ -656,10 +690,12 @@ const navCtx = $derived({
656
690
  <div class="cal-hd" class:cal-hd--mobile={useMobile} class:cal-hd--stack={stackHeader}>
657
691
  <!-- The visible title names the month; the status line announces the exact range -->
658
692
  <span class="cal-hd-title" aria-hidden="true">{periodTitle}</span>
659
- <span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
693
+ <span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{noteText || dateLabel}</span>
660
694
  {#if showModePills && !useMobile && labelsForMode.length > 1}
661
695
  <!-- View-type switcher (e.g. Planner ↔ Agenda) for the current mode -->
662
- <div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode}>
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}>
663
699
  {#each labelsForMode as label (label)}
664
700
  <button
665
701
  type="button"
@@ -667,6 +703,7 @@ const navCtx = $derived({
667
703
  class:cal-pill--active={activeView?.label === label}
668
704
  role="radio"
669
705
  aria-checked={activeView?.label === label}
706
+ tabindex={label === activeLabel ? 0 : -1}
670
707
  onclick={() => switchLabel(label)}
671
708
  >
672
709
  {viewLabel(label)}
@@ -675,7 +712,9 @@ const navCtx = $derived({
675
712
  </div>
676
713
  {/if}
677
714
  {#if showModePills && modes.length > 1}
678
- <div class="cal-pills" role="radiogroup" aria-label={L.viewMode}>
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}>
679
718
  {#each modes as g (g)}
680
719
  <button
681
720
  type="button"
@@ -683,6 +722,7 @@ const navCtx = $derived({
683
722
  class:cal-pill--active={viewState.mode === g}
684
723
  role="radio"
685
724
  aria-checked={viewState.mode === g}
725
+ tabindex={g === activeMode ? 0 : -1}
686
726
  onclick={() => switchMode(g)}
687
727
  >
688
728
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -715,6 +755,10 @@ const navCtx = $derived({
715
755
  </div>
716
756
  {/if}
717
757
 
758
+ {#if headerSnippet || !(showNavigation || (showModePills && modes.length > 1) || dateLabel)}
759
+ <span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{noteText}</span>
760
+ {/if}
761
+
718
762
  <div class="cal-body">
719
763
  {#if activeView}
720
764
  {@const Comp = activeView.component}
@@ -755,7 +799,7 @@ const navCtx = $derived({
755
799
  overflow: clip;
756
800
  display: flex;
757
801
  flex-direction: column;
758
- border: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
802
+ border: 1px solid var(--dt-border, rgba(15, 15, 15, 0.08));
759
803
  box-sizing: border-box;
760
804
  }
761
805
  .cal--auto {
@@ -775,11 +819,11 @@ const navCtx = $derived({
775
819
  align-items: center;
776
820
  gap: 10px 24px;
777
821
  padding: 14px 16px 12px;
778
- border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
822
+ border-bottom: 1px solid var(--dt-border, rgba(15, 15, 15, 0.08));
779
823
  flex-shrink: 0;
780
824
  box-sizing: border-box;
781
825
  font-family: var(--dt-sans, system-ui, sans-serif);
782
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
826
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
783
827
  }
784
828
 
785
829
  .cal-hd-title {
@@ -819,10 +863,10 @@ const navCtx = $derived({
819
863
  .cal-hd-btn {
820
864
  box-sizing: border-box;
821
865
  height: var(--cal-ctl-h);
822
- border: 2px solid var(--dt-text, rgba(0, 0, 0, 0.87));
866
+ border: 2px solid var(--dt-text, rgba(15, 15, 15, 0.87));
823
867
  border-radius: var(--cal-ctl-r);
824
868
  background: transparent;
825
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
869
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
826
870
  cursor: pointer;
827
871
  transition: background-color 120ms, opacity 120ms;
828
872
  -webkit-tap-highlight-color: transparent;
@@ -848,7 +892,7 @@ const navCtx = $derived({
848
892
  }
849
893
  .cal-hd-today:active:not(:disabled),
850
894
  .cal-hd-btn:active {
851
- background: color-mix(in srgb, var(--dt-text, #000) 12%, transparent);
895
+ background: color-mix(in srgb, var(--dt-text, #0f0f0f) 12%, transparent);
852
896
  }
853
897
  /* Today is already in view: the frame stays, quietly */
854
898
  .cal-hd-today:disabled {
@@ -859,7 +903,7 @@ const navCtx = $derived({
859
903
  .cal-hd-today:focus-visible,
860
904
  .cal-hd-btn:focus-visible,
861
905
  .cal-pill:focus-visible {
862
- outline: 2px solid var(--dt-text, rgba(0, 0, 0, 0.87));
906
+ outline: 2px solid var(--dt-text, rgba(15, 15, 15, 0.87));
863
907
  outline-offset: 2px;
864
908
  }
865
909
 
@@ -875,12 +919,12 @@ const navCtx = $derived({
875
919
  align-self: stretch;
876
920
  align-items: center;
877
921
  padding-inline-start: 24px;
878
- border-inline-start: 1px solid var(--dt-border, rgba(0, 0, 0, 0.12));
922
+ border-inline-start: 1px solid var(--dt-border, rgba(15, 15, 15, 0.12));
879
923
  }
880
924
  .cal-pill {
881
925
  border: none;
882
926
  background: transparent;
883
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
927
+ color: var(--dt-text-2, rgba(15, 15, 15, 0.54));
884
928
  cursor: pointer;
885
929
  font: 400 15px/1 var(--dt-sans, system-ui, sans-serif);
886
930
  min-height: var(--cal-ctl-h);
@@ -896,11 +940,11 @@ const navCtx = $derived({
896
940
  }
897
941
  /* :not(--active) — iOS keeps :hover stuck after a tap */
898
942
  .cal-pill:hover:not(.cal-pill--active) {
899
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
900
- text-decoration-color: var(--dt-border-day, var(--dt-border, rgba(0, 0, 0, 0.3)));
943
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
944
+ text-decoration-color: var(--dt-border-day, var(--dt-border, rgba(15, 15, 15, 0.3)));
901
945
  }
902
946
  .cal-pill--active {
903
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
947
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
904
948
  text-decoration-color: currentColor;
905
949
  }
906
950
 
@@ -936,6 +980,9 @@ const navCtx = $derived({
936
980
  .cal-hd {
937
981
  --cal-ctl-h: 44px;
938
982
  }
983
+ .cal-pill {
984
+ min-width: 44px;
985
+ }
939
986
  }
940
987
 
941
988
  @media (prefers-reduced-motion: reduce) {
@@ -962,7 +1009,7 @@ const navCtx = $derived({
962
1009
  justify-content: center;
963
1010
  height: 100%;
964
1011
  font: 400 13px / 1 var(--dt-sans, system-ui, sans-serif);
965
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1012
+ color: var(--dt-text-3, rgba(15, 15, 15, 0.38));
966
1013
  }
967
1014
 
968
1015
  .cal-loading {
@@ -974,7 +1021,7 @@ const navCtx = $derived({
974
1021
  background: linear-gradient(
975
1022
  90deg,
976
1023
  transparent 0%,
977
- var(--dt-accent, #2563eb) 50%,
1024
+ var(--dt-accent, #1d4fd7) 50%,
978
1025
  transparent 100%
979
1026
  );
980
1027
  animation: cal-slide 1.2s ease-in-out infinite;
@@ -988,7 +1035,7 @@ const navCtx = $derived({
988
1035
  @media (prefers-reduced-motion: reduce) {
989
1036
  .cal-loading {
990
1037
  animation: none;
991
- background: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
1038
+ background: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
992
1039
  }
993
1040
  }
994
1041
 
@@ -69,7 +69,7 @@ export interface CalendarProps {
69
69
  mondayStart?: boolean;
70
70
  /** Total height. Use `'auto'` to let content determine height (ideal for Agenda views). */
71
71
  height?: number | 'auto';
72
- /** Border radius in px (default: 12). Set to 0 for no rounding. */
72
+ /** Border radius in px (default: 0, square-cut). */
73
73
  borderRadius?: number;
74
74
  /** Text direction: 'ltr' (default), 'rtl', or 'auto' */
75
75
  dir?: 'ltr' | 'rtl' | 'auto';
@@ -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
  /**
@@ -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 String(h);
113
- }
114
- if (h === 0)
115
- return '12a';
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 [String(h), '00'];
127
- return [String(h % 12 || 12), h < 12 ? 'am' : 'pm'];
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
- const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
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(new Date(dayMs).getDate());
11
+ const dayNum = $derived(fmtDayNum(dayMs));
12
12
  </script>
13
13
 
14
14
  <div
@@ -43,18 +43,18 @@ const dayNum = $derived(new Date(dayMs).getDate());
43
43
  font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
44
44
  letter-spacing: 0.04em;
45
45
  text-transform: uppercase;
46
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
46
+ color: var(--dt-text-2, rgba(15, 15, 15, 0.54));
47
47
  }
48
48
  .dh-name-long {
49
49
  font-size: 12px;
50
50
  text-transform: none;
51
51
  letter-spacing: 0.01em;
52
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
52
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
53
53
  }
54
54
 
55
55
  .dh-num {
56
56
  font: 600 14px / 1 var(--dt-sans, system-ui, sans-serif);
57
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
57
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
58
58
  width: 28px;
59
59
  height: 28px;
60
60
  display: flex;
@@ -63,20 +63,20 @@ const dayNum = $derived(new Date(dayMs).getDate());
63
63
  border-radius: 50%;
64
64
  }
65
65
  .dh-num-today {
66
- background: var(--dt-accent, #2563eb);
66
+ background: var(--dt-accent, #1d4fd7);
67
67
  color: var(--dt-btn-text, #fff);
68
68
  }
69
69
 
70
70
  .dh-date {
71
71
  font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
72
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
72
+ color: var(--dt-text-2, rgba(15, 15, 15, 0.54));
73
73
  }
74
74
 
75
75
  .dh-rel {
76
76
  font: 500 12px / 1.3 var(--dt-sans, system-ui, sans-serif);
77
- color: var(--dt-text, rgba(0, 0, 0, 0.87));
77
+ color: var(--dt-text, rgba(15, 15, 15, 0.87));
78
78
  }
79
79
  .dh-today .dh-rel {
80
- color: var(--dt-accent, #2563eb);
80
+ color: var(--dt-accent, #1d4fd7);
81
81
  }
82
82
  </style>
@@ -49,13 +49,13 @@ function handleKeydown(e) {
49
49
  min-height: 24px;
50
50
  }
51
51
  .es:hover {
52
- border-color: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
53
- background: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
52
+ border-color: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
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-accent, #2563eb);
56
+ outline: 2px solid var(--dt-text, #0f0f0f);
57
57
  outline-offset: 2px;
58
- border-color: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
58
+ border-color: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
59
59
  }
60
60
 
61
61
  .es-hint {
@@ -74,12 +74,12 @@ function handleKeydown(e) {
74
74
 
75
75
  .es-plus {
76
76
  font: 500 14px / 1 var(--dt-sans, system-ui, sans-serif);
77
- color: var(--dt-accent, #2563eb);
77
+ color: var(--dt-accent, #1d4fd7);
78
78
  }
79
79
  .es-range {
80
80
  font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
81
81
  font-variant-numeric: tabular-nums;
82
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
82
+ color: var(--dt-text-2, rgba(15, 15, 15, 0.54));
83
83
  }
84
84
 
85
85
  .es-v {