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:
parent
4af06f732b
commit
2affd1aba0
34
packages/customizable-workspace/package.json
Normal file
34
packages/customizable-workspace/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
262
packages/customizable-workspace/src/Workspace.tsx
Normal file
262
packages/customizable-workspace/src/Workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
packages/customizable-workspace/src/__tests__/workspace.test.tsx
Normal file
137
packages/customizable-workspace/src/__tests__/workspace.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/customizable-workspace/src/index.ts
Normal file
28
packages/customizable-workspace/src/index.ts
Normal 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';
|
||||
34
packages/customizable-workspace/src/persistence.ts
Normal file
34
packages/customizable-workspace/src/persistence.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
34
packages/customizable-workspace/src/types.ts
Normal file
34
packages/customizable-workspace/src/types.ts
Normal 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>;
|
||||
}
|
||||
135
packages/customizable-workspace/src/useWorkspaceLayout.ts
Normal file
135
packages/customizable-workspace/src/useWorkspaceLayout.ts
Normal 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 };
|
||||
}
|
||||
11
packages/customizable-workspace/tsconfig.json
Normal file
11
packages/customizable-workspace/tsconfig.json
Normal 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"]
|
||||
}
|
||||
2
packages/customizable-workspace/vitest.config.ts
Normal file
2
packages/customizable-workspace/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||
Loading…
Reference in New Issue
Block a user