@open-webapp/calendar 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,7 +211,7 @@ The exported standalone selector keeps its single-list props and behavior; Selec
211
211
 
212
212
  ### Host CSS
213
213
 
214
- Day ribbon styling: override `--calendar-today-accent` to recolour today's ribbon; `--calendar-day-header-height` tunes the sticky label offset.
214
+ Day ribbon styling: each day's ribbon alternates between two tones by day parity (`data-parity="even|odd"`), set with `--calendar-day-ribbon-a` and `--calendar-day-ribbon-b`. Parity is stable across scroll, filtering and month boundaries, and the ribbon does not mark today. `--calendar-day-header-height` tunes the sticky label offset.
215
215
 
216
216
  `styles.css` is self-contained, using component-scoped semantic palette tokens and native-control `color-scheme`. Each pane/selector root resets the full palette; explicit dark overrides and system-only `prefers-color-scheme` rules keep instances isolated. It requires no host CSS variables. Layout/status variables:
217
217
 
@@ -223,9 +223,15 @@ The Unaccepted count badge uses `.calendar-rsvp-chip-badge`, with light and dark
223
223
 
224
224
  The shared `.calendar-selector-trigger` uses border-box inline-flex, centered alignment, an 8px gap, 36px minimum height, 8px 12px padding, 6px radius, a 1px solid control border, inherited font and pointer cursor. `.calendar-selector-trigger-label` bounds long labels with ellipsis; `.calendar-selector-chevron` stays 12px and does not shrink or add a tab stop. Existing scoped surface/text/control-border tokens style the resting trigger, the hover token supplies its hover background, and the focus token supplies a 2px `:focus-visible` outline with 2px offset. This approved visual-only exception changes trigger geometry and its derived dropdown position, preserving surrounding layout and theme isolation. Import the shipped stylesheet for both footer and standalone use.
225
225
 
226
+ ## 0.7.0 (minor, breaking-visual)
227
+
228
+ - Day ribbons alternate between two tones by day parity (`data-parity="even|odd"`, days since 1970 of the date key mod 2) instead of highlighting today. Parity is stable across scroll, filter and month boundaries.
229
+ - "Today" is now the browser's local date (previously UTC, which could be off by a day for users away from UTC). Sticky day header text ("Today · Oct 26") is unchanged.
230
+ - Migration: `--calendar-today-accent` was removed in 0.7.0; use `--calendar-day-ribbon-a` / `--calendar-day-ribbon-b`. The ribbon no longer marks today (`data-today` is now `data-parity`).
231
+
226
232
  ## 0.6.0 (minor, additive-visual)
227
233
 
228
- - Each day has a thin vertical ribbon left of its events with a rotated `Mmm D` label; today's ribbon uses the new `--calendar-today-accent` token (defaults to the selected-chip colour in light and dark). The sticky day header is unchanged.
234
+ - Each day has a thin vertical ribbon left of its events with a rotated `Mmm D` label; ribbons alternate tones by day parity (see 0.7.0). The sticky day header is unchanged.
229
235
  - The RSVP status bar moved from the left edge to the right edge of event rows.
230
236
  - New helper `formatRibbonLabel`.
231
237
 
@@ -39,9 +39,6 @@ export function getRollingWindow(now) {
39
39
  const end = new Date(now.getTime() + 30 * MS_PER_DAY);
40
40
  return { start: start.toISOString(), end: end.toISOString() };
41
41
  }
