diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index b53e8607e..15c900c28 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -256,6 +256,48 @@ commitAffordance(result: AffordancePreview): Afford ```ts computeAnchoredFloatingPosition(input: AnchoredFloatingPositionInput): AnchoredFloatingPosition ``` +## `contentInteractionAffordance` + +```ts +contentInteractionAffordance(input: ContentInteractionInput): ContentInteractionAffordance +``` +## `ContentInteractionAffordance` + +```ts +type ContentInteractionAffordance = { + readonly role: ContentInteractionRole; + readonly phase: ContentInteractionPhase; + readonly selected: boolean; + readonly primary: boolean; + readonly elevated: boolean; +}; +``` +## `ContentInteractionInput` + +```ts +type ContentInteractionInput = + | { + readonly role: "content"; + readonly selected?: boolean; + readonly primary?: boolean; + readonly active?: boolean; + readonly dragging?: boolean; + } + | { + readonly role: "drop-target" | "insertion"; + readonly active: boolean; + }; +``` +## `ContentInteractionPhase` + +```ts +type ContentInteractionPhase = "rest" | "active" | "dragging"; +``` +## `ContentInteractionRole` + +```ts +type ContentInteractionRole = "content" | "drop-target" | "insertion"; +``` ## `contextMenuAffordance` ```ts diff --git a/docs/api-reference/calendar.md b/docs/api-reference/calendar.md index b71eb7a2f..ddd6773cb 100644 --- a/docs/api-reference/calendar.md +++ b/docs/api-reference/calendar.md @@ -604,6 +604,11 @@ RangeCalendar(props: { readonly label: string; readonly value: DateRangeValue | ```ts shiftVisibleDate(visibleDate: string, period: CalendarPeriod, direction: 1 | -1): string ``` +## `startOfCalendarWeek` + +```ts +startOfCalendarWeek(date: string): string +``` ## `startOfIsoWeek` ```ts @@ -650,5 +655,6 @@ visiblePeriodLabel(period: CalendarPeriod, visibleDate: string, options?: Visibl type VisiblePeriodLabelOptions = { readonly monthNames?: ReadonlyArray; readonly weekSeparator?: string; + readonly dateStyle?: "iso" | "named"; }; ``` diff --git a/docs/api-reference/ui-primitives-react.md b/docs/api-reference/ui-primitives-react.md index 35964e2df..ceb8a738a 100644 --- a/docs/api-reference/ui-primitives-react.md +++ b/docs/api-reference/ui-primitives-react.md @@ -56,6 +56,38 @@ Command(props: Omit, "title"> & FocusPre ```ts type CommandKind = "primary" | "secondary" | "danger"; ``` +## `contentInteractionAttributes` + +```ts +contentInteractionAttributes(input: ContentInteractionInput): ContentInteractionAttributes +``` +## `ContentInteractionAttributes` + +```ts +type ContentInteractionAttributes = { + readonly "data-ui-interaction": "content" | "drop-target" | "insertion"; + readonly "data-ui-interaction-phase": "rest" | "active" | "dragging"; + readonly "data-selected"?: "true" | "false"; + readonly "data-primary"?: "true"; + readonly "data-elevated"?: "true"; +}; +``` +## `ContentInteractionInput` + +```ts +type ContentInteractionInput = + | { + readonly role: "content"; + readonly selected?: boolean; + readonly primary?: boolean; + readonly active?: boolean; + readonly dragging?: boolean; + } + | { + readonly role: "drop-target" | "insertion"; + readonly active: boolean; + }; +``` ## `ContextualControls` ```ts @@ -129,7 +161,7 @@ FileDropRegion(props: Omit, "onDrop"> & { readonl ## `GridCell` ```ts -GridCell(props: TdHTMLAttributes & { readonly selected: boolean; readonly focus?: boolean; }): import("/node_modules/@types/react/jsx-runtime").JSX.Element +GridCell(props: TdHTMLAttributes & { readonly selected: boolean; readonly primary?: boolean; readonly focus?: boolean; readonly active?: boolean; readonly dragging?: boolean; }): import("/node_modules/@types/react/jsx-runtime").JSX.Element ``` ## `InteractionHandleBindingOptions` @@ -206,7 +238,7 @@ ProductInspector(props: HTMLAttributes): ReactNode ## `ProductShell` ```ts -ProductShell(props: HTMLAttributes & { readonly toolbar?: ReactNode; readonly toolbarLabel?: string; readonly inspector?: ReactNode; readonly canvasClassName?: string; readonly fill?: boolean; }): ReactNode +ProductShell(props: HTMLAttributes & { readonly toolbar?: ReactNode; readonly toolbarLabel?: string; readonly toolbarPresentation?: "attached" | "floating"; readonly inspector?: ReactNode; readonly canvasClassName?: string; readonly fill?: boolean; }): ReactNode ``` ## `ResizeHandle` @@ -241,7 +273,10 @@ SelectableItem(props: SelectableItemProps): type SelectableItemProps = { readonly as?: T; readonly selected: boolean; + readonly primary?: boolean; readonly focus?: boolean; + readonly active?: boolean; + readonly dragging?: boolean; } & ControlAffordanceProps & Omit, "as" | "data-selected" | "data-focus">; ``` ## `TabOption` diff --git a/docs/public/ui-primitives.md b/docs/public/ui-primitives.md index c19be5782..a205b4a04 100644 --- a/docs/public/ui-primitives.md +++ b/docs/public/ui-primitives.md @@ -125,12 +125,30 @@ icon button은 독립 역할이 아니므로 공개 primitive가 아닙니다. `expanded`와 `controls`를 disclosure ARIA에 연결하며 표현 markup은 Host가 children으로 구성합니다. -`SelectableItem`은 polymorphic element에 `selected/focus` data state와 +`SelectableItem`은 polymorphic element에 `selected/active/dragging/focus` data state와 `data-ui-control` styling slot을 제공합니다. role, ARIA selection과 roving focus는 해당 widget의 semantic primitive 또는 Web Adapter가 소유합니다. 제품별 copy와 brand token은 Host 책임이지만 최소 hit target, focus-visible, disabled/pressed와 tooltip 가시성은 UI Primitive styling hook의 불변식입니다. +### Content interaction grammar + +제품 콘텐츠의 지속 선택, 순간 누름, 이동, 드롭 대상과 삽입 위치는 하나의 상태 +어휘를 사용합니다. `selected`는 레이아웃을 바꾸지 않는 안쪽 윤곽과 tonal fill, +`active`는 더 강한 안쪽 피드백, `dragging`은 실제 이동을 뜻하는 elevation을 +추가합니다. 빈 칸은 선택된 콘텐츠가 아니라 `insertion`입니다. + +```tsx +Card +Cell +
+
+``` + +focus-visible outer ring은 이 상태들과 독립이며, reduced motion에서는 상태 정보는 +유지하고 전환 시간만 제거합니다. toolbar의 pressed/toggle, 입력 caret, 브라우저의 +native text selection은 이 콘텐츠 문법에 포함하지 않습니다. + ### `ControlAffordance` 제품은 semantic role을 복제하지 않고 control이 현재 composition에서 어떻게 diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 619a28467..668997582 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -48,6 +48,10 @@ useEditing({ `historyAffordance(snapshot).hand` exposes the typed Undo/Redo availability map directly. The editing runtime still owns history state and execution. +`contentInteractionAffordance` is the canonical product-content state model. +It distinguishes persistent selection, transient active feedback, movement, +drop targets, and insertion positions without owning DOM or product color. + `createTypeaheadSession`, `createRenameSession`, and `createLineFocusSession` own the reusable state that spans several events. Product selection and rename Intents remain callbacks supplied by the host. diff --git a/packages/json-document-affordance/src/content-interaction.ts b/packages/json-document-affordance/src/content-interaction.ts new file mode 100644 index 000000000..5d035380d --- /dev/null +++ b/packages/json-document-affordance/src/content-interaction.ts @@ -0,0 +1,49 @@ +export type ContentInteractionRole = "content" | "drop-target" | "insertion"; + +export type ContentInteractionPhase = "rest" | "active" | "dragging"; + +export type ContentInteractionInput = + | { + readonly role: "content"; + readonly selected?: boolean; + readonly primary?: boolean; + readonly active?: boolean; + readonly dragging?: boolean; + } + | { + readonly role: "drop-target" | "insertion"; + readonly active: boolean; + }; + +export type ContentInteractionAffordance = { + readonly role: ContentInteractionRole; + readonly phase: ContentInteractionPhase; + readonly selected: boolean; + readonly primary: boolean; + readonly elevated: boolean; +}; + +/** Normalizes persistent selection and transient direct-manipulation feedback. */ +export function contentInteractionAffordance( + input: ContentInteractionInput, +): ContentInteractionAffordance { + if (input.role !== "content") { + return { + role: input.role, + phase: input.active ? "active" : "rest", + selected: false, + primary: false, + elevated: false, + }; + } + const phase = input.dragging ? "dragging" : input.active ? "active" : "rest"; + const selected = input.selected ?? false; + const primary = selected && (input.primary ?? false); + return { + role: input.role, + phase, + selected, + primary, + elevated: phase === "dragging" || primary, + }; +} diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index ccf289176..78881e675 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -24,6 +24,13 @@ export { export { createViewportPositionSession } from "./viewport-position.js"; export { computeAnchoredFloatingPosition } from "./anchored-floating-position.js"; export { contextualAffordance } from "./contextual.js"; +export { contentInteractionAffordance } from "./content-interaction.js"; +export type { + ContentInteractionAffordance, + ContentInteractionInput, + ContentInteractionPhase, + ContentInteractionRole, +} from "./content-interaction.js"; export type { AnchoredFloatingPosition, AnchoredFloatingPositionInput, diff --git a/packages/json-document-affordance/tests/content-interaction.test.ts b/packages/json-document-affordance/tests/content-interaction.test.ts new file mode 100644 index 000000000..0b29f7ccc --- /dev/null +++ b/packages/json-document-affordance/tests/content-interaction.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { contentInteractionAffordance } from "../src/index.js"; + +describe("contentInteractionAffordance", () => { + test("keeps selection persistent while active and dragging phases change", () => { + expect(contentInteractionAffordance({ role: "content", selected: true })).toEqual({ + role: "content", phase: "rest", selected: true, primary: false, elevated: false, + }); + expect(contentInteractionAffordance({ role: "content", selected: true, active: true })).toEqual({ + role: "content", phase: "active", selected: true, primary: false, elevated: false, + }); + expect(contentInteractionAffordance({ role: "content", selected: true, active: true, dragging: true })).toEqual({ + role: "content", phase: "dragging", selected: true, primary: false, elevated: true, + }); + expect(contentInteractionAffordance({ role: "content", selected: true, primary: true })).toEqual({ + role: "content", phase: "rest", selected: true, primary: true, elevated: true, + }); + }); + + test("keeps insertion and drop targets distinct from selected content", () => { + expect(contentInteractionAffordance({ role: "insertion", active: true })).toEqual({ + role: "insertion", phase: "active", selected: false, primary: false, elevated: false, + }); + expect(contentInteractionAffordance({ role: "drop-target", active: true })).toEqual({ + role: "drop-target", phase: "active", selected: false, primary: false, elevated: false, + }); + }); +}); diff --git a/packages/json-document-calendar/src/calendar-month-grid.tsx b/packages/json-document-calendar/src/calendar-month-grid.tsx index 20bb30d8a..9dd398be1 100644 --- a/packages/json-document-calendar/src/calendar-month-grid.tsx +++ b/packages/json-document-calendar/src/calendar-month-grid.tsx @@ -20,6 +20,7 @@ import { } from "@interactive-os/json-document-editing"; import { selectionModeFromModifiers } from "@interactive-os/json-document-react"; import { + contentInteractionAttributes, Command, ResizeHandle, SelectableItem, @@ -182,6 +183,7 @@ export const CalendarMonthGrid = forwardRef point.eventId === item.event.id) ?? false} primary={hand.isPrimaryOccurrence(item.event.id, item.event.start)} preview={item.event.id === "preview"} onPointerDown={(event) => { @@ -245,7 +247,7 @@ function MonthDayCell(input: { aria-current={cell.date === props.today ? "date" : undefined} data-calendar-day={cell.date} tabIndex={-1} - data-selected={selectedDate === cell.date ? "true" : undefined} + {...contentInteractionAttributes({ role: "insertion", active: selectedDate === cell.date })} data-ui-affordance={props.affordances.dateCell} className={joinClasses( props.classNames.day, @@ -326,6 +328,7 @@ function MonthEvent(props: { readonly classNames: CalendarMonthGridClassNames; readonly color: string; readonly selected: boolean; + readonly dragging?: boolean; readonly primary: boolean; readonly preview?: boolean; readonly onPointerDown?: (event: PointerEvent) => void; @@ -338,7 +341,8 @@ function MonthEvent(props: { {props.cells.map((cell) => (
- {props.weekdays[cell.weekday - 1]} + {props.weekdays[cell.weekday % 7]} {cell.day}
))} @@ -140,7 +140,7 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode { key={`allday-${day}`} data-calendar-allday-day={day} tabIndex={-1} - data-selected={selectedSlot === day ? "true" : undefined} + {...contentInteractionAttributes({ role: "insertion", active: selectedSlot === day })} data-ui-affordance={props.affordances.allDayCell} className={props.classNames.allDayCell} style={{ gridRow: `2 / span ${allDayLaneCount}` }} @@ -162,7 +162,8 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode { point.eventId === item.event.id) ?? false} aria-label={item.event.title} data-calendar-color={props.getEventColor(item.event)} data-calendar-move-surface="true" @@ -210,8 +211,8 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode { className={props.classNames.viewportAnchor} style={{ transform: `translateY(${(props.workHourStart - props.hourStart) * props.pixelsPerHour}px)` }} /> - {Array.from({ length: props.hourEnd - props.hourStart }, (_, index) => props.hourStart + index).map((hour) => ( -
+ {Array.from({ length: props.hourEnd - props.hourStart }, (_, index) => index === 0 ? null : props.hourStart + index).map((hour) => hour === null ? null : ( +
{props.labels.hour(hour)}
))} @@ -252,6 +253,7 @@ function TimeColumn(input: { {input.selectedSlot?.startsWith(`${day}T`) ? (
point.eventId === item.event.id) ?? false} aria-label={item.event.title} data-calendar-event="" data-calendar-color={props.getEventColor(item.event)} diff --git a/packages/json-document-calendar/src/date-values.ts b/packages/json-document-calendar/src/date-values.ts index 6558eda68..89973c2fe 100644 --- a/packages/json-document-calendar/src/date-values.ts +++ b/packages/json-document-calendar/src/date-values.ts @@ -7,6 +7,7 @@ export type DateRangeValue = { readonly start: string; readonly end: string }; export type VisiblePeriodLabelOptions = { readonly monthNames?: ReadonlyArray; readonly weekSeparator?: string; + readonly dateStyle?: "iso" | "named"; }; const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; @@ -98,6 +99,11 @@ export function startOfIsoWeek(date: string): string { return addCalendarDays(date, 1 - weekday); } +export function startOfCalendarWeek(date: string): string { + const weekday = isoWeekday(date); + return addCalendarDays(date, -(weekday % 7)); +} + export function startOfMonth(date: string): string { return Temporal.PlainDate.from(date).with({ day: 1 }).toString(); } @@ -163,12 +169,12 @@ export type CalendarCellInterval = { readonly start: string; readonly end: strin export function calendarCells(period: CalendarPeriod, visibleDate: string): ReadonlyArray { if (period === "day") return [cell(visibleDate, true)]; if (period === "week") { - const start = startOfIsoWeek(visibleDate); + const start = startOfCalendarWeek(visibleDate); return Array.from({ length: 7 }, (_, index) => cell(addCalendarDays(start, index), true)); } if (period === "month") { const monthStart = startOfMonth(visibleDate); - const gridStart = startOfIsoWeek(monthStart); + const gridStart = startOfCalendarWeek(monthStart); const month = civil(visibleDate).month; const year = civil(visibleDate).year; return Array.from({ length: 42 }, (_, index) => { @@ -201,10 +207,13 @@ export function visiblePeriodLabel( visibleDate: string, options: VisiblePeriodLabelOptions = {}, ): string { - if (period === "day") return visibleDate; + if (period === "day") { + return options.dateStyle === "named" ? namedDayLabel(visibleDate, options.monthNames) : visibleDate; + } const parts = civil(visibleDate); if (period === "week") { - const start = startOfIsoWeek(visibleDate); + const start = startOfCalendarWeek(visibleDate); + if (options.dateStyle === "named") return namedWeekLabel(start, addCalendarDays(start, 6), options.monthNames); if (options.weekSeparator === undefined) return `${start} · week`; return `${start}${options.weekSeparator}${addCalendarDays(start, 6)}`; } @@ -216,6 +225,24 @@ export function visiblePeriodLabel( return padYear(parts.year); } +function namedDayLabel(date: string, monthNames: ReadonlyArray | undefined): string { + const parts = civil(date); + const month = monthNames?.[parts.month - 1] ?? pad(parts.month); + return `${month} ${parts.day}, ${padYear(parts.year)}`; +} + +function namedWeekLabel(start: string, end: string, monthNames: ReadonlyArray | undefined): string { + const first = civil(start); + const last = civil(end); + const firstMonth = monthNames?.[first.month - 1] ?? pad(first.month); + const lastMonth = monthNames?.[last.month - 1] ?? pad(last.month); + if (first.year === last.year && first.month === last.month) { + return `${firstMonth} ${first.day} – ${last.day}, ${padYear(first.year)}`; + } + const firstYear = first.year === last.year ? "" : `, ${padYear(first.year)}`; + return `${firstMonth} ${first.day}${firstYear} – ${lastMonth} ${last.day}, ${padYear(last.year)}`; +} + function cell(date: string, inVisiblePeriod: boolean): CalendarCell { const parsed = Temporal.PlainDate.from(date); return { date, day: parsed.day, inVisiblePeriod, weekday: parsed.dayOfWeek }; diff --git a/packages/json-document-calendar/src/index.ts b/packages/json-document-calendar/src/index.ts index 003e78abc..1bd8a063c 100644 --- a/packages/json-document-calendar/src/index.ts +++ b/packages/json-document-calendar/src/index.ts @@ -51,6 +51,7 @@ export { calendarYearMonths, parseHtmlDateValue, startOfIsoWeek, + startOfCalendarWeek, startOfYear, visiblePeriodLabel, type CalendarCell, diff --git a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts index 1fc18becd..940b88829 100644 --- a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts +++ b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts @@ -130,7 +130,10 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return; } setHoveredTime(null); - const targetInstant = instantAt(day, event.clientY, grid); + const targetGridMinutes = calendarMinutesFromWebGrid(event.clientY, grid.getBoundingClientRect(), { + hourStart: policy.hourStart, hourEnd: policy.hourEnd, stepMinutes: policy.stepMinutes, + }); + const targetInstant = calendarInstantAt(day, targetGridMinutes); if (targetInstant === null) return; const next = timePointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetInstant !== state.originInstant @@ -139,8 +142,11 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetInstant, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { - if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: next.originInstant } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: targetInstant } })); + const originAnchor = next.dragSource.anchor.occurrenceStart; + const move = interpretCalendarTimeGridPointer(next); + if (move?.type !== "event.move") return; + if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: originAnchor } }); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: move.start } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setTimePreview(next); } diff --git a/packages/json-document-calendar/tests/calendar-time-grid.test.tsx b/packages/json-document-calendar/tests/calendar-time-grid.test.tsx index 5709b4c07..b39ad4273 100644 --- a/packages/json-document-calendar/tests/calendar-time-grid.test.tsx +++ b/packages/json-document-calendar/tests/calendar-time-grid.test.tsx @@ -66,7 +66,11 @@ describe("CalendarTimeGrid", () => { } render(); - expect(screen.getByRole("grid", { name: "Week" })).toBeTruthy(); + const grid = screen.getByRole("grid", { name: "Week" }); + expect(grid).toBeTruthy(); + expect((grid.firstElementChild as HTMLElement).style.gridTemplateColumns).toBe("4rem repeat(7, minmax(4.5rem, 1fr))"); + expect(grid.querySelector('[data-calendar-hour="00"]')).toBeNull(); + expect((grid.querySelector('[data-calendar-hour="01"]') as HTMLElement).style.top).toBe("72px"); expect(screen.getAllByRole("columnheader")).toHaveLength(7); expect(screen.getByText("all-day")).toBeTruthy(); expect(screen.getByRole("presentation", { name: "Now" })).toBeTruthy(); diff --git a/packages/json-document-calendar/tests/date-controls.test.tsx b/packages/json-document-calendar/tests/date-controls.test.tsx index e92ad25d2..f4e5ce42c 100644 --- a/packages/json-document-calendar/tests/date-controls.test.tsx +++ b/packages/json-document-calendar/tests/date-controls.test.tsx @@ -59,40 +59,40 @@ describe("HTML date values", () => { ]); }); - test("projects day and ISO week periods as canonical calendar cells", () => { + test("projects day and Sunday-first week periods as canonical calendar cells", () => { expect(calendarCells("day", "2026-05-28")).toEqual([ { date: "2026-05-28", day: 28, inVisiblePeriod: true, weekday: 4 }, ]); const week = calendarCells("week", "2026-05-28"); expect(week.map((cell) => cell.date)).toEqual([ - "2026-05-25", "2026-05-26", "2026-05-27", "2026-05-28", - "2026-05-29", "2026-05-30", "2026-05-31", + "2026-05-24", "2026-05-25", "2026-05-26", "2026-05-27", + "2026-05-28", "2026-05-29", "2026-05-30", ]); - expect(week.map((cell) => cell.day)).toEqual([25, 26, 27, 28, 29, 30, 31]); + expect(week.map((cell) => cell.day)).toEqual([24, 25, 26, 27, 28, 29, 30]); }); - test("projects a month grid as six ISO week rows of seven cells", () => { + test("projects a month grid as six Sunday-first week rows of seven cells", () => { const weeks = calendarMonthWeeks("2026-05-25"); expect(weeks).toHaveLength(6); expect(weeks.every((week) => week.length === 7)).toBe(true); expect(weeks[0]?.map((cell) => cell.date)).toEqual([ - "2026-04-27", "2026-04-28", "2026-04-29", "2026-04-30", - "2026-05-01", "2026-05-02", "2026-05-03", + "2026-04-26", "2026-04-27", "2026-04-28", "2026-04-29", + "2026-04-30", "2026-05-01", "2026-05-02", ]); - expect(weeks[0]?.map((cell) => cell.day)).toEqual([27, 28, 29, 30, 1, 2, 3]); - expect(weeks[5]?.at(-1)?.date).toBe("2026-06-07"); + expect(weeks[0]?.map((cell) => cell.day)).toEqual([26, 27, 28, 29, 30, 1, 2]); + expect(weeks[5]?.at(-1)?.date).toBe("2026-06-06"); }); test("projects ordered calendar cells to a half-open query interval", () => { expect(calendarCellInterval([])).toBeNull(); expect(calendarCellInterval(calendarCells("month", "2026-05-25"))).toEqual({ - start: "2026-04-27", - end: "2026-06-08", + start: "2026-04-26", + end: "2026-06-07", }); }); test("preserves default labels and accepts Calendar toolbar copy policy", () => { - expect(visiblePeriodLabel("week", "2026-05-28")).toBe("2026-05-25 · week"); + expect(visiblePeriodLabel("week", "2026-05-28")).toBe("2026-05-24 · week"); expect(visiblePeriodLabel("month", "2026-05-28")).toBe("2026-05"); expect(visiblePeriodLabel("year", "2026-05-28")).toBe("2026"); @@ -101,9 +101,12 @@ describe("HTML date values", () => { weekSeparator: " – ", }; expect(visiblePeriodLabel("day", "2026-05-28", options)).toBe("2026-05-28"); - expect(visiblePeriodLabel("week", "2026-05-28", options)).toBe("2026-05-25 – 2026-05-31"); + expect(visiblePeriodLabel("week", "2026-05-28", options)).toBe("2026-05-24 – 2026-05-30"); expect(visiblePeriodLabel("month", "2026-05-28", options)).toBe("May 2026"); expect(visiblePeriodLabel("year", "2026-05-28", options)).toBe("2026"); + expect(visiblePeriodLabel("day", "2026-05-28", { ...options, dateStyle: "named" })).toBe("May 28, 2026"); + expect(visiblePeriodLabel("week", "2026-05-28", { ...options, dateStyle: "named" })).toBe("May 24 – 30, 2026"); + expect(visiblePeriodLabel("week", "2026-06-01", { ...options, dateStyle: "named" })).toBe("May 31 – Jun 6, 2026"); }); }); diff --git a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx index 2851e9a9d..999ec4bf4 100644 --- a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx +++ b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx @@ -210,17 +210,17 @@ describe("useCalendarHand", () => { releasePointerCapture: () => undefined, }; act(() => result.current.pointer.timePointerDown({ - button: 0, clientY: 540, currentTarget: target, pointerId: 7, + button: 0, clientY: 555, currentTarget: target, pointerId: 7, } as never, "2026-08-03", "standup", "2026-08-03T09:00", "2026-08-03T09:30", "body")); act(() => result.current.pointer.timePointerMove({ pointerId: 7, clientX: 50, clientY: 600, target } as never)); expect(result.current.hand.selectionDragPreview).not.toBeNull(); expect(result.current.hand.paintedEvents.map((item) => item.start)).toEqual([ - "2026-08-03T10:00", "2026-08-04T12:00", + "2026-08-03T09:45", "2026-08-04T11:45", ]); act(() => result.current.pointer.timePointerUp({ pointerId: 7 } as never)); expect(result.current.hand.selectionDragPreview).toBeNull(); expect(result.current.hand.document.events.map((item) => item.start)).toEqual([ - "2026-08-03T10:00", "2026-08-04T12:00", + "2026-08-03T09:45", "2026-08-04T11:45", ]); expect(result.current.hand.selectedOccurrences).toHaveLength(2); act(() => result.current.hand.undo()); diff --git a/packages/json-document-database/src/database-hand.tsx b/packages/json-document-database/src/database-hand.tsx index 07b106152..73615c69c 100644 --- a/packages/json-document-database/src/database-hand.tsx +++ b/packages/json-document-database/src/database-hand.tsx @@ -36,7 +36,7 @@ import { webGridCellAddressProps, } from "@interactive-os/json-document-web"; import { databaseDocumentFromZod } from "@interactive-os/json-document-zod"; -import { Check, Command, Toolbar, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; +import { Check, Command, GridCell, Toolbar, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; import type { InteractionHandleEvent } from "@interactive-os/json-document-affordance"; import type { ZodType } from "zod/v4"; import type { JSONValue } from "@interactive-os/json-document"; @@ -575,12 +575,11 @@ function DatabaseTableSurface>(props: Databa const hostRecord = hostRecordFor(record); const custom = props.renderCell?.[property.id]; return ( - >(props: Databa }} /> )} - + ); })} {hiddenProperties.map((property) => )} diff --git a/packages/json-document-database/styles.css b/packages/json-document-database/styles.css index e31d4d304..56cb849de 100644 --- a/packages/json-document-database/styles.css +++ b/packages/json-document-database/styles.css @@ -1,3 +1,5 @@ +@import "@interactive-os/json-document-ui-primitives-react/content-interaction.css"; + .jd-database { --jd-db-bg: #ffffff; --jd-db-canvas: #ffffff; @@ -8,6 +10,10 @@ --jd-db-accent-soft: #eeeeff; --jd-db-danger: #c2415d; --jd-db-radius: 0; + --jd-content-interaction-accent: var(--jd-db-accent); + --jd-content-interaction-fill: color-mix(in srgb, var(--jd-db-accent) 7%, transparent); + --jd-content-interaction-fill-active: color-mix(in srgb, var(--jd-db-accent) 12%, transparent); + --jd-content-interaction-contour: color-mix(in srgb, var(--jd-db-accent) 58%, transparent); color: var(--jd-db-text); overflow: hidden; border: 1px solid var(--jd-db-border); @@ -74,8 +80,6 @@ .jd-database td { min-width: 150px; border-bottom: 1px solid var(--jd-db-border); background: var(--jd-db-bg); padding: 0; } .jd-database tbody tr:last-child td { border-bottom: 0; } .jd-database td:hover { background: var(--jd-db-accent-soft); } -.jd-database td[data-selected="true"] { border: 1px solid var(--jd-db-accent); background: var(--jd-db-selection-bg, color-mix(in srgb, var(--jd-db-accent) 10%, white)); box-shadow: inset 0 0 0 1px var(--jd-db-accent); } -.jd-database td:focus { outline: 2px solid var(--jd-db-accent); outline-offset: -2px; } .jd-database td > input:not([type="checkbox"]), .jd-database td > select { width: 100%; min-height: 32px; border: 0; outline: 0; background: transparent; padding: 5px 8px; } .jd-database td > input:focus, .jd-database td > select:focus { box-shadow: inset 0 0 0 2px var(--jd-db-accent); } .jd-database td > input[type="checkbox"] { display: block; width: 17px; height: 17px; margin: 12px auto; accent-color: var(--jd-db-accent); } diff --git a/packages/json-document-ui-primitives-react/README.md b/packages/json-document-ui-primitives-react/README.md index 60292807b..ea2afd4c6 100644 --- a/packages/json-document-ui-primitives-react/README.md +++ b/packages/json-document-ui-primitives-react/README.md @@ -19,6 +19,16 @@ Products may declare the composition-specific exposure grammar through the public `ControlAffordance` vocabulary; primitives project it as `data-ui-affordance` without changing their semantic control role. +`SelectableItem`, `GridCell`, and `contentInteractionAttributes` project the +canonical content interaction grammar. Import `content-interaction.css` once, +then map its CSS variables to product tokens. Selection remains a quiet content +contour; the primary member of the selection set becomes control-ready through +low elevation, while `active` and `dragging` continue the same interaction +grammar without changing layout. + Calendar widgets and projections belong to `@interactive-os/json-document-calendar`. File metadata formatting belongs to `@interactive-os/json-document-file-intake`. `ProductShell` is composition over the role primitives, not another control role. +Use `toolbarPresentation="floating"` when a product's controls form a distinct +functional layer above edge-to-edge content; the default `attached` presentation +remains part of document flow. diff --git a/packages/json-document-ui-primitives-react/package.json b/packages/json-document-ui-primitives-react/package.json index 8ca2b65d7..3009b3438 100644 --- a/packages/json-document-ui-primitives-react/package.json +++ b/packages/json-document-ui-primitives-react/package.json @@ -4,7 +4,7 @@ "description": "Official minimalist React UI primitives for json-document Hands.", "type": "module", "license": "MIT", - "sideEffects": false, + "sideEffects": ["./dist/content-interaction.css"], "main": "./dist/index.js", "types": "./dist/index.d.ts", "repository": { @@ -14,10 +14,13 @@ }, "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./content-interaction.css": "./dist/content-interaction.css" + }, "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && tsc -b tsconfig.json", + "build": "npm run clean && tsc -b tsconfig.json && cp src/content-interaction.css dist/content-interaction.css", "test": "vitest run --config vitest.config.ts", "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", diff --git a/packages/json-document-ui-primitives-react/src/content-interaction.css b/packages/json-document-ui-primitives-react/src/content-interaction.css new file mode 100644 index 000000000..d3da6bc7a --- /dev/null +++ b/packages/json-document-ui-primitives-react/src/content-interaction.css @@ -0,0 +1,69 @@ +:where([data-ui-interaction]) { + --_jd-content-interaction-accent: var(--jd-content-interaction-accent, currentColor); + --_jd-content-interaction-fill: var(--jd-content-interaction-fill, color-mix(in srgb, var(--_jd-content-interaction-accent) 7%, transparent)); + --_jd-content-interaction-fill-active: var(--jd-content-interaction-fill-active, color-mix(in srgb, var(--_jd-content-interaction-accent) 12%, transparent)); + --_jd-content-interaction-contour: var(--jd-content-interaction-contour, color-mix(in srgb, var(--_jd-content-interaction-accent) 58%, transparent)); + --_jd-content-interaction-selection-fill: var(--jd-content-interaction-selection-fill, var(--_jd-content-interaction-fill)); + --_jd-content-interaction-selection-contour: var(--jd-content-interaction-selection-contour, var(--_jd-content-interaction-contour)); + --_jd-content-interaction-shadow: var(--jd-content-interaction-shadow, 0 10px 28px color-mix(in srgb, black 14%, transparent)); + transition-duration: var(--jd-content-interaction-settle-duration, 140ms); + transition-property: box-shadow, opacity, transform; + transition-timing-function: var(--jd-content-interaction-easing, cubic-bezier(0.2, 0.8, 0.2, 1)); +} + +:where([data-ui-interaction="content"][data-selected="true"]) { + box-shadow: + inset 0 0 0 1px var(--_jd-content-interaction-selection-contour), + inset 0 0 0 999px var(--_jd-content-interaction-selection-fill); +} + +:where([data-ui-interaction="content"][data-selected="true"][data-primary="true"]) { + box-shadow: + inset 0 0 0 1px var(--_jd-content-interaction-selection-contour), + inset 0 0 0 999px var(--_jd-content-interaction-selection-fill), + var(--_jd-content-interaction-shadow); + transform: translateY(var(--offset-content-primary, -1px)); +} + +:where([data-ui-interaction="content"]:active), +:where([data-ui-interaction="content"][data-ui-interaction-phase="active"]) { + box-shadow: + inset 0 0 0 2px var(--_jd-content-interaction-contour), + inset 0 0 0 999px var(--_jd-content-interaction-fill-active); + transition-duration: var(--jd-content-interaction-active-duration, 80ms); + transform: scale(var(--scale-content-active, 0.995)); +} + +:where([data-ui-interaction="content"][data-ui-interaction-phase="dragging"]) { + box-shadow: + inset 0 0 0 2px var(--_jd-content-interaction-contour), + inset 0 0 0 999px var(--_jd-content-interaction-fill-active), + var(--_jd-content-interaction-shadow); + opacity: 0.96; + transform: translateY(var(--offset-content-dragging, -2px)) scale(var(--scale-content-dragging, 1.005)); +} + +:where([data-ui-interaction="drop-target"][data-ui-interaction-phase="active"]) { + outline: 1px dashed var(--_jd-content-interaction-contour); + outline-offset: -2px; + box-shadow: + inset 0 0 0 999px var(--_jd-content-interaction-fill); +} + +:where([data-ui-interaction="insertion"][data-ui-interaction-phase="active"]) { + box-shadow: + inset 2px 0 0 var(--_jd-content-interaction-accent), + inset 0 0 0 999px var(--_jd-content-interaction-fill); +} + +:where([data-ui-interaction]:focus-visible) { + outline: 2px solid var(--_jd-content-interaction-contour); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + :where([data-ui-interaction]) { + transition-duration: 0s !important; + transform: none !important; + } +} diff --git a/packages/json-document-ui-primitives-react/src/content-interaction.ts b/packages/json-document-ui-primitives-react/src/content-interaction.ts new file mode 100644 index 000000000..6a1ceae30 --- /dev/null +++ b/packages/json-document-ui-primitives-react/src/content-interaction.ts @@ -0,0 +1,28 @@ +import { + contentInteractionAffordance, + type ContentInteractionInput, +} from "@interactive-os/json-document-affordance"; + +export type ContentInteractionAttributes = { + readonly "data-ui-interaction": "content" | "drop-target" | "insertion"; + readonly "data-ui-interaction-phase": "rest" | "active" | "dragging"; + readonly "data-selected"?: "true" | "false"; + readonly "data-primary"?: "true"; + readonly "data-elevated"?: "true"; +}; + +/** Projects the canonical content-interaction meaning to stable product CSS hooks. */ +export function contentInteractionAttributes( + input: ContentInteractionInput, +): ContentInteractionAttributes { + const interaction = contentInteractionAffordance(input); + return { + "data-ui-interaction": interaction.role, + "data-ui-interaction-phase": interaction.phase, + ...(interaction.role === "content" ? { "data-selected": interaction.selected ? "true" : "false" } : {}), + ...(interaction.primary ? { "data-primary": "true" as const } : {}), + ...(interaction.elevated ? { "data-elevated": "true" as const } : {}), + }; +} + +export type { ContentInteractionInput } from "@interactive-os/json-document-affordance"; diff --git a/packages/json-document-ui-primitives-react/src/controls.tsx b/packages/json-document-ui-primitives-react/src/controls.tsx index a97b5b472..aab999615 100644 --- a/packages/json-document-ui-primitives-react/src/controls.tsx +++ b/packages/json-document-ui-primitives-react/src/controls.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from "react"; import type { ControlAffordanceProps } from "./control-affordance.js"; +import { contentInteractionAttributes } from "./content-interaction.js"; export type CommandKind = "primary" | "secondary" | "danger"; @@ -184,17 +185,20 @@ export function InlineChoice(props: { export type SelectableItemProps = { readonly as?: T; readonly selected: boolean; + readonly primary?: boolean; readonly focus?: boolean; + readonly active?: boolean; + readonly dragging?: boolean; } & ControlAffordanceProps & Omit, "as" | "data-selected" | "data-focus">; export function SelectableItem( props: SelectableItemProps, ): ReactNode { - const { affordance, as, selected, focus = false, ...itemProps } = props; + const { active = false, affordance, as, dragging = false, selected, primary = false, focus = false, ...itemProps } = props; const Component = as ?? "button"; return createElement(Component, { ...itemProps, - "data-selected": selected ? "true" : "false", + ...contentInteractionAttributes({ role: "content", selected, primary, active, dragging }), "data-focus": focus ? "true" : "false", "data-ui-control": "selectable", "data-ui-affordance": affordance, diff --git a/packages/json-document-ui-primitives-react/src/index.ts b/packages/json-document-ui-primitives-react/src/index.ts index 8a4771c83..d28fb71cf 100644 --- a/packages/json-document-ui-primitives-react/src/index.ts +++ b/packages/json-document-ui-primitives-react/src/index.ts @@ -24,6 +24,8 @@ export type { export { useListbox } from "./listbox.js"; export type { ControlAffordance, ControlAffordanceProps } from "./control-affordance.js"; export type { ListboxBinding, ListboxItem, UseListboxOptions } from "./listbox.js"; +export { contentInteractionAttributes } from "./content-interaction.js"; +export type { ContentInteractionAttributes, ContentInteractionInput } from "./content-interaction.js"; export { Command, DisclosureButton, diff --git a/packages/json-document-ui-primitives-react/src/product-shell.tsx b/packages/json-document-ui-primitives-react/src/product-shell.tsx index c0b37f54e..9b20aa867 100644 --- a/packages/json-document-ui-primitives-react/src/product-shell.tsx +++ b/packages/json-document-ui-primitives-react/src/product-shell.tsx @@ -4,6 +4,7 @@ import { Toolbar } from "./toolbar.js"; export function ProductShell(props: HTMLAttributes & { readonly toolbar?: ReactNode; readonly toolbarLabel?: string; + readonly toolbarPresentation?: "attached" | "floating"; readonly inspector?: ReactNode; readonly canvasClassName?: string; readonly fill?: boolean; @@ -11,6 +12,7 @@ export function ProductShell(props: HTMLAttributes & { const { toolbar, toolbarLabel, + toolbarPresentation = "attached", inspector, canvasClassName, fill = false, @@ -19,7 +21,15 @@ export function ProductShell(props: HTMLAttributes & { } = props; return (
- {toolbar == null ? null : {toolbar}} + {toolbar == null ? null : ( + + {toolbar} + + )} {children} {inspector == null ? null : {inspector}}
diff --git a/packages/json-document-ui-primitives-react/src/select.tsx b/packages/json-document-ui-primitives-react/src/select.tsx index f83dbcb2a..6836919c2 100644 --- a/packages/json-document-ui-primitives-react/src/select.tsx +++ b/packages/json-document-ui-primitives-react/src/select.tsx @@ -37,6 +37,7 @@ export function PopupChoice(props: { } & ControlAffordanceProps) { const generatedId = useId(); const listboxId = props.id ?? `json-document-select-${generatedId.replaceAll(":", "")}`; + const rootRef = useRef(null); const triggerRef = useRef(null); const listboxRef = useRef(null); const [open, setOpen] = useState(false); @@ -63,6 +64,15 @@ export function PopupChoice(props: { if (open) listboxRef.current?.focus(); }, [open]); + useEffect(() => { + if (!open) return; + const dismissOutside = (event: PointerEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) close(false); + }; + document.addEventListener("pointerdown", dismissOutside, true); + return () => document.removeEventListener("pointerdown", dismissOutside, true); + }, [open]); + function close(restoreFocus = true) { setOpen(false); if (restoreFocus) queueMicrotask(() => triggerRef.current?.focus()); @@ -75,7 +85,7 @@ export function PopupChoice(props: { } return ( -
+
); + await user.click(screen.getByRole("button", { name: "보기" })); + expect(screen.getByRole("listbox", { name: "보기" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "일정" })); + expect(screen.queryByRole("listbox", { name: "보기" })).toBeNull(); + }); + test("Menu moves through enabled actions and restores trigger focus", async () => { const user = userEvent.setup(); const onAction = vi.fn(); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index 226cefaaf..2f3e9e79b 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -39,6 +39,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-affordance", replacement: sourceFile("packages/json-document-affordance/src/index.ts"), }, + { + find: "@interactive-os/json-document-ui-primitives-react/content-interaction.css", + replacement: sourceFile("packages/json-document-ui-primitives-react/src/content-interaction.css"), + }, { find: "@interactive-os/json-document-ui-primitives-react", replacement: sourceFile("packages/json-document-ui-primitives-react/src/index.ts"), diff --git a/site/package.json b/site/package.json index 8274078e8..f5368264a 100644 --- a/site/package.json +++ b/site/package.json @@ -17,6 +17,7 @@ "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs && node scripts/check-document-type-audits.mjs && node scripts/check-documentation-page.mjs", "check:interaction-handles": "node scripts/check-interaction-handles.mjs", "check:contextual-affordance": "node scripts/check-contextual-affordance.mjs", + "check:content-interaction": "node scripts/check-content-interaction-grammar.mjs", "check:product-shell-toolbar": "node scripts/check-product-shell-toolbar.mjs", "check:calendar-temporal": "node scripts/check-calendar-temporal.mjs", "check:calendar-keyboard": "node scripts/check-calendar-keyboard.mjs", @@ -45,7 +46,7 @@ "test": "vitest run --config vitest.config.ts", "check:calendar-selection-drag": "node scripts/check-calendar-selection-drag.mjs", "check:anchored-floating-position": "node scripts/check-anchored-floating-position.mjs", - "typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:ui-roles && npm run check:choice-id && npm run check:canonical-modules && npm run check:interaction-handles && npm run check:contextual-affordance && npm run check:product-shell-toolbar && npm run check:calendar-temporal && npm run check:calendar-keyboard && npm run check:calendar-rename && npm run check:calendar-viewport && npm run check:calendar-pointer-coordinate && npm run check:calendar-selection-drag && npm run check:anchored-floating-position && npm run check:calendar-year-months && npm run check:calendar-month-weeks && npm run check:calendar-period-label && npm run check:calendar-visible-date-shift && npm run check:calendar-time-label && npm run check:calendar-event-label && npm run check:calendar-interval-last-date && npm run check:calendar-date-part && npm run check:calendar-period-cells && npm run check:calendar-allday-span && npm run check:calendar-view-parser && npm run check:calendar-instant-format && npm run check:calendar-cell-interval && npm run check:calendar-date-grid && npm run check:calendar-month-grid && npm run check:calendar-time-grid && npm run check:calendar-event-inspector && npm run check:calendar-recurrence-transition && npm run check:calendar-document-calendars && npm run check:tokens && tsc -p tsconfig.json --noEmit" + "typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:ui-roles && npm run check:choice-id && npm run check:canonical-modules && npm run check:interaction-handles && npm run check:contextual-affordance && npm run check:content-interaction && npm run check:product-shell-toolbar && npm run check:calendar-temporal && npm run check:calendar-keyboard && npm run check:calendar-rename && npm run check:calendar-viewport && npm run check:calendar-pointer-coordinate && npm run check:calendar-selection-drag && npm run check:anchored-floating-position && npm run check:calendar-year-months && npm run check:calendar-month-weeks && npm run check:calendar-period-label && npm run check:calendar-visible-date-shift && npm run check:calendar-time-label && npm run check:calendar-event-label && npm run check:calendar-interval-last-date && npm run check:calendar-date-part && npm run check:calendar-period-cells && npm run check:calendar-allday-span && npm run check:calendar-view-parser && npm run check:calendar-instant-format && npm run check:calendar-cell-interval && npm run check:calendar-date-grid && npm run check:calendar-month-grid && npm run check:calendar-time-grid && npm run check:calendar-event-inspector && npm run check:calendar-recurrence-transition && npm run check:calendar-document-calendars && npm run check:tokens && tsc -p tsconfig.json --noEmit" }, "dependencies": { "@a2ui/web_core": "^0.10.6", diff --git a/site/scripts/check-calendar-period-cells.mjs b/site/scripts/check-calendar-period-cells.mjs index 3fa86aec8..5f135f76d 100644 --- a/site/scripts/check-calendar-period-cells.mjs +++ b/site/scripts/check-calendar-period-cells.mjs @@ -23,7 +23,7 @@ requireText(ownerTest, "cell.day"); requireCount(ownerDateGrid, "{cell.day}", 1); requireText(ownerControls, "{cell}
)))}
diff --git a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx index a8408b466..05417e829 100644 --- a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx +++ b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx @@ -45,7 +45,6 @@ import { ToolbarGroup, ToolbarLayout, ToolbarRegion, - ToolbarSeparator, Toggle, } from "@interactive-os/json-document-ui-primitives-react"; import { useDemoEmbed } from "../../shared/demo-workbench/DemoPage"; @@ -114,7 +113,7 @@ const hourEnd = 24; const workHourStart = 7; const pxPerHour = 72; const monthDayRows = 3; -const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; export function CalendarDemoRoute(props: { @@ -160,6 +159,7 @@ export function CalendarDemoRoute(props: { active: !embedded && (view === "day" || view === "week"), resetKey: `${view}:${visibleDate}`, targetHour: workHourStart, + viewportOffset: 16, }); function setLocation(nextView: CalendarView, nextDate: string): void { @@ -179,8 +179,13 @@ export function CalendarDemoRoute(props: { const document = hand.document; const calendars = calendarDocumentCalendars(document); const selectedEvent = hand.selectedEvent; + const pointerGestureActive = hand.timePreview !== null + || hand.allDayPreview !== null + || hand.monthPreview !== null + || hand.selectionDragPreview !== null; + const eventInspectorVisible = selectedEvent !== null && !pointerGestureActive; const eventDetailsPosition = useAnchoredFloatingPosition({ - active: selectedEvent !== null, + active: eventInspectorVisible, policy: { type: "preferred", placement: "right-start", @@ -267,10 +272,10 @@ export function CalendarDemoRoute(props: { stickyHeader: classes("grid", styles.weekSticky()), columnHeader: styles.weekHead(), weekday: ui.text.meta, - dayNumber: styles.dayNumber(), - today: styles.todayMark(), - allDayLabel: classes("px-1 py-2 text-right", ui.text.meta), - allDayCell: classes("min-h-10", styles.weekCell()), + dayNumber: classes(styles.dayNumber(), ui.text.body), + today: styles.weekToday(), + allDayLabel: classes("whitespace-nowrap px-1 py-1.5 text-right text-foreground-muted/70", ui.text.meta), + allDayCell: classes("min-h-8", styles.weekCell()), allDayEventContainer: "group/event relative z-10 mx-0.5 my-1", allDayEvent: styles.allDayEvent(), resizeAllDayEnd: styles.resizeEdgeVertical(), @@ -278,15 +283,15 @@ export function CalendarDemoRoute(props: { timeViewportFill: styles.weekHours(), hourGutter: "relative overflow-hidden", viewportAnchor: "pointer-events-none absolute left-0 top-0", - hourLabel: styles.hourLabel(), + hourLabel: classes(styles.hourLabel(), ui.text.meta), timeCell: classes("overflow-hidden", styles.weekCell()), selectedSlot: styles.selectedSlot(), hourRule: styles.hourRule(), nowLine: styles.nowLine(), - creationTimeHint: styles.creationTimeHint(), - timedEventContainer: "group/event absolute z-10", + creationTimeHint: classes(styles.creationTimeHint(), ui.text.meta), + timedEventContainer: "group/event absolute z-10 p-0.5", timedEvent: styles.timedEvent(), - eventTitle: "min-w-0 truncate", + eventTitle: styles.timedEventTitle(), eventTime: styles.eventTime(), resizeTimedEnd: styles.resizeEdge(), }} @@ -295,7 +300,7 @@ export function CalendarDemoRoute(props: { allDay: "all-day", now: "Now", resizeEnd: (event) => `Resize ${event.title} end`, - hour: (hour) => String(hour).padStart(2, "0"), + hour: calendarHourLabel, }} getEventColor={(event) => calendarColor(document, event.calendarId)} /> @@ -312,30 +317,24 @@ export function CalendarDemoRoute(props: { + toolbar={pointerGestureActive ? null : ( + - {visiblePeriodLabel(view, visibleDate, { - monthNames: months, - weekSeparator: " – ", - })} setVisibleDate(shiftVisibleDate(visibleDate, view, -1))}> + setLocation(view === "year" ? "month" : view, today)}>Today setVisibleDate(shiftVisibleDate(visibleDate, view, 1))}> - - - setLocation(view === "year" ? "month" : view, today)}>Today - - - + + + {(context) => ( + <> + + {context.visible.includes("sources") ? ( + + ) : null} + + )} + + )} >
-
- + {eventInspectorVisible ?
-
-
@@ -581,3 +606,9 @@ export function CalendarDemoRoute(props: { function calendarColor(document: CalendarDocument, calendarId: string): "accent" | "subtle" { return calendarDocumentCalendar(document, calendarId)?.color === "accent" ? "accent" : "subtle"; } + +function calendarHourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const displayHour = hour % 12 || 12; + return `${displayHour} ${period}`; +} diff --git a/site/src/routes/calendar-demo/calendar-demo-navigator.tsx b/site/src/routes/calendar-demo/calendar-demo-navigator.tsx index 6ce6af25a..df53d59d3 100644 --- a/site/src/routes/calendar-demo/calendar-demo-navigator.tsx +++ b/site/src/routes/calendar-demo/calendar-demo-navigator.tsx @@ -14,13 +14,13 @@ import { calendarDemoRecipe } from "./calendar-demo-styles"; import { calendarControlAffordance } from "./calendar-control-affordances"; const weekdays = [ + { label: "Sunday", content: "S" }, { label: "Monday", content: "M" }, { label: "Tuesday", content: "T" }, { label: "Wednesday", content: "W" }, { label: "Thursday", content: "T" }, { label: "Friday", content: "F" }, { label: "Saturday", content: "S" }, - { label: "Sunday", content: "S" }, ]; export function CalendarDemoNavigator(props: { diff --git a/site/src/routes/calendar-demo/calendar-demo-styles.ts b/site/src/routes/calendar-demo/calendar-demo-styles.ts index 09934eee7..dd31ce705 100644 --- a/site/src/routes/calendar-demo/calendar-demo-styles.ts +++ b/site/src/routes/calendar-demo/calendar-demo-styles.ts @@ -1,34 +1,44 @@ import { tv } from "tailwind-variants"; +import { ui } from "../../shared/ui/styles"; -const chipRail = "relative before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-foreground-default data-[calendar-color=accent]:before:bg-background-accent"; -const chipFill = "bg-background-subtle text-foreground-strong data-[calendar-color=accent]:bg-background-accent-subtle data-[calendar-color=accent]:text-foreground-accent data-[preview=true]:opacity-60 data-[primary=true]:z-10"; +const chipRail = "relative before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-foreground-muted/45 data-[calendar-color=accent]:before:bg-background-accent"; +const chipFill = `${ui.surface.contentObject} border-transparent bg-background-subtle/60 text-foreground-strong data-[calendar-color=accent]:border-transparent data-[calendar-color=accent]:bg-background-accent-subtle/70 data-[calendar-color=accent]:text-foreground-accent data-[preview=true]:opacity-60 data-[primary=true]:z-10`; export const calendarDemoRecipe = tv({ slots: { - shell: "relative isolate overflow-hidden bg-background-canvas", - allDayEvent: `h-full w-full rounded-control border-0 px-2 py-0.5 text-left text-xs leading-4 ${chipFill} ${chipRail}`, - hourRule: "absolute inset-x-0 border-t border-line-subtle/70", - timedEvent: `group flex h-full w-full flex-col items-stretch gap-0 overflow-hidden rounded-control border-0 px-2 py-0.5 pl-2.5 text-left text-xs leading-4 ${chipFill} ${chipRail}`, - monthWeek: "relative grid min-w-[36rem] grid-cols-7 border-t border-line-subtle/60", + shell: "relative isolate overflow-hidden bg-background-subtle/20 [&>[data-ui-toolbar=product]]:absolute [&>[data-ui-toolbar=product]]:right-4 [&>[data-ui-toolbar=product]]:top-4 [&>[data-ui-toolbar=product]]:z-50 [&>[data-ui-toolbar=product]]:w-auto [&>[data-ui-toolbar=product]]:max-w-[calc(100%-1.5rem)] sm:[&>[data-ui-toolbar=product]]:max-w-[calc(100%-8rem)] [&>[data-ui-toolbar=product]_[data-placement=end]]:overflow-visible", + controlLayer: "pointer-events-none absolute inset-0 z-40 [&>*]:pointer-events-auto", + contentLayer: "relative z-0 h-full min-h-0 min-w-0 pb-24", + allDayEvent: `h-full w-full rounded-control px-2.5 py-0.5 text-left text-xs leading-4 ${chipFill} ${chipRail}`, + hourRule: "absolute inset-x-0 border-t border-line-subtle/20", + timedEvent: `group flex h-full w-full flex-col items-stretch gap-0 overflow-hidden rounded-control px-2.5 py-0.5 pl-3 text-left text-xs leading-4 [--offset-content-dragging:0px] [--offset-content-primary:0px] [--scale-content-dragging:1] ${chipFill} ${chipRail}`, + timedEventTitle: "min-w-0 truncate text-xs font-medium leading-4", + monthWeek: "relative grid min-w-[36rem] grid-cols-7 border-t border-line-subtle/35", monthDay: "relative z-0 flex min-h-32 w-full flex-col items-stretch px-1.5 pb-1 text-left text-xs", monthAllDay: `relative mx-0.5 h-5 w-full min-w-0 truncate rounded-control border-0 px-2 text-left text-xs leading-5 ${chipFill} ${chipRail}`, - monthTimed: `relative mx-0.5 flex h-5 w-full min-w-0 items-center justify-between gap-1 truncate rounded-control border-0 bg-transparent px-1 pl-2 text-left text-xs leading-5 ${chipRail}`, + monthTimed: `relative mx-0.5 flex h-5 w-full min-w-0 items-center justify-between gap-1 truncate rounded-control px-2 pl-3 text-left text-xs leading-5 ${chipFill} ${chipRail}`, monthEvent: `relative mx-0.5 h-5 w-full min-w-0 truncate rounded-control border-0 px-2 text-left text-xs leading-5 ${chipFill} ${chipRail}`, monthMore: "relative z-10 h-5 w-full truncate border-0 bg-transparent px-1 text-left text-xs leading-5 text-foreground-muted shadow-none hover:border-0 hover:bg-transparent", quietAction: "border-0 bg-transparent p-0 shadow-none hover:border-0 hover:bg-transparent", monthOverflow: "absolute left-0 top-0 z-30 flex w-max min-w-full max-w-56 flex-col gap-0.5 p-1.5", monthHead: "px-2 py-1.5 text-center text-xs text-foreground-muted", - weekSticky: "z-30 shrink-0 border-b border-line-subtle/60 bg-background-canvas", + weekSticky: `z-30 shrink-0 pt-14 ${ui.surface.scrollEdgeHard}`, weekHours: "min-h-0 flex-1 overflow-auto", - weekHead: "flex flex-col items-center gap-0.5 px-1 py-2 text-center", - weekCell: "relative border-l border-line-subtle/60", - selectedSlot: "pointer-events-none absolute inset-x-0 z-10 rounded-control bg-background-subtle/60 ring-1 ring-inset ring-line-subtle/40", + weekHead: "flex min-h-12 items-center justify-center gap-1.5 px-1 py-2 text-center", + weekCell: "relative border-l border-line-subtle/15 outline-none focus-visible:ring-0 focus-visible:ring-offset-0", + selectedSlot: "pointer-events-none absolute inset-x-1 z-10 rounded-control", resizeEdge: "absolute inset-x-0 bottom-0 h-2", resizeEdgeVertical: "absolute right-0 top-0 h-full w-2", - period: "px-1 text-sm font-medium text-foreground-strong", + periodHeading: "pointer-events-none absolute left-14 top-5 z-40 m-0 hidden whitespace-nowrap text-lg font-semibold tracking-tight text-foreground-strong sm:block", todayAction: "border-0 bg-transparent px-2 shadow-none hover:border-0 hover:bg-background-subtle", - contextualSidebar: "relative z-30 w-20 shrink-0 rounded-control px-1 py-2 outline-none transition-[width] focus-within:w-44 hover:w-44 focus-visible:ring-2 focus-visible:ring-line-accent/25", - sidebarHint: "block text-center text-overline text-foreground-muted [writing-mode:vertical-rl]", + viewChoiceRoot: "relative w-24", + viewChoiceTrigger: "flex h-8 w-full items-center justify-between gap-2 rounded-control border-0 bg-background-subtle/70 px-3 text-xs text-foreground-strong outline-none shadow-none transition-colors duration-interaction hover:bg-background-subtle focus-visible:ring-2 focus-visible:ring-line-accent/25", + viewChoiceListbox: "absolute right-0 top-[calc(100%+0.5rem)] z-50 grid min-w-28 gap-0.5 rounded-surface border border-line-subtle/45 bg-background-canvas p-1.5 shadow-overlay outline-none", + viewChoiceOption: "rounded-control border-0 bg-transparent px-2.5 py-2 text-left text-xs text-foreground-default shadow-none hover:bg-background-subtle", + viewChoiceOptionFocused: "bg-background-subtle text-foreground-strong", + viewChoiceOptionSelected: "font-medium text-foreground-strong", + toolbarSources: "relative grid size-8 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-line-accent/25", + sidebarHint: "grid h-5 place-items-center text-foreground-muted", yearMonth: "flex min-h-0 w-full flex-col gap-1 px-1 py-1 text-left", yearDay: "flex aspect-square w-full items-center justify-center border-0 bg-transparent p-0 text-overline shadow-none hover:border-0 hover:bg-transparent", @@ -36,25 +46,26 @@ export const calendarDemoRecipe = tv({ nowLine: "pointer-events-none absolute inset-x-0 z-20 border-t border-line-accent", today: "font-semibold text-foreground-accent", todayMark: "font-semibold text-foreground-strong", - dayNumber: "inline-flex size-7 items-center justify-center text-sm font-medium", - sidebar: "flex w-44 shrink-0 flex-col gap-3 overflow-auto pr-1", - inspector: "group/details z-40 flex w-80 min-w-0 flex-col gap-3 overflow-auto border border-line-subtle/60 p-3.5 shadow-overlay", + weekToday: "rounded-full bg-background-accent text-foreground-inverse", + dayNumber: "inline-flex size-7 items-center justify-center font-medium text-foreground-strong", + sidebar: `absolute right-0 top-[calc(100%+0.75rem)] flex max-h-[calc(100vh-8rem)] w-44 shrink-0 flex-col gap-3 overflow-auto p-3 ${ui.surface.floatingPanel}`, + inspector: `group/details z-50 flex w-80 min-w-0 flex-col gap-3 overflow-auto p-3.5 ${ui.surface.floatingPanel}`, field: "grid gap-1", inspectorHeader: "flex min-w-0 items-center gap-2", inspectorTitle: "m-0 min-w-0 flex-1 truncate px-0 text-base font-semibold tracking-tight text-foreground-strong", inspectorActions: "flex shrink-0 items-center gap-0.5 [&_[data-ui-control=command]]:size-7 [&_[data-ui-control=command]]:border-0 [&_[data-ui-control=command]]:bg-transparent [&_[data-ui-control=command]]:p-0 [&_[data-ui-control=command]]:shadow-none", eventSummary: "grid gap-2 border-t border-line-subtle/40 pt-3 text-xs text-foreground-muted [&_p]:m-0 [&_p]:flex [&_p]:items-center [&_p]:gap-2 [&_svg]:shrink-0 [&_svg]:text-foreground-muted", detailsEditor: "grid gap-3 border-t border-line-subtle/40 pt-3", - calendarToggle: "group flex w-full items-center justify-start gap-2 border-0 bg-transparent px-1 text-left shadow-none hover:border-0 hover:bg-transparent aria-pressed:border-0 aria-pressed:bg-transparent aria-pressed:text-foreground-strong aria-[pressed=false]:text-foreground-muted", + calendarToggle: "group flex w-full items-center justify-start gap-2 rounded-control border-0 bg-transparent px-2 py-1.5 text-left shadow-none outline-none hover:border-0 hover:bg-background-subtle focus-visible:ring-2 focus-visible:ring-foreground-muted/25 aria-pressed:border-0 aria-pressed:bg-transparent aria-pressed:text-foreground-strong aria-[pressed=false]:text-foreground-muted", calendarSwatch: "size-2.5 shrink-0 rounded-full bg-foreground-muted opacity-40 data-[calendar-color=accent]:bg-background-accent group-aria-pressed:opacity-100", - eventTime: "truncate text-overline font-normal text-foreground-muted", - creationTimeHint: "pointer-events-none absolute left-1 z-20 -translate-y-1/2 whitespace-nowrap text-overline tabular-nums leading-none text-foreground-muted/55", - hourLabel: "absolute right-1 whitespace-nowrap text-right text-overline tabular-nums leading-none text-foreground-muted", - composerDock: "bottom-4 left-1/2 z-30 flex w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 rounded-surface border border-line-subtle/70 bg-background-canvas/95 p-2 pl-3 shadow-overlay backdrop-blur", + eventTime: "truncate text-xs font-normal leading-3 text-foreground-muted", + creationTimeHint: "pointer-events-none absolute left-2 z-20 translate-y-1 whitespace-nowrap tabular-nums text-foreground-muted/55", + hourLabel: "absolute right-2 -translate-y-1/2 whitespace-nowrap text-right tabular-nums text-foreground-muted/70", + composerDock: `${ui.surface.floatingControl} bottom-4 left-1/2 z-30 flex w-[min(36rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 border-line-subtle/35 p-1.5 pl-3 shadow-none`, composerDockFixed: "fixed", composerDockEmbedded: "absolute", composerSpark: "shrink-0 text-foreground-accent", - composerInput: "min-w-0 flex-1 border-0 bg-transparent px-1 py-2 text-sm text-foreground-muted outline-none placeholder:text-foreground-muted", + composerInput: "min-w-0 flex-1 border-0 bg-transparent px-1 py-2 text-sm text-foreground-muted opacity-100 outline-none placeholder:text-foreground-muted/80 disabled:opacity-100", composerSend: "grid size-8 shrink-0 place-items-center rounded-full border-0 bg-background-subtle p-0 text-foreground-muted shadow-none disabled:bg-background-subtle disabled:text-foreground-muted", }, }); diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index 07f7e32c1..633d1da36 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -694,6 +694,7 @@ export function CanvasDemoRoute() { data-hover={hoverId === object.id} selected={option.selected} focus={option.focus} + dragging={offset !== null} onPointerDown={(event) => handleObjectPointerDown(event, object.id)} onPointerMove={handleObjectPointerMove} onPointerUp={commitCurrentDrag} diff --git a/site/src/routes/date-controls-demo/date-controls-demo-styles.ts b/site/src/routes/date-controls-demo/date-controls-demo-styles.ts index 6fc7fd2ce..1aac147f9 100644 --- a/site/src/routes/date-controls-demo/date-controls-demo-styles.ts +++ b/site/src/routes/date-controls-demo/date-controls-demo-styles.ts @@ -31,9 +31,9 @@ export const dateControlsDemoRecipe = tv({ timeViewport: "grid", timeHourGutter: "relative overflow-hidden", timeViewportAnchor: "pointer-events-none absolute left-0 top-0", - timeHourLabel: "absolute right-1 text-xs text-foreground-muted", + timeHourLabel: "absolute right-1 -translate-y-1/2 text-xs text-foreground-muted", timeCell: "relative overflow-hidden border-l border-line-subtle", - timeSelectedSlot: "pointer-events-none absolute inset-x-0 bg-background-subtle", + timeSelectedSlot: "pointer-events-none absolute inset-x-0", timeHourRule: "absolute inset-x-0 border-t border-line-subtle", timeNowLine: "pointer-events-none absolute inset-x-0 z-20 border-t border-line-accent", timeEventContainer: "absolute z-10", diff --git a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx index 6faced403..1616d5e28 100644 --- a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx +++ b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx @@ -42,6 +42,7 @@ const initialBoard: KanbanDocument = { export function KanbanDemoRoute() { const [editor] = useState(() => createKanbanEditor(initialBoard)); + const [draggingId, setDraggingId] = useState(null); const [boardDrag] = useState(() => createBoardDragSession({ onCommit: ({ item: cardId, target }) => { editor.dispatch({ @@ -149,18 +150,22 @@ export function KanbanDemoRoute() { type="button" selected={option.selected} focus={option.focus} + dragging={draggingId === card.id} draggable {...webKanbanCardProps(card.id)} - data-selected={option.selected ? "true" : "false"} data-focus={option.focus ? "true" : "false"} aria-selected={option.selected} onClick={option.onClick} onDragStart={(event) => { editing.getItem(card.id).getPressHandler()(event); + setDraggingId(card.id); boardDrag.begin(card.id); dragSession.begin(card.id); }} - onDragEnd={() => dragSession.cancel()} + onDragEnd={() => { + setDraggingId(null); + dragSession.cancel(); + }} className={classes("w-full p-3 text-left", ui.surface.documentBlock)} > {card.title} diff --git a/site/src/routes/showcase/ShowcaseRoute.tsx b/site/src/routes/showcase/ShowcaseRoute.tsx index 0134e0a6e..e413526a7 100644 --- a/site/src/routes/showcase/ShowcaseRoute.tsx +++ b/site/src/routes/showcase/ShowcaseRoute.tsx @@ -2,6 +2,7 @@ import { ActionLink } from "../../shared/ui/interactive"; import { PageFrame, PageHeader } from "../../shared/ui/primitives"; import { classes, ui } from "../../shared/ui/styles"; import { pageDescriptor } from "../../app/page-descriptors"; +import { contentInteractionAttributes } from "@interactive-os/json-document-ui-primitives-react"; const demos = [ { @@ -69,9 +70,8 @@ function ShowcasePreview(props: { readonly kind: "document" | "sheet" | "databas className={classes( "grid grid-cols-[2rem_1fr] items-center", ui.surface.documentBlock, - index === 1 && ui.surface.previewSelected, )} - data-selected={index === 1 ? "true" : "false"} + {...contentInteractionAttributes({ role: "content", selected: index === 1 })} > {index + 1} {text} @@ -98,8 +98,8 @@ function ShowcasePreview(props: { readonly kind: "document" | "sheet" | "databas {row.map((cell, columnIndex) => ( {cell} diff --git a/site/src/routes/ui-primitives-catalog/UiPrimitivesCatalogRoute.tsx b/site/src/routes/ui-primitives-catalog/UiPrimitivesCatalogRoute.tsx index 3826d1ac4..f7c88ede6 100644 --- a/site/src/routes/ui-primitives-catalog/UiPrimitivesCatalogRoute.tsx +++ b/site/src/routes/ui-primitives-catalog/UiPrimitivesCatalogRoute.tsx @@ -19,6 +19,7 @@ import { import { Check, Command, + contentInteractionAttributes, Toggle, ContextualControls, ControlHandle, @@ -260,6 +261,20 @@ export function UiPrimitivesCatalogRoute() {
+
+

Content interaction

+ One state grammar across product content +
+
    +
  • Rest
  • +
  • Selected
  • +
  • Active
  • +
  • Dragging
  • +
  • Drop target
  • +
  • Insertion
  • +
+
+

Document inspector

Formatting stays attached to the selection diff --git a/site/src/routes/ui-primitives-catalog/ui-primitives-catalog-styles.ts b/site/src/routes/ui-primitives-catalog/ui-primitives-catalog-styles.ts index 255bffd53..ecec39dbf 100644 --- a/site/src/routes/ui-primitives-catalog/ui-primitives-catalog-styles.ts +++ b/site/src/routes/ui-primitives-catalog/ui-primitives-catalog-styles.ts @@ -35,7 +35,7 @@ export const uiPrimitivesCatalog = tv({ bodyCopy: "m-0 text-xs leading-5 text-foreground-muted", inboxLayout: "grid min-h-80 sm:grid-cols-[minmax(10rem,0.8fr)_minmax(0,1.35fr)]", inboxList: "grid content-start border-b border-line-subtle p-2 sm:border-b-0 sm:border-r", - inboxItem: "flex w-full items-center justify-between gap-3 rounded-control border-0 bg-transparent px-3 py-3 text-left text-xs text-foreground-default data-[selected=true]:bg-background-subtle data-[selected=true]:font-medium data-[selected=true]:text-foreground-strong [&_small]:font-normal [&_small]:text-foreground-muted", + inboxItem: "flex w-full items-center justify-between gap-3 rounded-control border-0 bg-transparent px-3 py-3 text-left text-xs text-foreground-default [&_small]:font-normal [&_small]:text-foreground-muted", inboxDetail: "grid content-center gap-5 p-7 sm:p-9", agentStage: "grid min-h-64 content-center gap-5 bg-background-subtle/55 p-7 sm:p-9", agentDraft: "rounded-surface bg-background-canvas p-4 shadow-surface", @@ -66,7 +66,7 @@ export const uiPrimitivesCatalog = tv({ dropOverlay: "pointer-events-none text-sm font-medium text-foreground-accent", gridTable: "w-full border-collapse bg-background-canvas text-left text-sm", gridHead: "border border-line-subtle bg-background-subtle px-3 py-2 text-xs font-medium text-foreground-default", - gridCell: "border border-line-subtle bg-background-canvas px-3 py-2 text-sm text-foreground-strong data-[selected=true]:bg-background-subtle data-[selected=true]:outline data-[selected=true]:outline-2 data-[selected=true]:-outline-offset-2 data-[selected=true]:outline-line-accent data-[focus=true]:outline data-[focus=true]:outline-2 data-[focus=true]:-outline-offset-2 data-[focus=true]:outline-foreground-default", + gridCell: "border border-line-subtle bg-background-canvas px-3 py-2 text-sm text-foreground-strong", surfaceWorkspace: "grid overflow-hidden rounded-surface border border-line-subtle bg-background-canvas md:grid-cols-[14rem_minmax(0,1fr)]", surfaceSidebar: "grid content-start gap-5 border-b border-line-subtle bg-background-subtle/50 p-5 md:border-b-0 md:border-r", surfaceCanvas: "grid gap-6 p-5", @@ -85,7 +85,7 @@ export const uiPrimitivesCatalog = tv({ panel: "rounded-surface bg-background-subtle p-3", list: "m-0 grid list-none gap-1 p-0", selectionList: "m-0 grid max-w-sm list-none gap-1 rounded-surface bg-background-subtle p-1", - selectionItem: "w-full rounded-control border-0 bg-transparent px-3 py-2 text-left text-xs text-foreground-default data-[selected=true]:bg-background-canvas data-[selected=true]:font-medium data-[selected=true]:text-foreground-strong data-[focus=true]:outline data-[focus=true]:outline-1 data-[focus=true]:outline-line-default", + selectionItem: "w-full rounded-control border-0 bg-transparent px-3 py-2 text-left text-xs text-foreground-default", shellCanvas: "grid min-h-52 place-items-center bg-background-subtle/45 p-6", shellArtifact: "grid w-full max-w-md gap-3 rounded-surface bg-background-canvas p-6 shadow-surface", shellArtifactLine: "h-2 rounded-full bg-line-subtle", diff --git a/site/src/routes/widgets/BoardWidgetRoute.tsx b/site/src/routes/widgets/BoardWidgetRoute.tsx index caa55b087..e93c82bbf 100644 --- a/site/src/routes/widgets/BoardWidgetRoute.tsx +++ b/site/src/routes/widgets/BoardWidgetRoute.tsx @@ -20,7 +20,7 @@ import { dragAffordance, dropAffordance, } from "@interactive-os/json-document-affordance"; -import { SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; +import { contentInteractionAttributes, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; import { classes, ui } from "../../shared/ui/styles"; import { editingItemProps } from "@interactive-os/json-document-react"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; @@ -94,6 +94,7 @@ export function BoardWidgetRoute() { const focusColumnId = focusKey === null ? null : board.columns.find((column) => column.cardIds.includes(focusKey))?.id ?? null; + const dragSnapshot = boardDrag.getSnapshot(); function handleCardPointerDown(event: PointerEvent, cardId: string) { event.preventDefault(); @@ -192,7 +193,15 @@ export function BoardWidgetRoute() { }} > {board.columns.map((column) => ( -
+

{column.title}

{ diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 6f21f761e..f5ca2055d 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -55,6 +55,7 @@ import boardDragSessionSource from "../../../../packages/json-document-affordanc import canvasGestureSessionSource from "../../../../packages/json-document-affordance/src/canvas-gesture-session.ts?raw"; import gestureSessionSource from "../../../../packages/json-document-affordance/src/gesture-session.ts?raw"; import interactionHandleSource from "../../../../packages/json-document-affordance/src/interaction-handle.ts?raw"; +import contentInteractionAffordanceSource from "../../../../packages/json-document-affordance/src/content-interaction.ts?raw"; import databaseEditingSource from "../../../../packages/json-document-editing/src/database.ts?raw"; import databasePropertyValueSource from "../../../../packages/json-document-editing/src/database-property-value.ts?raw"; import databaseHandSource from "../../../../packages/json-document-database/src/database-hand.tsx?raw"; @@ -67,6 +68,7 @@ import uiSelectSource from "../../../../packages/json-document-ui-primitives-rea import uiSurfacesSource from "../../../../packages/json-document-ui-primitives-react/src/surfaces.tsx?raw"; import uiControlsSource from "../../../../packages/json-document-ui-primitives-react/src/controls.tsx?raw"; import uiControlAffordanceSource from "../../../../packages/json-document-ui-primitives-react/src/control-affordance.ts?raw"; +import uiContentInteractionSource from "../../../../packages/json-document-ui-primitives-react/src/content-interaction.ts?raw"; import uiChoiceSource from "../../../../packages/json-document-ui-primitives-react/src/choice.tsx?raw"; import uiInputControlsSource from "../../../../packages/json-document-ui-primitives-react/src/input-controls.tsx?raw"; import uiPresentationsSource from "../../../../packages/json-document-ui-primitives-react/src/presentations.tsx?raw"; @@ -216,6 +218,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-affordance/src/canvas-gesture-session.ts", canvasGestureSessionSource], ["packages/json-document-affordance/src/gesture-session.ts", gestureSessionSource], ["packages/json-document-affordance/src/interaction-handle.ts", interactionHandleSource], + ["packages/json-document-affordance/src/content-interaction.ts", contentInteractionAffordanceSource], ["packages/json-document-editing/src/database.ts", databaseEditingSource], ["packages/json-document-editing/src/database-property-value.ts", databasePropertyValueSource], ["packages/json-document-database/src/database-hand.tsx", databaseHandSource], @@ -228,6 +231,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-ui-primitives-react/src/surfaces.tsx", uiSurfacesSource], ["packages/json-document-ui-primitives-react/src/controls.tsx", uiControlsSource], ["packages/json-document-ui-primitives-react/src/control-affordance.ts", uiControlAffordanceSource], + ["packages/json-document-ui-primitives-react/src/content-interaction.ts", uiContentInteractionSource], ["packages/json-document-ui-primitives-react/src/choice.tsx", uiChoiceSource], ["packages/json-document-ui-primitives-react/src/input-controls.tsx", uiInputControlsSource], ["packages/json-document-ui-primitives-react/src/presentations.tsx", uiPresentationsSource], @@ -283,6 +287,16 @@ const registeredPublicUsages = [ symbol: "computeAnchoredFloatingPosition", sourcePath: "packages/json-document-affordance/src/anchored-floating-position.ts", }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "contentInteractionAffordance", + sourcePath: "packages/json-document-affordance/src/content-interaction.ts", + }, + { + packageName: "@interactive-os/json-document-ui-primitives-react", + symbol: "contentInteractionAttributes", + sourcePath: "packages/json-document-ui-primitives-react/src/content-interaction.ts", + }, { packageName: "@interactive-os/json-document-web", symbol: "createWebAnchoredFloatingPositionPorts", diff --git a/site/src/shared/ui/styles.ts b/site/src/shared/ui/styles.ts index fb84fe212..f6f7b8492 100644 --- a/site/src/shared/ui/styles.ts +++ b/site/src/shared/ui/styles.ts @@ -106,6 +106,10 @@ export const ui = { workspace: "rounded-surface", raised: "rounded-surface bg-background-subtle", overlay: "rounded-surface bg-background-canvas shadow-overlay", + contentObject: "rounded-control border border-line-subtle/30 bg-background-subtle/80", + floatingControl: "rounded-full border border-line-subtle/50 bg-background-canvas shadow-surface", + floatingPanel: "rounded-surface border border-line-subtle/45 bg-background-canvas shadow-overlay", + scrollEdgeHard: "border-b border-line-subtle/20 bg-background-canvas", inset: "rounded-control bg-background-subtle", tableHead: "border-b border-line-subtle text-foreground-default", tableCell: "border-b border-line-subtle/70 text-foreground-default", @@ -116,7 +120,6 @@ export const ui = { gridIndex: "border border-line-subtle bg-background-subtle text-foreground-muted", gridCell: "border border-line-subtle bg-background-canvas", selectableBlock: "border border-transparent bg-background-canvas px-3 py-2 text-left text-xs font-medium", - previewSelected: "border-line-accent bg-background-subtle outline outline-2 -outline-offset-2 outline-line-accent", documentBlock: "border-b border-line-subtle/70 bg-transparent px-3 py-3 last:border-b-0", documentIndex: "rounded-none border-0 border-r border-line-subtle bg-background-subtle text-foreground-muted shadow-none", empty: "rounded-none border border-dashed border-line-subtle bg-background-subtle text-foreground-muted", diff --git a/site/tests/browser/calendar-app.spec.ts b/site/tests/browser/calendar-app.spec.ts index 33f8a6fdb..81486c454 100644 --- a/site/tests/browser/calendar-app.spec.ts +++ b/site/tests/browser/calendar-app.spec.ts @@ -1,4 +1,9 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; + +async function selectCalendarView(root: Page | Locator, view: "Day" | "Week" | "Month" | "Year"): Promise { + await root.getByRole("button", { name: "View", exact: true }).click(); + await root.getByRole("option", { name: view, exact: true }).click(); +} test("Calendar app chrome fills the page without docs or workbench", async ({ page }) => { await page.goto("/demo/calendar"); @@ -9,7 +14,7 @@ test("Calendar app chrome fills the page without docs or workbench", async ({ pa await expect(page.getByRole("grid", { name: "Week" })).toBeVisible(); await expect(page.getByRole("button", { name: "Previous", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Today", exact: true })).toBeVisible(); - await expect(page.getByRole("radio", { name: "Week", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "View", exact: true })).toContainText("Week"); await expect(page.getByRole("button", { name: "Create", exact: true })).toHaveCount(0); const composer = page.getByRole("group", { name: "AI Composer preview", exact: true }); await expect(composer).toBeVisible(); @@ -30,13 +35,13 @@ test("Calendar app chrome fills the page without docs or workbench", async ({ pa await expect(navigation.getByRole("group", { name: "Hands" })).toBeVisible(); }); -test("Calendar view tabs stay on one toolbar axis across variable period labels", async ({ page }) => { +test("Calendar view menu stays compact across variable period labels", async ({ page }) => { await page.goto("/demo/calendar?view=week&date=2026-05-25"); const viewRegion = page.getByRole("group", { name: "Calendar view", exact: true }); const initialX = await viewRegion.evaluate((element) => element.getBoundingClientRect().x); for (const view of ["Day", "Month", "Year", "Week"] as const) { - await page.getByRole("radio", { name: view, exact: true }).click(); + await selectCalendarView(page, view); await expect(page).toHaveURL(new RegExp(`view=${view.toLowerCase()}`)); await expect.poll(async () => viewRegion.evaluate((element) => element.getBoundingClientRect().x)) .toBeCloseTo(initialX, 1); @@ -47,7 +52,7 @@ test("Calendar positions the work hour until the user claims the viewport", asyn await page.goto("/demo/calendar?view=week&date=2026-05-25"); const viewport = page.locator("[data-calendar-time-viewport]"); await expect(viewport).toBeVisible(); - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(7 * 72); + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(7 * 72 - 16); await viewport.evaluate((element) => { element.scrollTop = 640; @@ -86,7 +91,7 @@ test("Calendar keeps event time visible, hints an empty creation time, and opens await expect(title).toHaveValue("고객사 싱크"); const productFont = await page.locator('[data-ui-component="product-shell"]').evaluate((element) => getComputedStyle(element).fontFamily); - await expect(page.getByRole("radio", { name: "Week", exact: true })).toHaveCSS("font-family", productFont); + await expect(page.getByRole("button", { name: "View", exact: true })).toHaveCSS("font-family", productFont); await expect(event).toHaveCSS("font-family", productFont); await expect(time).toHaveCSS("font-family", productFont); await expect(title).toHaveCSS("font-family", productFont); @@ -111,7 +116,8 @@ test("Calendar distinguishes the selection set, primary event, move surface, and await expect(first).toHaveAttribute("data-selected", "true"); await expect(first).toHaveAttribute("data-primary", "true"); await expect(first).toHaveCSS("cursor", "grab"); - await expect(first).toHaveCSS("outline-width", "1px"); + await expect(first).toHaveCSS("outline-width", "2px"); + expect(await first.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none"); await expect(endHandle).toHaveCSS("cursor", "row-resize"); await expect(endHandle).not.toHaveAttribute("data-active", "true"); @@ -129,7 +135,7 @@ test("Calendar distinguishes the selection set, primary event, move surface, and await second.click({ modifiers: ["ControlOrMeta"] }); await expect(first).toHaveAttribute("data-selected", "true"); await expect(first).not.toHaveAttribute("data-primary", "true"); - await expect(first).toHaveCSS("outline-width", "1px"); + expect(await first.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none"); await expect(second).toHaveAttribute("data-selected", "true"); await expect(second).toHaveAttribute("data-primary", "true"); await expect(page.getByRole("button", { name: "Resize 고객사 싱크 end", exact: true })).toHaveCount(1); @@ -137,6 +143,32 @@ test("Calendar distinguishes the selection set, primary event, move surface, and await expect(page.getByRole("button", { name: "Resize 점심 end", exact: true })).toBeVisible(); }); +test("Calendar clears floating controls while a card is moving", async ({ page }) => { + await page.goto("/demo/calendar?view=week&date=2026-05-25"); + const event = page.getByRole("button", { name: "고객사 싱크", exact: true }); + await event.click(); + await expect(page.getByRole("region", { name: "Event" })).toBeVisible(); + + const box = await event.boundingBox(); + if (box === null) throw new Error("Calendar event must have a drag surface"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 + 40); + + await expect(event).toHaveAttribute("data-ui-interaction-phase", "dragging"); + await expect.poll(async () => event.evaluate((element) => { + const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform); + return { x: matrix.m41, y: matrix.m42, scaleX: matrix.a, scaleY: matrix.d }; + })).toEqual({ x: 0, y: 0, scaleX: 1, scaleY: 1 }); + await expect(page.getByRole("toolbar", { name: "Calendar controls" })).toHaveCount(0); + await expect(page.getByRole("region", { name: "Event" })).toHaveCount(0); + await expect(page.getByRole("group", { name: "AI Composer preview" })).toHaveCount(0); + + await page.mouse.up(); + await expect(page.getByRole("toolbar", { name: "Calendar controls" })).toBeVisible(); + await expect(page.getByRole("group", { name: "AI Composer preview" })).toBeVisible(); +}); + test("Calendar details follow the primary occurrence and flip inside a narrow viewport", async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 }); await page.goto("/demo/calendar?view=week&date=2026-05-25"); @@ -148,7 +180,7 @@ test("Calendar details follow the primary occurrence and flip inside a narrow vi await expect(details).toHaveAttribute("data-floating-fits", "true"); await expect(leftEvent.locator("..")).toHaveAttribute("data-calendar-event-anchor", "primary"); - const rightEvent = page.getByRole("button", { name: "월말 비용 정리", exact: true }); + const rightEvent = page.getByRole("button", { name: "매일 뉴스 브리핑", exact: true }).last(); await rightEvent.click(); await expect(details).toHaveAttribute("data-floating-placement", "left-start"); await expect(details).toHaveAttribute("data-floating-fits", "true"); @@ -172,15 +204,15 @@ test("Calendar month and year views show date grids", async ({ page }) => { await page.goto("/demo/calendar?view=month&date=2026-05-25"); await expect(page.getByRole("grid", { name: "Month", exact: true })).toBeVisible(); await expect(page.getByRole("gridcell", { name: "2026-05-25", exact: true })).toContainText("25"); - await expect(page.getByRole("radio", { name: "Month", exact: true })).toBeChecked(); - await page.getByLabel("Calendar sources", { exact: true }).focus(); + await expect(page.getByRole("button", { name: "View", exact: true })).toContainText("Month"); + await page.getByRole("group", { name: "Calendar sources", exact: true }).getByLabel("Calendar sources", { exact: true }).focus(); await expect(page.getByRole("grid", { name: "Jump 2026-05", exact: true })).toBeVisible(); await expect(page.getByRole("gridcell", { name: "2026-04-27", exact: true }).first()).toHaveText("27"); await page.getByRole("button", { name: "Next month", exact: true }).click(); await expect(page.getByRole("grid", { name: "Jump 2026-06", exact: true })).toBeVisible(); - await page.getByRole("radio", { name: "Month", exact: true }).press("ArrowRight"); + await selectCalendarView(page, "Year"); await expect(page.getByRole("grid", { name: "2026-05", exact: true })).toBeVisible(); const mayGrid = page.getByRole("region", { name: "2026-05", exact: true }).getByRole("grid", { name: "2026-05", exact: true }); await expect(mayGrid.getByRole("gridcell", { name: "2026-05-01" })).toHaveText("1"); @@ -226,7 +258,7 @@ test("Calendar location writes one search snapshot and restores on back", async await page.goto("/demo/calendar?view=month&date=2026-05-25"); await expect(page.getByRole("grid", { name: "Month", exact: true })).toBeVisible(); - await page.getByRole("radio", { name: "Week", exact: true }).click(); + await selectCalendarView(page, "Week"); await expect(page.getByRole("grid", { name: "Week", exact: true })).toBeVisible(); await expect(page).toHaveURL(/view=week/); await expect(page).toHaveURL(/date=2026-05-25/); @@ -239,8 +271,8 @@ test("Calendar location writes one search snapshot and restores on back", async test("Calendar invalid search falls back to the fixture week", async ({ page }) => { await page.goto("/demo/calendar?view=agenda&date=nope"); await expect(page.getByRole("grid", { name: "Week", exact: true })).toBeVisible(); - await expect(page.getByRole("radio", { name: "Week", exact: true })).toBeChecked(); - await expect(page.getByText("2026-05-25 – 2026-05-31", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "View", exact: true })).toContainText("Week"); + await expect(page.getByText("May 24 – 30, 2026", { exact: true })).toBeVisible(); }); test("Calendar Usage embed does not write view search params", async ({ page }) => { @@ -248,7 +280,7 @@ test("Calendar Usage embed does not write view search params", async ({ page }) const usage = page.locator('[data-live-demo="/demo/calendar"]'); await expect(usage).toHaveCount(1); await usage.evaluate((element) => element.scrollIntoView({ block: "center" })); - await usage.getByRole("radio", { name: "Month", exact: true }).click(); + await selectCalendarView(usage, "Month"); await expect(usage.getByRole("grid", { name: "Month", exact: true })).toBeVisible(); await expect(page).not.toHaveURL(/view=/); }); diff --git a/site/tests/browser/calendar-launch.spec.ts b/site/tests/browser/calendar-launch.spec.ts index 6bd0b3309..94e545d2f 100644 --- a/site/tests/browser/calendar-launch.spec.ts +++ b/site/tests/browser/calendar-launch.spec.ts @@ -23,6 +23,7 @@ test("Calendar launch shows a week interval editor then month view", async ({ pa await title.press("Escape"); await expect(page.getByRole("button", { name: "Event", exact: true })).toHaveCount(0); - await page.getByRole("radio", { name: "Month", exact: true }).click(); + await page.getByRole("button", { name: "View", exact: true }).click(); + await page.getByRole("option", { name: "Month", exact: true }).click(); await expect(page.getByRole("grid", { name: "Month", exact: true })).toBeVisible(); }); diff --git a/site/tests/browser/calendar-spa.spec.ts b/site/tests/browser/calendar-spa.spec.ts index 5fda2e0d3..a37e26265 100644 --- a/site/tests/browser/calendar-spa.spec.ts +++ b/site/tests/browser/calendar-spa.spec.ts @@ -11,7 +11,7 @@ test("Calendar Create and mini-month jump keep the current view", async ({ page await expect(inspector.getByLabel("End", { exact: true })).toHaveValue("2026-05-25T10:30"); await expect(page.getByRole("grid", { name: "Week", exact: true }).getByRole("button", { name: "Event", exact: true })).toBeVisible(); - await page.getByLabel("Calendar sources", { exact: true }).focus(); + await page.locator('[data-ui-component="contextual-controls"][aria-label="Calendar sources"]').focus(); const dateGrid = page.getByRole("region", { name: "Jump to date" }).getByRole("grid", { name: "Jump 2026-05", exact: true }); await dateGrid.getByRole("gridcell", { name: "2026-05-26", exact: true }).click(); await expect(page.getByRole("grid", { name: "Week", exact: true })).toBeVisible(); @@ -25,7 +25,7 @@ test("Calendar Create and mini-month jump keep the current view", async ({ page test("Calendar c hotkey creates a timed event on the visible date", async ({ page }) => { await page.goto("/demo/calendar?view=week&date=2026-05-25"); - await page.getByRole("radio", { name: "Week", exact: true }).focus(); + await page.getByRole("button", { name: "View", exact: true }).focus(); await page.keyboard.press("c"); const inspector = page.getByRole("region", { name: "Event" }); await expect(inspector.getByRole("textbox", { name: "Title" })).toHaveValue("Event"); @@ -35,7 +35,7 @@ test("Calendar c hotkey creates a timed event on the visible date", async ({ pag test("Calendar view and period hotkeys use the canonical Web keymap", async ({ page }) => { await page.goto("/demo/calendar?view=week&date=2026-05-25"); - await page.getByRole("radio", { name: "Week", exact: true }).focus(); + await page.getByRole("button", { name: "View", exact: true }).focus(); await page.keyboard.press("m"); await expect(page).toHaveURL(/view=month/); await expect(page.getByRole("gridcell", { name: "2026-05-25", exact: true })).toBeVisible(); @@ -97,7 +97,7 @@ test("Calendar month empty drag paints an all-day span without leaving month vie await expect(eventBars).toHaveCount(2); await expect.poll(() => eventBars.evaluateAll((bars) => ( bars.map((bar) => bar.getAttribute("data-calendar-span")).sort() - ))).toEqual(["1", "3"]); + ))).toEqual(["2", "2"]); }); test("Calendar month all-day bar resizes by its end handle", async ({ page }) => { @@ -121,7 +121,6 @@ test("Calendar month all-day bar resizes by its end handle", async ({ page }) => await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + end.width, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); - await expect(month.locator("[data-calendar-span=\"3\"]")).toBeVisible(); await page.keyboard.press("F2"); const inspector = page.getByRole("region", { name: "Event" }); await expect(inspector.getByRole("textbox", { name: "Title" })).toHaveValue("Event"); @@ -144,9 +143,9 @@ test("Calendar empty month cell click clears selection and does not create", asy }); test("Calendar Home and Work chips use distinct fill tokens", async ({ page }) => { - await page.goto("/demo/calendar?view=week&date=2026-05-25"); + await page.goto("/demo/calendar?view=month&date=2026-05-25"); const home = page.getByRole("button", { name: "휴일", exact: true }); - const work = page.getByRole("button", { name: "점심", exact: true }); + const work = page.getByRole("button", { name: /경쟁사 가격 모니터링/ }).first(); await expect(home).toHaveAttribute("data-calendar-color", "accent"); await expect(work).toHaveAttribute("data-calendar-color", "subtle"); }); diff --git a/site/tests/browser/calendar.spec.ts b/site/tests/browser/calendar.spec.ts index 63719f016..43ac3536b 100644 --- a/site/tests/browser/calendar.spec.ts +++ b/site/tests/browser/calendar.spec.ts @@ -18,7 +18,7 @@ test("Calendar Hands edits one interval across day, week, month, and year views" await expect(tuesday.getByRole("button", { name: "주간 사용량 리포트 요약", exact: true })).toBeVisible(); await page.getByRole("button", { name: "주간 사용량 리포트 요약", exact: true }).click(); - await page.getByRole("button", { name: "Delete", exact: true }).click(); + await page.keyboard.press("Delete"); await expect(page.getByRole("button", { name: "주간 사용량 리포트 요약", exact: true })).toHaveCount(0); await page.keyboard.press("ControlOrMeta+z"); await expect(page.getByRole("button", { name: "주간 사용량 리포트 요약", exact: true })).toBeVisible(); @@ -37,20 +37,20 @@ test("Calendar Hands edits one interval across day, week, month, and year views" expect(afterResize).not.toBeNull(); expect(afterResize!.width).toBeGreaterThan(beforeResize!.width); - await page.getByRole("radio", { name: "Month", exact: true }).click(); + await selectCalendarView(page, "Month"); const emptyMonthDay = page.getByRole("gridcell", { name: "2026-05-23", exact: true }); await emptyMonthDay.dblclick(); const month = page.getByRole("grid", { name: "Month", exact: true }); await expect(month.getByRole("button", { name: "Event", exact: true }).first()).toBeVisible(); - await page.getByRole("radio", { name: "Year", exact: true }).click(); + await selectCalendarView(page, "Year"); const may = page.getByRole("region", { name: "2026-05", exact: true }); await expect(page.getByRole("grid", { name: "2026-05", exact: true })).toBeVisible(); await may.getByRole("button", { name: "May", exact: true }).click(); await expect(page.getByRole("grid", { name: "Month", exact: true })).toBeVisible(); await expect(page.getByRole("grid", { name: "Month", exact: true }).getByRole("button", { name: "Event", exact: true }).first()).toBeVisible(); - await page.getByRole("radio", { name: "Year", exact: true }).click(); + await selectCalendarView(page, "Year"); await page.getByRole("region", { name: "2026-05", exact: true }).getByRole("gridcell", { name: "2026-05-01", exact: true }).click(); await expect(page.getByRole("grid", { name: "Day", exact: true })).toBeVisible(); const dayGrid = timeGridDay(page, "2026-05-01"); @@ -99,8 +99,8 @@ test("Calendar drags the selected occurrence set with one temporal delta and one await dragBy(page, first, 0, 72); const movedFirst = page.getByRole("button", { name: "경쟁사 가격 모니터링", exact: true }); const movedSecond = page.getByRole("button", { name: "고객사 싱크", exact: true }); - await expect(movedFirst).toContainText("08:15"); - await expect(movedSecond).toContainText("12:15"); + await expect(movedFirst).toContainText("08:00"); + await expect(movedSecond).toContainText("12:00"); await expect(movedFirst).toHaveAttribute("data-selected", "true"); await expect(movedSecond).toHaveAttribute("data-selected", "true"); @@ -157,7 +157,7 @@ test("Calendar occurrence selection shares replace, toggle, extend, focus, and c await expect(first).toHaveAttribute("data-selected", "true"); await expect(second).toHaveAttribute("data-selected", "true"); - await page.getByRole("radio", { name: "Month", exact: true }).click(); + await selectCalendarView(page, "Month"); const month = page.getByRole("grid", { name: "Month", exact: true }); await expect(month.getByRole("gridcell", { name: "2026-05-25", exact: true })).toHaveAttribute("aria-selected", "true"); const monthFirst = month.getByRole("button").filter({ hasText: "경쟁사 가격 모니터링" }).first(); @@ -234,6 +234,11 @@ function timeGridDay(page: Page, day: string): Locator { return page.locator(`[data-calendar-grid="time"][data-calendar-day="${day}"]`); } +async function selectCalendarView(page: Page, view: "Day" | "Week" | "Month" | "Year"): Promise { + await page.getByRole("button", { name: "View", exact: true }).click(); + await page.getByRole("option", { name: view, exact: true }).click(); +} + async function dragWithin(page: Page, target: Locator, fromRatio: number, toRatio: number): Promise { const box = await target.boundingBox(); if (box === null) throw new Error("Calendar time-grid day is not visible."); diff --git a/site/tests/browser/design-system-retrofit.spec.ts b/site/tests/browser/design-system-retrofit.spec.ts index a134f7926..d42d96600 100644 --- a/site/tests/browser/design-system-retrofit.spec.ts +++ b/site/tests/browser/design-system-retrofit.spec.ts @@ -53,5 +53,5 @@ test("Demo chrome and selection use quiet, separated state grammar", async ({ pa expect(state.background).not.toBe("rgba(0, 0, 0, 0)"); expect(state.border).not.toBe("rgb(222, 109, 85)"); expect(state.outline).toBe("rgba(0, 0, 0, 0)"); - expect(state.boxShadow).toBe("none"); + expect(state.boxShadow).toContain("inset"); }); diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index fde220753..4589ea09d 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -468,7 +468,7 @@ test("docs chrome groups with paper and type instead of rest-state borders", asy }; })).toEqual({ backgroundColor: "rgb(251, 248, 242)", - borderColor: "rgb(216, 209, 197)", + borderColor: "rgba(0, 0, 0, 0)", outlineColor: "rgba(0, 0, 0, 0)", }); @@ -547,8 +547,8 @@ test("cat palette gives impact to interaction states and keeps code ink-led", as backgroundColor: getComputedStyle(element).backgroundColor, borderColor: getComputedStyle(element).borderColor, }))).toEqual({ - backgroundColor: "rgb(251, 248, 242)", - borderColor: "rgb(222, 109, 85)", + backgroundColor: "rgb(255, 255, 255)", + borderColor: "rgb(229, 231, 235) rgb(229, 231, 235) rgb(216, 209, 197)", }); expect(await page.getByRole("combobox").first().evaluate(controlSnapshot)).toMatchObject({ backgroundColor: "rgb(255, 255, 255)", diff --git a/site/tests/unit/calendar-product-design.test.ts b/site/tests/unit/calendar-product-design.test.ts index d9b4651dd..08a5c3aec 100644 --- a/site/tests/unit/calendar-product-design.test.ts +++ b/site/tests/unit/calendar-product-design.test.ts @@ -17,6 +17,7 @@ function assertNoAccentChrome(source: string, label: string): void { describe("calendar product design", () => { const styles = calendarDemoRecipe(); + const route = readFileSync(path.join(siteRoot, "src/routes/calendar-demo/CalendarDemoRoute.tsx"), "utf8"); test("event chips and calendar-visibility toggles use fill and type, not accent chrome", () => { assertNoAccentChrome(styles.timedEvent(), "timedEvent"); @@ -28,9 +29,14 @@ describe("calendar product design", () => { expect(styles.timedEvent()).not.toContain("data-[selected=true]"); expect(styles.timedEvent()).toContain("data-[calendar-color=accent]:bg-background-accent-subtle"); expect(styles.timedEvent()).toContain("rounded-control"); + expect(styles.timedEvent()).toContain("border-transparent"); + expect(styles.timedEvent()).toContain("bg-background-subtle/60"); + expect(styles.timedEvent()).toContain("[--offset-content-primary:0px]"); + expect(styles.timedEvent()).toContain("[--offset-content-dragging:0px]"); + expect(styles.timedEvent()).toContain("[--scale-content-dragging:1]"); expect(styles.resizeEdge()).not.toContain("bg-line-accent"); expect(styles.timedEvent()).not.toContain("data-[selected=true]:bg-background-subtle"); - expect(styles.monthTimed()).not.toContain("data-[calendar-color=accent]:bg-background-accent-subtle"); + expect(styles.monthTimed()).toContain("data-[calendar-color=accent]:bg-background-accent-subtle"); expect(styles.todayMark()).toContain("font-semibold"); expect(styles.todayMark()).toContain("text-foreground-strong"); expect(styles.todayMark()).not.toContain("bg-background-accent"); @@ -52,12 +58,24 @@ describe("calendar product design", () => { test("time grid keeps calendar structure visible without a full-column hover wash", () => { const css = readFileSync(path.join(siteRoot, "src/app/index.css"), "utf8"); - expect(styles.hourRule()).toContain("border-line-subtle/70"); - expect(styles.weekCell()).toContain("border-line-subtle/60"); - expect(styles.weekSticky()).toContain("border-line-subtle/60"); - expect(styles.monthWeek()).toContain("border-line-subtle/60"); + const timeGrid = readFileSync(path.resolve(siteRoot, "../packages/json-document-calendar/src/calendar-time-grid.tsx"), "utf8"); + expect(styles.hourRule()).toContain("border-line-subtle/20"); + expect(styles.weekCell()).toContain("border-line-subtle/15"); + expect(styles.weekSticky()).toContain("border-line-subtle/20"); + expect(styles.weekSticky()).toContain("pt-14"); + expect(styles.monthWeek()).toContain("border-line-subtle/35"); expect(styles.creationTimeHint()).toContain("text-foreground-muted/55"); + expect(styles.creationTimeHint()).toContain("translate-y-1"); + expect(styles.creationTimeHint()).not.toContain("-translate-y-1/2"); + expect(route).toContain("const eventInspectorVisible = selectedEvent !== null && !pointerGestureActive"); + expect(route).toContain("eventInspectorVisible ? { ]); expect((await discoverDemoSources("routes/widgets/BoardWidgetRoute.tsx")).map((file) => file.path)).toEqual([ "routes/widgets/BoardWidgetRoute.tsx", + "packages/json-document-ui-primitives-react/src/content-interaction.ts", + "packages/json-document-affordance/src/content-interaction.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-editing/src/kanban.ts", "packages/json-document-web/src/kanban-drop-target.ts",