Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fb1a205
캘린더 콘텐츠와 플로팅 컨트롤 레이어 분리
Aug 31, 2026
56f511c
캘린더 플로팅 표면 곡률 정리
Aug 31, 2026
22c7ddd
캘린더 콘텐츠 시간 위계 정리
Aug 31, 2026
57f580e
캘린더 콘텐츠 글자를 Markdown 계층으로 통합
Aug 31, 2026
b693431
주간 캘린더 표준 시간 배치 적용
Aug 31, 2026
ca0917b
시간 슬롯에 일정 카드와 드래그 정렬
Aug 31, 2026
1b85e59
커서 시간을 슬롯 내부에 배치
Aug 31, 2026
eba5017
드래그 중 일정 상세 창 숨김
Aug 31, 2026
127fbd2
feat: 콘텐츠 인터랙션 문법을 정본화한다
Sep 1, 2026
427278a
merge: 캘린더 디자인 고도화 작업을 통합한다
Sep 1, 2026
f354a84
feat: 캘린더 시각 밀도와 주간 문법을 다듬는다
Sep 1, 2026
8c17843
feat: 캘린더 레퍼런스 시각 문법을 강화한다
Sep 1, 2026
8893f24
feat: 플로팅 제품 표면 문법을 정본화한다
Sep 1, 2026
c613277
fix: 30분 일정의 텍스트 배분을 바로잡는다
Sep 1, 2026
76f74af
feat: primary 콘텐츠의 제어 준비 상태를 드러낸다
Sep 1, 2026
f289d35
fix: 일정 rail과 리사이즈 핸들의 역할을 구분한다
Sep 1, 2026
6acf324
fix: 일정 이동 중 플로팅 컨트롤을 비운다
Sep 1, 2026
c9d5b36
style: 캘린더 표면의 시각적 발언량을 낮춘다
Sep 1, 2026
2494fa0
fix: 드래그 카드의 그리드 위치 변형을 제거한다
Sep 1, 2026
6f4697e
fix: 패키지 CSS를 공개 경로로 참조한다
Sep 1, 2026
8877003
fix: CSS 서브패스와 API 문서를 배포 경계에 맞춘다
Sep 1, 2026
c752ea9
test: 캘린더 제어 문법과 그리드 계약을 동기화한다
Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/api-reference/affordance.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,48 @@ commitAffordance<H extends AffordanceHand>(result: AffordancePreview<H>): 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
Expand Down
6 changes: 6 additions & 0 deletions docs/api-reference/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -650,5 +655,6 @@ visiblePeriodLabel(period: CalendarPeriod, visibleDate: string, options?: Visibl
type VisiblePeriodLabelOptions = {
readonly monthNames?: ReadonlyArray<string>;
readonly weekSeparator?: string;
readonly dateStyle?: "iso" | "named";
};
```
39 changes: 37 additions & 2 deletions docs/api-reference/ui-primitives-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@ Command(props: Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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
Expand Down Expand Up @@ -129,7 +161,7 @@ FileDropRegion(props: Omit<HTMLAttributes<HTMLDivElement>, "onDrop"> & { readonl
## `GridCell`

```ts
GridCell(props: TdHTMLAttributes<HTMLTableCellElement> & { readonly selected: boolean; readonly focus?: boolean; }): import("<repository>/node_modules/@types/react/jsx-runtime").JSX.Element
GridCell(props: TdHTMLAttributes<HTMLTableCellElement> & { readonly selected: boolean; readonly primary?: boolean; readonly focus?: boolean; readonly active?: boolean; readonly dragging?: boolean; }): import("<repository>/node_modules/@types/react/jsx-runtime").JSX.Element
```
## `InteractionHandleBindingOptions`

Expand Down Expand Up @@ -206,7 +238,7 @@ ProductInspector(props: HTMLAttributes<HTMLDivElement>): ReactNode
## `ProductShell`

```ts
ProductShell(props: HTMLAttributes<HTMLDivElement> & { readonly toolbar?: ReactNode; readonly toolbarLabel?: string; readonly inspector?: ReactNode; readonly canvasClassName?: string; readonly fill?: boolean; }): ReactNode
ProductShell(props: HTMLAttributes<HTMLDivElement> & { readonly toolbar?: ReactNode; readonly toolbarLabel?: string; readonly toolbarPresentation?: "attached" | "floating"; readonly inspector?: ReactNode; readonly canvasClassName?: string; readonly fill?: boolean; }): ReactNode
```
## `ResizeHandle`

Expand Down Expand Up @@ -241,7 +273,10 @@ SelectableItem<T extends ElementType = "button">(props: SelectableItemProps<T>):
type SelectableItemProps<T extends ElementType = "button"> = {
readonly as?: T;
readonly selected: boolean;
readonly primary?: boolean;
readonly focus?: boolean;
readonly active?: boolean;
readonly dragging?: boolean;
} & ControlAffordanceProps & Omit<ComponentPropsWithoutRef<T>, "as" | "data-selected" | "data-focus">;
```
## `TabOption`
Expand Down
20 changes: 19 additions & 1 deletion docs/public/ui-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<SelectableItem selected={selected} active={pressed} dragging={moving}>Card</SelectableItem>
<GridCell selected={selected} active={pressed} dragging={moving}>Cell</GridCell>
<div {...contentInteractionAttributes({ role: "drop-target", active: over })} />
<div {...contentInteractionAttributes({ role: "insertion", active: emptySlot })} />
```

focus-visible outer ring은 이 상태들과 독립이며, reduced motion에서는 상태 정보는
유지하고 전환 시간만 제거합니다. toolbar의 pressed/toggle, 입력 caret, 브라우저의
native text selection은 이 콘텐츠 문법에 포함하지 않습니다.

### `ControlAffordance`

제품은 semantic role을 복제하지 않고 control이 현재 composition에서 어떻게
Expand Down
4 changes: 4 additions & 0 deletions packages/json-document-affordance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions packages/json-document-affordance/src/content-interaction.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
7 changes: 7 additions & 0 deletions packages/json-document-affordance/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
8 changes: 6 additions & 2 deletions packages/json-document-calendar/src/calendar-month-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@interactive-os/json-document-editing";
import { selectionModeFromModifiers } from "@interactive-os/json-document-react";
import {
contentInteractionAttributes,
Command,
ResizeHandle,
SelectableItem,
Expand Down Expand Up @@ -182,6 +183,7 @@ export const CalendarMonthGrid = forwardRef<CalendarMonthGridHandle, CalendarMon
classNames={props.classNames}
color={props.getEventColor(item.event)}
selected={hand.isOccurrenceSelected(item.event.id, item.event.start)}
dragging={hand.selectionDragPreview?.source.points.some((point) => point.eventId === item.event.id) ?? false}
primary={hand.isPrimaryOccurrence(item.event.id, item.event.start)}
preview={item.event.id === "preview"}
onPointerDown={(event) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>) => void;
Expand All @@ -338,7 +341,8 @@ function MonthEvent(props: {
<SelectableItem
affordance={props.affordance}
selected={props.selected}
data-primary={props.primary ? "true" : undefined}
primary={props.primary}
{...(props.dragging === undefined ? {} : { dragging: props.dragging })}
aria-label={calendarEventLabel(props.event)}
data-calendar-color={props.color}
data-calendar-move-surface={props.onPointerDown === undefined ? undefined : "true"}
Expand Down
19 changes: 11 additions & 8 deletions packages/json-document-calendar/src/calendar-time-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type CalendarOccurrenceTopologySnapshot,
} from "@interactive-os/json-document-editing";
import { selectionModeFromModifiers } from "@interactive-os/json-document-react";
import { ResizeHandle, SelectableItem, type ControlAffordance } from "@interactive-os/json-document-ui-primitives-react";
import { contentInteractionAttributes, ResizeHandle, SelectableItem, type ControlAffordance } from "@interactive-os/json-document-ui-primitives-react";
import { calendarTimeLabel, type CalendarCell } from "./date-values.js";
import type { CalendarHand } from "./use-calendar-hand.js";
import type { CalendarPointerInteractions } from "./use-calendar-pointer-interactions.js";
Expand Down Expand Up @@ -112,7 +112,7 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode {
if (start !== null && end !== null) hand.createInterval(start, end);
}

const gridColumns = `3.25rem repeat(${days.length}, minmax(4.5rem, 1fr))`;
const gridColumns = `4rem repeat(${days.length}, minmax(4.5rem, 1fr))`;
const gridHeight = (props.hourEnd - props.hourStart) * props.pixelsPerHour;
return (
<div
Expand All @@ -130,7 +130,7 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode {
<div className={props.classNames.columnHeader} />
{props.cells.map((cell) => (
<div key={cell.date} role="columnheader" className={props.classNames.columnHeader}>
<span className={props.classNames.weekday}>{props.weekdays[cell.weekday - 1]}</span>
<span className={props.classNames.weekday}>{props.weekdays[cell.weekday % 7]}</span>
<span className={joinClasses(props.classNames.dayNumber, cell.date === props.today ? props.classNames.today : undefined)}>{cell.day}</span>
</div>
))}
Expand All @@ -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}` }}
Expand All @@ -162,7 +162,8 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode {
<SelectableItem
affordance={props.affordances.allDayEvent}
selected={hand.isOccurrenceSelected(item.event.id, item.event.start)}
data-primary={hand.isPrimaryOccurrence(item.event.id, item.event.start) ? "true" : undefined}
primary={hand.isPrimaryOccurrence(item.event.id, item.event.start)}
dragging={hand.selectionDragPreview?.source.points.some((point) => point.eventId === item.event.id) ?? false}
aria-label={item.event.title}
data-calendar-color={props.getEventColor(item.event)}
data-calendar-move-surface="true"
Expand Down Expand Up @@ -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) => (
<div key={hour} data-calendar-hour={String(hour).padStart(2, "0")} className={props.classNames.hourLabel} style={{ top: (hour - props.hourStart) * props.pixelsPerHour + 4 }}>
{Array.from({ length: props.hourEnd - props.hourStart }, (_, index) => index === 0 ? null : props.hourStart + index).map((hour) => hour === null ? null : (
<div key={hour} data-calendar-hour={String(hour).padStart(2, "0")} className={props.classNames.hourLabel} style={{ top: (hour - props.hourStart) * props.pixelsPerHour }}>
{props.labels.hour(hour)}
</div>
))}
Expand Down Expand Up @@ -252,6 +253,7 @@ function TimeColumn(input: {
{input.selectedSlot?.startsWith(`${day}T`) ? (
<div
data-calendar-selected-slot={input.selectedSlot}
{...contentInteractionAttributes({ role: "insertion", active: true })}
data-ui-affordance={props.affordances.selectedSlot}
className={props.classNames.selectedSlot}
style={{
Expand Down Expand Up @@ -288,7 +290,8 @@ function TimeColumn(input: {
<SelectableItem
affordance={props.affordances.timedEvent}
selected={hand.isOccurrenceSelected(item.event.id, item.event.start)}
data-primary={hand.isPrimaryOccurrence(item.event.id, item.event.start) ? "true" : undefined}
primary={hand.isPrimaryOccurrence(item.event.id, item.event.start)}
dragging={hand.selectionDragPreview?.source.points.some((point) => point.eventId === item.event.id) ?? false}
aria-label={item.event.title}
data-calendar-event=""
data-calendar-color={props.getEventColor(item.event)}
Expand Down
Loading