Editing values
- What the widget reads
+ What this reads
{props.values.map((item) => (
diff --git a/site/src/shared/widget-binding/history.ts b/site/src/shared/widget-binding/history.ts
index 7dbd6f85..e0b0a306 100644
--- a/site/src/shared/widget-binding/history.ts
+++ b/site/src/shared/widget-binding/history.ts
@@ -1,21 +1,25 @@
-export type HistoryCommandName = "undo" | "redo";
+import {
+ applyAffordance,
+ historyAffordance,
+ type HistoryAffordance,
+ type HistoryAffordanceMap,
+ type HistoryAffordanceName,
+} from "@interactive-os/json-document-affordance";
-export type HistoryCommand = {
- readonly name: HistoryCommandName;
- readonly disabled: boolean;
-};
-
-export type HistoryCommandMap = {
- readonly undo: HistoryCommand;
- readonly redo: HistoryCommand;
-};
+export type { HistoryAffordance as HistoryCommand, HistoryAffordanceMap as HistoryCommandMap, HistoryAffordanceName as HistoryCommandName };
export function historyCommands(snapshot: {
readonly canUndo: boolean;
readonly canRedo: boolean;
-}): HistoryCommandMap {
- return {
- undo: { name: "undo", disabled: !snapshot.canUndo },
- redo: { name: "redo", disabled: !snapshot.canRedo },
+}): 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
index 1d8238b1..1efffc91 100644
--- a/site/src/shared/widget-binding/index.ts
+++ b/site/src/shared/widget-binding/index.ts
@@ -1,4 +1,4 @@
export { historyCommands } from "./history";
export type { HistoryCommand, HistoryCommandMap, HistoryCommandName } from "./history";
-export { useWidgetKeyboard } from "./keyboard";
+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
index d6371fc3..b6af567a 100644
--- a/site/src/shared/widget-binding/keyboard.ts
+++ b/site/src/shared/widget-binding/keyboard.ts
@@ -1,9 +1,33 @@
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);
diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts
index 94907181..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,15 +60,33 @@ 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",
- "Document",
- "Canvas",
- "Tree",
- "Board",
+ 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);
@@ -76,10 +94,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: "Extensions" })).toHaveCount(0);
expect(requests.some(isLegacyRequest)).toBe(false);
@@ -97,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 887b6882..866bea96 100644
--- a/site/tests/browser/widgets.spec.ts
+++ b/site/tests/browser/widgets.spec.ts
@@ -1,16 +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.getByRole("link", { name: "Open Document" })).toHaveAttribute("href", "/widgets/document");
- await expect(page.getByRole("link", { name: "Open Canvas" })).toHaveAttribute("href", "/widgets/canvas");
- await expect(page.getByRole("link", { name: "Open Tree" })).toHaveAttribute("href", "/widgets/tree");
- await expect(page.getByRole("link", { name: "Open Board" })).toHaveAttribute("href", "/widgets/board");
- await expect(page.getByRole("link", { name: "Open Database" })).toHaveCount(0);
+ 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 }) => {
@@ -41,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();
@@ -122,6 +134,19 @@ test("Tree reads visible topology and selected keys", async ({ page }) => {
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([
@@ -134,6 +159,38 @@ test("Board reads columns and selected cards", async ({ page }) => {
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 2c0d072e..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", "Document", "Canvas", "Tree", "Board"]);
+ 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 5bfc0c88..5bdad52b 100644
--- a/site/tests/unit/breadcrumb.test.ts
+++ b/site/tests/unit/breadcrumb.test.ts
@@ -87,16 +87,17 @@ describe("breadcrumbTrail", () => {
"Zod:/connectors/zod",
"Validate:/connectors/zod/validate",
]);
- expect(trail("/widgets")).toEqual(["Overview:/", "제품 화면:/widgets"]);
- expect(trail("/widgets/toolbar")).toEqual([
+ expect(trail("/docs/affordance")).toEqual(["Overview:/", "Affordance:/docs/affordance"]);
+ expect(trail("/docs/affordance/select")).toEqual([
"Overview:/",
- "제품 화면:/widgets",
- "Toolbar:/widgets/toolbar",
+ "Affordance:/docs/affordance",
+ "Select:/docs/affordance/select",
]);
- expect(trail("/widgets/board")).toEqual([
+ expect(trail("/widgets/toolbar")).toEqual([
"Overview:/",
- "제품 화면:/widgets",
- "Board:/widgets/board",
+ "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 e0e953b7..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 { gridCellProps, historyCommands, optionProps, treeItemProps } from "../../src/shared/widget-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", () => {
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" },