@open-webapp/calendar 0.4.0 → 0.5.1

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
@@ -38,6 +38,7 @@ void controller.refresh(); // then syncs from Google
38
38
  <CalendarPane
39
39
  sources={sources}
40
40
  controller={controller}
41
+ theme="system" // optional: 'system' | 'light' | 'dark'
41
42
  selectedRsvpStatuses={statuses} // Set<RsvpStatus>
42
43
  onRsvpStatusesChange={setStatuses}
43
44
  connected={connected} // optional, see CalendarPane props
@@ -146,6 +147,7 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
146
147
  | --- | --- | --- |
147
148
  | `sources` | `Source[]` | shown accounts; 2+ enables multi-source UI (per-source color and email) |
148
149
  | `controller` | `CalendarController` | |
150
+ | `theme?` | `'system' \| 'light' \| 'dark'` | defaults to `'system'`, including when `undefined`; applies to every pane state and all internal selectors |
149
151
  | `selectedRsvpStatuses` | `Set<RsvpStatus>` | filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction |
150
152
  | `onRsvpStatusesChange` | `(next) => void` | |
151
153
  | `renderEventAction?` | `(event) => ReactNode` | per-event slot |
@@ -178,15 +180,38 @@ Built-in copy (overridable only through the slots above):
178
180
 
179
181
  One footer combobox opens a shared searchable dropdown, with calendars grouped under account headings in multi-source use. Each account has its own Clear (primary, else first calendar) and filtered Select all, which adds matching calendars while retaining existing selections. Actions affect only their account; its last selected calendar cannot be deselected, including a non-primary selection.
180
182
 
183
+ 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
+
181
185
  ### CalendarSelector props
182
186
 
183
- `calendars: CalendarInfo[]`, `selectedIds: string[]`, `onChange(ids)`, `heading?: string` (account heading in multi-source use). 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".
187
+ `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".
188
+
189
+ ```tsx
190
+ import { CalendarSelector } from '@open-webapp/calendar';
191
+ import '@open-webapp/calendar/styles.css';
192
+
193
+ <CalendarSelector
194
+ calendars={calendars}
195
+ selectedIds={selectedIds}
196
+ onChange={setSelectedIds}
197
+ heading="me@example.com"
198
+ theme="dark"
199
+ />;
200
+ ```
201
+
202
+ ### Theme behavior
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.
205
+
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
+
208
+ The host owns theme-picker UI and preference persistence (for example, saving a choice and passing it back as `theme`). The package neither stores the choice nor adds it to the controller. Hosts also own styling of `emptyState`, `sourceNotice` and `renderEventAction` slot content; give custom content explicit styles where needed rather than relying on the built-in palette.
184
209
 
185
210
  The exported standalone selector keeps its single-list props and behavior; Select all adds only search matches while retaining existing selections.
186
211
 
187
212
  ### Host CSS
188
213
 
189
- `styles.css` is self-contained (plain colors, dark mode via `prefers-color-scheme`). It requires no host CSS variables. Variables it reads, all with defaults:
214
+ `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:
190
215
 
191
216
  - `--calendar-footer-height` (default `64px`, set on `.calendar-pane`; override to match host footer)
