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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,11 @@ 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)

### Read-only calendars

Pass the boolean `readOnly` prop to disable interactive editing (requires
`@forcecalendar/interface >= 1.8.0`). `false` or omission keeps editing enabled.
The adapter maps this to the `readonly` boolean attribute consistently during
server rendering, lazy element registration, and later prop changes.
Programmatic event methods remain available in read-only mode.
2 changes: 2 additions & 0 deletions src/dom-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface ForceCalendarEventMap {
}

export interface ForceCalendarElement extends HTMLElement {
/** Disable interactive editing (interface >= 1.8.0); programmatic methods remain available. */
readOnly: boolean;
/** Declarative form of `setEvents()` (reconciles with `removeMissing: true`). */
events: CalendarEvent[];
setEvents(events: Iterable<CalendarEvent>, options?: EventsSetOptions): EventsSetResult | null;
Expand Down
8 changes: 7 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ declare global {
'week-starts-on'?: number | string;
height?: string;
theme?: string;
readonly?: string;
class?: string;
ref?: React.Ref<ForceCalendarElement>;
};
}
Expand Down Expand Up @@ -96,6 +98,8 @@ export interface ForceCalendarProps
timezone?: string;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
height?: string;
/** Disable interactive editing (interface >= 1.8.0). Defaults to false. */
readOnly?: boolean;
/** Named theme preset (`"slds"`) or any theme name the element supports. */
theme?: 'slds' | (string & {});
className?: string;
Expand Down Expand Up @@ -263,6 +267,7 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>
weekStartsOn,
height,
theme,
readOnly,
className,
style,
events,
Expand Down Expand Up @@ -374,7 +379,7 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>
<forcecal-main
{...rest}
ref={elementRef}
className={className}
class={className}
style={style}
view={view}
date={dateAttr}
Expand All @@ -383,6 +388,7 @@ export const ForceCalendar = forwardRef<ForceCalendarHandle, ForceCalendarProps>
week-starts-on={weekStartsOn}
height={height}
theme={theme}
readonly={readOnly ? '' : undefined}
/>
);
});
Expand Down
11 changes: 11 additions & 0 deletions test/dom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,14 @@ test('unmounting while definition is pending never schedules a callback', async
Object.defineProperty(globalThis, 'customElements', { value: registry, configurable: true, writable: true });
}
});

test('className maps to class through updates and removal', async () => {
const { el, update } = await mount({ className: 'first' });
assert.equal(el.getAttribute('class'), 'first');
assert.equal(el.hasAttribute('classname'), false);
await update({ className: 'second active' });
assert.equal(el.getAttribute('class'), 'second active');
await update({});
assert.equal(el.hasAttribute('class'), false);
assert.equal(el.hasAttribute('classname'), false);
});
49 changes: 49 additions & 0 deletions test/fixtures/readonly.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body><div id="host"></div></body></html>', {
url: 'http://localhost/', pretendToBeVisual: true,
});
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'SVGElement',
'Element', 'Node', 'Text', 'Comment', 'DocumentFragment', 'Event', 'CustomEvent',
'customElements', 'MutationObserver', 'getComputedStyle', 'requestAnimationFrame',
'cancelAnimationFrame']) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true });
}
const warm = process.argv[2] === 'warm';
const initial = process.argv[3] === 'omitted' ? undefined : process.argv[3] === 'true';
if (warm) await import('@forcecalendar/interface');
const { ForceCalendar } = await import('../../dist/index.js');
assert.equal(Boolean(customElements.get('forcecal-main')), warm);
const host = document.getElementById('host');
function check(el, expected) {
assert.equal(el.hasAttribute('readonly'), Boolean(expected));
if (expected) assert.equal(el.getAttribute('readonly'), '');
// Legacy interfaces predate readOnly but must retain the boolean attribute contract.
if ('readOnly' in el) assert.equal(el.readOnly, Boolean(expected));
assert.equal(Object.hasOwn(el, 'readOnly'), false, 'no property shadowing before upgrade');
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { createElement, createRef, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { flushSync } = await import('react-dom');
const handle = createRef();
const root = createRoot(host);
let el;
try {
await act(() => {
flushSync(() => root.render(createElement(ForceCalendar, { ref: handle, readOnly: initial })));
el = host.querySelector('forcecal-main');
check(el, initial); // Explicitly before lazy import/upgrade resolves.
});
await act(() => handle.current.whenReady());
check(el, initial);
for (const value of [true, false, undefined, true, false]) {
await act(() => root.render(createElement(ForceCalendar, { ref: handle, readOnly: value })));
check(el, value);
}
} finally {
await act(() => root.unmount());
el?.stateManager?.calendar?.destroy();
el?.destroy?.();
dom.window.close();
}
17 changes: 17 additions & 0 deletions test/readonly.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const fixture = fileURLToPath(new URL('./fixtures/readonly.mjs', import.meta.url));
for (const load of ['cold', 'warm']) {
for (const value of ['true', 'false', 'omitted']) {
test(`readOnly ${value}: ${load} upgrade and reactive toggles`, () => {
const result = spawnSync(process.execPath, [fixture, load, value], {
encoding: 'utf8', timeout: 30_000,
});
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
});
}
}
8 changes: 8 additions & 0 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ test('a forwarded ref does not break server rendering', () => {
const html = renderToString(createElement(ForceCalendar, { ref }));
assert.match(html, /forcecal-main/);
});

for (const readOnly of [true, false, undefined]) {
test(`SSR readOnly=${readOnly} uses boolean attribute presence`, async () => {
const html = renderToString(createElement(ForceCalendar, { readOnly }));
if (readOnly) assert.match(html, /\sreadonly(?:="")?(?:\s|>)/i);
else assert.doesNotMatch(html, /\sreadonly(?:=|\s|>)/i);
});
}
8 changes: 8 additions & 0 deletions test/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function Accepted() {
date={new Date()}
weekStartsOn={1}
theme="slds"
readOnly={false}
id="cal"
role="application"
aria-label="Team calendar"
Expand Down Expand Up @@ -83,3 +84,10 @@ export function StructuralInputs(handle: ForceCalendarHandle, element: ForceCale
element.updateEvent('plain', { start: '2026-10-03', owner: 'another team' });
return document.querySelector<ForceCalendarElement>('forcecal-main');
}

export const readOnlyElement = (el: ForceCalendarElement): boolean => {
el.readOnly = true;
return el.readOnly;
};
// @ts-expect-error readOnly is a boolean, not an HTML attribute string
export const invalidReadOnly = <ForceCalendar readOnly="false" />;
Loading