42
- function toIsoDate(d) {
43
- return d.toISOString().slice(0, 10);
44
- }
45
42
  /**
46
43
  * Returns events whose `selfResponseStatus` is a member of `selected` —
47
44
  * declined is a normal, independently-selectable status now, not hard-excluded.
@@ -176,7 +173,8 @@ function addDaysToIso(dateKey, days) {
176
173
  const { year, month, day } = parseIsoDateParts(dateKey);
177
174
  const d = new Date(Date.UTC(year, month - 1, day));
178
175
  d.setUTCDate(d.getUTCDate() + days);
179
- return toIsoDate(d);
176
+ // d is built from pure UTC parts (not "now"), so the UTC date is the right one.
177
+ return d.toISOString().slice(0, 10);
180
178
  }
181
179
  /**
182
180
  * "Today · Sept 26" / "Tomorrow · Sept 27" / else "Sept 28"-style.
@@ -76,7 +76,12 @@ function openEventPopup(url, popupRef, popupName) {
76
76
  popup.focus();
77
77
  }
78
78
  function toIsoDate(d) {
79
- return d.toISOString().slice(0, 10);
79
+ const p = (n) => String(n).padStart(2, '0');
80
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
81
+ }
82
+ function dayParity(dateKey) {
83
+ const [y, m, d] = dateKey.split('-').map(Number);
84
+ return Math.floor(Date.UTC(y, m - 1, d) / 86400000) % 2 === 0 ? 'even' : 'odd';
80
85
  }
81
86
  function formatTimeOfDay(dateTime) {
82
87
  const d = new Date(dateTime);
@@ -130,6 +135,7 @@ export function CalendarPane(props) {
130
135
  const hasScrolledForEventsRef = useRef(false);
131
136
  const paneRef = useRef(null);
132
137
  const chipsRef = useRef(null);
138
+ const scrollRef = useRef(null);
133
139
  const popupRef = useRef(null);
134
140
  const [now, setNow] = useState(() => new Date());
135
141
  const [savedSelection, setSavedSelection] = useState(null);
@@ -274,15 +280,21 @@ export function CalendarPane(props) {
274
280
  if (!chipsEl || !paneEl)
275
281
  return;
276
282
  const observer = new ResizeObserver((entries) => {
277
- const entry = entries[0];
278
- if (!entry)
279
- return;
280
- // contentRect excludes padding; the sticky day-header offset needs the
281
- // chip row's full border-box height (chips have vertical padding).
282
- const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
283
- paneEl.style.setProperty('--chip-row-height', `${height}px`);
283
+ for (const entry of entries) {
284
+ if (entry.target === scrollRef.current) {
285
+ // The day ribbon centers its label in the visible scroll area, so it needs that height.
286
+ paneEl.style.setProperty('--calendar-scroll-height', `${entry.target.getBoundingClientRect().height}px`);
287
+ continue;
288
+ }
289
+ // contentRect excludes padding; the sticky day-header offset needs the
290
+ // chip row's full border-box height (chips have vertical padding).
291
+ const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
292
+ paneEl.style.setProperty('--chip-row-height', `${height}px`);
293
+ }
284
294
  });
285
295
  observer.observe(chipsEl);
296
+ if (scrollRef.current)
297
+ observer.observe(scrollRef.current);
286
298
  return () => observer.disconnect();
287
299
  }, [connected, needsReauth]);
288
300
  if (!connected) {
@@ -298,14 +310,14 @@ export function CalendarPane(props) {
298
310
  const loading = ctrlState.loading && filteredEvents.length === 0;
299
311
  const emptyStateText = 'No matching events in the next 30 days';
300
312
  const showCalendarSelector = calendarSources.length > 0;
301
- return (_jsxs("div", { className: "calendar-pane", "data-calendar-theme": theme, ref: paneRef, children: [_jsxs("div", { className: "calendar-pane-scroll", children: [_jsxs("div", { className: "calendar-rsvp-chips", ref: chipsRef, children: [_jsx("button", { type: "button", className: "calendar-rsvp-chip calendar-scroll-today-btn", "aria-label": "Scroll to today", title: "Scroll to today", onClick: () => recenter('smooth'), children: _jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("rect", { x: "2", y: "3", width: "11", height: "10", rx: "1.5", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M2 6H13", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M5 2V4M10 2V4", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" }), _jsx("circle", { cx: "7.5", cy: "9.5", r: "1.3", fill: "currentColor" })] }) }), RSVP_CHIPS.map((chip) => (_jsxs("button", { type: "button", className: `calendar-rsvp-chip${(chip.status === 'needsAction' ? isExclusive : selectedRsvpStatuses.has(chip.status)) ? ' selected' : ''}`, "aria-pressed": chip.status === 'needsAction' ? isExclusive : selectedRsvpStatuses.has(chip.status), "aria-label": chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, title: chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, onClick: () => chip.status === 'needsAction' ? handleUnaccepted() : handleToggle(chip.status), children: [chip.icon, chip.status === 'needsAction' && (_jsx("span", { className: "calendar-rsvp-chip-badge", "aria-hidden": "true", children: unacceptedCount > 99 ? '99+' : unacceptedCount }))] }, chip.status)))] }), needsScopeSources.map((s) => (_jsx("div", { children: props.sourceNotice?.(s) }, s.id))), error && (_jsx("div", { className: "empty-state calendar-empty-state", role: "alert", children: _jsx("p", { children: "Couldn't load calendar events. Try again shortly." }) })), !(error && filteredEvents.length === 0) && (_jsxs("div", { className: "calendar-day-groups", children: [dayGroups.map((group) => (_jsxs("div", { className: "calendar-day-group", children: [_jsx("div", { className: "calendar-day-header", ref: (el) => {
313
+ return (_jsxs("div", { className: "calendar-pane", "data-calendar-theme": theme, ref: paneRef, children: [_jsxs("div", { className: "calendar-pane-scroll", ref: scrollRef, children: [_jsxs("div", { className: "calendar-rsvp-chips", ref: chipsRef, children: [_jsx("button", { type: "button", className: "calendar-rsvp-chip calendar-scroll-today-btn", "aria-label": "Scroll to today", title: "Scroll to today", onClick: () => recenter('smooth'), children: _jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("rect", { x: "2", y: "3", width: "11", height: "10", rx: "1.5", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M2 6H13", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M5 2V4M10 2V4", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" }), _jsx("circle", { cx: "7.5", cy: "9.5", r: "1.3", fill: "currentColor" })] }) }), RSVP_CHIPS.map((chip) => (_jsxs("button", { type: "button", className: `calendar-rsvp-chip${(chip.status === 'needsAction' ? isExclusive : selectedRsvpStatuses.has(chip.status)) ? ' selected' : ''}`, "aria-pressed": chip.status === 'needsAction' ? isExclusive : selectedRsvpStatuses.has(chip.status), "aria-label": chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, title: chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, onClick: () => chip.status === 'needsAction' ? handleUnaccepted() : handleToggle(chip.status), children: [chip.icon, chip.status === 'needsAction' && (_jsx("span", { className: "calendar-rsvp-chip-badge", "aria-hidden": "true", children: unacceptedCount > 99 ? '99+' : unacceptedCount }))] }, chip.status)))] }), needsScopeSources.map((s) => (_jsx("div", { children: props.sourceNotice?.(s) }, s.id))), error && (_jsx("div", { className: "empty-state calendar-empty-state", role: "alert", children: _jsx("p", { children: "Couldn't load calendar events. Try again shortly." }) })), !(error && filteredEvents.length === 0) && (_jsxs("div", { className: "calendar-day-groups", children: [dayGroups.map((group) => (_jsxs("div", { className: "calendar-day-group", children: [_jsx("div", { className: "calendar-day-header", ref: (el) => {
302
314
  if (el) {
303
315
  dayHeaderRefs.current.set(group.dateKey, el);
304
316
  }
305
317
  else {
306
318
  dayHeaderRefs.current.delete(group.dateKey);
307
319
  }
308
- }, children: formatDayHeader(group.dateKey, todayIso) }), group.events.length > 0 && (_jsxs("div", { className: "calendar-day-body", children: [_jsx("div", { className: "calendar-day-ribbon", "aria-hidden": "true", "data-today": group.dateKey === todayIso ? 'true' : undefined, children: _jsx("span", { className: "calendar-day-ribbon-label", children: formatRibbonLabel(group.dateKey) }) }), _jsx("div", { className: "calendar-day-events", children: group.events.map((event) => {
320
+ }, children: formatDayHeader(group.dateKey, todayIso) }), group.events.length > 0 && (_jsxs("div", { className: "calendar-day-body", children: [_jsx("div", { className: "calendar-day-ribbon", "aria-hidden": "true", "data-parity": dayParity(group.dateKey), children: _jsx("div", { className: "calendar-day-ribbon-window", children: _jsx("span", { className: "calendar-day-ribbon-label", children: formatRibbonLabel(group.dateKey) }) }) }), _jsx("div", { className: "calendar-day-events", children: group.events.map((event) => {
309
321
  const eventKey = getCalendarEventKey(event);
310
322
  const calendar = calendarsByKey.get(`${event.sourceId}\u0000${event.calendarId}`);
311
323
  const showCalendarLabel = calendar && !calendar.primary;
package/dist/styles.css CHANGED
@@ -11,7 +11,8 @@
11
11
  --calendar-chip-text: #333;
12
12
  --calendar-chip-border: #ccc;
13
13
  --calendar-chip-selected: #081a59;
14
- --calendar-today-accent: #081a59;
14
+ --calendar-day-ribbon-a: #e4e8ef;
15
+ --calendar-day-ribbon-b: #c8d6f0;
15
16
  --calendar-chip-selected-text: #ffffff;
16
17
  --calendar-badge: #e8eefb;
17
18
  --calendar-badge-text: #081a59;
@@ -217,16 +218,26 @@
217
218
  align-items: flex-start;
218
219
  }
219
220
 
220
- .calendar-day-ribbon[data-today='true'] {
221
- background: var(--calendar-today-accent);
222
- color: var(--calendar-chip-selected-text);
221
+ .calendar-day-ribbon[data-parity='even'] {
222
+ background: var(--calendar-day-ribbon-a);
223
+ }
224
+
225
+ .calendar-day-ribbon[data-parity='odd'] {
226
+ background: var(--calendar-day-ribbon-b);
227
+ }
228
+
229
+ .calendar-day-ribbon-window {
230
+ position: sticky;
231
+ top: calc(var(--chip-row-height, 44px) + var(--calendar-day-header-height, 33px));
232
+ /* Visible part of the ribbon: as tall as the scroll area below the sticky headers, capped at the ribbon. */
233
+ height: min(100%, calc(var(--calendar-scroll-height, 100vh) - var(--chip-row-height, 44px) - var(--calendar-day-header-height, 33px)));
234
+ display: flex;
235
+ align-items: center;
223
236
  }
