Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm test
timeout-minutes: 5
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ export function Agenda() {

## Types

The package exports `ForceCalendarProps`, `ForceCalendarHandle`, `CalendarEvent`, `CalendarView`, `VisibleRange`, `EventsSetOptions`, `EventsSetResult`, `ForceCalendarElement`, `ForceCalendarEventMap` and one `*Detail` type per callback. It also declares `forcecal-main` in `JSX.IntrinsicElements` and `HTMLElementTagNameMap`, so the raw element is type-checked if you render or query it yourself.
The package exports `ForceCalendarProps`, `ForceCalendarHandle`, `CalendarEvent`, `CalendarView`, `VisibleRange`, `EventsSetOptions`, `EventsSetResult`, `ForceCalendarElement`, `ForceCalendarEventMap` and one `*Detail` type per callback. It also declares `forcecal-main` in `JSX.IntrinsicElements`, so the raw element is type-checked in JSX.

The global `HTMLElementTagNameMap` mapping belongs to `@forcecalendar/interface` 1.7 and newer. Import its element type when using the raw DOM API. The adapter keeps its exported structural types, including plain `CalendarEvent` inputs, without redeclaring the global mapping:

```ts
import type { ForceCalendarElement } from '@forcecalendar/interface';

const element: ForceCalendarElement = document.createElement('forcecal-main');
```

With interface 1.6, use the adapter's exported type explicitly for direct DOM access: `document.querySelector<ForceCalendarElement>('forcecal-main')`. The React component and its ref keep the same types across supported interface versions.

Docs: [docs.forcecalendar.org](https://docs.forcecalendar.org) · License: [MIT](LICENSE)
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "npm run build && tsc -p tsconfig.test.json --noEmit && node --test \"test/*.mjs\""
"test": "npm run build && tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.test.nodenext.json --noEmit && node --test \"test/*.mjs\""
},
"peerDependencies": {
"@forcecalendar/core": ">=2.0.0 <3",
"@forcecalendar/interface": ">=1.6.0 <2",
"react": ">=18"
},
"devDependencies": {
"@forcecalendar/core": "^2.5.0",
"@forcecalendar/interface": "^1.6.0",
"@forcecalendar/core": "^2.5.5",
"@forcecalendar/interface": "^1.8.0",
"@types/react": "^19",
"jsdom": "^29.1.1",
"react": "^19",
Expand Down
11 changes: 3 additions & 8 deletions src/dom-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* (methods, `events` accessor and the `calendar-*` DOM events) so that the
* adapter and its consumers can talk to the element without `any`.
*
* Note: @forcecalendar/interface does not declare these itself yet; once it
* does, this file becomes a re-export.
* These structural types remain available for interface 1.6 and plain event
* inputs. Newer interfaces own the global DOM tag mapping; redeclaring it here
* would conflict with their canonical element type.
*/

export type CalendarView = 'month' | 'week' | 'day';
Expand Down Expand Up @@ -98,9 +99,3 @@ export interface ForceCalendarElement extends HTMLElement {
options?: boolean | EventListenerOptions
): void;
}

declare global {
interface HTMLElementTagNameMap {
'forcecal-main': ForceCalendarElement;
}
}
39 changes: 27 additions & 12 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
ForceCalendarElement,
ForceCalendarEventMap,
VisibleRange,
} from './dom-types';
} from './dom-types.js';

export type {
CalendarEvent,
Expand All @@ -27,7 +27,7 @@ export type {
ForceCalendarElement,
ForceCalendarEventMap,
VisibleRange,
} from './dom-types';
} from './dom-types.js';

const TAG = 'forcecal-main';
const MIN_INTERFACE_VERSION = '1.6.0';
Expand Down Expand Up @@ -307,12 +307,22 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>

let rangeChangeSeen = false;
let cancelled = false;
let initialRangeTimer: ReturnType<typeof setTimeout> | undefined;
const cancelInitialRange = () => {
if (initialRangeTimer !== undefined) {
clearTimeout(initialRangeTimer);
initialRangeTimer = undefined;
}
};

