Scheduler built across morfo/soma/sema/eidos, composing framework components (DragDrop/Dialog/Field/Select/ToggleGroup/pickers). Dates via $libs/days. - Views: month grid (overlap layout + per-cell "+N more" cap), week/day time-grid, agenda. Week-spanning events: arrow tail + notched head. - Drag-drop move (month cells + week/day hour slots); drag preview = styled chip sized to the full event span. - Month "+N more" -> resizable day-peek popover (day-view format). - Day cells: hover (+) to create (no click-to-create). - Event dialog with view (readonly) / edit modes + permission getters; 3-mode time model (all-day/single/split bands) + draft validation. - Customizable editor + tooltip via snippets (ChronosEditorContext). Hand-off + pending: src/uix/eidos/components/chronos/CONTINUE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>active-uix
parent
cb1d02a479
commit
4c5e33a098
@ -0,0 +1,121 @@
|
||||
# CONTINUE — Chronos (hand-off 2026-06-27 → next session)
|
||||
|
||||
Branch `active-uix`. Chronos is the event-calendar (scheduler) component built
|
||||
across the 4 layers. This is its **first commit** (everything was untracked).
|
||||
Resume from the **immediate next task** below.
|
||||
|
||||
Reading order: this file → `SPEC.md` (scope/roadmap) → `README.md` →
|
||||
`src/uix/soma/components/chronos/` (engine + provider).
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
src/uix/morfo/components/chronos.ts contract (parts, events)
|
||||
src/uix/sema/components/chronos.ts perception pack
|
||||
src/uix/soma/components/chronos/
|
||||
chronos-provider.svelte.ts state + editing session + permissions
|
||||
types.ts ChronosEvent / Editing / EditorContext / Tooltip
|
||||
engine/ pure: state.svelte.ts, layout.ts, time-layout.ts, …
|
||||
src/uix/eidos/components/chronos/
|
||||
chronos.svelte / chronos-view.svelte wrapper + month/week/day/agenda renderer + dialog
|
||||
chronos-when-editor.svelte the "when" controls (mode + dates + times/bands)
|
||||
chronos-day-peek.svelte month "+N more" → day-view popover (resizable)
|
||||
chronos.css recipe + all chrome
|
||||
web/routes/uix/components/chronos/+page.svelte testbed demo
|
||||
```
|
||||
|
||||
## Hard rules (from the user — still active)
|
||||
|
||||
- Only modify chronos-own files + the 3 registration files he lifted:
|
||||
`eidos/lib/recipes/base.ts`, `sema/components/index.ts`, `eidos/index.css`.
|
||||
- Never stage/commit/edit `palabras/**` or `words/**`. Stage explicit chronos paths.
|
||||
- Event colors come from the consumer `CalendarSchema` (hex/CSS + `predefinedColors`),
|
||||
NEVER the intent channel. Canonical "Other" type id = `otros`.
|
||||
- Compose framework components (DragDrop/Dialog/Field/Select/ToggleGroup/pickers/
|
||||
Button/IconButton), never reinvent. All values from tokens — no magic numbers/raw colors.
|
||||
- Castilian replies. Verify in the browser with measurements, not by eye.
|
||||
|
||||
## IMMEDIATE NEXT TASK — native `readonly` for Select + ToggleGroup
|
||||
|
||||
The event dialog has a **view (readonly) mode** and an **edit mode** (Pencil
|
||||
toggle in the header). View mode should render every control readonly (legible,
|
||||
non-editable). Of the dialog's controls:
|
||||
|
||||
| Control | native `readonly`? |
|
||||
| --- | --- |
|
||||
| Field.Input, DateRangePicker, TimeRangePicker | ✅ yes — uses `readonly={!editable}` |
|
||||
| **Select** (Type), **ToggleGroup** (mode) | ❌ **no** — only `disabled` |
|
||||
|
||||
For the two without `readonly` I shipped a **workaround** in `chronos.css`
|
||||
(scoped to `.chronos-editor-fields[data-view]` / `.chronos-when[data-view]`):
|
||||
keep `disabled` (AT-correct) but strip the dimming (`opacity:1` + restore the
|
||||
Select trigger's normal `--select-trigger-bg/border/color`).
|
||||
|
||||
**Do it properly:** add a canonical `readonly` prop to **Select** and
|
||||
**ToggleGroup** (morfo → soma → eidos), same shape as Field/pickers. Then:
|
||||
1. Switch chronos to `readonly={!editable}` on both.
|
||||
2. Delete the workaround CSS blocks in `chronos.css` (the `[data-view]` un-gray
|
||||
rules for select + toggle-group).
|
||||
3. Keep the date/time **segment flatten** rule (`[data-…-field-segment][data-readonly]`
|
||||
→ `background: transparent; text-decoration: none`) — that's a deliberate
|
||||
chronos choice (the field's readonly affordance paints a per-segment bg + dotted
|
||||
underline; too noisy in an all-readonly dialog).
|
||||
|
||||
⚠️ This touches `select` + `toggle-group` (NOT in the allowed file list) — the
|
||||
user must OK that scope first (he flagged it; answer pending).
|
||||
|
||||
## Done this session (in this commit)
|
||||
|
||||
- **Month "+N more" → day-peek popover** (`chronos-day-peek.svelte`): clicking the
|
||||
overflow link opens a Popover showing the day in **day-view format** (all-day
|
||||
row + windowed hour grid). `dayTimeLayout(day)` computes per-day layout on demand
|
||||
(`timeLayoutFor` is empty in month view — keyed by `visibleDays`).
|
||||
- **Day-peek polish**: "+N more" is `<Button variant="plain">`; **resizable width**
|
||||
via a visible right-edge handle (`.chronos-peek-resize`, sets inline width; scroll
|
||||
moved to `.chronos-peek-scroll` so the handle stays pinned); z-index pinned just
|
||||
below the dialog overlay; header = day number + weekday/month-year; all-day padding.
|
||||
- **Day cells**: removed click-to-create; a **(+) button appears on hover** (top-right
|
||||
of each cell) → `openCreate`.
|
||||
- **Event dialog view/edit modes**: `ChronosEditing.editable` + provider getters
|
||||
`canEditDraft` / `draftEditable` / `showEditAction` + `enableEditing()`. Existing
|
||||
events open in view (Pencil action, footer = Close); create opens editable.
|
||||
`openEdit` now ALWAYS opens (in view) even when readonly/non-editable. Pencil is
|
||||
absolutely positioned at `Dialog.Content` top-right, inset `--dialog-content-padding-md`
|
||||
(same as the footer buttons).
|
||||
- **Editor validation** (`draftError`) blocks Save + shows an inline alert.
|
||||
- **Week-spanning events**: arrow tail + notched head via clip-paths; per-cell "+N more"
|
||||
lane cap; single-day right padding; drag preview = styled chip sized to the full span.
|
||||
|
||||
## Other pending (not requested yet — confirm before starting)
|
||||
|
||||
- **Tooltip on event bands** (hover → time/description/type; consumer-customizable
|
||||
via `eventTooltip` snippet + `ChronosTooltipContext`). Requested earlier, interrupted,
|
||||
never built. Types already declared.
|
||||
- **Zero-hardcodes / langs**: `components.chronos.label` missing (uses "Calendar"
|
||||
fallback); aria-labels + UI strings ("New event", "Edit event", "Resize width",
|
||||
"Add band", weekday/month formatting in the peek) are hardcoded English — route
|
||||
through `langs`.
|
||||
- Event **resize handles** (drag edge to change duration); Week/Day **finer snap**
|
||||
(currently hour); keyboard roving nav; picker providers don't fire `close` events
|
||||
(inert). Year/Timeline/RRULE/virtualization = roadmap (`SPEC.md`).
|
||||
|
||||
## Gotchas (cost me time — don't relearn)
|
||||
|
||||
- `timeLayoutFor(day)` is **empty in Month view** (cached by `visibleDays`) → use
|
||||
`dayTimeLayout(day)`.
|
||||
- Composing `<Button>` as a `Popover.Trigger` inherits the popover **surface
|
||||
envelope** (`[data-popover-trigger]:not([data-archetype='field-trigger'])`, 0-2-0)
|
||||
— overrides the Button's `plain`. Fix = 0-3-0 chronos rule re-pointing to `--_button-*`.
|
||||
- Popover layer sits **above** the dialog by default (popover content-z 75 > dialog 70/71)
|
||||
→ pin the peek wrapper z below `--dialog-overlay-z`.
|
||||
- Native `resize` only exposes the **invisible bottom-right corner grip** → custom
|
||||
right-edge handle for discoverable horizontal resize.
|
||||
- Portaled pickers inside the Dialog lose `ActiveEidos` → bridge with
|
||||
`ActiveEidos.set(ActiveEidos.require())` in `chronos-when-editor.svelte`.
|
||||
- Readonly date/time **segments** ship a per-segment bg + dotted underline affordance.
|
||||
|
||||
## Verify at resume
|
||||
|
||||
`npm run check` is clean for chronos. Demo at `/uix/components/chronos`. The
|
||||
testbed has Size/Accent/week-start/max-per-cell/editable/readonly/custom-editor
|
||||
controls + a busy day (Jun 22) and a week-spanning Trip for arrows.
|
||||
@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Day-peek popover — opens from a month cell's "+N more" link and shows ALL of
|
||||
* that day's events in the day-view's format: an all-day row plus a vertical
|
||||
* hour grid with proportionally-positioned blocks. Clicking an event opens its
|
||||
* editor. The grid is WINDOWED to the hours that actually hold events, so it
|
||||
* stays compact inside the popover instead of spanning a full 24h column.
|
||||
*
|
||||
* Reuses the time-grid chip styling (`[data-chronos-event-chip][data-timed]`)
|
||||
* and the now-indicator (`.chronos-tg-now`); only the peek's own layout chrome
|
||||
* is bespoke. The eidos context is bridged across the portal boundary so the
|
||||
* portaled content resolves tokens / locale like the rest of the calendar.
|
||||
*/
|
||||
import type { DateValue } from '$libs/days';
|
||||
import { getLocalTimeZone, isToday } from '$libs/days';
|
||||
import { Popover } from '$uix/eidos/components/popover';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { ChronosProvider, type ChronosEvent } from '$soma/components/chronos';
|
||||
import { eventColorVars } from '$soma/components/chronos/engine/schema';
|
||||
import type { TimedBlock } from '$soma/components/chronos/engine/time-layout';
|
||||
|
||||
let {
|
||||
date,
|
||||
count,
|
||||
column,
|
||||
row
|
||||
}: { date: DateValue; count: number; column: number; row: number } = $props();
|
||||
|
||||
const provider = ChronosProvider.require();
|
||||
const tz = getLocalTimeZone();
|
||||
// Bridge the eidos context across the popover's portal boundary.
|
||||
ActiveEidos.set(ActiveEidos.require());
|
||||
|
||||
// Header: a prominent day number + the weekday and month/year stacked beside
|
||||
// it (the day-view's date identity, cleaned up).
|
||||
const weekdayLong = $derived(
|
||||
new Intl.DateTimeFormat(provider.effectiveLocale, { weekday: 'long' }).format(date.toDate(tz))
|
||||
);
|
||||
const monthYear = $derived(
|
||||
new Intl.DateTimeFormat(provider.effectiveLocale, { month: 'long', year: 'numeric' }).format(
|
||||
date.toDate(tz)
|
||||
)
|
||||
);
|
||||
const layout = $derived(provider.dayTimeLayout(date));
|
||||
const allDay = $derived(layout.allDay);
|
||||
const timed = $derived(layout.timed);
|
||||
|
||||
// Window = the whole hours that bracket every timed block (≥1h), so the grid
|
||||
// shows just the relevant slice instead of a full midnight-to-midnight column.
|
||||
const win = $derived.by(() => {
|
||||
if (timed.length === 0) return null;
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (const b of timed) {
|
||||
lo = Math.min(lo, b.startMin);
|
||||
hi = Math.max(hi, b.endMin);
|
||||
}
|
||||
const startMin = Math.floor(lo / 60) * 60;
|
||||
const endMin = Math.ceil(hi / 60) * 60;
|
||||
return { startMin, endMin, span: Math.max(60, endMin - startMin) };
|
||||
});
|
||||
const windowHours = $derived.by(() => {
|
||||
if (!win) return [];
|
||||
const out: number[] = [];
|
||||
for (let h = win.startMin / 60; h < win.endMin / 60; h++) out.push(h);
|
||||
return out;
|
||||
});
|
||||
|
||||
const showNow = $derived(
|
||||
!!win &&
|
||||
isToday(date, tz) &&
|
||||
provider.nowMinutes >= win.startMin &&
|
||||
provider.nowMinutes <= win.endMin
|
||||
);
|
||||
const nowTop = $derived(win ? ((provider.nowMinutes - win.startMin) / win.span) * 100 : 0);
|
||||
|
||||
const fmtMin = (min: number): string =>
|
||||
new Intl.DateTimeFormat(provider.effectiveLocale, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(2020, 0, 1, Math.floor(min / 60), min % 60));
|
||||
|
||||
// Absolute position within the windowed grid (percent of the window span).
|
||||
function blockStyle(b: TimedBlock): string {
|
||||
const w = win;
|
||||
if (!w) return '';
|
||||
const top = ((b.startMin - w.startMin) / w.span) * 100;
|
||||
const height = ((b.endMin - b.startMin) / w.span) * 100;
|
||||
const left = (b.col / b.cols) * 100;
|
||||
const width = (1 / b.cols) * 100;
|
||||
return `top:${top}%; height:${height}%; inset-inline-start:calc(${left}% + 1px); inline-size:calc(${width}% - 2px); ${eventColorVars(provider.resolveColor(b.event))}`;
|
||||
}
|
||||
|
||||
// Open the editor for the clicked event. The peek stays open — the editor
|
||||
// dialog floats above it (its own dismissal layer), so closing the dialog
|
||||
// leaves the peek where it was and the user can pick another event.
|
||||
function edit(event: ChronosEvent): void {
|
||||
provider.openEdit(event);
|
||||
}
|
||||
|
||||
// Horizontal resize via a visible right-edge handle (the native corner grip is
|
||||
// invisible + only the very corner works). Sets the panel's inline width
|
||||
// imperatively — like the native grip, so it beats the popover recipe's capped
|
||||
// inline-size; CSS min/max-inline-size still bound it.
|
||||
let resize: { panel: HTMLElement; startX: number; startW: number } | null = null;
|
||||
|
||||
function resizeStart(e: PointerEvent): void {
|
||||
const handle = e.currentTarget as HTMLElement;
|
||||
const panel = handle.closest('[data-chronos-peek-panel]') as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
resize = { panel, startX: e.clientX, startW: panel.getBoundingClientRect().width };
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
}
|
||||
function resizeMove(e: PointerEvent): void {
|
||||
if (!resize) return;
|
||||
resize.panel.style.width = `${resize.startW + (e.clientX - resize.startX)}px`;
|
||||
}
|
||||
function resizeEnd(e: PointerEvent): void {
|
||||
if (!resize) return;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
resize = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="plain"
|
||||
size="xs"
|
||||
data-chronos-more-link
|
||||
style="grid-column: {column}; grid-row: {row};"
|
||||
>
|
||||
{count} more…
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content size="lg" side="bottom" align="center" data-chronos-peek-panel>
|
||||
<div class="chronos-peek-scroll">
|
||||
<div class="chronos-peek" data-chronos-day-peek>
|
||||
<header class="chronos-peek-head">
|
||||
<span class="chronos-peek-daynum" data-today={isToday(date, tz) ? '' : undefined}>
|
||||
{date.day}
|
||||
</span>
|
||||
<span class="chronos-peek-headmeta">
|
||||
<span class="chronos-peek-weekday">{weekdayLong}</span>
|
||||
<span class="chronos-peek-fulldate">{monthYear}</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{#if allDay.length > 0}
|
||||
<div class="chronos-peek-allday">
|
||||
{#each allDay as ev (ev.id)}
|
||||
<button
|
||||
type="button"
|
||||
data-chronos-event-chip
|
||||
data-all-day
|
||||
style={eventColorVars(provider.resolveColor(ev))}
|
||||
aria-label={provider.eventAriaLabel(ev)}
|
||||
onclick={() => edit(ev)}
|
||||
>
|
||||
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if win}
|
||||
<div class="chronos-peek-body">
|
||||
<div class="chronos-peek-rail">
|
||||
{#each windowHours as h (h)}
|
||||
<div class="chronos-peek-hour"><span>{provider.hourLabels[h]}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="chronos-peek-col">
|
||||
{#each windowHours as h (h)}
|
||||
<div class="chronos-peek-slot"></div>
|
||||
{/each}
|
||||
{#each timed as b (b.key)}
|
||||
<button
|
||||
type="button"
|
||||
data-chronos-event-chip
|
||||
data-timed
|
||||
style={blockStyle(b)}
|
||||
aria-label={provider.eventAriaLabel(b.event)}
|
||||
onclick={() => edit(b.event)}
|
||||
>
|
||||
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
|
||||
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if showNow}
|
||||
<div class="chronos-tg-now" style="top:{nowTop}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="chronos-peek-empty">No timed events</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="chronos-peek-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize width"
|
||||
title="Drag to resize"
|
||||
onpointerdown={resizeStart}
|
||||
onpointermove={resizeMove}
|
||||
onpointerup={resizeEnd}
|
||||
onpointercancel={resizeEnd}
|
||||
></div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import './chronos.css';
|
||||
import * as Chronos from '$soma/components/chronos';
|
||||
import ChronosView from './chronos-view.svelte';
|
||||
import type { ChronosProps } from './types';
|
||||
|
||||
let { size = 'md', color = 'primary', editor, children, ...rest }: ChronosProps = $props();
|
||||
</script>
|
||||
|
||||
<Chronos.Provider {...rest} data-size={size} data-color={color}>
|
||||
<ChronosView {editor} />
|
||||
{@render children?.()}
|
||||
</Chronos.Provider>
|
||||
@ -0,0 +1,22 @@
|
||||
// Chronos — eidos compound API.
|
||||
//
|
||||
// import Chronos from '$uix/eidos/components/chronos';
|
||||
// <Chronos events={events} onEventCreate={...} />
|
||||
//
|
||||
// Single-root component: the eidos wrapper renders the full Month view
|
||||
// (toolbar + grid + inline editor) from the soma provider's reactive model.
|
||||
import ChronosComponent from './chronos.svelte';
|
||||
|
||||
export const Chronos = ChronosComponent;
|
||||
export default Chronos;
|
||||
|
||||
export type { ChronosProps, ChronosSize, ChronosColor } from './types';
|
||||
export type {
|
||||
ChronosEvent,
|
||||
ChronosEventType,
|
||||
ChronosEditorContext,
|
||||
ChronosTooltipContext,
|
||||
ChronosRange,
|
||||
ChronosView,
|
||||
CalendarSchema
|
||||
} from '$soma/components/chronos';
|
||||
@ -0,0 +1,23 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type {
|
||||
ChronosProps as SomaChronosProps,
|
||||
ChronosEditorContext
|
||||
} from '$soma/components/chronos';
|
||||
import type { ColorRole, Size } from '$uix/eidos/lib/types';
|
||||
|
||||
/** Month-cell density. (Responsive `ResponsiveProp` support is a v1+ enhancement.) */
|
||||
export type ChronosSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
/** Accent color role (today marker + default event chip). */
|
||||
export type ChronosColor = ColorRole;
|
||||
|
||||
export type ChronosProps = SomaChronosProps & {
|
||||
size?: ChronosSize;
|
||||
color?: ChronosColor;
|
||||
/**
|
||||
* Custom editor body. When provided, Chronos renders this snippet inside its
|
||||
* Dialog instead of the built-in form — the consuming application decides what
|
||||
* fields the dialog shows. The context's `save` / `remove` stay wired to the
|
||||
* calendar engine (command/history + callbacks).
|
||||
*/
|
||||
editor?: Snippet<[ChronosEditorContext]>;
|
||||
};
|
||||
@ -0,0 +1,413 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Chronos — event calendar (scheduler). v1 ships the **Month** view.
|
||||
*
|
||||
* Modelled on `calendarMorfo` (the ARIA `grid` pattern) but for an EVENT
|
||||
* calendar, not a date picker: cells host event chips, chips are draggable /
|
||||
* resizable, and the component owns an editor (a reused Dialog) plus an
|
||||
* undo/redo command stack.
|
||||
*
|
||||
* Design + competitive rationale live in
|
||||
* `src/uix/eidos/components/chronos/SPEC.md`. Scope decisions: v1 = Month with
|
||||
* create/move/resize + editor + undo/redo; Week/Day/Agenda/Year/Timeline,
|
||||
* recurrence and virtualization are roadmap.
|
||||
*
|
||||
* NOTE (verbs): undo/redo are expressed as `commit-restore` / `commit-apply`
|
||||
* (both canonical commit verbs). This is provisional — pending the author's
|
||||
* doctrinal call on the right verb for undo/redo (see SPEC §6.2).
|
||||
*
|
||||
* NOTE (editor): the create/edit editor is a REUSED Dialog. Its perceptual
|
||||
* open/close (emerge) belongs to Dialog's own morfo; Chronos does not
|
||||
* re-declare it. Chronos only orchestrates when the Dialog opens.
|
||||
*/
|
||||
export const chronosMorfo = {
|
||||
name: 'Chronos',
|
||||
kebab: 'chronos',
|
||||
scope: ['soma', 'sema'],
|
||||
expression: 'pack',
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
|
||||
texts: {
|
||||
label: '#?components.chronos.label|Calendar',
|
||||
prevMonth: '#?components.chronos.prev-month|Previous month',
|
||||
nextMonth: '#?components.chronos.next-month|Next month',
|
||||
today: '#?components.chronos.today|Today',
|
||||
moreEvents: '#?components.chronos.more-events|Show more events'
|
||||
},
|
||||
events: [
|
||||
// Month navigation (prev / next / today / jump).
|
||||
{
|
||||
name: 'shift-navigate',
|
||||
semantic: {
|
||||
family: 'shift',
|
||||
verb: 'navigate',
|
||||
target: v.partRef('provider'),
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// An event was created OR its dates changed (move / resize) and persisted.
|
||||
// Unified like Dialog's polymorphic close — the provider distinguishes via
|
||||
// the engine command; the perceptual signal is "something landed".
|
||||
{
|
||||
name: 'commit-save',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'save',
|
||||
target: v.partRef('event-chip'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// An event was moved by drag (committed). Distinct from a plain edit-save
|
||||
// so the cascade can give drag-drop its own character if desired.
|
||||
{
|
||||
name: 'commit-move',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'move',
|
||||
target: v.partRef('event-chip'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// An event was deleted (consequence consumed → loss).
|
||||
{
|
||||
name: 'commit-delete',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'delete',
|
||||
target: v.partRef('event-chip'),
|
||||
intent: 'loss',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// Undo — restore the previous snapshot. (Provisional verb; see header.)
|
||||
{
|
||||
name: 'commit-restore',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'restore',
|
||||
target: v.partRef('provider'),
|
||||
intent: 'neutral',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// Redo — re-apply the undone snapshot. (Provisional verb; see header.)
|
||||
{
|
||||
name: 'commit-apply',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'apply',
|
||||
target: v.partRef('provider'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
// Direct manipulation in flight — move. Haptic-only by doctrine (no sound
|
||||
// per frame). Intent concentrates on the drop (commit-move), not the carry.
|
||||
{
|
||||
name: 'handle-drag',
|
||||
semantic: {
|
||||
family: 'handle',
|
||||
verb: 'drag',
|
||||
target: v.partRef('event-chip'),
|
||||
sequence: 'coincident'
|
||||
}
|
||||
},
|
||||
// Direct manipulation in flight — resize (drag the chip edge).
|
||||
{
|
||||
name: 'handle-resize',
|
||||
semantic: {
|
||||
family: 'handle',
|
||||
verb: 'resize',
|
||||
target: v.partRef('event-resize-handle'),
|
||||
sequence: 'coincident'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
// ── Root ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'application',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-view', values: ['month', 'week', 'day', 'agenda'], severity: 'optional' },
|
||||
{ attr: 'data-readonly', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional',
|
||||
ariaBoolean: true
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'ArrowRight', action: 'next-day' },
|
||||
{ key: 'ArrowLeft', action: 'prev-day' },
|
||||
{ key: 'ArrowDown', action: 'next-week' },
|
||||
{ key: 'ArrowUp', action: 'prev-week' },
|
||||
{ key: 'PageUp', action: 'prev-month' },
|
||||
{ key: 'PageDown', action: 'next-month' },
|
||||
{ key: 'Home', action: 'first-day-of-week' },
|
||||
{ key: 'End', action: 'last-day-of-week' },
|
||||
{ key: 'Enter', action: 'create-or-open' },
|
||||
{ key: ' ', action: 'create-or-open' }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Toolbar (optional, fully replaceable) ──────────────────────────────
|
||||
{
|
||||
name: 'Toolbar',
|
||||
kebab: 'toolbar',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'header',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'PrevButton',
|
||||
kebab: 'prev-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.translationRef('prevMonth', 'Previous month'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'NextButton',
|
||||
kebab: 'next-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.translationRef('nextMonth', 'Next month'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'TodayButton',
|
||||
kebab: 'today-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.translationRef('today', 'Today'), severity: 'recommended' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'UndoButton',
|
||||
kebab: 'undo-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.commonRef('action.undo', 'Undo'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'RedoButton',
|
||||
kebab: 'redo-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.commonRef('action.redo', 'Redo'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Heading',
|
||||
kebab: 'heading',
|
||||
archetype: 'title',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
|
||||
// ── Grid (required) ────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'Grid',
|
||||
kebab: 'grid',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'grid',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('heading'),
|
||||
condition: { when: 'part-present', part: 'heading' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional', ariaBoolean: true },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'GridHead',
|
||||
kebab: 'grid-head',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'row',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'HeadCell',
|
||||
kebab: 'head-cell',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'columnheader',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'GridBody',
|
||||
kebab: 'grid-body',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'rowgroup',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'GridRow',
|
||||
kebab: 'grid-row',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'row',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
|
||||
// ── Day cell + its children ────────────────────────────────────────────
|
||||
{
|
||||
name: 'DayCell',
|
||||
kebab: 'day-cell',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'gridcell',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-date', value: v.propRef('date'), emit: 'value' },
|
||||
{ attr: 'data-today', severity: 'optional' },
|
||||
{ attr: 'data-weekend', severity: 'optional' },
|
||||
{ attr: 'data-outside-month', severity: 'optional' },
|
||||
{ attr: 'data-selected', severity: 'optional' },
|
||||
{ attr: 'data-has-events', severity: 'optional' },
|
||||
{ attr: 'data-drop-target', severity: 'optional' },
|
||||
{ attr: 'data-focused', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-label', value: v.propRef('dateLabel'), severity: 'recommended' },
|
||||
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional', ariaBoolean: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'DayNumber',
|
||||
kebab: 'day-number',
|
||||
archetype: 'label',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-value', value: v.propRef('value'), emit: 'value' }],
|
||||
aria: []
|
||||
},
|
||||
|
||||
// ── Event chip + resize handle ─────────────────────────────────────────
|
||||
{
|
||||
name: 'EventChip',
|
||||
kebab: 'event-chip',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-event-id', value: v.propRef('eventId'), emit: 'value' },
|
||||
{ attr: 'data-color', value: v.propRef('color'), emit: 'value', severity: 'optional' },
|
||||
{ attr: 'data-all-day', severity: 'optional' },
|
||||
{ attr: 'data-multi-day', severity: 'optional' },
|
||||
{ attr: 'data-continues-before', severity: 'optional' },
|
||||
{ attr: 'data-continues-after', severity: 'optional' },
|
||||
{ attr: 'data-dragging', severity: 'optional' },
|
||||
{ attr: 'data-resizing', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.propRef('eventLabel'), severity: 'recommended' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'EventResizeHandle',
|
||||
kebab: 'event-resize-handle',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-edge', values: ['start', 'end'], value: v.propRef('edge') }],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
|
||||
// ── "+N more" overflow link ────────────────────────────────────────────
|
||||
{
|
||||
name: 'MoreLink',
|
||||
kebab: 'more-link',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-count', value: v.propRef('count'), emit: 'value' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-label', value: v.translationRef('moreEvents', 'Show more events'), severity: 'recommended' }
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,62 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { chronosMorfo } from '$uix/morfo/components/chronos';
|
||||
import { soundTuning } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* Chronos sema pack. NOTE: not yet registered in `sema/components/index.ts`
|
||||
* (that barrel is outside the component and edits there need explicit sign-off
|
||||
* — see SPEC §13). Sema is ornamental (sound/haptic default off), so the
|
||||
* component is fully functional without registration; wiring the export is a
|
||||
* follow-up.
|
||||
*/
|
||||
export const chronosSema: Sema = {
|
||||
name: 'chronos',
|
||||
cascade: [
|
||||
// Event landed (create / edit-save / resize-save).
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-save' }),
|
||||
sound: soundTuning('form.commit.soft'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
// Event moved by drag.
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-move' }),
|
||||
sound: soundTuning('form.commit.soft'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
// Event deleted (loss).
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-delete' }),
|
||||
sound: soundTuning('form.commit.subtle'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
// Month navigation.
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'shift-navigate' }),
|
||||
sound: soundTuning('form.commit.subtle', {
|
||||
gain: 0.18,
|
||||
contour: 'ascending',
|
||||
pitch: { op: 'add', value: -90 }
|
||||
})
|
||||
},
|
||||
// Undo / redo.
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-restore' }),
|
||||
sound: soundTuning('form.commit.subtle')
|
||||
},
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-apply' }),
|
||||
sound: soundTuning('form.commit.subtle')
|
||||
},
|
||||
// Direct manipulation in flight — haptic only (no sound per frame).
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'handle-drag' }),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
{
|
||||
selector: semaSelector(chronosMorfo, 'event-resize-handle', { eventName: 'handle-resize' }),
|
||||
haptic: { kind: 'tap' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ChronosProvider } from '../chronos-provider.svelte';
|
||||
import type { ChronosProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'chronos'),
|
||||
date,
|
||||
events = [],
|
||||
schema,
|
||||
otherTypeLabel,
|
||||
view,
|
||||
onViewChange,
|
||||
weekStartsOn,
|
||||
fixedWeeks = true,
|
||||
locale,
|
||||
maxEventsPerCell = 3,
|
||||
editable = true,
|
||||
readonly = false,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
onEventCreate,
|
||||
onEventUpdate,
|
||||
onEventDelete,
|
||||
onRangeChange,
|
||||
onEventClick,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ChronosProps = $props();
|
||||
|
||||
const provider = ChronosProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
date: readableActive(() => date),
|
||||
events: readableActive(() => events),
|
||||
schema: readableActive(() => schema),
|
||||
otherTypeLabel: readableActive(() => otherTypeLabel),
|
||||
view: readableActive(() => view),
|
||||
weekStartsOn: readableActive(() => weekStartsOn),
|
||||
fixedWeeks: readableActive(() => fixedWeeks),
|
||||
locale: readableActive(() => locale ?? ''),
|
||||
maxEventsPerCell: readableActive(() => maxEventsPerCell),
|
||||
editable: readableActive(() => editable),
|
||||
readonly: readableActive(() => readonly),
|
||||
disabled: readableActive(() => disabled),
|
||||
ariaLabel: readableActive(() => ariaLabel),
|
||||
// Wrap in closures so the provider always calls the current prop value
|
||||
// (avoids `state_referenced_locally` — destructured props are reactive).
|
||||
onViewChange: (v) => onViewChange?.(v),
|
||||
onEventCreate: (e) => onEventCreate?.(e),
|
||||
onEventUpdate: (e) => onEventUpdate?.(e),
|
||||
onEventDelete: (id) => onEventDelete?.(id),
|
||||
onRangeChange: (r) => onRangeChange?.(r),
|
||||
onEventClick: (e) => onEventClick?.(e)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Command dispatcher — pure. Each command maps a snapshot to a new snapshot
|
||||
* plus a `changed` flag. Precedent: `words/engine/operations/commands.ts`.
|
||||
*
|
||||
* No-op commands (move an unknown id, edit a non-editable event, delete a
|
||||
* missing id) return the SAME snapshot reference with `changed: false` so the
|
||||
* caller can skip pushing a useless history entry.
|
||||
*/
|
||||
|
||||
import type { ChronosCommand, ChronosCommandResult, ChronosEvent, ChronosSnapshot } from './types';
|
||||
|
||||
function mapEvent(
|
||||
snapshot: ChronosSnapshot,
|
||||
id: string,
|
||||
fn: (e: ChronosEvent) => ChronosEvent
|
||||
): ChronosCommandResult {
|
||||
let changed = false;
|
||||
const events = snapshot.events.map((e) => {
|
||||
if (e.id !== id) return e;
|
||||
if (e.editable === false) return e;
|
||||
const next = fn(e);
|
||||
if (next !== e) changed = true;
|
||||
return next;
|
||||
});
|
||||
return changed ? { snapshot: { events }, changed } : { snapshot, changed: false };
|
||||
}
|
||||
|
||||
export function applyChronosCommand(
|
||||
snapshot: ChronosSnapshot,
|
||||
cmd: ChronosCommand
|
||||
): ChronosCommandResult {
|
||||
switch (cmd.type) {
|
||||
case 'create': {
|
||||
// Replacing an existing id is treated as an update-by-replace.
|
||||
const exists = snapshot.events.some((e) => e.id === cmd.event.id);
|
||||
const events = exists
|
||||
? snapshot.events.map((e) => (e.id === cmd.event.id ? cmd.event : e))
|
||||
: [...snapshot.events, cmd.event];
|
||||
return { snapshot: { events }, changed: true };
|
||||
}
|
||||
case 'move':
|
||||
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, start: cmd.start, end: cmd.end }));
|
||||
case 'resize':
|
||||
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, end: cmd.end }));
|
||||
case 'update':
|
||||
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, ...cmd.patch }));
|
||||
case 'delete': {
|
||||
const events = snapshot.events.filter((e) => e.id !== cmd.id);
|
||||
const changed = events.length !== snapshot.events.length;
|
||||
return changed ? { snapshot: { events }, changed } : { snapshot, changed: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CalendarDate, type DateValue } from '$libs/days';
|
||||
import { applyChronosCommand } from './commands';
|
||||
import {
|
||||
createHistory,
|
||||
commitHistory,
|
||||
undoHistory,
|
||||
redoHistory,
|
||||
canUndo,
|
||||
canRedo
|
||||
} from './history';
|
||||
import { eventsOnDay, eventsInRange, compareForLayout } from './event-store';
|
||||
import { layoutMonth } from './layout';
|
||||
import type { ChronosEvent, ChronosSnapshot } from './types';
|
||||
|
||||
function ev(id: string, start: DateValue, end: DateValue, extra: Partial<ChronosEvent> = {}): ChronosEvent {
|
||||
return { id, title: id, start, end, ...extra };
|
||||
}
|
||||
|
||||
const d = (m: number, day: number) => new CalendarDate(2026, m, day);
|
||||
|
||||
function buildWeeks(start: CalendarDate, numWeeks: number): DateValue[][] {
|
||||
const weeks: DateValue[][] = [];
|
||||
let cur: DateValue = start;
|
||||
for (let w = 0; w < numWeeks; w++) {
|
||||
const row: DateValue[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
row.push(cur);
|
||||
cur = cur.add({ days: 1 });
|
||||
}
|
||||
weeks.push(row);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
describe('commands', () => {
|
||||
const base: ChronosSnapshot = { events: [ev('a', d(6, 3), d(6, 3), { allDay: true })] };
|
||||
|
||||
it('create appends', () => {
|
||||
const r = applyChronosCommand(base, { type: 'create', event: ev('b', d(6, 5), d(6, 5)) });
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.snapshot.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('create with existing id replaces', () => {
|
||||
const r = applyChronosCommand(base, {
|
||||
type: 'create',
|
||||
event: ev('a', d(6, 4), d(6, 4), { title: 'A2' })
|
||||
});
|
||||
expect(r.snapshot.events).toHaveLength(1);
|
||||
expect(r.snapshot.events[0].title).toBe('A2');
|
||||
});
|
||||
|
||||
it('move updates start/end', () => {
|
||||
const r = applyChronosCommand(base, { type: 'move', id: 'a', start: d(6, 10), end: d(6, 10) });
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.snapshot.events[0].start.day).toBe(10);
|
||||
});
|
||||
|
||||
it('resize updates end only', () => {
|
||||
const r = applyChronosCommand(base, { type: 'resize', id: 'a', end: d(6, 6) });
|
||||
expect(r.snapshot.events[0].end.day).toBe(6);
|
||||
expect(r.snapshot.events[0].start.day).toBe(3);
|
||||
});
|
||||
|
||||
it('delete removes', () => {
|
||||
const r = applyChronosCommand(base, { type: 'delete', id: 'a' });
|
||||
expect(r.snapshot.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-editable event resists mutation', () => {
|
||||
const locked: ChronosSnapshot = { events: [ev('x', d(6, 3), d(6, 3), { editable: false })] };
|
||||
const r = applyChronosCommand(locked, { type: 'move', id: 'x', start: d(6, 9), end: d(6, 9) });
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.snapshot).toBe(locked);
|
||||
});
|
||||
|
||||
it('missing id is a no-op (same reference)', () => {
|
||||
const r = applyChronosCommand(base, { type: 'delete', id: 'nope' });
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.snapshot).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
const s0: ChronosSnapshot = { events: [] };
|
||||
const s1: ChronosSnapshot = { events: [ev('a', d(6, 1), d(6, 1))] };
|
||||
const s2: ChronosSnapshot = { events: [ev('a', d(6, 2), d(6, 2))] };
|
||||
|
||||
it('commit / undo / redo round-trips', () => {
|
||||
let h = createHistory(s0);
|
||||
h = commitHistory(h, s1);
|
||||
h = commitHistory(h, s2);
|
||||
expect(h.present).toBe(s2);
|
||||
expect(canUndo(h)).toBe(true);
|
||||
h = undoHistory(h);
|
||||
expect(h.present).toBe(s1);
|
||||
h = undoHistory(h);
|
||||
expect(h.present).toBe(s0);
|
||||
expect(canUndo(h)).toBe(false);
|
||||
h = redoHistory(h);
|
||||
expect(h.present).toBe(s1);
|
||||
expect(canRedo(h)).toBe(true);
|
||||
});
|
||||
|
||||
it('a fresh commit clears the redo branch', () => {
|
||||
let h = createHistory(s0);
|
||||
h = commitHistory(h, s1);
|
||||
h = undoHistory(h);
|
||||
expect(canRedo(h)).toBe(true);
|
||||
h = commitHistory(h, s2);
|
||||
expect(canRedo(h)).toBe(false);
|
||||
expect(h.present).toBe(s2);
|
||||
});
|
||||
|
||||
it('caps the past at the limit', () => {
|
||||
let h = createHistory(s0, 2);
|
||||
h = commitHistory(h, s1);
|
||||
h = commitHistory(h, s2);
|
||||
h = commitHistory(h, s1);
|
||||
expect(h.past).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event-store', () => {
|
||||
const events = [
|
||||
ev('a', d(6, 3), d(6, 3), { allDay: true }),
|
||||
ev('b', d(6, 5), d(6, 9), { allDay: true })
|
||||
];
|
||||
|
||||
it('eventsOnDay covers multi-day spans', () => {
|
||||
expect(eventsOnDay(events, d(6, 3)).map((e) => e.id)).toEqual(['a']);
|
||||
expect(eventsOnDay(events, d(6, 7)).map((e) => e.id)).toEqual(['b']);
|
||||
expect(eventsOnDay(events, d(6, 20))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('eventsInRange overlaps inclusively', () => {
|
||||
expect(eventsInRange(events, d(6, 1), d(6, 4)).map((e) => e.id)).toEqual(['a']);
|
||||
expect(eventsInRange(events, d(6, 1), d(6, 30)).map((e) => e.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('compareForLayout: all-day before timed', () => {
|
||||
const allDay = ev('z', d(6, 1), d(6, 1), { allDay: true });
|
||||
const timed = ev('y', d(6, 1), d(6, 1));
|
||||
expect(compareForLayout(allDay, timed)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
// Week 0 = Jun 1..7, Week 1 = Jun 8..14 (2026).
|
||||
const weeks = buildWeeks(d(6, 1), 2);
|
||||
|
||||
it('places a single-day event in one cell', () => {
|
||||
const out = layoutMonth([ev('a', d(6, 3), d(6, 3), { allDay: true })], weeks, { maxLanes: 5 });
|
||||
expect(out.segments).toHaveLength(1);
|
||||
expect(out.segments[0]).toMatchObject({ weekIndex: 0, startCol: 2, span: 1, lane: 0 });
|
||||
});
|
||||
|
||||
it('splits a multi-day event across the week boundary', () => {
|
||||
const out = layoutMonth([ev('b', d(6, 5), d(6, 9), { allDay: true })], weeks, { maxLanes: 5 });
|
||||
expect(out.segments).toHaveLength(2);
|
||||
const [first, second] = out.segments;
|
||||
expect(first).toMatchObject({ weekIndex: 0, startCol: 4, span: 3, continuesAfter: true });
|
||||
expect(second).toMatchObject({ weekIndex: 1, startCol: 0, span: 2, continuesBefore: true });
|
||||
});
|
||||
|
||||
it('assigns different lanes to overlapping events', () => {
|
||||
const out = layoutMonth(
|
||||
[ev('a', d(6, 3), d(6, 3), { allDay: true }), ev('c', d(6, 3), d(6, 3), { allDay: true })],
|
||||
weeks,
|
||||
{ maxLanes: 5 }
|
||||
);
|
||||
const lanes = out.segments.map((s) => s.lane).sort();
|
||||
expect(lanes).toEqual([0, 1]);
|
||||
expect(out.usedLanes).toBe(2);
|
||||
});
|
||||
|
||||
it('collapses overflow into a +N more tally', () => {
|
||||
const out = layoutMonth(
|
||||
[ev('a', d(6, 3), d(6, 3), { allDay: true }), ev('c', d(6, 3), d(6, 3), { allDay: true })],
|
||||
weeks,
|
||||
{ maxLanes: 1 }
|
||||
);
|
||||
expect(out.segments).toHaveLength(1);
|
||||
expect(out.overflow).toEqual([{ date: '2026-06-03', hiddenCount: 1 }]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Event queries — pure. Day-granular overlap for the Month view.
|
||||
*
|
||||
* v1 uses O(n) scans, which is what every reference scheduler does for a
|
||||
* month's worth of events. An interval-tree is roadmap (only justified once a
|
||||
* single view holds thousands of events).
|
||||
*/
|
||||
|
||||
import type { DateValue } from '$libs/days';
|
||||
import type { ChronosEvent } from './types';
|
||||
|
||||
/**
|
||||
* Comparable day ordinal: `YYYYMMDD` as a number. Reduces any `DateValue`
|
||||
* (CalendarDate / CalendarDateTime / ZonedDateTime) to day granularity so
|
||||
* all-day and timed events compare uniformly in the Month grid.
|
||||
*/
|
||||
export function dayOrdinal(d: DateValue): number {
|
||||
return d.year * 10000 + d.month * 100 + d.day;
|
||||
}
|
||||
|
||||
/** True when an event's [start, end] (inclusive, day-granular) overlaps [from, to]. */
|
||||
export function eventOverlapsRange(event: ChronosEvent, from: DateValue, to: DateValue): boolean {
|
||||
const evStart = dayOrdinal(event.start);
|
||||
const evEnd = dayOrdinal(event.end);
|
||||
return evStart <= dayOrdinal(to) && evEnd >= dayOrdinal(from);
|
||||
}
|
||||
|
||||
/** Events overlapping the inclusive day range [from, to]. */
|
||||
export function eventsInRange(
|
||||
events: readonly ChronosEvent[],
|
||||
from: DateValue,
|
||||
to: DateValue
|
||||
): ChronosEvent[] {
|
||||
return events.filter((e) => eventOverlapsRange(e, from, to));
|
||||
}
|
||||
|
||||
/** Events that cover a single day. */
|
||||
export function eventsOnDay(events: readonly ChronosEvent[], day: DateValue): ChronosEvent[] {
|
||||
const key = dayOrdinal(day);
|
||||
return events.filter((e) => dayOrdinal(e.start) <= key && dayOrdinal(e.end) >= key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable ordering for events sharing a cell/row: all-day before timed, then by
|
||||
* start, then by (longer) duration, then by id for determinism.
|
||||
*/
|
||||
export function compareForLayout(a: ChronosEvent, b: ChronosEvent): number {
|
||||
const aAll = a.allDay ? 0 : 1;
|
||||
const bAll = b.allDay ? 0 : 1;
|
||||
if (aAll !== bAll) return aAll - bAll;
|
||||
const aStart = dayOrdinal(a.start);
|
||||
const bStart = dayOrdinal(b.start);
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
const aLen = dayOrdinal(a.end) - aStart;
|
||||
const bLen = dayOrdinal(b.end) - bStart;
|
||||
if (aLen !== bLen) return bLen - aLen; // longer first
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CalendarDate, type DateValue } from '$libs/days';
|
||||
import { buildMonthGrid } from './grid';
|
||||
|
||||
const anchor = new CalendarDate(2026, 6, 15); // mid-June 2026
|
||||
|
||||
function flat(weeks: DateValue[][]): DateValue[] {
|
||||
return weeks.flat();
|
||||
}
|
||||
|
||||
describe('buildMonthGrid', () => {
|
||||
it('produces rows of exactly 7 days', () => {
|
||||
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' });
|
||||
for (const row of weeks) expect(row).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('days are strictly consecutive across the whole grid', () => {
|
||||
const days = flat(buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: true, locale: 'en-US' }));
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
expect(days[i].compare(days[i - 1].add({ days: 1 }))).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('contains every day of the target month', () => {
|
||||
const keys = new Set(
|
||||
flat(buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' })).map((d) =>
|
||||
d.toString()
|
||||
)
|
||||
);
|
||||
for (let day = 1; day <= 30; day++) {
|
||||
expect(keys.has(new CalendarDate(2026, 6, day).toString())).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts on the week-start on/before the 1st', () => {
|
||||
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' });
|
||||
const first = weeks[0][0];
|
||||
// First cell is on or before June 1, and within the prior week.
|
||||
expect(first.compare(new CalendarDate(2026, 6, 1))).toBeLessThanOrEqual(0);
|
||||
expect(new CalendarDate(2026, 6, 1).compare(first.add({ days: 7 }))).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('fixedWeeks pads to a constant 6 rows (42 cells)', () => {
|
||||
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: true, locale: 'en-US' });
|
||||
expect(weeks).toHaveLength(6);
|
||||
expect(flat(weeks)).toHaveLength(42);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Undo/redo — snapshot stack. Pure functions over an immutable
|
||||
* `ChronosHistory`. Precedent: `words/engine/operations/history.ts`
|
||||
* (`WORDS_HISTORY_LIMIT = 200`).
|
||||
*
|
||||
* `commit` pushes the current present onto `past`, sets the new present, and
|
||||
* clears `future` (a fresh edit invalidates the redo branch). `past` is capped
|
||||
* at `limit` — the oldest entries fall off the bottom.
|
||||
*/
|
||||
|
||||
import type { ChronosSnapshot } from './types';
|
||||
|
||||
export const CHRONOS_HISTORY_LIMIT = 200;
|
||||
|
||||
export interface ChronosHistory {
|
||||
readonly past: readonly ChronosSnapshot[];
|
||||
readonly present: ChronosSnapshot;
|
||||
readonly future: readonly ChronosSnapshot[];
|
||||
readonly limit: number;
|
||||
}
|
||||
|
||||
export function createHistory(
|
||||
present: ChronosSnapshot,
|
||||
limit: number = CHRONOS_HISTORY_LIMIT
|
||||
): ChronosHistory {
|
||||
return { past: [], present, future: [], limit };
|
||||
}
|
||||
|
||||
/** Record a new present, capping `past` and discarding the redo branch. */
|
||||
export function commitHistory(h: ChronosHistory, next: ChronosSnapshot): ChronosHistory {
|
||||
const past = [...h.past, h.present];
|
||||
const overflow = past.length - h.limit;
|
||||
return {
|
||||
past: overflow > 0 ? past.slice(overflow) : past,
|
||||
present: next,
|
||||
future: [],
|
||||
limit: h.limit
|
||||
};
|
||||
}
|
||||
|
||||
export function canUndo(h: ChronosHistory): boolean {
|
||||
return h.past.length > 0;
|
||||
}
|
||||
|
||||
export function canRedo(h: ChronosHistory): boolean {
|
||||
return h.future.length > 0;
|
||||
}
|
||||
|
||||
export function undoHistory(h: ChronosHistory): ChronosHistory {
|
||||
if (h.past.length === 0) return h;
|
||||
const previous = h.past[h.past.length - 1];
|
||||
return {
|
||||
past: h.past.slice(0, -1),
|
||||
present: previous,
|
||||
future: [h.present, ...h.future],
|
||||
limit: h.limit
|
||||
};
|
||||
}
|
||||
|
||||
export function redoHistory(h: ChronosHistory): ChronosHistory {
|
||||
if (h.future.length === 0) return h;
|
||||
const [next, ...rest] = h.future;
|
||||
return {
|
||||
past: [...h.past, h.present],
|
||||
present: next,
|
||||
future: rest,
|
||||
limit: h.limit
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Month layout — pure. Given the month's week rows and the events, produce the
|
||||
* render-ready segments (one per event per week it touches) with consistent
|
||||
* lane assignment, plus the per-cell "+N more" overflow tally.
|
||||
*
|
||||
* This is the correct overlap algorithm Chronos ships that several references
|
||||
* get wrong (react-big-calendar's "no-overlap" still overlaps short events).
|
||||
*
|
||||
* Lane assignment per week row (greedy): segments are ordered (all-day first,
|
||||
* earlier start, longer span), then each takes the lowest lane index free
|
||||
* across every column it spans. Multi-day bars therefore keep ONE lane across
|
||||
* the whole row. Segments whose lane exceeds `maxLanes` are not emitted; each
|
||||
* cell they cover gets a "+N more" increment instead.
|
||||
*/
|
||||
|
||||
import type { DateValue } from '$libs/days';
|
||||
import type {
|
||||
ChronosCellOverflow,
|
||||
ChronosEvent,
|
||||
ChronosLayoutOpts,
|
||||
ChronosMonthLayout,
|
||||
ChronosSegment
|
||||
} from './types';
|
||||
import { compareForLayout, dayOrdinal } from './event-store';
|
||||
|
||||
interface DraftSegment extends Omit<ChronosSegment, 'lane'> {
|
||||
lane: number;
|
||||
}
|
||||
|
||||
export function layoutMonth(
|
||||
events: readonly ChronosEvent[],
|
||||
weeks: readonly (readonly DateValue[])[],
|
||||
opts: ChronosLayoutOpts
|
||||
): ChronosMonthLayout {
|
||||
const segments: ChronosSegment[] = [];
|
||||
const hidden = new Map<string, number>();
|
||||
let usedLanes = 0;
|
||||
|
||||
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
|
||||
const week = weeks[weekIndex];
|
||||
if (week.length === 0) continue;
|
||||
const wStart = dayOrdinal(week[0]);
|
||||
const wEnd = dayOrdinal(week[week.length - 1]);
|
||||
|
||||
// 1. Build this row's segments (clamped to the visible week).
|
||||
const drafts: DraftSegment[] = [];
|
||||
for (const event of events) {
|
||||
const evStart = dayOrdinal(event.start);
|
||||
const evEnd = dayOrdinal(event.end);
|
||||
if (evEnd < wStart || evStart > wEnd) continue;
|
||||
|
||||
let startCol = 0;
|
||||
while (startCol < week.length && dayOrdinal(week[startCol]) < evStart) startCol++;
|
||||
if (startCol >= week.length) startCol = week.length - 1;
|
||||
|
||||
let endCol = week.length - 1;
|
||||
while (endCol > 0 && dayOrdinal(week[endCol]) > evEnd) endCol--;
|
||||
|
||||
drafts.push({
|
||||
event,
|
||||
weekIndex,
|
||||
startCol,
|
||||
span: Math.max(1, endCol - startCol + 1),
|
||||
lane: -1,
|
||||
continuesBefore: evStart < wStart,
|
||||
continuesAfter: evEnd > wEnd
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Order then greedily assign the lowest free lane across the span.
|
||||
drafts.sort((a, b) => {
|
||||
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
|
||||
if (a.span !== b.span) return b.span - a.span;
|
||||
return compareForLayout(a.event, b.event);
|
||||
});
|
||||
|
||||
const laneCols: boolean[][] = []; // laneCols[lane][col] = occupied
|
||||
for (const seg of drafts) {
|
||||
let lane = 0;
|
||||
for (;;) {
|
||||
if (!laneCols[lane]) laneCols[lane] = new Array(week.length).fill(false);
|
||||
let free = true;
|
||||
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
|
||||
if (laneCols[lane][c]) {
|
||||
free = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (free) {
|
||||
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) laneCols[lane][c] = true;
|
||||
seg.lane = lane;
|
||||
break;
|
||||
}
|
||||
lane++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Per-cell visible cap. A cell with MORE than `maxLanes` segments
|
||||
// reserves its last lane for the "+N more" link, so it shows
|
||||
// (maxLanes - 1) bars + the link and never exceeds `maxLanes` rows.
|
||||
// Cells at/under the cap show everything.
|
||||
const cap = opts.maxLanes;
|
||||
const cellTotal = new Map<string, number>();
|
||||
for (const seg of drafts) {
|
||||
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
|
||||
const key = week[c].toString();
|
||||
cellTotal.set(key, (cellTotal.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const visibleCap = (key: string) => ((cellTotal.get(key) ?? 0) > cap ? cap - 1 : cap);
|
||||
|
||||
for (const seg of drafts) {
|
||||
// A multi-day bar uses the strictest cap across the cells it covers.
|
||||
let segCap = cap;
|
||||
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
|
||||
segCap = Math.min(segCap, visibleCap(week[c].toString()));
|
||||
}
|
||||
if (seg.lane < segCap) {
|
||||
usedLanes = Math.max(usedLanes, seg.lane + 1);
|
||||
segments.push(seg);
|
||||
} else {
|
||||
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
|
||||
const key = week[c].toString();
|
||||
hidden.set(key, (hidden.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overflow: ChronosCellOverflow[] = [...hidden.entries()].map(([date, hiddenCount]) => ({
|
||||
date,
|
||||
hiddenCount
|
||||
}));
|
||||
|
||||
return { segments, overflow, usedLanes };
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Calendar color schema — pure resolution. Event colors come from the
|
||||
* consumer's `CalendarSchema` (categories → palette scale), NOT from the intent
|
||||
* channel. The canonical "Other" type is always available and carries a custom
|
||||
* color chosen per-event.
|
||||
*/
|
||||
|
||||
import { CHRONOS_OTHER_TYPE_ID, type CalendarSchema, type ChronosEvent, type ChronosEventType } from './types';
|
||||
|
||||
/** The single built-in event type. Its color is per-event (custom picker). */
|
||||
export const CHRONOS_OTHER_TYPE: ChronosEventType = {
|
||||
id: CHRONOS_OTHER_TYPE_ID,
|
||||
label: 'Other',
|
||||
color: '#6b7280'
|
||||
};
|
||||
|
||||
export const EMPTY_CALENDAR_SCHEMA: CalendarSchema = { eventTypes: [], predefinedColors: [] };
|
||||
|
||||
/** Selectable types: the schema's categories plus the canonical "Other". */
|
||||
export function listEventTypes(
|
||||
schema: CalendarSchema,
|
||||
otherLabel: string = CHRONOS_OTHER_TYPE.label
|
||||
): ChronosEventType[] {
|
||||
return [...schema.eventTypes, { ...CHRONOS_OTHER_TYPE, label: otherLabel }];
|
||||
}
|
||||
|
||||
/** Resolve an event's color (eidos scale name or CSS color string). */
|
||||
export function resolveEventColor(event: ChronosEvent, schema: CalendarSchema): string {
|
||||
if (event.typeId && event.typeId !== CHRONOS_OTHER_TYPE_ID) {
|
||||
const type = schema.eventTypes.find((t) => t.id === event.typeId);
|
||||
if (type) return type.color;
|
||||
}
|
||||
return event.color || CHRONOS_OTHER_TYPE.color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-chip CSS custom properties for a resolved color. `color` is a
|
||||
* CSS color VALUE (hex / oklch / rgb) — NOT an eidos scale name: the raw
|
||||
* `--scale-*` palette is not shipped at runtime (the foundation ships role
|
||||
* scales only), so we composite the chip's bg / accent / text from the value
|
||||
* with `color-mix`, which is theme-adaptive and dependency-free.
|
||||
*/
|
||||
export function eventColorVars(color: string): string {
|
||||
return (
|
||||
`--_chronos-event-accent: ${color};` +
|
||||
`--_chronos-event-bg: color-mix(in oklab, ${color} 18%, var(--color-surface-raised));` +
|
||||
`--_chronos-event-fg: color-mix(in oklab, ${color} 64%, var(--color-content-primary));`
|
||||
);
|
||||
}
|
||||
|
||||
/** A swatch fill (legend / picker) for a color value. */
|
||||
export function swatchColor(color: string): string {
|
||||
return color;
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Time-grid layout — pure. Positions timed events within a single day's
|
||||
* vertical hour axis (Week / Day views) and resolves overlaps into side-by-side
|
||||
* columns (the correct algorithm that several references get wrong).
|
||||
*
|
||||
* All-day / multi-day events are returned separately so the view can render
|
||||
* them in the all-day row above the grid.
|
||||
*/
|
||||
|
||||
import type { DateValue } from '$libs/days';
|
||||
import type { ChronosEvent } from './types';
|
||||
import { eventTimeMode } from './types';
|
||||
import { dayOrdinal } from './event-store';
|
||||
|
||||
export const MINUTES_PER_DAY = 1440;
|
||||
const MIN_BLOCK_MINUTES = 30;
|
||||
|
||||
/** Minutes from midnight for a DateValue (0 for date-only values). */
|
||||
function minutesOf(d: DateValue): number {
|
||||
return 'hour' in d ? d.hour * 60 + d.minute : 0;
|
||||
}
|
||||
|
||||
/** A positioned timed event within a day's grid. */
|
||||
export interface TimedBlock {
|
||||
event: ChronosEvent;
|
||||
/** Stable per-block key — `id` for single events, `id#band` for split bands. */
|
||||
key: string;
|
||||
/** Minutes from midnight (clamped to the day). */
|
||||
startMin: number;
|
||||
endMin: number;
|
||||
/** Column index + total columns in the overlap cluster (for side-by-side). */
|
||||
col: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export interface DayTimeLayout {
|
||||
timed: TimedBlock[];
|
||||
allDay: ChronosEvent[];
|
||||
}
|
||||
|
||||
/** Layout the timed events of a single day, plus its all-day events. */
|
||||
export function layoutDayTimed(events: readonly ChronosEvent[], day: DateValue): DayTimeLayout {
|
||||
const key = dayOrdinal(day);
|
||||
const onDay = events.filter((e) => dayOrdinal(e.start) <= key && dayOrdinal(e.end) >= key);
|
||||
const allDay = onDay.filter((e) => e.allDay);
|
||||
|
||||
const blocks: TimedBlock[] = [];
|
||||
for (const e of onDay) {
|
||||
if (e.allDay) continue;
|
||||
if (eventTimeMode(e) === 'split') {
|
||||
// Split: the same bands repeat on every day of the span.
|
||||
(e.parts ?? []).forEach((band, i) => {
|
||||
const startMin = band.start.hour * 60 + band.start.minute;
|
||||
let endMin = band.end.hour * 60 + band.end.minute;
|
||||
if (endMin <= startMin) endMin = Math.min(MINUTES_PER_DAY, startMin + MIN_BLOCK_MINUTES);
|
||||
blocks.push({ event: e, key: `${e.id}#${i}`, startMin, endMin, col: 0, cols: 1 });
|
||||
});
|
||||
} else {
|
||||
// Single: one continuous block, clipped to this day.
|
||||
const startMin = dayOrdinal(e.start) < key ? 0 : minutesOf(e.start);
|
||||
let endMin = dayOrdinal(e.end) > key ? MINUTES_PER_DAY : minutesOf(e.end);
|
||||
if (endMin <= startMin) endMin = Math.min(MINUTES_PER_DAY, startMin + MIN_BLOCK_MINUTES);
|
||||
blocks.push({ event: e, key: e.id, startMin, endMin, col: 0, cols: 1 });
|
||||
}
|
||||
}
|
||||
blocks.sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
|
||||
|
||||
assignColumns(blocks);
|
||||
return { timed: blocks, allDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy interval-graph column assignment. Walks blocks in start order,
|
||||
* grouping transitively-overlapping blocks into clusters; within a cluster each
|
||||
* block takes the lowest free column, and every block in the cluster shares the
|
||||
* cluster's column count (so widths line up).
|
||||
*/
|
||||
function assignColumns(blocks: TimedBlock[]): void {
|
||||
let cluster: TimedBlock[] = [];
|
||||
let clusterEnd = -1;
|
||||
|
||||
const flush = () => {
|
||||
const cols = Math.max(1, ...cluster.map((b) => b.col + 1));
|
||||
for (const b of cluster) b.cols = cols;
|
||||
cluster = [];
|
||||
clusterEnd = -1;
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
if (cluster.length > 0 && block.startMin >= clusterEnd) flush();
|
||||
// Lowest column whose last block ends on/before this block's start.
|
||||
const colEnds: number[] = [];
|
||||
for (const b of cluster) colEnds[b.col] = Math.max(colEnds[b.col] ?? -1, b.endMin);
|
||||
let col = 0;
|
||||
while (colEnds[col] !== undefined && colEnds[col] > block.startMin) col++;
|
||||
block.col = col;
|
||||
cluster.push(block);
|
||||
clusterEnd = Math.max(clusterEnd, block.endMin);
|
||||
}
|
||||
if (cluster.length > 0) flush();
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Chronos engine — pure data model + command/layout types.
|
||||
*
|
||||
* No DOM, no runes, no Svelte. Everything here is plain data + pure functions
|
||||
* so the calendar's brain is trivially unit-testable (precedent:
|
||||
* `words/engine/operations`, `$libs/datagrid`). The reactive `CalendarState`
|
||||
* (state.svelte.ts) is the only file in `engine/` that touches runes.
|
||||
*/
|
||||
|
||||
import type { DateValue, Time } from '$libs/days';
|
||||
|
||||
/**
|
||||
* Canonical, always-available event type. Every other type comes from the
|
||||
* consumer's `CalendarSchema`; this one is built in so a user can always file
|
||||
* an event as "Other" and pick a custom color for it.
|
||||
*/
|
||||
export const CHRONOS_OTHER_TYPE_ID = 'otros';
|
||||
|
||||
/**
|
||||
* How an event occupies time. Derived from the event shape, never stored:
|
||||
* - `all-day` → `allDay: true` (covers whole days across the date span).
|
||||
* - `single` → one continuous timed block (`start`/`end` carry the time).
|
||||
* - `split` → multiple time bands repeated on each day of the date span
|
||||
* (`parts` holds them; `start`/`end` are date-only).
|
||||
*/
|
||||
export type ChronosTimeMode = 'all-day' | 'single' | 'split';
|
||||
|
||||
/** One time band of a `split` event — a start→end slice within a day. */
|
||||
export interface ChronosTimeBand {
|
||||
start: Time;
|
||||
end: Time;
|
||||
}
|
||||
|
||||
/** Resolve an event's time mode from its shape (single source of truth). */
|
||||
export function eventTimeMode(event: ChronosEvent): ChronosTimeMode {
|
||||
if (event.allDay) return 'all-day';
|
||||
if (event.parts && event.parts.length > 0) return 'split';
|
||||
return 'single';
|
||||
}
|
||||
|
||||
/**
|
||||
* One event category in a `CalendarSchema`. `color` is an eidos palette SCALE
|
||||
* name (`blue`, `grass`, `tomato`, `amber`, `violet`, …) — NEVER an intent role
|
||||
* (affirm/risk/…). Intent is an evaluative channel; event categories are not
|
||||
* evaluative, so they use the neutral palette scales.
|
||||
*/
|
||||
export interface ChronosEventType {
|
||||
id: string;
|
||||
label: string;
|
||||
/** CSS color value (hex / oklch / rgb). Composited per-chip via `color-mix`. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The color/category contract a consumer passes to Chronos. Event types are
|
||||
* consumer-defined; `predefinedColors` is the palette offered by the editor's
|
||||
* color picker when an event is filed under the canonical "Other" type.
|
||||
*/
|
||||
export interface CalendarSchema {
|
||||
eventTypes: readonly ChronosEventType[];
|
||||
/** CSS color values offered in the editor's picker for "Other" events. */
|
||||
predefinedColors: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar event. `start`/`end` are `$libs/days` values (never raw `Date`)
|
||||
* so zone + DST survive. `end` is INCLUSIVE of the last covered instant
|
||||
* (documented; react-big-calendar's exclusive end is a perennial confusion).
|
||||
*/
|
||||
export interface ChronosEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
/** all-day → `CalendarDate`; timed → `ZonedDateTime`. */
|
||||
start: DateValue;
|
||||
end: DateValue;
|
||||
allDay?: boolean;
|
||||
/** References a `CalendarSchema` event type id, or `CHRONOS_OTHER_TYPE_ID`. */
|
||||
typeId?: string;
|
||||
/** Custom color (scale name or CSS color). Only used for the "Other" type. */
|
||||
color?: string;
|
||||
/**
|
||||
* `split` mode: time bands repeated on every day of `[start, end]`. When
|
||||
* present and non-empty the event is "split"; `start`/`end` are date-only and
|
||||
* the times live here. Empty / absent → `all-day` or `single` (see
|
||||
* {@link eventTimeMode}). Bands are kept ordered + non-overlapping.
|
||||
*/
|
||||
parts?: ChronosTimeBand[];
|
||||
/** When false, the event cannot be dragged / resized / deleted. */
|
||||
editable?: boolean;
|
||||
extendedProps?: Record<string, unknown>;
|
||||
|
||||
// — roadmap (declared for forward-compat; ignored by v1 engine) —
|
||||
rrule?: string;
|
||||
resourceId?: string;
|
||||
}
|
||||
|
||||
/** Immutable snapshot of the mutable calendar state for the history stack. */
|
||||
export interface ChronosSnapshot {
|
||||
readonly events: readonly ChronosEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Command pattern (precedent: `words/engine/operations/commands.ts`). Each
|
||||
* command is plain JSON → `applyCommand` returns a new snapshot + a `changed`
|
||||
* flag. This is what makes undo/redo a thin snapshot stack.
|
||||
*/
|
||||
export type ChronosCommand =
|
||||
| { type: 'create'; event: ChronosEvent }
|
||||
| { type: 'move'; id: string; start: DateValue; end: DateValue }
|
||||
| { type: 'resize'; id: string; end: DateValue }
|
||||
| { type: 'update'; id: string; patch: Partial<Omit<ChronosEvent, 'id'>> }
|
||||
| { type: 'delete'; id: string };
|
||||
|
||||
/** Result of applying a command: the next snapshot + whether anything changed. */
|
||||
export interface ChronosCommandResult {
|
||||
snapshot: ChronosSnapshot;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single contiguous segment of an event inside ONE week row. A multi-day
|
||||
* event spanning a week boundary produces multiple segments (one per row).
|
||||
* This is the unit the Month view renders as a bar/chip.
|
||||
*/
|
||||
export interface ChronosSegment {
|
||||
event: ChronosEvent;
|
||||
/** Index of the week row in the month grid (0-based). */
|
||||
weekIndex: number;
|
||||
/** Column where the segment starts within the row (0 = first weekday). */
|
||||
startCol: number;
|
||||
/** Number of columns the segment spans (1..7). */
|
||||
span: number;
|
||||
/** The event's lane (vertical track) within the cells it covers. */
|
||||
lane: number;
|
||||
/** True when the event began before this row's first visible day. */
|
||||
continuesBefore: boolean;
|
||||
/** True when the event continues past this row's last visible day. */
|
||||
continuesAfter: boolean;
|
||||
}
|
||||
|
||||
/** Per-cell overflow info: how many lanes were hidden behind "+N more". */
|
||||
export interface ChronosCellOverflow {
|
||||
/** ISO date string of the cell. */
|
||||
date: string;
|
||||
/** Number of events not shown because they exceed `maxLanes`. */
|
||||
hiddenCount: number;
|
||||
}
|
||||
|
||||
/** The laid-out Month: segments to render + overflow tallies per cell. */
|
||||
export interface ChronosMonthLayout {
|
||||
segments: readonly ChronosSegment[];
|
||||
overflow: readonly ChronosCellOverflow[];
|
||||
/** Max lane index actually used (for sizing). */
|
||||
usedLanes: number;
|
||||
}
|
||||
|
||||
export interface ChronosLayoutOpts {
|
||||
/** Max lanes rendered per cell before collapsing into "+N more". */
|
||||
maxLanes: number;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
export { default as Provider } from './components/chronos.svelte';
|
||||
export { ChronosProvider, chronosAttrs } from './internals';
|
||||
export type { ChronosOpts, ChronosEditing } from './internals';
|
||||
export type {
|
||||
ChronosProps,
|
||||
ChronosEditorContext,
|
||||
ChronosTooltipContext,
|
||||
ChronosEvent,
|
||||
ChronosEventType,
|
||||
ChronosRange,
|
||||
ChronosView,
|
||||
ChronosAgendaGroup,
|
||||
ChronosTimeBand,
|
||||
ChronosTimeMode,
|
||||
CalendarSchema
|
||||
} from './types';
|
||||
export { eventTimeMode } from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,7 @@
|
||||
// Public barrel must not re-export the provider implementation by filename.
|
||||
export {
|
||||
ChronosProvider,
|
||||
chronosAttrs,
|
||||
type ChronosOpts,
|
||||
type ChronosEditing
|
||||
} from './chronos-provider.svelte';
|
||||
@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import Chronos, {
|
||||
type ChronosEvent,
|
||||
type ChronosRange,
|
||||
type CalendarSchema,
|
||||
type ChronosEditorContext
|
||||
} from '$uix/eidos/components/chronos';
|
||||
import type { ChronosColor, ChronosSize } from '$uix/eidos/components/chronos';
|
||||
import { CalendarDate, CalendarDateTime, type WeekStartsOn } from '$libs/days';
|
||||
// Framework component reused INSIDE the consumer-defined custom editor details.
|
||||
import { Field } from '$uix/eidos/components/field';
|
||||
|
||||
const M = (d: number) => new CalendarDate(2026, 6, d);
|
||||
const DT = (d: number, h: number, min = 0) => new CalendarDateTime(2026, 6, d, h, min);
|
||||
|
||||
const schema: CalendarSchema = {
|
||||
eventTypes: [
|
||||
{ id: 'work', label: 'Work', color: '#3b82f6' },
|
||||
{ id: 'meeting', label: 'Meeting', color: '#8b5cf6' },
|
||||
{ id: 'personal', label: 'Personal', color: '#22c55e' },
|
||||
{ id: 'travel', label: 'Travel', color: '#f59e0b' }
|
||||
],
|
||||
predefinedColors: [
|
||||
'#3b82f6',
|
||||
'#8b5cf6',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#a855f7',
|
||||
'#14b8a6',
|
||||
'#6b7280'
|
||||
]
|
||||
};
|
||||
|
||||
const initialEvents: ChronosEvent[] = [
|
||||
{ id: 'e1', title: 'Standup', start: M(2), end: M(2), allDay: true, typeId: 'work' },
|
||||
{ id: 'e2', title: 'Design review', start: M(8), end: M(8), allDay: true, typeId: 'meeting' },
|
||||
{ id: 'e3', title: 'Conference', start: M(8), end: M(11), allDay: true, typeId: 'travel' },
|
||||
{ id: 'e4', title: 'Launch', start: M(15), end: M(15), allDay: true, typeId: 'work' },
|
||||
{ id: 'e5', title: 'Retro', start: M(18), end: M(18), allDay: true, typeId: 'meeting' },
|
||||
// A busy day to exercise "+N more".
|
||||
{ id: 'e6', title: 'Sync A', start: M(22), end: M(22), allDay: true, typeId: 'work' },
|
||||
{ id: 'e7', title: 'Sync B', start: M(22), end: M(22), allDay: true, typeId: 'meeting' },
|
||||
{ id: 'e8', title: 'Sync C', start: M(22), end: M(22), allDay: true, typeId: 'personal' },
|
||||
{ id: 'e9', title: '1:1', start: M(22), end: M(22), allDay: true, typeId: 'meeting' },
|
||||
{ id: 'e10', title: 'Trip', start: M(24), end: M(30), allDay: true, typeId: 'travel' },
|
||||
// Timed events so Week / Day views (and their drag-drop) have content.
|
||||
{ id: 't1', title: 'Standup', start: DT(2, 9, 0), end: DT(2, 9, 30), typeId: 'work' },
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Design review',
|
||||
start: DT(2, 11, 0),
|
||||
end: DT(2, 12, 30),
|
||||
typeId: 'meeting'
|
||||
},
|
||||
{ id: 't3', title: 'Lunch', start: DT(3, 13, 0), end: DT(3, 14, 0), typeId: 'personal' },
|
||||
{ id: 't4', title: 'Flight', start: DT(4, 16, 0), end: DT(4, 18, 0), typeId: 'travel' },
|
||||
// Timed events on the busy day (22) so the "+N more" day-peek shows a grid.
|
||||
{ id: 't5', title: 'Planning', start: DT(22, 10, 0), end: DT(22, 11, 0), typeId: 'work' },
|
||||
{ id: 't6', title: 'Review', start: DT(22, 11, 30), end: DT(22, 12, 30), typeId: 'meeting' },
|
||||
{ id: 't7', title: 'Demo call', start: DT(22, 15, 0), end: DT(22, 16, 0), typeId: 'personal' }
|
||||
];
|
||||
|
||||
let size = $state<ChronosSize>('md');
|
||||
let color = $state<ChronosColor>('primary');
|
||||
let weekStart = $state<'locale' | '0' | '1'>('locale');
|
||||
let fixedWeeks = $state(true);
|
||||
let maxEventsPerCell = $state(3);
|
||||
let editable = $state(true);
|
||||
let readonly = $state(false);
|
||||
let customEditor = $state(false);
|
||||
let trace = $state<string[]>([]);
|
||||
|
||||
const weekStartsOn = $derived(
|
||||
weekStart === 'locale' ? undefined : (Number(weekStart) as WeekStartsOn)
|
||||
);
|
||||
|
||||
function log(msg: string): void {
|
||||
trace = [msg, ...trace].slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-uix-canvas-inner
|
||||
style="display:flex; flex-direction:column; gap:1.5rem; padding:1.5rem; max-width:1100px;"
|
||||
>
|
||||
<header>
|
||||
<h1 style="margin:0 0 0.25rem;">Chronos</h1>
|
||||
<p style="margin:0; opacity:0.7;">
|
||||
Event calendar — Month view. Hover a day and click <strong>+</strong> to create, click an event
|
||||
to edit, undo/redo in the toolbar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end;">
|
||||
<label style="display:flex; flex-direction:column; gap:0.25rem;">
|
||||
Size
|
||||
<select bind:value={size}>
|
||||
<option value="sm">sm</option>
|
||||
<option value="md">md</option>
|
||||
<option value="lg">lg</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex; flex-direction:column; gap:0.25rem;">
|
||||
Accent
|
||||
<select bind:value={color}>
|
||||
<option value="primary">primary</option>
|
||||
<option value="secondary">secondary</option>
|
||||
<option value="neutral">neutral</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex; flex-direction:column; gap:0.25rem;">
|
||||
Week start
|
||||
<select bind:value={weekStart}>
|
||||
<option value="locale">locale</option>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex; flex-direction:column; gap:0.25rem;">
|
||||
Max / cell
|
||||
<input type="number" min="1" max="6" bind:value={maxEventsPerCell} style="width:4rem;" />
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
||||
<input type="checkbox" bind:checked={fixedWeeks} /> Fixed weeks
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
||||
<input type="checkbox" bind:checked={editable} /> Editable
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
||||
<input type="checkbox" bind:checked={readonly} /> Readonly
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
||||
<input type="checkbox" bind:checked={customEditor} /> Custom editor
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Chronos
|
||||
date={M(1)}
|
||||
events={initialEvents}
|
||||
{schema}
|
||||
{size}
|
||||
{color}
|
||||
{weekStartsOn}
|
||||
{fixedWeeks}
|
||||
{maxEventsPerCell}
|
||||
{editable}
|
||||
{readonly}
|
||||
onEventCreate={(e: ChronosEvent) => log(`create: ${e.title || '(untitled)'}`)}
|
||||
onEventUpdate={(e: ChronosEvent) => log(`update: ${e.title || '(untitled)'}`)}
|
||||
onEventDelete={(id: string) => log(`delete: ${id}`)}
|
||||
onRangeChange={(r: ChronosRange) => log(`range → ${r.start.toString()}`)}
|
||||
onEventClick={(e: ChronosEvent) => log(`click: ${e.title || '(untitled)'}`)}
|
||||
editor={customEditor ? customEventEditor : undefined}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style="font-size:1rem; margin:0 0 0.5rem;">Trace</h2>
|
||||
<p style="opacity:0.6; margin:0 0 0.5rem; font-size:0.8rem;">
|
||||
Drag a chip to another day (Month) or another time slot (Week / Day). Toggle
|
||||
<strong>Custom editor</strong> to replace the dialog body with an app-defined form.
|
||||
</p>
|
||||
{#if trace.length === 0}
|
||||
<p style="opacity:0.6; margin:0;">Interact with the calendar to see callbacks.</p>
|
||||
{:else}
|
||||
<ul style="margin:0; padding-left:1.2rem; font-family:monospace; font-size:0.85rem;">
|
||||
{#each trace as line, i (i)}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Consumer-defined editor DETAILS. Chronos owns the dialog header, the "when"
|
||||
controls (date span + time mode + bands) and the footer (save/cancel/delete);
|
||||
the host app only decides the detail fields shown here. `ctx.draft` is the live
|
||||
draft; mutations flow straight into the calendar engine on save.
|
||||
-->
|
||||
{#snippet customEventEditor(ctx: ChronosEditorContext)}
|
||||
<p style="margin:0 0 0.25rem; font-size:0.8rem; opacity:0.7;">
|
||||
These detail fields are defined by the host application, not by Chronos.
|
||||
</p>
|
||||
|
||||
<Field>
|
||||
<Field.Label>Title</Field.Label>
|
||||
<Field.Control>
|
||||
<Field.Input
|
||||
type="text"
|
||||
bind:value={ctx.draft.title}
|
||||
placeholder="Event title"
|
||||
readonly={!ctx.editable}
|
||||
/>
|
||||
</Field.Control>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Field.Label>Notes (app-specific field)</Field.Label>
|
||||
<Field.Control>
|
||||
<Field.Input
|
||||
type="text"
|
||||
value={(ctx.draft.extendedProps?.notes as string) ?? ''}
|
||||
readonly={!ctx.editable}
|
||||
oninput={(e) =>
|
||||
((ctx.draft.extendedProps ??= {}).notes = (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Anything the app needs"
|
||||
/>
|
||||
</Field.Control>
|
||||
</Field>
|
||||
{/snippet}
|
||||
Loading…
Reference in new issue