192
217
  - `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
@@ -194,6 +219,18 @@ The exported standalone selector keeps its single-list props and behavior; Selec
194
219
 
195
220
  The Unaccepted count badge uses `.calendar-rsvp-chip-badge`, with light and dark styling included in `styles.css`.
196
221
 
222
+ 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.
223
+
224
+ ## 0.5.1 (patch)
225
+
226
+ - Corrects footer and standalone selector affordance with a padded, rounded 36px-minimum trigger, decorative chevron, themed hover and keyboard-focus treatment, and long-label containment.
227
+ - Approved visual-only exception to native trigger geometry/surface treatment; the default 64px footer and 6px dropdown clearance are preserved. Public props, copy, accessible names/relationships, interactions, selection semantics, controller and storage/schema contracts are unchanged. No migration is required.
228
+
229
+ ## 0.5.0 (minor, additive)
230
+
231
+ - Adds optional `theme?: 'system' | 'light' | 'dark'` to both React components, with live OS tracking by default and live per-instance overrides.
232
+ - Themes all built-in pane states and internal/standalone selectors while preserving existing palettes, layout, copy and interaction contracts. Existing callers need no migration.
233
+
197
234
  ## 0.4.0 (BREAKING pre-1.0 minor)
198
235
 
199
236
  - CalendarPane replaces per-account footer triggers with one shared dropdown grouped by account. Hosts relying on multiple footer triggers must adapt. Account-local selection rules and the exported standalone CalendarSelector remain compatible.
@@ -3,6 +3,7 @@ import type { CalendarEvent, RsvpStatus, Source } from '../core/types.js';
3
3
  import type { CalendarController } from '../core/controller.js';
4
4
  export type RsvpSelection = Set<RsvpStatus>;
5
5
  export interface CalendarPaneProps {
6
+ theme?: 'system' | 'light' | 'dark';
6
7
  sources: Source[];
7
8
  controller: CalendarController;
8
9
  selectedRsvpStatuses: RsvpSelection;
@@ -100,7 +100,7 @@ function usableUrl(url) {
100
100
  }
101
101
  export function CalendarPane(props) {
102
102
  const isDesktop = useWindowWidth() >= 960;
103
- const { sources, controller, selectedRsvpStatuses } = props;
103
+ const { sources, controller, selectedRsvpStatuses, theme = 'system' } = props;
104
104
  const popupName = props.popupName ?? DEFAULT_POPUP_NAME;
105
105
  const [version, setVersion] = useState(0);
106
106
  useEffect(() => {
@@ -286,10 +286,10 @@ export function CalendarPane(props) {
286
286
  return () => observer.disconnect();
287
287
  }, [connected, needsReauth]);
288
288
  if (!connected) {
289
- return (_jsx("div", { className: "calendar-pane", 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" })] })) }) }));
289
+ 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" })] })) }) }));
290
290
  }
291
291
  if (needsReauth) {
292
- return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.sourceNotice && needsScopeSources.length > 0 ? (needsScopeSources.map((s) => _jsx("div", { children: props.sourceNotice?.(s) }, s.id))) : (_jsxs(_Fragment, { children: [_jsx("p", { children: "Calendar connection needs reauthorization." }), _jsx("button", { className: "calendar-connect-button", onClick: props.onReconnect, children: "Reconnect to restore sync" })] })) }) }));
292
+ return (_jsx("div", { className: "calendar-pane", "data-calendar-theme": theme, children: _jsx("div", { className: "calendar-pane-prompt", children: props.sourceNotice && needsScopeSources.length > 0 ? (needsScopeSources.map((s) => _jsx("div", { children: props.sourceNotice?.(s) }, s.id))) : (_jsxs(_Fragment, { children: [_jsx("p", { children: "Calendar connection needs reauthorization." }), _jsx("button", { className: "calendar-connect-button", onClick: props.onReconnect, children: "Reconnect to restore sync" })] })) }) }));
293
293
  }
294
294
  const sourcesWithEvents = new Set(filteredEvents.map((e) => e.sourceId));
295
295
  const hasErrorWithoutEvents = sources.some((s) => statuses[s.id] === 'error' && !sourcesWithEvents.has(s.id));
@@ -298,7 +298,7 @@ export function CalendarPane(props) {
298
298
  const loading = ctrlState.loading && filteredEvents.length === 0;
299
299
  const emptyStateText = 'No matching events in the next 30 days';
300
300
  const showCalendarSelector = calendarSources.length > 0;
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) => {
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) => {
302
302
  if (el) {
303
303
  dayHeaderRefs.current.set(group.dateKey, el);
304
304
  }
@@ -343,7 +343,7 @@ export function CalendarPane(props) {
343
343
  openEventPopup(event.htmlLink, popupRef, popupName);
344
344
  }
345
345
  }, children: _jsx("span", { className: "calendar-event-title", children: event.summary }) })) : (_jsx("span", { className: "calendar-event-title", children: event.summary })), props.renderEventAction?.(event)] }), showCalendarLabel && (_jsxs("span", { className: "calendar-event-calendar", children: [_jsx("span", { className: "calendar-event-calendar-dot", style: { backgroundColor: calendar.backgroundColor } }), calendar.summary] })), showOrganizerLabel && (_jsxs("span", { className: "calendar-event-organizer", children: [multi && (_jsx("span", { className: "calendar-event-source-dot", style: { backgroundColor: sourceColor(event.sourceId) } })), organizerText] }))] }, eventKey));
346
- })] }, group.dateKey))), loading && _jsx("p", { className: "calendar-empty-state-text", children: "Loading calendar\u2026" }), filteredEvents.length === 0 && !loading && !error && needsScopeSources.length === 0 && _jsx("p", { className: "calendar-empty-state-text", children: emptyStateText })] }))] }), showCalendarSelector && (_jsx("footer", { className: "calendar-pane-footer", children: _jsx(GroupedCalendarSelector, { groups: calendarSources.map((s) => ({
346
+ })] }, group.dateKey))), loading && _jsx("p", { className: "calendar-empty-state-text", children: "Loading calendar\u2026" }), filteredEvents.length === 0 && !loading && !error && needsScopeSources.length === 0 && _jsx("p", { className: "calendar-empty-state-text", children: emptyStateText })] }))] }), showCalendarSelector && (_jsx("footer", { className: "calendar-pane-footer", children: _jsx(GroupedCalendarSelector, { theme: theme, groups: calendarSources.map((s) => ({
347
347
  id: s.id,
348
348
  calendars: controller.getCalendars(s.id),
349
349
  selectedIds: controller.getSelectedIds(s.id),
@@ -5,12 +5,14 @@ interface CalendarSelectorProps {
5
5
  onChange: (selectedIds: string[]) => void;
6
6
  /** Optional account heading (used when several sources are shown). */
7
7
  heading?: string;
8
+ theme?: 'system' | 'light' | 'dark';
8
9
  }
9
- export declare function CalendarSelector({ calendars, selectedIds, onChange, heading }: CalendarSelectorProps): import("react").JSX.Element;
10
+ export declare function CalendarSelector({ calendars, selectedIds, onChange, heading, theme }: CalendarSelectorProps): import("react").JSX.Element;
10
11
  /** Internal pane renderer; intentionally not re-exported from the package barrel. */
11
- export declare function GroupedCalendarSelector({ groups }: {
12
+ export declare function GroupedCalendarSelector({ groups, theme }: {
12
13
  groups: (CalendarSelectorProps & {
13
14
  id: string;
14
15
  })[];
16
+ theme?: CalendarSelectorProps['theme'];
15
17
  }): import("react").JSX.Element;
16
18
  export {};
@@ -1,11 +1,11 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useRef, useState } from 'react';
3
3
  import { filterCalendarsByName, summarizeCalendarSelection } from '../core/events.js';
4
- export function CalendarSelector({ calendars, selectedIds, onChange, heading }) {
5
- return _jsx(GroupedCalendarSelector, { groups: [{ id: '', calendars, selectedIds, onChange, heading }] });
4
+ export function CalendarSelector({ calendars, selectedIds, onChange, heading, theme = 'system' }) {
5
+ return _jsx(GroupedCalendarSelector, { theme: theme, groups: [{ id: '', calendars, selectedIds, onChange, heading }] });
6
6
  }
7
7
  /** Internal pane renderer; intentionally not re-exported from the package barrel. */
8
- export function GroupedCalendarSelector({ groups }) {
8
+ export function GroupedCalendarSelector({ groups, theme = 'system' }) {
9
9
  const [open, setOpen] = useState(false);
10
10
  const [query, setQuery] = useState('');
11
11
  const [activeIndex, setActiveIndex] = useState(0);
@@ -90,11 +90,11 @@ export function GroupedCalendarSelector({ groups }) {
90
90
  ? summarizeCalendarSelection(groups[0].selectedIds, groups[0].calendars)
91
91
  : `${groups.reduce((count, group) => count + group.selectedIds.length, 0)} calendars selected`;
92
92
  const activeCalendar = visibleCalendars[activeIndex];
93
- return (_jsxs("div", { className: "calendar-selector", ref: rootRef, children: [_jsx("button", { ref: buttonRef, type: "button", role: "combobox", "aria-label": label, "aria-expanded": open, "aria-controls": listboxId, "aria-haspopup": "listbox", onClick: () => {
93
+ return (_jsxs("div", { className: "calendar-selector", "data-calendar-theme": theme, ref: rootRef, children: [_jsxs("button", { className: "calendar-selector-trigger", ref: buttonRef, type: "button", role: "combobox", "aria-label": label, "aria-expanded": open, "aria-controls": listboxId, "aria-haspopup": "listbox", onClick: () => {
94
94
  setOpen((isOpen) => !isOpen);
95
95
  setQuery('');
96
96
  setActiveIndex(0);
97
- }, children: label }), open && (_jsxs("div", { className: "calendar-selector-dropdown calendar-selector-dropdown--upward", onKeyDown: handleListboxKeyDown, children: [groups.length === 1 && groups[0].heading && (_jsx("div", { role: "heading", "aria-level": 3, className: "calendar-selector-heading", children: groups[0].heading })), _jsx("input", { ref: searchRef, type: "search", role: "searchbox", "aria-label": "Search calendars", value: query, onChange: (event) => {
97
+ }, children: [_jsx("span", { className: "calendar-selector-trigger-label", children: label }), _jsx("svg", { className: "calendar-selector-chevron", width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3 4.5 6 7.5 9 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open && (_jsxs("div", { className: "calendar-selector-dropdown calendar-selector-dropdown--upward", onKeyDown: handleListboxKeyDown, children: [groups.length === 1 && groups[0].heading && (_jsx("div", { role: "heading", "aria-level": 3, className: "calendar-selector-heading", children: groups[0].heading })), _jsx("input", { ref: searchRef, type: "search", role: "searchbox", "aria-label": "Search calendars", value: query, onChange: (event) => {
98
98
  setQuery(event.target.value);
99
99
  setActiveIndex(0);
100
100
  } }), _jsxs("div", { className: "calendar-selector-actions", children: [groups.length === 1 && _jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => selectAll(visibleGroups[0]), children: "Select all" }), _jsx("button", { type: "button", onClick: () => clear(groups[0]), children: "Clear" })] }), _jsx("button", { type: "button", onClick: close, children: "Done" })] }), _jsx("div", { id: listboxId, role: "listbox", "aria-multiselectable": "true", "aria-activedescendant": activeCalendar ? optionId(activeCalendar.group.id, activeCalendar.calendar.id) : undefined, tabIndex: -1, children: visibleGroups.map((group) => (_jsxs("div", { role: groups.length > 1 ? 'group' : undefined, "aria-label": group.heading, children: [groups.length > 1 && _jsxs(_Fragment, { children: [_jsx("div", { role: "heading", "aria-level": 3, className: "calendar-selector-heading", children: group.heading }), _jsxs("div", { className: "calendar-selector-actions", children: [_jsx("button", { type: "button", onClick: () => selectAll(group), children: "Select all" }), _jsx("button", { type: "button", onClick: () => clear(group), children: "Clear" })] })] }), group.calendars.length === 0 ? (_jsx("p", { children: "No calendars available" })) : group.visible.length === 0 ? (_jsx("p", { children: "No calendars found" })) : (group.visible.map((calendar) => (_jsxs("button", { type: "button", id: optionId(group.id, calendar.id), role: "option", title: calendar.summary, "aria-selected": group.selectedIds.includes(calendar.id), "data-active": (activeCalendar?.group.id === group.id && activeCalendar.calendar.id === calendar.id) || undefined, onClick: () => toggle(group, calendar.id), children: [_jsx("input", { type: "checkbox", className: "calendar-selector-checkbox", checked: group.selectedIds.includes(calendar.id), readOnly: true, tabIndex: -1, "aria-hidden": "true" }), _jsx("span", { "aria-hidden": "true", className: "calendar-selector-dot", "data-testid": `calendar-color-${calendar.id}`, style: { backgroundColor: calendar.backgroundColor } }), _jsx("span", { className: "calendar-selector-name", title: calendar.summary, children: calendar.summary })] }, optionId(group.id, calendar.id)))))] }, group.id))) })] }))] }));
package/dist/styles.css CHANGED
@@ -1,3 +1,55 @@
1
+ .calendar-pane,
2
+ .calendar-selector {
3
+ /* Reset the complete palette at every independent component boundary. */
4
+ --calendar-scheme: light;
5
+ --calendar-surface: #ffffff;
6
+ --calendar-text: #222;
7
+ --calendar-border: #eee;
8
+ --calendar-prompt: #555;
9
+ --calendar-accent: #00a9ce;
10
+ --calendar-hover: #f0f8fb;
11
+ --calendar-chip-text: #333;
12
+ --calendar-chip-border: #ccc;
13
+ --calendar-chip-selected: #081a59;
14
+ --calendar-chip-selected-text: #ffffff;
15
+ --calendar-badge: #e8eefb;
16
+ --calendar-badge-text: #081a59;
17
+ --calendar-header: #f5f5f5;
18
+ --calendar-allday: #fdf6e3;
19
+ --calendar-highlight: #f0f8fb;
20
+ --calendar-insert: #888;
21
+ --calendar-insert-hover: #222;
22
+ --calendar-insert-background: rgba(0, 0, 0, 0.06);
23
+ --calendar-focus: #1a73e8;
24
+ --calendar-rsvp-accepted: #2e7d32;
25
+ --calendar-rsvp-tentative: #d97706;
26
+ --calendar-rsvp-needsAction: #2563eb;
27
+ --calendar-rsvp-declined: #dc2626;
28
+ --calendar-selector-border: #ccc;
29
+ --calendar-selector-text: #1a1a1a;
30
+ --calendar-selector-shadow: 0 4px 14px rgb(0 0 0 / 15%);
31
+ --calendar-control-border: #767676;
32
+ --calendar-control-background: #efefef;
33
+ --calendar-input-background: #ffffff;
34
+ --calendar-control-text: #000000;
35
+ --calendar-control-width: 2px;
36
+ --calendar-button-border-style: outset;
37
+ --calendar-input-border-style: inset;
38
+ --calendar-option-hover: transparent;
39
+ --calendar-option-active: #e8f5f8;
40
+ --calendar-muted: #888;
41
+ --calendar-time: #666;
42
+ --calendar-empty: #999;
43
+ --calendar-chip-hover: #00a9ce;
44
+ --calendar-checkbox: #22a6c0;
45
+ color-scheme: var(--calendar-scheme);
46
+ color: var(--calendar-text);
47
+ }
48
+
49
+ .calendar-pane {
50
+ background: var(--calendar-surface);
51
+ }
52
+
1
53
  .calendar-pane {
2
54
  --calendar-footer-height: 64px;
3
55
  display: flex;
@@ -23,8 +75,8 @@
23
75
  box-sizing: border-box;
24
76
  min-height: var(--calendar-footer-height);
25
77
  padding: 12px 16px;
26
- border-top: 1px solid #eee;
27
- background: #ffffff;
78
+ border-top: 1px solid var(--calendar-border);
79
+ background: var(--calendar-surface);
28
80
  }
29
81
 
30
82
  .calendar-pane-prompt {
@@ -37,7 +89,7 @@
37
89
 
38
90
  .calendar-pane-prompt p {
39
91
  margin: 0;
40
- color: #555;
92
+ color: var(--calendar-prompt);
41
93
  font-size: 14px;
42
94
  }
43
95
 
@@ -45,15 +97,15 @@
45
97
  padding: 10px 16px;
46
98
  font-size: 14px;
47
99
  font-weight: 500;
48
- background: #ffffff;
49
- color: #00a9ce;
50
- border: 1px solid #00a9ce;
100
+ background: var(--calendar-surface);
101
+ color: var(--calendar-accent);
102
+ border: 1px solid var(--calendar-accent);
51
103
  border-radius: 6px;
52
104
  cursor: pointer;
53
105
  }
54
106
 
55
107
  .calendar-connect-button:hover {
56
- background: #f0f8fb;
108
+ background: var(--calendar-hover);
57
109
  }
58
110
 
59
111
  .calendar-rsvp-chips {
@@ -63,7 +115,7 @@
63
115
  flex-wrap: wrap;
64
116
  position: sticky;
65
117
  top: 0;
66
- background: #ffffff;
118
+ background: var(--calendar-surface);
67
119
  z-index: 2;
68
120
  }
69
121
 
@@ -79,8 +131,8 @@
79
131
  gap: 4px;
80
132
  padding: 0 8px;
81
133
  background: transparent;
82
- color: #333;
83
- border: 1px solid #ccc;
134
+ color: var(--calendar-chip-text);
135
+ border: 1px solid var(--calendar-chip-border);
84
136
  border-radius: 999px;
85
137
  cursor: pointer;
86
138
  }
@@ -88,25 +140,25 @@
88
140
  .calendar-scroll-today-btn {
89
141
  margin-right: 4px;
90
142
  padding-right: 4px;
91
- border-right: 1px solid #ccc;
143
+ border-right: 1px solid var(--calendar-chip-border);
92
144
  }
93
145
 
94
146
  .calendar-rsvp-chip:hover {
95
- border-color: #00a9ce;
147
+ border-color: var(--calendar-chip-hover);
96
148
  }
97
149
 
98
150
  .calendar-rsvp-chip.selected {
99
- background: #081a59;
100
- color: #ffffff;
101
- border-color: #081a59;
151
+ background: var(--calendar-chip-selected);
152
+ color: var(--calendar-chip-selected-text);
153
+ border-color: var(--calendar-chip-selected);
102
154
  }
103
155
 
104
156
  .calendar-rsvp-chip-badge {
105
157
  min-width: 16px;
106
158
  padding: 1px 4px;
107
159
  border-radius: 999px;
108
- background: #e8eefb;
109
- color: #081a59;
160
+ background: var(--calendar-badge);
161
+ color: var(--calendar-badge-text);
110
162
  font-size: 11px;
111
163
  font-weight: 600;
112
164
  line-height: 16px;
@@ -116,7 +168,7 @@
116
168
 
117
169
  .calendar-empty-state {
118
170
  padding: 24px 16px;
119
- color: #999;
171
+ color: var(--calendar-empty);
120
172
  font-size: 14px;
121
173
  }
122
174
 
@@ -133,8 +185,8 @@
133
185
  .calendar-day-header {
134
186
  position: sticky;
135
187
  top: var(--chip-row-height, 44px);
136
- background: #f5f5f5;
137
- color: #333;
188
+ background: var(--calendar-header);
189
+ color: var(--calendar-chip-text);
138
190
  font-size: 13px;
139
191
  font-weight: 600;
140
192
  padding: 8px 16px;
@@ -147,7 +199,7 @@
147
199
  gap: 8px;
148
200
  padding: 10px 16px;
149
201
  border-left: 3px solid var(--rsvp-bar, transparent);
150
- border-bottom: 1px solid #eee;
202
+ border-bottom: 1px solid var(--calendar-border);
151
203
  }
152
204
 
153
205
  .calendar-event-row {
@@ -156,28 +208,28 @@
156
208
  gap: 8px;
157
209
  padding: 10px 16px;
158
210
  border-left: 3px solid var(--rsvp-bar, transparent);
159
- border-bottom: 1px solid #eee;
211
+ border-bottom: 1px solid var(--calendar-border);
160
212
  }
161
213
 
162
214
  .calendar-event-allday {
163
- background: #fdf6e3;
215
+ background: var(--calendar-allday);
164
216
  font-style: italic;
165
217
  }
166
218
 
167
219
  .calendar-event-row--rsvp-accepted {
168
- --rsvp-bar: #2e7d32;
220
+ --rsvp-bar: var(--calendar-rsvp-accepted);
169
221
  }
170
222
 
171
223
  .calendar-event-row--rsvp-tentative {
172
- --rsvp-bar: #d97706;
224
+ --rsvp-bar: var(--calendar-rsvp-tentative);
173
225
  }
174
226
 
175
227
  .calendar-event-row--rsvp-needsAction {
176
- --rsvp-bar: #2563eb;
228
+ --rsvp-bar: var(--calendar-rsvp-needsAction);
177
229
  }
178
230
 
179
231
  .calendar-event-row--rsvp-declined {
180
- --rsvp-bar: #dc2626;
232
+ --rsvp-bar: var(--calendar-rsvp-declined);
181
233
  }
182
234
 
183
235
  .calendar-event-header {
@@ -217,36 +269,36 @@
217
269
  border: none;
218
270
  border-radius: 4px;
219
271
  background: transparent;
220
- color: #888;
272
+ color: var(--calendar-insert);
221
273
  cursor: pointer;
222
274
  }
223
275
 
224
276
  .calendar-event-insert-link:hover {
225
- color: #222;
226
- background: rgba(0, 0, 0, 0.06);
277
+ color: var(--calendar-insert-hover);
278
+ background: var(--calendar-insert-background);
227
279
  }
228
280
 
229
281
  .calendar-event-insert-link:focus-visible {
230
- outline: 2px solid #1a73e8;
282
+ outline: 2px solid var(--calendar-focus);
231
283
  outline-offset: 1px;
232
- color: #222;
284
+ color: var(--calendar-insert-hover);
233
285
  }
234
286
 
235
287
  .calendar-event-time {
236
288
  font-size: 12px;
237
- color: #666;
289
+ color: var(--calendar-time);
238
290
  min-width: 110px;
239
291
  flex-shrink: 0;
240
292
  }
241
293
 
242
294
  .calendar-event-title {
243
295
  font-size: 14px;
244
- color: #222;
296
+ color: var(--calendar-text);
245
297
  }
246
298
 
247
299
  .calendar-event-organizer {
248
300
  font-size: 12px;
249
- color: #888;
301
+ color: var(--calendar-muted);
250
302
  }
251
303
 
252
304
  .calendar-event-calendar {
@@ -254,7 +306,7 @@
254
306
  align-items: center;
255
307
  gap: 5px;
256
308
  font-size: 12px;
257
- color: #888;
309
+ color: var(--calendar-muted);
258
310
  }
259
311
 
260
312
  .calendar-event-calendar-dot {
@@ -267,135 +319,153 @@
267
319
  .calendar-event-join {
268
320
  font-size: 12px;
269
321
  font-weight: 500;
270
- color: #00a9ce;
322
+ color: var(--calendar-accent);
271
323
  cursor: pointer;
272
324
  text-decoration: none;
273
325
  padding: 4px 8px;
274
- border: 1px solid #00a9ce;
326
+ border: 1px solid var(--calendar-accent);
275
327
  border-radius: 4px;
276
328
  }
277
329
 
278
330
  .calendar-event-join:hover {
279
- background: #f0f8fb;
331
+ background: var(--calendar-hover);
280
332
  }
281
333
 
282
334
  .calendar-event-row--current,
283
335
  .calendar-event-row--selected {
284
- background: #f0f8fb;
336
+ background: var(--calendar-highlight);
337
+ }
338
+
339
+ /* Explicit dark and system-dark use exactly the same palette. The media query
340
+ only assigns tokens on system roots, never on their descendants. */
341
+ .calendar-pane[data-calendar-theme='dark'],
342
+ .calendar-selector[data-calendar-theme='dark'] {
343
+ --calendar-scheme: dark;
344
+ --calendar-surface: #1a1a1a;
345
+ --calendar-text: #eee;
346
+ --calendar-border: #333;
347
+ --calendar-prompt: #ccc;
348
+ --calendar-accent: #22d0ef;
349
+ --calendar-hover: #2a2a2a;
350
+ --calendar-chip-text: #eee;
351
+ --calendar-chip-border: #444;
352
+ --calendar-chip-selected: #22d0ef;
353
+ --calendar-chip-selected-text: #081a59;
354
+ --calendar-badge: #081a59;
355
+ --calendar-badge-text: #e8eefb;
356
+ --calendar-header: #222;
357
+ --calendar-allday: #2a2510;
358
+ --calendar-highlight: #10262b;
359
+ --calendar-insert: #999;
360
+ --calendar-insert-hover: #eee;
361
+ --calendar-insert-background: rgba(255, 255, 255, 0.1);
362
+ --calendar-focus: #22d0ef;
363
+ --calendar-rsvp-accepted: #4ade80;
364
+ --calendar-rsvp-tentative: #fbbf24;
365
+ --calendar-rsvp-needsAction: #60a5fa;
366
+ --calendar-rsvp-declined: #f87171;
367
+ --calendar-selector-border: #333;
368
+ --calendar-selector-text: #eee;
369
+ --calendar-selector-shadow: 0 4px 14px rgb(0 0 0 / 50%);
370
+ --calendar-control-border: #444;
371
+ --calendar-control-background: #2a2a2a;
372
+ --calendar-input-background: #2a2a2a;
373
+ --calendar-control-text: #eee;
374
+ --calendar-control-width: 1px;
375
+ --calendar-button-border-style: solid;
376
+ --calendar-input-border-style: solid;
377
+ --calendar-option-hover: #2a2a2a;
378
+ --calendar-option-active: #10262b;
285
379
  }
286
380
 
287
381
  @media (prefers-color-scheme: dark) {
288
- .calendar-pane-prompt p {
289
- color: #ccc;
290
- }
291
-
292
- .calendar-connect-button {
293
- background: #1a1a1a;
294
- color: #22d0ef;
295
- border-color: #22d0ef;
296
- }
297
-
298
- .calendar-connect-button:hover {
299
- background: #2a2a2a;
300
- }
301
-
302
- .calendar-rsvp-chips {
303
- background: #1a1a1a;
304
- }
305
-
306
- .calendar-pane-footer {
307
- border-top-color: #333;
308
- background: #1a1a1a;
309
- }
310
-
311
- .calendar-rsvp-chip {
312
- color: #eee;
313
- border-color: #444;
314
- }
315
-
316
- .calendar-scroll-today-btn {
317
- border-right-color: #444;
318
- }
319
-
320
- .calendar-rsvp-chip.selected {
321
- background: #22d0ef;
322
- color: #081a59;
323
- border-color: #22d0ef;
324
- }
325
-
326
- .calendar-rsvp-chip-badge {
327
- background: #081a59;
328
- color: #e8eefb;
329
- }
330
-
331
- .calendar-day-header {
332
- background: #222;
333
- color: #eee;
334
- }
335
-
336
- .calendar-event-allday {
337
- border-bottom-color: #333;
338
- }
339
-
340
- .calendar-event-row {
341
- border-bottom-color: #333;
342
- }
343
-
344
- .calendar-event-allday {
345
- background: #2a2510;
346
- }
347
-
348
- .calendar-event-row--rsvp-accepted {
349
- --rsvp-bar: #4ade80;
350
- }
351
-
352
- .calendar-event-row--rsvp-tentative {
353
- --rsvp-bar: #fbbf24;
354
- }
355
-
356
- .calendar-event-row--rsvp-needsAction {
357
- --rsvp-bar: #60a5fa;
358
- }
359
-
360
- .calendar-event-row--rsvp-declined {
361
- --rsvp-bar: #f87171;
362
- }
363
-
364
- .calendar-event-title {
365
- color: #eee;
382
+ .calendar-pane[data-calendar-theme='system'],
383
+ .calendar-selector[data-calendar-theme='system'] {
384
+ --calendar-scheme: dark;
385
+ --calendar-surface: #1a1a1a;
386
+ --calendar-text: #eee;
387
+ --calendar-border: #333;
388
+ --calendar-prompt: #ccc;
389
+ --calendar-accent: #22d0ef;
390
+ --calendar-hover: #2a2a2a;
391
+ --calendar-chip-text: #eee;
392
+ --calendar-chip-border: #444;
393
+ --calendar-chip-selected: #22d0ef;
394
+ --calendar-chip-selected-text: #081a59;
395
+ --calendar-badge: #081a59;
396
+ --calendar-badge-text: #e8eefb;
397
+ --calendar-header: #222;
398
+ --calendar-allday: #2a2510;
399
+ --calendar-highlight: #10262b;
400
+ --calendar-insert: #999;
401
+ --calendar-insert-hover: #eee;
402
+ --calendar-insert-background: rgba(255, 255, 255, 0.1);
403
+ --calendar-focus: #22d0ef;
404
+ --calendar-rsvp-accepted: #4ade80;
405
+ --calendar-rsvp-tentative: #fbbf24;
406
+ --calendar-rsvp-needsAction: #60a5fa;
407
+ --calendar-rsvp-declined: #f87171;
408
+ --calendar-selector-border: #333;
409
+ --calendar-selector-text: #eee;
410
+ --calendar-selector-shadow: 0 4px 14px rgb(0 0 0 / 50%);
411
+ --calendar-control-border: #444;
412
+ --calendar-control-background: #2a2a2a;
413
+ --calendar-input-background: #2a2a2a;
414
+ --calendar-control-text: #eee;
415
+ --calendar-control-width: 1px;
416
+ --calendar-button-border-style: solid;
417
+ --calendar-input-border-style: solid;
418
+ --calendar-option-hover: #2a2a2a;
419
+ --calendar-option-active: #10262b;
366
420
  }
421
+ }
367
422
 
368
- .calendar-event-join {
369
- color: #22d0ef;
370
- border-color: #22d0ef;
371
- }
423
+ .calendar-selector {
424
+ position: relative;
425
+ }
372
426
 
373
- .calendar-event-join:hover {
374
- background: #2a2a2a;
375
- }
427
+ .calendar-selector-trigger {
428
+ display: inline-flex;
429
+ box-sizing: border-box;
430
+ align-items: center;
431
+ justify-content: center;
432
+ vertical-align: top;
433
+ gap: 8px;
434
+ max-width: 100%;
435
+ min-height: 36px;
436
+ padding: 8px 12px;
437
+ border: 1px solid var(--calendar-control-border);
438
+ border-radius: 6px;
439
+ background: var(--calendar-surface);
440
+ color: var(--calendar-text);
441
+ font: inherit;
442
+ cursor: pointer;
443
+ }
376
444
 
377
- .calendar-event-row--current,
378
- .calendar-event-row--selected {
379
- background: #10262b;
380
- }
445
+ .calendar-selector-trigger:hover {
446
+ background: var(--calendar-hover);
447
+ }
381
448
 
382
- .calendar-event-insert-link {
383
- color: #999;
384
- }
449
+ .calendar-selector-trigger:focus {
450
+ outline: none;
451
+ }
385
452
 
386
- .calendar-event-insert-link:hover {
387
- color: #eee;
388
- background: rgba(255, 255, 255, 0.1);
389
- }
453
+ .calendar-selector-trigger:focus-visible {
454
+ outline: 2px solid var(--calendar-focus);
455
+ outline-offset: 2px;
456
+ }
390
457
 
391
- .calendar-event-insert-link:focus-visible {
392
- outline-color: #22d0ef;
393
- color: #eee;
394
- }
458
+ .calendar-selector-trigger-label {
459
+ min-width: 0;
460
+ overflow: hidden;
461
+ text-overflow: ellipsis;
462
+ white-space: nowrap;
395
463
  }
396
464
 
397
- .calendar-selector {
398
- position: relative;
465
+ .calendar-selector-chevron {
466
+ flex: none;
467
+ width: 12px;
468
+ height: 12px;
399
469
  }
400
470
 
401
471
  .calendar-selector-dropdown {
@@ -405,11 +475,11 @@
405
475
  min-width: 220px;
406
476
  max-width: min(320px, calc(100vw - 32px));
407
477
  padding: 8px;
408
- border: 1px solid #ccc;
478
+ border: 1px solid var(--calendar-selector-border);
409
479
  border-radius: 6px;
410
- background: #fff;
411
- color: #1a1a1a;
412
- box-shadow: 0 4px 14px rgb(0 0 0 / 15%);
480
+ background: var(--calendar-surface);
481
+ color: var(--calendar-selector-text);
482
+ box-shadow: var(--calendar-selector-shadow);
413
483
  }
414
484
 
415
485
  .calendar-selector-dropdown--upward {
@@ -420,13 +490,22 @@
420
490
  .calendar-selector-dropdown input[type='search'] {
421
491
  box-sizing: border-box;
422
492
  width: 100%;
493
+ border: var(--calendar-control-width) var(--calendar-input-border-style) var(--calendar-control-border);
494
+ background: var(--calendar-input-background);
495
+ color: var(--calendar-control-text);
496
+ }
497
+
498
+ .calendar-selector-dropdown button {
499
+ border: var(--calendar-control-width) var(--calendar-button-border-style) var(--calendar-control-border);
500
+ background: var(--calendar-control-background);
501
+ color: var(--calendar-control-text);
423
502
  }
424
503
 
425
504
  .calendar-selector-checkbox {
426
505
  flex: none;
427
506
  margin: 0;
428
507
  pointer-events: none;
429
- accent-color: #22a6c0;
508
+ accent-color: var(--calendar-checkbox);
430
509
  }
431
510
 
432
511
  .calendar-selector-actions {
@@ -450,11 +529,16 @@
450
529
  padding: 6px;
451
530
  border: 0;
452
531
  background: transparent;
532
+ color: var(--calendar-control-text);
453
533
  text-align: left;
454
534
  }
455
535
 
536
+ .calendar-selector-dropdown [role='option']:hover {
537
+ background: var(--calendar-option-hover);
538
+ }
539
+
456
540
  .calendar-selector-dropdown [role='option'][data-active='true'] {
457
- background: #e8f5f8;
541
+ background: var(--calendar-option-active);
458
542
  }
459
543
 
460
544
  .calendar-selector-dot {
@@ -471,41 +555,6 @@
471
555
  white-space: nowrap;
472
556
  }
473
557
 
474
- @media (prefers-color-scheme: dark) {
475
- .calendar-selector-dropdown {
476
- border-color: #333;
477
- background: #1a1a1a;
478
- color: #eee;
479
- box-shadow: 0 4px 14px rgb(0 0 0 / 50%);
480
- }
481
-
482
- .calendar-selector-dropdown input[type='search'] {
483
- border: 1px solid #444;
484
- background: #2a2a2a;
485
- color: #eee;
486
- }
487
-
488
- .calendar-selector-dropdown button {
489
- border: 1px solid #444;
490
- background: #2a2a2a;
491
- color: #eee;
492
- }
493
-
494
- .calendar-selector-dropdown [role='option'] {
495
- border: 0;
496
- background: transparent;
497
- color: #eee;
498
- }
499
-
500
- .calendar-selector-dropdown [role='option']:hover {
501
- background: #2a2a2a;
502
- }
503
-
504
- .calendar-selector-dropdown [role='option'][data-active='true'] {
505
- background: #10262b;
506
- }
507
- }
508
-
509
558
  .calendar-event-source-dot {
510
559
  display: inline-block;
511
560
  width: 8px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/calendar",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,8 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "build": "tsc && cp src/react/styles.css dist/styles.css",
27
- "test": "vitest run"
27
+ "test": "vitest run",
28
+ "test:theme-browser": "vitest run --config vitest.browser.config.ts"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "react": "^19",
@@ -37,8 +38,10 @@
37
38
  "@testing-library/user-event": "^14.6.8",
38
39
  "@types/react": "^19.0.0",
39
40
  "@types/react-dom": "^19.0.0",
41
+ "@vitest/browser": "2.1.9",
40
42
  "fake-indexeddb": "^6.0.0",
41
43
  "jsdom": "^30.0.1",
44
+ "playwright": "1.51.1",
42
45
  "react": "^19.0.0",
43
46
  "react-dom": "^19.0.0",
44
47
  "typescript": "^5.6.0",