// Attach synchronously, before the import: an element that upgrades while
// the interface is loading dispatches its first events right away.
const listeners = EVENT_MAP.map(([eventName, callbackName]) => {
const listener = (e: Event) => {
if (eventName === 'calendar-range-change') rangeChangeSeen = true;
if (eventName === 'calendar-range-change') {
rangeChangeSeen = true;
cancelInitialRange();
}
const callback = callbacks.current[callbackName] as ((detail: unknown) => void) | undefined;
callback?.((e as CustomEvent).detail ?? {});
};
Expand All @@ -322,15 +332,19 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>

whenDefined().then(
() => {
// The element's own first calendar-range-change fires during upgrade,
// which can happen before this effect when the interface was already
// loaded. Report the initial window so consumers can fetch data for it.
const onRangeChange = callbacks.current.onRangeChange;
if (cancelled || rangeChangeSeen || !onRangeChange) return;
const range = typeof el.getVisibleRange === 'function' ? el.getVisibleRange() : null;
if (!range) return;
rangeChangeSeen = true;
onRangeChange({ ...range, ...readViewState(el, range) });
if (cancelled || rangeChangeSeen) return;
// Interface 1.7 defers its initial announcement until the next task.
// Give that event priority before seeding a range for older interfaces,
// or for an announcement that happened before our listeners attached.
initialRangeTimer = setTimeout(() => {
initialRangeTimer = undefined;
const onRangeChange = callbacks.current.onRangeChange;
if (cancelled || rangeChangeSeen || !onRangeChange) return;
const range = typeof el.getVisibleRange === 'function' ? el.getVisibleRange() : null;
if (!range) return;
rangeChangeSeen = true;
onRangeChange({ ...range, ...readViewState(el, range) });
}, 0);
},
error => {
if (!cancelled) reportLoadFailure(error);
Expand All @@ -339,6 +353,7 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>

return () => {
cancelled = true;
cancelInitialRange();
for (const [eventName, listener] of listeners) {
el.removeEventListener(eventName, listener);
}
Expand Down
100 changes: 94 additions & 6 deletions test/dom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ for (const key of [
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

// react-dom must see the DOM globals when it is first evaluated.
const { createElement, act, createRef } = await import('react');
const { createElement, act, createRef, StrictMode } = await import('react');
const { createRoot } = await import('react-dom/client');
const { ForceCalendar } = await import('../dist/index.js');

Expand All @@ -53,6 +53,7 @@ const MAPPED_EVENTS = [
class StubCalendar extends dom.window.HTMLElement {
/** When set, the stub announces its range on connect like the real element does on first render. */
static announceRangeOnConnect = false;
static deferRangeOnConnect = false;

constructor() {
super();
Expand All @@ -66,13 +67,15 @@ class StubCalendar extends dom.window.HTMLElement {
connectedCallback() {
if (StubCalendar.announceRangeOnConnect) {
const { view, currentDate } = this.stateManager.getState();
this.dispatchEvent(
const announce = () => this.dispatchEvent(
new dom.window.CustomEvent('calendar-range-change', {
detail: { ...this.getVisibleRange(), view, date: currentDate, announcedBy: 'element' },
bubbles: true,
composed: true,
})
);
if (StubCalendar.deferRangeOnConnect) setTimeout(announce, 0);
else announce();
}
}
addEventListener(type, listener, options) {
Expand Down Expand Up @@ -132,8 +135,11 @@ class StubCalendar extends dom.window.HTMLElement {
}
}

before(() => {
before(async () => {
dom.window.customElements.define('forcecal-main', StubCalendar);
// Make timing tests deterministic: loading the interface is no longer racing
// the stub's announcement. The stub still owns the registered element.
await import('@forcecalendar/interface');
});

const mounted = [];
Expand All @@ -145,14 +151,18 @@ afterEach(async () => {
}
});

async function mount(props) {
async function mount(props, { strict = false } = {}) {
const container = dom.window.document.createElement('div');
dom.window.document.body.append(container);
const root = createRoot(container);
mounted.push({ root, container });
await act(() => root.render(createElement(ForceCalendar, props)));
const render = nextProps => {
const calendar = createElement(ForceCalendar, nextProps);
return strict ? createElement(StrictMode, null, calendar) : calendar;
};
await act(() => root.render(render(props)));
const el = container.querySelector('forcecal-main');
const update = nextProps => act(() => root.render(createElement(ForceCalendar, nextProps)));
const update = nextProps => act(() => root.render(render(nextProps)));
const unmount = async () => {
await act(() => root.unmount());
mounted.splice(mounted.findIndex(m => m.root === root), 1);
Expand Down Expand Up @@ -361,3 +371,81 @@ test('does not call onRangeChange at all when it is not provided', async () => {
await settle();
assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false);
});

for (const strict of [false, true]) {
test(`deferred element announcement wins over readiness fallback${strict ? ' in StrictMode' : ''}`, async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
StubCalendar.announceRangeOnConnect = true;
StubCalendar.deferRangeOnConnect = true;
try {
const ranges = [];
const { el } = await mount({ onRangeChange: detail => ranges.push(detail) }, { strict });
assert.equal(ranges.length, 0, 'readiness must not synthesize before deferred DOM events');
await act(() => t.mock.timers.runAll());
assert.equal(ranges.length, 1);
assert.equal(ranges[0].announcedBy, 'element', 'the real element event takes priority');
assert.equal(el.calls.filter(c => c[0] === 'getVisibleRange').length, 1, 'no synthetic range read');
for (const [type] of MAPPED_EVENTS) assert.equal(el.listenerCounts.get(type), 1);

const repeated = { ...ranges[0] };
dispatch(el, 'calendar-range-change', repeated);
dispatch(el, 'calendar-range-change', repeated);
assert.equal(ranges.length, 3, 'later equal range events are not suppressed');
assert.equal(ranges[1], repeated);
assert.equal(ranges[2], repeated);
} finally {
StubCalendar.announceRangeOnConnect = false;
StubCalendar.deferRangeOnConnect = false;
}
});
}

test('StrictMode replays effects without duplicating the legacy readiness fallback', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const ranges = [];
await mount({ onRangeChange: detail => ranges.push(detail) }, { strict: true });
assert.equal(ranges.length, 0);
await act(() => t.mock.timers.runAll());
assert.equal(ranges.length, 1);
});

test('cancels a pending fallback on unmount', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const ranges = [];
const { el, unmount } = await mount({ onRangeChange: detail => ranges.push(detail) });
assert.equal(ranges.length, 0);
await unmount();
await act(() => t.mock.timers.runAll());
assert.equal(ranges.length, 0);
assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false);
});

test('pending fallback reads the latest callback after a prop update', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const seen = [];
const { update } = await mount({ onRangeChange: () => seen.push('first') });
await update({ onRangeChange: () => seen.push('second') });
await act(() => t.mock.timers.runAll());
assert.deepEqual(seen, ['second']);
});

test('unmounting while definition is pending never schedules a callback', async t => {
const registry = dom.window.customElements;
let release;
const defined = new Promise(resolve => (release = resolve));
Object.defineProperty(globalThis, 'customElements', {
value: { whenDefined: () => defined }, configurable: true, writable: true,
});
try {
const ranges = [];
const { el, unmount } = await mount({ onRangeChange: detail => ranges.push(detail) });
await unmount();
t.mock.timers.enable({ apis: ['setTimeout'] });
await act(async () => { release(); await defined; });
await act(() => t.mock.timers.runAll());
assert.deepEqual(ranges, []);
assert.equal(el.calls.some(c => c[0] === 'getVisibleRange'), false);
} finally {
Object.defineProperty(globalThis, 'customElements', { value: registry, configurable: true, writable: true });
}
});
Loading
Loading