Editing values
- What the widget reads
+ What this reads
{props.values.map((item) => (
diff --git a/site/src/routes/widgets/binding/history.ts b/site/src/routes/widgets/binding/history.ts
deleted file mode 100644
index 7dbd6f85..00000000
--- a/site/src/routes/widgets/binding/history.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export type HistoryCommandName = "undo" | "redo";
-
-export type HistoryCommand = {
- readonly name: HistoryCommandName;
- readonly disabled: boolean;
-};
-
-export type HistoryCommandMap = {
- readonly undo: HistoryCommand;
- readonly redo: HistoryCommand;
-};
-
-export function historyCommands(snapshot: {
- readonly canUndo: boolean;
- readonly canRedo: boolean;
-}): HistoryCommandMap {
- return {
- undo: { name: "undo", disabled: !snapshot.canUndo },
- redo: { name: "redo", disabled: !snapshot.canRedo },
- };
-}
diff --git a/site/src/routes/widgets/binding/index.ts b/site/src/routes/widgets/binding/index.ts
deleted file mode 100644
index 5807e801..00000000
--- a/site/src/routes/widgets/binding/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { historyCommands } from "./history";
-export type { HistoryCommand, HistoryCommandMap, HistoryCommandName } from "./history";
-export { gridCellProps, optionProps } from "./option";
-export { useOrderWidget } from "./order";
-export { useSheetWidget } from "./sheet";
diff --git a/site/src/routes/widgets/binding/keyboard.ts b/site/src/routes/widgets/binding/keyboard.ts
deleted file mode 100644
index d6371fc3..00000000
--- a/site/src/routes/widgets/binding/keyboard.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useState } from "react";
-import {
- createWebKeyboardAdapter,
- type WebKeyboardCommand,
-} from "@interactive-os/json-document-web";
-
-export function useWidgetKeyboard() {
- const [keyboard] = useState(() => createWebKeyboardAdapter());
- const [lastCommand, setLastCommand] = useState(null);
-
- return {
- lastCommand,
- resolve(stroke: Parameters[0]) {
- const command = keyboard.resolve(stroke);
- if (command) setLastCommand(command);
- return command;
- },
- };
-}
diff --git a/site/src/routes/widgets/binding/order.ts b/site/src/routes/widgets/binding/order.ts
deleted file mode 100644
index 4d4c8256..00000000
--- a/site/src/routes/widgets/binding/order.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import {
- type OrderDocument,
- type OrderEditor,
-} from "@interactive-os/json-document-editing";
-import { useEditing, type EditingKeyboardOptions } from "@interactive-os/json-document-react";
-import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web";
-import { historyCommands } from "./history";
-import { useWidgetKeyboard } from "./keyboard";
-import { optionProps } from "./option";
-
-export function useOrderWidget(editor: OrderEditor) {
- const keyboard = useWidgetKeyboard();
- const focusKey = orderFocusId(editor);
- const editing = useEditing({
- source: editor,
- selectedKeys: editor.selectedItemIds,
- focusKey,
- onSelect: (itemId, mode) => {
- editor.dispatch({ type: "selection.set", itemId, mode });
- },
- keyboard: orderKeyboard(editor, (stroke) => keyboard.resolve(stroke)),
- });
- const snapshot = editing.snapshot;
- const document = snapshot.value as OrderDocument;
-
- return {
- snapshot,
- document,
- focusKey: orderFocusId(editor),
- selectedKeys: editor.selectedItemIds,
- commands: historyCommands(snapshot),
- lastCommand: keyboard.lastCommand,
- getOption: (id: string) => optionProps(editing.getItem(id)),
- onKeyDown: editing.getKeyDownHandler(),
- };
-}
-
-function orderFocusId(editor: OrderEditor): string | null {
- const selection = editor.snapshot.selection;
- return selection.ranges[selection.primaryIndex ?? 0]?.focus.itemId ?? null;
-}
-
-function orderKeyboard(
- editor: OrderEditor,
- resolve: EditingKeyboardOptions["resolve"],
-): EditingKeyboardOptions {
- return {
- resolve,
- focusKey: () => orderFocusId(editor) ?? undefined,
- neighbor: (key, command) => {
- const ids = (editor.snapshot.value as OrderDocument).items.map((item) => item.id);
- return command.type === "move"
- ? moveLinePoint(ids, key, command.direction)
- : lineBoundary(ids, command.edge);
- },
- onDelete: () => {
- editor.dispatch({ type: "selection.remove" });
- },
- onUndo: () => {
- editor.undo();
- },
- onRedo: () => {
- editor.redo();
- },
- };
-}
diff --git a/site/src/routes/widgets/binding/sheet.ts b/site/src/routes/widgets/binding/sheet.ts
deleted file mode 100644
index 5040da6c..00000000
--- a/site/src/routes/widgets/binding/sheet.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import {
- gridTopology,
- type SheetDocument,
- type SheetEditor,
-} from "@interactive-os/json-document-editing";
-import { useEditing, type EditingKeyboardOptions } from "@interactive-os/json-document-react";
-import { gridBoundary, moveGridPoint } from "@interactive-os/json-document-web";
-import { useWidgetKeyboard } from "./keyboard";
-import { gridCellProps } from "./option";
-
-export function useSheetWidget(editor: SheetEditor) {
- const keyboard = useWidgetKeyboard();
- const editing = useEditing({
- source: editor,
- selectedKeys: editor.selectedCells.map((cell) => cellKey(cell.rowId, cell.columnId)),
- focusKey: sheetFocusKey(editor),
- onSelect: (key, mode) => {
- const { rowId, columnId } = parseCellKey(key);
- editor.dispatch({ type: "selection.set", rowId, columnId, mode });
- },
- keyboard: sheetKeyboard(editor, (stroke) => keyboard.resolve(stroke)),
- });
- const snapshot = editing.snapshot;
- const document = snapshot.value as SheetDocument;
- const topology = gridTopology(
- document.rows.map((row) => row.id),
- document.columns.map((column) => column.id),
- );
-
- return {
- snapshot,
- document,
- topology,
- selectedCells: editor.selectedCells.map((cell) => ({ rowId: cell.rowId, columnId: cell.columnId })),
- lastCommand: keyboard.lastCommand,
- getCell: (rowId: string, columnId: string) => gridCellProps(editing.getItem(cellKey(rowId, columnId))),
- onKeyDown: editing.getKeyDownHandler(),
- };
-}
-
-function sheetFocusKey(editor: SheetEditor): string | null {
- const focus = editor.snapshot.selection.focus;
- return focus ? cellKey(focus.rowId, focus.columnId) : null;
-}
-
-function sheetKeyboard(
- editor: SheetEditor,
- resolve: EditingKeyboardOptions["resolve"],
-): EditingKeyboardOptions {
- return {
- resolve,
- focusKey: () => sheetFocusKey(editor) ?? undefined,
- neighbor: (key, command) => {
- const sheet = editor.snapshot.value as SheetDocument;
- const visible = gridTopology(
- sheet.rows.map((row) => row.id),
- sheet.columns.map((column) => column.id),
- );
- const current = parseCellKey(key);
- const next = command.type === "move"
- ? moveGridPoint(visible, current, command.direction)
- : gridBoundary(visible, current, command.edge);
- return next ? cellKey(next.rowId, next.columnId) : null;
- },
- onDelete: () => {
- editor.dispatch({ type: "selection.fill", value: null });
- },
- onUndo: () => {
- editor.undo();
- },
- onRedo: () => {
- editor.redo();
- },
- };
-}
-
-function cellKey(rowId: string, columnId: string): string {
- return `${rowId}\u0000${columnId}`;
-}
-
-function parseCellKey(key: string): { readonly rowId: string; readonly columnId: string } {
- const split = key.indexOf("\u0000");
- return { rowId: key.slice(0, split), columnId: key.slice(split + 1) };
-}
diff --git a/site/src/shared/widget-binding/history.ts b/site/src/shared/widget-binding/history.ts
new file mode 100644
index 00000000..e0b0a306
--- /dev/null
+++ b/site/src/shared/widget-binding/history.ts
@@ -0,0 +1,25 @@
+import {
+ applyAffordance,
+ historyAffordance,
+ type HistoryAffordance,
+ type HistoryAffordanceMap,
+ type HistoryAffordanceName,
+} from "@interactive-os/json-document-affordance";
+
+export type { HistoryAffordance as HistoryCommand, HistoryAffordanceMap as HistoryCommandMap, HistoryAffordanceName as HistoryCommandName };
+
+export function historyCommands(snapshot: {
+ readonly canUndo: boolean;
+ readonly canRedo: boolean;
+}): HistoryAffordanceMap {
+ let commands: HistoryAffordanceMap = {
+ undo: { name: "undo", disabled: true },
+ redo: { name: "redo", disabled: true },
+ };
+ applyAffordance(historyAffordance(snapshot), {
+ hand: (hand) => {
+ if (hand.type === "history") commands = { undo: hand.undo, redo: hand.redo };
+ },
+ });
+ return commands;
+}
diff --git a/site/src/shared/widget-binding/index.ts b/site/src/shared/widget-binding/index.ts
new file mode 100644
index 00000000..1efffc91
--- /dev/null
+++ b/site/src/shared/widget-binding/index.ts
@@ -0,0 +1,4 @@
+export { historyCommands } from "./history";
+export type { HistoryCommand, HistoryCommandMap, HistoryCommandName } from "./history";
+export { editingCommandFromStroke, useWidgetKeyboard } from "./keyboard";
+export { gridCellProps, optionProps, treeItemProps } from "./option";
diff --git a/site/src/shared/widget-binding/keyboard.ts b/site/src/shared/widget-binding/keyboard.ts
new file mode 100644
index 00000000..b6af567a
--- /dev/null
+++ b/site/src/shared/widget-binding/keyboard.ts
@@ -0,0 +1,43 @@
+import { useState } from "react";
+import {
+ applyAffordance,
+ resolveAffordanceKey,
+} from "@interactive-os/json-document-affordance";
+import {
+ createWebKeyboardAdapter,
+ type WebKeyboardCommand,
+ type WebKeyboardStroke,
+} from "@interactive-os/json-document-web";
+
+export function editingCommandFromStroke(stroke: WebKeyboardStroke): WebKeyboardCommand | null {
+ let command: WebKeyboardCommand | null = null;
+ applyAffordance(resolveAffordanceKey(stroke), {
+ hand: (hand) => {
+ if (
+ hand.type === "move"
+ || hand.type === "boundary"
+ || hand.type === "toggle"
+ || hand.type === "delete"
+ || hand.type === "undo"
+ || hand.type === "redo"
+ ) {
+ command = hand;
+ }
+ },
+ });
+ return command;
+}
+
+export function useWidgetKeyboard() {
+ const [keyboard] = useState(() => createWebKeyboardAdapter());
+ const [lastCommand, setLastCommand] = useState(null);
+
+ return {
+ lastCommand,
+ resolve(stroke: Parameters[0]) {
+ const command = keyboard.resolve(stroke);
+ if (command) setLastCommand(command);
+ return command;
+ },
+ };
+}
diff --git a/site/src/routes/widgets/binding/option.ts b/site/src/shared/widget-binding/option.ts
similarity index 77%
rename from site/src/routes/widgets/binding/option.ts
rename to site/src/shared/widget-binding/option.ts
index e9e690e8..4bd83aa1 100644
--- a/site/src/routes/widgets/binding/option.ts
+++ b/site/src/shared/widget-binding/option.ts
@@ -17,3 +17,10 @@ export function gridCellProps(item: EditingItem) {
tabIndex: item.getIsFocus() ? 0 : -1,
} as const;
}
+
+export function treeItemProps(item: EditingItem) {
+ return {
+ ...optionProps(item),
+ role: "treeitem" as const,
+ } as const;
+}
diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts
index 95b2c4e7..714ca74f 100644
--- a/site/tests/browser/site-shell.spec.ts
+++ b/site/tests/browser/site-shell.spec.ts
@@ -15,10 +15,10 @@ test("official overview exposes the product hierarchy", async ({ page }) => {
"JSON Document",
"Collaboration",
"Editing",
- "Hands",
"Adapter",
"Connector",
- "제품 화면",
+ "Affordance",
+ "Hands",
]);
await expect(navigation.getByRole("link", { name: "Why" })).toHaveCount(0);
await expect(navigation.getByRole("link", { name: "Replica" })).toHaveCount(0);
@@ -60,18 +60,44 @@ test("official overview exposes the product hierarchy", async ({ page }) => {
await expect(navigation.getByRole("group", { name: "Adapter" }).getByRole("link")).toHaveText(["Keyboard", "Clipboard adapter", "Contenteditable"]);
await navigation.getByRole("button", { name: "Connector" }).click();
await expect(navigation.getByRole("group", { name: "Connector" }).getByRole("link")).toHaveText(["React", "React Hook Form", "Ajv", "Zod", "TanStack Table"]);
- await navigation.getByRole("button", { name: "제품 화면" }).click();
- await expect(navigation.getByRole("group", { name: "제품 화면" }).getByRole("link")).toHaveText(["Toolbar", "Listbox", "Grid"]);
+ await navigation.getByRole("button", { name: "Affordance" }).click();
+ await expect(navigation.getByRole("group", { name: "Affordance" }).getByRole("link")).toHaveText([
+ "Focus",
+ "Caret",
+ "Select",
+ "Typeahead",
+ "Activate",
+ "Escape",
+ "Expand/Collapse",
+ "Undo",
+ "Delete",
+ "Rename",
+ "Nudge",
+ "Hover",
+ "Double-click",
+ "Triple-click",
+ "Context menu",
+ "Drag",
+ "Marquee",
+ "Drop",
+ "Duplicate",
+ "Resize",
+ "Pan",
+ "Scroll",
+ "Zoom",
+ "Snap",
+ "Not-allowed",
+ ]);
await expect(navigation.getByRole("group", { name: "Demos" })).toHaveCount(0);
await expect(navigation.getByRole("group", { name: "Reference" })).toHaveCount(0);
expect(await navigation.getByRole("group").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("aria-label")))).toEqual([
"JSON Document",
"Collaboration",
"Editing",
- "Hands",
"Adapter",
"Connector",
- "제품 화면",
+ "Affordance",
+ "Hands",
]);
await expect(navigation.getByRole("link", { name: "Extensions" })).toHaveCount(0);
expect(requests.some(isLegacyRequest)).toBe(false);
@@ -89,7 +115,7 @@ test("mobile navigation preserves the product groups without duplicating documen
await expect(siteNavigation.getByRole("group", { name: "Adapter" })).toBeVisible();
await expect(siteNavigation.getByRole("group", { name: "Demos" })).toHaveCount(0);
await expect(siteNavigation.getByRole("group", { name: "Connector" })).toBeVisible();
- await expect(siteNavigation.getByRole("group", { name: "제품 화면" })).toBeVisible();
+ await expect(siteNavigation.getByRole("group", { name: "Affordance" })).toBeVisible();
await expect(siteNavigation.getByRole("group", { name: "Collaboration" })).toBeVisible();
await page.goto("/docs/tutorial");
diff --git a/site/tests/browser/widgets.spec.ts b/site/tests/browser/widgets.spec.ts
index ca844845..866bea96 100644
--- a/site/tests/browser/widgets.spec.ts
+++ b/site/tests/browser/widgets.spec.ts
@@ -1,11 +1,21 @@
import { expect, test, type Page } from "@playwright/test";
-test("Widgets catalog lists only editing widgets", async ({ page }) => {
+test("Widgets catalog redirects to affordance usage", async ({ page }) => {
await page.goto("/widgets");
- await expect(page.getByRole("heading", { level: 1, name: "제품 화면" })).toBeVisible();
- await expect(page.getByRole("link", { name: "Open Toolbar" })).toHaveAttribute("href", "/widgets/toolbar");
- await expect(page.getByRole("link", { name: "Open Listbox" })).toHaveAttribute("href", "/widgets/listbox");
- await expect(page.getByRole("link", { name: "Open Grid" })).toHaveAttribute("href", "/widgets/grid");
+ await expect(page).toHaveURL(/\/docs\/affordance$/);
+ await expect(page.getByRole("heading", { level: 1, name: "Affordance" })).toBeVisible();
+ const navigation = page.getByRole("navigation", { name: "Site navigation" });
+ await expect(navigation.getByRole("link", { name: "Select", exact: true })).toHaveAttribute("href", "/docs/affordance/select");
+ await expect(navigation.getByRole("link", { name: "Expand/Collapse", exact: true })).toHaveAttribute("href", "/docs/affordance/fold");
+ await expect(navigation.getByRole("link", { name: "Drag", exact: true })).toHaveAttribute("href", "/docs/affordance/drag");
+ await expect(navigation.getByRole("link", { name: "Undo", exact: true })).toHaveAttribute("href", "/docs/affordance/history");
+ const content = page.getByRole("main");
+ await expect(content.getByRole("link", { name: "Select" }).first()).toHaveAttribute("href", "/docs/affordance/select");
+ await expect(content.getByRole("link", { name: "Expand/Collapse" }).first()).toHaveAttribute("href", "/docs/affordance/fold");
+ await expect(content.getByRole("link", { name: "Drag" }).first()).toHaveAttribute("href", "/docs/affordance/drag");
+ await expect(content.getByRole("link", { name: "Undo" }).first()).toHaveAttribute("href", "/docs/affordance/history");
+ await expect(content.getByRole("link", { name: "Focus" }).first()).toHaveAttribute("href", "/docs/affordance/focus");
+ await expect(content.getByRole("link", { name: "Resize" }).first()).toHaveAttribute("href", "/docs/affordance/resize");
});
test("Toolbar binds Undo and Redo to canUndo and canRedo", async ({ page }) => {
@@ -36,6 +46,13 @@ test("Toolbar binds Undo and Redo to canUndo and canRedo", async ({ page }) => {
expect(await json(page, "widget-toolbar-keyboard")).toEqual({ type: "undo" });
});
+test("Listbox typeahead jumps to the matching label", async ({ page }) => {
+ await page.goto("/widgets/listbox");
+ await page.getByRole("listbox", { name: "Order items" }).focus();
+ await page.keyboard.type("T");
+ expect(await json(page, "widget-listbox-selected")).toEqual(["today"]);
+});
+
test("Listbox reads selected keys and focus from Order", async ({ page }) => {
await page.goto("/widgets/listbox");
await page.getByRole("option", { name: "Today" }).click();
@@ -88,6 +105,92 @@ test("Grid reads topology and selected cells from Sheet", async ({ page }) => {
});
});
+test("Document reads selected keys, focus, and text offset", async ({ page }) => {
+ await page.goto("/widgets/document");
+ await page.getByRole("option", { name: "Select a range" }).click();
+ expect(await json(page, "widget-document-selected")).toEqual(["select"]);
+ expect(await json(page, "widget-document-focus")).toBe("select");
+ expect(await json(page, "widget-document-offset")).toEqual({
+ write: null,
+ select: 0,
+ move: null,
+ });
+});
+
+test("Canvas reads selected objects on a plane", async ({ page }) => {
+ await page.goto("/widgets/canvas");
+ await page.getByRole("option", { name: "Card" }).click();
+ expect(await json(page, "widget-canvas-selected")).toEqual(["card"]);
+ expect(await json(page, "widget-canvas-focus")).toBe("card");
+});
+
+test("Tree reads visible topology and selected keys", async ({ page }) => {
+ await page.goto("/widgets/tree");
+ expect(await json(page, "widget-tree-topology")).toEqual({
+ visibleIds: ["fruit", "apple", "pear", "veg", "kale"],
+ });
+ await page.getByRole("treeitem", { name: "Apple" }).click();
+ expect(await json(page, "widget-tree-selected")).toEqual(["apple"]);
+ expect(await json(page, "widget-tree-focus")).toBe("apple");
+});
+
+test("Tree left collapses and right expands the focused parent", async ({ page }) => {
+ await page.goto("/widgets/tree");
+ await page.getByRole("treeitem", { name: "Fruit" }).click();
+ await page.keyboard.press("ArrowLeft");
+ expect(await json(page, "widget-tree-topology")).toEqual({
+ visibleIds: ["fruit", "veg", "kale"],
+ });
+ await page.keyboard.press("ArrowRight");
+ expect(await json(page, "widget-tree-topology")).toEqual({
+ visibleIds: ["fruit", "apple", "pear", "veg", "kale"],
+ });
+});
+
+test("Board reads columns and selected cards", async ({ page }) => {
+ await page.goto("/widgets/board");
+ expect(await json(page, "widget-board-columns")).toEqual([
+ { id: "todo", cardIds: ["write", "review"] },
+ { id: "doing", cardIds: ["draw"] },
+ { id: "done", cardIds: [] },
+ ]);
+ await page.getByRole("option", { name: "Draw the board" }).click();
+ expect(await json(page, "widget-board-selected")).toEqual(["draw"]);
+ expect(await json(page, "widget-board-focus")).toBe("draw");
+});
+
+test("Board modifier click toggles cards and drag moves a card", async ({ page }) => {
+ await page.goto("/widgets/board");
+ await page.getByRole("option", { name: "Write the brief" }).click();
+ await page.keyboard.down("ControlOrMeta");
+ await page.getByRole("option", { name: "Review copy" }).click();
+ await page.keyboard.up("ControlOrMeta");
+ expect(await json(page, "widget-board-selected")).toEqual(["write", "review"]);
+
+ await page.getByRole("option", { name: "Write the brief" }).dragTo(page.getByRole("listbox", { name: "Done" }));
+ expect(await json(page, "widget-board-columns")).toEqual([
+ { id: "todo", cardIds: ["review"] },
+ { id: "doing", cardIds: ["draw"] },
+ { id: "done", cardIds: ["write"] },
+ ]);
+});
+
+test("Canvas escape cancels an in-progress marquee", async ({ page }) => {
+ await page.goto("/widgets/canvas");
+ await page.getByRole("option", { name: "Card" }).click();
+ expect(await json(page, "widget-canvas-selected")).toEqual(["card"]);
+ const canvas = page.getByRole("listbox", { name: "Canvas objects" });
+ const box = await canvas.boundingBox();
+ if (!box) throw new Error("canvas bounding box");
+ await canvas.focus();
+ await page.mouse.move(box.x + box.width - 24, box.y + box.height - 24);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width - 8, box.y + box.height - 8);
+ await page.keyboard.press("Escape");
+ await page.mouse.up();
+ expect(await json(page, "widget-canvas-selected")).toEqual(["card"]);
+});
+
async function json(page: Page, testId: string): Promise {
return JSON.parse(await page.getByTestId(testId).innerText());
}
diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx
index 7e961e04..5ad7ed0e 100644
--- a/site/tests/unit/app-shell.test.tsx
+++ b/site/tests/unit/app-shell.test.tsx
@@ -39,10 +39,10 @@ describe("official site shell", () => {
"JSON Document",
"Collaboration",
"Editing",
- "Hands",
"Adapter",
"Connector",
- "제품 화면",
+ "Affordance",
+ "Hands",
]);
await user.click(nav.getByRole("button", { name: "JSON Document" }));
expect(groupLinks(nav, "JSON Document")).toEqual([
@@ -82,18 +82,44 @@ describe("official site shell", () => {
expect(groupLinks(nav, "Adapter")).toEqual(["Keyboard", "Clipboard adapter", "Contenteditable"]);
await user.click(nav.getByRole("button", { name: "Connector" }));
expect(groupLinks(nav, "Connector")).toEqual(["React", "React Hook Form", "Ajv", "Zod", "TanStack Table"]);
- await user.click(nav.getByRole("button", { name: "제품 화면" }));
- expect(groupLinks(nav, "제품 화면")).toEqual(["Toolbar", "Listbox", "Grid"]);
+ await user.click(nav.getByRole("button", { name: "Affordance" }));
+ expect(groupLinks(nav, "Affordance")).toEqual([
+ "Focus",
+ "Caret",
+ "Select",
+ "Typeahead",
+ "Activate",
+ "Escape",
+ "Expand/Collapse",
+ "Undo",
+ "Delete",
+ "Rename",
+ "Nudge",
+ "Hover",
+ "Double-click",
+ "Triple-click",
+ "Context menu",
+ "Drag",
+ "Marquee",
+ "Drop",
+ "Duplicate",
+ "Resize",
+ "Pan",
+ "Scroll",
+ "Zoom",
+ "Snap",
+ "Not-allowed",
+ ]);
expect(nav.queryByRole("group", { name: "Demos" })).toBeNull();
expect(nav.queryByRole("group", { name: "Reference" })).toBeNull();
expect(nav.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([
"JSON Document",
"Collaboration",
"Editing",
- "Hands",
"Adapter",
"Connector",
- "제품 화면",
+ "Affordance",
+ "Hands",
]);
expect(nav.queryByRole("link", { name: "Extensions" })).toBeNull();
diff --git a/site/tests/unit/breadcrumb.test.ts b/site/tests/unit/breadcrumb.test.ts
index 64e2f006..5bdad52b 100644
--- a/site/tests/unit/breadcrumb.test.ts
+++ b/site/tests/unit/breadcrumb.test.ts
@@ -87,11 +87,17 @@ describe("breadcrumbTrail", () => {
"Zod:/connectors/zod",
"Validate:/connectors/zod/validate",
]);
- expect(trail("/widgets")).toEqual(["Overview:/", "제품 화면:/widgets"]);
+ expect(trail("/docs/affordance")).toEqual(["Overview:/", "Affordance:/docs/affordance"]);
+ expect(trail("/docs/affordance/select")).toEqual([
+ "Overview:/",
+ "Affordance:/docs/affordance",
+ "Select:/docs/affordance/select",
+ ]);
expect(trail("/widgets/toolbar")).toEqual([
"Overview:/",
- "제품 화면:/widgets",
- "Toolbar:/widgets/toolbar",
+ "Affordance:/docs/affordance",
+ "Undo:/docs/affordance/history",
+ "Toolbar proof:/widgets/toolbar",
]);
});
diff --git a/site/tests/unit/docs-route.test.tsx b/site/tests/unit/docs-route.test.tsx
index e643081f..14aa9e06 100644
--- a/site/tests/unit/docs-route.test.tsx
+++ b/site/tests/unit/docs-route.test.tsx
@@ -40,8 +40,8 @@ describe("documentation routes", () => {
await user.click(nav.getByRole("button", { name: "Connector" }));
await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "React", exact: true }));
- await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document"));
- expect(await screen.findByRole("heading", { level: 1, name: "React Connector" })).toBeTruthy();
+ await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document"), { timeout: 10000 });
+ expect(await screen.findByRole("heading", { level: 1, name: "React Connector" }, { timeout: 10000 })).toBeTruthy();
await user.click(nav.getByRole("button", { name: "Editing" }));
await user.click(within(nav.getByRole("group", { name: "Editing" })).getByRole("link", { name: "Topology" }));
diff --git a/site/tests/unit/widget-binding.test.ts b/site/tests/unit/widget-binding.test.ts
index 8843b477..01aa37ac 100644
--- a/site/tests/unit/widget-binding.test.ts
+++ b/site/tests/unit/widget-binding.test.ts
@@ -1,5 +1,20 @@
import { describe, expect, test } from "vitest";
-import { historyCommands, optionProps } from "../../src/routes/widgets/binding";
+import { editingCommandFromStroke, gridCellProps, historyCommands, optionProps, treeItemProps } from "../../src/shared/widget-binding";
+
+describe("editingCommandFromStroke", () => {
+ test("fills the editing keyboard port from applyAffordance", () => {
+ expect(editingCommandFromStroke({
+ key: "ArrowDown",
+ shiftKey: false,
+ metaKey: false,
+ ctrlKey: false,
+ })).toEqual({
+ type: "move",
+ direction: "down",
+ operation: "replace",
+ });
+ });
+});
describe("historyCommands", () => {
test("binds disabled to canUndo and canRedo", () => {
@@ -33,4 +48,33 @@ describe("optionProps", () => {
});
});
+describe("gridCellProps", () => {
+ test("maps selection marks to gridcell props", () => {
+ const props = gridCellProps({
+ getIsSelected: () => true,
+ getIsFocus: () => true,
+ getTextOffset: () => null,
+ getPressHandler: () => handler,
+ });
+ expect(props.role).toBe("gridcell");
+ expect(props.tabIndex).toBe(0);
+ expect(props.selected).toBe(true);
+ expect(props.onClick).toBe(handler);
+ });
+});
+
+describe("treeItemProps", () => {
+ test("maps selection marks to treeitem props", () => {
+ const props = treeItemProps({
+ getIsSelected: () => false,
+ getIsFocus: () => true,
+ getTextOffset: () => null,
+ getPressHandler: () => handler,
+ });
+ expect(props.role).toBe("treeitem");
+ expect(props.focus).toBe(true);
+ expect(props.onClick).toBe(handler);
+ });
+});
+
function handler() {}
diff --git a/site/tsconfig.json b/site/tsconfig.json
index 6076f736..c733fc06 100644
--- a/site/tsconfig.json
+++ b/site/tsconfig.json
@@ -12,6 +12,7 @@
"@interactive-os/json-document-react": ["../packages/json-document-react/src/index.ts"],
"@interactive-os/json-document-react-hook-form": ["../packages/json-document-react-hook-form/src/index.ts"],
"@interactive-os/json-document-ajv": ["../packages/json-document-ajv/src/index.ts"],
+ "@interactive-os/json-document-affordance": ["../packages/json-document-affordance/src/index.ts"],
"@interactive-os/json-document-tanstack-table": ["../packages/json-document-tanstack-table/src/index.ts"],
"@interactive-os/json-document-web": ["../packages/json-document-web/src/index.ts"],
"@interactive-os/json-document-contenteditable": ["../packages/json-document-contenteditable/src/index.ts"],
diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md
index 8050be23..6d60b128 100644
--- a/standards/repository-implementation-shape.md
+++ b/standards/repository-implementation-shape.md
@@ -204,7 +204,7 @@ foundation으로 유지한다.
## 현재 package 분류
-아래 표는 현재 15개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
+아래 표는 현재 16개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
`후속`은 이 RFC가 source를 이동하지 않고 별도 이슈가 책임짐을 뜻한다.
| Package path | 정본 모형 | 현재 판단 |
@@ -215,6 +215,7 @@ foundation으로 유지한다.
| `packages/json-document-react` | Single-native Connector | 하나의 React subscription/lifecycle entry로 flat 유지 |
| `packages/json-document-react-hook-form` | Single-native Connector | RHF lifecycle이 하나인 동안 flat 유지; 독립 binding이 생기면 분리 |
| `packages/json-document-ajv` | Single-native Connector | 하나의 validator translation으로 flat 유지 |
+| `packages/json-document-affordance` | Responsibility family | select/fold/drag/history 책임 file과 root facade 유지 |
| `packages/json-document-zod` | Composite Connector | validator와 Database translation을 책임 file로 분리한 현재 모양 유지 |
| `packages/json-document-tanstack-table` | Single-native Connector | 하나의 Table/Sheet binding으로 flat 유지 |
| `packages/json-document-web` | Adapter family | keyboard/clipboard/input/modifier 책임 file과 root facade 유지 |
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 54776707..12edb1eb 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -8,6 +8,7 @@
{ "path": "./packages/json-document-react" },
{ "path": "./packages/json-document-react-hook-form" },
{ "path": "./packages/json-document-ajv" },
+ { "path": "./packages/json-document-affordance" },
{ "path": "./packages/json-document-zod" },
{ "path": "./packages/json-document-tanstack-table" },
{ "path": "./packages/json-document-web" },