@nomideusz/svelte-calendar 0.9.0 → 0.10.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.
@@ -8,14 +8,11 @@
8
8
  <script lang="ts">import { onMount, tick, untrack } from "svelte";
9
9
  import { useCalendarContext } from "../shared/context.svelte.js";
10
10
  import EventContent from "../shared/EventContent.svelte";
11
- import { fly } from "svelte/transition";
12
11
  import { createClock } from "../../core/clock.svelte.js";
13
12
  import { createTextMeasure } from "../../core/measure.js";
14
13
  import { DAY_MS, HOUR_MS, sod } from "../../core/time.js";
15
14
  import { isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
16
- import { fmtH, fmtTime, weekdayShort } from "../../core/locale.js";
17
- import { getLabels } from "../../core/locale.js";
18
- const L = $derived(getLabels());
15
+ import { fmtDuration, fmtH, fmtTime, weekdayShort } from "../../core/locale.js";
19
16
  let {
20
17
  height = 520,
21
18
  events = [],
@@ -29,6 +26,7 @@ let {
29
26
  visibleHours
30
27
  } = $props();
31
28
  const ctx = useCalendarContext();
29
+ const L = $derived(ctx.labels);
32
30
  const clock = createClock(ctx.timezone);
33
31
  const drag = $derived(ctx.drag);
34
32
  const commitDragCtx = $derived(ctx.commitDrag);
@@ -92,8 +90,8 @@ function timeToPx(ms) {
92
90
  const elapsed = ms - origin;
93
91
  const dayIndex = Math.floor(elapsed / DAY_MS);
94
92
  const hourInDay = (elapsed - dayIndex * DAY_MS) / HOUR_MS;
95
- const hourOffset = hourInDay - startHour;
96
- return dayIndex * (dayWidth + DAY_GAP) + hourOffset * hourWidth;
93
+ const clamped = Math.min(Math.max(hourInDay, startHour), endHour);
94
+ return dayIndex * (dayWidth + DAY_GAP) + (clamped - startHour) * hourWidth;
97
95
  }
98
96
  function pxToTime(px) {
99
97
  const dayStride = dayWidth + DAY_GAP;
@@ -103,6 +101,8 @@ function pxToTime(px) {
103
101
  return origin + dayIndex * DAY_MS + hour * HOUR_MS;
104
102
  }
105
103
  const nowPx = $derived(timeToPx(clock.tick));
104
+ const nowHour = $derived((clock.tick - clock.today) / HOUR_MS);
105
+ const nowInBand = $derived(nowHour >= startHour && nowHour < endHour);
106
106
  const timedEvents = $derived(events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
107
107
  const allDayEvents = $derived.by(() => {
108
108
  const segs = [];
@@ -126,8 +126,25 @@ const measure = createTextMeasure({
126
126
  secondaryLineHeight: 13,
127
127
  contentGap: 6
128
128
  });
129
- const positionedEvents = $derived.by(() => {
129
+ const nowInfo = $derived.by(() => {
130
130
  const now = clock.tick;
131
+ const current = /* @__PURE__ */ new Set();
132
+ const todayStart = clock.today;
133
+ const todayEnd = todayStart + DAY_MS;
134
+ let nextId = null;
135
+ let nextStart = Infinity;
136
+ for (const ev of timedEvents) {
137
+ const s = ev.start.getTime();
138
+ const e = ev.end.getTime();
139
+ if (s <= now && e > now) current.add(ev.id);
140
+ else if (s >= todayStart && s < todayEnd && s > now && s < nextStart) {
141
+ nextStart = s;
142
+ nextId = ev.id;
143
+ }
144
+ }
145
+ return { current, nextId };
146
+ });
147
+ const positionedEvents = $derived.by(() => {
131
148
  const dragP = drag?.active && (drag.mode === "move" || drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
132
149
  const staticEvents = [];
133
150
  let draggedEv = null;
@@ -136,17 +153,6 @@ const positionedEvents = $derived.by(() => {
136
153
  else staticEvents.push(ev);
137
154
  }
138
155
  const sorted = [...staticEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
139
- const todayStart = sod(now);
140
- const todayEnd = todayStart + DAY_MS;
141
- let nextEventId = null;
142
- for (const ev of sorted) {
143
- const s = ev.start.getTime();
144
- const e = ev.end.getTime();
145
- if (s >= todayStart && s < todayEnd && s > now && !(s <= now && e > now)) {
146
- nextEventId = ev.id;
147
- break;
148
- }
149
- }
150
156
  const infos = sorted.map((ev) => {
151
157
  const s = ev.start.getTime();
152
158
  const e = ev.end.getTime();
@@ -158,13 +164,12 @@ const positionedEvents = $derived.by(() => {
158
164
  width: Math.max(xEnd - x, 28),
159
165
  row: 0,
160
166
  groupMaxRow: 1,
161
- isCurrent: s <= now && e > now,
162
- isNext: ev.id === nextEventId,
163
167
  isDragged: false,
164
168
  startMs: s,
165
- endMs: e
169
+ endMs: e,
170
+ clippedWidth: xEnd - x
166
171
  };
167
- });
172
+ }).filter((info) => info.clippedWidth > 0);
168
173
  const par = infos.map((_, i) => i);
169
174
  function find(i) {
170
175
  while (par[i] !== i) {
@@ -204,7 +209,7 @@ const positionedEvents = $derived.by(() => {
204
209
  for (const idx of indices) infos[idx].groupMaxRow = rows.length;
205
210
  }
206
211
  const availH = containerH - contentTop - 8;
207
- const result = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
212
+ const result = infos.map(({ startMs: _s, endMs: _e, clippedWidth: _c, ...info }) => {
208
213
  const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
209
214
  const topPx = contentTop + info.row * (availH / info.groupMaxRow);
210
215
  const fit = measure.fitContent({
@@ -216,7 +221,7 @@ const positionedEvents = $derived.by(() => {
216
221
  maxWidth: laneH - 16,
217
222
  maxHeight: info.width - 16
218
223
  });
219
- return { ...info, topPx, heightPx: laneH, isNext: info.isNext, fit };
224
+ return { ...info, topPx, heightPx: laneH, fit };
220
225
  });
221
226
  if (draggedEv && dragP) {
222
227
  const x = timeToPx(dragP.start.getTime());
@@ -231,8 +236,6 @@ const positionedEvents = $derived.by(() => {
231
236
  groupMaxRow: 1,
232
237
  topPx: contentTop,
233
238
  heightPx: dragH,
234
- isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
235
- isNext: false,
236
239
  isDragged: true,
237
240
  fit: measure.fitContent({
238
241
  title: draggedEv.title,
@@ -297,16 +300,20 @@ function syncFocusFromScroll() {
297
300
  viewState.setFocusDate(new Date(centerDayMs));
298
301
  }
299
302
  }
300
- onMount(() => {
301
- const ro = new ResizeObserver((entries) => {
302
- for (const entry of entries) {
303
- containerW = entry.contentRect.width;
304
- containerH = entry.contentRect.height;
305
- }
303
+ let scrollRaf = 0;
304
+ function handleScroll() {
305
+ if (following || rebasing || scrollRaf) return;
306
+ scrollRaf = requestAnimationFrame(() => {
307
+ scrollRaf = 0;
308
+ if (!el || following || rebasing) return;
309
+ checkEdges();
310
+ syncFocusFromScroll();
306
311
  });
307
- ro.observe(el);
312
+ }
313
+ $effect(() => {
314
+ if (!following) return;
308
315
  function frame() {
309
- if (following && el && !scrollDragging) {
316
+ if (el && !scrollDragging) {
310
317
  if (internalCenterMs !== clock.today) {
311
318
  internalCenterMs = clock.today;
312
319
  lastExternalMs = clock.today;
@@ -317,15 +324,22 @@ onMount(() => {
317
324
  if (todayD) {
318
325
  el.scrollLeft = todayD.x + dayWidth / 2 - el.clientWidth / 2;
319
326
  }
320
- } else if (el && !rebasing) {
321
- checkEdges();
322
- syncFocusFromScroll();
323
327
  }
324
328
  rafId = requestAnimationFrame(frame);
325
329
  }
326
330
  rafId = requestAnimationFrame(frame);
331
+ return () => cancelAnimationFrame(rafId);
332
+ });
333
+ onMount(() => {
334
+ const ro = new ResizeObserver((entries) => {
335
+ for (const entry of entries) {
336
+ containerW = entry.contentRect.width;
337
+ containerH = entry.contentRect.height;
338
+ }
339
+ });
340
+ ro.observe(el);
327
341
  return () => {
328
- cancelAnimationFrame(rafId);
342
+ cancelAnimationFrame(scrollRaf);
329
343
  ro.disconnect();
330
344
  };
331
345
  });
@@ -558,20 +572,52 @@ function onResizeCancel() {
558
572
  if (drag && rsStarted) drag.cancel();
559
573
  cleanupResize();
560
574
  }
575
+ function onWindowKeydown(e) {
576
+ if (e.key !== "Escape" || !drag?.active) return;
577
+ drag.cancel();
578
+ cleanupCreateDrag();
579
+ cleanupEvDrag();
580
+ cleanupResize();
581
+ wasDragging = true;
582
+ window.addEventListener(
583
+ "pointerup",
584
+ () => setTimeout(() => {
585
+ wasDragging = false;
586
+ }, 0),
587
+ { once: true }
588
+ );
589
+ }
561
590
  </script>
562
591
 
592
+ <svelte:window onkeydown={onWindowKeydown} />
593
+
563
594
  <div class="fs" class:fs--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')} role="region" aria-label={L.dayPlanner}>
564
595
  <div
565
596
  class="fs-scroll"
566
597
  class:fs-grabbing={scrollDragging}
567
598
  class:fs-readonly={readOnly}
568
599
  bind:this={el}
569
- onwheel={(e) => { e.preventDefault(); el.scrollLeft += e.deltaY || e.deltaX; following = false; }}
600
+ onwheel={(e) => {
601
+ const delta = e.deltaY || e.deltaX;
602
+ if (delta === 0) return;
603
+ // Only capture the wheel when the track can consume the delta in
604
+ // that direction — otherwise let the page scroll normally.
605
+ const maxScroll = el.scrollWidth - el.clientWidth;
606
+ const canConsume = delta < 0 ? el.scrollLeft > 0 : el.scrollLeft < maxScroll - 1;
607
+ if (!canConsume) return;
608
+ e.preventDefault();
609
+ el.scrollLeft += delta;
610
+ following = false;
611
+ }}
612
+ onscroll={handleScroll}
570
613
  onpointerdown={onPointerDown}
571
- role="application"
614
+ role="region"
572
615
  aria-label={L.scrollableDayPlanner}
573
616
  >
574
- <div class="fs-track" style:width="{totalWidth}px" onclick={handleTrackClick} role="none">
617
+ <!-- Presentational layer: the click handler is a convenience delegate for
618
+ click-to-create on empty canvas; keyboard users create events via the
619
+ host UI, and events themselves are focusable buttons. -->
620
+ <div class="fs-track" style:width="{totalWidth}px" onclick={handleTrackClick} role="presentation">
575
621
  {#each days as d (d.ms)}
576
622
  <div
577
623
  class="fs-day"
@@ -623,19 +669,23 @@ function onResizeCancel() {
623
669
  </div>
624
670
  {/each}
625
671
 
626
- <!-- Now line -->
627
- <div class="fs-now" style:left="{nowPx}px">
628
- <span class="fs-now-tag">{clock.hm}<span class="fs-now-sec">{clock.s}</span></span>
629
- <div class="fs-now-line"></div>
630
- </div>
672
+ <!-- Now line (hidden when the clock is outside the visible hour band) -->
673
+ {#if nowInBand}
674
+ <div class="fs-now" style:left="{nowPx}px">
675
+ <span class="fs-now-tag">{clock.hm}</span>
676
+ <div class="fs-now-line"></div>
677
+ </div>
678
+ {/if}
631
679
 
632
680
  <!-- Events -->
633
681
  {#each positionedEvents as p (p.ev.id)}
682
+ {@const isCurrent = nowInfo.current.has(p.ev.id)}
683
+ {@const isNext = !p.isDragged && p.ev.id === nowInfo.nextId}
634
684
  <div
635
685
  class="fs-event"
636
686
  class:fs-event--selected={selectedEventId === p.ev.id}
637
- class:fs-event--current={p.isCurrent}
638
- class:fs-event--next={p.isNext}
687
+ class:fs-event--current={isCurrent}
688
+ class:fs-event--next={isNext}
639
689
  class:fs-event--dragging={p.isDragged}
640
690
  class:fs-event--resizing={p.isDragged && drag?.mode !== 'move'}
641
691
  class:fs-event--readonly={p.ev.data?.readOnly}
@@ -651,15 +701,15 @@ function onResizeCancel() {
651
701
  role="button"
652
702
  tabindex="0"
653
703
  title={p.ev.title}
654
- aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
704
+ aria-label="{p.ev.title}, {fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}, {fmtDuration(p.ev.start, p.ev.end)}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{isCurrent ? ` (${L.inProgress})` : ''}{isNext ? ` (${L.upNext})` : ''}"
655
705
  onpointerdown={(e) => onEventPointerDown(e, p.ev)}
656
706
  onpointerenter={() => oneventhover?.(p.ev)}
657
707
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
658
708
  >
659
709
  <div class="fs-ev-inner">
660
- {#if p.isCurrent}
710
+ {#if isCurrent}
661
711
  <span class="fs-ev-live" aria-hidden="true"></span>
662
- {:else if p.isNext}
712
+ {:else if isNext}
663
713
  <span class="fs-ev-next-badge" aria-hidden="true">{L.upNext}</span>
664
714
  {/if}
665
715
  <EventContent event={p.ev}>
@@ -797,9 +847,10 @@ function onResizeCancel() {
797
847
  border-left: 1px solid var(--dt-border-day, rgba(0, 0, 0, 0.14));
798
848
  box-sizing: border-box;
799
849
  }
800
- .fs-day:first-of-type { border-left: none; }
801
- .fs-today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
802
- .fs-past { opacity: 0.7; }
850
+ .fs-today { background: var(--dt-today-bg, color-mix(in srgb, var(--dt-accent, #2563eb) 8%, transparent)); }
851
+ /* Past days: dim via a background wash instead of a subtree opacity so
852
+ event text keeps full contrast. */
853
+ .fs-past { background: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent); }
803
854
 
804
855
  /* ─── Disabled day ───────────────────────────────── */
805
856
  .fs-disabled {
@@ -877,12 +928,11 @@ function onResizeCancel() {
877
928
  white-space: nowrap;
878
929
  pointer-events: none;
879
930
  }
880
- .fs-tick--half { bottom: auto; height: calc(18px + 8px); }
931
+ /* Half-hour guide: full-height line at low opacity through the event area */
881
932
  .fs-tick--half::before {
882
933
  top: 18px;
883
- height: 6px;
884
- bottom: auto;
885
- opacity: 0.4;
934
+ bottom: 0;
935
+ opacity: 0.35;
886
936
  }
887
937
 
888
938
  /* ─── Now-line ────────────────────────────────────── */
@@ -905,7 +955,9 @@ function onResizeCancel() {
905
955
  }
906
956
  .fs-now-tag {
907
957
  position: absolute;
908
- top: 8px;
958
+ /* Below the hour-label row (labels sit at top: 2px) so the tag never
959
+ collides with an hour label near hour boundaries. */
960
+ top: 20px;
909
961
  left: 8px;
910
962
  font: 700 11px/1 var(--dt-mono, ui-monospace, monospace);
911
963
  color: var(--dt-accent, #2563eb);
@@ -916,13 +968,9 @@ function onResizeCancel() {
916
968
  white-space: nowrap;
917
969
  z-index: 1;
918
970
  }
919
- .fs-now-sec {
920
- font-weight: 400;
921
- opacity: 0.5;
922
- font-size: 10px;
923
- }
924
-
925
971
  /* ─── All-day strip ─────────────────────────────── */
972
+ /* The container is a full-width overlay — let clicks pass through it and
973
+ only the chips themselves capture pointer events. */
926
974
  .fs-allday {
927
975
  position: absolute;
928
976
  left: 0;
@@ -933,7 +981,7 @@ function onResizeCancel() {
933
981
  z-index: 7;
934
982
  overflow-x: auto;
935
983
  scrollbar-width: none;
936
- pointer-events: auto;
984
+ pointer-events: none;
937
985
  }
938
986
  .fs-allday::-webkit-scrollbar { display: none; }
939
987
 
@@ -947,15 +995,18 @@ function onResizeCancel() {
947
995
  border-left: 3px solid var(--ev-color);
948
996
  white-space: nowrap;
949
997
  flex-shrink: 0;
998
+ min-width: 0;
999
+ max-width: 320px;
950
1000
  cursor: pointer;
951
1001
  transition: background 0.15s;
1002
+ pointer-events: auto;
952
1003
  }
953
1004
  .fs-ad:hover {
954
1005
  background: color-mix(in srgb, var(--ev-color) 28%, var(--dt-surface, var(--dt-bg, #ffffff)));
955
1006
  }
956
1007
  .fs-ad:focus-visible {
957
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
958
- outline-offset: 2px;
1008
+ outline: none;
1009
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
959
1010
  }
960
1011
  .fs-ad--selected {
961
1012
  background: color-mix(in srgb, var(--ev-color) 30%, var(--dt-surface, var(--dt-bg, #ffffff)));
@@ -974,7 +1025,8 @@ function onResizeCancel() {
974
1025
  font-size: 0.7rem;
975
1026
  font-weight: 500;
976
1027
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
977
- max-width: 120px;
1028
+ flex: 0 1 auto;
1029
+ min-width: 0;
978
1030
  overflow: hidden;
979
1031
  text-overflow: ellipsis;
980
1032
  }
@@ -990,7 +1042,10 @@ function onResizeCancel() {
990
1042
  position: absolute;
991
1043
  z-index: 6;
992
1044
  border-radius: 6px;
993
- cursor: pointer;
1045
+ /* Editable events are grabbable; touch drags move the event instead of
1046
+ scrolling the strip. */
1047
+ cursor: grab;
1048
+ touch-action: none;
994
1049
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
995
1050
  border: 1px solid color-mix(in srgb, var(--ev-color) 40%, transparent);
996
1051
  /* Solid stripe at the start edge — matches the week view, keeps the
@@ -1041,6 +1096,15 @@ function onResizeCancel() {
1041
1096
  cursor: ew-resize;
1042
1097
  touch-action: none;
1043
1098
  }
1099
+ /* Hit-slop: ~20px effective grab zone while the visual stays 6px */
1100
+ .fs-ev-handle::before {
1101
+ content: '';
1102
+ position: absolute;
1103
+ top: 0;
1104
+ bottom: 0;
1105
+ left: -7px;
1106
+ right: -7px;
1107
+ }
1044
1108
  .fs-ev-handle--start { left: 0; }
1045
1109
  .fs-ev-handle--end { right: 0; }
1046
1110
  .fs-ev-handle::after {
@@ -1055,7 +1119,12 @@ function onResizeCancel() {
1055
1119
  opacity: 0;
1056
1120
  transition: opacity 120ms;
1057
1121
  }
1058
- .fs-event:hover .fs-ev-handle::after { opacity: 0.55; }
1122
+ .fs-event:hover .fs-ev-handle::after,
1123
+ .fs-event:focus-within .fs-ev-handle::after { opacity: 0.55; }
1124
+ /* Coarse pointers can't hover — show the grips persistently */
1125
+ @media (hover: none) {
1126
+ .fs-ev-handle::after { opacity: 0.55; }
1127
+ }
1059
1128
 
1060
1129
  /* ─── Drag-to-create ghost ───────────────────────── */
1061
1130
  .fs-create-ghost {
@@ -1082,12 +1151,16 @@ function onResizeCancel() {
1082
1151
  .fs-event--dragging {
1083
1152
  transition: box-shadow 120ms, background 120ms;
1084
1153
  }
1154
+ .fs-create-ghost,
1155
+ .fs-ad,
1156
+ .fs-ev-handle::after {
1157
+ transition: none;
1158
+ }
1085
1159
  }
1086
- .fs-event--cancelled {
1087
- opacity: 0.5;
1088
- }
1160
+ /* Cancelled: strikethrough + secondary text, not a subtree opacity dim */
1089
1161
  .fs-event--cancelled .fs-ev-title {
1090
1162
  text-decoration: line-through;
1163
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1091
1164
  }
1092
1165
  .fs-event--tentative {
1093
1166
  opacity: 0.65;
@@ -1100,7 +1173,8 @@ function onResizeCancel() {
1100
1173
  opacity: 0.65;
1101
1174
  border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1102
1175
  }
1103
- .fs-event--readonly {
1176
+ .fs-event--readonly,
1177
+ .fs-readonly .fs-event {
1104
1178
  cursor: default;
1105
1179
  }
1106
1180
 
@@ -1149,6 +1223,7 @@ function onResizeCancel() {
1149
1223
  display: -webkit-box;
1150
1224
  -webkit-box-orient: vertical;
1151
1225
  -webkit-line-clamp: 2;
1226
+ line-clamp: 2;
1152
1227
  white-space: normal;
1153
1228
  max-height: 100%;
1154
1229
  flex-shrink: 0;
@@ -1156,14 +1231,12 @@ function onResizeCancel() {
1156
1231
  .fs-ev-time {
1157
1232
  font: 400 10px/1 var(--dt-mono, ui-monospace, monospace);
1158
1233
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1159
- opacity: 0.7;
1160
1234
  white-space: nowrap;
1161
1235
  flex-shrink: 0;
1162
1236
  }
1163
1237
  .fs-ev-sub {
1164
1238
  font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
1165
1239
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1166
- opacity: 0.6;
1167
1240
  white-space: nowrap;
1168
1241
  overflow: hidden;
1169
1242
  text-overflow: ellipsis;
@@ -1172,7 +1245,7 @@ function onResizeCancel() {
1172
1245
  }
1173
1246
  .fs-ev-loc {
1174
1247
  font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
1175
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1248
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1176
1249
  white-space: nowrap;
1177
1250
  overflow: hidden;
1178
1251
  text-overflow: ellipsis;
@@ -1195,9 +1268,11 @@ function onResizeCancel() {
1195
1268
  }
1196
1269
 
1197
1270
  /* ─── Focus-visible ──────────────────────────────── */
1271
+ /* box-shadow instead of outline: outlines get clipped by the
1272
+ overflow: hidden scroll container. */
1198
1273
  .fs-event:focus-visible {
1199
- outline: 2px solid var(--dt-accent, #2563eb);
1200
- outline-offset: 2px;
1274
+ outline: none;
1275
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
1201
1276
  }
1202
1277
  </style>
1203
1278