@open-webapp/calendar 0.2.1 → 0.3.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
@@ -158,6 +158,14 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
158
158
  | `onClose?` | `() => void` | |
159
159
  | `popupName?` | `string` | `window.open` name for the event popup (default `notesdiary-cal-event`) |
160
160
 
161
+ #### RSVP chips
162
+
163
+ 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).
164
+
165
+ 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.
166
+
167
+ 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.
168
+
161
169
  Built-in copy (overridable only through the slots above):
162
170
 
163
171
  - Not connected: "Not connected to Google Calendar." with "Connect Google Calendar".
@@ -178,6 +186,14 @@ Built-in copy (overridable only through the slots above):
178
186
  - `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
179
187
  - `--rsvp-bar` (set internally per RSVP status; do not override)
180
188
 
189
+ The Unaccepted count badge uses `.calendar-rsvp-chip-badge`, with light and dark styling included in `styles.css`.
190
+
191
+ ## 0.3.0 (minor, additive)
192
+
193
+ - Adds the Unaccepted exclusive shortcut after Accepted, Maybe, and Declined, with local selection restoration and default fallback as described above.
194
+ - 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`.
195
+ - Adds the `.calendar-rsvp-chip-badge` host styling hook. Public props and default RSVP statuses remain compatible.
196
+
181
197
  ## 0.2.1 (patch)
182
198
 
183
199
  Fixes only, no behavior contract change: `getState().loading` is now published while a sync pass is in flight; a selection-change (partial) pass no longer clears `deferred` set by an earlier full pass; `CalendarPane` tolerates controllers without `getState`/`hydrate`.
@@ -2,6 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { filterByRsvp, groupByDay, getOrganizerLabel, formatDayHeader, isEventCurrent, findNextUpcomingEvent, getCalendarEventKey, DEFAULT_RSVP_STATUSES, } from '../core/events.js';
4
4
  import { CalendarSelector } from './CalendarSelector.js';
5
+ function toggleRsvpStatus(selection, status) {
6
+ const next = new Set(selection);
7
+ if (next.has(status)) {
8
+ next.delete(status);
9
+ }
10
+ else {
11
+ next.add(status);
12
+ }
13
+ return next.size === 0 ? new Set(DEFAULT_RSVP_STATUSES) : next;
14
+ }
5
15
  function useWindowWidth() {
6
16
  const [width, setWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
7
17
  useEffect(() => {
@@ -23,16 +33,16 @@ const RSVP_CHIPS = [
23
33
  label: 'Maybe',
24
34
  icon: (_jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("path", { d: "M5.2 5.6C5.2 4.2 6.2 3.2 7.5 3.2C8.8 3.2 9.8 4.2 9.8 5.4C9.8 6.8 7.6 6.9 7.6 8.8", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("circle", { cx: "7.6", cy: "11.2", r: "0.9", fill: "currentColor" })] })),
25
35
  },
26
- {
27
- status: 'needsAction',
28
- label: 'Unaccepted',
29
- icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("circle", { cx: "7.5", cy: "7.5", r: "4.3", stroke: "currentColor", strokeWidth: "1.4" }) })),
30
- },
31
36
  {
32
37
  status: 'declined',
33
38
  label: 'Declined',
34
39
  icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M4 4L11 11M11 4L4 11", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })),
35
40
  },
41
+ {
42
+ status: 'needsAction',
43
+ label: 'Unaccepted',
44
+ icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("circle", { cx: "7.5", cy: "7.5", r: "4.3", stroke: "currentColor", strokeWidth: "1.4" }) })),
45
+ },
36
46
  ];
37
47
  const DEFAULT_POPUP_NAME = 'notesdiary-cal-event';
38
48
  const POPUP_W = 560;
@@ -122,6 +132,13 @@ export function CalendarPane(props) {
122
132
  const chipsRef = useRef(null);
123
133
  const popupRef = useRef(null);
124
134
  const [now, setNow] = useState(() => new Date());
135
+ const [savedSelection, setSavedSelection] = useState(null);
136
+ const isExclusive = selectedRsvpStatuses.size === 1 && selectedRsvpStatuses.has('needsAction');
137
+ useEffect(() => {
138
+ if (!isExclusive && savedSelection !== null)
139
+ setSavedSelection(null);
140
+ }, [isExclusive, savedSelection]);
141
+ const unacceptedCount = useMemo(() => events.filter((event) => event.selfResponseStatus === 'needsAction').length, [events]);
125
142
  const filteredEvents = useMemo(() => filterByRsvp(events, selectedRsvpStatuses), [events, selectedRsvpStatuses]);
126
143
  const calendarsByKey = useMemo(() => {
127
144
  const map = new Map();
@@ -143,19 +160,21 @@ export function CalendarPane(props) {
143
160
  const nextUpcomingEvent = findNextUpcomingEvent(filteredEvents, now);
144
161
  const selectedEventKey = currentEventKeys.values().next().value ??
145
162
  (nextUpcomingEvent ? getCalendarEventKey(nextUpcomingEvent) : undefined);
146
- function handleToggle(status) {
147
- const next = new Set(selectedRsvpStatuses);
148
- if (next.has(status)) {
149
- next.delete(status);
150
- if (next.size === 0) {
151
- props.onRsvpStatusesChange(new Set(DEFAULT_RSVP_STATUSES));
152
- return;
153
- }
163
+ function handleUnaccepted() {
164
+ if (isExclusive) {
165
+ props.onRsvpStatusesChange(savedSelection ?? new Set(DEFAULT_RSVP_STATUSES));
166
+ setSavedSelection(null);
154
167
  }
155
168
  else {
156
- next.add(status);
169
+ setSavedSelection(new Set(selectedRsvpStatuses));
170
+ props.onRsvpStatusesChange(new Set(['needsAction']));
157
171
  }
158
- props.onRsvpStatusesChange(next);
172
+ }
173
+ function handleToggle(status) {
174
+ const base = isExclusive ? savedSelection ?? DEFAULT_RSVP_STATUSES : selectedRsvpStatuses;
175
+ if (isExclusive)
176
+ setSavedSelection(null);
177
+ props.onRsvpStatusesChange(toggleRsvpStatus(base, status));
159
178
  }
160
179
  const dayGroups = groupByDay(filteredEvents, todayIso);
161
180
  function scrollToToday(behavior = 'auto') {
@@ -279,7 +298,7 @@ export function CalendarPane(props) {
279
298
  const loading = ctrlState.loading && filteredEvents.length === 0;
280
299
  const emptyStateText = 'No matching events in the next 30 days';
281
300
  const showCalendarSelector = calendarSources.length > 0;
282
- return (_jsxs("div", { className: "calendar-pane", 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) => (_jsx("button", { type: "button", className: `calendar-rsvp-chip${selectedRsvpStatuses.has(chip.status) ? ' selected' : ''}`, "aria-pressed": selectedRsvpStatuses.has(chip.status), "aria-label": chip.label, title: chip.label, onClick: () => handleToggle(chip.status), children: chip.icon }, 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) => {
301
+ return (_jsxs("div", { className: "calendar-pane", 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) => {
283
302
  if (el) {
284
303
  dayHeaderRefs.current.set(group.dateKey, el);
285
304
  }
package/dist/styles.css CHANGED
@@ -68,12 +68,16 @@
68
68
  }
69
69
 
70
70
  .calendar-rsvp-chip {
71
- width: 32px;
71
+ width: auto;
72
+ min-width: 32px;
72
73
  height: 32px;
74
+ box-sizing: border-box;
75
+ flex-shrink: 0;
73
76
  display: flex;
74
77
  align-items: center;
75
78
  justify-content: center;
76
- padding: 0;
79
+ gap: 4px;
80
+ padding: 0 8px;
77
81
  background: transparent;
78
82
  color: #333;
79
83
  border: 1px solid #ccc;
@@ -97,6 +101,19 @@
97
101
  border-color: #081a59;
98
102
  }
99
103
 
104
+ .calendar-rsvp-chip-badge {
105
+ min-width: 16px;
106
+ padding: 1px 4px;
107
+ border-radius: 999px;
108
+ background: #e8eefb;
109
+ color: #081a59;
110
+ font-size: 11px;
111
+ font-weight: 600;
112
+ line-height: 16px;
113
+ text-align: center;
114
+ white-space: nowrap;
115
+ }
116
+
100
117
  .calendar-empty-state {
101
118
  padding: 24px 16px;
102
119
  color: #999;
@@ -306,6 +323,11 @@
306
323
  border-color: #22d0ef;
307
324
  }
308
325
 
326
+ .calendar-rsvp-chip-badge {
327
+ background: #081a59;
328
+ color: #e8eefb;
329
+ }
330
+
309
331
  .calendar-day-header {
310
332
  background: #222;
311
333
  color: #eee;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/calendar",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",