From 2affd1aba00b42765e94e4fcc7af60bb0af6882d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 17:30:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(customizable-workspace):=20@bytelyst/custo?= =?UTF-8?q?mizable-workspace@0.1.0=20=E2=80=94=20Wave=2013.F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag-reorderable tile grid with per-tile width control + view-scoped persistence. Native HTML5 drag — zero runtime deps (no @dnd-kit). ────────────────────────────────────────────────────────────────── Module surface ────────────────────────────────────────────────────────────────── Wave 13.F.1 + .2 - Native HTML5 drag-and-drop (draggable + dragover + drop) - role=grid + per-cell role=gridcell + tabIndex=0 - Keyboard shortcuts on focused tile: ← / → resize span down/up (clamped 1..columns) ↑ / ↓ move tile up/down by one slot - Per-tile 1/2/3/4 size buttons (suppressed in readOnly) - Drop target gets dashed accent outline - Reset-layout CTA (suppressed in readOnly) useWorkspaceLayout(tiles, { storageKey, persistence }) - move(from, to) / resize(id, span) / reset() - hydrated flag — true once initial load resolves - One-shot load on mount, save on every change (post-hydration) reconcile(layout, tiles) · exported pure fn - Drops entries referencing removed tiles - Appends new tiles in registry order with defaultSpan fallback - Used by the hook on every tile-registry change (defensive against schema drift between releases) localStoragePersistence · default; SSR-safe (returns null when window is absent) ────────────────────────────────────────────────────────────────── Quality gates ────────────────────────────────────────────────────────────────── ✓ 10/10 tests passing - reconcile: 3 cases (drop unknown / append new / preserve order) - hook: 4 cases (hydrate defaults / move / resize / reset) - component: 3 cases (renders cells / keyboard resize / readOnly) ✓ tsc build clean ────────────────────────────────────────────────────────────────── Roadmap (lands in subsequent commit) ────────────────────────────────────────────────────────────────── 13.F.1 Drag-resize tiles 13.F.2 Saved views per route + persistence layer Showcase /futurism/workspace (MAG.6) lands in the paired showcase commit. --- packages/customizable-workspace/package.json | 34 +++ .../customizable-workspace/src/Workspace.tsx | 262 ++++++++++++++++++ .../src/__tests__/workspace.test.tsx | 137 +++++++++ packages/customizable-workspace/src/index.ts | 28 ++ .../customizable-workspace/src/persistence.ts | 34 +++ packages/customizable-workspace/src/types.ts | 34 +++ .../src/useWorkspaceLayout.ts | 135 +++++++++ packages/customizable-workspace/tsconfig.json | 11 + .../customizable-workspace/vitest.config.ts | 2 + 9 files changed, 677 insertions(+) create mode 100644 packages/customizable-workspace/package.json create mode 100644 packages/customizable-workspace/src/Workspace.tsx create mode 100644 packages/customizable-workspace/src/__tests__/workspace.test.tsx create mode 100644 packages/customizable-workspace/src/index.ts create mode 100644 packages/customizable-workspace/src/persistence.ts create mode 100644 packages/customizable-workspace/src/types.ts create mode 100644 packages/customizable-workspace/src/useWorkspaceLayout.ts create mode 100644 packages/customizable-workspace/tsconfig.json create mode 100644 packages/customizable-workspace/vitest.config.ts diff --git a/packages/customizable-workspace/package.json b/packages/customizable-workspace/package.json new file mode 100644 index 00000000..91adabb5 --- /dev/null +++ b/packages/customizable-workspace/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bytelyst/customizable-workspace", + "version": "0.1.0", + "type": "module", + "description": "Drag-reorderable tile grid with width control + per-view persistence. Native HTML5 drag — zero runtime deps.", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.3", + "vitest": "^4.0.18" + } +} diff --git a/packages/customizable-workspace/src/Workspace.tsx b/packages/customizable-workspace/src/Workspace.tsx new file mode 100644 index 00000000..b47df528 --- /dev/null +++ b/packages/customizable-workspace/src/Workspace.tsx @@ -0,0 +1,262 @@ +import { useCallback, useState, type CSSProperties, type DragEvent } from 'react'; +import type { LayoutPersistence, TileSpan, TileSpec } from './types.js'; +import { useWorkspaceLayout } from './useWorkspaceLayout.js'; + +export interface WorkspaceProps { + /** Tile registry. */ + tiles: TileSpec[]; + /** Persistence key (route-scoped). */ + storageKey: string; + /** Optional persistence override (default localStorage). */ + persistence?: LayoutPersistence; + /** + * Number of grid columns. Tiles' `span` is interpreted as a fraction + * of this. Default 4. + */ + columns?: 4 | 6 | 12; + /** Show the reset-layout CTA. Default true. */ + showResetCta?: boolean; + /** Bypass drag entirely (read-only). Default false. */ + readOnly?: boolean; + className?: string; + style?: CSSProperties; +} + +/** + * `` — drag-reorderable tile grid with width control. + * + * Wave 13.F.1 + .2. HTML5 native drag (no `@dnd-kit` dependency). + * Persists via the `LayoutPersistence` adapter (default localStorage — + * hosts on `platform-service` pass a server-backed one). + * + * Keyboard shortcuts on a focused tile: + * - ←/→ resize span down/up (clamped to 1..4) + * - ↑/↓ move tile up/down by one slot + */ +export function Workspace({ + tiles, + storageKey, + persistence, + columns = 4, + showResetCta = true, + readOnly = false, + className, + style, +}: WorkspaceProps) { + const { layout, move, resize, reset, hydrated } = useWorkspaceLayout(tiles, { + storageKey, + persistence, + }); + const tileById = new Map(tiles.map((t) => [t.id, t])); + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + + const onDragStart = useCallback( + (e: DragEvent, idx: number) => { + if (readOnly) return; + setDragIndex(idx); + e.dataTransfer.effectAllowed = 'move'; + try { + e.dataTransfer.setData('text/plain', String(idx)); + } catch { + /* ignore */ + } + }, + [readOnly], + ); + + const onDragOver = useCallback( + (e: DragEvent, idx: number) => { + if (readOnly) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropIndex(idx); + }, + [readOnly], + ); + + const onDrop = useCallback( + (e: DragEvent, idx: number) => { + if (readOnly) return; + e.preventDefault(); + const from = dragIndex; + setDragIndex(null); + setDropIndex(null); + if (from === null) return; + move(from, idx); + }, + [dragIndex, move, readOnly], + ); + + return ( +
+ {showResetCta && !readOnly && ( +
+ + {!hydrated && ( + + loading… + + )} +
+ )} +
+ {layout.entries.map((entry, idx) => { + const tile = tileById.get(entry.id); + if (!tile) return null; + const span = Math.max(1, Math.min(columns, entry.span)) as TileSpan; + return ( +
onDragStart(e, idx)} + onDragOver={(e) => onDragOver(e, idx)} + onDragLeave={() => setDropIndex((v) => (v === idx ? null : v))} + onDrop={(e) => onDrop(e, idx)} + onKeyDown={(e) => { + if (readOnly) return; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + resize(entry.id, Math.max(1, span - 1) as TileSpan); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + resize(entry.id, Math.min(columns, span + 1) as TileSpan); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + move(idx, Math.max(0, idx - 1)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + move(idx, idx + 1); + } + }} + style={{ + gridColumn: `span ${span}`, + background: 'var(--bl-surface-card, #fff)', + border: '1px solid var(--bl-border, rgba(0,0,0,0.12))', + borderRadius: 14, + padding: 12, + outline: + dropIndex === idx + ? '2px dashed var(--bl-accent, #6366f1)' + : 'none', + cursor: readOnly ? 'default' : 'grab', + transition: 'outline-color 120ms ease', + }} + > +
+
+

+ {tile.title} +

+ {tile.subtitle && ( +

+ {tile.subtitle} +

+ )} +
+ {!readOnly && ( +
+ {([1, 2, 3, 4] as TileSpan[]).filter((n) => n <= columns).map((n) => ( + + ))} +
+ )} +
+
{tile.content}
+
+ ); + })} +
+
+ ); +} diff --git a/packages/customizable-workspace/src/__tests__/workspace.test.tsx b/packages/customizable-workspace/src/__tests__/workspace.test.tsx new file mode 100644 index 00000000..510d070a --- /dev/null +++ b/packages/customizable-workspace/src/__tests__/workspace.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; + +import { Workspace } from '../Workspace.js'; +import { useWorkspaceLayout, reconcile } from '../useWorkspaceLayout.js'; +import type { LayoutPersistence, LayoutSpec, TileSpec } from '../types.js'; + +const TILES: TileSpec[] = [ + { id: 'a', title: 'Tile A', content:
A
, defaultSpan: 2 }, + { id: 'b', title: 'Tile B', content:
B
, defaultSpan: 2 }, + { id: 'c', title: 'Tile C', content:
C
, defaultSpan: 1 }, +]; + +function memoryPersistence(): LayoutPersistence & { current: LayoutSpec | null } { + let value: LayoutSpec | null = null; + return { + get current() { + return value; + }, + save: (_k, l) => { + value = l; + }, + load: () => value, + } as LayoutPersistence & { current: LayoutSpec | null }; +} + +beforeEach(() => cleanup()); + +describe('reconcile', () => { + it('drops entries for unknown tiles', () => { + const out = reconcile( + { entries: [{ id: 'a', span: 2 }, { id: 'gone', span: 1 }] }, + [{ id: 'a', title: 'A', content: null }], + ); + expect(out.entries.map((e) => e.id)).toEqual(['a']); + }); + + it('appends new tiles in registry order with defaultSpan fallback', () => { + const out = reconcile( + { entries: [{ id: 'a', span: 3 }] }, + [ + { id: 'a', title: 'A', content: null }, + { id: 'b', title: 'B', content: null, defaultSpan: 4 }, + ], + ); + expect(out.entries).toEqual([ + { id: 'a', span: 3 }, + { id: 'b', span: 4 }, + ]); + }); + + it('preserves order of surviving entries', () => { + const out = reconcile( + { entries: [{ id: 'b', span: 2 }, { id: 'a', span: 2 }] }, + [ + { id: 'a', title: 'A', content: null }, + { id: 'b', title: 'B', content: null }, + ], + ); + expect(out.entries.map((e) => e.id)).toEqual(['b', 'a']); + }); +}); + +describe('useWorkspaceLayout', () => { + it('hydrates with defaults when persistence returns null', async () => { + const p = memoryPersistence(); + const { result } = renderHook(() => + useWorkspaceLayout(TILES, { storageKey: 'k1', persistence: p }), + ); + await waitFor(() => expect(result.current.hydrated).toBe(true)); + expect(result.current.layout.entries.map((e) => e.id)).toEqual(['a', 'b', 'c']); + }); + + it('move() reorders entries and persists', async () => { + const p = memoryPersistence(); + const { result } = renderHook(() => + useWorkspaceLayout(TILES, { storageKey: 'k2', persistence: p }), + ); + await waitFor(() => expect(result.current.hydrated).toBe(true)); + act(() => result.current.move(0, 2)); + expect(result.current.layout.entries.map((e) => e.id)).toEqual(['b', 'c', 'a']); + await waitFor(() => + expect(p.current?.entries.map((e) => e.id)).toEqual(['b', 'c', 'a']), + ); + }); + + it('resize() updates a tile span', async () => { + const p = memoryPersistence(); + const { result } = renderHook(() => + useWorkspaceLayout(TILES, { storageKey: 'k3', persistence: p }), + ); + await waitFor(() => expect(result.current.hydrated).toBe(true)); + act(() => result.current.resize('a', 4)); + expect(result.current.layout.entries.find((e) => e.id === 'a')!.span).toBe(4); + }); + + it('reset() restores defaults', async () => { + const p = memoryPersistence(); + const { result } = renderHook(() => + useWorkspaceLayout(TILES, { storageKey: 'k4', persistence: p }), + ); + await waitFor(() => expect(result.current.hydrated).toBe(true)); + act(() => result.current.move(0, 2)); + act(() => result.current.reset()); + expect(result.current.layout.entries.map((e) => e.id)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('', () => { + it('renders one cell per tile', async () => { + const p = memoryPersistence(); + render(); + await waitFor(() => + expect(screen.getAllByTestId('bl-workspace-tile')).toHaveLength(3), + ); + }); + + it('arrow-right keypress on focused tile increases its span', async () => { + const p = memoryPersistence(); + render(); + await waitFor(() => screen.getAllByTestId('bl-workspace-tile')); + const first = screen.getAllByTestId('bl-workspace-tile')[0]!; + first.focus(); + fireEvent.keyDown(first, { key: 'ArrowRight' }); + await waitFor(() => { + expect(p.current?.entries[0]!.span).toBeGreaterThanOrEqual(3); + }); + }); + + it('readOnly hides resize controls + reset CTA', async () => { + const p = memoryPersistence(); + render(); + await waitFor(() => screen.getAllByTestId('bl-workspace-tile')); + expect(screen.queryByTestId('bl-workspace-reset')).toBeNull(); + expect(screen.queryByTestId('bl-workspace-tile-size-1')).toBeNull(); + }); +}); diff --git a/packages/customizable-workspace/src/index.ts b/packages/customizable-workspace/src/index.ts new file mode 100644 index 00000000..896798bd --- /dev/null +++ b/packages/customizable-workspace/src/index.ts @@ -0,0 +1,28 @@ +/** + * @bytelyst/customizable-workspace — drag-reorderable tile grid. + * + * Wave 13.F. Native HTML5 drag, keyboard-accessible (←/→ resize, + * ↑/↓ reorder), persistence-pluggable. + */ + +export { Workspace } from './Workspace.js'; +export type { WorkspaceProps } from './Workspace.js'; + +export { + useWorkspaceLayout, + reconcile, +} from './useWorkspaceLayout.js'; +export type { + UseWorkspaceLayoutOptions, + UseWorkspaceLayoutHelpers, +} from './useWorkspaceLayout.js'; + +export { localStoragePersistence } from './persistence.js'; + +export type { + LayoutEntry, + LayoutPersistence, + LayoutSpec, + TileSpan, + TileSpec, +} from './types.js'; diff --git a/packages/customizable-workspace/src/persistence.ts b/packages/customizable-workspace/src/persistence.ts new file mode 100644 index 00000000..b25b33a8 --- /dev/null +++ b/packages/customizable-workspace/src/persistence.ts @@ -0,0 +1,34 @@ +import type { LayoutPersistence, LayoutSpec } from './types.js'; + +/** + * Default persistence layer — backed by `localStorage`. SSR-safe + * (returns `null` / no-op when `window` is absent). + * + * Hosts on `platform-service` should pass their own `LayoutPersistence` + * to `` that round-trips through the + * `/api/platform/layouts/{routeKey}` endpoint. + */ +export const localStoragePersistence: LayoutPersistence = { + save: (key, layout) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(key, JSON.stringify(layout)); + } catch { + /* quota / disabled — silently ignore */ + } + }, + load: (key) => { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.entries)) { + return parsed as LayoutSpec; + } + return null; + } catch { + return null; + } + }, +}; diff --git a/packages/customizable-workspace/src/types.ts b/packages/customizable-workspace/src/types.ts new file mode 100644 index 00000000..3854cf81 --- /dev/null +++ b/packages/customizable-workspace/src/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +/** Available tile widths — fractions of the row. */ +export type TileSpan = 1 | 2 | 3 | 4; + +export interface TileSpec { + /** Stable id — used as React key + persistence key. */ + id: string; + /** Title rendered in the tile header (also used as the drag handle label). */ + title: string; + /** Optional one-line subtitle. */ + subtitle?: string; + /** Body slot. */ + content: ReactNode; + /** Default tile width. Default 2. */ + defaultSpan?: TileSpan; +} + +export interface LayoutEntry { + id: string; + span: TileSpan; +} + +export interface LayoutSpec { + /** Ordered list of tile ids + spans. */ + entries: LayoutEntry[]; +} + +export interface LayoutPersistence { + /** Called on every layout change. Default writes to localStorage. */ + save: (key: string, layout: LayoutSpec) => void | Promise; + /** Loaded once on mount. Default reads from localStorage. */ + load: (key: string) => LayoutSpec | null | Promise; +} diff --git a/packages/customizable-workspace/src/useWorkspaceLayout.ts b/packages/customizable-workspace/src/useWorkspaceLayout.ts new file mode 100644 index 00000000..f3f7fbf5 --- /dev/null +++ b/packages/customizable-workspace/src/useWorkspaceLayout.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + LayoutEntry, + LayoutPersistence, + LayoutSpec, + TileSpan, + TileSpec, +} from './types.js'; +import { localStoragePersistence } from './persistence.js'; + +export interface UseWorkspaceLayoutOptions { + /** Persistence key (route-scoped). Should be product + view unique. */ + storageKey: string; + /** Override the persistence layer — default is `localStorage`. */ + persistence?: LayoutPersistence; +} + +export interface UseWorkspaceLayoutHelpers { + layout: LayoutSpec; + /** Move a tile from `fromIndex` → `toIndex` (clamped). */ + move: (fromIndex: number, toIndex: number) => void; + /** Set a tile's span. */ + resize: (id: string, span: TileSpan) => void; + /** Reset to the default layout (one entry per tile, defaultSpan or 2). */ + reset: () => void; + /** True once the persistence load has resolved. */ + hydrated: boolean; +} + +/** + * `useWorkspaceLayout` — derives the live layout from a tile registry + + * persistence layer. Order + spans are persisted on every change. + * + * The hook is `tiles`-driven: pass the canonical tile registry and the + * hook returns the current ordered+sized layout. Tiles absent from + * persisted layout are appended in registry order; persisted entries + * referencing removed tiles are dropped (defensive against schema + * drift between releases). + */ +export function useWorkspaceLayout( + tiles: TileSpec[], + options: UseWorkspaceLayoutOptions, +): UseWorkspaceLayoutHelpers { + const persistence = options.persistence ?? localStoragePersistence; + const persistenceRef = useRef(persistence); + persistenceRef.current = persistence; + + const defaults: LayoutSpec = useMemo( + () => ({ + entries: tiles.map((t) => ({ id: t.id, span: t.defaultSpan ?? 2 })), + }), + [tiles], + ); + + const [layout, setLayout] = useState(defaults); + const [hydrated, setHydrated] = useState(false); + + // One-shot load on mount. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const loaded = await persistenceRef.current.load(options.storageKey); + if (cancelled) return; + if (loaded) { + setLayout(reconcile(loaded, tiles)); + } + } finally { + if (!cancelled) setHydrated(true); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.storageKey]); + + // Re-reconcile whenever the tile registry changes (add/remove/reorder). + useEffect(() => { + if (!hydrated) return; + setLayout((cur) => reconcile(cur, tiles)); + }, [tiles, hydrated]); + + // Persist on every change (after hydration so initial load doesn't write back). + useEffect(() => { + if (!hydrated) return; + void persistenceRef.current.save(options.storageKey, layout); + }, [layout, options.storageKey, hydrated]); + + const move = useCallback((from: number, to: number) => { + setLayout((cur) => { + const len = cur.entries.length; + if (from === to || from < 0 || from >= len) return cur; + const clampedTo = Math.max(0, Math.min(len - 1, to)); + const next = cur.entries.slice(); + const [moved] = next.splice(from, 1); + if (!moved) return cur; + next.splice(clampedTo, 0, moved); + return { entries: next }; + }); + }, []); + + const resize = useCallback((id: string, span: TileSpan) => { + setLayout((cur) => ({ + entries: cur.entries.map((e) => (e.id === id ? { ...e, span } : e)), + })); + }, []); + + const reset = useCallback(() => { + setLayout(defaults); + }, [defaults]); + + return { layout, move, resize, reset, hydrated }; +} + +/** + * Drop entries that no longer exist; append new tiles in registry order + * with their default span. Pure function (used by tests). + */ +export function reconcile( + layout: LayoutSpec, + tiles: TileSpec[], +): LayoutSpec { + const knownIds = new Set(tiles.map((t) => t.id)); + const surviving: LayoutEntry[] = layout.entries.filter((e) => + knownIds.has(e.id), + ); + const seen = new Set(surviving.map((e) => e.id)); + for (const t of tiles) { + if (!seen.has(t.id)) { + surviving.push({ id: t.id, span: t.defaultSpan ?? 2 }); + } + } + return { entries: surviving }; +} diff --git a/packages/customizable-workspace/tsconfig.json b/packages/customizable-workspace/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/customizable-workspace/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/customizable-workspace/vitest.config.ts b/packages/customizable-workspace/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/customizable-workspace/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });