@open-webapp/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.
- package/README.md +10 -1
- package/dist/react/CalendarPane.js +50 -1
- package/dist/react/CalendarSelector.js +25 -1
- package/dist/styles.css +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
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
5
|
> **0.8.0 is BREAKING.** See [0.8.0](#080-breaking-pre-10-minor) for independent Unaccepted toggling.
|
|
6
|
+
>
|
|
7
|
+
> **0.10.0 is BREAKING.** See [0.10.0](#0100-breaking-pre-10-minor) for the approved CalendarPane selector-popup geometry correction.
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
@@ -182,6 +184,8 @@ One footer combobox opens a shared searchable dropdown, with calendars grouped u
|
|
|
182
184
|
|
|
183
185
|
The footer and standalone `CalendarSelector` share a padded, rounded trigger with a visible decorative chevron, themed hover surface and keyboard-only focus outline. Long labels ellipsize visually while retaining the full accessible name. This is a visual-only correction to the former native trigger geometry: click, native Enter/Space activation, search, keyboard navigation, Escape/Done focus return, combobox relationships and selection callbacks/rules are unchanged. The default footer remains 64px high, with the upward dropdown 6px above the trigger.
|
|
184
186
|
|
|
187
|
+
In a `CalendarPane`, the upward dropdown now starts at the trigger's left edge and fits the pane's existing 16px footer-content inset. Its usual 220px minimum and 320px maximum yield to the available pane width when necessary. While it is open, the entire popup is limited to the pane/viewport space above the trigger after the preserved 6px clearance; search and actions stay reachable while the options list shrinks and scrolls (retaining its 240px cap when space allows). Geometry updates without closing the popup or resetting its query when its pane/root, window, scroll position, query, groups or theme layout changes. This is a popup-only change: it does not impose a pane root height or repair a separately unconfirmed missing-footer symptom. Standalone `CalendarSelector` placement, normal width and 240px listbox cap are unchanged.
|
|
188
|
+
|
|
185
189
|
### CalendarSelector props
|
|
186
190
|
|
|
187
191
|
`calendars: CalendarInfo[]`, `selectedIds: string[]`, `onChange(ids)`, `heading?: string` (account heading in multi-source use), `theme?: 'system' | 'light' | 'dark'` (defaults to `'system'`, including when `undefined`). It has no slot props or `popupName`. At least one calendar stays selected (deselecting the last one is ignored). "Clear" resets to the primary calendar. Built-in copy: "Search calendars", "Select all", "Clear", "Done", "No calendars found", "No calendars available".
|
|
@@ -223,10 +227,15 @@ Day ribbon styling: each day's ribbon alternates between two tones by day parity
|
|
|
223
227
|
|
|
224
228
|
The Unaccepted count badge uses `.calendar-rsvp-chip-badge`, with light and dark styling included in `styles.css`.
|
|
225
229
|
|
|
226
|
-
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.
|
|
230
|
+
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. The default 64px footer and 6px upward clearance remain unchanged. Only pane dropdowns use the approved trigger-left, pane-inset and available-height exception; standalone selectors retain their existing right-aligned 220px-to-320px geometry and 240px options cap. Import the shipped stylesheet for both footer and standalone use.
|
|
227
231
|
|
|
228
232
|
Done, Select all and Clear use the approved action-only flat styling via `.calendar-selector-actions > button` in standalone and grouped footer dropdowns: transparent resting background, zero border, no shadow, inherited font, `--calendar-selector-text` color and pointer cursor. Hover uses `--calendar-hover`; pointer focus has no decorative outline, while keyboard `:focus-visible` uses a 2px solid `--calendar-focus` outline with 2px offset. Action padding, row flex layout and 8px gap/top/bottom margins, footer/dropdown geometry, trigger/search/option styling, other native controls and all selection, callback and focus-return contracts are preserved.
|
|
229
233
|
|
|
234
|
+
## 0.10.0 (BREAKING pre-1.0 minor)
|
|
235
|
+
|
|
236
|
+
- Approved CalendarPane selector-popup geometry correction: the upward popup is aligned to the trigger's left edge, adapts to the pane's 16px content inset, and uses the pane/viewport space above the trigger as its complete height budget. Options shrink and scroll before search/actions become inaccessible.
|
|
237
|
+
- The default 64px footer, 36px-minimum trigger and 6px upward clearance are preserved. There is no pane-height/fill change and no missing-footer fix. Standalone selector geometry, public API, selection/data behavior and storage schema are unchanged; no migration is required.
|
|
238
|
+
|
|
230
239
|
## 0.9.0 (BREAKING pre-1.0 minor)
|
|
231
240
|
|
|
232
241
|
- User-approved replacement of the frozen native dropdown-action appearance: Done, Select all and Clear are flat and themed with hover and keyboard-focus treatment as described above. Hosts relying on the former native action appearance must adapt. Other visual, interaction, public API and storage contracts are preserved; no data migration is required.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { filterByRsvp, groupByDay, getOrganizerLabel, formatDayHeader, formatRibbonLabel, isEventCurrent, findNextUpcomingEvent, getCalendarEventKey, DEFAULT_RSVP_STATUSES, } from '../core/events.js';
|
|
4
4
|
import { GroupedCalendarSelector } from './CalendarSelector.js';
|
|
5
5
|
function toggleRsvpStatus(selection, status) {
|
|
@@ -144,6 +144,8 @@ export function CalendarPane(props) {
|
|
|
144
144
|
const chipsRef = useRef(null);
|
|
145
145
|
const scrollRef = useRef(null);
|
|
146
146
|
const popupRef = useRef(null);
|
|
147
|
+
const anchorSnapshotRef = useRef(null);
|
|
148
|
+
const didMountRsvpRef = useRef(false);
|
|
147
149
|
const [now, setNow] = useState(() => new Date());
|
|
148
150
|
const unacceptedCount = useMemo(() => events.filter((event) => event.selfResponseStatus === 'needsAction').length, [events]);
|
|
149
151
|
const filteredEvents = useMemo(() => filterByRsvp(events, selectedRsvpStatuses), [events, selectedRsvpStatuses]);
|
|
@@ -167,6 +169,53 @@ export function CalendarPane(props) {
|
|
|
167
169
|
const nextUpcomingEvent = findNextUpcomingEvent(filteredEvents, now);
|
|
168
170
|
const selectedEventKey = currentEventKeys.values().next().value ??
|
|
169
171
|
(nextUpcomingEvent ? getCalendarEventKey(nextUpcomingEvent) : undefined);
|
|
172
|
+
function captureAnchorSnapshot() {
|
|
173
|
+
const scrollEl = scrollRef.current;
|
|
174
|
+
if (!scrollEl)
|
|
175
|
+
return;
|
|
176
|
+
const scrollRect = scrollEl.getBoundingClientRect();
|
|
177
|
+
const offsets = new Map();
|
|
178
|
+
let anchor;
|
|
179
|
+
for (const [dateKey, header] of dayHeaderRefs.current) {
|
|
180
|
+
const rect = header.getBoundingClientRect();
|
|
181
|
+
offsets.set(dateKey, rect.top - scrollRect.top);
|
|
182
|
+
if (rect.bottom > scrollRect.top && (anchor === undefined || dateKey < anchor))
|
|
183
|
+
anchor = dateKey;
|
|
184
|
+
}
|
|
185
|
+
anchorSnapshotRef.current = anchor === undefined ? null : { anchor, offsets };
|
|
186
|
+
}
|
|
187
|
+
// Keep the visible date stable when RSVP filtering adds/removes day groups above the viewport.
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const scrollEl = scrollRef.current;
|
|
190
|
+
if (!scrollEl)
|
|
191
|
+
return;
|
|
192
|
+
const onScroll = () => captureAnchorSnapshot();
|
|
193
|
+
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
|
194
|
+
return () => scrollEl.removeEventListener('scroll', onScroll);
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
196
|
+
}, []);
|
|
197
|
+
useLayoutEffect(() => {
|
|
198
|
+
if (!didMountRsvpRef.current) {
|
|
199
|
+
didMountRsvpRef.current = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const scrollEl = scrollRef.current;
|
|
203
|
+
const snap = anchorSnapshotRef.current;
|
|
204
|
+
if (!scrollEl || !snap)
|
|
205
|
+
return;
|
|
206
|
+
const keys = Array.from(dayHeaderRefs.current.keys()).sort();
|
|
207
|
+
const target = (dayHeaderRefs.current.has(snap.anchor) ? snap.anchor : undefined) ??
|
|
208
|
+
keys.find((k) => k > snap.anchor && snap.offsets.has(k)) ??
|
|
209
|
+
keys.reverse().find((k) => k < snap.anchor && snap.offsets.has(k));
|
|
210
|
+
const header = target ? dayHeaderRefs.current.get(target) : undefined;
|
|
211
|
+
if (target && header) {
|
|
212
|
+
const delta = header.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top - snap.offsets.get(target);
|
|
213
|
+
if (delta !== 0)
|
|
214
|
+
scrollEl.scrollTop += delta;
|
|
215
|
+
}
|
|
216
|
+
captureAnchorSnapshot();
|
|
217
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
218
|
+
}, [selectedRsvpStatuses]);
|
|
170
219
|
function handleToggle(status) {
|
|
171
220
|
props.onRsvpStatusesChange(toggleRsvpStatus(selectedRsvpStatuses, status));
|
|
172
221
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
3
3
|
import { filterCalendarsByName, summarizeCalendarSelection } from '../core/events.js';
|
|
4
4
|
export function CalendarSelector({ calendars, selectedIds, onChange, heading, theme = 'system' }) {
|
|
5
5
|
return _jsx(GroupedCalendarSelector, { theme: theme, groups: [{ id: '', calendars, selectedIds, onChange, heading }] });
|
|
@@ -16,6 +16,30 @@ export function GroupedCalendarSelector({ groups, theme = 'system' }) {
|
|
|
16
16
|
const visibleGroups = groups.map((group) => ({ ...group, visible: filterCalendarsByName(group.calendars, query) }));
|
|
17
17
|
const visibleCalendars = visibleGroups.flatMap((group) => group.visible.map((calendar) => ({ calendar, group })));
|
|
18
18
|
const optionId = (sourceId, calendarId) => `${listboxId}-${encodeURIComponent(JSON.stringify([sourceId, calendarId]))}`;
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
const root = rootRef.current;
|
|
21
|
+
const pane = root?.closest('.calendar-pane');
|
|
22
|
+
if (!open || !root || !pane)
|
|
23
|
+
return;
|
|
24
|
+
function updateAvailableHeight() {
|
|
25
|
+
const top = Math.max(0, pane.getBoundingClientRect().top);
|
|
26
|
+
const bottom = Math.min(window.innerHeight, root.getBoundingClientRect().top - 6);
|
|
27
|
+
root.style.setProperty('--owa-geometry-calendar-selector-available-height', `${Math.max(0, bottom - top)}px`);
|
|
28
|
+
}
|
|
29
|
+
// Observe layout inputs, not the absolutely positioned popup they constrain.
|
|
30
|
+
const observer = new ResizeObserver(updateAvailableHeight);
|
|
31
|
+
observer.observe(pane);
|
|
32
|
+
observer.observe(root);
|
|
33
|
+
window.addEventListener('resize', updateAvailableHeight);
|
|
34
|
+
window.addEventListener('scroll', updateAvailableHeight, true);
|
|
35
|
+
updateAvailableHeight();
|
|
36
|
+
return () => {
|
|
37
|
+
observer.disconnect();
|
|
38
|
+
window.removeEventListener('resize', updateAvailableHeight);
|
|
39
|
+
window.removeEventListener('scroll', updateAvailableHeight, true);
|
|
40
|
+
root.style.removeProperty('--owa-geometry-calendar-selector-available-height');
|
|
41
|
+
};
|
|
42
|
+
}, [open, groups, query, theme]);
|
|
19
43
|
useEffect(() => {
|
|
20
44
|
if (open)
|
|
21
45
|
searchRef.current?.focus();
|
package/dist/styles.css
CHANGED
|
@@ -715,6 +715,27 @@
|
|
|
715
715
|
bottom: calc(100% + 6px);
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
+
.calendar-pane .calendar-selector-dropdown--upward {
|
|
719
|
+
left: 0;
|
|
720
|
+
right: auto;
|
|
721
|
+
display: flex;
|
|
722
|
+
flex-direction: column;
|
|
723
|
+
width: max-content;
|
|
724
|
+
min-width: min(220px, 100%);
|
|
725
|
+
max-width: min(320px, 100%, calc(100vw - 32px));
|
|
726
|
+
max-height: var(--owa-geometry-calendar-selector-available-height);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.calendar-pane .calendar-selector-dropdown > * {
|
|
730
|
+
flex: none;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.calendar-pane .calendar-selector-dropdown > [role='listbox'] {
|
|
734
|
+
flex: 0 1 auto;
|
|
735
|
+
min-height: 0;
|
|
736
|
+
min-width: 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
718
739
|
.calendar-selector-dropdown input[type='search'] {
|
|
719
740
|
box-sizing: border-box;
|
|
720
741
|
width: 100%;
|