@open-webapp/calendar 0.4.0 → 0.5.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
@@ -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 |
@@ -180,13 +182,34 @@ One footer combobox opens a shared searchable dropdown, with calendars grouped u
180
182
 
181
183
  ### CalendarSelector props
182
184
 
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".
185
+ `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".
186
+
187
+ ```tsx
188
+ import { CalendarSelector } from '@open-webapp/calendar';
189
+ import '@open-webapp/calendar/styles.css';
190
+
191
+ <CalendarSelector
192
+ calendars={calendars}
193
+ selectedIds={selectedIds}
194
+ onChange={setSelectedIds}
195
+ heading="me@example.com"
196
+ theme="dark"
197
+ />;
198
+ ```
199
+
200
+ ### Theme behavior
201
+
202
+ 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.
203
+
204
+ Each pane forwards its requested mode to its unified grouped footer selector, including both account groups, account-local actions and the shared search. The existing single-trigger layout and cross-account keyboard navigation are preserved. 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.
205
+
206
+ 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
207
 
185
208
  The exported standalone selector keeps its single-list props and behavior; Select all adds only search matches while retaining existing selections.
186
209
 
187
210
  ### Host CSS
188
211
 
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:
212
+ `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
213
 
191
214
  - `--calendar-footer-height` (default `64px`, set on `.calendar-pane`; override to match host footer)
192
215
  - `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
@@ -194,6 +217,11 @@ The exported standalone selector keeps its single-list props and behavior; Selec
194
217
 
195
218
  The Unaccepted count badge uses `.calendar-rsvp-chip-badge`, with light and dark styling included in `styles.css`.
196
219
 
220
+ ## 0.5.0 (minor, additive)
221
+
222
+ - Adds optional `theme?: 'system' | 'light' | 'dark'` to both React components, with live OS tracking by default and live per-instance overrides.
223
+ - Themes all built-in pane states and internal/standalone selectors while preserving existing palettes, layout, copy and interaction contracts. Existing callers need no migration.
224
+
197
225
  ## 0.4.0 (BREAKING pre-1.0 minor)
198
226
 
199
227
  - 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
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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,7 +90,7 @@ 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: [_jsx("button", { 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);
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,130 +319,104 @@
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;
366
- }
367
-
368
- .calendar-event-join {
369
- color: #22d0ef;
370
- border-color: #22d0ef;
371
- }
372
-
373
- .calendar-event-join:hover {
374
- background: #2a2a2a;
375
- }
376
-
377
- .calendar-event-row--current,
378
- .calendar-event-row--selected {
379
- background: #10262b;
380
- }
381
-
382
- .calendar-event-insert-link {
383
- color: #999;
384
- }
385
-
386
- .calendar-event-insert-link:hover {
387
- color: #eee;
388
- background: rgba(255, 255, 255, 0.1);
389
- }
390
-
391
- .calendar-event-insert-link:focus-visible {
392
- outline-color: #22d0ef;
393
- 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;
394
420
  }
395
421
  }
396
422
 
@@ -398,6 +424,12 @@
398
424
  position: relative;
399
425
  }
400
426
 
427
+ .calendar-selector > [role='combobox'] {
428
+ color: var(--calendar-control-text);
429
+ background: var(--calendar-control-background);
430
+ border-color: var(--calendar-control-border);
431
+ }
432
+
401
433
  .calendar-selector-dropdown {
402
434
  position: absolute;
403
435
  z-index: 3;
@@ -405,11 +437,11 @@
405
437
  min-width: 220px;
406
438
  max-width: min(320px, calc(100vw - 32px));
407
439
  padding: 8px;
408
- border: 1px solid #ccc;
440
+ border: 1px solid var(--calendar-selector-border);
409
441
  border-radius: 6px;
410
- background: #fff;
411
- color: #1a1a1a;
412
- box-shadow: 0 4px 14px rgb(0 0 0 / 15%);
442
+ background: var(--calendar-surface);
443
+ color: var(--calendar-selector-text);
444
+ box-shadow: var(--calendar-selector-shadow);
413
445
  }
414
446
 
415
447
  .calendar-selector-dropdown--upward {
@@ -420,13 +452,22 @@
420
452
  .calendar-selector-dropdown input[type='search'] {
421
453
  box-sizing: border-box;
422
454
  width: 100%;
455
+ border: var(--calendar-control-width) var(--calendar-input-border-style) var(--calendar-control-border);
456
+ background: var(--calendar-input-background);
457
+ color: var(--calendar-control-text);
458
+ }
459
+
460
+ .calendar-selector-dropdown button {
461
+ border: var(--calendar-control-width) var(--calendar-button-border-style) var(--calendar-control-border);
462
+ background: var(--calendar-control-background);
463
+ color: var(--calendar-control-text);
423
464
  }
424
465
 
425
466
  .calendar-selector-checkbox {
426
467
  flex: none;
427
468
  margin: 0;
428
469
  pointer-events: none;
429
- accent-color: #22a6c0;
470
+ accent-color: var(--calendar-checkbox);
430
471
  }
431
472
 
432
473
  .calendar-selector-actions {
@@ -450,11 +491,16 @@
450
491
  padding: 6px;
451
492
  border: 0;
452
493
  background: transparent;
494
+ color: var(--calendar-control-text);
453
495
  text-align: left;
454
496
  }
455
497
 
498
+ .calendar-selector-dropdown [role='option']:hover {
499
+ background: var(--calendar-option-hover);
500
+ }
501
+
456
502
  .calendar-selector-dropdown [role='option'][data-active='true'] {
457
- background: #e8f5f8;
503
+ background: var(--calendar-option-active);
458
504
  }
459
505
 
460
506
  .calendar-selector-dot {
@@ -471,41 +517,6 @@
471
517
  white-space: nowrap;
472
518
  }
473
519
 
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
520
  .calendar-event-source-dot {
510
521
  display: inline-block;
511
522
  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.0",
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",