224
237
 
225
238
  .calendar-day-ribbon-label {
226
239
  writing-mode: vertical-rl;
227
240
  transform: rotate(180deg);
228
- position: sticky;
229
- top: calc(var(--chip-row-height, 44px) + var(--calendar-day-header-height, 33px));
230
241
  white-space: nowrap;
231
242
  padding: 6px 0;
232
243
  }
@@ -388,7 +399,8 @@
388
399
  --calendar-chip-text: #eee;
389
400
  --calendar-chip-border: #444;
390
401
  --calendar-chip-selected: #22d0ef;
391
- --calendar-today-accent: #22d0ef;
402
+ --calendar-day-ribbon-a: #2b3342;
403
+ --calendar-day-ribbon-b: #1c3a5e;
392
404
  --calendar-chip-selected-text: #081a59;
393
405
  --calendar-badge: #081a59;
394
406
  --calendar-badge-text: #e8eefb;
@@ -430,7 +442,8 @@
430
442
  --calendar-chip-text: #eee;
431
443
  --calendar-chip-border: #444;
432
444
  --calendar-chip-selected: #22d0ef;
433
- --calendar-today-accent: #22d0ef;
445
+ --calendar-day-ribbon-a: #2b3342;
446
+ --calendar-day-ribbon-b: #1c3a5e;
434
447
  --calendar-chip-selected-text: #081a59;
435
448
  --calendar-badge: #081a59;
436
449
  --calendar-badge-text: #e8eefb;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/calendar",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",