@tasknotes/calendar 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.7
6
+
7
+ ### Added
8
+
9
+ - Hosts can read collision column counts and the unobscured timed content width, choose views responsively, fill an empty list through a content hook, and translate the stack label with the `appointments` text.
10
+
11
+ ### Fixed
12
+
13
+ - Collision stacks show appointment counts and open only the matching collision group, without unrelated all-day or timed items. Narrow time grids reveal the selected day on entry and view changes, not only after Today.
14
+ - Crowded time-grid days use readable collision stacks without widening the entire week; stack contents retain drag-and-drop, including after moving the calendar into another window.
15
+ - Today reveals the current day horizontally, with persistent day navigation when the grid is wider than its pane.
16
+ - Overflow dialogs clamp to the visible viewport, reposition on resize and scroll, keep Close reachable, and provide touch-sized rows and keyboard navigation.
17
+ - Collision stacks honour `eventMaxStack`, and overflow triggers stay stable across rerenders, so Escape returns focus to them.
18
+
3
19
  ## 0.1.6
4
20
 
5
21
  ### Changed
package/README.md CHANGED
@@ -69,8 +69,15 @@ to 00:00–24:00 in 30-minute slots, initially scrolled to 08:00.
69
69
  `text` supplies translated labels; `itemContent` and related hooks supply
70
70
  custom DOM. The exported `CalendarOptions<D>` type documents the full API.
71
71
 
72
+ `toolbar.viewDisplay` defaults to `"auto"`: full view-name buttons become a
73
+ native select when the toolbar cannot fit them. Use `"buttons"` or `"select"`
74
+ to choose a fixed presentation. The select uses the same translated view names
75
+ and changes views through the same navigation lifecycle as the buttons.
76
+
72
77
  ### Month and list
73
78
 
79
+ - `listEmptyContent()` may return a host-owned DOM node for an empty list, such as an explanation and recovery action. The default translated empty message remains when omitted. The node is removed when the list gains items.
80
+
74
81
  - `monthDisplay: "dots"` draws every item in a month cell as a small dot (`--tnc-dot-size`, `--tnc-dot-gap`) that wraps across the cell, with "+N" when they do not fit; `dayMaxEvents` caps the dots (`false` lets cells grow). The day cell is the tap target: dots ignore the pointer, are `aria-hidden`, get no content hook, and the cell is labelled "<date>, N items" (`text.itemCount`). Items in dots have `variant: "indicator"`; colour them with `color` or `itemDidMount` setting `--tnc-c`.
