feat(customizable-workspace): @bytelyst/customizable-workspace@0.1.0 — Wave 13.F

Drag-reorderable tile grid with per-tile width control + view-scoped
persistence. Native HTML5 drag — zero runtime deps (no @dnd-kit).

──────────────────────────────────────────────────────────────────
Module surface
──────────────────────────────────────────────────────────────────
  <Workspace tiles storageKey persistence? columns? readOnly?>
    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.
This commit is contained in:
saravanakumardb1 2026-05-27 17:30:33 -07:00
parent 4af06f732b
commit 2affd1aba0
9 changed files with 677 additions and 0 deletions

View File

@ -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"
}
}

View File

@ -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;
}
/**
* `<Workspace>` 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<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const onDragStart = useCallback(
(e: DragEvent<HTMLDivElement>, 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<HTMLDivElement>, idx: number) => {
if (readOnly) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropIndex(idx);
},
[readOnly],
);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>, 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 (
<div data-testid="bl-workspace" className={className} style={style}>
{showResetCta && !readOnly && (
<div className="bl-workspace-toolbar">
<button
type="button"
onClick={reset}
data-testid="bl-workspace-reset"
style={{
padding: '4px 10px',
fontSize: 12,
borderRadius: 999,
border: '1px solid var(--bl-border, rgba(0,0,0,0.14))',
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-secondary, #555)',
cursor: 'pointer',
}}
>
Reset layout
</button>
{!hydrated && (
<span
data-testid="bl-workspace-hydrating"
style={{
marginLeft: 8,
fontSize: 11,
color: 'var(--bl-text-tertiary, #999)',
fontFamily: 'ui-monospace, monospace',
}}
>
loading
</span>
)}
</div>
)}
<div
role="grid"
aria-label="Workspace tiles"
data-testid="bl-workspace-grid"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 12,
marginTop: 8,
}}
>
{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 (
<div
key={entry.id}
role="gridcell"
data-testid="bl-workspace-tile"
data-tile-id={entry.id}
data-drop-target={dropIndex === idx ? 'true' : undefined}
tabIndex={0}
draggable={!readOnly}
onDragStart={(e) => 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',
}}
>
<header
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 8,
marginBottom: 8,
}}
>
<div>
<h4
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
color: 'var(--bl-text-primary, #111)',
}}
>
{tile.title}
</h4>
{tile.subtitle && (
<p
style={{
margin: '2px 0 0',
fontSize: 11,
color: 'var(--bl-text-tertiary, #999)',
}}
>
{tile.subtitle}
</p>
)}
</div>
{!readOnly && (
<div
role="group"
aria-label={`Resize ${tile.title}`}
style={{ display: 'inline-flex', gap: 2 }}
>
{([1, 2, 3, 4] as TileSpan[]).filter((n) => n <= columns).map((n) => (
<button
key={n}
type="button"
aria-pressed={span === n}
onClick={() => resize(entry.id, n)}
data-testid={`bl-workspace-tile-size-${n}`}
style={{
width: 22,
height: 22,
fontSize: 11,
borderRadius: 6,
border:
span === n
? '1px solid var(--bl-accent, #6366f1)'
: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
background:
span === n
? 'color-mix(in srgb, var(--bl-accent, #6366f1) 16%, transparent)'
: 'transparent',
color:
span === n
? 'var(--bl-accent, #6366f1)'
: 'var(--bl-text-tertiary, #999)',
cursor: 'pointer',
}}
>
{n}
</button>
))}
</div>
)}
</header>
<div>{tile.content}</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -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: <div data-testid="body-a">A</div>, defaultSpan: 2 },
{ id: 'b', title: 'Tile B', content: <div data-testid="body-b">B</div>, defaultSpan: 2 },
{ id: 'c', title: 'Tile C', content: <div data-testid="body-c">C</div>, 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('<Workspace>', () => {
it('renders one cell per tile', async () => {
const p = memoryPersistence();
render(<Workspace tiles={TILES} storageKey="r1" persistence={p} />);
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(<Workspace tiles={TILES} storageKey="r2" persistence={p} />);
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(<Workspace tiles={TILES} storageKey="r3" persistence={p} readOnly />);
await waitFor(() => screen.getAllByTestId('bl-workspace-tile'));
expect(screen.queryByTestId('bl-workspace-reset')).toBeNull();
expect(screen.queryByTestId('bl-workspace-tile-size-1')).toBeNull();
});
});

View File

@ -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';

View File

@ -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 `<Workspace>` 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;
}
},
};

View File

@ -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<void>;
/** Loaded once on mount. Default reads from localStorage. */
load: (key: string) => LayoutSpec | null | Promise<LayoutSpec | null>;
}

View File

@ -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<LayoutSpec>(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 };
}

View File

@ -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"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });