From cf87aef6ccae8b020dc02111b3dd80fe79d5e636 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:29:58 +1000 Subject: [PATCH 1/2] fix: type readOnly and preserve boolean attribute semantics --- README.md | 8 +++++++ src/dom-types.ts | 2 ++ src/index.tsx | 5 ++++ test/fixtures/readonly.mjs | 49 ++++++++++++++++++++++++++++++++++++++ test/readonly.mjs | 17 +++++++++++++ test/smoke.mjs | 8 +++++++ test/types.tsx | 8 +++++++ 7 files changed, 97 insertions(+) create mode 100644 test/fixtures/readonly.mjs create mode 100644 test/readonly.mjs diff --git a/README.md b/README.md index c5a8950..589ebee 100644 --- a/README.md +++ b/README.md @@ -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('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. diff --git a/src/dom-types.ts b/src/dom-types.ts index 66e8f2f..137aada 100644 --- a/src/dom-types.ts +++ b/src/dom-types.ts @@ -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, options?: EventsSetOptions): EventsSetResult | null; diff --git a/src/index.tsx b/src/index.tsx index 9772017..c54afb0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -45,6 +45,7 @@ declare global { 'week-starts-on'?: number | string; height?: string; theme?: string; + readonly?: string; ref?: React.Ref; }; } @@ -96,6 +97,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; @@ -263,6 +266,7 @@ export const ForceCalendar = forwardRef weekStartsOn, height, theme, + readOnly, className, style, events, @@ -383,6 +387,7 @@ export const ForceCalendar = forwardRef week-starts-on={weekStartsOn} height={height} theme={theme} + readonly={readOnly ? '' : undefined} /> ); }); diff --git a/test/fixtures/readonly.mjs b/test/fixtures/readonly.mjs new file mode 100644 index 0000000..ced22ce --- /dev/null +++ b/test/fixtures/readonly.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +const dom = new JSDOM('
', { + 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(); +} diff --git a/test/readonly.mjs b/test/readonly.mjs new file mode 100644 index 0000000..b1b7bf6 --- /dev/null +++ b/test/readonly.mjs @@ -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}`); + }); + } +} diff --git a/test/smoke.mjs b/test/smoke.mjs index 5b32cc1..ed24dd2 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -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); + }); +} diff --git a/test/types.tsx b/test/types.tsx index 188018a..6022f2e 100644 --- a/test/types.tsx +++ b/test/types.tsx @@ -25,6 +25,7 @@ export function Accepted() { date={new Date()} weekStartsOn={1} theme="slds" + readOnly={false} id="cal" role="application" aria-label="Team calendar" @@ -83,3 +84,10 @@ export function StructuralInputs(handle: ForceCalendarHandle, element: ForceCale element.updateEvent('plain', { start: '2026-10-03', owner: 'another team' }); return document.querySelector('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 = ; From a50d25e42eb07ae320710dfee8c512e0a266bff0 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:34:43 +1000 Subject: [PATCH 2/2] fix: map className to class for React 18 custom elements --- src/index.tsx | 3 ++- test/dom.mjs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index c54afb0..18413f1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,6 +46,7 @@ declare global { height?: string; theme?: string; readonly?: string; + class?: string; ref?: React.Ref; }; } @@ -378,7 +379,7 @@ export const ForceCalendar = forwardRef { + 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); +});