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
5 changes: 4 additions & 1 deletion docs/api-reference/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ interface CalendarHand {
undo(): void;
redo(): void;
copy(): CalendarClipboard | null;
cut(): EditingResult<CalendarSelection> | null;
cut(clipboard?: CalendarClipboard): EditingResult<CalendarSelection> | null;
paste(clipboard: CalendarClipboard): EditingResult<CalendarSelection>;
}
```
Expand All @@ -226,6 +226,7 @@ interface CalendarHand {
type CalendarHandOptions = {
readonly initialOccurrence?: CalendarOccurrenceRange;
readonly defaultTitle?: string;
readonly onResult?: (result: EditingResult<CalendarSelection>) => void;
};
```
## `CalendarKeyboardOptions`
Expand Down Expand Up @@ -348,6 +349,8 @@ type CalendarPeriod = "day" | CalendarGrain;

```ts
interface CalendarPointerInteractions {
/** Bind to one Calendar surface; canonical grids attach it automatically. */
readonly rootRef: RefObject<HTMLDivElement | null>;
readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null;
instantAt(day: string, clientY: number, grid: Element): string | null;
timePointerDown(event: PointerEvent<HTMLElement>, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void;
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ interface CalendarEditor {
): CalendarSelectionDragSource | null;
dispatch(intent: CalendarIntent): EditingResult<CalendarSelection>;
copy(occurrences?: ReadonlyArray<CalendarOccurrenceSelection>): CalendarClipboard | null;
cut(occurrences?: ReadonlyArray<CalendarOccurrenceSelection>): EditingClipboardCut<CalendarClipboard, EditingResult<CalendarSelection>> | null;
paste(clipboard: CalendarClipboard, target?: string): EditingResult<CalendarSelection>;
cut(source?: ReadonlyArray<CalendarOccurrenceSelection> | CalendarClipboard): EditingClipboardCut<CalendarClipboard, EditingResult<CalendarSelection>> | null;
paste(clipboard: CalendarClipboard, target?: string, options?: { readonly calendarId?: string }): EditingResult<CalendarSelection>;
undo(): EditingResult<CalendarSelection>;
redo(): EditingResult<CalendarSelection>;
subscribe(listener: (snapshot: EditingSnapshot<CalendarSelection>) => void): () => void;
Expand Down
3 changes: 2 additions & 1 deletion docs/evaluate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,12 @@ const misplacedMarkdown = filesUnder("").filter((path) => {
return path.endsWith(".md")
&& !path.startsWith("docs/")
&& !path.startsWith("standards/")
&& !rootPackage.workspaces.some((workspace) => workspace.startsWith("packages/") && path.startsWith(`${workspace}/docs/`))
&& name !== "README.md"
&& name !== "AGENTS.md";
});
if (misplacedMarkdown.length > 0) {
fail(`docs layout: non-README markdown must live under docs/: ${misplacedMarkdown.join(", ")}.`);
fail(`docs layout: non-README markdown must live under docs/, standards/, or a registered package's docs/: ${misplacedMarkdown.join(", ")}.`);
}

for (const [name, source] of Object.entries(surfaces)) {
Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

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

34 changes: 29 additions & 5 deletions packages/json-document-calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,43 @@ time-grid policy.
const editor = createCalendarEditor(document);
const calendar = useCalendarHand(editor);

calendar.dispatch({ type: "selection.set", eventIds: [eventId] });
calendar.dispatch({
type: "selection.set",
point: { eventId, occurrenceStart },
topology: calendarOccurrenceTopology(document, rangeStart, rangeEnd),
});
calendar.applySelectedPatch({ title: "Planning" });
const payload = calendar.copy();
calendar.paste(payload);
if (payload !== null) calendar.paste(payload);
const titleInput = useCalendarRenameInput(calendar);
useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRemove });
useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRename, onRemove });
```

The Hand resolves the currently focused occurrence as the copy/cut source and
paste target. The Host selects Web representations; Calendar schema,
The Hand resolves the currently focused occurrence as the copy source and
paste target. Bind `cut: calendar.cut` directly to the Web clipboard surface:
the Hand accepts the payload already written by Web and removes that captured
target even if selection changes during the write. The Host selects Web representations; Calendar schema,
occurrence projection, temporal placement, selection, and history remain in
their canonical owners.

Pass `onResult` to `useCalendarHand` to observe domain rejection codes and
present product-owned feedback. `commitIntent` runs occurrence/rename aftercare
only on success. Web clipboard decoding/writing failures remain Web results;
observe the surface's `onResult` as well. Unexpected provider/programmer errors
are not converted into a successful edit.

`useCalendarPointerInteractions` exposes `rootRef`. `CalendarTimeGrid` and
`CalendarMonthGrid` attach it automatically; a custom Calendar surface must
attach it to its own root. Use one interaction instance per mounted surface.
Hit tests and all-day column measurements never fall back to global document
queries. Keyboard listeners can also use the existing `target` option when a
Host embeds multiple active calendars.

The domain-owned [Calendar protocol profile](../json-document-editing/docs/calendar-profile.md)
defines temporal values, recurrence scopes, stale-source rejection and clipboard
compatibility. On the site it is visible under [Editing API](/docs/api/editing#calendar-protocol-profile-rc);
the Calendar Hand does not introduce a second domain protocol.

Date and time controls belong to this Calendar owner rather than the generic UI
Primitive package:

Expand Down
3 changes: 3 additions & 0 deletions packages/json-document-calendar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"typecheck": "tsc -p tsconfig.test.json --noEmit",
"verify": "npm run typecheck && npm test && npm run build"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5.1"
},
"peerDependencies": {
"@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1",
"@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const CalendarMonthGrid = forwardRef<CalendarMonthGridHandle, CalendarMon

return (
<div
ref={interactions.rootRef}
role="grid"
aria-label={props.labels.grid}
aria-multiselectable="true"
Expand Down
1 change: 1 addition & 0 deletions packages/json-document-calendar/src/calendar-time-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode {
const gridHeight = (props.hourEnd - props.hourStart) * props.pixelsPerHour;
return (
<div
ref={interactions.rootRef}
role="grid"
aria-multiselectable="true"
aria-label={props.labels.grid}
Expand Down
42 changes: 25 additions & 17 deletions packages/json-document-calendar/src/use-calendar-hand.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { createRenameSession, type RenameSessionSnapshot } from "@interactive-os/json-document-affordance";
import {
calendarClipboardFormat,
calendarOccurrenceAfterIntent,
calendarOccurrenceForInspector,
calendarOccurrenceFromSelection,
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface CalendarSelectionDragPreview {
export type CalendarHandOptions = {
readonly initialOccurrence?: CalendarOccurrenceRange;
readonly defaultTitle?: string;
readonly onResult?: (result: EditingResult<CalendarSelection>) => void;
};

export interface CalendarHand {
Expand Down Expand Up @@ -88,12 +90,14 @@ export interface CalendarHand {
undo(): void;
redo(): void;
copy(): CalendarClipboard | null;
cut(): EditingResult<CalendarSelection> | null;
cut(clipboard?: CalendarClipboard): EditingResult<CalendarSelection> | null;
paste(clipboard: CalendarClipboard): EditingResult<CalendarSelection>;
}

export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOptions = {}): CalendarHand {
const snapshot = useEditingSnapshot(editor);
const optionsRef = useRef(options);
optionsRef.current = options;
const selectedEvent = editor.selectedEvents[0] ?? null;
const selectedOccurrences = editor.selectedOccurrences;
const [occurrence, setOccurrence] = useState<CalendarOccurrenceRange>(
Expand All @@ -112,15 +116,13 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt
if (current === undefined) return;
const next = draft.trim() || (options.defaultTitle ?? "Event");
if (next !== current.title) {
editor.dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next }));
setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null));
if (dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next }))) rememberSelection();
}
},
onCancel(key, draft) {
const fallback = options.defaultTitle ?? "Event";
if (createdRenameKeyRef.current === key && (draft.trim() === "" || draft.trim() === fallback)) {
editor.dispatch({ type: "selection.remove" });
setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null));
if (dispatch({ type: "selection.remove" })) rememberSelection();
}
},
onFinish(key) {
Expand Down Expand Up @@ -156,11 +158,16 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt
: selectedEvent?.title ?? "";

function dispatch(intent: CalendarIntent | null): boolean {
return intent !== null && editor.dispatch(intent).ok;
return intent !== null && observe(editor.dispatch(intent)).ok;
}

function observe(result: EditingResult<CalendarSelection>): EditingResult<CalendarSelection> {
optionsRef.current.onResult?.(result);
return result;
}

function rememberSelection(): void {
setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null));
setOccurrence(calendarOccurrenceFromSelection(editor.primaryOccurrence));
}

function commitIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): boolean {
Expand All @@ -170,7 +177,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt
}

function rememberIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): void {
const committed = calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null);
const committed = calendarOccurrenceFromSelection(editor.primaryOccurrence);
setOccurrence(calendarOccurrenceAfterIntent(intent, origin, committed));
if (intent?.type === "event.create") {
setScope("this");
Expand Down Expand Up @@ -287,28 +294,29 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt

function undo(): void {
setSelectionDragPreview(null);
editor.undo();
rememberSelection();
if (observe(editor.undo()).ok) rememberSelection();
}

function redo(): void {
setSelectionDragPreview(null);
editor.redo();
rememberSelection();
if (observe(editor.redo()).ok) rememberSelection();
}

function copy(): CalendarClipboard | null {
return editor.copy();
}

function cut(): EditingResult<CalendarSelection> | null {
const cut = editor.cut();
if (cut?.result.ok) rememberSelection();
return cut?.result ?? null;
function cut(clipboard?: CalendarClipboard): EditingResult<CalendarSelection> | null {
if (clipboard !== undefined && calendarClipboardFormat.parse(clipboard) === null) return observe({ ok: false, code: "clipboard.invalid" });
const cut = editor.cut(clipboard);
if (cut === null) return null;
const result = observe(cut.result);
if (result.ok) rememberSelection();
return result;
}

function paste(clipboard: CalendarClipboard): EditingResult<CalendarSelection> {
const result = editor.paste(clipboard, occurrence.start ?? selectedEvent?.start);
const result = observe(editor.paste(clipboard, occurrence.start ?? selectedEvent?.start));
if (result.ok) rememberSelection();
return result;
}
Expand Down
Loading