75
82
  - **"+N more" height**: day rows (and the time grid's all-day row) make room for the link at its rendered height, so a host may give it a 44px touch target (`--tnc-more-h`, or any min-height) without overflowing the row.
76
83
  - `listStart: "date" | "week"`: the list view starts at the date (default; FullCalendar `duration: { days: n }` lists, as the TaskNotes plugin and app used) or at the start of its week (FullCalendar's stock `listWeek`); week-aligned lists step by whole weeks.
@@ -105,6 +112,30 @@ custom DOM. The exported `CalendarOptions<D>` type documents the full API.
105
112
  Set `--tnc-theme-*` variables on the host (they inherit into the calendar)
106
113
  or on `.tnc`. The stylesheet is scoped to `.tnc` and does not inject CSS.
107
114
 
115
+ Time grids collapse crowded collision groups into labelled, expandable stacks
116
+ instead of widening every day. `--tnc-event-min-width` sets the readable event
117
+ width in pixels (default 128). Keep the day minimum independent of collisions:
118
+
119
+ ```css
120
+ .my-calendar .tnc-tg {
121
+ --tnc-col-min: 128px;
122
+ --tnc-event-min-width: 128px;
123
+ }
124
+ ```
125
+
126
+ When the day minimums exceed a narrow viewport, persistent day buttons reveal
127
+ offscreen dates, and Today reveals the current day. Headers, all-day rows and
128
+ timed columns stay aligned. `--tnc-collision-columns` remains available as
129
+ diagnostic metadata (respecting `eventMaxStack`), not a week-wide width multiplier.
130
+ Overflow dialogs clamp to the visible calendar and viewport, scroll vertically,
131
+ and support Tab, arrow keys, Home, End and Escape. Timed items dragged from
132
+ overflow start at their actual time rather than the slot underneath the dialog.
133
+
134
+ Timed events also expose `--tnc-event-readable-width`, the percentage of their
135
+ width not covered by the next collision column. Hosts can use it as the
136
+ `max-width` of `.tnc-item-main` to keep text readable while preserving the user's
137
+ overlapping event backgrounds. It is `100%` for non-overlapping placements.
138
+
108
139
  ```css
109
140
  .my-calendar {
110
141
  --tnc-theme-bg: #181818;
package/dist/calendar.css CHANGED
@@ -467,6 +467,35 @@
467
467
  overflow: auto;
468
468
  overscroll-behavior: contain;
469
469
  }
470
+ .tnc-tg-day-navigation {
471
+ display: flex;
472
+ flex-wrap: wrap;
473
+ gap: 2px;
474
+ flex: none;
475
+ }
476
+ .tnc-tg-day-navigation[hidden] {
477
+ display: none;
478
+ }
479
+ .tnc-tg-day-navigation .is-offscreen {
480
+ text-decoration: underline;
481
+ text-underline-offset: 3px;
482
+ }
483
+ .tnc-more--collision {
484
+ display: flex;
485
+ align-items: start;
486
+ gap: 4px;
487
+ min-height: 40px;
488
+ white-space: normal;
489
+ text-align: start;
490
+ }
491
+ .tnc-collision-title {
492
+ flex: 1;
493
+ min-width: 0;
494
+ display: -webkit-box;
495
+ -webkit-box-orient: vertical;
496
+ -webkit-line-clamp: 3;
497
+ overflow: hidden;
498
+ }
470
499
  .tnc--height-auto .tnc-tg-scroll {
471
500
  overflow: visible;
472
501
  }
@@ -1620,6 +1649,7 @@
1620
1649
  outline: none;
1621
1650
  }
1622
1651
  .tnc-popover-head {
1652
+ flex: none;
1623
1653
  display: flex;
1624
1654
  align-items: center;
1625
1655
  justify-content: space-between;
@@ -1629,6 +1659,7 @@
1629
1659
  font-weight: 650;
1630
1660
  }
1631
1661
  .tnc-popover-close {
1662
+ flex: none;
1632
1663
  min-width: 28px;
1633
1664
  min-height: 28px;
1634
1665
  border: 0;
@@ -1642,6 +1673,7 @@
1642
1673
  background: var(--tnc-surface-3);
1643
1674
  }
1644
1675
  .tnc-popover-body {
1676
+ min-height: 0;
1645
1677
  display: flex;
1646
1678
  flex-direction: column;
1647
1679
  gap: 2px;
@@ -1652,6 +1684,27 @@
1652
1684
  position: relative;
1653
1685
  flex: none;
1654
1686
  }
1687
+ .tnc-popover-title {
1688
+ min-width: 0;
1689
+ overflow: hidden;
1690
+ text-overflow: ellipsis;
1691
+ white-space: nowrap;
1692
+ }
1693
+ .tnc-popover-body .tnc-item {
1694
+ height: auto;
1695
+ min-height: 40px;
1696
+ }
1697
+ .tnc-popover-body .tnc-item-main {
1698
+ min-height: 40px;
1699
+ }
1700
+ .tnc-popover-body .tnc-item-title {
1701
+ white-space: normal;
1702
+ overflow-wrap: anywhere;
1703
+ }
1704
+ .tnc-popover-close:focus-visible {
1705
+ outline: 2px solid var(--tnc-accent);
1706
+ outline-offset: -2px;
1707
+ }
1655
1708
 
1656
1709
  @media (prefers-reduced-motion: reduce) {
1657
1710
  .tnc * {
@@ -953,6 +953,7 @@ var DEFAULT_TEXT = {
953
953
  allDay: "All day",
954
954
  noItems: "Nothing scheduled",
955
955
  more: (n) => `+${n} more`,
956
+ appointments: (n) => `${n} appointments`,
956
957
  itemCount: (n) => n === 1 ? "1 item" : `${n} items`,
957
958
  weekNumber: (n) => `W${n}`,
958
959
  views: { week: "Week", day: "Day", days: "Days", month: "Month", list: "List", year: "Year", timeline: "Timeline" },
@@ -1022,6 +1023,7 @@ var LAYOUT_KEYS = /* @__PURE__ */ new Set([
1022
1023
  "dateStrip",
1023
1024
  "listLeadTitle",
1024
1025
  "listAddText",
1026
+ "listEmptyContent",
1025
1027
  "monthDisplay",
1026
1028
  "timeZone",
1027
1029
  "locale",
@@ -1261,19 +1263,27 @@ var DayGridView = class {
1261
1263
  });
1262
1264
  }
1263
1265
  w.pool.end();
1264
- w.more.replaceChildren();
1266
+ const existing = new Map(Array.from(w.more.children, (element) => [element.getAttribute("data-day"), element]));
1265
1267
  hidden.forEach((items, col) => {
1266
1268
  if (!items.length) return;
1267
1269
  const day = w.days[col];
1268
- const link = h(ctx.doc, "button", "tnc-more", { type: "button", "aria-haspopup": "dialog" }, ctx.text.more(items.length));
1270
+ const key = String(day);
1271
+ let link = existing.get(key);
1272
+ if (!link) {
1273
+ link = h(ctx.doc, "button", "tnc-more", { type: "button", "aria-haspopup": "dialog", "data-day": key });
1274
+ const anchor = link;
1275
+ link.addEventListener("click", (ev) => ctx.openMore(day, anchor, ev));
1276
+ w.more.append(link);
1277
+ }
1278
+ existing.delete(key);
1279
+ link.textContent = ctx.text.more(items.length);
1269
1280
  setStyle(link, {
1270
1281
  left: `calc(${pct(col / n)} + 2px)`,
1271
1282
  width: `calc(${pct(1 / n)} - 4px)`,
1272
1283
  top: `calc(var(--tnc-dg-top-h) + ${visible(col)} * (var(--tnc-bar-h) + ${BAR_GAP}px))`
1273
1284
  });
1274
- link.addEventListener("click", (ev) => ctx.openMore(day, link, ev));
1275
- w.more.append(link);
1276
1285
  });
1286
+ for (const link of existing.values()) link.remove();
1277
1287
  const step = this.barPx + BAR_GAP;
1278
1288
  let need = maxLevel >= 0 ? (maxLevel + 1) * step - BAR_GAP : 0;
1279
1289
  hidden.forEach((items, col) => {
@@ -1476,6 +1486,8 @@ var ListView = class {
1476
1486
  if (!keep.size && !leadItems.length) {
1477
1487
  if (!empty) {
1478
1488
  empty = h(doc, "div", "tnc-list-empty", { role: "listitem" }, ctx.text.noItems);
1489
+ const content = ctx.opts.listEmptyContent?.();
1490
+ if (content) empty.replaceChildren(content);
1479
1491
  this.body.append(empty);
1480
1492
  }
1481
1493
  } else empty?.remove();
@@ -1824,6 +1836,8 @@ var TimeGridView = class {
1824
1836
  this.el = h(doc, "div", "tnc-tg", { role: "table", "aria-label": ctx.view.title });
1825
1837
  this.el.classList.toggle("tnc-tg--no-grid", !showGrid);
1826
1838
  this.scroller = h(doc, "div", "tnc-tg-scroll");
1839
+ this.dayNavigation = h(doc, "div", "tnc-tg-day-navigation", { role: "group", "aria-label": ctx.view.title });
1840
+ this.scroller.addEventListener("scroll", () => this.updateDayNavigation());
1827
1841
  this.top = h(doc, "div", "tnc-tg-top");
1828
1842
  this.headRow = h(doc, "div", "tnc-tg-head tnc-tg-cols", { role: "row" });
1829
1843
  this.top.append(this.headRow);
@@ -1851,7 +1865,7 @@ var TimeGridView = class {
1851
1865
  } else {
1852
1866
  this.body = this.gutter = null;
1853
1867
  }
1854
- this.el.append(this.scroller);
1868
+ this.el.append(this.dayNavigation, this.scroller);
1855
1869
  this.build();
1856
1870
  if (showGrid && opts.height === "fill") {
1857
1871
  const RO = ctx.win.ResizeObserver;
@@ -1864,6 +1878,7 @@ var TimeGridView = class {
1864
1878
  ctx;
1865
1879
  el;
1866
1880
  scroller;
1881
+ dayNavigation;
1867
1882
  top;
1868
1883
  headRow;
1869
1884
  allDayRow;
@@ -1919,6 +1934,9 @@ var TimeGridView = class {
1919
1934
  head.append(cap);
1920
1935
  }
1921
1936
  this.headRow.append(head);
1937
+ const dayButton = h(doc, "button", "tnc-btn", { type: "button", "aria-label": ctx.fmt.longDate(day) }, `${ctx.fmt.weekdayShort(day)} ${ctx.fmt.dayNumber(day)}`);
1938
+ dayButton.addEventListener("click", () => this.revealDay(day));
1939
+ this.dayNavigation.append(dayButton);
1922
1940
  opts.dayHeaderDidMount?.({ ...arg, el: head });
1923
1941
  const col = h(doc, "div", ["tnc-col", ...ctx.dayClasses(day)].join(" "), { role: "cell", "aria-label": ctx.fmt.longDate(day), "data-date": arg.dateStr });
1924
1942
  const bgLayer = h(doc, "div", "tnc-col-bg");
@@ -1935,6 +1953,7 @@ var TimeGridView = class {
1935
1953
  this.body?.append(col);
1936
1954
  return {
1937
1955
  day,
1956
+ collisionColumns: 1,
1938
1957
  head,
1939
1958
  col,
1940
1959
  plan: new ItemPool(ctx, planLayer),
@@ -1991,12 +2010,17 @@ var TimeGridView = class {
1991
2010
  /* --------------------------------------------------------------- render */
1992
2011
  render(change) {
1993
2012
  this.renderAllDay();
1994
- if (!this.body) return;
2013
+ if (!this.body) {
2014
+ this.updateDayNavigation();
2015
+ return;
2016
+ }
1995
2017
  const dirty = change ? change.days : null;
1996
2018
  for (const c of this.cols) {
1997
2019
  if (dirty && !dirty.has(c.day) && !(this.ctx.slot.max > MINUTES_PER_DAY && dirty.has(c.day + 1))) continue;
1998
2020
  this.renderColumn(c);
1999
2021
  }
2022
+ this.el.style.setProperty("--tnc-collision-columns", String(Math.max(1, ...this.cols.map((column) => column.collisionColumns))));
2023
+ this.updateDayNavigation();
2000
2024
  this.renderCapacity();
2001
2025
  this.tick();
2002
2026
  }
@@ -2114,6 +2138,11 @@ var TimeGridView = class {
2114
2138
  }
2115
2139
  const minMinutes = opts.eventMinHeight / this.ppm;
2116
2140
  const layout = layoutTimedDay(items, c.day, zone, minMinutes, ctx.order, [slot.min, slot.max]);
2141
+ c.collisionColumns = Math.max(
2142
+ 1,
2143
+ ...layout.plan.map((placement) => opts.eventMaxStack > 0 ? Math.min(placement.cols, opts.eventMaxStack) : placement.cols),
2144
+ ...layout.actual.filter((placement) => !placement.split).map((placement) => placement.cols)
2145
+ );
2117
2146
  const total = this.minutes;
2118
2147
  const top = (m) => pct((m - slot.min) / total);
2119
2148
  const height = (s) => pct((s.endMin - s.startMin) / total);
@@ -2125,14 +2154,22 @@ var TimeGridView = class {
2125
2154
  c.bg.end();
2126
2155
  const hasActual = layout.actual.length > 0;
2127
2156
  c.col.classList.toggle("has-actual", hasActual);
2157
+ for (const link of c.more.children) link.setAttribute("data-stale", "true");
2128
2158
  this.placeLane(c.plan, layout.plan, c, top, height, "plan");
2129
2159
  this.placeLane(c.actual, layout.actual, c, top, height, "actual");
2160
+ for (const link of [...c.more.children]) if (link.hasAttribute("data-stale")) link.remove();
2130
2161
  }
2131
2162
  placeLane(pool, placements, c, top, height, lane) {
2132
2163
  const { ctx } = this;
2133
2164
  const { opts } = ctx;
2134
- const withMore = lane === "plan";
2135
- const maxStack = opts.eventMaxStack;
2165
+ const readableWidth = Number.parseFloat(ctx.win.getComputedStyle(this.el).getPropertyValue("--tnc-event-min-width")) || 128;
2166
+ const laneWidth = c.col.clientWidth * (lane === "actual" && placements.some((placement) => placement.split) ? 0.22 : 1);
2167
+ const collapse = placements.some((placement) => {
2168
+ const columns = opts.eventMaxStack > 0 ? Math.min(placement.cols, opts.eventMaxStack) : placement.cols;
2169
+ return columns > 1 && columns * readableWidth > laneWidth;
2170
+ });
2171
+ const withMore = lane === "plan" || collapse;
2172
+ const maxStack = collapse ? 1 : opts.eventMaxStack;
2136
2173
  const hiddenByCluster = /* @__PURE__ */ new Map();
2137
2174
  pool.begin();
2138
2175
  for (const p of placements) {
@@ -2158,21 +2195,38 @@ var TimeGridView = class {
2158
2195
  top: top(p.startMin),
2159
2196
  height: `max(${height(p)}, ${opts.eventMinHeight}px)`,
2160
2197
  ...inLane(lane, p.split, left, width),
2198
+ "--tnc-event-readable-width": pct(span / cols / width),
2161
2199
  "z-index": String(p.col + 1)
2162
2200
  });
2163
2201
  }
2164
2202
  pool.end();
2165
2203
  if (!withMore) return;
2166
- c.more.replaceChildren();
2167
- for (const rec of hiddenByCluster.values()) {
2168
- const link = h(ctx.doc, "button", "tnc-more tnc-more--stack", { type: "button", "aria-haspopup": "dialog" }, `+${rec.count}`);
2204
+ for (const [cluster, rec] of hiddenByCluster) {
2205
+ const key = `${lane}-${cluster}`;
2206
+ let link = c.more.querySelector(`[data-cluster="${key}"]`);
2207
+ if (!link) {
2208
+ link = h(ctx.doc, "button", "tnc-more tnc-more--stack", { type: "button", "aria-haspopup": "dialog", "data-cluster": key });
2209
+ const anchor = link;
2210
+ link.addEventListener("click", (ev) => ctx.openMore(c.day, anchor, ev, JSON.parse(anchor.dataset.itemIds ?? "[]")));
2211
+ c.more.append(link);
2212
+ }
2213
+ link.removeAttribute("data-stale");
2214
+ link.dataset.itemIds = JSON.stringify(placements.filter((placement) => placement.cluster === cluster).map((placement) => placement.item.id));
2215
+ link.classList.toggle("tnc-more--collision", collapse);
2216
+ link.textContent = `+${rec.count}`;
2217
+ if (collapse) {
2218
+ link.textContent = ctx.text.appointments(rec.count);
2219
+ link.setAttribute("aria-label", link.textContent);
2220
+ link.title = link.getAttribute("aria-label");
2221
+ } else {
2222
+ link.removeAttribute("aria-label");
2223
+ link.removeAttribute("title");
2224
+ }
2169
2225
  const cols = maxStack;
2170
2226
  setStyle(link, {
2171
2227
  top: top(rec.start),
2172
2228
  ...inLane(lane, rec.split, (cols - 1) / cols, 1 / cols)
2173
2229
  });
2174
- link.addEventListener("click", (ev) => ctx.openMore(c.day, link, ev));
2175
- c.more.append(link);
2176
2230
  }
2177
2231
  }
2178
2232
  meta(s, bg) {
@@ -2283,6 +2337,26 @@ var TimeGridView = class {
2283
2337
  }
2284
2338
  resize() {
2285
2339
  this.fitRows();
2340
+ this.render(null);
2341
+ }
2342
+ revealDay(day) {
2343
+ const column = this.cols.find((candidate) => candidate.day === day);
2344
+ if (!column) return;
2345
+ const bounds = column.head.getBoundingClientRect();
2346
+ const viewport = this.scroller.getBoundingClientRect();
2347
+ this.scroller.scrollLeft += bounds.left - viewport.left - (viewport.width - bounds.width) / 2;
2348
+ this.updateDayNavigation();
2349
+ }
2350
+ updateDayNavigation() {
2351
+ this.dayNavigation.hidden = this.scroller.scrollWidth <= this.scroller.clientWidth + 1;
2352
+ const viewport = this.scroller.getBoundingClientRect();
2353
+ this.cols.forEach((column, index) => {
2354
+ const bounds = column.head.getBoundingClientRect();
2355
+ const visible = bounds.left >= viewport.left && bounds.right <= viewport.right;
2356
+ const button = this.dayNavigation.children[index];
2357
+ button?.classList.toggle("is-offscreen", !visible);
2358
+ button?.setAttribute("aria-pressed", String(visible));
2359
+ });
2286
2360
  }
2287
2361
  scrollToTime(minute) {
2288
2362
  if (!this.body) return;
@@ -3048,7 +3122,11 @@ var Calendar = class {
3048
3122
  this.resizeObs?.disconnect();
3049
3123
  const RO = this.win.ResizeObserver;
3050
3124
  if (RO) {
3051
- this.resizeObs = new RO(() => this.current?.resize?.());
3125
+ this.resizeObs = new RO(() => {
3126
+ this.syncToolbarViewDisplay();
3127
+ this.current?.resize?.();
3128
+ this.positionMore();
3129
+ });
3052
3130
  this.resizeObs.observe(this.root);
3053
3131
  }
3054
3132
  }
@@ -3068,9 +3146,11 @@ var Calendar = class {
3068
3146
  if (this.frame !== null) old.cancelAnimationFrame(this.frame);
3069
3147
  this.timer = this.frame = null;
3070
3148
  this.doc.removeEventListener("pointerdown", this.onDocPointerDown, true);
3149
+ this.listenMoreGeometry(false);
3071
3150
  this.doc = doc;
3072
3151
  this.win = doc.defaultView ?? old;
3073
3152
  this.doc.addEventListener("pointerdown", this.onDocPointerDown, true);
3153
+ this.listenMoreGeometry(true);
3074
3154
  this.observeResize();
3075
3155
  this.rebuild(false);
3076
3156
  this.scheduleTick();
@@ -3188,6 +3268,7 @@ var Calendar = class {
3188
3268
  goToday() {
3189
3269
  this.anchor = this.todayDay();
3190
3270
  this.rebuild(true);
3271
+ if (this.current instanceof TimeGridView) this.current.revealDay(this.anchor);
3191
3272
  }
3192
3273
  /** The anchor date, as local midnight of that civil day in the calendar zone. */
3193
3274
  getDate() {
@@ -3206,6 +3287,7 @@ var Calendar = class {
3206
3287
  /** Re-measures (e.g. after the host was shown or resized by a parent). */
3207
3288
  updateSize() {
3208
3289
  this.current?.resize?.();
3290
+ this.positionMore();
3209
3291
  }
3210
3292
  getScroll() {
3211
3293
  return this.current?.getScroll() ?? { top: 0, left: 0 };
@@ -3237,7 +3319,10 @@ var Calendar = class {
3237
3319
  }
3238
3320
  const view = this.current;
3239
3321
  if (view instanceof TimelineView) view.scrollToInstant(ms, opts.align ?? "center", opts.smooth ?? false);
3240
- else if (view instanceof TimeGridView && p.kind !== "day") view.scrollToTime(wallTime(ms, this.zone).minute);
3322
+ else if (view instanceof TimeGridView) {
3323
+ view.revealDay(day);
3324
+ if (p.kind !== "day") view.scrollToTime(wallTime(ms, this.zone).minute);
3325
+ }
3241
3326
  }
3242
3327
  /** What is under a client point: a time slot, a day, or a timeline row and date. Null outside the view. */
3243
3328
  hitTest(clientX, clientY) {
@@ -3412,9 +3497,10 @@ var Calendar = class {
3412
3497
  if (meta) this.opts.itemWillUnmount?.(this.itemArg(el, meta));
3413
3498
  el.remove();
3414
3499
  }
3415
- openMore(day, anchor, jsEvent) {
3500
+ openMore(day, anchor, jsEvent, itemIds) {
3416
3501
  jsEvent.stopPropagation();
3417
- const items = this.store.inRange(day, day + 1).filter((n) => n.item.display !== "background" && (n.allDay || this.opts.view === "month" || !this.opts.timeGrid || this.opts.eventMaxStack > 0)).sort((a, b) => Number(b.allDay) - Number(a.allDay) || this.order(a, b));
3502
+ const group = itemIds ? new Set(itemIds) : null;
3503
+ const items = this.store.inRange(day, day + 1).filter((item) => !group || group.has(item.id)).filter((n) => n.item.display !== "background" && (n.allDay || anchor.classList.contains("tnc-more--stack") || this.opts.view === "month" || !this.opts.timeGrid || this.opts.eventMaxStack > 0)).sort((a, b) => Number(b.allDay) - Number(a.allDay) || this.order(a, b));
3418
3504
  const shown = new Set(
3419
3505
  [...this.viewHost.querySelectorAll(".tnc-item")].filter((el2) => this.meta.get(el2)?.day === day || (this.meta.get(el2)?.n.startDay ?? Infinity) <= day).map((el2) => el2.dataset.id ?? "")
3420
3506
  );
@@ -3443,15 +3529,7 @@ var Calendar = class {
3443
3529
  }
3444
3530
  pool.end();
3445
3531
  this.popover = { el, pool, anchor, day };
3446
- const rr = this.root.getBoundingClientRect();
3447
- const ar = anchor.getBoundingClientRect();
3448
- const w = Math.max(220, ar.width + 24);
3449
- let left = ar.left - rr.left - 12;
3450
- left = Math.max(4, Math.min(left, rr.width - w - 4));
3451
- let top = ar.top - rr.top - 40;
3452
- const height = el.offsetHeight;
3453
- top = Math.max(4, Math.min(top, rr.height - height - 4));
3454
- setStyle(el, { left, top, width: w });
3532
+ this.positionMore();
3455
3533
  el.focus();
3456
3534
  }
3457
3535
  /* =============================================================== render */
@@ -3575,8 +3653,10 @@ var Calendar = class {
3575
3653
  this.current.setScroll({ top: keep.top, left: 0 });
3576
3654
  this.current.placeInstant(keep.ms, keep.vx);
3577
3655
  } else if (scroll) this.current.setScroll(scroll);
3578
- else if (this.current instanceof TimeGridView) this.current.scrollToTime(this.slot.scroll);
3579
- else if (this.current instanceof TimelineView) this.current.scrollToDay(this.anchor);
3656
+ else if (this.current instanceof TimeGridView) {
3657
+ this.current.scrollToTime(this.slot.scroll);
3658
+ this.current.revealDay(this.anchor);
3659
+ } else if (this.current instanceof TimelineView) this.current.scrollToDay(this.anchor);
3580
3660
  if (rangeChanged) this.opts.onRangeChange?.(this.view);
3581
3661
  }
3582
3662
  /** Sets the visible days and view info from the anchor. */
@@ -3747,17 +3827,42 @@ var Calendar = class {
3747
3827
  );
3748
3828
  const title = h(this.doc, "h2", "tnc-title", { "aria-live": "polite" }, this.view.title);
3749
3829
  const viewGroup = h(this.doc, "div", "tnc-toolbar-group tnc-toolbar-views", { role: "group" });
3830
+ const viewSelect = h(this.doc, "select", "tnc-btn tnc-toolbar-view-select", {
3831
+ "aria-label": this.text.viewHints[this.opts.view] ?? this.text.views[this.opts.view] ?? this.opts.view
3832
+ });
3750
3833
  for (const v of views) {
3751
3834
  const label = v === "days" ? this.text.views.days ?? `${this.opts.dayCount} days` : this.text.views[v] ?? v;
3752
3835
  viewGroup.append(btn(label, this.text.viewHints[v] ?? label, `tnc-btn-view tnc-btn-view-${v}`, () => this.changeView(v), v === this.opts.view));
3836
+ viewSelect.append(h(this.doc, "option", "", { value: v }, label));
3753
3837
  }
3838
+ viewSelect.value = this.opts.view;
3839
+ viewSelect.addEventListener("change", () => this.changeView(viewSelect.value));
3754
3840
  const start = h(this.doc, "div", "tnc-toolbar-start");
3755
3841
  const end = h(this.doc, "div", "tnc-toolbar-end");
3756
3842
  if (cfg.extraPlacement === "start") start.append(...extras);
3757
3843
  start.append(nav, title);
3758
- end.append(viewGroup);
3844
+ end.append(viewGroup, viewSelect);
3759
3845
  if (cfg.extraPlacement !== "start") end.append(...extras);
3760
3846
  this.toolbarEl.append(start, end);
3847
+ this.syncToolbarViewDisplay();
3848
+ }
3849
+ syncToolbarViewDisplay() {
3850
+ const buttons = this.toolbarEl.querySelector(".tnc-toolbar-views");
3851
+ const select = this.toolbarEl.querySelector(".tnc-toolbar-view-select");
3852
+ const start = this.toolbarEl.querySelector(".tnc-toolbar-start");
3853
+ if (!buttons || !select || !start) return;
3854
+ const config = this.opts.toolbar;
3855
+ const mode = typeof config === "object" ? config.viewDisplay ?? "auto" : "auto";
3856
+ buttons.hidden = false;
3857
+ select.hidden = true;
3858
+ buttons.style.display = "flex";
3859
+ select.style.display = "none";
3860
+ const startWidth = Array.from(start.children).reduce((width, child) => width + child.scrollWidth, 0);
3861
+ const compact = mode === "select" || mode === "auto" && startWidth + buttons.scrollWidth + 32 > this.root.clientWidth;
3862
+ buttons.hidden = compact;
3863
+ select.hidden = !compact;
3864
+ buttons.style.display = compact ? "none" : "flex";
3865
+ select.style.display = compact ? "" : "none";
3761
3866
  }
3762
3867
  applyChange(change) {
3763
3868
  if (!change.added.length && !change.updated.length && !change.removed.length && !change.days.size && !change.spans) return;
@@ -3890,6 +3995,38 @@ var Calendar = class {
3890
3995
  }
3891
3996
  }
3892
3997
  /* ============================================================== popover */
3998
+ onMoreGeometryChange = () => this.positionMore();
3999
+ listenMoreGeometry(on) {
4000
+ const method = on ? "addEventListener" : "removeEventListener";
4001
+ this.doc[method]("scroll", this.onMoreGeometryChange, true);
4002
+ this.win[method]("resize", this.onMoreGeometryChange);
4003
+ this.win.visualViewport?.[method]("resize", this.onMoreGeometryChange);
4004
+ this.win.visualViewport?.[method]("scroll", this.onMoreGeometryChange);
4005
+ }
4006
+ positionMore() {
4007
+ const popover = this.popover;
4008
+ if (!popover) return;
4009
+ if (!popover.anchor.isConnected) {
4010
+ this.closeMore();
4011
+ return;
4012
+ }
4013
+ const viewport = this.win.visualViewport;
4014
+ const root = this.root.getBoundingClientRect();
4015
+ const anchor = popover.anchor.getBoundingClientRect();
4016
+ const leftEdge = Math.max(root.left, viewport?.offsetLeft ?? 0) + 4;
4017
+ const topEdge = Math.max(root.top, viewport?.offsetTop ?? 0) + 4;
4018
+ const rightEdge = Math.min(root.right, (viewport?.offsetLeft ?? 0) + (viewport?.width ?? this.win.innerWidth)) - 4;
4019
+ const bottomEdge = Math.min(root.bottom, (viewport?.offsetTop ?? 0) + (viewport?.height ?? this.win.innerHeight)) - 4;
4020
+ if (rightEdge <= leftEdge || bottomEdge <= topEdge) {
4021
+ this.closeMore();
4022
+ return;
4023
+ }
4024
+ const width = Math.min(360, rightEdge - leftEdge);
4025
+ setStyle(popover.el, { width, "max-height": Math.min(420, bottomEdge - topEdge) });
4026
+ const left = Math.max(leftEdge, Math.min(anchor.left - 12, rightEdge - width));
4027
+ const top = Math.max(topEdge, Math.min(anchor.top - 40, bottomEdge - popover.el.offsetHeight));
4028
+ setStyle(popover.el, { left: left - root.left, top: top - root.top });
4029
+ }
3893
4030
  refreshMore() {
3894
4031
  const p = this.popover;
3895
4032
  if (!p) return;
@@ -3899,6 +4036,7 @@ var Calendar = class {
3899
4036
  p.pool.place(n.id, { n, day: p.day, isStart: n.startDay >= p.day, isEnd: n.endDay <= p.day + 1, variant: n.allDay ? "bar" : "dot", mirror: false });
3900
4037
  }
3901
4038
  p.pool.end();
4039
+ this.positionMore();
3902
4040
  }
3903
4041
  closeMore(restoreFocus = false) {
3904
4042
  const p = this.popover;
@@ -3926,6 +4064,7 @@ var Calendar = class {
3926
4064
  r.addEventListener("pointerover", this.onAdoptCheck);
3927
4065
  r.addEventListener("focusin", this.onAdoptCheck);
3928
4066
  this.doc.addEventListener("pointerdown", this.onDocPointerDown, true);
4067
+ this.listenMoreGeometry(true);
3929
4068
  }
3930
4069
  unbindEvents() {
3931
4070
  const r = this.root;
@@ -3944,6 +4083,7 @@ var Calendar = class {
3944
4083
  r.removeEventListener("pointerover", this.onAdoptCheck);
3945
4084
  r.removeEventListener("focusin", this.onAdoptCheck);
3946
4085
  this.doc.removeEventListener("pointerdown", this.onDocPointerDown, true);
4086
+ this.listenMoreGeometry(false);
3947
4087
  this.listenDoc(false);
3948
4088
  }
3949
4089
  listenDoc(on) {
@@ -3989,6 +4129,7 @@ var Calendar = class {
3989
4129
  mode = "select";
3990
4130
  }
3991
4131
  const touch = ev.pointerType === "touch";
4132
+ const overflowOrigin = itemEl?.closest(".tnc-popover") && origin ? wallTime(origin.startMs, this.zone) : null;
3992
4133
  const s = {
3993
4134
  mode,
3994
4135
  pointerId: ev.pointerId,
@@ -3997,7 +4138,7 @@ var Calendar = class {
3997
4138
  y0: ev.clientY,
3998
4139
  active: false,
3999
4140
  origin,
4000
- grab: this.current?.hit(ev.clientX, ev.clientY) ?? null,
4141
+ grab: overflowOrigin && this.current instanceof TimeGridView && !origin?.allDay ? { kind: "time", day: overflowOrigin.day, minute: overflowOrigin.minute } : this.current?.hit(ev.clientX, ev.clientY) ?? null,
4001
4142
  proposal: null,
4002
4143
  denied: false,
4003
4144
  timer: null,
@@ -4500,6 +4641,25 @@ var Calendar = class {
4500
4641
  if (el && meta) this.opts.onItemHover?.({ ...this.itemArg(el, meta), jsEvent: ev, entering: false });
4501
4642
  }
4502
4643
  onKeyDown = (ev) => {
4644
+ if (this.popover && !ev.altKey && !ev.ctrlKey && !ev.metaKey && ["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) {
4645
+ const rows = [...this.popover.el.querySelectorAll(".tnc-item")];
4646
+ const index = rows.indexOf(this.doc.activeElement);
4647
+ const next = ev.key === "Home" ? 0 : ev.key === "End" ? rows.length - 1 : ev.key === "ArrowDown" ? (index + 1) % rows.length : index <= 0 ? rows.length - 1 : index - 1;
4648
+ ev.preventDefault();
4649
+ ev.stopPropagation();
4650
+ rows[next]?.focus();
4651
+ rows[next]?.scrollIntoView({ block: "nearest", inline: "nearest" });
4652
+ return;
4653
+ }
4654
+ if (ev.key === "Tab" && this.popover) {
4655
+ const controls = [...this.popover.el.querySelectorAll('button, a[href], [tabindex="0"]')];
4656
+ const index = controls.indexOf(this.doc.activeElement);
4657
+ const next = ev.shiftKey ? index <= 0 ? controls.length - 1 : index - 1 : (index + 1) % controls.length;
4658
+ ev.preventDefault();
4659
+ ev.stopPropagation();
4660
+ controls[next]?.focus();
4661
+ return;
4662
+ }
4503
4663
  if (ev.key === "Escape" && this.popover) {
4504
4664
  ev.preventDefault();
4505
4665
  ev.stopPropagation();