From f49f7e604965fb9e2583dbb4cf239fa59b8674d6 Mon Sep 17 00:00:00 2001 From: fezz Date: Mon, 28 Sep 2026 18:28:23 -0700 Subject: [PATCH 01/12] Isolate errors in views: a fallback per view, one error channel A view whose content fails to mount (or whose iframe function throws) shows a fallback with its title, the error and Try again, and the rest of the workspace carries on. In React, every view's content, icon and accessory renders inside its own error boundary, so one throwing component no longer unmounts the workspace. Everything that throws, from view code, callbacks, close guards or event listeners, goes to a new error event with its source, view and type; without a listener it goes to the console. ws.reportError sends an app's own errors down the same channel, errorFallback replaces the fallback (core and React, per workspace or per type), and React gets an onError prop. Co-Authored-By: Claude Opus 5.5 --- e2e/app/src/main.tsx | 17 ++- e2e/workspace.spec.ts | 142 ++++++++++++++++++++++++ packages/core/src/runtime/dom.ts | 15 +++ packages/core/src/runtime/lifetime.ts | 4 +- packages/core/src/runtime/types.ts | 39 +++++++ packages/core/src/runtime/view.ts | 15 ++- packages/core/src/runtime/workspace.ts | 147 ++++++++++++++++++++----- packages/core/src/style.css | 33 ++++++ packages/react/src/Workspace.tsx | 102 ++++++++++++++++- packages/react/src/index.ts | 3 +- 10 files changed, 477 insertions(+), 40 deletions(-) diff --git a/e2e/app/src/main.tsx b/e2e/app/src/main.tsx index 5a0f3c7..ce3c621 100644 --- a/e2e/app/src/main.tsx +++ b/e2e/app/src/main.tsx @@ -76,6 +76,8 @@ if (scenario === "vanilla") { const [gestureType, gestureValue] = (params.get("gestures") ?? "").split(":"); if (gestureType && options.types[gestureType]) options.types[gestureType].gestures = gestureValue as "content" | "workspace"; + // Tests add types with ws.update({ types: { ...w.types, … } }). + w.types = options.types; const ws = createWorkspace(app, options); for (const e of ["open", "close", "focus", "navigate", "change"] as const) ws.on(e, (d: any) => w.events.push(`${e}:${typeof d === "object" && d ? (d.id ?? "doc") : d}`)); @@ -106,6 +108,12 @@ function Counter() { ); } +/** Throws while rendering whenever window.__boom is set. */ +function Boom() { + const view = useView(); + if ((window as any).__boom) throw new Error(`boom in ${view.id}`); + return

fine

; +} function Opener() { const ws = useWorkspace(); return ( @@ -127,7 +135,14 @@ if (scenario === "react") { - void (w.ws = h)}> + void (w.ws = h)} + onError={(e) => (w.errors ??= []).push(`${e.source}:${e.viewId ?? ""}`)} + > + + + String(v.params.name)} placement="stage"> diff --git a/e2e/workspace.spec.ts b/e2e/workspace.spec.ts index 4ae106d..e74cb51 100644 --- a/e2e/workspace.spec.ts +++ b/e2e/workspace.spec.ts @@ -1455,3 +1455,145 @@ test.describe("free navigation gestures", () => { await expect.poll(() => framed(page)).toBeNull(); }); }); + +test.describe("errors in views", () => { + /** Adds a vanilla type whose mount throws until window.__fixed is set. */ + const addBroken = (page: Page) => + page.evaluate(() => { + const w = window as any; + w.errors = []; + w.ws.on("error", (e: any) => w.errors.push(`${e.source}:${e.viewId ?? ""}:${e.type ?? ""}`)); + w.ws.update({ + types: { + ...w.types, + broken: { + title: "Broken", + mount(el: HTMLElement) { + if (!w.__fixed) throw new Error("mount failed"); + el.innerHTML = '

recovered

'; + }, + }, + }, + }); + return w.ws.open("broken", { placement: { beside: "right", edge: "bottom" } }).id as string; + }); + + test("a view that fails to mount shows a fallback, reports it, and the rest keeps working", async ({ + page, + }) => { + await page.goto("/?scenario=vanilla"); + await expect(tab(page, "a")).toBeVisible(); + const id = await addBroken(page); + const fallback = surface(page, id).locator("[data-trellis-part=view-error]"); + await expect(fallback).toBeVisible(); + await expect(fallback).toHaveAttribute("role", "alert"); + await expect(fallback).toContainText("Broken"); + await expect(fallback).toContainText("mount failed"); + expect(await page.evaluate(() => (window as any).errors)).toContain(`mount:${id}:broken`); + // The rest of the workspace is untouched. + await surface(page, "a").locator("[data-test=input]").fill("still here"); + await expect(surface(page, "a").locator("[data-test=input]")).toHaveValue("still here"); + // Try again mounts it once the problem is fixed. + await page.evaluate(() => ((window as any).__fixed = true)); + await fallback.getByRole("button", { name: "Try again" }).click(); + await expect(surface(page, id).locator("[data-test=broken-ok]")).toBeVisible(); + await expect(surface(page, id).locator("[data-trellis-part=view-error]")).toHaveCount(0); + }); + + test("errorFallback replaces the built-in fallback", async ({ page }) => { + await page.goto("/?scenario=vanilla"); + await expect(tab(page, "a")).toBeVisible(); + await page.evaluate(() => + (window as any).ws.update({ + errorFallback: ({ error, retry }: any) => { + const el = document.createElement("div"); + el.dataset.test = "custom-fallback"; + el.textContent = `custom: ${error.message}`; + el.addEventListener("click", retry); + return el; + }, + }), + ); + const id = await addBroken(page); + await expect(surface(page, id).locator("[data-test=custom-fallback]")).toHaveText("custom: mount failed"); + }); + + test("a throwing title function falls back to the type and reports it", async ({ page }) => { + await page.goto("/?scenario=vanilla"); + await expect(tab(page, "a")).toBeVisible(); + const id = await page.evaluate(() => { + const w = window as any; + w.errors = []; + w.ws.on("error", (e: any) => w.errors.push(`${e.source}:${e.viewId ?? ""}`)); + w.ws.update({ + types: { + ...w.types, + untitled: { + title: () => { + throw new Error("no title"); + }, + mount() {}, + }, + }, + }); + return w.ws.open("untitled").id as string; + }); + await expect(tab(page, id)).toContainText("untitled"); + expect(await page.evaluate(() => (window as any).errors)).toContain(`title:${id}`); + }); + + test("a throwing event listener doesn't stop others and is reported", async ({ page }) => { + await page.goto("/?scenario=vanilla"); + await expect(tab(page, "a")).toBeVisible(); + const result = await page.evaluate(() => { + const w = window as any; + const seen: string[] = []; + w.ws.on("error", (e: any) => seen.push(`error:${e.source}`)); + w.ws.on("change", () => { + throw new Error("listener broke"); + }); + w.ws.on("change", () => seen.push("second listener ran")); + w.ws.select("b"); + return seen; + }); + expect(result).toContain("second listener ran"); + expect(result).toContain("error:listener"); + }); + + test("ws.reportError sends an app's own errors through the same channel", async ({ page }) => { + await page.goto("/?scenario=vanilla"); + await expect(tab(page, "a")).toBeVisible(); + const seen = await page.evaluate(() => { + const w = window as any; + const out: any[] = []; + w.ws.on("error", (e: any) => + out.push({ source: e.source, viewId: e.viewId, type: e.type, message: e.error.message }), + ); + w.ws.reportError(new Error("socket dropped"), { viewId: "a", source: "content" }); + return out; + }); + expect(seen).toEqual([{ source: "content", viewId: "a", type: "editor", message: "socket dropped" }]); + }); + + test("React: a view that throws while rendering shows a fallback; other views keep their state", async ({ + page, + }) => { + await page.goto("/?scenario=react"); + await expect(page.locator("[data-test=inc]").first()).toBeVisible(); + await page.locator("[data-test=inc]").first().click(); + await expect(page.locator("[data-test=inc]").first()).toHaveText("count 1"); + await page.evaluate(() => ((window as any).__boom = true)); + const id = await page.evaluate(() => (window as any).ws.open("boom").id as string); + const fallback = surface(page, id).locator("[data-trellis-part=view-error]"); + await expect(fallback).toBeVisible(); + await expect(fallback).toContainText(`boom in ${id}`); + expect(await page.evaluate(() => (window as any).errors)).toContain(`render:${id}`); + // The workspace and the other views survived, state included. + await page.evaluate(() => (window as any).ws.focus("c1")); + await expect(page.locator("[data-test=inc]").first()).toHaveText("count 1"); + await page.evaluate(() => ((window as any).__boom = false)); + await page.evaluate((v) => (window as any).ws.focus(v), id); + await fallback.getByRole("button", { name: "Try again" }).click(); + await expect(surface(page, id).locator("[data-test=boom-ok]")).toBeVisible(); + }); +}); diff --git a/packages/core/src/runtime/dom.ts b/packages/core/src/runtime/dom.ts index 3bf4521..5e214af 100644 --- a/packages/core/src/runtime/dom.ts +++ b/packages/core/src/runtime/dom.ts @@ -13,6 +13,21 @@ export function h( return el; } +/** The built-in fallback for a view whose content failed to mount. Text only: messages are never + * parsed as markup. */ +export function errorFallbackElement(title: string, error: unknown, retry: () => void): HTMLElement { + const message = error instanceof Error ? error.message : String(error); + const button = h("button", { type: "button", "data-trellis-part": "view-error-retry" }, "Try again"); + button.addEventListener("click", retry); + return h( + "div", + { "data-trellis-part": "view-error", role: "alert" }, + h("strong", {}, `${title} couldn’t load`), + h("p", {}, message), + button, + ); +} + export const icons = { close: '', diff --git a/packages/core/src/runtime/lifetime.ts b/packages/core/src/runtime/lifetime.ts index c73a61e..660b0f0 100644 --- a/packages/core/src/runtime/lifetime.ts +++ b/packages/core/src/runtime/lifetime.ts @@ -69,6 +69,8 @@ export class Lifetime { type Handler = (...args: any[]) => unknown; export class Emitter { private handlers = new Map>(); + /** Where errors thrown by handlers go. One handler throwing never stops the others. */ + constructor(private onError: (error: unknown) => void = (error) => console.error(error)) {} on(event: E, handler: Events[E]): () => void { let set = this.handlers.get(event); if (!set) this.handlers.set(event, (set = new Set())); @@ -84,7 +86,7 @@ export class Emitter { const value = handler(...args); if (value !== undefined) result = value; } catch (error) { - console.error(error); + this.onError(error); } } return result; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index d67f11e..6ce03f5 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -209,6 +209,9 @@ export interface WorkspaceOptions { /** CSS custom properties applied to the workspace, e.g. { "--trellis-accent": "#f60" }. */ tokens?: Record; keymap?: Keymap; + /** What a view shows when its content fails to mount: a node or text. The default shows the + * view's title, the error message and a Try again button. */ + errorFallback?: (info: ErrorFallbackInfo) => Node | string; /** Keys held with the pointer for free-navigation gestures: `pan`, `scale` and `rect` (drag), and * `step` (scroll). Each is a combo, a list of combos (any works) or `null` (off). See * `defaultGestureKeys()` for the defaults. */ @@ -261,7 +264,41 @@ export interface WorkspaceSnapshot { dragging: boolean; } +/** Where an error came from: a view type's `mount`, its cleanup, `title`, `iframe` or `menu` + * function, a close guard, an event listener, a framework render (adapters), your own code + * (`reportError`), or another callback option. */ +export type ErrorSource = + | "mount" + | "cleanup" + | "title" + | "iframe" + | "menu" + | "guard" + | "listener" + | "render" + | "content" + | "callback"; + +export interface WorkspaceError { + error: unknown; + source: ErrorSource; + /** The view it happened in, when there is one. */ + viewId?: string; + /** That view's type. */ + type?: string; +} + +export interface ErrorFallbackInfo { + error: unknown; + view: ViewHandle; + /** Mount the view's content again. */ + retry(): void; +} + export interface WorkspaceEvents { + /** Something in a view or a callback threw. The workspace carries on; with no listener, the + * error goes to the console. */ + error(error: WorkspaceError): void; /** Committed layout changes only; never fires mid-drag or mid-animation. */ change(document: LayoutDocument): void; open(view: ViewInfo): void; @@ -291,6 +328,8 @@ export interface WorkspaceHandle { toggleDock(panelOrViewId: string): void; setTitle(viewId: string, title: string): void; setParams(viewId: string, patch: object): void; + /** Send an error through the workspace's `error` event, as if the workspace had caught it. */ + reportError(error: unknown, context?: { viewId?: string; source?: ErrorSource }): void; navigation: { frame(target: string | string[] | "all" | "stage"): void; /** Maximize a docked panel, or restore it if it is maximized. Returns false for floating or hidden panels, or when navigation is off. (Double-clicking a stage float's tab bar frames the stage instead.) */ diff --git a/packages/core/src/runtime/view.ts b/packages/core/src/runtime/view.ts index 339cc20..87156f7 100644 --- a/packages/core/src/runtime/view.ts +++ b/packages/core/src/runtime/view.ts @@ -1,6 +1,6 @@ import type { Params } from "../model/types"; import { Emitter } from "./lifetime"; -import type { ViewEvents, ViewHandle, ViewState, WorkspaceHandle } from "./types"; +import type { ErrorSource, ViewEvents, ViewHandle, ViewState, WorkspaceHandle } from "./types"; export interface ViewHost { workspace: WorkspaceHandle; @@ -12,11 +12,12 @@ export interface ViewHost { focus(id: string): void; close(id: string, options?: { force?: boolean }): Promise; hide(id: string): void; + reportError(error: unknown, context: { viewId: string; source: ErrorSource }): void; } /** One per view. Presentation state changes arrive from the engine's render. */ export class ViewController implements ViewHandle { - readonly events = new Emitter(); + readonly events: Emitter; readonly guards = new Set<() => boolean | Promise>(); private listeners = new Set<() => void>(); state: ViewState; @@ -27,6 +28,7 @@ export class ViewController implements ViewHandle { initial: ViewState, ) { this.state = initial; + this.events = new Emitter((error) => host.reportError(error, { viewId: id, source: "listener" })); } get params() { return this.state.params as any; @@ -123,7 +125,12 @@ export class ViewController implements ViewHandle { if (next.interactive !== prev.interactive) this.events.emit("interactive", next.interactive); if (next.scale !== prev.scale) this.events.emit("scale", next.scale); this.events.emit("change", next); - for (const listener of [...this.listeners]) listener(); + for (const listener of [...this.listeners]) + try { + listener(); + } catch (error) { + this.host.reportError(error, { viewId: this.id, source: "listener" }); + } } /** Run close guards. Any false vetoes. */ async canClose(): Promise { @@ -131,7 +138,7 @@ export class ViewController implements ViewHandle { try { if ((await guard()) === false) return false; } catch (error) { - console.error(error); + this.host.reportError(error, { viewId: this.id, source: "guard" }); return false; } } diff --git a/packages/core/src/runtime/workspace.ts b/packages/core/src/runtime/workspace.ts index 48e1420..5ffb2d3 100644 --- a/packages/core/src/runtime/workspace.ts +++ b/packages/core/src/runtime/workspace.ts @@ -42,13 +42,14 @@ import type { Rect, SplitNode, } from "../model/types"; -import { h, icons, place, setAttr, setStyle } from "./dom"; +import { errorFallbackElement, h, icons, place, setAttr, setStyle } from "./dom"; import { DEFAULT_KEYMAP, formatCombo, matches, type Command } from "./keymap"; import { defaultGestureKeys } from "./gestures"; import { Emitter, Lifetime } from "./lifetime"; import { Menu, tidyMenu } from "./menu"; import { DOCK_EASE, DOCK_MS, lerpRect, LayoutTween, MOTION, RectSpring, sameRect } from "./motion"; import type { + ErrorSource, IframeOptions, MenuEntry, MenuItem, @@ -96,6 +97,8 @@ interface SurfaceRecord { mountKey: unknown; iconHtml: string | null; surface: Surface; + /** Set while the content failed to mount and shows the error fallback. */ + failure: { error: unknown } | null; } interface Leaving { panel: PanelNode; @@ -137,7 +140,33 @@ const DETAIL = { size: 48, outline: 2 }; export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOptions): WorkspaceHandle { let options: WorkspaceOptions = { ...initialOptions }; const lifetime = new Lifetime(); - const events = new Emitter(); + const events = new Emitter((error) => reportError(error, { source: "listener" })); + // ---------------------------------------------------------------- errors + let reporting = false; + /** One channel for everything that throws: the `error` event, or the console without a + * listener. An error thrown while reporting one goes to the console, so nothing loops. */ + function reportError(error: unknown, context: { source: ErrorSource; viewId?: string }) { + if (reporting || !events.has("error")) { + console.error(error); + return; + } + const type = context.viewId ? doc?.views[context.viewId]?.type : undefined; + reporting = true; + try { + events.emit("error", { error, source: context.source, viewId: context.viewId, type }); + } finally { + reporting = false; + } + } + /** Run a callback from options or a view type; if it throws, report it and use `fallback`. */ + function guarded(run: () => T, fallback: T, context: { source: ErrorSource; viewId?: string }): T { + try { + return run(); + } catch (error) { + reportError(error, context); + return fallback; + } + } const listeners = new Set<() => void>(); // ---------------------------------------------------------------- DOM @@ -235,7 +264,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti try { return title(controller); } catch (error) { - console.error(error); + reportError(error, { source: "title", viewId }); } } else if (title) return title; return record.type; @@ -377,6 +406,8 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti focus: (id: string) => handle.focus(id), close: (id: string, o?: { force?: boolean }) => close(id, o), hide: (id: string) => hide(id), + reportError: (error: unknown, context: { viewId: string; source: ErrorSource }) => + reportError(error, context), }; function ensureSurface(viewId: string): SurfaceRecord { let record = records.get(viewId); @@ -406,6 +437,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti mountKey: null, iconHtml: null, surface: { view: controller, content, icon, accessory }, + failure: null, }; records.set(viewId, record); // Title functions receive the view handle, which only exists now. @@ -419,27 +451,40 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti * Content is only remounted when what it renders actually changes. */ function mountContent(record: SurfaceRecord) { const def = typeOf(record.controller.id); + const viewId = record.controller.id; record.mountedWith = def; setAttr(record.shell, "class", def.className ?? null); - const raw = def.iframe - ? typeof def.iframe === "function" - ? def.iframe(record.controller) - : def.iframe - : null; + let iframeError: { error: unknown } | null = null; + let raw: string | IframeOptions | null = null; + if (typeof def.iframe === "function") + try { + raw = def.iframe(record.controller); + } catch (error) { + iframeError = { error }; + } + else raw = def.iframe ?? null; const frame: IframeOptions | null = raw === null ? null : typeof raw === "string" ? { src: raw } : raw; - const key: unknown = frame ? `iframe:${JSON.stringify(frame)}` : (def.mount ?? null); + const key: unknown = iframeError + ? iframeError + : frame + ? `iframe:${JSON.stringify(frame)}` + : (def.mount ?? null); if (key !== record.mountKey) { if (record.mountKey !== null) { try { record.cleanup?.(); } catch (error) { - console.error(error); + reportError(error, { source: "cleanup", viewId }); } record.cleanup = null; record.content.replaceChildren(); } record.mountKey = key; - if (frame) { + record.failure = null; + if (iframeError) { + reportError(iframeError.error, { source: "iframe", viewId }); + showFailure(record, iframeError.error); + } else if (frame) { const frameEl = h("iframe", { src: frame.src, srcdoc: frame.srcdoc, @@ -459,7 +504,9 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti }); record.cleanup = typeof cleanup === "function" ? cleanup : null; } catch (error) { - console.error(error); + reportError(error, { source: "mount", viewId }); + record.content.replaceChildren(); + showFailure(record, error); } } } @@ -469,13 +516,34 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti record.iconHtml = icon; } } + /** Show a view's error fallback in place of content that failed to mount. */ + function showFailure(record: SurfaceRecord, error: unknown) { + const viewId = record.controller.id; + record.failure = { error }; + const retry = () => { + if (records.get(viewId) !== record) return; + record.mountKey = null; + record.failure = null; + record.content.replaceChildren(); + mountContent(record); + }; + const custom = options.errorFallback + ? guarded(() => options.errorFallback!({ error, view: record.controller, retry }), null, { + source: "callback", + viewId, + }) + : null; + record.content.replaceChildren( + custom === null ? errorFallbackElement(titleOf(viewId), error, retry) : custom, + ); + } function destroySurface(viewId: string) { const record = records.get(viewId); if (!record) return; try { record.cleanup?.(); } catch (error) { - console.error(error); + reportError(error, { source: "cleanup", viewId }); } record.shell.remove(); record.icon.remove(); @@ -916,7 +984,11 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const drop = new Set(); for (const [id, record] of Object.entries(input.views ?? {})) { if (!record || options.types[record.type]) continue; - const decision = options.onMissingType?.(record.type, id) ?? "placeholder"; + const decision = + guarded(() => options.onMissingType?.(record.type, id), undefined, { + source: "callback", + viewId: id, + }) ?? "placeholder"; if (decision === "drop") drop.add(id); } const doc = sanitize(input, () => true); @@ -1916,9 +1988,10 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti function menuFor(panel: PanelNode): MenuEntry[] { const def = typeOf(panel.selected); const controller = records.get(panel.selected)?.controller; - const custom = - typeof def.menu === "function" ? (controller ? def.menu(controller) : []) : (def.menu ?? []); - return custom; + const menuFn = def.menu; + if (typeof menuFn !== "function") return menuFn ?? []; + if (!controller) return []; + return guarded(() => menuFn(controller), [], { source: "menu", viewId: panel.selected }); } /** The built-in items for a panel, each with a stable id. */ function builtInMenu(panel: PanelNode): MenuEntry[] { @@ -1985,7 +2058,11 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti items.push({ id: "hide", label: "Hide", - run: () => hide(panelId, { toward: options.hideToward?.(panelId) ?? undefined }), + run: () => + hide(panelId, { + toward: + guarded(() => options.hideToward?.(panelId), undefined, { source: "callback" }) ?? undefined, + }), }); const closable = panel.views.filter((v) => typeOf(v).closable !== false); if (closable.length) { @@ -2016,7 +2093,10 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti if (setting === true) return tidyMenu(all); const view = records.get(panel.selected)?.controller; if (!view) return tidyMenu(all); - return tidyMenu(setting(tidyMenu(all), { panelId: panel.id, view, region: regionOf(panel.id) })); + const context = { panelId: panel.id, view, region: regionOf(panel.id) }; + return tidyMenu( + guarded(() => setting(tidyMenu(all), context), tidyMenu(all), { source: "menu", viewId: view.id }), + ); } function openPanelMenu(panelId: string, button: HTMLElement | null, at?: { x: number; y: number }) { const panel = findPanel(panelId); @@ -2029,15 +2109,21 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const reset = () => button && setAttr(button, "aria-expanded", null); if (options.renderMenu) { menu.close(); - options.renderMenu({ - entries, - x: r ? r.right : b.left + (at?.x ?? 0), - y: r ? r.bottom + 4 : b.top + (at?.y ?? 0), - align: r ? "end" : "start", - anchor: button, - panelId, - close: reset, - }); + const render = options.renderMenu; + guarded( + () => + render({ + entries, + x: r ? r.right : b.left + (at?.x ?? 0), + y: r ? r.bottom + 4 : b.top + (at?.y ?? 0), + align: r ? "end" : "start", + anchor: button, + panelId, + close: reset, + }), + undefined, + { source: "callback" }, + ); return; } menuCloseHook = reset; @@ -2528,6 +2614,11 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti slots, open, close, + reportError: (error, context = {}) => + reportError(error, { + source: context.source ?? "content", + viewId: context.viewId && doc.views[context.viewId] ? context.viewId : undefined, + }), focus: (id: string) => { const panel = locatePanel(doc, id)?.panel; const viewId = panel ? panel.selected : id; diff --git a/packages/core/src/style.css b/packages/core/src/style.css index ef82c8d..d9d5d4e 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -597,6 +597,39 @@ color: var(--trellis-text); font-weight: 600; } +:where(.trellis [data-trellis-part="view-error"]) { + display: grid; + place-content: center; + justify-items: center; + gap: 6px; + height: 100%; + padding: 24px; + text-align: center; + color: var(--trellis-text-muted); +} +:where(.trellis [data-trellis-part="view-error"] strong) { + color: var(--trellis-text); + font-weight: 600; +} +:where(.trellis [data-trellis-part="view-error"] p) { + max-width: 44ch; + margin: 0; + overflow-wrap: anywhere; +} +:where(.trellis [data-trellis-part="view-error-retry"]) { + margin-top: 6px; + padding: 5px 12px; + border: 1px solid var(--trellis-border); + border-radius: 6px; + background: transparent; + color: var(--trellis-text); + font: inherit; + cursor: pointer; +} +:where(.trellis [data-trellis-part="view-error-retry"]:focus-visible) { + outline: 2px solid var(--trellis-accent); + outline-offset: 2px; +} /* Without a bar above it, a surface rounds all four corners. (After the base surface rule: * both have zero specificity, so order decides.) */ diff --git a/packages/react/src/Workspace.tsx b/packages/react/src/Workspace.tsx index dd393af..edba74b 100644 --- a/packages/react/src/Workspace.tsx +++ b/packages/react/src/Workspace.tsx @@ -1,5 +1,6 @@ import { Children, + Component, createContext, forwardRef, Fragment, @@ -38,6 +39,7 @@ import { type Surface, type Theme, type ViewHandle, + type WorkspaceError, type ViewInfo, type ViewRules, type ViewState, @@ -202,8 +204,18 @@ export interface ViewTypeProps

extends ViewRules { menu?: MenuEntry[] | ((view: ViewHandle

) => MenuEntry[]); /** Who gets gestures over the content under free navigation. See the core `gestures` option. */ gestures?: ViewTypeDefinition

["gestures"]; + /** Shown instead of this type's content if it throws while rendering. Overrides the + * workspace's `errorFallback`. */ + errorFallback?: (info: ErrorFallbackProps

) => ReactNode; className?: string; } + +export interface ErrorFallbackProps

{ + error: unknown; + view: ViewHandle

; + /** Render the view's content again. */ + retry(): void; +} /** Register a kind of view. Renders nothing itself. */ export function ViewType

(_props: ViewTypeProps

): null { return null; @@ -403,6 +415,12 @@ export interface WorkspaceProps { onClose?(view: ViewInfo): void; onFocus?(viewId: string | null): void; onNavigate?(framed: string | null): void; + /** Something in a view or a callback threw; the workspace carries on. Without this prop, + * errors go to the console. */ + onError?(error: WorkspaceError): void; + /** Shown instead of a view's content if it throws while rendering. The default shows the + * view's title, the error and a Try again button. A type's own `errorFallback` wins. */ + errorFallback?: (info: ErrorFallbackProps) => ReactNode; onMissingType?(type: string, id: string): "drop" | "placeholder"; className?: string; style?: CSSProperties; @@ -520,6 +538,11 @@ function WorkspaceImpl(props: WorkspaceProps, forwarded: Ref) { handle.on("close", (v) => latest.current.props.onClose?.(v)), handle.on("focus", (v) => latest.current.props.onFocus?.(v)), handle.on("navigate", (v) => latest.current.props.onNavigate?.(v)), + handle.on("error", (e) => { + const onError = latest.current.props.onError; + if (onError) onError(e); + else console.error(e.error); + }), ]; setWs(handle); return () => { @@ -608,7 +631,13 @@ function WorkspaceImpl(props: WorkspaceProps, forwarded: Ref) { /> {ws && surfaces.map((surface) => ( - + ))} {ws && (parsed.stage?.backdrop ?? parsed.backdrop) != null && @@ -642,16 +671,79 @@ export const Workspace = Object.assign(forwardRef(WorkspaceImpl), { }) as WorkspaceComponent; const noSurfaces: readonly Surface[] = []; -function SurfacePortal({ surface, type }: { surface: Surface; type: ViewTypeProps | undefined }) { +function SurfacePortal({ + surface, + type, + ws, + fallback, +}: { + surface: Surface; + type: ViewTypeProps | undefined; + ws: WorkspaceHandle; + fallback: WorkspaceProps["errorFallback"]; +}) { const view = surface.view; + // Each view renders inside its own boundary: one that throws shows its fallback and the rest + // of the workspace, other views' state included, carries on. + const boundary = (children: ReactNode, show: ViewBoundaryProps["fallback"]) => ( + + {children} + + ); return ( - {type && !type.iframe && !type.mount && createPortal(, surface.content, view.id)} - {type && type.icon != null && !isMarkup(type.icon) && createPortal(type.icon, surface.icon)} - {type?.accessory != null && createPortal(, surface.accessory)} + {type && + !type.iframe && + !type.mount && + createPortal( + boundary(, type.errorFallback ?? fallback ?? defaultFallback), + surface.content, + view.id, + )} + {type && + type.icon != null && + !isMarkup(type.icon) && + createPortal(boundary(type.icon, null), surface.icon)} + {type?.accessory != null && createPortal(boundary(, null), surface.accessory)} ); } + +interface ViewBoundaryProps { + view: ViewHandle; + ws: WorkspaceHandle; + /** What to show instead; null shows nothing (icons and accessories). */ + fallback: ((info: ErrorFallbackProps) => ReactNode) | null; + children: ReactNode; +} +class ViewBoundary extends Component { + state = { failure: null as { error: unknown } | null }; + static getDerivedStateFromError(error: unknown) { + return { failure: { error } }; + } + componentDidCatch(error: unknown) { + this.props.ws.reportError(error, { viewId: this.props.view.id, source: "render" }); + } + retry = () => this.setState({ failure: null }); + render() { + const { failure } = this.state; + if (!failure) return this.props.children; + const { fallback, view } = this.props; + return fallback ? fallback({ error: failure.error, view, retry: this.retry }) : null; + } +} +/** Matches the core's fallback for content that fails to mount, so both look the same. */ +function defaultFallback({ error, view, retry }: ErrorFallbackProps) { + return ( +

+ {view.title} couldn’t load +

{error instanceof Error ? error.message : String(error)}

+ +
+ ); +} function Content({ type }: { type: ViewTypeProps }) { if (type.render) return ; return <>{type.children}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4e79b4b..0edaa57 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -26,6 +26,7 @@ export type { StageProps, FloatingProps, ViewApi, + ErrorFallbackProps, } from "./Workspace"; -export { layout, createDocument, formatCombo } from "@danfessler/trellis"; +export { layout, createDocument, formatCombo, defaultGestureKeys } from "@danfessler/trellis"; export type * from "@danfessler/trellis"; From f7a5b523c928e3dc5408e5aed244fc7566f25741 Mon Sep 17 00:00:00 2001 From: fezz Date: Mon, 28 Sep 2026 18:39:49 -0700 Subject: [PATCH 02/12] Right-to-left layouts With direction "rtl" (or "auto", the default, on a right-to-left page) the workspace mirrors: a row's first child sits on the right, tabs and menus run right to left, and floating windows are placed from the right. The document stays direction-neutral; the mirror is applied where layout coordinates meet the screen, so dividers, docking, tab reordering, floating resize, collapsed groups and every navigation gesture follow the pointer. Arrow keys follow reading order in tab lists and menus and move dividers the way they point. Key combinations in menus keep reading left to right. React gets a direction prop, the web component a direction attribute. Co-Authored-By: Claude Opus 5.5 --- e2e/app/src/main.tsx | 2 + e2e/workspace.spec.ts | 227 ++++++++++++++++++++++++ packages/core/src/runtime/drag.ts | 29 ++- packages/core/src/runtime/menu.ts | 16 +- packages/core/src/runtime/navigation.ts | 17 +- packages/core/src/runtime/types.ts | 4 + packages/core/src/runtime/workspace.ts | 75 ++++++-- packages/core/src/style.css | 24 ++- packages/element/src/index.ts | 6 +- packages/react/src/Workspace.tsx | 5 + 10 files changed, 369 insertions(+), 36 deletions(-) diff --git a/e2e/app/src/main.tsx b/e2e/app/src/main.tsx index ce3c621..8310a1b 100644 --- a/e2e/app/src/main.tsx +++ b/e2e/app/src/main.tsx @@ -22,6 +22,8 @@ const params = new URLSearchParams(location.search); const scenario = params.get("scenario") ?? "vanilla"; const app = document.getElementById("app")!; const w = window as any; +// ?dir=rtl lays the page out right to left; the workspace follows it. +if (params.get("dir")) document.documentElement.dir = params.get("dir")!; w.mounts = {} as Record; w.unmounts = {} as Record; w.events = [] as string[]; diff --git a/e2e/workspace.spec.ts b/e2e/workspace.spec.ts index e74cb51..8c7b3af 100644 --- a/e2e/workspace.spec.ts +++ b/e2e/workspace.spec.ts @@ -1597,3 +1597,230 @@ test.describe("errors in views", () => { await expect(surface(page, id).locator("[data-test=boom-ok]")).toBeVisible(); }); }); + +test.describe("right to left", () => { + const open = async (page: Page, extra = "") => { + await page.goto(`/?scenario=vanilla&dir=rtl${extra}`); + await expect(tab(page, "a")).toBeVisible(); + }; + const x = async (locator: Locator) => (await box(locator)).x; + const right = async (locator: Locator) => { + const b = await box(locator); + return b.x + b.width; + }; + + test("mirrors the layout: a row's first child is on the right", async ({ page }) => { + await open(page); + expect(await x(panel(page, "left"))).toBeGreaterThan(await x(panel(page, "docs"))); + expect(await x(panel(page, "docs"))).toBeGreaterThan(await x(panel(page, "right"))); + await expect(page.locator(".trellis")).toHaveCSS("direction", "rtl"); + }); + + test("tabs run right to left, with the panel menu at the end", async ({ page }) => { + await open(page); + expect(await x(tab(page, "files"))).toBeGreaterThan(await x(tab(page, "search"))); + const menu = panel(page, "left").locator("[data-trellis-part=panel-menu]"); + expect(await x(menu)).toBeLessThan(await x(tab(page, "search"))); + }); + + test("arrow keys in a tab list follow reading order", async ({ page }) => { + await open(page); + // Three tabs, so next and previous differ: a, b, c from right to left. + await page.evaluate(() => + (window as any).ws.open("editor", { id: "c", params: { name: "c.ts" }, focus: false }), + ); + await tab(page, "a").click(); + await page.keyboard.press("ArrowLeft"); + await expect(tab(page, "b")).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("ArrowLeft"); + await expect(tab(page, "c")).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("ArrowRight"); + await expect(tab(page, "b")).toHaveAttribute("aria-selected", "true"); + }); + + test("dividers move the way they're dragged, by pointer and keyboard", async ({ page }) => { + await open(page); + const width = async () => (await box(panel(page, "left"))).width; + const before = await width(); + const d = await box(page.locator("[data-trellis-part=divider][data-index='0']").first()); + // The first divider sits on the left edge of the first panel, which is on the right. + await drag(page, center(d), { x: d.x + d.width / 2 - 100, y: d.y + d.height / 2 }); + const dragged = await width(); + expect(dragged).toBeGreaterThan(before + 80); + await page.locator("[data-trellis-part=divider][data-index='0']").first().focus(); + await page.keyboard.press("ArrowLeft"); + expect(await width()).toBeGreaterThan(dragged + 5); + }); + + test("dropping a tab on a panel's left edge docks it on the left", async ({ page }) => { + await open(page); + const docs = await box(panel(page, "docs")); + const from = center(await box(tab(page, "outline"))); + await drag(page, from, { x: docs.x + 12, y: docs.y + docs.height / 2 }, false); + await page.waitForTimeout(250); + await page.mouse.up(); + await expect.poll(async () => (await box(surface(page, "outline"))).x).toBeLessThan(docs.x + 40); + const outline = await box(surface(page, "outline")); + expect(outline.x + outline.width).toBeLessThanOrEqual((await box(panel(page, "docs"))).x + 12); + expect(outline.y).toBeGreaterThanOrEqual(docs.y - 2); + }); + + test("reordering tabs follows reading order", async ({ page }) => { + await open(page); + // a, b, c run from right to left. Drag c into the gap between a and b. + await page.evaluate(() => + (window as any).ws.open("editor", { id: "c", params: { name: "c.ts" }, focus: false }), + ); + const b = await box(tab(page, "b")); + const c = center(await box(tab(page, "c"))); + await page.mouse.move(c.x, c.y); + await page.mouse.down(); + await page.mouse.move(c.x + 10, c.y, { steps: 3 }); + await page.mouse.move(b.x + b.width * 0.85, c.y, { steps: 8 }); + await page.mouse.up(); + await expect + .poll(async () => { + const root = (await doc(page)).root; + const stage = root.children.find((n: any) => n.kind === "stage"); + return stage.child.views; + }) + .toEqual(["a", "c", "b"]); + }); + + test("floating windows are placed from the right and follow the pointer", async ({ page }) => { + await open(page); + const id = await page.evaluate( + () => + (window as any).ws.open("files", { placement: { float: { x: 0.05, y: 0.1, w: 0.3, h: 0.4 } } }) + .panelId as string, + ); + const start = await box(panel(page, id)); + expect(start.x).toBeGreaterThan(1200 / 2); + const bar = await box(panel(page, id).locator("[data-trellis-part=tabbar]")); + await drag( + page, + { x: bar.x + bar.width - 20, y: bar.y + bar.height / 2 }, + { + x: bar.x + bar.width - 80, + y: bar.y + bar.height / 2 + 30, + }, + ); + await expect.poll(async () => (await box(panel(page, id))).x).toBeLessThan(start.x - 30); + }); + + test("menus open toward the start edge, and submenus to the left", async ({ page }) => { + await open(page); + const button = await box(panel(page, "docs").locator("[data-trellis-part=panel-menu]")); + await panel(page, "docs").locator("[data-trellis-part=panel-menu]").click(); + const menu = page.locator(".trellis-menu").first(); + await expect(menu).toBeVisible(); + await expect(menu).toHaveCSS("direction", "rtl"); + expect(Math.abs((await box(menu)).x - button.x)).toBeLessThan(4); + const move = menu.getByRole("menuitem", { name: /^Move/ }); + await move.focus(); + await page.keyboard.press("ArrowLeft"); + const sub = page.locator(".trellis-menu").nth(1); + await expect(sub).toBeVisible(); + // It overlaps its parent slightly, as it does left to right. + expect(await right(sub)).toBeLessThanOrEqual((await box(menu)).x + 10); + // The split that lands on the left says so. + await expect(sub.getByRole("menuitem", { name: "New split left" })).toBeVisible(); + await page.keyboard.press("ArrowRight"); + await expect(page.locator(".trellis-menu")).toHaveCount(1); + // Shortcuts keep reading left to right. + await expect(menu.locator(".trellis-menu-shortcut").first()).toHaveCSS("direction", "ltr"); + }); + + test("navigation gestures follow the pointer", async ({ page }) => { + await open(page, "&navigation=free"); + // Stepping in toward the pointer frames the panel under it. + const files = center(await box(surface(page, "files"))); + await page.mouse.move(files.x, files.y); + for (const key of ["ControlOrMeta", "Alt"]) await page.keyboard.down(key); + await page.mouse.wheel(0, -100); + for (const key of ["Alt", "ControlOrMeta"]) await page.keyboard.up(key); + await expect.poll(() => page.evaluate(() => (window as any).ws.navigation.framed)).toBe("left"); + await page.evaluate(() => (window as any).ws.navigation.frame("all")); + await page.waitForTimeout(300); + // A rectangle around a panel frames that panel. + const r = await box(panel(page, "right")); + for (const key of ["ControlOrMeta", "Alt", "Shift"]) await page.keyboard.down(key); + await page.mouse.move(r.x + 4, r.y + 4); + await page.mouse.down(); + await page.mouse.move(r.x + r.width - 4, r.y + r.height - 4, { steps: 6 }); + await page.mouse.up(); + for (const key of ["Shift", "Alt", "ControlOrMeta"]) await page.keyboard.up(key); + await expect.poll(() => page.evaluate(() => (window as any).ws.navigation.framed)).toBe("right"); + // Panning moves the layout with the pointer. + await page.evaluate(() => (window as any).ws.navigation.frame("all")); + await page.waitForTimeout(300); + const before = await x(panel(page, "docs")); + const at = center(await box(surface(page, "a"))); + for (const key of ["ControlOrMeta", "Alt"]) await page.keyboard.down(key); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + await page.mouse.move(at.x + 120, at.y, { steps: 6 }); + const during = await x(panel(page, "docs")); + await page.mouse.up(); + for (const key of ["Alt", "ControlOrMeta"]) await page.keyboard.up(key); + expect(during).toBeGreaterThan(before + 60); + }); + + test("a collapsed group mirrors its lines, and double-clicking zooms to the part under the pointer", async ({ + page, + }) => { + await open(page, "&navigation=free"); + const ids = await page.evaluate(() => { + const ws = (window as any).ws; + const x1 = ws.open("files", { placement: { beside: "right", edge: "bottom", share: 0.5 } }); + const x2 = ws.open("search", { placement: { beside: x1.panelId, edge: "right" } }); + const x3 = ws.open("files", { placement: { beside: x2.panelId, edge: "right" } }); + ws.open("search", { placement: { beside: x3.panelId, edge: "bottom" } }); + const doc = ws.getDocument(); + doc.root.weights = [1, 12, 0.3]; + ws.setDocument(doc); + ws.navigation.overview(); + return { x1: x1.panelId }; + }); + const group = page.locator("[data-trellis-part=group]"); + await expect(group).toHaveCount(1); + // The row's first seam, after x1, is measured from the tile's right edge. + const first = group.locator("i[data-axis=x]").first(); + expect(await first.evaluate((el) => (el as HTMLElement).style.right)).not.toBe(""); + // x1 is the row's first part, so it's on the right of the tile. + const tile = await box(group); + await page.mouse.dblclick(tile.x + tile.width * 0.9, tile.y + tile.height / 2); + await expect(panel(page, ids.x1)).toBeVisible(); + }); + + test("resizing a floating window from its left edge grows it leftward", async ({ page }) => { + await open(page); + const id = await page.evaluate( + () => + (window as any).ws.open("files", { placement: { float: { x: 0.1, y: 0.1, w: 0.3, h: 0.4 } } }) + .panelId as string, + ); + const before = await box(panel(page, id)); + const handle = page.locator(`.trellis-handles[data-panel="${id}"] [data-dir=w]`); + const h = center(await box(handle)); + await page.mouse.move(h.x, h.y); + await page.mouse.down(); + await page.mouse.move(h.x - 60, h.y, { steps: 5 }); + await page.mouse.up(); + await expect.poll(async () => (await box(panel(page, id))).width).toBeGreaterThan(before.width + 50); + const after = await box(panel(page, id)); + expect(Math.abs(after.x + after.width - (before.x + before.width))).toBeLessThan(2); + // The document still measures it from the right: its start edge. + const rect = await page.evaluate( + (p) => (window as any).ws.getDocument().floating.find((f: any) => f.panel.id === p).rect, + id, + ); + expect(rect.x).toBeCloseTo(0.1, 2); + }); + + test('direction: "ltr" overrides the page', async ({ page }) => { + await open(page); + await page.evaluate(() => (window as any).ws.update({ direction: "ltr" })); + await expect.poll(async () => (await x(panel(page, "left"))) < (await x(panel(page, "docs")))).toBe(true); + }); +}); diff --git a/packages/core/src/runtime/drag.ts b/packages/core/src/runtime/drag.ts index d1b5db5..6f6ce83 100644 --- a/packages/core/src/runtime/drag.ts +++ b/packages/core/src/runtime/drag.ts @@ -72,6 +72,8 @@ export interface DragHost { tabbarHeight(panel: PanelNode): number; toScreen(world: Rect): Rect; fromScreen(p: { x: number; y: number }): { x: number; y: number }; + /** Right to left: tabs run leftward and floating rects are measured from the right. */ + rtl(): boolean; /** Screen rect of a docked node, inset by the gap. */ panelScreen(world: Rect): Rect; /** Inverse of panelScreen. */ @@ -282,23 +284,31 @@ export function createDragController(host: DragHost) { const dragged = sort.items.find((item) => item.id === d.pendingTab)!; const list = host.panelDom(d.lifted.id)?.tablist.getBoundingClientRect() ?? sort.row; const left = Math.max(list.left, Math.min(list.right - dragged.width, e.clientX - sort.grab)); - let x = sort.items[0].x; + // Tabs run from the start edge: the left, or the right when right to left. `edge` walks the + // row in reading order, one resting slot at a time. + const rtl = host.rtl(); + const first = sort.items[0]; + const slot = (edge: number, width: number) => (rtl ? edge - width : edge); + const step = (edge: number, width: number) => (rtl ? edge - width : edge + width); + let edge = rtl ? first.x + first.width : first.x; let index = 0; for (const id of sort.order) { const item = sort.items.find((item) => item.id === id)!; // Reorder when the pointer crosses a neighbouring tab's resting centre. - if (id !== dragged.id && e.clientX > x + item.width / 2) index++; - x += item.width; + const centre = slot(edge, item.width) + item.width / 2; + if (id !== dragged.id && (rtl ? e.clientX < centre : e.clientX > centre)) index++; + edge = step(edge, item.width); } sort.order = sort.order.filter((id) => id !== dragged.id); sort.order.splice(index, 0, dragged.id); - x = sort.items[0].x; + edge = rtl ? first.x + first.width : first.x; for (const id of sort.order) { const item = sort.items.find((item) => item.id === id)!; item.el.setAttribute("data-sorting", ""); if (id === dragged.id) item.el.setAttribute("data-dragging", ""); - item.el.style.transform = `translateX(${(id === dragged.id ? left : x) - item.x}px)`; - x += item.width; + const x = id === dragged.id ? left : slot(edge, item.width); + item.el.style.transform = `translateX(${x - item.x}px)`; + edge = step(edge, item.width); } } @@ -527,7 +537,9 @@ export function createDragController(host: DragHost) { const el = dom.tabs.get(panel.views[i])?.el; if (!el) continue; const r = el.getBoundingClientRect(); - if (p.x < r.left - rootBox.left + r.width / 2) { + const centre = r.left - rootBox.left + r.width / 2; + // Before this tab in reading order: left of its centre, or right of it right to left. + if (host.rtl() ? p.x > centre : p.x < centre) { index = i; break; } @@ -863,7 +875,8 @@ export function createDragController(host: DragHost) { const x = d.pointer.x - w * d.grabX; const y = d.pointer.y - grabY; return { - x: (x - container.x) / container.w, + // Measured from the layer's start edge (see floatScreen). + x: host.rtl() ? (container.x + container.w - x - w) / container.w : (x - container.x) / container.w, y: (y - container.y) / container.h, w: w / container.w, h: h / container.h, diff --git a/packages/core/src/runtime/menu.ts b/packages/core/src/runtime/menu.ts index 3701478..53e88bf 100644 --- a/packages/core/src/runtime/menu.ts +++ b/packages/core/src/runtime/menu.ts @@ -24,6 +24,10 @@ export class Menu { get open() { return !!this.el; } + /** Right to left, submenus open to the left and the arrow keys swap. */ + private get rtl() { + return getComputedStyle(this.host).direction === "rtl"; + } show(entries: MenuEntry[], anchor: { x: number; y: number; alignRight?: boolean }, focusFirst = true) { this.close(false); this.restoreFocus = document.activeElement as HTMLElement | null; @@ -93,7 +97,12 @@ export class Menu { this.submenu = new Menu(this.host); const r = button.getBoundingClientRect(); const b = this.host.getBoundingClientRect(); - this.submenu.show(entry.items, { x: r.right - b.left - 4, y: r.top - b.top - 5 }); + this.submenu.show( + entry.items, + this.rtl + ? { x: r.left - b.left + 4, y: r.top - b.top - 5, alignRight: true } + : { x: r.right - b.left - 4, y: r.top - b.top - 5 }, + ); this.submenu.parent = this; }; button.addEventListener("pointerenter", () => { @@ -125,6 +134,7 @@ export class Menu { e.preventDefault(); items[(to + items.length) % items.length]?.focus(); }; + const [open, back] = this.rtl ? ["ArrowLeft", "ArrowRight"] : ["ArrowRight", "ArrowLeft"]; switch (e.key) { case "ArrowDown": return move(index + 1); @@ -134,13 +144,13 @@ export class Menu { return move(0); case "End": return move(items.length - 1); - case "ArrowRight": + case open: if (items[index]?.getAttribute("aria-haspopup")) { e.preventDefault(); items[index].click(); } return; - case "ArrowLeft": + case back: case "Escape": e.preventDefault(); e.stopPropagation(); diff --git a/packages/core/src/runtime/navigation.ts b/packages/core/src/runtime/navigation.ts index 48792be..3bd4339 100644 --- a/packages/core/src/runtime/navigation.ts +++ b/packages/core/src/runtime/navigation.ts @@ -52,6 +52,8 @@ export interface NavigationHost { fromScreen(p: { x: number; y: number }): { x: number; y: number }; toScreen(world: Rect): Rect; panelScreen(world: Rect): Rect; + /** Right to left, the screen is the layout mirrored. */ + rtl(): boolean; /** Pause per-frame content updates while a gesture drives the camera. */ setGesture(active: boolean): void; busy(): boolean; @@ -233,8 +235,13 @@ export function createNavigator(host: NavigationHost) { host.camera.velocity = { x: 0, y: 0, w: 0, h: 0 }; const b = bounds(); const c = host.camera.value; - const px = (clientX - b.left) / b.width; + let px = (clientX - b.left) / b.width; const py = (clientY - b.top) / b.height; + // Mirrored, the pointer's place on screen is the other way round in the layout. + if (host.rtl()) { + px = 1 - px; + panX = -panX; + } const min = Math.min(...[...entries.values()].map((e) => Math.min(e.rect.w, e.rect.h))) * 0.65; factor = Math.max(min / Math.min(c.w, c.h), Math.min(factor, 1.35 / Math.max(c.w, c.h))); const next = { @@ -423,7 +430,13 @@ export function createNavigator(host: NavigationHost) { } | null = null; function moveRect(e: PointerEvent) { const d = drag!; - const world = rectangleCamera({ x: d.x, y: d.y }, { x: e.clientX, y: e.clientY }, d.viewport, d.view); + const mirror = (x: number) => (host.rtl() ? 2 * d.viewport.x + d.viewport.w - x : x); + const world = rectangleCamera( + { x: mirror(d.x), y: d.y }, + { x: mirror(e.clientX), y: e.clientY }, + d.viewport, + d.view, + ); const screen = host.toScreen(world); place(marqueeEl, screen); d.candidate = screen.w >= 8 && screen.h >= 8 ? bestFit(world, entries) : null; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 6ce03f5..fbc97c4 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -209,6 +209,10 @@ export interface WorkspaceOptions { /** CSS custom properties applied to the workspace, e.g. { "--trellis-accent": "#f60" }. */ tokens?: Record; keymap?: Keymap; + /** Layout direction. "auto" (default) follows the page (the `dir` attribute or CSS `direction` + * around the workspace); "rtl" mirrors the layout, tabs, menus, keys and gestures. Documents are + * direction-neutral: a row's first child sits at its start edge either way. */ + direction?: "ltr" | "rtl" | "auto"; /** What a view shows when its content fails to mount: a node or text. The default shows the * view's title, the error message and a Try again button. */ errorFallback?: (info: ErrorFallbackInfo) => Node | string; diff --git a/packages/core/src/runtime/workspace.ts b/packages/core/src/runtime/workspace.ts index 5ffb2d3..25b1e54 100644 --- a/packages/core/src/runtime/workspace.ts +++ b/packages/core/src/runtime/workspace.ts @@ -272,6 +272,21 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti // ---------------------------------------------------------------- theme let appliedTokens = new Set(); + // ---------------------------------------------------------------- direction + /** Right to left: the layout is mirrored at the screen boundary (toScreen and fromScreen), so + * the document stays direction-neutral: a row's first child is its start edge. */ + let rtl = false; + /** Follow the `direction` option, or the page's own direction with "auto". Returns whether it + * changed. */ + function readDirection(): boolean { + const direction = options.direction ?? "auto"; + setAttr(root, "dir", direction === "auto" ? null : direction); + const next = getComputedStyle(root).direction === "rtl"; + if (next === rtl) return false; + rtl = next; + setAttr(root, "data-direction", rtl ? "rtl" : null); + return true; + } function applyTheme() { setAttr(root, "data-theme", options.theme ?? "system"); const tabs = options.tabs ?? {}; @@ -288,6 +303,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti appliedTokens = new Set(Object.keys(tokens)); setAttr(root, "data-navigation", String(navigationMode())); readMetrics(); + readDirection(); } function readMetrics() { const style = getComputedStyle(root); @@ -304,18 +320,21 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const p = pad(); const W = viewport.w - p * 2; const H = viewport.h - p * 2; + const x = p + ((r.x - c.x) / c.w) * W; + const w = (r.w / c.w) * W; return { - x: p + ((r.x - c.x) / c.w) * W, + x: rtl ? viewport.w - x - w : x, y: p + ((r.y - c.y) / c.h) * H, - w: (r.w / c.w) * W, + w, h: (r.h / c.h) * H, }; } function fromScreen(p: { x: number; y: number }) { const c = camera.value; const q = pad(); + const x = rtl ? viewport.w - p.x : p.x; return { - x: c.x + ((p.x - q) / (viewport.w - q * 2)) * c.w, + x: c.x + ((x - q) / (viewport.w - q * 2)) * c.w, y: c.y + ((p.y - q) / (viewport.h - q * 2)) * c.h, }; } @@ -339,13 +358,16 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti if (layerName === "overlay") return { x: 0, y: 0, w: viewport.w, h: viewport.h }; return toScreen(stageWorld()); } + /** Floating rects are fractions of their layer, measured from its start edge. */ function floatScreen(rect: Rect, layerName: FloatingLayer): Rect { const c = floatContainer(layerName); - return { x: c.x + rect.x * c.w, y: c.y + rect.y * c.h, w: rect.w * c.w, h: rect.h * c.h }; + const x = rtl ? c.x + c.w - (rect.x + rect.w) * c.w : c.x + rect.x * c.w; + return { x, y: c.y + rect.y * c.h, w: rect.w * c.w, h: rect.h * c.h }; } function screenToFloat(r: Rect, layerName: FloatingLayer): Rect { const c = floatContainer(layerName); - return { x: (r.x - c.x) / c.w, y: (r.y - c.y) / c.h, w: r.w / c.w, h: r.h / c.h }; + const x = rtl ? (c.x + c.w - r.x - r.w) / c.w : (r.x - c.x) / c.w; + return { x, y: (r.y - c.y) / c.h, w: r.w / c.w, h: r.h / c.h }; } /** Where a panel should be, ignoring tweens. */ function targetRect(panelId: string): Rect | null { @@ -1137,13 +1159,15 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const r = entries.get(n.id)?.rect; if (!r) return; const pct = (v: number, from: number, size: number) => `${(((v - from) / size) * 100).toFixed(3)}%`; + // Horizontal positions are measured from the tile's start edge. + const start = rtl ? "right" : "left"; for (const child of n.children.slice(1)) { const c = entries.get(child.id)?.rect; if (!c) continue; html += n.axis === "x" - ? `` - : ``; + ? `` + : ``; } n.children.forEach((c) => walk(c, level + 1)); }; @@ -2044,8 +2068,9 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti if (panel.views.length > 1 && region !== "floating") moves.push( { + // Toward the end edge, which is on the left when the layout is mirrored. id: "split-right", - label: "New split right", + label: rtl ? "New split left" : "New split right", run: () => dock(viewId, { beside: panelId, edge: "right" }), }, { @@ -2127,8 +2152,14 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti return; } menuCloseHook = reset; - if (r) menu.show(entries, { x: r.right - b.left, y: r.bottom - b.top + 4, alignRight: true }); - else menu.show(entries, at ?? { x: 0, y: 0 }); + // The menu opens toward the start edge: under the button, lined up with its outer edge. + if (r) + menu.show(entries, { + x: (rtl ? r.left : r.right) - b.left, + y: r.bottom - b.top + 4, + alignRight: !rtl, + }); + else menu.show(entries, { ...(at ?? { x: 0, y: 0 }), alignRight: rtl }); } // ---------------------------------------------------------------- keyboard @@ -2144,10 +2175,12 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti setFocus(viewId); panelDoms.get(panelId)?.tabs.get(viewId)?.el.focus(); }; + // Next is the tab after this one in reading order: to the left, right to left. + const [next, previous] = rtl ? ["ArrowLeft", "ArrowRight"] : ["ArrowRight", "ArrowLeft"]; switch (e.key) { - case "ArrowRight": + case next: return go(index + 1); - case "ArrowLeft": + case previous: return go(index - 1); case "Home": return go(0); @@ -2269,7 +2302,9 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const p = pad(); const a = fromScreen({ x: screen.x - p, y: screen.y - p }); const b = fromScreen({ x: screen.x + screen.w + p, y: screen.y + screen.h + p }); - return { x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }; + // Mirrored, the screen's left edge is the world's right edge. + const x = Math.min(a.x, b.x); + return { x, y: a.y, w: Math.abs(b.x - a.x), h: b.y - a.y }; } function announce(text: string) { live.textContent = text; @@ -2348,8 +2383,13 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti const split = effectiveSplit(el.dataset.split!); const entry = entries.get(el.dataset.split!); if (!split || !entry || !doc.root) return; - const decrease = split.axis === "x" ? "ArrowLeft" : "ArrowUp"; - const increase = split.axis === "x" ? "ArrowRight" : "ArrowDown"; + // The arrow moves the divider that way on screen, which is backwards in a mirrored row. + const [decrease, increase] = + split.axis === "y" + ? ["ArrowUp", "ArrowDown"] + : rtl + ? ["ArrowRight", "ArrowLeft"] + : ["ArrowLeft", "ArrowRight"]; if (e.key !== decrease && e.key !== increase) return; e.preventDefault(); const push = dragBoundary(doc.root, layoutMetrics(), split.id, Number(el.dataset.index)); @@ -2491,6 +2531,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti tabbarHeight: (panel) => barHeight(panel), toScreen, fromScreen, + rtl: () => rtl, panelScreen: (world) => inset(toScreen(world), pad()), fromScreenRect, floatWorld(panelId) { @@ -2544,6 +2585,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti viewport: () => viewport, fromScreen, toScreen, + rtl: () => rtl, panelScreen: (world) => inset(toScreen(world), pad()), setGesture(active) { gesture = active; @@ -2585,6 +2627,7 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti if (w === viewport.w && hh === viewport.h) return; viewport = { w, h: hh }; readMetrics(); + readDirection(); tween.stop(); // Minimums are in pixels, so a new size can change the layout's proportions and scales. sync(); @@ -2685,6 +2728,8 @@ export function createWorkspace(host: HTMLElement, initialOptions: WorkspaceOpti update(patch) { const typesChanged = patch.types && patch.types !== options.types; options = { ...options, ...patch }; + // With direction "auto", any update picks up a change to the page's own direction. + readDirection(); if ( "theme" in patch || "tokens" in patch || diff --git a/packages/core/src/style.css b/packages/core/src/style.css index d9d5d4e..41dcd47 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -250,7 +250,8 @@ display: flex; align-items: stretch; gap: 2px; - padding: 0 2px 0 0; + padding-block: 0; + padding-inline: 0 2px; background: var(--trellis-tabbar); border-radius: var(--trellis-radius) var(--trellis-radius) 0 0; box-shadow: inset 0 -1px 0 var(--trellis-border); @@ -285,7 +286,8 @@ min-width: 72px; max-width: var(--trellis-tab-max-width); flex: 0 1 auto; - padding: 0 6px 0 10px; + padding-block: 0; + padding-inline: 10px 6px; border-radius: var(--trellis-tab-radius) var(--trellis-tab-radius) 0 0; color: var(--trellis-text-muted); font-weight: 500; @@ -296,9 +298,9 @@ background-color 120ms, color 120ms; } -/* The right padding assumes the close button's own space; without one, match the left. */ +/* The end padding assumes the close button's own space; without one, match the start. */ :where(.trellis [data-trellis-part="tab"]:has(> [data-trellis-part="tab-close"][hidden])) { - padding-right: 10px; + padding-inline-end: 10px; } :where(.trellis [data-trellis-part="tab"]:hover) { background: var(--trellis-tab-hover); @@ -460,7 +462,7 @@ } :where(.trellis [data-trellis-part="panel-menu"]) { align-self: center; - margin-left: 2px; + margin-inline-start: 2px; } :where(.trellis [data-trellis-part="accessories"]) { display: flex; @@ -903,13 +905,14 @@ gap: 8px; width: 100%; height: 28px; - padding: 0 8px 0 4px; + padding-block: 0; + padding-inline: 4px 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; font: inherit; - text-align: left; + text-align: start; cursor: default; outline: none; } @@ -937,6 +940,10 @@ width: 14px; height: 14px; } +/* A submenu opens toward the end edge: its chevron points left, right to left. */ +:where(.trellis .trellis-menu-chevron:dir(rtl) svg) { + transform: scaleX(-1); +} :where(.trellis .trellis-menu-label) { flex: 1; overflow: hidden; @@ -947,6 +954,9 @@ color: var(--trellis-text-muted); font-size: 11.5px; letter-spacing: 0.02em; + /* Key combinations always read left to right, even in a right-to-left menu. */ + direction: ltr; + unicode-bidi: isolate; } :where(.trellis .trellis-menu-separator) { height: 1px; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 06f7177..f8eb5d9 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -111,7 +111,7 @@ function specFrom(el: Element): LayoutSpec | null { * ``: a light-DOM custom element around `createWorkspace`. * * Attributes: theme, floating ("false" | "stage" | "overlay"), navigation ("false" | "focus" | "free"), - * motion, storage-key, version, label, panel-menu, keep-pushed ("false" undoes pushes when a divider is dragged back). + * motion, storage-key, version, label, panel-menu, direction ("ltr" | "rtl" | "auto"), keep-pushed ("false" undoes pushes when a divider is dragged back). * Children: `