@open-webapp/calendar 0.7.0 → 0.8.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Multi-account Google Calendar read-only sync (controller) plus React UI (`CalendarPane`, `CalendarSelector`). Storage and encryption are host-supplied adapters.
4
4
 
5
- > **0.2.0 is BREAKING.** See [Breaking changes in 0.2.0](#breaking-changes-in-020).
5
+ > **0.8.0 is BREAKING.** See [0.8.0](#080-breaking-pre-10-minor) for independent Unaccepted toggling.
6
6
 
7
7
  ## Install
8
8
 
@@ -148,7 +148,7 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
148
148
  | `sources` | `Source[]` | shown accounts; 2+ enables multi-source UI (per-source color and email) |
149
149
  | `controller` | `CalendarController` | |
150
150
  | `theme?` | `'system' \| 'light' \| 'dark'` | defaults to `'system'`, including when `undefined`; applies to every pane state and all internal selectors |
151
- | `selectedRsvpStatuses` | `Set<RsvpStatus>` | filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction |
151
+ | `selectedRsvpStatuses` | `Set<RsvpStatus>` | host-controlled filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction (O pressed) |
152
152
  | `onRsvpStatusesChange` | `(next) => void` | |
153
153
  | `renderEventAction?` | `(event) => ReactNode` | per-event slot |
154
154
  | `connected?` | `boolean` | defaults to `sources.length > 0` (back-compat). `false` shows the not-connected block |
@@ -162,9 +162,9 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
162
162
 
163
163
  #### RSVP chips
164
164
 
165
- The chips appear in order: Accepted, Maybe, Declined, Unaccepted. Accepted, Maybe, and Declined toggle individual statuses; an empty selection falls back to `DEFAULT_RSVP_STATUSES` (accepted, tentative, needsAction).
165
+ The chips appear in order: Accepted, Maybe, Declined, Unaccepted (O). All four independently toggle current supplied membership. Defaults remain accepted, tentative, needsAction, so O is pressed by default.
166
166
 
167
- Unaccepted is an exclusive shortcut: clicking it selects only `needsAction`, saving the previous selection locally in the pane. Clicking it again restores that selection, or the defaults when no saved selection is available. Toggling another chip while exclusive mode is active applies that toggle to the saved selection (or defaults). An external nonexclusive selection clears the saved selection. Unaccepted is pressed only when the selection is exactly `needsAction`; a mixed selection containing `needsAction` does not press it.
167
+ O is pressed iff `needsAction` belongs to the selection, including mixed sets. Unanswered events follow that membership within existing calendar/window filters. Clicking O preserves other statuses; no previous RSVP selection is saved/restored. Removing the last status returns `DEFAULT_RSVP_STATUSES`: O-only -> O returns defaults with O still pressed. Explicit empty host selections remain empty; host rerenders do not normalize membership or emit callbacks.
168
168
 
169
169
  The Unaccepted badge is always visible, including at zero. It counts `needsAction` events from the controller's selected-calendar, rolling ±30-day snapshot before RSVP filtering. Visible counts cap at `99+`; the chip's `aria-label` and `title` expose the full count.
170
170
 
@@ -201,7 +201,7 @@ import '@open-webapp/calendar/styles.css';
201
201
 
202
202
  ### Theme behavior
203
203
 
204
- Both components follow the OS/browser `prefers-color-scheme` preference live in `'system'` mode; omitting `theme` preserves this default. Explicit `'light'` and `'dark'` ignore OS changes. Hosts can change the prop on a mounted component (including back to `'system'` or `undefined`) without remounting, closing an open dropdown, losing its search query, focus or keyboard-active option, changing selected calendars, or resetting the pane's saved RSVP selection. Theme changes do not trigger selection callbacks or controller refreshes.
204
+ Both components follow the OS/browser `prefers-color-scheme` preference live in `'system'` mode; omitting `theme` preserves this default. Explicit `'light'` and `'dark'` ignore OS changes. Hosts can change the prop on a mounted component (including back to `'system'` or `undefined`) without remounting, closing an open dropdown, losing its search query, focus or keyboard-active option, changing selected calendars, or resetting the pane's current RSVP selection. Theme changes do not trigger selection callbacks or controller refreshes.
205
205
 
206
206
  Each pane forwards its requested mode to its unified grouped footer selector, including both account groups, account-local actions and the shared search. The single-trigger structure and cross-account keyboard navigation are preserved; the approved trigger geometry exception is described above. Standalone selectors choose their own mode: an omitted/system selector follows the OS even inside an explicitly dark or light pane. Adjacent and nested instances can use opposite modes independently; no document/body theme switch is applied.
207
207
 
@@ -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: 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.
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. Labels center in the actual ribbon/viewport intersection below the measured chips and that day's header, including empty Today before an event day. Short ribbons center within their bounds. Scroll, resize, rendered-content and mounted theme changes update measurements; `--calendar-day-header-height` no longer positions the label.
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,6 +223,12 @@ 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.8.0 (BREAKING pre-1.0 minor)
227
+
228
+ - Unaccepted independently toggles `needsAction`, replacing the exclusive shortcut and local restoration introduced in 0.3.0. Hosts relying on pending-only callbacks or restoration must adapt to callbacks reflecting current membership.
229
+ - Defaults and last-deselection fallback remain accepted/tentative/needsAction; O is pressed for defaults and mixed selections containing needsAction.
230
+ - Ribbon labels center in the measured visible intersection, including empty Today, scrolling, resizing and mounted theme changes.
231
+
226
232
  ## 0.7.0 (minor, breaking-visual)
227
233
 
228
234
  - 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.
@@ -251,7 +257,7 @@ The shared `.calendar-selector-trigger` uses border-box inline-flex, centered al
251
257
 
252
258
  ## 0.3.0 (minor, additive)
253
259
 
254
- - Adds the Unaccepted exclusive shortcut after Accepted, Maybe, and Declined, with local selection restoration and default fallback as described above.
260
+ - Added the Unaccepted exclusive shortcut after Accepted, Maybe, and Declined, with local selection restoration and default fallback. This historical behavior is superseded by the independent toggle in 0.8.0.
255
261
  - Adds an always-visible Unaccepted count badge, including zero, computed before RSVP filtering from the controller's selected-calendar ±30-day snapshot. Counts above 99 display `99+`, with the full count in the chip's `aria-label` and `title`.
256
262
  - Adds the `.calendar-rsvp-chip-badge` host styling hook. Public props and default RSVP statuses remain compatible.
257
263
 
@@ -138,12 +138,6 @@ export function CalendarPane(props) {
138
138
  const scrollRef = useRef(null);
139
139
  const popupRef = useRef(null);
140
140
  const [now, setNow] = useState(() => new Date());
141
- const [savedSelection, setSavedSelection] = useState(null);
142
- const isExclusive = selectedRsvpStatuses.size === 1 && selectedRsvpStatuses.has('needsAction');
143
- useEffect(() => {
144
- if (!isExclusive && savedSelection !== null)
145
- setSavedSelection(null);
146
- }, [isExclusive, savedSelection]);
147
141
  const unacceptedCount = useMemo(() => events.filter((event) => event.selfResponseStatus === 'needsAction').length, [events]);
148
142
  const filteredEvents = useMemo(() => filterByRsvp(events, selectedRsvpStatuses), [events, selectedRsvpStatuses]);
149
143
  const calendarsByKey = useMemo(() => {
@@ -166,21 +160,8 @@ export function CalendarPane(props) {
166
160
  const nextUpcomingEvent = findNextUpcomingEvent(filteredEvents, now);
167
161
  const selectedEventKey = currentEventKeys.values().next().value ??
168
162
  (nextUpcomingEvent ? getCalendarEventKey(nextUpcomingEvent) : undefined);
169
- function handleUnaccepted() {
170
- if (isExclusive) {
171
- props.onRsvpStatusesChange(savedSelection ?? new Set(DEFAULT_RSVP_STATUSES));
172
- setSavedSelection(null);
173
- }
174
- else {
175
- setSavedSelection(new Set(selectedRsvpStatuses));
176
- props.onRsvpStatusesChange(new Set(['needsAction']));
177
- }
178
- }
179
163
  function handleToggle(status) {
180
- const base = isExclusive ? savedSelection ?? DEFAULT_RSVP_STATUSES : selectedRsvpStatuses;
181
- if (isExclusive)
182
- setSavedSelection(null);
183
- props.onRsvpStatusesChange(toggleRsvpStatus(base, status));
164
+ props.onRsvpStatusesChange(toggleRsvpStatus(selectedRsvpStatuses, status));
184
165
  }
185
166
  const dayGroups = groupByDay(filteredEvents, todayIso);
186
167
  function scrollToToday(behavior = 'auto') {
@@ -277,26 +258,51 @@ export function CalendarPane(props) {
277
258
  useEffect(() => {
278
259
  const chipsEl = chipsRef.current;
279
260
  const paneEl = paneRef.current;
280
- if (!chipsEl || !paneEl)
261
+ const scrollEl = scrollRef.current;
262
+ if (!chipsEl || !paneEl || !scrollEl)
281
263
  return;
264
+ const ribbons = Array.from(scrollEl.querySelectorAll('.calendar-day-ribbon'));
265
+ function updateRibbonWindows() {
266
+ const viewport = scrollEl.getBoundingClientRect();
267
+ const chipsBottom = chipsEl.getBoundingClientRect().bottom;
268
+ for (const ribbon of ribbons) {
269
+ const bounds = ribbon.getBoundingClientRect();
270
+ const header = ribbon.parentElement?.previousElementSibling;
271
+ const headerBottom = header?.getBoundingClientRect().bottom ?? chipsBottom;
272
+ const top = Math.max(bounds.top, viewport.top, chipsBottom, headerBottom);
273
+ const bottom = Math.min(bounds.bottom, viewport.bottom);
274
+ ribbon.style.setProperty('--calendar-ribbon-window-top', `${Math.min(bounds.height, Math.max(0, top - bounds.top))}px`);
275
+ ribbon.style.setProperty('--calendar-ribbon-window-height', `${Math.max(0, bottom - top)}px`);
276
+ }
277
+ }
278
+ // Observe layout inputs only: the absolutely positioned window cannot resize them.
282
279
  const observer = new ResizeObserver((entries) => {
283
280
  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.
281
+ if (entry.target === scrollEl) {
286
282
  paneEl.style.setProperty('--calendar-scroll-height', `${entry.target.getBoundingClientRect().height}px`);
287
- continue;
288
283
  }
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`);
284
+ else if (entry.target === chipsEl) {
285
+ // contentRect excludes padding; the sticky day-header offset needs the
286
+ // chip row's full border-box height (chips have vertical padding).
287
+ const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
288
+ paneEl.style.setProperty('--chip-row-height', `${height}px`);
289
+ }
293
290
  }
291
+ updateRibbonWindows();
294
292
  });
293
+ observer.observe(scrollEl);
295
294
  observer.observe(chipsEl);
296
- if (scrollRef.current)
297
- observer.observe(scrollRef.current);
298
- return () => observer.disconnect();
299
- }, [connected, needsReauth]);
295
+ for (const header of dayHeaderRefs.current.values())
296
+ observer.observe(header);
297
+ for (const ribbon of ribbons)
298
+ observer.observe(ribbon);
299
+ scrollEl.addEventListener('scroll', updateRibbonWindows, { passive: true });
300
+ updateRibbonWindows();
301
+ return () => {
302
+ observer.disconnect();
303
+ scrollEl.removeEventListener('scroll', updateRibbonWindows);
304
+ };
305
+ }, [connected, needsReauth, filteredEvents, todayIso, theme]);
300
306
  if (!connected) {
301
307
  return (_jsx("div", { className: "calendar-pane", "data-calendar-theme": theme, children: _jsx("div", { className: "calendar-pane-prompt", children: props.emptyState ?? (_jsxs(_Fragment, { children: [_jsx("p", { children: "Not connected to Google Calendar." }), _jsx("button", { className: "calendar-connect-button", onClick: props.onConnect, children: "Connect Google Calendar" })] })) }) }));
302
308
  }
@@ -310,7 +316,7 @@ export function CalendarPane(props) {
310
316
  const loading = ctrlState.loading && filteredEvents.length === 0;
311
317
  const emptyStateText = 'No matching events in the next 30 days';
312
318
  const showCalendarSelector = calendarSources.length > 0;
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) => {
319
+ 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${selectedRsvpStatuses.has(chip.status) ? ' selected' : ''}`, "aria-pressed": selectedRsvpStatuses.has(chip.status), "aria-label": chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, title: chip.status === 'needsAction' ? `${chip.label} (${unacceptedCount})` : chip.label, onClick: () => 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) => {
314
320
  if (el) {
315
321
  dayHeaderRefs.current.set(group.dateKey, el);
316
322
  }
package/dist/styles.css CHANGED
@@ -205,6 +205,7 @@
205
205
  }
206
206
 
207
207
  .calendar-day-ribbon {
208
+ position: relative;
208
209
  flex: 0 0 16px;
209
210
  width: 16px;
210
211
  min-height: 48px;
@@ -227,10 +228,9 @@
227
228
  }
228
229
 
229
230
  .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)));
231
+ position: absolute;
232
+ top: var(--calendar-ribbon-window-top, 0px);
233
+ height: var(--calendar-ribbon-window-height, 100%);
234
234
  display: flex;
235
235
  align-items: center;
236
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/calendar",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",