From 5d9738699b32c09512866ee126e8261c31fc401b Mon Sep 17 00:00:00 2001 From: akash-aman Date: Sun, 4 Oct 2026 23:12:45 +0530 Subject: [PATCH 01/41] docs(v2): audit of v1 and design proposal for v2 --- docs/v2/01-audit.md | 375 ++++++++++++++ docs/v2/02-design.md | 1149 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1524 insertions(+) create mode 100644 docs/v2/01-audit.md create mode 100644 docs/v2/02-design.md diff --git a/docs/v2/01-audit.md b/docs/v2/01-audit.md new file mode 100644 index 0000000..2a6173f --- /dev/null +++ b/docs/v2/01-audit.md @@ -0,0 +1,375 @@ +# dynamix-layout v2 — Phase 1 audit + +Status: **draft for review**. No source code was changed in this phase. + +- **Baseline:** branch `feat/maximize-and-fold-ui` at `1be7413`. That is `main` (core 1.1.0, react 1.0.2, solid 1.0.2) plus the still-open PR #83 (maximize and fold). Features that exist only in PR #83 are marked **[#83]**. +- **Line references** are to that commit. +- **Evidence.** Items marked **(probed)** were checked by running throwaway Vitest / React Testing Library scripts against the source. The scripts were deleted afterwards, and their output is quoted where it matters. + +--- + +## 0. Summary + +| Area | Verdict | +|---|---| +| Global singleton state | **Confirmed, and worse than described.** Two instances don't just overwrite each other: instance A's methods mutate instance B's tree (probed). | +| Engine lifecycle in React | **Confirmed.** Every new `tabs` identity rebuilds the engine from the *initial* tree, and the rendered DOM keeps pointing at the old engine's node ids (probed). | +| SSR | **Confirmed.** `renderToString` works but isn't deterministic (random UUIDs in markup), so hydration will mismatch (probed). | +| Packaging | **Confirmed**, plus two new problems: (a) the React bundle *inlines React 19's `jsx-runtime`*, so the package can't work with React 18; (b) the React smoke test never runs, because the package has no `test` script. | +| Public API | About 60 exports across core and react. Most are internals. Three exported React types describe an API that doesn't exist. | +| Serialized format | Documented in §5. Tab identity is the tab **label** (`nodName`); `uidNode` of tabs is a per-session random id and is ignored on load. | +| New bugs | 25 items in §4. The most important are B1–B6 and B25. | + +--- + +## 1. Feature inventory + +### 1.1 Layout model (core) + +| # | Feature | Where | Notes | +|---|---|---|---| +| F1 | Tree of `row → tabset → tab` nodes with bonds (splitters) between siblings | `core/src/app/node.ts:7-225` (`Node`), `:227-256` (`Bond`) | Bonds are a doubly-linked list woven through `Node.prev/next`, not children. | +| F2 | Alternating direction: the root row lays out horizontally, and every level flips | `tree-builder.ts:60-79`, `:90`, `:127`; `Node.cache.mapDirs` | Not serialized; derived from depth. | +| F3 | Initial layout from a list of tab names | `tree-builder.ts:134-242` | Right-leaning binary tree: `ts(t1) \| row(ts(t2) / row(…))`. One tab → one tabset (`:151-171`). | +| F4 | Restore from a saved `LayoutTree` | `tree-builder.ts:81-132`, `:244-260`; `dynamix.ts:106-128` | BFS zip of the JSON tree against the node tree. Tab ids are re-mapped from labels (`:255-257`). | +| F5 | Minimum sizes: per-tabset `minW`/`minH`, propagated up (sum along the axis plus bonds, max across it) | `geometry.ts:19-136`; `node.ts:80-100` | Tab bar height raises `minH` in the adapters (`react/src/hooks/useLayout.ts:76-78`). | +| F6 | Root overflow: if the container is smaller than the tree minimum, the root grows past it and is clipped | `geometry.ts:144-170`; root `overflow:hidden` (`react/src/components/Layout.tsx:180`) | Users can't scroll to the clipped part. | +| F7 | Space distribution by weight (`part`) of the space *above the minimum*, with cumulative boundary rounding | `node.ts:102-191` | `part` isn't a percentage (see §5.3). | +| F8 | Splitter resize: clamp to the neighbours' minimums, re-weight only the two neighbours, recompute only that subtree | `slider.ts:30-126` | Edges outside the dragged pair don't move. | +| F9 | Optional debounce for splitter and container updates | `slider.ts:7-28`; `geometry.ts:263-285` | Both share **one** `engine.cntdown` timer (see B12). Disabled by default in the adapters. | +| F10 | Injectable `createId` and `timer` | `state.ts:5-15`; `dynamix.ts:82-85` | Added in 1.1.0. Stored globally. | +| F11 | Tree mutations from drag-and-drop | `tree-mutations.ts:8-107` | See 1.2. | +| F12 | Tree normalization after a removal: collapse single-child rows into the grandparent; delete an empty tabset | `tree-mutations.ts:447-500`, `:527-575` | Incomplete (see B7). | +| F13 | Serialization `Node.toJSON()` | `node.ts:193-224` | Only reachable through the static `DynamixLayoutCore._root.toJSON()`. | +| F14 | Reactive output maps (`nodOpts` tabsets, `bndOpts` bonds, `tabOpts` tabs) with an equality check | `geometry.ts:172-261`; `reactive-state.ts`; `comparator.ts` | Static, not per instance. | +| F15 | **[#83]** Maximize one tabset over the whole layout; the others stay mounted and hidden; restore is exact | `view-state.ts:73-92`; `geometry.ts:190-237` | Persisted as root `nodMaxd`. | +| F16 | **[#83]** Fold a tabset to a strip along its row; unfold restores the exact size; folding the last open sibling unfolds the most recently folded one | `view-state.ts:94-125`; `geometry.ts:84-100`; `node.ts:114-124` | Persisted as tabset `nodFold`. Neighbouring bonds are locked (`geometry.ts:249`, `slider.ts:55`). | +| F17 | **[#83]** View-state normalization: a lone tabset can't stay folded, every row keeps one open child, a missing maximized id is dropped | `view-state.ts:52-71` | | +| F18 | Framework-agnostic helpers: drop-preview geometry, tab bar placement, body rect, rAF scheduler | `drop-preview.ts`, `dom.ts` | Added in 1.1.0 and used by both adapters. | + +### 1.2 Drag-and-drop operations (`updateTree(src, des, area)`) + +| Source → target, area | Result | Where | +|---|---|---| +| tab → tab, `left`/`right` | Insert the tab before/after the target tab in its tabset | `tree-mutations.ts:122-192` | +| tab → tabset, `contain` | Append to the tabset (rewritten as "beside its last tab") | `:77-79` | +| tabset → tab or tabset, `contain` | Merge all its tabs into the target tabset | `:168-189` | +| tab or tabset → tabset, `top`/`bottom`/`left`/`right` | Split: a sibling if the axis matches, otherwise wrap both in a new row | `:194-251`, `:253-445` | +| tab or tabset → root, a side | Dock at the layout edge, wrapping the existing children if needed | `:322-407` | +| Guards | Ignore: a move onto itself; a lone tab onto its own tabset; the only tabset/tab in the layout; nested-row targets; `row` sources | `:17-87` | +| Side effects | Any move ends maximize, and a moved tabset arrives unfolded **[#83]** | `:89-91` | + +### 1.3 React adapter features + +| # | Feature | Where | +|---|---|---| +| R1 | `` | `components/Layout.tsx:50-436` | +| R2 | **All tab bodies are rendered as flat siblings of the root and positioned absolutely.** Moving a tab between tabsets therefore never remounts its content (editors, iframes and terminals keep their state). | `Layout.tsx:335-368` — **this behaviour must survive v2** | +| R3 | Imperative DOM fast path: engine change listeners write rects directly; React re-renders only on structural changes | `hooks/useLayout.ts:124-187` | +| R4 | Splitter drag with pointer events and pointer capture, coalesced by rAF | `useLayout.ts:189-254` | +| R5 | Tab and tabset drag with HTML5 DnD: transparent drag image, drop indicator, root-edge targets, tab-bar insertion marker, folded-strip target **[#83]** | `useLayout.ts:256-585` | +| R6 | During a drag: `.is-dragging` disables pointer events inside tab bodies, so iframes don't swallow events; overlay panels catch drops; `document.body.style.cursor` is changed | `layout.css:27-29`; `useLayout.ts:414-458`, `:486-566` | +| R7 | Active tab switch, done imperatively (no React render) | `useLayout.ts:587-648` | +| R8 | Container measurement with `getBoundingClientRect` and padding `pad {t,b,l,r}` | `Layout.tsx:108-118` | +| R9 | Container resize on `window.resize` only | `useLayout.ts:650-682` | +| R10 | Tab bar toggle (`enableTabbar`), height (`tabHeadHeight`), display names (`tabNames`) | `Layout.tsx:65`, `:71-72`, `:306` | +| R11 | Six replaceable wrappers plus defaults: `WrapTabPanel`, `WrapTabHead`, `WrapTabLabel`, `WrapTabBody`, `SliderElement`, `HoverElement`; **[#83]** `TabsetToolbar` | `Layout.tsx:64-70`, `:99`; `components/Default.tsx` | +| R12 | Fourteen styling props (7 `*Styles`, 7 `*Class`) | `Layout.tsx:81-94` | +| R13 | `updateJSON(tree)` on mount, after a drop, after a splitter release, and after maximize/fold | `useLayout.ts:674`, `:557`, `:229`, `:364` | +| R14 | **[#83]** Toolbar (maximize, fold), tab-bar double-click, and Alt +/- shortcuts for the last-touched tabset | `Default.tsx:338-406`; `useLayout.ts:358-412` | +| R15 | Default stylesheet: active tab colours, hidden scrollbars, toolbar, `[data-dx-hidden]` | `components/layout.css` | + +--- + +## 2. Public API inventory + +"Public" means exported from the package entry. "Intended" is my judgement of whether a user should rely on it. + +### 2.1 `@dynamix-layout/core` (`src/index.ts` → `src/app/index.ts`) + +| Export | Kind | Intended public? | Notes | +|---|---|---|---| +| `DynamixLayoutCore` | class | Yes (the only engine entry) | See the method table below. | +| `Node`, `Bond` | class | **No** | Exported so the adapters can read `Node.cache`. | +| `Queue` | class | **No** | 41 methods; core uses 13 (§3, P8). | +| `createReactiveState` | fn | **No** | | +| `areNodeOptionsMapEqual`, `areNodeOptionsEqual` | fn | **No** | | +| `getTabsetDropPreview`, `getNavbarDropPreview`, `getRootSplitPreview`, `isSameDropPreview` | fn | Semi (adapter helpers, 1.1.0) | Pure; worth keeping, possibly under a subpath. | +| `setElementRect`, `getTabBodyRect`, `getTabbarPlacement`, `placeTabbar`, `createFrameScheduler` | fn | Semi (adapter helpers) | Touch the DOM only when called. | +| Types `Dimension`, `LayoutTree`, `NodeOptions`, `BaseNode`, `NodeType`, `NodeTypeWithBond`, `TabsIds`, `NodeCache`, `RootAdjustment`, `ReactiveValue`, `ChangeListener`, `DropArea`, `RootSide`, `DropPreview`, `PreviewRect`, `TabbarPlacement`, `FrameScheduler` | types | Only `LayoutTree` and `Dimension` are user-facing | `Timer` appears in the public constructor signature but **isn't exported**. | +| *(side effect)* console banner and `window.__DYNAMIX_LAYOUT__` | import-time | No | P11. | + +**`DynamixLayoutCore` members (all public):** +- Static accessors: `_root`, `_tree`, `_minW`, `_minH`, `_bond`, `_inst` (`dynamix.ts:22-68`). +- Fields: `nodeOps` (unused), `cntdown`, `tabsIds`. +- Methods: + - Iterators: `JSONLayoutIterator`, `NodeLayoutIterator`, `NodeLayoutRecursiveIterator`. + - Tree building: `updateChildDirections`, `createNodeLayout`, `createNodeTreeFromJSONTree`, `createBinaryNodeTreeFromQueue`, `createNodeFromJSON`. + - Geometry: `calcTabsetCountAndMinDim`, `pruneStaleDirections`, `calculateRootAdjustment`, `calcDimensions`, `updateDimension`, `updateSlider`, `updateSliderDimension`. + - Mutations: `updateTree`, `isOnlyContent`, `moveNodeAsTab`, `moveRelativeToRoot`, `moveRelativeToTabset`, `insertNode`, `insertNodeWithNewParent`, `moveAdjacentNodeToGrandParent`, `removeRelation`, `removeNode`, `removeKid`, `clearAllCache`. + - **[#83]** `maximizedId`, `maximize`, `restore`, `toggleMaximize`, `collapse`, `expand`, `toggleCollapse`. +- Of these, a user needs about 8: construct, `updateDimension`, `updateSlider`, `updateTree`, serialize, and the view-state methods. + +### 2.2 `@dynamix-layout/react` (`src/index.ts`) + +| Export | Kind | Intended public? | Notes | +|---|---|---|---| +| `DynamixLayout` | component | Yes | 46 props (§3, API surface). | +| `useDynamixLayout` | hook | **No in practice** | It needs `tabOutput` (from `getTabOutput`) and `dimensions` callbacks, and returns 31 members, including raw refs, DnD handlers and 5 deprecated setters. | +| `getTabOutput` | fn | **No** | Calls `crypto.randomUUID()` per tab (`Layout.tsx:33`). | +| `DefaultWrapTabLabel`, `DefaultWrapTabBody`, `DefaultWrapTabHead`, `DefaultWrapTabPanel`, `DefaultHoverElement`, `DefaultSliderElement`, `RootSplitterHoverEl`, `DefaultTabsetToolbar` **[#83]** | components | Yes (for composing custom wrappers) | Inline styles everywhere, so they're hard to theme. | +| `LayoutProps`, `TabsetToolbarProps` **[#83]**, `TabInput`, `TabOutput`, `TabEntry`, `DivFC`, `useDynamixLayoutOptions` | types | `LayoutProps` and `TabsetToolbarProps` only | | +| `DynamixLayoutProps`, `UseDynamixLayoutOptions`, `UseDynamixLayoutResult` | types | **Dead** | They describe an API (`setTabs`, `setLayoutTree`, `tabs: string[]`) that doesn't exist. They're used nowhere (`types/index.ts:4-22`). Same in Solid. | +| `./style.css` | CSS subpath | Yes | | + +--- + +## 3. Verification of known problems + +### Architecture + +**P1. Global singleton state — CONFIRMED (and broader).** +- `state.ts:18-33` is module-level mutable state. Its fields: + - `createId`, `timer`, `root`, `tree`, `minW`, `minH`, `bond`, `inst`; + - **[#83]** `maximized`, `collapsedSize`, `foldSeq`. +- The static accessors are at `dynamix.ts:22-68`. `Node.cache` is static at `node.ts:8-25`. +- **Probe: two engines.** + - I created engine A (`a1,a2`), then engine B (`b1,b2,b3`). `_root` contained `[b1,b2,b3]`. + - Calling `a.updateTree(b1, b3, 'contain')` **succeeded and rearranged B's tabs**. +- **Probe: two ``s on a page.** + - Layout `#one` (tabs A, B) rendered the **tab bars of layout two** (`cd`) above its own bodies. +- **SSR:** every request on a server shares the same module state. React StrictMode creates the instance twice; the second overwrites the first, which only works because both write the same globals. +- Also, every core module reads `layoutState` directly, not through `engine`. The "injected `engine`" parameter only gives access to methods; the data stays global. + +**P2. Side effects during render — CONFIRMED.** +- `useLayout.ts:80-96` writes `DynamixLayoutCore._bond/_minH/_minW` and constructs the engine inside `useMemo`. +- The constructor writes globals (`dynamix.ts:101-130`) and clears the shared caches (via `calcTabsetCountAndMinDim`). +- Component defaults read the statics (`Layout.tsx:56-58`, `:71`), so a default depends on whichever layout was constructed last. +- Solid does the same (`solid/src/components/Layout.tsx:94-100`, `:121`). + +**P3. Engine recreated too often — CONFIRMED, with a worse consequence.** +- The deps `[layoutJSON, tabOutput, …]` are at `useLayout.ts:97-106`. `tabOutput = useMemo(() => getTabOutput(tabs), [tabs])` is at `Layout.tsx:104`. +- So any parent render with an inline `tabs` array (the official example does this: `examples/react/src/App.tsx:238`) rebuilds the engine. +- **Probe: rerender with a new `tabs` identity.** + - The engine root was replaced, but the tab bars kept the **old** tabset uids, and the tab body ids changed. + - After that, clicks and drops target uids that no longer exist in `Node.cache.mapElem`, so they silently do nothing. +- Also, `layoutJSON` is `useState(layoutTree)` (`useLayout.ts:45-47`) and is never updated from user moves. Each rebuild therefore **resets the layout to the initial tree, losing all user changes**. +- The resize effect (`:650-682`) doesn't depend on `layoutInstance`, so its closure keeps the old engine. It only works because the old and new engines share globals. + +**P4. The class is a thin facade with everything public — CONFIRMED.** +- `dynamix.ts:133-296`: 33 of 37 methods are one-line forwards to free functions. +- All internals are public: iterators, `insertNodeWithNewParent`, `removeKid`, `clearAllCache`, the field `cntdown`, and the unused field `nodeOps` (`:70`, `:117-120`). + +**P5. Mixed concerns in the React hook — CONFIRMED.** +- `useLayout.ts` is 733 lines (it was 627 before #83). One hook holds: + - engine construction; + - three cache subscriptions with imperative DOM writes; + - splitter pointer handling; + - HTML5 DnD (7 handlers); + - drag-image creation and `document.body.style.cursor` writes; + - active-tab switching through DOM `dataset`; + - window resize; + - **[#83]** maximize/fold, keyboard shortcuts and double-click. +- Solid mirrors it (`solid/src/hooks/useLayout.ts`, 657 lines). + +**P6. Resize detection uses `window.resize` — CONFIRMED.** See `useLayout.ts:665`, and Solid `useLayout.ts:594`. A container resized by a sidebar or flex parent isn't detected; the layout stays at its old size until the window resizes. + +**P7. HTML5 DnD for tabs — CONFIRMED; recommend moving to pointer events.** +- Tabs and tab bars use `draggable` with `onDragStart/Over/End` (`Layout.tsx:228-250`, `:287-289`). Consequences: + - no touch support; + - a fake 1×1 drag image is attached to `document.body` (`useLayout.ts:424-438`); + - no keyboard path; + - the drop is decided in `onDragEnd`, not `onDrop`. +- Splitters already use pointer events with capture (`:236-253`). +- A pointer-based tab drag also lets us use the same `.is-dragging` path and hit-test with `elementFromPoint` against the engine's rects, rather than DOM `dragover` on overlay panels. +- I'll evaluate this in the Phase 2 design. Risks: losing native drag-out to other windows (not supported today anyway), and needing a movement threshold so clicks still select tabs. + +**P8. Custom 702-line `Queue` — CONFIRMED oversized.** +- Of 41 methods, core uses: `enqueue`, `enqueueFront`, `insert`, `insertQueue`, `dequeue`, `indexOf`, `removeAt`, `get`, `peek`, `peekBack`, `size`, `isEmpty`, `clear`, and iteration. +- The rest are aliases (`push/unshift/fifo*/filo*/lifo*/lilo*/front/back`) or unused. +- Children lists are tiny (a few items), so a plain `readonly T[]` is enough. BFS iterators can use an array plus an index. + +### Packaging and DX + +**P9. `react` peer dependency is `^0.0.1` — CONFIRMED.** See `react/package.json:81`. `react-dom` lists `^17 || ^18 || ^19`, but React 17 can't work: React 19's jsx-runtime is bundled, as described next. + +> **NEW P9b (severe). The React bundle inlines `react/jsx-runtime`.** +> - Rollup `external` is `['react', 'react-dom', '@dynamix-layout/core']` (`react/vite.config.ts:54`). That matches `react` exactly, not `react/jsx-runtime`. +> - `dist/index.es.js` therefore embeds React **19**'s jsx runtime: `Symbol.for("react.transitional.element")`, plus a `process.env.NODE_ENV` switch. +> - React 18 doesn't recognise that element symbol, so the package can't render under React 18. It also references `process`, which breaks unbundled ESM use. +> - Solid isn't affected. + +**P10. Documented CSS import path fails — CONFIRMED.** +- `react/README.md:42` says `@dynamix-layout/react/dist/layout.css`. The file doesn't exist (the build emits `dist/index.css`), and `exports` only exposes `./style.css` (`package.json:48`). +- Also, `react/README.md:183` tells React users to import `@dynamix-layout/solid/style.css` (a copy-paste error). + +**P11. Import-time side effects in core — CONFIRMED.** +- An IIFE in `core/src/index.ts:8-51` prints a collapsed console banner and sets `window.__DYNAMIX_LAYOUT__ = { __LICENSE__, __LAYOUT__ }`, but `package.json:50` declares `"sideEffects": false`. +- Because of that flag, bundlers may drop the banner or keep it depending on usage, which is non-deterministic. +- The full MIT licence text is embedded **twice** in `dist/core.es.js` (the banner plus the `__LICENSE__` define). + +**P12. Repository links point to `akash-aman/dynamix-layout` — CONFIRMED.** Locations: +- `README.md:19`, `:52`, `:170` +- `CONTRIBUTING.md:9`, `:13` +- `SECURITY.md:9` +- `packages/core/README.md:21` +- `packages/react/README.md:23` +- the core banner, `core/src/index.ts:38` + +The `package.json` `repository` fields are already correct. + +**P13. The React package has a single smoke test — CONFIRMED, and it never runs.** +- `react/src/test/layout.test.ts` has one render test. +- `react/package.json` has **no `test` script** (`:68-76`), so `turbo run test` skips the package entirely. Solid has a script. +- The React `lint` script also lacks `--max-warnings 0`, unlike core. + +### Naming — CONFIRMED + +Every abbreviation listed in the brief exists. Additional ones: +- `cntdown`, `uqid`, `unId`, `papa`, `dada` (`tree-mutations.ts:533-534`), `desHst`, `insrtIndex`, `gndHost`, `bnd`, `rghtName`, `totlMinSize`, `curntOffset`, `KidNode`, `LT` (`useLayout.ts:85`). +- The typo `supress` (`dynamix.ts:180`, `geometry.ts:175`). +- `nodMaxd`, `nodFold` **[#83]**. These follow the v1 naming scheme; v2 renames them. + +Inconsistencies: +- `rowIsHorizontal` vs `nodeDir` semantics: a tabset's `nodeDir` is the *opposite* of its row (`dom.ts:40-41`). +- `RootSplitterHoverElStyles` (PascalCase) vs `hoverElementStyles`. +- The type `useDynamixLayoutOptions` is lowercase. + +### API surface — CONFIRMED + +- `LayoutProps` has 46 props (`react/types/index.ts:41-99`). Configuration groups: + - 14 styling props; + - 6 wrappers plus `TabsetToolbar`; + - 4 debounce props, where `disable*Timeout` defaults to `true`, so the `*Timeout` values do nothing unless the user flips both. +- Tabs are tuples, the label is the id, and `tabNames` provides the display name. +- `useDynamixLayout` returns 31 members (`useLayout.ts:695-732`). `isUpdating` is never set to `true` anywhere (dead state). +- No controlled mode, no imperative handle, and no programmatic add/remove/select/move. + +--- + +## 4. Bugs found (not fixed) + +Severity: **H** = data loss, crash or wrong behaviour in normal use; **M** = wrong in an edge case or a real environment; **L** = cosmetic or DX. + +| # | Sev | Bug | Evidence | +|---|---|---|---| +| B1 | **H** | **Parent re-render resets the layout and breaks interaction.** A new `tabs` identity rebuilds the engine from the initial tree; the DOM keeps the stale uids. | P3, probed. | +| B2 | **H** | **The React package doesn't work with React 18 or 17** (bundled React 19 jsx-runtime) despite its peer range. | P9b. | +| B3 | **H** | **Two layouts on one page corrupt each other.** | P1, probed. | +| B4 | **M** | **SSR markup isn't deterministic** (random tab body ids), so hydration fails. The Next.js example avoids this only with `dynamic(..., { ssr: false })` (`examples/nextjs/src/app/page.tsx:8-18`). | Probed. | +| B5 | **M** | **`crypto.randomUUID` throws outside secure contexts** (plain `http://` on a LAN IP or an internal host), so the layout crashes on construction. | `state.ts:10`; `Layout.tsx:33`; Solid `Layout.tsx:26`. | +| B6 | **M** | **A callback `ref` on `` is never called.** | `Layout.tsx:106` (`typeof ref === 'function' ? ownRef : …`), probed. | +| B7 | M | **Non-normalized tree after some moves.** Example: `b1 \| (b2 / b3)`, then move `b1` into `b3`. `removeNode` removes `ts(b1)` and calls `moveAdjacentNodeToGrandParent(row)` with the root as the parent; that warns "has no host" and returns. The root is left with one nested `row` child. `updateTree` still returns `true`, and that shape is what gets saved. | Probed; `tree-mutations.ts:544-554`, `:447-451`. | +| B8 | M | **Saved layout vs `tabs` drift is silent.** Tabs added to `tabs` after a layout was saved never appear. Tabs removed from `tabs` still show a tab label, with no body. | Probed (`tabs:[x,y,z]` + tree with x,y → `[x,y]`; `tabs:[x]` → `[x,y]`). | +| B9 | M | **Duplicate tab labels** create several core tabs, but every React map is keyed by label (`Layout.tsx:29-41`). Bodies collapse into one, and `tabset.open` (stored as a *name*) marks every duplicate active. | Probed (core keeps 3 tabs for `['a','a','b']`). | +| B10 | M | **The `layoutTree` prop is read only on mount.** Later changes are ignored (`useState(layoutTree)`), with no warning. | `useLayout.ts:45-47`. | +| B11 | M | **The published snapshot is mutated in place.** `calcTabsetCountAndMinDim` calls `Node.cache.nodOpts.get().clear()`, so a listener holding the last snapshot sees it emptied. `updateActiveTab` also writes `nodOpen` into the cached objects (`useLayout.ts:619`, `:632-646`). | Probed (snapshot size 3 → 0); `geometry.ts:29-30`. | +| B12 | M | **The splitter and resize debounces share one timer** (`engine.cntdown`). With timeouts enabled, a resize during a drag cancels the pending splitter update, or the other way round. | `slider.ts:19-27`; `geometry.ts:276-284`. | +| B13 | M | **Solid ignores `0` and `false`-y numeric props** (`props.bondWidth \|\| …`, `tabHeadHeight \|\| …`), so `bondWidth={0}` is impossible. Solid also reads `tabs` and `layoutTree` once, so later changes are ignored. | `solid/src/components/Layout.tsx:94-100`, `:121`, `:44`. | +| B14 | M | **Duplicate DOM ids.** `id={rootId}` defaults to `dynamix-layout-root` on every instance, and tab body `id`s are UUIDs that change on every rebuild. | `Layout.tsx:80`, `:170`, `:340`. | +| B15 | M | The resize effect closes over a stale `layoutInstance`. It works only through shared globals. Once state is per instance, this becomes a real bug. | `useLayout.ts:650-682`. | +| B16 | M | **`onDragEnd` schedules `requestAnimationFrame` without cancelling it on unmount.** It can run after unmount and call `setState` / `updateJSON`. | `useLayout.ts:554-558`. | +| B17 | L | `updateJSON` fires on every mount with the initial tree. Under StrictMode it fires twice. | `useLayout.ts:674`. | +| B18 | L | `updateTree` warns "Source node … not found" even when the *destination* is missing. `console.warn` calls in the engine can't be silenced (21 call sites). | `tree-mutations.ts:23`; grep. | +| B19 | L | The keyboard-shortcut effect has no dependency array, so it re-subscribes the `window` listener on every render. **[#83]** | `useLayout.ts:393-412`. | +| B20 | L | Core `package.json` `"browser": "./dist/core.iife.js"` points bundlers that honour `browser` at an IIFE with no exports. The IIFE and UMD builds are also emitted for React, where `exports` never references them. | `core/package.json:33`; `react/vite.config.ts:50`. | +| B21 | L | Solid lists `solid-js` under `dependencies`, not `peerDependencies`. That risks two Solid runtimes. | `solid/package.json:89-92`. | +| B22 | L | `Timer` appears in the public constructor options but isn't exported. | `dynamix.ts:85`; `index.ts`. | +| B23 | L | `examples/svelte` contains only a stale `dist/` (no `package.json`). It's dead weight in the repo. | `ls examples/svelte`. | +| B24 | L | The comment in the React `layout.css` says "for the SolidJS components". A fallback "No content" body uses Tailwind classes the library doesn't ship. | `layout.css:1`; `Layout.tsx:360-364`. | +| B25 | **H** | **Wrong placement unless the root sits at the viewport's top-left.** The container rect uses viewport coordinates (`rect.left/top`, `Layout.tsx:108-118`), but children are absolutely positioned *inside* the root. A root offset by 150px drew its tab bars at 300px, and the far side was clipped. The page scroll position isn't tracked either. Hidden today because every example is full-viewport. | Probed in agent-browser on `examples/react` (root `margin-left:150px` → tab bar `style.left=150px`, on screen at 300px). | + +--- + +## 5. Serialized format (v1 `LayoutTree`) + +### 5.1 TypeScript shape (`core/src/type/index.ts:25-41`) + +```ts +type LayoutTree = { + typNode: 'row' | 'tabset' | 'tab' | 'bond' // 'bond' never appears in output + nodName: string + uidNode: string + nodPart: number + nodOpen?: string | boolean // output: string, tabsets only + nodKids?: LayoutTree[] + nodFold?: boolean // [#83] tabset only + nodMaxd?: string // [#83] root only +} +``` + +### 5.2 What `Node.toJSON()` actually writes (`node.ts:193-224`) + +Key order: `typNode, nodPart, nodName, uidNode, nodOpen?, nodFold?, nodMaxd?, nodKids?`. A real output (probed, two tabs): + +```json +{ + "typNode": "row", "nodPart": 100, + "nodName": "dynamix-layout-root", "uidNode": "dynamix-layout-root", + "nodKids": [ + { "typNode": "tabset", "nodPart": 100, "nodName": "", "uidNode": "3ddc2c41-…", "nodOpen": "x", + "nodKids": [{ "typNode": "tab", "nodPart": 100, "nodName": "x", "uidNode": "fc8ab160-…" }] }, + { "typNode": "tabset", "nodPart": 100, "nodName": "", "uidNode": "adf88532-…", "nodOpen": "y", + "nodKids": [{ "typNode": "tab", "nodPart": 100, "nodName": "y", "uidNode": "f24bb0d8-…" }] } + ] +} +``` + +| Node | Field rules | +|---|---| +| **Root** | `typNode:"row"`. `nodName` and `uidNode` both equal `rootId` (default `"dynamix-layout-root"`). `nodPart:100`. On load the saved root uid is **ignored**; the live `rootId` is used. **[#83]** optional `nodMaxd`: the uid of a tabset. | +| **Row** | `nodName:""`, `uidNode`: random UUID. Always has `nodKids` in practice. Direction isn't stored: the root is horizontal and directions alternate by depth. | +| **Tabset** | `nodName:""`, `uidNode`: random UUID (stable across saves within a session and restored on load). `nodOpen`: the **label** of the active tab, omitted when empty. `nodKids`: tabs. **[#83]** `nodFold:true` when folded (omitted otherwise). | +| **Tab** | `nodName`: **the tab's label, which is its identity**. `uidNode`: in React/Solid, a random UUID from `getTabOutput`, new every page load; on load it's **replaced** by `tabsIds.get(nodName)` (`tree-builder.ts:255-257`). `nodPart:100`, meaningless for tabs. No `nodKids`. | + +### 5.3 Semantics that migration must respect + +- **`nodPart` is a weight, not a size or percentage.** + - Each child gets `min + extra × part / Σpart(open siblings)`, where `extra = parent size − Σmin − bonds` (`node.ts:107-157`). + - Parts are floats after a splitter drag (`slider.ts:84-95`). New nodes default to 100. + - Converting to a v2 `size` means either keeping weight semantics or accepting that a restored layout looks slightly different. Phase 2 decision; see Q4. +- **Identity:** tabs are matched by `nodName`; tabsets and rows by `uidNode`. +- **Direction is positional.** Moving a row to another depth flips its direction. A migration must compute `direction` from depth: even depth (root = 0) is horizontal. +- **Load algorithm:** BFS-zip the JSON against a freshly built node tree (`tree-builder.ts:81-111`). Tabs present in `tabs` but absent from the tree are dropped; tabs in the tree but absent from `tabs` are kept (B8). + +### 5.4 Shapes that occur in saved data (migration must accept) + +1. **Root with a single child row.** This happens after B7. The direction of that row's children is vertical, because the row sits at depth 1. +2. **Nested rows whose direction equals their parent's.** This can't happen by construction, but I'll treat it as possible in hand-edited files. +3. **Tabset `nodOpen` naming a tab that isn't among its kids.** This is possible after B9-style duplicates or hand edits. +4. **Duplicate tab `nodName`s** (B9). +5. **Fractional `nodPart`s, and `nodPart` ≤ 0.** The second is not produced by the code, but hand edits are possible. +6. **`nodFold` / `nodMaxd` present [#83], or absent** (everything saved by ≤ 1.1.0). +7. **Unknown fields**, to be ignored. + +Unverified (to be pinned by characterization tests in Phase 3): empty tabsets, a row directly containing tabs, and a root whose `typNode` isn't `row`. + +--- + +## 6. Baseline numbers (for Phase 5 comparison) + +| Artifact | Raw | gzip -9 | +|---|---|---| +| `core/dist/core.es.js` | 45.8 kB | 11.0 kB (includes the licence twice and the banner) | +| `react/dist/index.es.js` | 41.1 kB | 10.4 kB (includes the React 19 jsx-runtime) | +| `react/dist/index.css` | 1.0 kB | 0.5 kB | +| `solid/dist/index.es.js` | 29.7 kB | 7.6 kB | + +Tests: +- Core has 6 files, 44 `it` blocks, and generated cases in `manual`/`dynamic`; the whole repo runs 3,766 tests. +- React has 1 test (not executed). +- Solid has 1 test file. + +--- + +## 7. Open questions (need your decision before Phase 2) + +1. **PR #83 (maximize/fold).** Should it merge first, so v2 includes and ports it? The audit assumes yes. +2. **Solid's version.** The brief lists Solid with no version and says to mark only `core` and `react` major. But Solid is published (1.0.2) and depends on the core API and types that v2 removes. Should Solid be **major** too, with a port that keeps its props, or should it pin core 1.x until a later Solid redesign? +3. **Tabs missing from a saved layout (B8).** In v2, should a tab in `tabs` but absent from the layout be (a) appended to the active or first tabset, (b) ignored as in v1, or (c) reported through a callback? Should a layout tab with no matching entry in `tabs` be dropped? +4. **Meaning of `size` in v2.** The options: + - (a) keep v1 weight-of-extra-space semantics, so migration is lossless and resize behaviour is unchanged; + - (b) switch to a proportional share of the whole size, like react-resizable-panels percentages; + - with (b), migration converts weights using the minimums, and restored layouts can shift by a few pixels. +5. **Duplicate tab ids.** Should v2 throw in development and dedupe with a warning in production? +6. **`examples/svelte`** (stale `dist/` only): delete it? diff --git a/docs/v2/02-design.md b/docs/v2/02-design.md new file mode 100644 index 0000000..64fe43e --- /dev/null +++ b/docs/v2/02-design.md @@ -0,0 +1,1149 @@ +# dynamix-layout v2 — Phase 2 design + +Status: **proposal, waiting for approval**. Builds on [01-audit.md](./01-audit.md); `F*`, `R*`, `P*` and `B*` refer to items there. +No source code changes in this phase. + +--- + +## 0. Decisions taken (please confirm or overturn) + +The open questions from the audit were answered "go ahead", so I took the recommended option for each. Every one is cheap to flip now and expensive later. + +| # | Decision | Why | +|---|---|---| +| D1 | **PR #83 (maximize and fold) merges before v2 work starts.** v2 ports it. | It's done and reviewed; porting it once is cheaper than rebasing v2 onto it later. | +| D2 | **`@dynamix-layout/solid` also gets a major (2.0.0).** It's a minimal port: props unchanged, but it now reads and writes the v2 JSON. | Solid depends on core types and APIs that v2 removes. A minor bump would break Solid users, which violates semver. | +| D3 | **`tabs` is the list of open tabs; the layout only decides where they go.** A tab in `tabs` that's missing from the layout is added to the last active tabset (or to its `target`). A layout tab that's missing from `tabs` is removed. | It fixes B8, and it gives one source of truth for "which tabs exist". Closing a tab means removing it from `tabs` (see §3.4). | +| D4 | **Keep v1's sizing semantics, and name the field `weight`, not `size`.** A weight works like CSS `flex-grow` with `flex-basis` set to the minimum size. | Migration stays lossless (v1 layouts restore pixel-exact). Splitter and resize behaviour doesn't change. A field named `size` would suggest pixels or percentages, and it would be neither. | +| D5 | **Duplicate tab ids throw in development. In production the first occurrence wins, and an `onWarning` is emitted.** | Fixes B9 loudly where developers see it, without crashing production apps. | +| D6 | **Delete `examples/svelte`** (it contains only a stale `dist/`). | Dead weight (B23). | +| D7 | **Tab drag moves from HTML5 DnD to pointer events.** | Touch support; one drag model for tabs and splitters; drop targets computed from engine rects instead of DOM overlays; Escape to cancel; a keyboard move mode becomes possible. See §3.9. | +| D8 | **All rects are relative to the layout root, not the viewport.** | Fixes B25. Positioning becomes independent of where the layout sits on the page or how far the page is scrolled. | +| D9 | **React's imperative handle has no `addTab`/`removeTab`.** Tabs are added or removed by changing the `tabs` prop. Core keeps `addTab`/`removeTab` for headless and vanilla users. | In React, a handle that adds tabs would create a second, content-less source of truth that competes with the `tabs` prop (see D3). | +| D10 | **Publish a final v1.x patch** (packaging fixes plus `@deprecated` JSDoc, no runtime warnings). See §7. | React 18 users are broken today (B2) and shouldn't have to wait for v2. | + +--- + +## 1. Goals and non-goals + +**Goals** +- Instance-based, fully typed, pure core: no globals, no timers, no DOM at import time. +- A small React API at three levels: component, headless hooks, and state/actions anywhere. +- Lossless migration of saved v1 layouts. +- SSR-safe, StrictMode-safe and accessible. +- Fix every **H** and **M** bug in the audit. +- **Keep the behaviours users depend on**: + - tab contents never remount when tabs move (R2); + - the edges outside a dragged splitter pair never move (F8); + - exact restore after maximize and after fold (F15/F16); + - drag stays smooth over iframes (R6); + - the imperative DOM fast path during drags (R3). + +**Non-goals for 2.0** (listed as follow-ups in §12) +- Dragging tabs out to other windows (not supported in v1 either). +- Scrollable layouts smaller than their minimum size. v1 clips (F6); v2 keeps that. +- Redesigning the Solid API. +- Undo/redo, floating panels, popouts. + +--- + +## 2. Core (`@dynamix-layout/core`) + +### 2.1 Source layout + +``` +packages/core/src/ + model/ + types.ts # LayoutNode, RowNode, TabsetNode, TabNode, Direction, Rect, Point + guards.ts # isRow, isTabset, isTab + tree/ + build.ts # buildDefaultTree(tabIds, createId) ← tree-builder.ts:134-242 + find.ts # findNode, findParent, findTabset, walk + insert.ts # insertTab, insertBeside, dockAtRoot ← tree-mutations.ts:122-445 + remove.ts # removeTab, removeTabset ← tree-mutations.ts:527-630 + move.ts # moveTab, moveTabset (remove + insert, plus guards ← :8-107) + normalize.ts # prune empties, flatten single-child and same-direction rows, fix activeTabId + view-state.ts # fold / unfold / maximize rules ← view-state.ts + reconcile-tabs.ts # apply the tabs list (D3) + geometry/ + min-size.ts # min-size propagation ← geometry.ts:19-136 + distribute.ts # cumulative-boundary distribution ← node.ts:102-191 + compute-rects.ts # computeLayoutRects (incl. maximize/fold overrides) + splitter.ts # clamp and re-weight a splitter pair ← slider.ts:30-126 + tab-bar.ts # getTabBarRect, getTabContentRect ← dom.ts:10-46 + drop/ + drop-target.ts # getDropTarget(point, snapshot, measurements) + drop-indicator.ts # getDropIndicatorRect(target, …) ← drop-preview.ts + drop-targets.ts # listDropTargets(source) for keyboard move mode + store/ + snapshot.ts # deriving TabsetState/SplitterState/TabState with structural sharing + store.ts # createStore: getSnapshot/subscribe/setState (sync, no timers) + serialize/ + schema.ts # LayoutJSON types, LAYOUT_VERSION + to-json.ts / from-json.ts (validation with path-based errors) + migrate-v1.ts # migrateLayoutFromV1, isLayoutV1 + dom/ + frame-scheduler.ts # createFrameScheduler (moved from dom.ts:54-96) + apply-rect.ts # applyRect(el, rect) — used by adapters' fast path + ids.ts # createDeterministicIdGenerator + dev.ts # isDev, warn helpers (no import-time work) + errors.ts # DynamixLayoutError, error codes + create-layout.ts # the instance: wires tree + geometry + drop + store + index.ts # public exports only +``` + +Removed: `dynamix.ts`, `node.ts`, `state.ts`, `queue.ts`, `reactive-state.ts`, `comparator.ts`, the banner in `index.ts`, and the `window.__DYNAMIX_LAYOUT__` global (P1, P4, P8, P11). + +**Rules.** +- No module-level mutable state. +- `tree/`, `geometry/`, `drop/` and `serialize/` are pure functions: data in, data out, no instance access. +- `create-layout.ts` is the only stateful module, and it owns one store per instance. +- Children are plain `readonly` arrays (replacing `Queue`, P8). + +### 2.2 Model + +```ts +export type Direction = 'horizontal' | 'vertical' +export interface Point { readonly x: number; readonly y: number } +export interface Rect { readonly x: number; readonly y: number; readonly width: number; readonly height: number } + +export interface TabNode { readonly type: 'tab'; readonly id: string } // id = user tab id + +export interface TabsetNode { + readonly type: 'tabset' + readonly id: string + /** Share of the parent row's space above the minimum sizes (like CSS flex-grow). */ + readonly weight: number + readonly activeTabId: string // always one of children (normalized) + readonly isFolded: boolean + readonly children: readonly TabNode[] // never empty (normalized) +} + +export interface RowNode { + readonly type: 'row' + readonly id: string + readonly weight: number + readonly direction: Direction + readonly children: readonly (RowNode | TabsetNode)[] +} + +export type LayoutNode = RowNode | TabsetNode | TabNode +``` + +- **Direction is stored**, not derived from depth as in v1 (F2). That makes flattening exact, and it allows a vertical root. +- **Splitters are derived**: one between each pair of adjacent children of a row. A splitter's id is `` `${before.id}~${after.id}` ``, so it's stable when unrelated siblings change and deterministic across server and client. + +**Invariants**, enforced by `normalize.ts` after every mutation and every load: +1. The root is a row, and it may have zero children (an empty layout). +2. Tabsets have at least one tab, and `activeTabId` is one of them. +3. A non-root row has at least 2 children. A single-child row is replaced by its child, which takes the row's weight. This fixes B7. +4. A child row never has its parent's direction; same-direction rows are flattened. Child weights become `rowWeight × childWeight / Σ childWeights`, which gives the same pixels. +5. Weights are finite and > 0. Invalid values become 100. +6. Tab ids are unique across the layout. +7. View-state rules from #83 (F17): a tabset alone in its row can't be folded; every row keeps at least one unfolded child; `maximizedTabsetId` must exist. + +### 2.3 Geometry (pure, behaviour-preserving) + +```ts +export interface GeometryConfig { + readonly minPanelSize: { readonly width: number; readonly height: number } + readonly splitterSize: number + readonly foldedSize: number +} +export interface LayoutRects { + readonly container: Rect + readonly rows: ReadonlyMap + readonly tabsets: ReadonlyMap // maximize override applied + readonly splitters: ReadonlyMap +} +export function computeLayoutRects( + root: RowNode, container: Rect, config: GeometryConfig, maximizedTabsetId: string | null, + previous?: LayoutRects, // unchanged rects are reused by reference +): LayoutRects +``` + +The v1 algorithm is ported as is: +- min-size propagation (F5); +- root overflow growth (F6); +- weight distribution over the space above the minimums, with **cumulative boundary rounding** (F7); +- a folded tabset is `foldedSize` along its row and keeps its weight (F16); +- a maximized tabset gets the container rect, and the others are marked hidden (F15). + +Rects are in **root coordinates**: `container.x/y` is the padding offset, so the root's top-left corner is (0, 0) (D8). + +Splitter resize (`geometry/splitter.ts`) is the v1 clamp-and-re-weight of the two neighbours (`slider.ts:69-123`). It returns a new root with only that row path copied. + +Performance: v1 recomputed only the dragged subtree. v2 recomputes rects for the whole tree, which is O(nodes) and tiny for realistic layouts (< 200 nodes). Unchanged `Rect` objects are reused by reference, so the DOM writer skips them. A drag benchmark in Phase 5 guards against regressions; if it regresses, subtree recompute is a local optimization inside `compute-rects.ts`. + +### 2.4 Drop targets (pure) + +```ts +export type Side = 'top' | 'bottom' | 'left' | 'right' +export type DropTarget = + | { readonly type: 'tabset'; readonly tabsetId: string; readonly position: Side | 'center' } + | { readonly type: 'tab'; readonly tabId: string; readonly position: 'before' | 'after' } + | { readonly type: 'root'; readonly position: Side } + +/** Tab bar geometry the engine can't know (label widths). Measured by the adapter, in root coordinates. */ +export interface DropMeasurements { + readonly tabBars: ReadonlyMap +} + +export function getDropTarget(snapshot: LayoutSnapshot, point: Point, source: DragSource, + measurements?: DropMeasurements, options?: { edgeSize?: number }): DropTarget | null +export function getDropIndicatorRect(snapshot: LayoutSnapshot, target: DropTarget, measurements?: DropMeasurements): Rect +``` + +The brief proposed `{ targetId, position }`. I chose a discriminated union instead, because a tab, a tabset and the root accept different positions. A union makes invalid pairs such as `{ tab, 'top' }` impossible to type. + +The v1 rules are kept: +- tabset thirds (`drop-preview.ts:26-61`); +- tab-bar insertion marker (`:97-158`); +- root edges (`:64-91`) for a pointer within `edgeSize` (default 16px) of a root edge; +- a folded strip is always `center` (#83). + +`v1 'contain'` becomes `'center'`. Invalid targets return `null`: a lone tab onto its own tabset, a tabset onto itself, or the only tabset moving anywhere (`tree-mutations.ts:27-87`). + +### 2.5 Store and snapshots + +```ts +export interface TabsetState { + readonly id: string + readonly tabIds: readonly string[] + readonly activeTabId: string + /** Direction of the row this tabset sits in. */ + readonly parentDirection: Direction + readonly isFolded: boolean + readonly isMaximized: boolean + readonly isHidden: boolean // another tabset is maximized + readonly canFold: boolean + readonly canMaximize: boolean +} +export interface SplitterState { + readonly id: string + readonly rowId: string + readonly direction: Direction // the row's direction: 'horizontal' row ⇒ vertical bar + readonly beforeId: string + readonly afterId: string + readonly isLocked: boolean // next to a folded tabset + readonly isHidden: boolean +} +export interface TabState { + readonly id: string + readonly tabsetId: string + readonly isActive: boolean + readonly isVisible: boolean // active, not folded, not hidden, body height > 0 +} +export type DragSource = + | { readonly type: 'tab'; readonly tabId: string } + | { readonly type: 'tabset'; readonly tabsetId: string } + | { readonly type: 'splitter'; readonly splitterId: string } +export interface DragState { readonly source: DragSource; readonly target: DropTarget | null } + +export interface LayoutSnapshot { + readonly root: RowNode + readonly tabsets: ReadonlyMap + readonly splitters: ReadonlyMap + readonly tabs: ReadonlyMap + readonly rects: LayoutRects + readonly maximizedTabsetId: string | null + readonly drag: DragState | null +} +``` + +**Immutability and structural sharing.** Each change produces a new snapshot object. Each field keeps its previous reference unless it changed: +- A container resize or splitter drag changes `rects` (and, for a splitter, `root`), but not `tabsets`, `splitters` or `tabs`. +- Selecting a tab replaces one `TabsetState` and two `TabState`s. Every other entry keeps its object identity. + +React can therefore compare by reference (`Object.is`) at every level. The snapshot is frozen in development, so accidental mutation throws; this fixes B11. + +**Notifications are synchronous:** one notification per action, with no timers (P-brief, B12). Batching and throttling belong to the adapter (`createFrameScheduler`, `resizeThrottleMs`). + +### 2.6 `createLayout()` — public API + +```ts +export interface TabInit { + /** Unique, stable id. It's also what the saved layout stores. */ + readonly id: string + /** Where the tab goes the first time it appears. Default: the last active tabset. */ + readonly target?: DropTarget +} + +export type LayoutChangeReason = 'move' | 'resize' | 'select' | 'fold' | 'maximize' | 'tabs' | 'reset' + +export interface LayoutOptions { + /** Open tabs. Order matters only for the default layout. */ + tabs: readonly TabInit[] + /** Saved layout (v2 JSON, or v1 tree that is migrated automatically). */ + initialLayout?: LayoutJSON | LayoutTreeV1 | null + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** Size of a folded tabset along its row. @default minPanelSize.height */ + foldedSize?: number + /** Id factory for rows and tabsets. @default deterministic per instance */ + createId?: (kind: 'row' | 'tabset') => string + /** Called after every committed change (never for transient drag frames or programmatic `load`). */ + onLayoutChange?: (layout: LayoutJSON, reason: LayoutChangeReason) => void + /** @default console.warn in development, silent in production */ + onWarning?: (warning: LayoutWarning) => void +} + +export interface Layout { + getSnapshot(): LayoutSnapshot + subscribe(listener: (snapshot: LayoutSnapshot) => void): () => void + + setContainerRect(rect: Rect): void + setTabs(tabs: readonly TabInit[]): void + setOptions(options: Partial>): void + + moveTab(tabId: string, target: DropTarget): boolean + moveTabset(tabsetId: string, target: DropTarget): boolean + selectTab(tabId: string): boolean + addTab(tab: TabInit, target?: DropTarget): boolean + removeTab(tabId: string): boolean + + /** Moves a splitter so its centre is at `position` (root coordinates); clamped to min sizes. */ + resizeSplitter(splitterId: string, position: Point): boolean + /** Keyboard resizing: moves a splitter by `delta` px along its row. */ + moveSplitterBy(splitterId: string, delta: number): boolean + + maximize(tabsetId: string): boolean + restore(): boolean + toggleMaximize(tabsetId: string): boolean + fold(tabsetId: string): boolean + unfold(tabsetId: string): boolean + toggleFold(tabsetId: string): boolean + + /** Pointer-driven interactions (tabs, tabsets and splitters share one drag model). */ + startDrag(source: DragSource): boolean + updateDrag(point: Point, measurements?: DropMeasurements): void // transient: no onLayoutChange + endDrag(): boolean // commits: one onLayoutChange + cancelDrag(): void + + getDropTarget(point: Point, measurements?: DropMeasurements): DropTarget | null + getDropIndicatorRect(target: DropTarget, measurements?: DropMeasurements): Rect + /** Every valid target for the current drag source, in reading order (keyboard move mode). */ + listDropTargets(source: DragSource): readonly DropTarget[] + /** Splitter position and bounds in px, for ARIA (aria-valuenow/min/max). */ + getSplitterBounds(splitterId: string): { value: number; min: number; max: number } | null + + /** Replace the layout (no onLayoutChange). */ + load(layout: LayoutJSON | LayoutTreeV1): void + /** Back to `initialLayout`, or to the default layout built from the current tabs. */ + reset(): void + toJSON(): LayoutJSON // memoized per root + /** Removes all listeners; later calls are ignored with a development warning. */ + destroy(): void +} + +export function createLayout(options: LayoutOptions): Layout +``` + +Example (the brief's shape, refined): + +```ts +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }], + initialLayout: saved, // v1 or v2 + minPanelSize: { width: 40, height: 40 }, + splitterSize: 10, + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) +const unsubscribe = layout.subscribe((snapshot) => render(snapshot)) +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) +layout.moveTab('terminal', { type: 'tabset', tabsetId: layout.getSnapshot().tabs.get('editor')!.tabsetId, position: 'bottom' }) +``` + +**Return values.** Actions return `true` if they changed the layout. A disallowed or no-op action returns `false`; this replaces v1's 21 `console.warn` calls (B18). An unknown id also returns `false`, plus a development warning. Actions never throw. + +**What throws `DynamixLayoutError`** (`errors.ts`), always with `code`, `message` and, for JSON errors, a `path`: +- invalid JSON passed to `createLayout` / `load` (`INVALID_LAYOUT`, with a `path` such as `root.children[1].weight`); +- an unsupported `version` (`UNSUPPORTED_VERSION`); +- duplicate tab ids in development (`DUPLICATE_TAB_ID`, D5). + +**IDs.** The default generator is deterministic per instance: +- The initial tabsets are named after their first tab: `ts-editor`. +- Later rows and tabsets get a counter: `row-1`, `ts-2`. +- Ids already present in a loaded layout are skipped. + +So the same input gives the same ids on server and client, with no `crypto.randomUUID()` (B4, B5). User tab ids are used unchanged. + +**Timers.** None in core. `createFrameScheduler` stays available under `dom/` for adapters. + +### 2.7 Serialized format v2 + +```ts +export const LAYOUT_VERSION = 2 +export interface LayoutJSON { + version: 2 + root: RowJSON + /** Id of the maximized tabset, if any. */ + maximizedTabsetId?: string +} +export interface RowJSON { + type: 'row'; id: string; weight: number; direction: Direction + children: (RowJSON | TabsetJSON)[] +} +export interface TabsetJSON { + type: 'tabset'; id: string; weight: number + activeTabId?: string + isFolded?: boolean // written only when true + children: TabJSON[] +} +export interface TabJSON { type: 'tab'; id: string } +``` + +- Splitters aren't serialized. Neither are rects: sizes are weights, so a container resize never changes the JSON. +- The root's `weight` is ignored and written as `100`. +- Tab ids are the user's ids, so a saved layout is meaningful across sessions. This differs from v1, where tab `uidNode` was a per-session UUID. + +### 2.8 `migrateLayoutFromV1` + +```ts +export interface LayoutTreeV1 { // exported for typing old data + typNode: 'row' | 'tabset' | 'tab' | 'bond'; nodName: string; uidNode: string; nodPart: number + nodOpen?: string | boolean; nodKids?: LayoutTreeV1[]; nodFold?: boolean; nodMaxd?: string +} +export function isLayoutV1(input: unknown): input is LayoutTreeV1 +export function migrateLayoutFromV1(tree: LayoutTreeV1, options?: { onWarning?: (w: LayoutWarning) => void }): LayoutJSON +``` + +`createLayout({ initialLayout })` and `layout.load()` call it automatically when `isLayoutV1(input)` is true. In that case they emit a `MIGRATED_FROM_V1` development warning, suggesting the app re-save the layout. + +Mapping (matches audit §5): + +| v1 | v2 | +|---|---| +| `typNode` | `type` | +| row/tabset `uidNode` | `id` (kept, so `nodMaxd` still resolves) | +| tab `nodName` | tab `id` (**v1 tab identity is the label**; tab `uidNode` is discarded) | +| `nodPart` | `weight` (same semantics, D4; a non-finite or ≤ 0 value → 100) | +| depth parity | `direction` (root and even depths `horizontal`, odd depths `vertical`), computed **before** normalizing | +| tabset `nodOpen` (a tab label) | `activeTabId` (falls back to the first child) | +| tabset `nodFold: true` | `isFolded: true` | +| root `nodMaxd` | `maximizedTabsetId` (dropped if the id doesn't exist) | +| root `nodName`/`uidNode` (= `rootId`) | root `id` kept; irrelevant to the user | + +Edge cases (audit §5.4): + +| Input | Handling | +|---|---| +| Root with a single nested row (B7) | Row flattened into the root. The root takes the row's direction (`vertical`), so it's pixel-identical. | +| Same-direction nested rows (hand-edited) | Flattened with re-scaled weights (invariant 4). | +| `nodOpen` naming a missing tab | `activeTabId` = first tab; `INVALID_ACTIVE_TAB` warning. | +| Duplicate tab `nodName`s | The first occurrence (BFS order) wins and later ones are dropped; `DUPLICATE_TAB_ID` warning (migration never throws on this). | +| Fractional `nodPart` | Kept as is. | +| `nodPart` ≤ 0, `NaN` | 100; `INVALID_WEIGHT` warning. | +| Empty tabset / empty row | Removed; the parent is then normalized. | +| Tab directly inside a row | Wrapped in a new tabset (deterministic id `ts-`). | +| Root that is a tabset | Wrapped in a root row. | +| `typNode: 'bond'` or unknown fields | Ignored. | +| Not an object, or missing `typNode` | `DynamixLayoutError('INVALID_LAYOUT')`. | + +Migration is pure and generates no random ids. Phase 3 tests it against fixtures produced by the **current v1 code** (§10). + +--- + +## 3. React (`@dynamix-layout/react`) + +### 3.1 Source layout + +``` +packages/react/src/ + context/ + layout-context.ts # LayoutContext { controller } + useLayoutContext (dev error when missing) + DynamixLayoutProvider.tsx + hooks/ + use-dynamix-layout.ts # creates/owns the controller; returns prop getters + lists + use-layout-state.ts # selector subscription (useSyncExternalStore) + use-layout-actions.ts + use-tabset.ts + use-tab.ts + use-splitter.ts + use-drag-state.ts + internal/ + controller.ts # engine + tab registry + element registry + options sync + rect-writer.ts # the DOM fast path (one documented module, see §3.8) + pointer-drag.ts # pointer-event tab/tabset/splitter drags (§3.9) + container-observer.ts # ResizeObserver + rAF (§3.10) + keyboard.ts # ARIA tab navigation, splitter keys, shortcuts, move mode + dom-ids.ts # deterministic, CSS-safe ids from useId + tab id + components/ + DynamixLayout.tsx # Level 1, composes everything below + Panel.tsx TabBar.tsx Tab.tsx TabContent.tsx Splitter.tsx + DropIndicator.tsx RootDropZone.tsx TabsetToolbar.tsx + utils/ + merge-props.ts merge-refs.ts use-isomorphic-layout-effect.ts use-stable-callback.ts + types.ts + index.ts # 'use client' banner added at build time + styles.css +``` + +Each file has one responsibility and aims for < 200 lines. `use-layout.ts` (733 lines, P5) is split across `controller`, `rect-writer`, `pointer-drag`, `container-observer` and `keyboard`. + +### 3.2 Level 1 — `` + +```ts +export interface TabItem { + /** Unique, stable id. Stored in saved layouts. */ + id: string + /** Tab bar label. @default id */ + title?: ReactNode + content: ReactNode + /** Show a close button; closing calls `onTabClose`. @default false */ + closable?: boolean + /** Placement the first time this tab appears. @default last active tabset */ + target?: DropTarget +} + +export type LayoutSlot = + | 'root' | 'panel' | 'tabBar' | 'tab' | 'activeTab' | 'tabClose' | 'tabContent' + | 'splitter' | 'dropIndicator' | 'rootDropZone' | 'toolbar' | 'toolbarButton' + +export interface DynamixLayoutProps + extends Omit, 'children' | 'defaultValue' | 'onChange'> { + tabs: readonly TabItem[] + /** Uncontrolled: initial layout (v1 or v2). Later changes are ignored. */ + defaultLayout?: LayoutJSON | LayoutTreeV1 + /** Controlled layout (v1 or v2). Use with `onLayoutChange`. */ + layout?: LayoutJSON | LayoutTreeV1 + onLayoutChange?: (layout: LayoutJSON, details: { reason: LayoutChangeReason }) => void + onTabClose?: (tabId: string) => void + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** @default 40 */ + tabBarHeight?: number + /** @default true */ + showTabBar?: boolean + /** @default 0 */ + padding?: number | { top?: number; right?: number; bottom?: number; left?: number } + /** @default true */ + allowMaximize?: boolean + /** @default true (requires the tab bar) */ + allowFold?: boolean + /** @default true */ + maximizeOnDoubleClick?: boolean + /** Alt/Option + "+" maximize, Alt/Option + "-" fold, for the focused tabset. @default true */ + keyboardShortcuts?: boolean + /** WAI-ARIA tabs activation. @default 'automatic' */ + tabActivation?: 'automatic' | 'manual' + /** Container resize throttle; 0 = once per animation frame. @default 0 */ + resizeThrottleMs?: number + components?: Partial + classNames?: Partial> + styles?: Partial> +} + +export const DynamixLayout: (props: DynamixLayoutProps & { ref?: React.Ref }) => React.ReactElement +``` + +That's **23 own props** instead of 46. Custom components replace the 6 wrappers; one `classNames` and one `styles` object replace the 14 styling props; one throttle prop replaces 4 debounce props. + +Defaults equal the v1 defaults (`bond 10`, `minW/minH 40`, `tabHeadHeight = minH = 40`). Upgrading without setting any options therefore looks the same. + +As in v1 (`useLayout.ts:76-78`), the effective minimum height is `max(minPanelSize.height, tabBarHeight)` when the tab bar is shown. The fold size equals `tabBarHeight` (or `minPanelSize.height` without a tab bar). + +**Slot components** (each receives DOM props to spread, plus state; must forward `ref`): + +```ts +type SlotProps = React.HTMLAttributes & { ref?: React.Ref } +export interface LayoutComponents { + Panel: React.ComponentType + TabBar: React.ComponentType + Tab: React.ComponentType & { tab: TabItem; isActive: boolean; isDragging: boolean; onClose?: () => void }> + TabContent: React.ComponentType + Splitter: React.ComponentType + DropIndicator: React.ComponentType + RootDropZone: React.ComponentType + TabsetToolbar: React.ComponentType +} +export interface TabsetToolbarProps { + tabset: TabsetState; isRotated: boolean; canMaximize: boolean; canFold: boolean + onToggleMaximize: () => void; onToggleFold: () => void +} +``` + +`Panel` is now a real background element for each tabset; that's where borders and backgrounds go. v1's `WrapTabPanel` was a transparent overlay shown only during drags, and pointer hit-testing no longer needs it. + +### 3.3 Tabs reconciliation (fixes B1, P3) + +- The engine is created **once per component instance**, in a `useState` initializer. `createLayout` is pure, so StrictMode's double-invoke just discards one instance. +- Each render computes the **id list**. Only when the ids change (a joined-string comparison) does the controller call `engine.setTabs()`, in a layout effect. +- Titles, content and `closable` live in a tab registry passed through context. Changing them never touches the engine. +- So an inline `tabs` array with unchanged ids causes **zero engine work**, and content never remounts. A test asserts this (§10). +- `minPanelSize`, `splitterSize`, `tabBarHeight` and `showTabBar` changes call `engine.setOptions()` without recreating the engine. + +### 3.4 Controlled and uncontrolled + +**Uncontrolled** (`defaultLayout`): +- The engine owns the layout, and `onLayoutChange` reports each committed change. +- Changes to `defaultLayout` after mount are ignored, like React's `defaultValue`. + +**Controlled** (`layout` + `onLayoutChange`): +1. A user action is applied by the engine, and `onLayoutChange(next)` fires. +2. The wrapper schedules a reconcile render. +3. In a layout effect, if `layout` is not the object it last emitted or loaded, it calls `engine.load(layout)`. + +So if the parent stores `next`, nothing reloads; if it ignores or edits it, the layout reverts to (or adopts) the parent's value. All of this happens before paint, so there's no visible flicker. + +Transient drag frames (splitter moves, drop-target changes) aren't changes; only `endDrag` commits. + +**Closing tabs** follows the same model: the close button calls `onTabClose(id)`, and the parent removes the tab from `tabs`. + +`onLayoutChange` reasons: + +| Reason | Fired after | +|---|---| +| `'move'` | a drop | +| `'resize'` | a splitter release or a keyboard resize | +| `'select'` | selecting a tab | +| `'fold'` / `'maximize'` | the view-state toggles | +| `'tabs'` | `tabs` added or removed | + +It is **not** fired on mount (B17), on container resize, or after a programmatic `load`. + +### 3.5 Imperative handle + +```ts +export interface DynamixLayoutHandle { + readonly element: HTMLDivElement | null + moveTab(tabId: string, target: DropTarget): boolean + moveTabset(tabsetId: string, target: DropTarget): boolean + selectTab(tabId: string): boolean + focusTab(tabId: string): void + maximize(tabsetId: string): boolean + restore(): boolean + toggleMaximize(tabsetId: string): boolean + fold(tabsetId: string): boolean + unfold(tabsetId: string): boolean + toggleFold(tabsetId: string): boolean + reset(): void + toJSON(): LayoutJSON + getSnapshot(): LayoutSnapshot +} +``` + +There is no `addTab`/`removeTab` (D9): change `tabs`, optionally with `TabItem.target` for placement. Both function refs and object refs work (fixes B6). + +### 3.6 Level 2 — headless hooks + +```ts +export interface UseDynamixLayoutOptions extends Omit> { + tabs: readonly Pick[] // content not required + id?: string +} +export interface UseDynamixLayoutResult { + /** Pass to so child hooks work. Stable. */ + controller: LayoutController + getRootProps:

& { ref?: React.Ref }>(props?: P) => P + tabsets: readonly TabsetState[] // changes only on structural changes + splitters: readonly SplitterState[] + tabIds: readonly string[] + dropIndicator: { target: DropTarget; props: SlotProps } | null + rootDropZones: readonly { side: Side; isActive: boolean; props: SlotProps }[] // empty unless dragging + actions: LayoutActions // stable +} +export function useDynamixLayout(options: UseDynamixLayoutOptions): UseDynamixLayoutResult +export function DynamixLayoutProvider(props: { controller: LayoutController; children: ReactNode }): ReactElement + +export function useTabset(tabsetId: string): { + tabset: TabsetState; isRotated: boolean + getPanelProps: PropGetter; getTabBarProps: PropGetter // tablist role, drag source for the whole tabset + toggleMaximize(): void; toggleFold(): void +} +export function useTab(tabId: string): { + tab: TabState; isActive: boolean; isDragging: boolean + select(): void; close(): void + getTabProps: PropGetter // role=tab, aria-*, roving tabindex, pointer drag, keyboard + getTabContentProps: PropGetter // role=tabpanel, positioning ref +} +export function useSplitter(splitterId: string): { + splitter: SplitterState; isDragging: boolean; direction: Direction + value: number; min: number; max: number // px, for custom UI + getSplitterProps: PropGetter // role=separator, aria-*, pointer + keys +} +export function useDragState(): DragState | null +``` + +The brief's example, corrected so the child hooks have context: + +```tsx +function MyLayout({ tabs }: { tabs: MyTab[] }) { + const { controller, getRootProps, tabsets, splitters, tabIds, dropIndicator } = useDynamixLayout({ tabs }) + return ( + +

+ {tabsets.map((ts) => )} + {tabIds.map((id) => )} {/* flat: never remounts */} + {splitters.map((s) => )} + {dropIndicator &&
} +
+ + ) +} +``` + +`useTabDrag` from the brief became `useDragState`. Drag *initiation* is already part of `getTabProps` and `getTabBarProps`, so a separate drag hook would be a second way to do the same thing. `useContainerRect` stays internal; `getRootProps` wires it. + +**Prop getters** merge rather than overwrite: +- `ref`s are combined. +- `className`s are joined. +- `style` merges user style first, then positioning (`left/top/width/height/transform`) last. +- Event handlers run the user's first; if the user calls `event.preventDefault()`, the internal handler is skipped (documented). + +A missing provider throws in development: `` `useTab` must be used inside or . `` + +### 3.7 Level 3 — state and actions anywhere + +```ts +export function useLayoutState(selector: (snapshot: LayoutSnapshot) => T, isEqual?: (a: T, b: T) => boolean): T +export function useLayoutActions(): LayoutActions // stable object +export type LayoutActions = Pick +``` + +`useLayoutState` is built on `useSyncExternalStore` with a memoized selector: it caches by snapshot reference plus selector and checks `isEqual`, defaulting to `Object.is`. It's about 30 lines and needs no dependency. Because snapshots share structure (§2.5), `s.tabsets.get(id)?.activeTabId` re-renders only when that value changes; a test asserts the render counts (§10). + +**Stability guarantee** (documented in the API docs): + +| Value | Stable across renders? | +|---|---| +| `controller`, `actions`, every function in hook results, `select`/`close`/`toggle*`, `getXProps` | Yes, always | +| `tabsets`, `splitters`, `tabIds` arrays | Change only when the structure changes (not on resize or drag frames) | +| `TabsetState` / `SplitterState` / `TabState` objects | Change only when that entry changes | +| `dropIndicator` | Changes when the target changes (not on every pointer move) | +| Rects | Not exposed through React state; applied by the fast path | + +### 3.8 DOM fast path (`internal/rect-writer.ts`) + +It keeps v1's key performance property (R3): **pointer moves never re-render React.** + +1. Components register their element under `slot:id` (`panel`, `tabBar`, `tabContent`, `splitter`, `dropIndicator`, `toolbar`) through the ref in the prop getters. +2. The writer subscribes to the engine. When `snapshot.rects` changes reference, it walks the registry and writes `left/top/width/height` (and `transform` for rotated strips) **only for rects whose object identity changed**. +3. Visibility goes through attributes (`data-dx-hidden`, `data-dx-visible`) styled in CSS. Inline `display` is never written, for the reason in #83 (an inline `display` wipes the slot component's own value). +4. Components *also* render positioning from the current snapshot at render time. That makes the first paint and SSR correct, and React's diff (props against props, not against the DOM) never fights the writer. + +The module is internal, documented in its header, and covered by a test that drags a splitter and asserts zero React commits. + +### 3.9 Pointer-based tab dragging (D7, replaces P7) + +- **Start.** `pointerdown` on a tab or tab bar, then movement past 4px for mouse/pen, or a **350ms long press** for touch so the tab bar can still be scrolled by panning. Then `setPointerCapture` and `engine.startDrag`. A click without movement selects the tab, as today. +- **Move.** Moves are coalesced to one per frame with `createFrameScheduler`. The pointer is converted to root coordinates, and `engine.updateDrag(point, measurements)` is called. `measurements` (tab bar and label rects) is measured once at drag start and again if a tab bar scrolls. +- **End.** `pointerup` → `engine.endDrag()` (performs the move, one `onLayoutChange`). **Escape** or `pointercancel` → `cancelDrag()`. +- **Feedback.** + - The root gets `data-dx-dragging`, which keeps `.is-dragging`-style rules so iframes and editors inside tab content don't swallow events (R6). + - The cursor comes from CSS on the captured element; there are no `document.body.style` writes (P5). + - `DropIndicator` and `RootDropZone` render from `snapshot.drag`. +- **Maximize.** As in #83, starting a drag restores maximize first, so every target is visible. +- **Splitters** use the same module and `startDrag({ type: 'splitter' })`. +- **Lost by this change:** native HTML5 drag events on the wrappers. Users who attached `onDragStart` and similar to custom `WrapTab*` components must move to `useDragState`. This is listed as a breaking change. + +### 3.10 Container measurement + +- A `ResizeObserver` on the root measures the content box minus `padding`. Updates are batched per animation frame, or throttled by `resizeThrottleMs`. This replaces `window.resize` (P6) and catches sidebars and flex parents. +- The first measurement happens synchronously in an isomorphic layout effect (`getBoundingClientRect`), so there's no flash. +- Rects are root-relative (D8), so page scroll and the layout's offset don't matter (B25). + +### 3.11 SSR, StrictMode, Next.js + +- **No `window`/`document` at import or during render.** Everything DOM-related lives in effects or event handlers. The core is pure. +- **Deterministic first render.** + - Engine ids are deterministic (§2.6), and DOM ids come from `useId()` (`internal/dom-ids.ts` encodes tab ids into CSS-safe ids). This fixes B4 and B14. + - Before the first measurement the root carries `data-dx-measuring` (CSS `visibility: hidden` on its children). Server and client render the same markup, and the client measures before paint. +- **`'use client'`.** The React build prepends `'use client';` to the entry chunk with a Rollup `output.banner`. `migrateLayoutFromV1` and the types come from core, which is server-safe. +- **StrictMode.** + - Every effect returns a cleanup: RO disconnect, pointer listeners, frame cancel, keyboard listeners. The double mount/unmount leaves nothing behind (tested with spies, §10). + - React never calls `engine.destroy()`, because StrictMode remounts reuse the same engine; listener cleanup is enough. +- The Next.js example drops `dynamic(..., { ssr: false })` and renders on the server. + +### 3.12 Accessibility (new) + +**Tabs (WAI-ARIA Tabs pattern).** +- Tab bar: `role="tablist"`, with `aria-orientation="horizontal"` (`vertical` on a rotated strip). +- Each tab: `role="tab"`, `id`, `aria-selected`, `aria-controls` pointing at the content id, and a roving `tabindex` (active `0`, others `-1`). +- Content: `role="tabpanel"`, `aria-labelledby`, `tabindex="0"`. +- Keys: ←/→ (↑/↓ on a vertical strip), Home and End move focus. With `tabActivation: 'automatic'`, focus also selects; with `'manual'`, Enter or Space selects. Delete closes a `closable` tab (calls `onTabClose`). + +**Splitters (Window Splitter pattern).** +- `role="separator"`, `aria-orientation`: `vertical` for a bar between side-by-side panels, `horizontal` otherwise. +- `aria-valuenow`/`aria-valuemin`/`aria-valuemax` as percentages of the pair, from `getSplitterBounds`. +- `aria-controls` points at the leading panel. `aria-label` defaults to "Resize {before} and {after}", using tab titles. +- `tabindex="0"`. Arrow keys move 10px (Shift: 50px); Home/End go to min/max. Enter folds or unfolds the leading tabset when it can fold, per APG "collapse". +- A locked splitter gets `aria-disabled="true"`. + +**Toolbar.** Real ` - )} - {showFold && ( - - )} -
- ) -} diff --git a/packages/react/src/components/Layout.tsx b/packages/react/src/components/Layout.tsx deleted file mode 100644 index a9a392a..0000000 --- a/packages/react/src/components/Layout.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import React, { - ForwardRefExoticComponent, - RefAttributes, - useCallback, - useMemo, - useRef, -} from 'react' -import { useDynamixLayout } from '../hooks/useLayout' -import { - DynamixLayoutCore, - NodeOptions, - getTabbarPlacement, -} from '@dynamix-layout/core' -import { LayoutProps, TabEntry, TabInput, TabOutput } from '../types' -import { - DefaultHoverElement, - DefaultWrapTabLabel, - DefaultWrapTabPanel, - DefaultWrapTabHead, - DefaultSliderElement, - DefaultWrapTabBody, - DefaultTabsetToolbar, - RootSplitterHoverEl, -} from './Default' -import './layout.css' - -export const getTabOutput = (input: TabInput): TabOutput => { - const keys: string[] = [] - const maps: Map = new Map() - const name: Map = new Map() - - for (const [label, node] of input) { - const key = crypto.randomUUID() - keys.push(label) - maps.set(label, { - uqid: key, - name: label, - node, - } as TabEntry) - name.set(label, key) - } - - return { - keys, - maps, - name, - } -} - -export const DynamixLayout: ForwardRefExoticComponent< - LayoutProps & RefAttributes -> = React.forwardRef( - ( - { - tabs, - bondWidth = DynamixLayoutCore._bond, - minTabHeight = DynamixLayoutCore._minH, - minTabWidth = DynamixLayoutCore._minW, - updateJSON, - sliderUpdateTimeout = 2, - windowResizeTimeout = 2, - disableSliderTimeout = true, - disableResizeTimeout = true, - HoverElement = DefaultHoverElement, - enableTabbar = true, - WrapTabLabel = DefaultWrapTabLabel, - WrapTabPanel = DefaultWrapTabPanel, - WrapTabHead = DefaultWrapTabHead, - WrapTabBody = DefaultWrapTabBody, - SliderElement = DefaultSliderElement, - tabHeadHeight = DynamixLayoutCore._minH, - tabNames = new Map(), - layoutTree, - pad = { - t: 0, - b: 0, - l: 0, - r: 0, - }, - rootId = 'dynamix-layout-root', - hoverElementStyles, - sliderElementStyles, - tabPanelElementStyles, - tabBodyElementStyles, - tabHeadElementStyles, - tabLabelElementStyles, - RootSplitterHoverElStyles, - hoverElementClass, - sliderElementClass, - tabPanelElementClass, - tabBodyElementClass, - tabHeadElementClass, - tabLabelElementClass, - RootSplitterHoverElClass, - enableMaximize = true, - enableCollapse = true, - enableDoubleClickMaximize = true, - keyboardShortcuts = true, - TabsetToolbar = DefaultTabsetToolbar, - ...props - }: LayoutProps, - ref? - ) => { - const tabOutput = useMemo(() => getTabOutput(tabs), [tabs]) - const ownRef = useRef(null) - const resolvedRef = typeof ref === 'function' ? ownRef : ref || ownRef - - const dimensions = useCallback(() => { - const el = resolvedRef.current - if (!el) return { w: 0, h: 0, x: 0, y: 0 } - const rect = el.getBoundingClientRect() - return { - w: rect.width - (pad.l + pad.r), - h: rect.height - (pad.t + pad.b), - x: rect.left + pad.l, - y: rect.top + pad.t, - } - }, [resolvedRef, pad.l, pad.r, pad.t, pad.b]) - - const { - tabsetsRef, - slidersRef, - sliders, - tabsets, - hoverElementRef, - onDragEnd, - onDragOver, - onDragStart, - onDragEnter, - onDragLeave, - onDrop, - dragging, - panelsRef, - onPointerDown, - handleNavbarDragOver, - updateActiveTab, - rootSplitHoverEl, - handleRootSplit, - tabsRef, - isUpdating, - toggleMaximize, - toggleCollapse, - onRootPointerDown, - onTabbarDoubleClick, - } = useDynamixLayout({ - tabOutput, - rootId, - updateJSON, - enableTabbar, - layoutTree, - tabHeadHeight, - dimensions, - sliderUpdateTimeout, - windowResizeTimeout, - bondWidth, - minTabHeight, - minTabWidth, - disableSliderTimeout, - disableResizeTimeout, - hoverElementStyles, - keyboardShortcuts, - enableDoubleClickMaximize, - }) - - // Folding needs the tab bar: a folded tabset is only its tab bar. - const canFold = enableCollapse && enableTabbar - - return ( -
- {enableTabbar && - tabsets && - Array.from(tabsets.values()).map((tabset) => { - const placement = getTabbarPlacement( - tabset, - tabHeadHeight - ) - const showMaximize = - enableMaximize && !!tabset.nodMaximizable - const showFold = - canFold && !!tabset.nodFoldable && !tabset.nodMaxd - const hasToolbar = showMaximize || showFold - - return ( - - { - const id = tabset.uidNode - if (el) panelsRef.current.set(id, el) - else panelsRef.current.delete(id) - }} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDrop={onDrop} - data-uid={tabset.uidNode} - data-type={tabset.typNode} - className={ - 'hide-scrollbar DefaultWrapTabPanel ' + - (tabPanelElementClass || '') - } - style={{ - position: 'absolute', - ...tabPanelElementStyles, - width: `${tabset.nodDims.w}px`, - height: `${tabset.nodDims.h}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - backgroundColor: 'transparent', - }} - /> - - { - const id = tabset.uidNode - if (el) tabsetsRef.current.set(id, el) - else tabsetsRef.current.delete(id) - }} - className={ - 'hide-scrollbar ' + - (tabHeadElementClass || '') - } - style={{ - ...tabHeadElementStyles, - position: 'absolute', - zIndex: 99, - width: `${placement.rect.w}px`, - height: `${placement.rect.h}px`, - left: `${placement.rect.x}px`, - top: `${placement.rect.y}px`, - transformOrigin: '0 0', - // Only add keys when needed: an explicit `undefined` - // would wipe the tab bar component's own value. - ...(placement.rotated - ? { transform: 'rotate(90deg)' } - : {}), - ...(hasToolbar - ? { paddingRight: '64px' } - : {}), - cursor: isUpdating ? 'wait' : 'pointer', - }} - > - {tabset.nodKids && - tabset.nodKids.map( - (tab: NodeOptions) => ( - - {tabNames.get( - tab.nodName - ) || tab.nodName} - - ) - )} - {hasToolbar && ( - - toggleMaximize(tabset.uidNode) - } - onToggleFold={() => - toggleCollapse(tabset.uidNode) - } - /> - )} - - - ) - })} - - {tabOutput.keys.map((key) => { - const tabId = tabOutput.maps.get(key)?.uqid - return ( - { - if (tabId) { - if (el) tabsRef.current.set(tabId, el) - else tabsRef.current.delete(tabId) - } - }} - > - {tabOutput.maps.get(key)?.node || ( -
- - No content - -
- )} -
- ) - })} - - {sliders && - Array.from(sliders.values()).map((slider) => ( - { - const id = slider.uidNode - if (el) slidersRef.current.set(id, el) - else slidersRef.current.delete(id) - }} - style={{ - width: `${slider.nodDims.w}px`, - height: `${slider.nodDims.h}px`, - left: `${slider.nodDims.x}px`, - top: `${slider.nodDims.y}px`, - cursor: slider.nodeDir - ? 'ns-resize' - : 'ew-resize', - ...(slider.nodLocked - ? { pointerEvents: 'none' as const } - : {}), - ...sliderElementStyles, - }} - /> - ))} - - - - {(['left', 'right', 'top', 'bottom'] as const).map( - (area, index) => ( - { - if (el) rootSplitHoverEl.current[index] = el - }} - style={{ ...RootSplitterHoverElStyles }} - key={area} - area={area} - size={{ h: '25%', w: '8px' }} - /> - ) - )} -
- ) - } -) - -DynamixLayout.displayName = 'DynamixLayout' diff --git a/packages/react/src/hooks/useLayout.ts b/packages/react/src/hooks/useLayout.ts deleted file mode 100644 index 3cf0368..0000000 --- a/packages/react/src/hooks/useLayout.ts +++ /dev/null @@ -1,733 +0,0 @@ -import { - LayoutTree, - DynamixLayoutCore, - Node, - NodeOptions, - DropPreview, - RootSide, - createFrameScheduler, - getNavbarDropPreview, - getRootSplitPreview, - getTabBodyRect, - getTabsetDropPreview, - getTabbarPlacement, - isSameDropPreview, - placeTabbar, - setElementRect, -} from '@dynamix-layout/core' -import React, { useState, useEffect } from 'react' -import { useDynamixLayoutOptions } from '../types' - -export const useDynamixLayout = ({ - tabOutput, - rootId, - layoutTree, - updateJSON, - dimensions, - tabHeadHeight, - enableTabbar, - bondWidth, - minTabHeight, - minTabWidth, - sliderUpdateTimeout, - windowResizeTimeout, - disableSliderTimeout, - disableResizeTimeout, - keyboardShortcuts = true, - enableDoubleClickMaximize = true, -}: useDynamixLayoutOptions) => { - const tabsetsRef = React.useRef(new Map()) - const slidersRef = React.useRef(new Map()) - const panelsRef = React.useRef(new Map()) - const tabsRef = React.useRef(new Map()) - const hoverElementRef = React.useRef(null) - const rootSplitHoverEl = React.useRef([]) - const [layoutJSON, setLayoutJSON] = useState( - layoutTree - ) - const [tabsets, setTabsets] = useState>() - const [sliders, setSliders] = useState>() - const [dragging, setDragging] = useState(false) - const animationFrameRef = React.useRef(null) - const [isUpdating, setIsUpdating] = useState(false) - const dragElemRef = React.useRef<{ - src?: HTMLDivElement - des?: HTMLDivElement - drag?: boolean - area?: 'top' | 'bottom' | 'left' | 'right' | 'contain' - } | null>({ - src: undefined, - des: undefined, - area: undefined, - drag: false, - }) - - // --- REFACTORED: Using Pointer Events for Slider --- - const dragSliderRef = React.useRef<{ - sliderId?: string - isSliding?: boolean - }>({ - sliderId: undefined, - isSliding: false, - }) - - // A tabset can never be shorter than its tab bar, otherwise the bar spills - // over the slider below it and the tab body gets a negative height. - const minLayoutHeight = enableTabbar - ? Math.max(minTabHeight, tabHeadHeight) - : minTabHeight - - const layoutInstance = React.useMemo(() => { - DynamixLayoutCore._bond = bondWidth - DynamixLayoutCore._minH = minLayoutHeight - DynamixLayoutCore._minW = minTabWidth - - const LT = new DynamixLayoutCore({ - tabs: tabOutput.keys, - tree: layoutJSON, - minW: minTabWidth, - minH: minLayoutHeight, - bond: bondWidth, - uqid: rootId, - tabsIds: tabOutput.name, - // A folded tabset shrinks to its tab bar. - collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, - }) - return LT - }, [ - layoutJSON, - tabOutput, - bondWidth, - minLayoutHeight, - minTabWidth, - rootId, - enableTabbar, - tabHeadHeight, - ]) - - const updateTabsets = (nodes: Map) => { - const newTabsets = new Map(nodes) - setTabsets(newTabsets) - } - - const updateSliders = (nodes: Map) => { - const newSliders = new Map(nodes) - setSliders(newSliders) - } - - // Read through refs so the subscriptions below never need to be recreated. - const enableTabbarRef = React.useRef(enableTabbar) - const tabHeadHeightRef = React.useRef(tabHeadHeight) - enableTabbarRef.current = enableTabbar - tabHeadHeightRef.current = tabHeadHeight - - useEffect(() => { - const offNodes = Node.cache.nodOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const panelEl = panelsRef.current.get(id) - if (panelEl) setElementRect(panelEl, node.nodDims) - - const tabsetEl = tabsetsRef.current.get(id) - if (tabsetEl && enableTabbarRef.current) { - tabsetEl.toggleAttribute( - 'data-dx-hidden', - !!node.nodHidden - ) - placeTabbar( - tabsetEl, - getTabbarPlacement(node, tabHeadHeightRef.current) - ) - } - }) - } - ) - - const offBonds = Node.cache.bndOpts.onChange( - (nodes: Map) => { - nodes.forEach((node: NodeOptions, id: string) => { - const sliderEl = slidersRef.current.get(id) - if (!sliderEl) return - setElementRect(sliderEl, node.nodDims) - sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) - sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' - }) - } - ) - - const offTabs = Node.cache.tabOpts.onChange( - (nodes: Map) => { - const tabbarHeight = enableTabbarRef.current - ? tabHeadHeightRef.current - : 0 - - nodes.forEach((node: NodeOptions, id: string) => { - const tabEl = tabsRef.current.get(id) - if (!tabEl) return - - const body = getTabBodyRect(node.nodDims, tabbarHeight) - setElementRect(tabEl, body) - // Empty bodies stay hidden so their borders/shadows don't - // bleed onto the slider below. - const visible = - node.nodOpen && - !node.nodFold && - !node.nodHidden && - body.h > 0 - tabEl.style.display = visible ? 'block' : 'none' - }) - } - ) - - return () => { - offNodes() - offBonds() - offTabs() - } - }, []) - - // --- START: SLIDER POINTER EVENT HANDLERS --- - const sliderScheduler = React.useMemo( - () => - createFrameScheduler( - ({ - id, - point, - }: { - id: string - point: { x: number; y: number } - }) => - layoutInstance.updateSlider( - id, - point, - disableSliderTimeout, - sliderUpdateTimeout - ) - ), - [layoutInstance, disableSliderTimeout, sliderUpdateTimeout] - ) - - useEffect(() => () => sliderScheduler.cancel(), [sliderScheduler]) - - const onPointerMove = (e: PointerEvent) => { - const id = dragSliderRef.current.sliderId - if (!dragSliderRef.current.isSliding || !id) return - - sliderScheduler.schedule({ id, point: { x: e.clientX, y: e.clientY } }) - } - - const onPointerUp = (e: PointerEvent) => { - if (!dragSliderRef.current.isSliding) return - - sliderScheduler.flush() - - const sliderElement = e.currentTarget as HTMLDivElement - dragSliderRef.current.isSliding = false - dragSliderRef.current.sliderId = undefined - setDragging(false) - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - sliderElement.releasePointerCapture(e.pointerId) - sliderElement.removeEventListener('pointermove', onPointerMove) - sliderElement.removeEventListener('pointerup', onPointerUp) - sliderElement.removeEventListener('pointercancel', onPointerUp) - } - - const onPointerDown = (e: React.PointerEvent) => { - e.preventDefault() - - const sliderElement = e.currentTarget - dragSliderRef.current.isSliding = true - dragSliderRef.current.sliderId = sliderElement.id - // Applies `.is-dragging`, so iframes and editors inside tab bodies - // cannot swallow pointer events while the slider is being dragged. - setDragging(true) - - // Capture the pointer to ensure events are received - sliderElement.setPointerCapture(e.pointerId) - - // Add listeners - sliderElement.addEventListener('pointermove', onPointerMove) - sliderElement.addEventListener('pointerup', onPointerUp) - sliderElement.addEventListener('pointercancel', onPointerUp) - } - // --- END: SLIDER POINTER EVENT HANDLERS --- - - const lastHoverState = React.useRef>({}) - - const findDropTargetTabset = ( - clientX: number, - clientY: number, - target: HTMLDivElement - ) => { - if (!hoverElementRef.current || !dragElemRef.current || !target) { - return null - } - - const preview = getTabsetDropPreview( - target.getBoundingClientRect(), - clientX, - clientY - ) - updateHoverElement(preview) - dragElemRef.current.des = target - dragElemRef.current.area = preview.area - } - - const handleNavbarDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - // A folded strip shows its tab bar rotated; label boxes are vertical - // there, so the whole strip is the target and drops go into the tabset. - if (e.currentTarget.hasAttribute('data-rotated')) { - if (!hoverElementRef.current || !dragElemRef.current) return - const r = e.currentTarget.getBoundingClientRect() - updateHoverElement({ - area: 'contain', - left: r.left, - top: r.top, - width: r.width, - height: r.height, - }) - dragElemRef.current.des = e.currentTarget - dragElemRef.current.area = 'contain' - return - } - - // Only tab labels: the tab bar also holds the maximize/fold toolbar. - const tabElems = Array.from( - e.currentTarget.querySelectorAll( - ':scope > [data-type="tab"]' - ) - ) - if (!hoverElementRef.current || !dragElemRef.current) return - - const preview = getNavbarDropPreview( - e.currentTarget.getBoundingClientRect(), - tabElems.map((tab) => tab.getBoundingClientRect()), - e.clientX, - e.clientY - ) - if (!preview) return - - updateHoverElement(preview) - dragElemRef.current.des = tabElems[preview.index] - dragElemRef.current.area = preview.area - } - - const handleRootSplit = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - const { area, uid } = e.currentTarget.dataset - if (!area || !uid || !hoverElementRef.current || !dragElemRef.current) { - return - } - - const side = area as RootSide - dragElemRef.current.des = e.currentTarget - dragElemRef.current.area = side - updateHoverElement(getRootSplitPreview(dimensions(), side)) - } - - const updateHoverElement = (preview: DropPreview) => { - const hoverEl = hoverElementRef.current - if (!hoverEl || isSameDropPreview(lastHoverState.current, preview)) - return - - Object.assign(hoverEl.style, { - left: `${preview.left}px`, - top: `${preview.top}px`, - width: `${preview.width}px`, - height: `${preview.height}px`, - display: 'block', - zIndex: '100', - }) - lastHoverState.current = preview - } - - // --- Maximize / fold --- - const activeTabsetRef = React.useRef(null) - - const refreshViewState = () => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - } - - const toggleMaximize = (tabsetId: string) => { - if (layoutInstance.toggleMaximize(tabsetId)) refreshViewState() - } - - const toggleCollapse = (tabsetId: string) => { - if (layoutInstance.toggleCollapse(tabsetId)) refreshViewState() - } - - /** Remembers which tabset the user last touched, for the shortcuts. */ - const onRootPointerDown = (e: React.PointerEvent) => { - const el = (e.target as HTMLElement).closest('[data-uid]') - const node = el?.dataset.uid - ? Node.cache.mapElem.get(el.dataset.uid) - : undefined - if (!(node instanceof Node)) return - if (node.type === 'tabset') activeTabsetRef.current = node.unId - else if (node.type === 'tab' && node.host) { - activeTabsetRef.current = node.host.unId - } - } - - const onTabbarDoubleClick = (e: React.MouseEvent) => { - const id = e.currentTarget.dataset.uid - if (enableDoubleClickMaximize && id) toggleMaximize(id) - } - - useEffect(() => { - if (!keyboardShortcuts) return - - const onKeyDown = (e: KeyboardEvent) => { - if (!e.altKey || e.ctrlKey || e.metaKey) return - const id = activeTabsetRef.current ?? layoutInstance.maximizedId - if (!id) return - - if (e.code === 'Equal' || e.code === 'NumpadAdd') { - e.preventDefault() - toggleMaximize(id) - } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') { - e.preventDefault() - toggleCollapse(id) - } - } - - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }) - - const onDragStart = (e: React.DragEvent) => { - e.stopPropagation() - // Every drop target must be visible while dragging. - if (layoutInstance.restore()) refreshViewState() - setDragging(true) - document.body.style.cursor = 'move' - if (e.currentTarget) { - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move' - - const dragImage = document.createElement('div') - dragImage.style.width = '1px' - dragImage.style.height = '1px' - dragImage.style.backgroundColor = 'transparent' - dragImage.style.position = 'absolute' - dragImage.style.top = '-1000px' - document.body.appendChild(dragImage) - - e.dataTransfer.setDragImage(dragImage, 0, 0) - - setTimeout(() => { - if (dragImage.parentNode) { - dragImage.parentNode.removeChild(dragImage) - } - }, 0) - } - - rootSplitHoverEl.current.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '99' - } - }) - - panelsRef.current.forEach((el) => { - if (el) { - el.style.display = 'block' - el.style.zIndex = '95' - } - }) - - dragElemRef.current!.src = e.currentTarget as HTMLDivElement - dragElemRef.current!.drag = true - } - } - - const onDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - document.body.style.cursor = 'move' - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - - if (!dragElemRef.current?.src) { - return - } - - const target = e.currentTarget as HTMLDivElement - const clientX = e.clientX - const clientY = e.clientY - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - - animationFrameRef.current = requestAnimationFrame(() => { - findDropTargetTabset(clientX, clientY, target) - }) - } - - const onDragEnd = () => { - document.body.style.cursor = 'default' - rootSplitHoverEl.current.forEach((el) => { - if (el) { - el.style.display = 'none' - el.style.zIndex = '-1' - } - }) - - panelsRef.current.forEach((el) => { - if (el) { - el.style.display = 'none' - el.style.zIndex = '-1' - } - }) - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - animationFrameRef.current = null - } - - if (hoverElementRef.current) { - hoverElementRef.current.style.display = 'none' - hoverElementRef.current.style.zIndex = '-1' - } - - lastHoverState.current = {} - dragElemRef.current!.drag = false - - if (!dragElemRef.current?.src || !dragElemRef.current?.des) { - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.current.src === dragElemRef.current.des) { - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - return - } - - if (dragElemRef.current.src && dragElemRef.current.des) { - if (!dragElemRef.current.area) { - console.warn('No area specified for drag and drop') - - setDragging(false) - return - } - - const result = layoutInstance.updateTree( - dragElemRef.current.src.dataset.uid as string, - dragElemRef.current.des.dataset.uid as string, - dragElemRef.current.area || 'contain' - ) - - if (!result) { - console.warn('Failed to update layout tree') - - setDragging(false) - return - } - - requestAnimationFrame(() => { - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - }) - } - - dragElemRef.current!.area = undefined - dragElemRef.current!.src = undefined - dragElemRef.current!.des = undefined - - setDragging(false) - } - - const onDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move' - } - } - - const onDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const onDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const updateActiveTab = (e: React.MouseEvent) => { - const target = e.currentTarget as HTMLDivElement - const uid = target.dataset.uid - - if (!uid) return - - const node = Node.cache.mapElem.get(uid) - - if (!(node instanceof Node)) { - console.warn(`Element with uid ${uid} is not a Node instance`) - return - } - - if (!node || !node.host || !node.host.unId) { - console.warn(`Node with uid ${uid} not found in mapNode`) - return - } - - if (node.host.open == node.name) { - console.warn(`Node ${node.name} is already open`) - return - } - - const nodeOpts = Node.cache.nodOpts.get().get(node.host.unId) - - if (!nodeOpts) return - - if (nodeOpts.nodOpen && node?.open) { - return - } - - node.host.open = node.name - nodeOpts.nodOpen = node.name - - const childNodes = target?.parentElement - ?.childNodes as NodeListOf - - childNodes?.forEach((child) => { - if (child instanceof HTMLDivElement && child.dataset.uid !== uid) { - child.dataset.state = 'inactive' - } else { - child.dataset.state = 'active' - } - }) - - nodeOpts.nodKids?.forEach((kid: NodeOptions) => { - if (kid.nodName == node.name) { - kid.nodOpen = true - const tabbarHeight = enableTabbar ? tabHeadHeight : 0 - const body = getTabBodyRect(kid.nodDims, tabbarHeight) - const visible = !kid.nodFold && !kid.nodHidden && body.h > 0 - tabsRef.current - .get(kid.uidNode) - ?.style.setProperty('display', visible ? 'block' : 'none') - } else { - kid.nodOpen = false - tabsRef.current - .get(kid.uidNode) - ?.style.setProperty('display', 'none') - } - }) - } - - useEffect(() => { - const updateDimension = (flag: boolean) => { - layoutInstance.updateDimension( - dimensions(), - flag, - windowResizeTimeout - ) - } - - updateDimension(true) - - const handler = async ( - _: UIEvent // eslint-disable-line @typescript-eslint/no-unused-vars - ) => updateDimension(disableResizeTimeout ?? false) - - window.addEventListener('resize', handler) - - updateTabsets(Node.cache.nodOpts.get()) - updateSliders(Node.cache.bndOpts.get()) - - Node.cache.nodOpts.triggerChange() - Node.cache.bndOpts.triggerChange() - Node.cache.tabOpts.triggerChange() - - if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) - return () => { - window.removeEventListener('resize', handler) - - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - } - }, [dimensions, disableResizeTimeout, windowResizeTimeout]) // eslint-disable-line - - useEffect(() => { - Node.cache.nodOpts.triggerChange() - Node.cache.bndOpts.triggerChange() - Node.cache.tabOpts.triggerChange() - return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } - } - }, [tabsets, sliders]) - - return { - tabsets, - sliders, - tabsetsRef, - panelsRef, - tabsRef, - slidersRef, - layoutJSON, - layoutInstance, - hoverElementRef, - rootSplitHoverEl, - dragging, - isUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setIsUpdating, - /** @deprecated Internal state setter; will be removed in v2. */ - setDragging, - /** @deprecated Internal state setter; will be removed in v2. */ - setTabsets, - /** @deprecated Internal state setter; will be removed in v2. */ - setSliders, - /** @deprecated Internal state setter; will be removed in v2. */ - setLayoutJSON, - onDragStart, - onDragOver, - onDragEnd, - onDragEnter, - onDragLeave, - onDrop, - onPointerDown, - updateActiveTab, - handleRootSplit, - handleNavbarDragOver, - toggleMaximize, - toggleCollapse, - onRootPointerDown, - onTabbarDoubleClick, - } -} diff --git a/packages/react/src/components/layout.css b/packages/react/src/styles.css similarity index 100% rename from packages/react/src/components/layout.css rename to packages/react/src/styles.css diff --git a/packages/react/src/test/layout.test.ts b/packages/react/src/test/layout.test.ts deleted file mode 100644 index 233416b..0000000 --- a/packages/react/src/test/layout.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// src/test/layout.test.ts -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom/vitest' // Ensure you have this import -import { DynamixLayout } from '../' -import * as React from 'react' - -describe('React Layout Component', () => { - it('should render the root div', () => { - const mockTabs: [string, React.ReactNode][] = [ - ['tab1', React.createElement('div', null, 'Tab 1 Content')], - ['tab2', React.createElement('div', null, 'Tab 2 Content')], - ['tab3', React.createElement('div', null, 'Tab 3 Content')], - ['tab4', React.createElement('div', null, 'Tab 4 Content')], - ['tab5', React.createElement('div', null, 'Tab 5 Content')], - ['tab6', React.createElement('div', null, 'Tab 6 Content')], - ['tab7', React.createElement('div', null, 'Tab 7 Content')], - ] - - render( - React.createElement(DynamixLayout, { - tabs: mockTabs, - rootId: 'test-root', - }) - ) - - const layoutElement = screen.getByTestId('test-root') - expect(layoutElement).toBeInTheDocument() - }) -}) diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts deleted file mode 100644 index 34d109e..0000000 --- a/packages/react/src/types/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { ReactNode } from 'react' -import type { LayoutTree, Dimension } from '@dynamix-layout/core' - -export interface DynamixLayoutProps { - children?: ReactNode - layoutTree?: LayoutTree - tabs?: string[] - className?: string - style?: React.CSSProperties -} - -export interface UseDynamixLayoutOptions { - initialLayoutTree?: LayoutTree - tabs?: string[] -} - -export interface UseDynamixLayoutResult { - layoutTree: LayoutTree | undefined - tabs: string[] - setTabs: (tabs: string[]) => void - setLayoutTree: (layoutTree: LayoutTree) => void -} - -export type DivFC = React.ForwardRefExoticComponent< - React.HTMLAttributes & React.RefAttributes -> - -export type TabEntry = { - uqid: string - name: string - node: React.ReactNode -} - -export type TabInput = [string, React.ReactNode][] -export type TabOutput = { - keys: string[] - maps: Map - name: Map -} - -export interface LayoutProps { - tabs: [string, ReactNode][] - enableTabbar?: boolean - WrapTabPanel?: DivFC - WrapTabLabel?: React.ForwardRefExoticComponent< - React.HTMLAttributes & - React.RefAttributes & { active?: boolean } - > - WrapTabHead?: DivFC - WrapTabBody?: DivFC - HoverElement?: DivFC - SliderElement?: React.ForwardRefExoticComponent< - React.HTMLAttributes & - React.RefAttributes & { direction?: boolean } - > - tabHeadHeight?: number - layoutTree?: LayoutTree - pad?: { - t: number - b: number - l: number - r: number - } - className?: string - style?: React.CSSProperties - bondWidth?: number - minTabHeight?: number - minTabWidth?: number - updateJSON?: (layoutTree: LayoutTree) => void - sliderUpdateTimeout?: number - windowResizeTimeout?: number - disableSliderTimeout?: boolean - disableResizeTimeout?: boolean - hoverElementStyles?: React.CSSProperties - sliderElementStyles?: React.CSSProperties - tabPanelElementStyles?: React.CSSProperties - tabBodyElementStyles?: React.CSSProperties - tabHeadElementStyles?: React.CSSProperties - tabLabelElementStyles?: React.CSSProperties - RootSplitterHoverElStyles?: React.CSSProperties - hoverElementClass?: string - sliderElementClass?: string - tabPanelElementClass?: string - tabBodyElementClass?: string - tabHeadElementClass?: string - tabLabelElementClass?: string - RootSplitterHoverElClass?: string - rootId?: string - tabNames?: Map - /** Show the maximize button on tab bars (default true). */ - enableMaximize?: boolean - /** Show the fold button on tab bars (default true; needs the tab bar). */ - enableCollapse?: boolean - /** Double-click a tab bar to maximize or restore it (default true). */ - enableDoubleClickMaximize?: boolean - /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ - keyboardShortcuts?: boolean - TabsetToolbar?: React.ComponentType -} - -export interface TabsetToolbarProps { - maximized: boolean - folded: boolean - /** The tab bar is drawn as a rotated vertical strip (folded, side-by-side row). */ - rotated: boolean - /** The tabset's row lays its children out side by side. */ - rowIsHorizontal: boolean - showMaximize: boolean - showFold: boolean - onToggleMaximize: () => void - onToggleFold: () => void -} - -export interface useDynamixLayoutOptions { - tabOutput: TabOutput - rootId?: string - layoutTree?: LayoutTree - enableTabbar: boolean - updateJSON?: (layoutTree: LayoutTree) => void - dimensions: () => Dimension - sliderUpdateTimeout: number - windowResizeTimeout: number - disableSliderTimeout: boolean - disableResizeTimeout: boolean - hoverElementStyles?: React.CSSProperties - bondWidth: number - minTabHeight: number - minTabWidth: number - tabHeadHeight: number - keyboardShortcuts?: boolean - enableDoubleClickMaximize?: boolean -} diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 13b7b10..9c151e9 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ plugins: [ dts({ outDir: 'dist/types', + // Types-only modules never reach the bundle graph; list sources explicitly. + include: ['src'], exclude: ['node_modules/**', 'src/test/**'], staticImport: true, }), From 0a03b7aa2076087bb89a067bd96cd3c03032af0f Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:17:43 +0530 Subject: [PATCH 10/41] feat(core): name TabBarMeasurement and accept readonly v1 input Adapters measure tab bars into DropMeasurements; give the entry a public name. LayoutTreeV1 accepts readonly child arrays, so typed literals and frozen data can be passed for migration. --- packages/core/src/drop/drop-target.ts | 22 ++++++++++------------ packages/core/src/serialize/migrate-v1.ts | 2 +- packages/core/src/serialize/schema.ts | 2 +- packages/core/src/v2.ts | 1 + 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/core/src/drop/drop-target.ts b/packages/core/src/drop/drop-target.ts index 56e2017..9e5670b 100644 --- a/packages/core/src/drop/drop-target.ts +++ b/packages/core/src/drop/drop-target.ts @@ -3,19 +3,17 @@ import type { DropTarget, LayoutModel, Point, Rect, Side } from '../model/types' import { collectTabsets } from '../tree/find' import { canMove, type MoveSource } from '../tree/move' -/** Tab bar geometry the engine can't know (label widths), in layout-root coordinates. */ +/** A tab bar and its tabs as rendered, in layout-root coordinates. */ +export interface TabBarMeasurement { + readonly rect: Rect + /** Drawn as a rotated strip (a folded tabset in a side-by-side row). */ + readonly isRotated?: boolean + readonly tabs: readonly { readonly id: string; readonly rect: Rect }[] +} + +/** Tab bar geometry the engine can't know (label widths), keyed by tabset id. */ export interface DropMeasurements { - readonly tabBars: ReadonlyMap< - string, - { - readonly rect: Rect - readonly isRotated?: boolean - readonly tabs: readonly { - readonly id: string - readonly rect: Rect - }[] - } - > + readonly tabBars: ReadonlyMap } /** Size of the root-edge drop zones (v1 `RootSplitterHoverEl`). */ diff --git a/packages/core/src/serialize/migrate-v1.ts b/packages/core/src/serialize/migrate-v1.ts index f96782e..bad68e5 100644 --- a/packages/core/src/serialize/migrate-v1.ts +++ b/packages/core/src/serialize/migrate-v1.ts @@ -70,7 +70,7 @@ export function migrateLayoutFromV1( : freshId(fallback) const seenTabs = new Set() - const readTabs = (kids: LayoutTreeV1[], path: string) => + const readTabs = (kids: readonly LayoutTreeV1[], path: string) => kids.flatMap((kid, i) => { if (kid?.typNode !== 'tab') return [] const id = kid.nodName diff --git a/packages/core/src/serialize/schema.ts b/packages/core/src/serialize/schema.ts index 2d10717..a73947f 100644 --- a/packages/core/src/serialize/schema.ts +++ b/packages/core/src/serialize/schema.ts @@ -48,7 +48,7 @@ export interface LayoutTreeV1 { uidNode: string nodPart: number nodOpen?: string | boolean - nodKids?: LayoutTreeV1[] + nodKids?: readonly LayoutTreeV1[] nodFold?: boolean nodMaxd?: string } diff --git a/packages/core/src/v2.ts b/packages/core/src/v2.ts index f86ac42..606d0b8 100644 --- a/packages/core/src/v2.ts +++ b/packages/core/src/v2.ts @@ -33,6 +33,7 @@ export type { TabInit } from './tree/reconcile-tabs' export type { DropMeasurements, RootDropZoneOptions, + TabBarMeasurement, } from './drop/drop-target' export { getRootDropZoneRect } from './drop/drop-target' export { From d95b341bc31c8c7842c926e734d87bd80a1b8006 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:17:55 +0530 Subject: [PATCH 11/41] feat(react)!: rebuild on createLayout with context, headless hooks and slot components Rebuilds @dynamix-layout/react on the v2 core instance API. - takes `tabs` as objects, `defaultLayout` or `layout` (v1 or v2 JSON) and reports changes through `onLayoutChange`. Sizes, slots, classNames and styles replace the 46 v1 props. The engine is created once per component and never rebuilt by prop changes; an inline `tabs` array with unchanged ids does no engine work and remounts nothing. - Headless API: useDynamixLayout with DynamixLayoutProvider, plus useTabset, useTab, useSplitter, useLayoutState, useLayoutActions and useDragState. Prop getters merge refs, classes, styles and handlers. - Pointer events drive tab, tabset and splitter drags (touch long press, Escape cancels). A ResizeObserver follows the container, and rects are relative to the root, so a layout no longer needs to sit at the viewport's top-left. - Pointer moves and resizes never re-render React: an internal fast path writes positions computed by the same function the render uses. - Tabs follow the WAI-ARIA tabs pattern, splitters the window splitter pattern, and a keyboard move mode (Mod+Shift+M) moves tabs without a mouse. Shortcuts only act when focus is inside the layout. - SSR-safe and deterministic: the Next.js example now renders on the server. The React and Next.js examples move to the new API. BREAKING CHANGE: The v1 props, wrapper components (WrapTab*, SliderElement, HoverElement, RootSplitterHoverEl), useDynamixLayout return shape, getTabOutput and the tuple `tabs` format are removed. `ref` returns a DynamixLayoutHandle (`handle.element` is the root). `onLayoutChange` replaces `updateJSON`, receives v2 JSON and is not called on mount. Default class names change to `.dx-*`, and data attributes to `data-dx-*`. See docs/v2/02-design.md for the full mapping. --- examples/nextjs/src/app/comp.tsx | 47 +- examples/nextjs/src/app/page.tsx | 31 +- examples/nextjs/tsconfig.json | 4 +- examples/react/src/App.tsx | 322 ++++++------- .../react/src/components/layout-config.ts | 9 + examples/react/src/components/wrapper.tsx | 325 +++---------- packages/react/eslint.config.mjs | 15 +- packages/react/package.json | 4 +- .../react/src/components/DropIndicator.tsx | 9 + .../react/src/components/DynamixLayout.tsx | 427 ++++++++++++++++++ packages/react/src/components/LiveRegion.tsx | 20 + packages/react/src/components/Panel.tsx | 10 + .../react/src/components/RootDropZone.tsx | 15 + packages/react/src/components/Splitter.tsx | 9 + packages/react/src/components/Tab.tsx | 33 ++ packages/react/src/components/TabBar.tsx | 14 + packages/react/src/components/TabContent.tsx | 13 + .../react/src/components/TabsetToolbar.tsx | 109 +++++ .../src/context/DynamixLayoutProvider.tsx | 34 ++ packages/react/src/context/layout-context.ts | 33 ++ packages/react/src/hooks/use-drag-state.ts | 13 + .../react/src/hooks/use-dynamix-layout.ts | 175 +++++++ .../react/src/hooks/use-layout-actions.ts | 37 ++ packages/react/src/hooks/use-layout-state.ts | 48 ++ packages/react/src/hooks/use-splitter.ts | 113 +++++ packages/react/src/hooks/use-tab.ts | 122 +++++ packages/react/src/hooks/use-tabset.ts | 135 ++++++ packages/react/src/index.ts | 40 +- .../react/src/internal/container-observer.ts | 59 +++ packages/react/src/internal/controller.ts | 183 ++++++++ packages/react/src/internal/dom-ids.ts | 23 + packages/react/src/internal/keyboard.ts | 228 ++++++++++ packages/react/src/internal/pointer-drag.ts | 152 +++++++ packages/react/src/internal/rect-writer.ts | 78 ++++ packages/react/src/internal/slot-geometry.ts | 131 ++++++ packages/react/src/internal/splitter-aria.ts | 25 + packages/react/src/internal/tab-label.ts | 41 ++ packages/react/src/internal/tab-registry.ts | 67 +++ packages/react/src/internal/use-controller.ts | 179 ++++++++ packages/react/src/styles.css | 257 +++++++++-- packages/react/src/test/dom.ts | 150 ++++++ packages/react/src/test/drag.test.tsx | 276 +++++++++++ packages/react/src/test/render.test.tsx | 271 +++++++++++ packages/react/src/test/rerender.test.tsx | 280 ++++++++++++ packages/react/src/test/ssr.test.tsx | 32 ++ packages/react/src/test/strict-mode.test.tsx | 63 +++ packages/react/src/types.ts | 258 +++++++++++ packages/react/src/utils/merge-props.ts | 56 +++ packages/react/src/utils/merge-refs.ts | 12 + .../src/utils/use-isomorphic-layout-effect.ts | 9 + .../react/src/utils/use-stable-callback.ts | 16 + 51 files changed, 4467 insertions(+), 545 deletions(-) create mode 100644 examples/react/src/components/layout-config.ts create mode 100644 packages/react/src/components/DropIndicator.tsx create mode 100644 packages/react/src/components/DynamixLayout.tsx create mode 100644 packages/react/src/components/LiveRegion.tsx create mode 100644 packages/react/src/components/Panel.tsx create mode 100644 packages/react/src/components/RootDropZone.tsx create mode 100644 packages/react/src/components/Splitter.tsx create mode 100644 packages/react/src/components/Tab.tsx create mode 100644 packages/react/src/components/TabBar.tsx create mode 100644 packages/react/src/components/TabContent.tsx create mode 100644 packages/react/src/components/TabsetToolbar.tsx create mode 100644 packages/react/src/context/DynamixLayoutProvider.tsx create mode 100644 packages/react/src/context/layout-context.ts create mode 100644 packages/react/src/hooks/use-drag-state.ts create mode 100644 packages/react/src/hooks/use-dynamix-layout.ts create mode 100644 packages/react/src/hooks/use-layout-actions.ts create mode 100644 packages/react/src/hooks/use-layout-state.ts create mode 100644 packages/react/src/hooks/use-splitter.ts create mode 100644 packages/react/src/hooks/use-tab.ts create mode 100644 packages/react/src/hooks/use-tabset.ts create mode 100644 packages/react/src/internal/container-observer.ts create mode 100644 packages/react/src/internal/controller.ts create mode 100644 packages/react/src/internal/dom-ids.ts create mode 100644 packages/react/src/internal/keyboard.ts create mode 100644 packages/react/src/internal/pointer-drag.ts create mode 100644 packages/react/src/internal/rect-writer.ts create mode 100644 packages/react/src/internal/slot-geometry.ts create mode 100644 packages/react/src/internal/splitter-aria.ts create mode 100644 packages/react/src/internal/tab-label.ts create mode 100644 packages/react/src/internal/tab-registry.ts create mode 100644 packages/react/src/internal/use-controller.ts create mode 100644 packages/react/src/test/dom.ts create mode 100644 packages/react/src/test/drag.test.tsx create mode 100644 packages/react/src/test/render.test.tsx create mode 100644 packages/react/src/test/rerender.test.tsx create mode 100644 packages/react/src/test/ssr.test.tsx create mode 100644 packages/react/src/test/strict-mode.test.tsx create mode 100644 packages/react/src/types.ts create mode 100644 packages/react/src/utils/merge-props.ts create mode 100644 packages/react/src/utils/merge-refs.ts create mode 100644 packages/react/src/utils/use-isomorphic-layout-effect.ts create mode 100644 packages/react/src/utils/use-stable-callback.ts diff --git a/examples/nextjs/src/app/comp.tsx b/examples/nextjs/src/app/comp.tsx index fe07816..62427ff 100644 --- a/examples/nextjs/src/app/comp.tsx +++ b/examples/nextjs/src/app/comp.tsx @@ -1,38 +1,11 @@ -export const tabs: [string, React.ReactNode][] = [ - [ - 'editor', -
- Editor -
, - ], - [ - 'preview', -
- Preview -
, - ], - [ - 'terminal', -
- Terminal -
, - ], +import type { TabItem } from '@dynamix-layout/react' + +const panel = (background: string, label: string) => ( +
{label}
+) + +export const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content: panel('#c0ca33', 'Editor') }, + { id: 'preview', title: 'Preview', content: panel('#66bb6a', 'Preview') }, + { id: 'terminal', title: 'Terminal', content: panel('#ffc400', 'Terminal') }, ] diff --git a/examples/nextjs/src/app/page.tsx b/examples/nextjs/src/app/page.tsx index d3a8128..2db6bcc 100644 --- a/examples/nextjs/src/app/page.tsx +++ b/examples/nextjs/src/app/page.tsx @@ -1,30 +1,15 @@ 'use client' -import React from 'react' -import dynamic from 'next/dynamic' -import { LayoutProps } from '@dynamix-layout/react' +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' import { tabs } from './comp' -import '@dynamix-layout/react/style.css' - -const DynamixLayout = dynamic( - async () => { - const { DynamixLayout } = await import('@dynamix-layout/react') - return { - default: (props: LayoutProps) => ( - - ), - } - }, - { ssr: false, loading: () =>
Loading...
} -) +// Rendered on the server too: v2's first render is deterministic, so it hydrates cleanly. export default function Home() { return ( - <> - console.log(layoutTree)} - style={{ height: '100vh', width: '100vw' }} - tabs={tabs} - /> - + console.log(layout)} + style={{ height: '100vh', width: '100vw' }} + /> ) } diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json index 7df89e7..1a04a15 100644 --- a/examples/nextjs/tsconfig.json +++ b/examples/nextjs/tsconfig.json @@ -19,7 +19,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "react": ["./node_modules/@types/react"], + "react/*": ["./node_modules/@types/react/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 3ac4ddb..f3db371 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,18 +1,15 @@ import Editor from './components/Editor' import { Terminal } from './components/Terminal' -import { DynamixLayout } from '@dynamix-layout/react' +import { + DynamixLayout, + type LayoutTreeV1, + type TabItem, +} from '@dynamix-layout/react' import { IframePreview } from './components/IframePreview' -import '@dynamix-layout/react/style.css' +import '@dynamix-layout/react/styles.css' import './App.css' -import { - DefaultSliderElement, - DefaultHoverElement, - DefaultWrapTabBody, - DefaultWrapTabHead, - DefaultWrapTabLabel, - DefaultWrapTabPanel, -} from './components/wrapper' +import { classNames, components } from './components/layout-config' import { Slider } from './components/ui/slider' import { cn } from './lib/utils' import { Card } from './components/ui/card' @@ -89,6 +86,136 @@ body { ${Array.from({ length: 30 }, (_, i) => `.box${i + 1} { width: ${100 + i * 5}px; height: 40px; background: #${((Math.random() * 0xffffff) << 0).toString(16)}; margin-bottom: 8px; border-radius: 4px; }`).join('\n')} ` +/** A layout saved by v1; v2 migrates it on load. */ +const savedV1Layout: LayoutTreeV1 = { + typNode: 'row', + nodPart: 100, + nodName: 'dynamix-layout-root', + uidNode: 'dynamix-layout-root', + nodKids: [ + { + typNode: 'row', + nodPart: 95.13923576423574, + nodName: '', + uidNode: 'ad18bbee-2260-4172-bcbe-bead8ab919cf', + nodOpen: 'preview', + nodKids: [ + { + typNode: 'row', + nodPart: 253.63885510746962, + nodName: '', + uidNode: 'e4917519-3c9c-4ab9-adea-3a608fa8d45e', + nodOpen: 'TS', + nodKids: [ + { + typNode: 'tabset', + nodPart: 95.68121135993162, + nodName: '', + uidNode: '4c880a2e-df03-4183-9043-9d0cbc25d1a9', + nodOpen: 'TS', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'TS', + uidNode: + '9c5b720f-63b4-480b-ba80-717000b4f6df', + }, + ], + }, + { + typNode: 'tabset', + nodPart: 102.46562527840646, + nodName: '', + uidNode: '94a19aba-384a-4a4f-977a-2b3e5cf45453', + nodOpen: 'preview', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'preview', + uidNode: + '84932e7f-9efe-4437-b27e-242db949b47d', + }, + ], + }, + { + typNode: 'row', + nodPart: 101.85316336166194, + nodName: '', + uidNode: 'd06eb31a-ca54-4c17-ab58-ccc55c5a25e4', + nodOpen: 'css', + nodKids: [ + { + typNode: 'tabset', + nodPart: 100, + nodName: '', + uidNode: + 'f4fb69bf-d5ee-426c-b420-867ecc111f25', + nodOpen: 'css', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'css', + uidNode: + '595562f3-908e-4002-b107-1f86fd2f621a', + }, + ], + }, + { + typNode: 'tabset', + nodPart: 100, + nodName: '', + uidNode: + 'f6453908-1037-4d62-9a8d-cb8684ef311c', + nodOpen: 'Html', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'Html', + uidNode: + 'd19f9840-85b4-4a98-ba7d-dd2445c26500', + }, + ], + }, + ], + }, + ], + }, + { + typNode: 'tabset', + nodPart: 97.75714553534206, + nodName: '', + uidNode: 'f2b421d9-b10d-41db-a410-df41f6b01e8a', + nodOpen: 'terminal', + nodKids: [ + { + typNode: 'tab', + nodPart: 100, + nodName: 'terminal', + uidNode: '0c9bce4c-2bc5-4f68-95e5-1db51d143b9c', + }, + ], + }, + ], + }, + ], +} + +const tabs: TabItem[] = [ + { id: 'TS', content: }, + { id: 'Html', content: }, + { id: 'preview', title: 'Preview', content: }, + { + id: 'css', + title: 'CSS', + content: , + }, + { id: 'terminal', title: 'Terminal', content: }, +] + function App() { const [minTabHeight, setMinTabHeight] = useState(40) const [minTabWidth, setMinTabWidth] = useState(40) @@ -98,171 +225,16 @@ function App() {
{ console.log(layoutTree)} - pad={{ t: 0, b: 0, l: 0, r: 0 }} - SliderElement={DefaultSliderElement} - HoverElement={DefaultHoverElement} - WrapTabBody={DefaultWrapTabBody} - WrapTabHead={DefaultWrapTabHead} - WrapTabLabel={DefaultWrapTabLabel} - WrapTabPanel={DefaultWrapTabPanel} - minTabHeight={minTabHeight} - minTabWidth={minTabWidth} - tabHeadHeight={40} - bondWidth={bondWidth} - style={{ overflow: 'auto' }} - tabBodyElementClass="rounded-b-2 border-b-2 shadow-lg" - tabs={[ - [ - 'TS', - , - ], - [ - 'Html', - , - ], - ['preview', ], - [ - 'css', - , - ], - ['terminal', ], - ]} + defaultLayout={savedV1Layout} + onLayoutChange={(layout, { reason }) => + console.log(reason, layout) + } + components={components} + classNames={classNames} + minPanelSize={{ width: minTabWidth, height: minTabHeight }} + tabBarHeight={40} + splitterSize={bondWidth} + tabs={tabs} /> }
diff --git a/examples/react/src/components/layout-config.ts b/examples/react/src/components/layout-config.ts new file mode 100644 index 0000000..b4c6280 --- /dev/null +++ b/examples/react/src/components/layout-config.ts @@ -0,0 +1,9 @@ +import type { LayoutComponents, LayoutSlot } from '@dynamix-layout/react' +import { Splitter, Tab } from './wrapper' + +export const components: Partial = { Tab, Splitter } + +export const classNames: Partial> = { + tabBar: 'bg-gray-200 text-muted-foreground rounded-t-sm', + tabContent: 'rounded-b-2 border border-b-2 shadow-lg bg-background', +} diff --git a/examples/react/src/components/wrapper.tsx b/examples/react/src/components/wrapper.tsx index 5f8c4cd..f380140 100644 --- a/examples/react/src/components/wrapper.tsx +++ b/examples/react/src/components/wrapper.tsx @@ -1,285 +1,82 @@ import { cn } from '@/lib/utils' -import { forwardRef, type ReactNode, type HTMLAttributes } from 'react' +import { forwardRef, type HTMLAttributes, type Ref } from 'react' +import type { SplitterProps, TabProps } from '@dynamix-layout/react' -export const DefaultSliderElement = forwardRef< - HTMLDivElement, +/** A shadcn-style tab: the library passes ARIA, data-state and handlers in `props`. */ +export const Tab = forwardRef(function Tab( { - children?: ReactNode - direction?: boolean - } & HTMLAttributes ->(({ children, className, direction = false, ...props }, ref) => ( -
+ tab, + isActive, + isDragging, + onClose, + closeButtonProps, + className, + ...props + }, + ref +) { + return (
} + {...(props as HTMLAttributes)} className={cn( - 'flex items-center gap-1', - direction ? 'flex-col' : 'flex-row' + className, + 'inline-flex h-[calc(100%-6px)] items-center gap-1.5 rounded-md border border-transparent px-2 text-sm font-medium whitespace-nowrap text-foreground', + 'data-[state=active]:bg-background data-[state=active]:shadow-sm dark:data-[state=active]:bg-input/30', + 'focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50', + isActive && 'font-semibold', + isDragging && 'opacity-60' )} > - {!direction ? ( - - - - - - - - - ) : ( + × + + )} +
+ ) +}) + +/** A transparent splitter with a lucide grip, rotated for vertical rows. */ +export const Splitter = forwardRef( + function Splitter({ splitter, isDragging, className, ...props }, ref) { + return ( +
- )} -
- {children} -
-)) - -DefaultSliderElement.displayName = 'DefaultSliderElement' - -export const DefaultWrapTabLabel = forwardRef< - HTMLDivElement, - { children?: ReactNode; active?: boolean } & HTMLAttributes ->(({ children, active = false, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabLabel.displayName = 'DefaultWrapTabLabel' - -export const DefaultWrapTabBody = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabBody.displayName = 'DefaultWrapTabBody' - -export const DefaultWrapTabHead = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabHead.displayName = 'DefaultWrapTabHead' - -export const DefaultWrapTabPanel = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultWrapTabPanel.displayName = 'DefaultWrapTabPanel' - -export const DefaultHoverElement = forwardRef< - HTMLDivElement, - { children?: ReactNode } & HTMLAttributes ->(({ children, ...props }, ref) => ( -
- {children} -
-)) -DefaultHoverElement.displayName = 'DefaultHoverElement' - -export const RootSplitterHoverEl = forwardRef< - HTMLDivElement, - { - children?: ReactNode - area: 'left' | 'right' | 'top' | 'bottom' - size?: { w: string; h: string } - gap?: string - } & HTMLAttributes ->( - ( - { - children, - area, - size = { h: '20%', w: '6px' }, - gap = '0px', - ...props - }, - ref - ) => { - props.style = { ...props.style } - - if (area === 'left') { - props.style = { - borderTopRightRadius: '16px', - borderBottomRightRadius: '16px', - height: size.h, - left: gap, - width: size.w, - ...props.style, - } - props.style.top = `calc(50% - ${props.style.height} / 2)` - } else if (area === 'top') { - props.style = { - borderBottomLeftRadius: '16px', - borderBottomRightRadius: '16px', - width: size.h, - top: gap, - height: size.w, - ...props.style, - } - props.style.left = `calc(50% - ${props.style.width} / 2)` - } else if (area === 'right') { - props.style = { - borderTopLeftRadius: '16px', - borderBottomLeftRadius: '16px', - height: size.h, - right: gap, - width: size.w, - ...props.style, - } - props.style.top = `calc(50% - ${props.style.height} / 2)` - } else if (area === 'bottom') { - props.style = { - borderTopLeftRadius: '16px', - borderTopRightRadius: '16px', - width: size.h, - bottom: gap, - height: size.w, - ...props.style, - } - props.style.left = `calc(50% - ${props.style.width} / 2)` - } - - return ( -
- {children}
) } ) -RootSplitterHoverEl.displayName = 'RootSplitterHoverEl' diff --git a/packages/react/eslint.config.mjs b/packages/react/eslint.config.mjs index 19170f8..447a2c5 100644 --- a/packages/react/eslint.config.mjs +++ b/packages/react/eslint.config.mjs @@ -1,4 +1,15 @@ -import { config } from "@repo/eslint-config/react-internal"; +import { config } from '@repo/eslint-config/react-internal' /** @type {import("eslint").Linter.Config} */ -export default config; +export default [ + ...config, + { + rules: { + // Slot components destructure state props so they don't reach the DOM. + '@typescript-eslint/no-unused-vars': [ + 'warn', + { ignoreRestSiblings: true }, + ], + }, + }, +] diff --git a/packages/react/package.json b/packages/react/package.json index cbae04f..959ea99 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -45,6 +45,7 @@ "import": "./dist/index.es.js", "require": "./dist/index.cjs.js" }, + "./styles.css": "./dist/index.css", "./style.css": "./dist/index.css" }, "files": [ @@ -72,7 +73,8 @@ "build": "pnpm run clean && vite build && cp ../../LICENSE dist/LICENSE", "dev:local": "vite", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx" + "lint": "eslint . --ext ts,tsx --max-warnings 0", + "test": "vitest" }, "dependencies": { "@dynamix-layout/core": "workspace:*" diff --git a/packages/react/src/components/DropIndicator.tsx b/packages/react/src/components/DropIndicator.tsx new file mode 100644 index 0000000..dbc611c --- /dev/null +++ b/packages/react/src/components/DropIndicator.tsx @@ -0,0 +1,9 @@ +import { forwardRef } from 'react' +import type { DropIndicatorProps } from '../types' + +/** Default `DropIndicator`: highlights where a dragged tab would land. */ +export const DropIndicator = forwardRef( + function DropIndicator({ target, ...props }, ref) { + return
+ } +) diff --git a/packages/react/src/components/DynamixLayout.tsx b/packages/react/src/components/DynamixLayout.tsx new file mode 100644 index 0000000..9f5ce1c --- /dev/null +++ b/packages/react/src/components/DynamixLayout.tsx @@ -0,0 +1,427 @@ +import { + forwardRef, + memo, + useImperativeHandle, + useRef, + useState, + type CSSProperties, + type ForwardedRef, + type ForwardRefExoticComponent, + type RefAttributes, + type MouseEvent, + type PointerEvent, + type ReactElement, + type Ref, +} from 'react' +import { LayoutContext, fromPublicController } from '../context/layout-context' +import { useDynamixLayout } from '../hooks/use-dynamix-layout' +import { useSplitter } from '../hooks/use-splitter' +import { useTab } from '../hooks/use-tab' +import { useTabset } from '../hooks/use-tabset' +import type { + DynamixLayoutHandle, + DynamixLayoutProps, + LayoutComponents, + LayoutSlot, +} from '../types' +import { DropIndicator } from './DropIndicator' +import { LiveRegion } from './LiveRegion' +import { Panel } from './Panel' +import { RootDropZone } from './RootDropZone' +import { Splitter } from './Splitter' +import { Tab } from './Tab' +import { TabBar } from './TabBar' +import { TabContent } from './TabContent' +import { TabsetToolbar } from './TabsetToolbar' + +const DEFAULT_COMPONENTS: LayoutComponents = { + Panel, + TabBar, + Tab, + TabContent, + Splitter, + DropIndicator, + RootDropZone, + TabsetToolbar, +} + +/** Rendering settings shared by the parts below. */ +interface Parts { + components: LayoutComponents + classNames: Partial> + styles: Partial> + showTabBar: boolean + allowMaximize: boolean + allowFold: boolean +} + +const slot = (parts: Parts, name: LayoutSlot) => ({ + className: parts.classNames[name], + style: parts.styles[name], +}) + +const TabView = memo(function TabView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, isDragging, close, getTabProps } = useTab(tabId) + if (!item) return null + const props = getTabProps({ + className: + [parts.classNames.tab, isActive && parts.classNames.activeTab] + .filter(Boolean) + .join(' ') || undefined, + style: { + ...parts.styles.tab, + ...(isActive ? parts.styles.activeTab : {}), + }, + }) + const closeButtonProps = item.closable + ? { + className: ['dx-tab-close', parts.classNames.tabClose] + .filter(Boolean) + .join(' '), + style: parts.styles.tabClose, + 'aria-label': `Close ${typeof item.title === 'string' ? item.title : item.id}`, + tabIndex: -1, + onPointerDown: (event: PointerEvent) => event.stopPropagation(), + onClick: (event: MouseEvent) => { + event.stopPropagation() + close() + }, + } + : undefined + const Component = parts.components.Tab + return ( + + ) +}) + +const TabsetView = memo(function TabsetView({ + tabsetId, + parts, +}: { + tabsetId: string + parts: Parts +}) { + const { + tabset, + isRotated, + getPanelProps, + getTabBarProps, + toggleMaximize, + toggleFold, + } = useTabset(tabsetId) + if (!tabset) return null + const { + Panel: PanelComponent, + TabBar: TabBarComponent, + TabsetToolbar: Toolbar, + } = parts.components + const canMaximize = parts.allowMaximize && tabset.canMaximize + const canFold = parts.allowFold && tabset.canFold && !tabset.isMaximized + return ( + <> + + {parts.showTabBar && ( + + {tabset.tabIds.map((tabId) => ( + + ))} + {(canMaximize || canFold) && ( + + )} + + )} + + ) +}) + +const ContentView = memo(function ContentView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, getTabContentProps } = useTab(tabId) + if (!item) return null + const Component = parts.components.TabContent + return ( + + {item.content} + + ) +}) + +const SplitterView = memo(function SplitterView({ + splitterId, + parts, +}: { + splitterId: string + parts: Parts +}) { + const { splitter, isDragging, getSplitterProps } = useSplitter(splitterId) + if (!splitter) return null + const Component = parts.components.Splitter + return ( + + ) +}) + +function DynamixLayoutImpl( + props: DynamixLayoutProps, + ref: ForwardedRef +): ReactElement { + const { + tabs, + defaultLayout, + layout, + onLayoutChange, + onTabClose, + minPanelSize, + splitterSize, + tabBarHeight, + showTabBar = true, + padding, + allowMaximize = true, + allowFold = true, + maximizeOnDoubleClick, + keyboardShortcuts, + tabActivation, + resizeThrottleMs, + components, + classNames = {}, + styles = {}, + className, + style, + ...rest + } = props + const result = useDynamixLayout({ + tabs, + defaultLayout, + layout, + onLayoutChange, + onTabClose, + minPanelSize, + splitterSize, + tabBarHeight, + showTabBar, + padding, + allowMaximize, + allowFold, + maximizeOnDoubleClick, + keyboardShortcuts, + tabActivation, + resizeThrottleMs, + id: rest.id, + }) + const { + controller, + getRootProps, + tabsets, + splitters, + tabIds, + dropIndicator, + rootDropZones, + actions, + } = result + + // `components`, `classNames` and `styles` are usually inline objects; only their contents matter. + const [parts, setParts] = useState(() => ({ + components: { ...DEFAULT_COMPONENTS, ...components }, + classNames, + styles, + showTabBar, + allowMaximize, + allowFold: allowFold && showTabBar, + })) + const nextParts: Parts = { + components: { ...DEFAULT_COMPONENTS, ...components }, + classNames, + styles, + showTabBar, + allowMaximize, + allowFold: allowFold && showTabBar, + } + if (!sameParts(parts, nextParts)) setParts(nextParts) + + const rootElement = useRef(null) + useImperativeHandle( + ref, + () => ({ + ...actions, + get element() { + return rootElement.current + }, + focusTab(tabId: string) { + document + .getElementById( + fromPublicController(controller).core.ids.tab(tabId) + ) + ?.focus() + }, + }), + [actions, controller] + ) + + const context = fromPublicController(controller) + const rootProps = getRootProps({ + ...rest, + className: + [className, classNames.root].filter(Boolean).join(' ') || undefined, + style: { ...style, ...styles.root }, + ref: rootElement as Ref, + }) + const { DropIndicator: Indicator, RootDropZone: Zone } = parts.components + + return ( + +
+ {tabsets.map((tabset) => ( + + ))} + {tabIds.map((tabId) => ( + + ))} + {splitters.map((splitter) => ( + + ))} + {rootDropZones.map((zone) => ( + + ))} + {dropIndicator && ( + + )} + +
+
+ ) +} + +function sameParts(a: Parts, b: Parts): boolean { + const shallow = (x: object, y: object) => { + const keys = Object.keys(x) + return ( + keys.length === Object.keys(y).length && + keys.every( + (k) => + (x as Record)[k] === + (y as Record)[k] + ) + ) + } + return ( + a.showTabBar === b.showTabBar && + a.allowMaximize === b.allowMaximize && + a.allowFold === b.allowFold && + shallow(a.components, b.components) && + shallow(a.classNames, b.classNames) && + Object.keys({ ...a.styles, ...b.styles }).every((k) => + shallow( + (a.styles as Record)[k] ?? {}, + (b.styles as Record)[k] ?? {} + ) + ) + ) +} + +/** + * A docking layout: tabs in tabsets, split by draggable splitters, with tab + * drag-and-drop, maximize and fold. Most apps only need this component. + * + * Tab contents are rendered once and never remount when tabs move. Save the + * layout from `onLayoutChange` and pass it back as `defaultLayout` (or control + * it with `layout`). + * + * @example + * }, + * { id: 'terminal', title: 'Terminal', content: }, + * ]} + * defaultLayout={saved} + * onLayoutChange={(layout) => save(layout)} + * /> + */ +export const DynamixLayout: ForwardRefExoticComponent< + DynamixLayoutProps & RefAttributes +> = forwardRef(DynamixLayoutImpl) + +DynamixLayout.displayName = 'DynamixLayout' diff --git a/packages/react/src/components/LiveRegion.tsx b/packages/react/src/components/LiveRegion.tsx new file mode 100644 index 0000000..48896b7 --- /dev/null +++ b/packages/react/src/components/LiveRegion.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState, type ReactElement } from 'react' +import { useLayoutContext } from '../context/layout-context' + +/** Polite live region for screen-reader announcements (keyboard move mode). */ +export function LiveRegion(): ReactElement { + const { core } = useLayoutContext('LiveRegion') + const [message, setMessage] = useState('') + useEffect(() => core.onAnnounce(setMessage), [core]) + return ( +
+ {message} +
+ ) +} diff --git a/packages/react/src/components/Panel.tsx b/packages/react/src/components/Panel.tsx new file mode 100644 index 0000000..5c0098d --- /dev/null +++ b/packages/react/src/components/Panel.tsx @@ -0,0 +1,10 @@ +import { forwardRef } from 'react' +import type { PanelProps } from '../types' + +/** Default `Panel`: the box of a tabset, behind its tab bar and content. */ +export const Panel = forwardRef(function Panel( + { tabset, ...props }, + ref +) { + return
+}) diff --git a/packages/react/src/components/RootDropZone.tsx b/packages/react/src/components/RootDropZone.tsx new file mode 100644 index 0000000..40ac0df --- /dev/null +++ b/packages/react/src/components/RootDropZone.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from 'react' +import type { RootDropZoneProps } from '../types' + +/** Default `RootDropZone`: a handle at one edge of the layout, shown while dragging a tab. */ +export const RootDropZone = forwardRef( + function RootDropZone({ side, isActive, ...props }, ref) { + return ( +
+ ) + } +) diff --git a/packages/react/src/components/Splitter.tsx b/packages/react/src/components/Splitter.tsx new file mode 100644 index 0000000..12b7541 --- /dev/null +++ b/packages/react/src/components/Splitter.tsx @@ -0,0 +1,9 @@ +import { forwardRef } from 'react' +import type { SplitterProps } from '../types' + +/** Default `Splitter`: a bar with a grip (drawn in CSS). */ +export const Splitter = forwardRef( + function Splitter({ splitter, isDragging, ...props }, ref) { + return
+ } +) diff --git a/packages/react/src/components/Tab.tsx b/packages/react/src/components/Tab.tsx new file mode 100644 index 0000000..9d3a632 --- /dev/null +++ b/packages/react/src/components/Tab.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type HTMLAttributes, type Ref } from 'react' +import type { TabProps } from '../types' + +/** Default `Tab`: the title, plus a close button when the tab is closable. */ +export const Tab = forwardRef(function Tab( + { tab, isActive, isDragging, onClose, closeButtonProps, ...props }, + ref +) { + return ( +
} + {...(props as HTMLAttributes)} + > + {tab.title ?? tab.id} + {closeButtonProps && ( + + )} +
+ ) +}) diff --git a/packages/react/src/components/TabBar.tsx b/packages/react/src/components/TabBar.tsx new file mode 100644 index 0000000..6b326b7 --- /dev/null +++ b/packages/react/src/components/TabBar.tsx @@ -0,0 +1,14 @@ +import { forwardRef } from 'react' +import type { TabBarProps } from '../types' + +/** Default `TabBar`: a scrollable row of tabs with the toolbar at its end. */ +export const TabBar = forwardRef(function TabBar( + { tabset, isRotated, children, ...props }, + ref +) { + return ( +
+ {children} +
+ ) +}) diff --git a/packages/react/src/components/TabContent.tsx b/packages/react/src/components/TabContent.tsx new file mode 100644 index 0000000..051079c --- /dev/null +++ b/packages/react/src/components/TabContent.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react' +import type { TabContentProps } from '../types' + +/** Default `TabContent`: holds a tab's content; positioned by the library. */ +export const TabContent = forwardRef( + function TabContent({ tab, isActive, children, ...props }, ref) { + return ( +
+ {children} +
+ ) + } +) diff --git a/packages/react/src/components/TabsetToolbar.tsx b/packages/react/src/components/TabsetToolbar.tsx new file mode 100644 index 0000000..3284345 --- /dev/null +++ b/packages/react/src/components/TabsetToolbar.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, type ReactElement } from 'react' +import type { TabsetToolbarProps } from '../types' + +const ICON_MAXIMIZE = + 'M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3' +const ICON_RESTORE = + 'M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3' +const ICON_CHEVRON = 'm15 18-6-6 6-6' + +function Icon({ path, rotate }: { path: string; rotate: number }) { + return ( + + ) +} + +/** + * Default `TabsetToolbar`: maximize/restore and fold/unfold buttons at the end + * of a tab bar, shown on hover and always on folded strips. + */ +export function TabsetToolbar({ + tabset, + isRotated, + canMaximize, + canFold, + onToggleMaximize, + onToggleFold, + className, + style, + buttonClassName, + buttonStyle, +}: TabsetToolbarProps): ReactElement { + const toolbarRef = useRef(null) + const { isMaximized, isFolded } = tabset + // Fold points back along the row (left or up), unfold the other way; inside + // a rotated strip the icons turn back so they read upright. + const chevronTurn = + (tabset.parentDirection === 'horizontal' ? 0 : 90) + + (isFolded ? 180 : 0) + const upright = isRotated ? -90 : 0 + + // Stay pinned to the visible end of a tab bar that scrolls. + useEffect(() => { + const toolbar = toolbarRef.current + const bar = toolbar?.parentElement + if (!toolbar || !bar) return + const pin = () => { + toolbar.style.transform = `translateX(${bar.scrollLeft}px)` + } + pin() + bar.addEventListener('scroll', pin, { passive: true }) + return () => bar.removeEventListener('scroll', pin) + }, []) + + const buttonClass = ['dx-toolbar-button', buttonClassName] + .filter(Boolean) + .join(' ') + return ( +
event.stopPropagation()} + > + {canMaximize && ( + + )} + {canFold && ( + + )} +
+ ) +} diff --git a/packages/react/src/context/DynamixLayoutProvider.tsx b/packages/react/src/context/DynamixLayoutProvider.tsx new file mode 100644 index 0000000..886bbd7 --- /dev/null +++ b/packages/react/src/context/DynamixLayoutProvider.tsx @@ -0,0 +1,34 @@ +import type { ReactElement, ReactNode } from 'react' +import type { LayoutController } from '../types' +import { LayoutContext, fromPublicController } from './layout-context' + +/** Props of {@link DynamixLayoutProvider}. */ +export interface DynamixLayoutProviderProps { + /** The `controller` returned by `useDynamixLayout`. */ + controller: LayoutController + children?: ReactNode +} + +/** + * Makes a layout created with `useDynamixLayout` available to `useTab`, + * `useTabset`, `useSplitter`, `useLayoutState` and `useLayoutActions`. + * `` renders one for you. + * + * @example + * const layout = useDynamixLayout({ tabs }) + * return ( + * + *
…
+ *
+ * ) + */ +export function DynamixLayoutProvider({ + controller, + children, +}: DynamixLayoutProviderProps): ReactElement { + return ( + + {children} + + ) +} diff --git a/packages/react/src/context/layout-context.ts b/packages/react/src/context/layout-context.ts new file mode 100644 index 0000000..72c71d4 --- /dev/null +++ b/packages/react/src/context/layout-context.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react' +import type { LayoutController as Core } from '../internal/controller' +import type { TabRegistry } from '../internal/tab-registry' +import type { LayoutController } from '../types' + +/** What the hooks share. Stable for the layout's lifetime, so it never re-renders consumers. */ +export interface LayoutContextValue { + readonly core: Core + readonly tabs: TabRegistry +} + +export const LayoutContext = createContext(null) + +/** The public, opaque controller is the context value itself. */ +export const toPublicController = (value: LayoutContextValue) => + value as unknown as LayoutController + +export const fromPublicController = (controller: LayoutController) => + controller as unknown as LayoutContextValue + +/** + * Reads the layout context. + * @throws When used outside `` or ``. + */ +export function useLayoutContext(hookName: string): LayoutContextValue { + const value = useContext(LayoutContext) + if (!value) { + throw new Error( + `\`${hookName}\` must be used inside or .` + ) + } + return value +} diff --git a/packages/react/src/hooks/use-drag-state.ts b/packages/react/src/hooks/use-drag-state.ts new file mode 100644 index 0000000..b238777 --- /dev/null +++ b/packages/react/src/hooks/use-drag-state.ts @@ -0,0 +1,13 @@ +import type { DragState } from '@dynamix-layout/core' +import { useLayoutState } from './use-layout-state' + +/** + * The drag in progress (its source, target and indicator rect), or `null`. + * Changes when a drag starts or ends and when its target changes, not on every + * pointer move. + * + * @throws When used outside a layout. + */ +export function useDragState(): DragState | null { + return useLayoutState((snapshot) => snapshot.drag) +} diff --git a/packages/react/src/hooks/use-dynamix-layout.ts b/packages/react/src/hooks/use-dynamix-layout.ts new file mode 100644 index 0000000..879d1c6 --- /dev/null +++ b/packages/react/src/hooks/use-dynamix-layout.ts @@ -0,0 +1,175 @@ +import type { Side, TabsetState } from '@dynamix-layout/core' +import { + useCallback, + useId, + useMemo, + type FocusEvent, + type KeyboardEvent, + type PointerEvent, +} from 'react' +import { toPublicController } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' +import { handleRootKeyDown } from '../internal/keyboard' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import { useController } from '../internal/use-controller' +import type { + PropGetter, + SlotProps, + UseDynamixLayoutOptions, + UseDynamixLayoutResult, +} from '../types' +import { mergeProps } from '../utils/merge-props' +import { createActions } from './use-layout-actions' +import { useSnapshotSelector } from './use-layout-state' + +const SIDES: readonly Side[] = ['left', 'right', 'top', 'bottom'] + +/** Remembers which tabset the user is working in, for the keyboard shortcuts. */ +function trackFocusedTabset(core: Core, target: EventTarget) { + const element = + target instanceof Element + ? target.closest('[data-dx-slot][data-dx-id]') + : null + const id = element?.dataset.dxId + if (!element || !id) return + const slot = element.dataset.dxSlot + if (slot === 'panel' || slot === 'tabBar') core.focusedTabsetId = id + else if (slot === 'tab' || slot === 'tabContent') + core.focusedTabsetId = + core.engine.getSnapshot().tabs.get(id)?.tabsetId ?? + core.focusedTabsetId +} + +/** Values of a map as an array whose identity follows the map's. */ +const useValues = (map: ReadonlyMap) => + useMemo(() => [...map.values()], [map]) + +/** + * The headless layout: state, prop getters and actions for building a fully + * custom UI. `` is built on it. + * + * Render every tab's content once, in `tabIds` order, as direct children of + * the root (see `useTab().getTabContentProps`); the library positions it, so + * moving a tab never remounts its content. + * + * @param options - Tabs (content optional) and behaviour options. + * @returns Lists that change only on structural changes, stable prop getters + * and actions, and the `controller` to pass to ``. + * @example + * const { controller, getRootProps, tabsets, splitters, tabIds } = useDynamixLayout({ tabs }) + */ +export function useDynamixLayout( + options: UseDynamixLayoutOptions +): UseDynamixLayoutResult { + const reactId = useId() + const { core, contextValue, rootRef, isMeasured, tabIds } = useController( + options, + options.id ?? reactId + ) + + const tabsets: readonly TabsetState[] = useValues( + useSnapshotSelector(core, (s) => s.tabsets) + ) + const splitters = useValues(useSnapshotSelector(core, (s) => s.splitters)) + const tabs = useValues(useSnapshotSelector(core, (s) => s.tabs)) + const drag = useSnapshotSelector(core, (s) => s.drag) + + const getRootProps = useCallback( + (props?: SlotProps) => + mergeProps(props, { + ref: rootRef, + className: 'dx-root', + 'data-dx-measuring': isMeasured ? undefined : '', + 'data-dx-dragging': core.engine.getSnapshot().drag + ? '' + : undefined, + onKeyDown: (event: KeyboardEvent) => + handleRootKeyDown(core, event), + onPointerDownCapture: (event: PointerEvent) => + trackFocusedTabset(core, event.target), + onFocusCapture: (event: FocusEvent) => + trackFocusedTabset(core, event.target), + }), + [core, rootRef, isMeasured] + ) as PropGetter + + const indicatorRef = useCallback( + (el: HTMLElement | null) => + core.register('dropIndicator', 'indicator', el), + [core] + ) + const zoneRefs = useMemo( + () => + Object.fromEntries( + SIDES.map((side) => [ + side, + (el: HTMLElement | null) => + core.register('rootDropZone', side, el), + ]) + ), + [core] + ) + + const dropIndicator = useMemo(() => { + if (!drag?.target) return null + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'dropIndicator', + 'indicator' + ) + return { + target: drag.target, + props: { + ref: indicatorRef, + className: 'dx-drop-indicator', + style: positionStyle(geometry), + 'aria-hidden': true, + 'data-dx-slot': 'dropIndicator', + } satisfies SlotProps, + } + }, [core, drag, indicatorRef]) + + const rootDropZones = useMemo(() => { + if (!drag || drag.source.type === 'splitter') return [] + const snapshot = core.engine.getSnapshot() + return SIDES.map((side) => ({ + side, + isActive: + drag.target?.type === 'root' && drag.target.position === side, + props: { + ref: zoneRefs[side], + className: 'dx-root-drop-zone', + style: positionStyle( + getSlotGeometry( + snapshot, + core.options, + 'rootDropZone', + side + ) + ), + 'aria-hidden': true, + 'data-dx-slot': 'rootDropZone', + 'data-dx-side': side, + } satisfies SlotProps, + })) + }, [core, drag, zoneRefs]) + + const actions = useMemo(() => createActions(core), [core]) + const controller = useMemo( + () => toPublicController(contextValue), + [contextValue] + ) + + return { + controller, + getRootProps, + tabsets, + splitters, + tabs, + tabIds, + dropIndicator, + rootDropZones, + actions, + } +} diff --git a/packages/react/src/hooks/use-layout-actions.ts b/packages/react/src/hooks/use-layout-actions.ts new file mode 100644 index 0000000..7d28524 --- /dev/null +++ b/packages/react/src/hooks/use-layout-actions.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { useLayoutContext } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' +import type { LayoutActions } from '../types' + +/** Builds the stable actions object of a layout. */ +export function createActions(core: Core): LayoutActions { + const { engine } = core + return { + moveTab: engine.moveTab, + moveTabset: engine.moveTabset, + selectTab: engine.selectTab, + maximize: engine.maximize, + restore: engine.restore, + toggleMaximize: engine.toggleMaximize, + fold: engine.fold, + unfold: engine.unfold, + toggleFold: engine.toggleFold, + reset: engine.reset, + toJSON: engine.toJSON, + getSnapshot: engine.getSnapshot, + } +} + +/** + * Layout actions from anywhere inside a layout. The object and every function + * in it keep their identity for the layout's lifetime. + * + * @throws When used outside a layout. + * @example + * const { moveTab, selectTab } = useLayoutActions() + * moveTab('terminal', { type: 'root', position: 'bottom' }) + */ +export function useLayoutActions(): LayoutActions { + const { core } = useLayoutContext('useLayoutActions') + return useMemo(() => createActions(core), [core]) +} diff --git a/packages/react/src/hooks/use-layout-state.ts b/packages/react/src/hooks/use-layout-state.ts new file mode 100644 index 0000000..8b94611 --- /dev/null +++ b/packages/react/src/hooks/use-layout-state.ts @@ -0,0 +1,48 @@ +import type { LayoutSnapshot } from '@dynamix-layout/core' +import { useRef, useSyncExternalStore } from 'react' +import { useLayoutContext } from '../context/layout-context' +import type { LayoutController as Core } from '../internal/controller' + +/** + * Subscribes to a slice of a layout's snapshot. Re-renders only when the + * selected value changes according to `isEqual`. The selection is cached per + * snapshot, so React's repeated `getSnapshot` calls return the same value. + */ +export function useSnapshotSelector( + core: Core, + selector: (snapshot: LayoutSnapshot) => T, + isEqual: (a: T, b: T) => boolean = Object.is +): T { + const cache = useRef<{ snapshot: LayoutSnapshot; value: T } | null>(null) + const select = () => { + const snapshot = core.engine.getSnapshot() + const cached = cache.current + if (cached && cached.snapshot === snapshot) return cached.value + const next = selector(snapshot) + const value = + cached && isEqual(cached.value, next) ? cached.value : next + cache.current = { snapshot, value } + return value + } + return useSyncExternalStore(core.engine.subscribe, select, select) +} + +/** + * Reads part of the layout state from anywhere inside a layout, re-rendering + * only when that part changes. Snapshots share unchanged parts by reference, + * so selecting an object (`s.tabsets.get(id)`) is cheap and precise. + * + * @param selector - Picks the value from the snapshot. + * @param isEqual - Decides whether the value changed. @default Object.is + * @returns The selected value. + * @throws When used outside a layout. + * @example + * const activeTabId = useLayoutState((s) => s.tabsets.get(tabsetId)?.activeTabId) + */ +export function useLayoutState( + selector: (snapshot: LayoutSnapshot) => T, + isEqual?: (a: T, b: T) => boolean +): T { + const { core } = useLayoutContext('useLayoutState') + return useSnapshotSelector(core, selector, isEqual) +} diff --git a/packages/react/src/hooks/use-splitter.ts b/packages/react/src/hooks/use-splitter.ts new file mode 100644 index 0000000..be824a2 --- /dev/null +++ b/packages/react/src/hooks/use-splitter.ts @@ -0,0 +1,113 @@ +import type { Direction, SplitterState } from '@dynamix-layout/core' +import { useCallback, type KeyboardEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { splitterAria } from '../internal/splitter-aria' +import { nodeLabel } from '../internal/tab-label' +import { handleSplitterKeyDown } from '../internal/keyboard' +import { beginPointerDrag } from '../internal/pointer-drag' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useSplitter` returns. Functions are stable. */ +export interface UseSplitterResult { + /** `undefined` once the splitter no longer exists. */ + splitter: SplitterState | undefined + isDragging: boolean + /** Direction of the row it divides; a `'horizontal'` row has a vertical bar. */ + direction: Direction | undefined + /** + * Size of the panel before the splitter and its limits, in px, as of the + * last render. They aren't reactive (they change on every drag frame); the + * `aria-value*` attributes are kept current by the library. + */ + value: number + min: number + max: number + /** Props for the splitter: `role="separator"`, ARIA values, pointer drag and arrow keys. */ + getSplitterProps: PropGetter +} + +/** + * State and prop getters for one splitter. + * + * @param splitterId - The splitter's id (from `useDynamixLayout().splitters`). + * @throws When used outside a layout. + */ +export function useSplitter(splitterId: string): UseSplitterResult { + const { core, tabs } = useLayoutContext('useSplitter') + const splitter = useSnapshotSelector(core, (s) => + s.splitters.get(splitterId) + ) + const isDragging = useSnapshotSelector( + core, + (s) => + s.drag?.source.type === 'splitter' && + s.drag.source.splitterId === splitterId + ) + const ref = useCallback( + (el: HTMLElement | null) => core.register('splitter', splitterId, el), + [core, splitterId] + ) + + const getSplitterProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.splitters.get(splitterId) + const geometry = getSlotGeometry( + snapshot, + core.options, + 'splitter', + splitterId + ) + const { percent } = splitterAria(core, splitterId) + const label = (id: string | undefined) => + id ? nodeLabel(snapshot, id, tabs) : 'panel' + return mergeProps(props, { + ref, + role: 'separator', + 'aria-orientation': + state?.direction === 'horizontal' + ? 'vertical' + : 'horizontal', + 'aria-valuenow': percent?.now, + 'aria-valuemin': percent?.min, + 'aria-valuemax': percent?.max, + 'aria-controls': + state && snapshot.tabsets.has(state.beforeId) + ? core.ids.panel(state.beforeId) + : undefined, + 'aria-label': `Resize ${label(state?.beforeId)} and ${label(state?.afterId)}`, + 'aria-disabled': state?.isLocked || undefined, + tabIndex: geometry?.isHidden ? -1 : 0, + className: 'dx-splitter', + style: positionStyle(geometry), + 'data-dx-slot': 'splitter', + 'data-dx-id': splitterId, + 'data-dx-direction': state?.direction, + 'data-dx-locked': state?.isLocked ? '' : undefined, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + onPointerDown: (event: PointerEvent) => + beginPointerDrag(core, event, { + type: 'splitter', + splitterId, + }), + onKeyDown: (event: KeyboardEvent) => + handleSplitterKeyDown(core, event, splitterId), + }) + }, + [core, tabs, splitterId, ref] + ) as PropGetter + + const bounds = core.engine.getSplitterBounds(splitterId) + return { + splitter, + isDragging, + direction: splitter?.direction, + value: bounds?.value ?? 0, + min: bounds?.min ?? 0, + max: bounds?.max ?? 0, + getSplitterProps, + } +} diff --git a/packages/react/src/hooks/use-tab.ts b/packages/react/src/hooks/use-tab.ts new file mode 100644 index 0000000..b2692c0 --- /dev/null +++ b/packages/react/src/hooks/use-tab.ts @@ -0,0 +1,122 @@ +import type { TabState } from '@dynamix-layout/core' +import { useCallback, type KeyboardEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { handleTabKeyDown } from '../internal/keyboard' +import { beginPointerDrag } from '../internal/pointer-drag' +import { useTabItem } from '../internal/tab-registry' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { HeadlessTabItem, PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useTab` returns. Functions are stable. */ +export interface UseTabResult { + /** The tab's layout state; `undefined` while it isn't placed. */ + tab: TabState | undefined + /** The item you passed in `tabs`. */ + item: HeadlessTabItem | undefined + isActive: boolean + isDragging: boolean + select(): void + /** Asks to close the tab (`onTabClose`); does nothing unless it's closable. */ + close(): void + /** Props for the tab: `role="tab"`, ARIA state, roving tabindex, click, drag and keys. */ + getTabProps: PropGetter + /** Props for the tab's content: `role="tabpanel"`, positioned by the library. */ + getTabContentProps: PropGetter +} + +/** + * State and prop getters for one tab. + * + * @param tabId - The tab's id. + * @throws When used outside a layout. + */ +export function useTab(tabId: string): UseTabResult { + const { core, tabs } = useLayoutContext('useTab') + const item = useTabItem(tabs, tabId) + const tab = useSnapshotSelector(core, (s) => s.tabs.get(tabId)) + const isDragging = useSnapshotSelector( + core, + (s) => s.drag?.source.type === 'tab' && s.drag.source.tabId === tabId + ) + const isClosable = !!item?.closable + + const contentRef = useCallback( + (el: HTMLElement | null) => core.register('tabContent', tabId, el), + [core, tabId] + ) + const select = useCallback( + () => void core.engine.selectTab(tabId), + [core, tabId] + ) + const close = useCallback(() => { + if (isClosable) core.options.onTabClose?.(tabId) + }, [core, tabId, isClosable]) + + const getTabProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.tabs.get(tabId) + const tabset = state && snapshot.tabsets.get(state.tabsetId) + const isRotated = + !!tabset?.isFolded && tabset.parentDirection === 'horizontal' + const active = !!state?.isActive + return mergeProps(props, { + role: 'tab', + id: core.ids.tab(tabId), + 'aria-selected': active, + 'aria-controls': core.ids.tabContent(tabId), + tabIndex: active ? 0 : -1, + className: 'dx-tab', + 'data-dx-slot': 'tab', + 'data-dx-id': tabId, + 'data-state': active ? 'active' : 'inactive', + onClick: () => core.engine.selectTab(tabId), + onPointerDown: (event: PointerEvent) => + beginPointerDrag(core, event, { type: 'tab', tabId }), + onKeyDown: (event: KeyboardEvent) => + handleTabKeyDown(core, event, tabId, { + isRotated, + isClosable, + }), + }) + }, + [core, tabId, isClosable] + ) as PropGetter + + const getTabContentProps = useCallback( + (props?: SlotProps) => { + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'tabContent', + tabId + ) + return mergeProps(props, { + ref: contentRef, + role: 'tabpanel', + id: core.ids.tabContent(tabId), + 'aria-labelledby': core.ids.tab(tabId), + tabIndex: 0, + className: 'dx-tab-content', + style: positionStyle(geometry), + 'data-dx-slot': 'tabContent', + 'data-dx-id': tabId, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + }) + }, + [core, tabId, contentRef] + ) as PropGetter + + return { + tab, + item, + isActive: !!tab?.isActive, + isDragging, + select, + close, + getTabProps, + getTabContentProps, + } +} diff --git a/packages/react/src/hooks/use-tabset.ts b/packages/react/src/hooks/use-tabset.ts new file mode 100644 index 0000000..882ea8f --- /dev/null +++ b/packages/react/src/hooks/use-tabset.ts @@ -0,0 +1,135 @@ +import type { TabsetState } from '@dynamix-layout/core' +import { useCallback, type MouseEvent, type PointerEvent } from 'react' +import { useLayoutContext } from '../context/layout-context' +import { beginPointerDrag } from '../internal/pointer-drag' +import { getSlotGeometry, positionStyle } from '../internal/slot-geometry' +import type { PropGetter, SlotProps } from '../types' +import { mergeProps } from '../utils/merge-props' +import { useSnapshotSelector } from './use-layout-state' + +/** What `useTabset` returns. Functions are stable. */ +export interface UseTabsetResult { + /** `undefined` once the tabset no longer exists. */ + tabset: TabsetState | undefined + /** The tab bar is drawn as a vertical strip (folded in a side-by-side row). */ + isRotated: boolean + /** Props for the tabset's box (`role` none; positioned by the library). */ + getPanelProps: PropGetter + /** Props for the tab bar: `role="tablist"`, drag-to-move the whole tabset, double-click to maximize. */ + getTabBarProps: PropGetter + toggleMaximize(): void + toggleFold(): void +} + +const isInteractive = (target: EventTarget) => + target instanceof Element && + !!target.closest('[data-dx-slot="tab"], button, a, input, select, textarea') + +/** + * State and prop getters for one tabset. + * + * @param tabsetId - The tabset's id. + * @throws When used outside a layout. + */ +export function useTabset(tabsetId: string): UseTabsetResult { + const { core } = useLayoutContext('useTabset') + const tabset = useSnapshotSelector(core, (s) => s.tabsets.get(tabsetId)) + const isRotated = + !!tabset?.isFolded && tabset.parentDirection === 'horizontal' + + const panelRef = useCallback( + (el: HTMLElement | null) => core.register('panel', tabsetId, el), + [core, tabsetId] + ) + const barRef = useCallback( + (el: HTMLElement | null) => core.register('tabBar', tabsetId, el), + [core, tabsetId] + ) + + const getPanelProps = useCallback( + (props?: SlotProps) => { + const geometry = getSlotGeometry( + core.engine.getSnapshot(), + core.options, + 'panel', + tabsetId + ) + return mergeProps(props, { + ref: panelRef, + id: core.ids.panel(tabsetId), + className: 'dx-panel', + style: positionStyle(geometry), + 'data-dx-slot': 'panel', + 'data-dx-id': tabsetId, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + }) + }, + [core, tabsetId, panelRef] + ) as PropGetter + + const getTabBarProps = useCallback( + (props?: SlotProps) => { + const snapshot = core.engine.getSnapshot() + const state = snapshot.tabsets.get(tabsetId) + const geometry = getSlotGeometry( + snapshot, + core.options, + 'tabBar', + tabsetId + ) + const rotated = !!geometry?.isRotated + return mergeProps(props, { + ref: barRef, + role: 'tablist', + 'aria-orientation': rotated ? 'vertical' : 'horizontal', + className: 'dx-tab-bar', + style: positionStyle(geometry), + 'data-dx-slot': 'tabBar', + 'data-dx-id': tabsetId, + 'data-dx-folded': state?.isFolded ? '' : undefined, + 'data-dx-maximized': state?.isMaximized ? '' : undefined, + 'data-dx-rotated': rotated ? '' : undefined, + 'data-dx-hidden': geometry?.isHidden ? '' : undefined, + onPointerDown: (event: PointerEvent) => { + if (!isInteractive(event.target)) + beginPointerDrag(core, event, { + type: 'tabset', + tabsetId, + }) + }, + onDoubleClick: (event: MouseEvent) => { + const { allowMaximize, maximizeOnDoubleClick } = + core.options + if ( + allowMaximize && + maximizeOnDoubleClick && + !( + event.target instanceof Element && + event.target.closest('button') + ) + ) + core.engine.toggleMaximize(tabsetId) + }, + }) + }, + [core, tabsetId, barRef] + ) as PropGetter + + const toggleMaximize = useCallback( + () => void core.engine.toggleMaximize(tabsetId), + [core, tabsetId] + ) + const toggleFold = useCallback( + () => void core.engine.toggleFold(tabsetId), + [core, tabsetId] + ) + + return { + tabset, + isRotated, + getPanelProps, + getTabBarProps, + toggleMaximize, + toggleFold, + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 614fa39..c004cad 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,36 @@ -export * from './components/Layout' -export * from './hooks/useLayout' -export * from './components/Default' -export * from './types' +import './styles.css' +export { DynamixLayout } from './components/DynamixLayout' +export { + DynamixLayoutProvider, + type DynamixLayoutProviderProps, +} from './context/DynamixLayoutProvider' +export { useDynamixLayout } from './hooks/use-dynamix-layout' +export { useTabset, type UseTabsetResult } from './hooks/use-tabset' +export { useTab, type UseTabResult } from './hooks/use-tab' +export { useSplitter, type UseSplitterResult } from './hooks/use-splitter' +export { useLayoutState } from './hooks/use-layout-state' +export { useLayoutActions } from './hooks/use-layout-actions' +export { useDragState } from './hooks/use-drag-state' +export { Panel } from './components/Panel' +export { TabBar } from './components/TabBar' +export { Tab } from './components/Tab' +export { TabContent } from './components/TabContent' +export { Splitter } from './components/Splitter' +export { DropIndicator } from './components/DropIndicator' +export { RootDropZone } from './components/RootDropZone' +export { TabsetToolbar } from './components/TabsetToolbar' +export type * from './types' +export { + isLayoutV1, + migrateLayoutFromV1, + DynamixLayoutError, + type DropTarget, + type LayoutJSON, + type LayoutTreeV1, + type LayoutSnapshot, + type LayoutChangeReason, + type TabsetState, + type TabState, + type SplitterState, + type Side, +} from '@dynamix-layout/core' diff --git a/packages/react/src/internal/container-observer.ts b/packages/react/src/internal/container-observer.ts new file mode 100644 index 0000000..90b697c --- /dev/null +++ b/packages/react/src/internal/container-observer.ts @@ -0,0 +1,59 @@ +import { createFrameScheduler, type Rect } from '@dynamix-layout/core' +import type { LayoutController, Padding } from './controller' + +/** The area inside the root (its padding box) minus the layout's padding. */ +export function readContainer(element: HTMLElement, padding: Padding): Rect { + return { + x: padding.left, + y: padding.top, + width: Math.max(0, element.clientWidth - padding.left - padding.right), + height: Math.max( + 0, + element.clientHeight - padding.top - padding.bottom + ), + } +} + +/** + * Measures the root now, then follows its size with a `ResizeObserver`, so + * resizes from sidebars or flex parents are caught, not only window resizes. + * Updates run at most once per animation frame, or once per + * `resizeThrottleMs` when that is set. + * + * @returns A function that disconnects the observer and drops pending work. + */ +export function observeContainer( + controller: LayoutController, + element: HTMLElement +): () => void { + const update = () => + controller.engine.setContainerRect( + readContainer(element, controller.options.padding) + ) + update() + if (typeof ResizeObserver === 'undefined') return () => {} + + const frame = createFrameScheduler(update) + let timer: ReturnType | null = null + let lastRun = 0 + + const schedule = () => { + const throttle = controller.options.resizeThrottleMs + if (throttle <= 0) return frame.schedule(null) + if (timer !== null) return + const wait = Math.max(0, lastRun + throttle - Date.now()) + timer = setTimeout(() => { + timer = null + lastRun = Date.now() + update() + }, wait) + } + + const observer = new ResizeObserver(schedule) + observer.observe(element) + return () => { + observer.disconnect() + frame.cancel() + if (timer !== null) clearTimeout(timer) + } +} diff --git a/packages/react/src/internal/controller.ts b/packages/react/src/internal/controller.ts new file mode 100644 index 0000000..898dc9f --- /dev/null +++ b/packages/react/src/internal/controller.ts @@ -0,0 +1,183 @@ +import { + createLayout, + type DropMeasurements, + type Layout, + type LayoutChangeReason, + type LayoutJSON, + type LayoutTreeV1, + type Point, + type Rect, + type TabBarMeasurement, + type TabInit, +} from '@dynamix-layout/core' +import { createDomIds, type DomIds } from './dom-ids' +import type { PositionedSlot, SlotGeometryOptions } from './slot-geometry' + +/** Padding on each side of the layout, in px. */ +export interface Padding { + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number +} + +/** Behaviour options the internals read; always the latest props. */ +export interface ControllerOptions extends SlotGeometryOptions { + readonly padding: Padding + readonly resizeThrottleMs: number + readonly allowMaximize: boolean + readonly allowFold: boolean + readonly maximizeOnDoubleClick: boolean + readonly keyboardShortcuts: boolean + readonly tabActivation: 'automatic' | 'manual' + /** Called when a tab's close button or Delete key asks to close it. */ + readonly onTabClose?: (tabId: string) => void +} + +/** + * Everything one layout instance shares between its hooks and components: + * the engine, the positioned elements, DOM ids and the latest options. Created + * once per instance; never recreated by prop changes. + */ +export interface LayoutController { + readonly engine: Layout + readonly ids: DomIds + /** Latest options; updated in a layout effect, never during render. */ + options: ControllerOptions + /** The root element, once mounted. */ + root: HTMLElement | null + /** Tabset the user last pointed at or focused (target of keyboard shortcuts). */ + focusedTabsetId: string | null + /** Plain-text tab titles for announcements and ARIA labels (ids when titles aren't text). */ + readonly labels: Map + /** Registers (or, with `null`, removes) an element the fast path positions. */ + register( + slot: PositionedSlot, + id: string, + element: HTMLElement | null + ): void + /** Every registered element of a slot. */ + elements(slot: PositionedSlot): ReadonlyMap + /** Converts viewport coordinates to layout-root coordinates. */ + toRootPoint(clientX: number, clientY: number): Point + /** Tab bar and tab rects for drop targeting, in layout-root coordinates. */ + measure(): DropMeasurements + /** Says something to screen readers through the layout's live region. */ + announce(message: string): void + /** Listeners for `announce`, used by the live region. */ + onAnnounce(listener: (message: string) => void): () => void +} + +/** Options for {@link createController}. */ +export interface CreateControllerOptions { + readonly idBase: string + readonly tabs: readonly TabInit[] + readonly initialLayout: LayoutJSON | LayoutTreeV1 | null | undefined + readonly options: ControllerOptions + readonly minPanelSize: { width: number; height: number } + readonly splitterSize: number + readonly onLayoutChange: ( + layout: LayoutJSON, + reason: LayoutChangeReason + ) => void +} + +/** Effective core sizes: a tabset is never shorter than its tab bar, and folds to it. */ +export function coreSizes( + minPanelSize: { width: number; height: number }, + splitterSize: number, + options: SlotGeometryOptions +) { + const height = options.showTabBar + ? Math.max(minPanelSize.height, options.tabBarHeight) + : minPanelSize.height + return { + minPanelSize: { width: minPanelSize.width, height }, + splitterSize, + foldedSize: options.showTabBar ? options.tabBarHeight : height, + } +} + +const relativeRect = (rect: DOMRect, origin: Point): Rect => ({ + x: rect.left - origin.x, + y: rect.top - origin.y, + width: rect.width, + height: rect.height, +}) + +/** + * Creates the controller. Pure apart from creating the engine, so it is safe + * to call during render (StrictMode may call it twice and drop one). + */ +export function createController( + init: CreateControllerOptions +): LayoutController { + const registry = new Map>() + const announceListeners = new Set<(message: string) => void>() + const engine = createLayout({ + tabs: init.tabs, + initialLayout: init.initialLayout ?? null, + ...coreSizes(init.minPanelSize, init.splitterSize, init.options), + onLayoutChange: init.onLayoutChange, + }) + + const slotMap = (slot: PositionedSlot) => { + let map = registry.get(slot) + if (!map) registry.set(slot, (map = new Map())) + return map + } + + const controller: LayoutController = { + engine, + ids: createDomIds(init.idBase), + options: init.options, + root: null, + focusedTabsetId: null, + labels: new Map(), + register(slot, id, element) { + if (element) slotMap(slot).set(id, element) + else slotMap(slot).delete(id) + }, + elements: (slot) => slotMap(slot), + toRootPoint(clientX, clientY) { + const root = controller.root + if (!root) return { x: clientX, y: clientY } + const box = root.getBoundingClientRect() + return { + x: clientX - box.left - root.clientLeft, + y: clientY - box.top - root.clientTop, + } + }, + measure() { + const root = controller.root + const tabBars = new Map() + if (!root) return { tabBars } + const box = root.getBoundingClientRect() + const origin = { + x: box.left + root.clientLeft, + y: box.top + root.clientTop, + } + slotMap('tabBar').forEach((bar, tabsetId) => { + const tabs = Array.from( + bar.querySelectorAll('[data-dx-slot="tab"]') + ).map((tab) => ({ + id: tab.dataset.dxId ?? '', + rect: relativeRect(tab.getBoundingClientRect(), origin), + })) + tabBars.set(tabsetId, { + rect: relativeRect(bar.getBoundingClientRect(), origin), + isRotated: bar.hasAttribute('data-dx-rotated'), + tabs, + }) + }) + return { tabBars } + }, + announce: (message) => + announceListeners.forEach((listener) => listener(message)), + onAnnounce(listener) { + announceListeners.add(listener) + return () => announceListeners.delete(listener) + }, + } + return controller +} diff --git a/packages/react/src/internal/dom-ids.ts b/packages/react/src/internal/dom-ids.ts new file mode 100644 index 0000000..7b52cfe --- /dev/null +++ b/packages/react/src/internal/dom-ids.ts @@ -0,0 +1,23 @@ +/** + * Encodes any tab or tabset id into characters that are safe in an HTML id + * and in CSS selectors. Injective: different ids never collide. + */ +export function encodeId(id: string): string { + return id.replace( + /[^A-Za-z0-9-]/g, + (char) => `_${char.codePointAt(0)!.toString(36)}_` + ) +} + +/** DOM ids for ARIA relationships, scoped to one layout via React's `useId`. */ +export function createDomIds(base: string) { + const prefix = `dx${encodeId(base)}` + return { + tab: (tabId: string) => `${prefix}-tab-${encodeId(tabId)}`, + tabContent: (tabId: string) => `${prefix}-content-${encodeId(tabId)}`, + panel: (tabsetId: string) => `${prefix}-panel-${encodeId(tabsetId)}`, + live: `${prefix}-live`, + } +} + +export type DomIds = ReturnType diff --git a/packages/react/src/internal/keyboard.ts b/packages/react/src/internal/keyboard.ts new file mode 100644 index 0000000..5f82fd8 --- /dev/null +++ b/packages/react/src/internal/keyboard.ts @@ -0,0 +1,228 @@ +import type { DropTarget } from '@dynamix-layout/core' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { LayoutController } from './controller' + +/** Splitter keyboard steps, in px. */ +const STEP = 10 +const LARGE_STEP = 50 + +const isModifier = (event: ReactKeyboardEvent) => event.metaKey || event.ctrlKey + +/** Keyboard move mode: a tab drag driven by arrow keys (Mod+Shift+M, Enter drops, Escape cancels). */ +interface MoveMode { + readonly tabId: string + readonly targets: readonly DropTarget[] + index: number +} +const moveModes = new WeakMap() + +function describeTarget( + controller: LayoutController, + target: DropTarget +): string { + const snapshot = controller.engine.getSnapshot() + const label = (tabId: string | undefined) => + tabId ? (controller.labels.get(tabId) ?? tabId) : '' + if (target.type === 'root') return `${target.position} edge of the layout` + if (target.type === 'tab') + return `${target.position} ${label(target.tabId)}` + const tabset = snapshot.tabsets.get(target.tabsetId) + const name = label(tabset?.tabIds[0]) + return target.position === 'center' + ? `into ${name}` + : `${target.position} of ${name}` +} + +function showMoveTarget(controller: LayoutController, mode: MoveMode) { + const target = mode.targets[mode.index] + controller.engine.setDragTarget(target, controller.measure()) + const name = controller.labels.get(mode.tabId) ?? mode.tabId + controller.announce( + `Move ${name}: ${describeTarget(controller, target)}. Enter to drop, Escape to cancel.` + ) +} + +function startMoveMode(controller: LayoutController, tabId: string): boolean { + const source = { type: 'tab', tabId } as const + const targets = controller.engine.listDropTargets(source) + if (targets.length === 0 || !controller.engine.startDrag(source)) + return false + const mode = { tabId, targets, index: 0 } + moveModes.set(controller, mode) + showMoveTarget(controller, mode) + return true +} + +function handleMoveMode( + controller: LayoutController, + event: ReactKeyboardEvent +): boolean { + const mode = moveModes.get(controller) + if (!mode) return false + const name = controller.labels.get(mode.tabId) ?? mode.tabId + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + mode.index = (mode.index + 1) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'ArrowLeft': + case 'ArrowUp': + mode.index = + (mode.index - 1 + mode.targets.length) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'Enter': + case ' ': + moveModes.delete(controller) + controller.engine.endDrag() + controller.announce(`Moved ${name}.`) + break + case 'Escape': + case 'Tab': + moveModes.delete(controller) + controller.engine.cancelDrag() + controller.announce(`Move of ${name} cancelled.`) + if (event.key === 'Tab') return true + break + default: + return true + } + event.preventDefault() + return true +} + +/** Ends keyboard move mode, if active (on blur or unmount). */ +export function cancelMoveMode(controller: LayoutController): void { + if (!moveModes.delete(controller)) return + controller.engine.cancelDrag() +} + +/** + * Keys on a focused tab (WAI-ARIA tabs pattern): arrows and Home/End move + * focus along the tab bar (selecting with automatic activation), Enter/Space + * select, Delete closes a closable tab, Mod+Shift+M starts move mode. + */ +export function handleTabKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent, + tabId: string, + options: { isRotated: boolean; isClosable: boolean } +): void { + if (handleMoveMode(controller, event)) return + const { engine, options: settings } = controller + + if ( + event.key.toLowerCase() === 'm' && + event.shiftKey && + isModifier(event) + ) { + if (startMoveMode(controller, tabId)) event.preventDefault() + return + } + + const snapshot = engine.getSnapshot() + const tab = snapshot.tabs.get(tabId) + const tabIds = tab && snapshot.tabsets.get(tab.tabsetId)?.tabIds + if (!tabIds) return + const index = tabIds.indexOf(tabId) + const [previousKey, nextKey] = options.isRotated + ? ['ArrowUp', 'ArrowDown'] + : ['ArrowLeft', 'ArrowRight'] + + let next: string | undefined + if (event.key === previousKey) + next = tabIds[(index - 1 + tabIds.length) % tabIds.length] + else if (event.key === nextKey) next = tabIds[(index + 1) % tabIds.length] + else if (event.key === 'Home') next = tabIds[0] + else if (event.key === 'End') next = tabIds[tabIds.length - 1] + else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + engine.selectTab(tabId) + return + } else if ( + (event.key === 'Delete' || event.key === 'Backspace') && + options.isClosable + ) { + event.preventDefault() + settings.onTabClose?.(tabId) + return + } + if (next === undefined) return + + event.preventDefault() + document.getElementById(controller.ids.tab(next))?.focus() + if (settings.tabActivation === 'automatic') engine.selectTab(next) +} + +/** + * Keys on a focused splitter (WAI-ARIA window splitter pattern): arrows along + * its row move it (Shift for larger steps), Home/End move it to its limits, + * Enter folds or unfolds the panel before it when that panel can fold. + */ +export function handleSplitterKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent, + splitterId: string +): void { + const { engine } = controller + const splitter = engine.getSnapshot().splitters.get(splitterId) + if (!splitter) return + const horizontal = splitter.direction === 'horizontal' + const step = event.shiftKey ? LARGE_STEP : STEP + const keys = horizontal + ? ['ArrowLeft', 'ArrowRight'] + : ['ArrowUp', 'ArrowDown'] + const bounds = engine.getSplitterBounds(splitterId) + + let delta: number | null = null + if (event.key === keys[0]) delta = -step + else if (event.key === keys[1]) delta = step + else if (event.key === 'Home' && bounds) delta = bounds.min - bounds.value + else if (event.key === 'End' && bounds) delta = bounds.max - bounds.value + else if (event.key === 'Enter') { + const before = engine.getSnapshot().tabsets.get(splitter.beforeId) + const after = engine.getSnapshot().tabsets.get(splitter.afterId) + const target = + before?.isFolded || (before && !after?.isFolded) ? before : after + if (target?.canFold && controller.options.allowFold) { + event.preventDefault() + engine.toggleFold(target.id) + } + return + } + if (delta === null) return + event.preventDefault() + engine.moveSplitterBy(splitterId, delta) +} + +/** + * Layout-wide shortcuts, listened to on the root (so several layouts on a + * page don't all react): Alt/Option + "+" maximizes or restores, Alt/Option + + * "-" folds or unfolds the tabset that has focus or was last pointed at. + */ +export function handleRootKeyDown( + controller: LayoutController, + event: ReactKeyboardEvent +): void { + const { engine, options } = controller + if (!options.keyboardShortcuts || !event.altKey || isModifier(event)) return + const snapshot = engine.getSnapshot() + const id = controller.focusedTabsetId ?? snapshot.maximizedTabsetId + if (!id || !snapshot.tabsets.has(id)) return + + if ( + (event.code === 'Equal' || event.code === 'NumpadAdd') && + options.allowMaximize + ) { + event.preventDefault() + engine.toggleMaximize(id) + } else if ( + (event.code === 'Minus' || event.code === 'NumpadSubtract') && + options.allowFold && + options.showTabBar + ) { + event.preventDefault() + engine.toggleFold(id) + } +} diff --git a/packages/react/src/internal/pointer-drag.ts b/packages/react/src/internal/pointer-drag.ts new file mode 100644 index 0000000..7be68a9 --- /dev/null +++ b/packages/react/src/internal/pointer-drag.ts @@ -0,0 +1,152 @@ +import { + createFrameScheduler, + type DragSource, + type DropMeasurements, +} from '@dynamix-layout/core' +import type { PointerEvent as ReactPointerEvent } from 'react' +import type { LayoutController } from './controller' + +/** Distance a mouse or pen must move before a press becomes a tab drag. */ +const DRAG_THRESHOLD = 4 +/** How long a touch must hold still before it becomes a tab drag (so panning still scrolls tab bars). */ +const LONG_PRESS_MS = 350 +/** Movement that turns a pending touch into a pan instead. */ +const TOUCH_SLOP = 8 + +const active = new WeakMap void>() + +/** Cancels the drag in progress, if any (used on unmount). */ +export function stopPointerDrag(controller: LayoutController): void { + active.get(controller)?.() +} + +/** + * Starts tracking a press on a tab, tab bar or splitter. Splitters drag + * immediately; tabs and tab bars only after moving `DRAG_THRESHOLD` px (mouse, + * pen) or a long press (touch), so a plain click still selects a tab. + * + * During the drag: moves are coalesced to one per frame, Escape or + * `pointercancel` cancels, and the click that follows a drag is swallowed. + */ +export function beginPointerDrag( + controller: LayoutController, + event: ReactPointerEvent, + source: DragSource +): void { + if (event.button !== 0 || event.isPrimary === false) return + stopPointerDrag(controller) + + const { engine } = controller + const element = event.currentTarget + const pointerId = event.pointerId + const isTouch = event.pointerType === 'touch' + const start = { x: event.clientX, y: event.clientY } + let isDragging = false + let measurements: DropMeasurements | undefined + let longPress: ReturnType | null = null + + const frame = createFrameScheduler<{ x: number; y: number }>((client) => + engine.updateDrag( + controller.toRootPoint(client.x, client.y), + measurements + ) + ) + + const capture = () => { + try { + element.setPointerCapture?.(pointerId) + } catch { + // The pointer may already be released (e.g. a very short tap). + } + } + + const startDrag = () => { + if (isDragging) return + measurements = + source.type === 'splitter' ? undefined : controller.measure() + if (!engine.startDrag(source)) return cleanup() + isDragging = true + capture() + } + + const swallowClick = (click: MouseEvent) => { + click.stopPropagation() + click.preventDefault() + } + + const onMove = (move: PointerEvent) => { + if (move.pointerId !== pointerId) return + if (!isDragging) { + const distance = Math.hypot( + move.clientX - start.x, + move.clientY - start.y + ) + if (isTouch) { + if (distance > TOUCH_SLOP) cleanup() + return + } + if (distance < DRAG_THRESHOLD) return + startDrag() + if (!isDragging) return + } + move.preventDefault() + frame.schedule({ x: move.clientX, y: move.clientY }) + } + + const onUp = (up: PointerEvent) => { + if (up.pointerId !== pointerId) return + if (isDragging) { + frame.flush() + engine.endDrag() + // The click that follows the release would select or toggle something. + element.addEventListener('click', swallowClick, { + capture: true, + once: true, + }) + setTimeout( + () => + element.removeEventListener('click', swallowClick, { + capture: true, + }), + 0 + ) + } + cleanup() + } + + const onCancel = () => { + if (isDragging) engine.cancelDrag() + cleanup() + } + + const onKeyDown = (key: KeyboardEvent) => { + if (key.key === 'Escape' && isDragging) { + key.preventDefault() + onCancel() + } + } + + function cleanup() { + frame.cancel() + if (longPress !== null) clearTimeout(longPress) + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + window.removeEventListener('keydown', onKeyDown, true) + if (active.get(controller) === onCancel) active.delete(controller) + isDragging = false + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + window.addEventListener('keydown', onKeyDown, true) + active.set(controller, onCancel) + + if (source.type === 'splitter') { + event.preventDefault() + startDrag() + } else if (isTouch) { + longPress = setTimeout(startDrag, LONG_PRESS_MS) + } +} diff --git a/packages/react/src/internal/rect-writer.ts b/packages/react/src/internal/rect-writer.ts new file mode 100644 index 0000000..9cdc9e5 --- /dev/null +++ b/packages/react/src/internal/rect-writer.ts @@ -0,0 +1,78 @@ +import type { LayoutSnapshot } from '@dynamix-layout/core' +import type { LayoutController } from './controller' +import { splitterAria } from './splitter-aria' +import { + applySlotGeometry, + getSlotGeometry, + type PositionedSlot, + type SlotGeometry, +} from './slot-geometry' + +/** + * The DOM fast path. Pointer moves (splitter drags, drop targeting) and + * container resizes change only rects, which React never subscribes to, so + * React doesn't re-render; this writer moves the registered elements instead. + * + * Components render the same geometry from the current snapshot + * (`getSlotGeometry`), so a later render and the writer always agree: React + * diffs props against its previous props, never against the DOM. + */ + +const SLOTS: readonly PositionedSlot[] = [ + 'panel', + 'tabBar', + 'tabContent', + 'splitter', + 'dropIndicator', + 'rootDropZone', +] + +const sameGeometry = (a: SlotGeometry | undefined, b: SlotGeometry) => + !!a && + a.isHidden === b.isHidden && + a.isRotated === b.isRotated && + a.rect.x === b.rect.x && + a.rect.y === b.rect.y && + a.rect.width === b.rect.width && + a.rect.height === b.rect.height + +/** Splitter values change on every drag frame, so they're kept current here, not by renders. */ +function writeSplitterAria( + controller: LayoutController, + element: HTMLElement, + id: string +) { + const { percent } = splitterAria(controller, id) + if (!percent) return + element.setAttribute('aria-valuenow', String(percent.now)) + element.setAttribute('aria-valuemin', String(percent.min)) + element.setAttribute('aria-valuemax', String(percent.max)) +} + +/** Starts writing positions on every snapshot. @returns A function that stops it. */ +export function startRectWriter(controller: LayoutController): () => void { + const written = new WeakMap() + + const write = (snapshot: LayoutSnapshot) => { + controller.root?.toggleAttribute('data-dx-dragging', !!snapshot.drag) + for (const slot of SLOTS) { + controller.elements(slot).forEach((element, id) => { + const geometry = getSlotGeometry( + snapshot, + controller.options, + slot, + id + ) + if (!geometry || sameGeometry(written.get(element), geometry)) + return + applySlotGeometry(element, geometry) + written.set(element, geometry) + if (slot === 'splitter') + writeSplitterAria(controller, element, id) + }) + } + } + + write(controller.engine.getSnapshot()) + return controller.engine.subscribe(write) +} diff --git a/packages/react/src/internal/slot-geometry.ts b/packages/react/src/internal/slot-geometry.ts new file mode 100644 index 0000000..9cbe6ce --- /dev/null +++ b/packages/react/src/internal/slot-geometry.ts @@ -0,0 +1,131 @@ +import { + getRootDropZoneRect, + getTabBarPlacement, + getTabContentRect, + type LayoutSnapshot, + type Rect, + type Side, +} from '@dynamix-layout/core' +import type { CSSProperties } from 'react' + +/** Elements the library positions. */ +export type PositionedSlot = + | 'panel' + | 'tabBar' + | 'tabContent' + | 'splitter' + | 'dropIndicator' + | 'rootDropZone' + +/** Geometry the slot geometry depends on besides the snapshot. */ +export interface SlotGeometryOptions { + readonly tabBarHeight: number + readonly showTabBar: boolean +} + +/** Where an element goes and whether it shows. */ +export interface SlotGeometry { + readonly rect: Rect + readonly isRotated: boolean + readonly isHidden: boolean +} + +const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** + * Geometry of one positioned element, computed from the snapshot alone. Both + * the render and the DOM fast path use it, so they always agree. + */ +export function getSlotGeometry( + snapshot: LayoutSnapshot, + options: SlotGeometryOptions, + slot: PositionedSlot, + id: string +): SlotGeometry | null { + const { rects } = snapshot + switch (slot) { + case 'panel': { + const tabset = snapshot.tabsets.get(id) + const rect = rects.tabsets.get(id) + if (!tabset || !rect) return null + return { rect, isRotated: false, isHidden: tabset.isHidden } + } + case 'tabBar': { + const tabset = snapshot.tabsets.get(id) + const rect = rects.tabsets.get(id) + if (!tabset || !rect) return null + const placement = getTabBarPlacement( + rect, + tabset, + options.tabBarHeight + ) + return { + rect: placement.rect, + isRotated: placement.isRotated, + isHidden: tabset.isHidden, + } + } + case 'tabContent': { + const tab = snapshot.tabs.get(id) + const tabsetRect = tab && rects.tabsets.get(tab.tabsetId) + if (!tab || !tabsetRect) + return { rect: EMPTY, isRotated: false, isHidden: true } + const rect = getTabContentRect( + tabsetRect, + options.showTabBar ? options.tabBarHeight : 0 + ) + return { + rect, + isRotated: false, + isHidden: !tab.isVisible || rect.height <= 0, + } + } + case 'splitter': { + const splitter = snapshot.splitters.get(id) + const rect = rects.splitters.get(id) + if (!splitter || !rect) return null + return { rect, isRotated: false, isHidden: splitter.isHidden } + } + case 'dropIndicator': { + const rect = snapshot.drag?.indicator + return rect + ? { rect, isRotated: false, isHidden: false } + : { rect: EMPTY, isRotated: false, isHidden: true } + } + case 'rootDropZone': { + const rect = getRootDropZoneRect(rects.container, id as Side) + const isTabDrag = + !!snapshot.drag && snapshot.drag.source.type !== 'splitter' + return { rect, isRotated: false, isHidden: !isTabDrag } + } + } +} + +/** Inline style for a geometry (positioning only; everything else is CSS). */ +export function positionStyle(geometry: SlotGeometry | null): CSSProperties { + if (!geometry) return {} + const { x, y, width, height } = geometry.rect + return { + left: x, + top: y, + width, + height, + ...(geometry.isRotated ? { transform: 'rotate(90deg)' } : {}), + } +} + +/** Applies a geometry to an element (the fast path between renders). */ +export function applySlotGeometry( + element: HTMLElement, + geometry: SlotGeometry | null +): void { + if (!geometry) return + const { style } = element + const { x, y, width, height } = geometry.rect + style.left = `${x}px` + style.top = `${y}px` + style.width = `${width}px` + style.height = `${height}px` + style.transform = geometry.isRotated ? 'rotate(90deg)' : '' + element.toggleAttribute('data-dx-hidden', geometry.isHidden) +} diff --git a/packages/react/src/internal/splitter-aria.ts b/packages/react/src/internal/splitter-aria.ts new file mode 100644 index 0000000..a3d7b4d --- /dev/null +++ b/packages/react/src/internal/splitter-aria.ts @@ -0,0 +1,25 @@ +import type { LayoutController as Core } from './controller' + +/** ARIA values as percentages of the two neighbours' combined size. */ +export function splitterAria(core: Core, splitterId: string) { + const bounds = core.engine.getSplitterBounds(splitterId) + const snapshot = core.engine.getSnapshot() + const splitter = snapshot.splitters.get(splitterId) + const after = + splitter && + (snapshot.rects.tabsets.get(splitter.afterId) ?? + snapshot.rects.rows.get(splitter.afterId)) + if (!bounds || !splitter || !after) return { bounds, percent: null } + const afterSize = + splitter.direction === 'horizontal' ? after.width : after.height + const total = bounds.value + afterSize || 1 + const percent = (value: number) => Math.round((value / total) * 100) + return { + bounds, + percent: { + now: percent(bounds.value), + min: percent(bounds.min), + max: percent(bounds.max), + }, + } +} diff --git a/packages/react/src/internal/tab-label.ts b/packages/react/src/internal/tab-label.ts new file mode 100644 index 0000000..dfcf2ba --- /dev/null +++ b/packages/react/src/internal/tab-label.ts @@ -0,0 +1,41 @@ +import type { LayoutSnapshot, RowNode, TabsetNode } from '@dynamix-layout/core' +import type { HeadlessTabItem } from '../types' + +/** Plain-text name of a tab for ARIA labels: its title when that's text, else its id. */ +export function tabLabel(tab: HeadlessTabItem | undefined, id: string): string { + const title = tab?.title + return typeof title === 'string' || typeof title === 'number' + ? String(title) + : id +} + +/** Name of a row or tabset for a splitter label: the active tab of it (or of its first tabset). */ +export function nodeLabel( + snapshot: LayoutSnapshot, + id: string, + tabs: { get(tabId: string): HeadlessTabItem | undefined } +): string { + const find = (node: RowNode | TabsetNode): TabsetNode | undefined => { + if (node.type === 'tabset') return node + for (const child of node.children) { + const found = find(child) + if (found) return found + } + return undefined + } + const search = (row: RowNode): RowNode | TabsetNode | undefined => { + for (const child of row.children) { + if (child.id === id) return child + if (child.type === 'row') { + const found = search(child) + if (found) return found + } + } + return undefined + } + const node = search(snapshot.root) + const tabset = node && find(node) + return tabset + ? tabLabel(tabs.get(tabset.activeTabId), tabset.activeTabId) + : 'panel' +} diff --git a/packages/react/src/internal/tab-registry.ts b/packages/react/src/internal/tab-registry.ts new file mode 100644 index 0000000..02c9bfa --- /dev/null +++ b/packages/react/src/internal/tab-registry.ts @@ -0,0 +1,67 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { HeadlessTabItem } from '../types' + +/** + * The `tabs` prop as a store with one subscription per tab. Titles and + * content live here, outside the engine, so changing them never touches the + * layout, and a component reading one tab re-renders only when that tab's item + * changes (an inline `tabs` array with unchanged items re-renders nothing). + */ +export interface TabRegistry { + get(tabId: string): HeadlessTabItem | undefined + subscribe(tabId: string, listener: () => void): () => void + /** Applies a new `tabs` prop; notifies only tabs whose item changed. */ + update(items: readonly HeadlessTabItem[]): void +} + +const sameItem = (a: HeadlessTabItem, b: HeadlessTabItem) => + a.title === b.title && + a.content === b.content && + a.closable === b.closable && + a.target === b.target + +export function createTabRegistry( + initial: readonly HeadlessTabItem[] +): TabRegistry { + let items = new Map(initial.map((item) => [item.id, item])) + const listeners = new Map void>>() + + return { + get: (tabId) => items.get(tabId), + subscribe(tabId, listener) { + let set = listeners.get(tabId) + if (!set) listeners.set(tabId, (set = new Set())) + set.add(listener) + return () => set!.delete(listener) + }, + update(next) { + const previous = items + const changed: string[] = [] + items = new Map( + next.map((item) => { + const old = previous.get(item.id) + if (old && sameItem(old, item)) return [item.id, old] + changed.push(item.id) + return [item.id, item] + }) + ) + for (const id of previous.keys()) + if (!items.has(id)) changed.push(id) + for (const id of changed) + listeners.get(id)?.forEach((listener) => listener()) + }, + } +} + +/** Subscribes to one tab's item. */ +export function useTabItem( + registry: TabRegistry, + tabId: string +): HeadlessTabItem | undefined { + const subscribe = useCallback( + (listener: () => void) => registry.subscribe(tabId, listener), + [registry, tabId] + ) + const get = () => registry.get(tabId) + return useSyncExternalStore(subscribe, get, get) +} diff --git a/packages/react/src/internal/use-controller.ts b/packages/react/src/internal/use-controller.ts new file mode 100644 index 0000000..ab07047 --- /dev/null +++ b/packages/react/src/internal/use-controller.ts @@ -0,0 +1,179 @@ +import type { LayoutChangeReason, LayoutJSON } from '@dynamix-layout/core' +import { useCallback, useMemo, useReducer, useRef, useState } from 'react' +import type { LayoutContextValue } from '../context/layout-context' +import type { + HeadlessTabItem, + LayoutPadding, + UseDynamixLayoutOptions, +} from '../types' +import { useIsomorphicLayoutEffect } from '../utils/use-isomorphic-layout-effect' +import { useStableCallback } from '../utils/use-stable-callback' +import { observeContainer, readContainer } from './container-observer' +import { + coreSizes, + createController, + type ControllerOptions, + type Padding, +} from './controller' +import { cancelMoveMode } from './keyboard' +import { stopPointerDrag } from './pointer-drag' +import { startRectWriter } from './rect-writer' +import { tabLabel } from './tab-label' +import { createTabRegistry } from './tab-registry' + +const DEFAULT_MIN_PANEL_SIZE = { width: 40, height: 40 } + +function toPadding(padding: LayoutPadding | undefined): Padding { + if (typeof padding === 'number') + return { top: padding, right: padding, bottom: padding, left: padding } + return { + top: padding?.top ?? 0, + right: padding?.right ?? 0, + bottom: padding?.bottom ?? 0, + left: padding?.left ?? 0, + } +} + +/** Joins tab ids into a key that changes only when the set or order of ids does. */ +const idsKey = (tabs: readonly HeadlessTabItem[]) => + tabs.map((tab) => tab.id).join('\u0000') + +/** + * Creates the controller once and keeps it in sync with props: options and + * tab ids are pushed into the existing engine (never recreated), the container + * is observed, the fast path runs, and a controlled `layout` is reconciled. + */ +export function useController( + options: UseDynamixLayoutOptions, + idBase: string +) { + const { + tabs, + layout, + defaultLayout, + minPanelSize = DEFAULT_MIN_PANEL_SIZE, + splitterSize = 10, + tabBarHeight = 40, + showTabBar = true, + } = options + const padding = toPadding(options.padding) + const controllerOptions: ControllerOptions = { + tabBarHeight, + showTabBar, + padding, + resizeThrottleMs: options.resizeThrottleMs ?? 0, + allowMaximize: options.allowMaximize ?? true, + allowFold: (options.allowFold ?? true) && showTabBar, + maximizeOnDoubleClick: options.maximizeOnDoubleClick ?? true, + keyboardShortcuts: options.keyboardShortcuts ?? true, + tabActivation: options.tabActivation ?? 'automatic', + onTabClose: options.onTabClose, + } + + const isControlled = layout !== undefined + const [, reconcile] = useReducer((count: number) => count + 1, 0) + const controlledRef = useRef(isControlled) + const userOnLayoutChange = useStableCallback(options.onLayoutChange) + const onLayoutChange = useCallback( + (json: LayoutJSON, reason: LayoutChangeReason) => { + userOnLayoutChange(json, { reason }) + // Re-render so the effect below can revert a change the parent didn't accept. + if (controlledRef.current) reconcile() + }, + [userOnLayoutChange] + ) + + const [core] = useState(() => + createController({ + idBase, + tabs: tabs.map(({ id, target }) => ({ id, target })), + initialLayout: layout ?? defaultLayout, + options: controllerOptions, + minPanelSize, + splitterSize, + onLayoutChange, + }) + ) + const sync = useRef({ from: layout, json: core.engine.toJSON() }) + + useIsomorphicLayoutEffect(() => { + controlledRef.current = isControlled + core.options = controllerOptions + }) + + useIsomorphicLayoutEffect(() => { + core.engine.setOptions( + coreSizes(minPanelSize, splitterSize, { tabBarHeight, showTabBar }) + ) + }, [ + core, + minPanelSize.width, + minPanelSize.height, + splitterSize, + tabBarHeight, + showTabBar, + ]) + + const key = idsKey(tabs) + const latestTabs = useRef(tabs) + useIsomorphicLayoutEffect(() => { + latestTabs.current = tabs + core.labels.clear() + for (const tab of tabs) core.labels.set(tab.id, tabLabel(tab, tab.id)) + }) + useIsomorphicLayoutEffect(() => { + core.engine.setTabs( + latestTabs.current.map(({ id, target }) => ({ id, target })) + ) + }, [core, key]) + + // Controlled mode: adopt the parent's layout unless it is what we last emitted or loaded. + useIsomorphicLayoutEffect(() => { + if (!isControlled) return + const current = core.engine.toJSON() + if (layout === current) sync.current = { from: layout, json: current } + else if ( + layout !== sync.current.from || + current !== sync.current.json + ) { + core.engine.load(layout!) + sync.current = { from: layout, json: core.engine.toJSON() } + } + }) + + const [root, setRoot] = useState(null) + const [isMeasured, setMeasured] = useState(false) + const rootRef = useCallback( + (element: HTMLElement | null) => { + core.root = element + setRoot(element) + }, + [core] + ) + useIsomorphicLayoutEffect(() => { + if (!root) return + const stopObserving = observeContainer(core, root) + const stopWriting = startRectWriter(core) + setMeasured(true) + return () => { + stopObserving() + stopWriting() + stopPointerDrag(core) + cancelMoveMode(core) + } + }, [core, root]) + // Padding changes the container without resizing the element. + useIsomorphicLayoutEffect(() => { + if (root) core.engine.setContainerRect(readContainer(root, padding)) + }, [core, root, padding.top, padding.right, padding.bottom, padding.left]) + + const [registry] = useState(() => createTabRegistry(tabs)) + useIsomorphicLayoutEffect(() => registry.update(tabs), [registry, tabs]) + const contextValue = useMemo( + () => ({ core, tabs: registry }), + [core, registry] + ) + const tabIds = useMemo(() => (key === '' ? [] : key.split('\u0000')), [key]) + + return { core, contextValue, rootRef, isMeasured, tabIds } +} diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css index 13703ad..85ac9d9 100644 --- a/packages/react/src/styles.css +++ b/packages/react/src/styles.css @@ -1,36 +1,210 @@ -/* This file contains all the necessary global CSS for the SolidJS components. - These rules cannot be applied as inline styles. -*/ -/* --- Tab Label State --- */ -/* Controls the background color of the DefaultWrapTabLabel component based on its state. */ -.DefaultWrapTabLabel[data-state='active'] { - background-color: #ffffff; - font-family: 'Poppins', sans-serif; +/* + * Default styles for @dynamix-layout/react. Every visual default is a CSS + * variable, so themes only need to set variables (or use `classNames`). + * Positions (left/top/width/height) are inline and set by the library. + */ + +.dx-root { + --dx-font: inherit; + --dx-tab-bar-bg: #dfdfdf; + --dx-tab-bg: #dfdfdf; + --dx-tab-active-bg: #ffffff; + --dx-tab-color: inherit; + --dx-tab-radius: 4px; + --dx-tab-gap: 6px; + --dx-splitter-bg: #ffffff; + --dx-splitter-hover-bg: #e8f2ff; + --dx-grip-color: #9a9a9a; + --dx-drop-indicator-bg: rgba(0, 175, 249, 0.35); + --dx-drop-indicator-border: 2px dashed rgb(0, 196, 42); + --dx-root-drop-zone-bg: #0081f9; + --dx-focus-ring: 2px solid #0081f9; + + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + box-sizing: border-box; + font-family: var(--dx-font); +} + +/* Server render and first client render: nothing is measured yet. */ +.dx-root[data-dx-measuring] > * { + visibility: hidden; } -.DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; +.dx-root *, +.dx-root *::before, +.dx-root *::after { + box-sizing: border-box; +} + +.dx-panel, +.dx-tab-bar, +.dx-tab-content, +.dx-splitter, +.dx-drop-indicator, +.dx-root-drop-zone { + position: absolute; +} + +.dx-panel { + z-index: 1; +} + +/* --- Tab bar and tabs --- */ +.dx-tab-bar { + z-index: 3; + display: flex; + align-items: center; + gap: var(--dx-tab-gap); + padding-left: 8px; + background: var(--dx-tab-bar-bg); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + transform-origin: 0 0; + touch-action: pan-x; + cursor: default; +} +.dx-tab-bar::-webkit-scrollbar { + display: none; +} +.dx-tab-bar[data-dx-has-toolbar] { + padding-right: 64px; +} + +.dx-tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-radius: var(--dx-tab-radius); + background: var(--dx-tab-bg); + color: var(--dx-tab-color); + font-size: 14px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.dx-tab[data-state='active'] { + background: var(--dx-tab-active-bg); +} +.dx-tab:focus-visible, +.dx-splitter:focus-visible, +.dx-toolbar-button:focus-visible { + outline: var(--dx-focus-ring); + outline-offset: -2px; +} + +.dx-tab-close { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-tab-close:hover { + background: rgba(0, 0, 0, 0.08); } -/* --- Scrollbar Hiding --- */ -/* Hides the scrollbar for WebKit-based browsers (Chrome, Safari, Opera) - in components using the 'hide-scrollbar' class. */ -.hide-scrollbar::-webkit-scrollbar { +/* --- Tab content --- */ +.dx-tab-content { + z-index: 2; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} +.dx-tab-content::-webkit-scrollbar { display: none; } +/* While dragging, iframes and editors inside tabs must not swallow pointer events. */ +.dx-root[data-dx-dragging] .dx-tab-content > * { + pointer-events: none; +} +.dx-root[data-dx-dragging], +.dx-root[data-dx-dragging] * { + cursor: grabbing; +} -/* --- Dragging State --- */ -/* Prevents child elements (like a text editor) inside DefaultWrapTabBody - from capturing mouse events when a parent container has the 'is-dragging' class. - This ensures smooth drag-and-drop behavior. */ -.is-dragging .DefaultWrapTabBody > * { +/* --- Splitters --- */ +.dx-splitter { + z-index: 4; + display: grid; + place-items: center; + background: var(--dx-splitter-bg); + touch-action: none; +} +.dx-splitter[data-dx-direction='horizontal'] { + cursor: ew-resize; +} +.dx-splitter[data-dx-direction='vertical'] { + cursor: ns-resize; +} +.dx-splitter:hover { + background: var(--dx-splitter-hover-bg); +} +.dx-splitter[data-dx-locked] { + pointer-events: none; +} +/* A grip of three dots across the bar. */ +.dx-splitter::after { + content: ''; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--dx-grip-color); + box-shadow: + 0 -6px 0 var(--dx-grip-color), + 0 6px 0 var(--dx-grip-color); +} +.dx-splitter[data-dx-direction='vertical']::after { + box-shadow: + -6px 0 0 var(--dx-grip-color), + 6px 0 0 var(--dx-grip-color); +} + +/* --- Drag feedback --- */ +.dx-drop-indicator { + z-index: 6; + background: var(--dx-drop-indicator-bg); + border: var(--dx-drop-indicator-border); + border-radius: 10px; pointer-events: none; + transition: all 0.15s ease; } + +.dx-root-drop-zone { + z-index: 5; + background: var(--dx-root-drop-zone-bg); + opacity: 0.6; + pointer-events: none; +} +.dx-root-drop-zone[data-dx-active] { + opacity: 1; +} +.dx-root-drop-zone[data-dx-side='left'] { + border-radius: 0 16px 16px 0; +} +.dx-root-drop-zone[data-dx-side='right'] { + border-radius: 16px 0 0 16px; +} +.dx-root-drop-zone[data-dx-side='top'] { + border-radius: 0 0 16px 16px; +} +.dx-root-drop-zone[data-dx-side='bottom'] { + border-radius: 16px 16px 0 0; +} + /* --- Tabset toolbar (maximize / fold) --- */ -/* Shown while the tab bar is hovered or focused, like LeetCode. Folded strips - always show it so they can be unfolded. */ -.DefaultTabsetToolbar { +/* Shown while the tab bar is hovered or focused; folded strips always show it. */ +.dx-toolbar { position: absolute; top: 0; right: 6px; @@ -39,26 +213,23 @@ align-items: center; gap: 2px; padding-left: 6px; - /* Opaque, so overflowing tab labels slide underneath instead of showing - through the buttons. */ + /* Opaque, so overflowing tabs slide underneath instead of showing through. */ background: inherit; opacity: 0; transition: opacity 0.15s ease; } - -[data-tabbar]:hover .DefaultTabsetToolbar, -[data-tabbar][data-folded] .DefaultTabsetToolbar, -.DefaultTabsetToolbar:focus-within { +.dx-tab-bar:hover .dx-toolbar, +.dx-tab-bar[data-dx-folded] .dx-toolbar, +.dx-toolbar:focus-within { opacity: 1; } - @media (hover: none) { - .DefaultTabsetToolbar { + .dx-toolbar { opacity: 1; } } -.DefaultTabsetToolbarButton { +.dx-toolbar-button { display: grid; place-items: center; width: 24px; @@ -70,14 +241,24 @@ color: inherit; cursor: pointer; } - -.DefaultTabsetToolbarButton:hover, -.DefaultTabsetToolbarButton:focus-visible { +.dx-toolbar-button:hover { background-color: rgba(0, 0, 0, 0.08); } -/* Hidden while another tabset is maximized. An attribute (not inline display) - keeps each component's own display value intact. */ -[data-dx-hidden] { +/* Hidden elements (another tabset is maximized, an inactive tab). An attribute, + not inline `display`, keeps each slot component's own display value intact. */ +.dx-root [data-dx-hidden] { display: none !important; } + +.dx-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/packages/react/src/test/dom.ts b/packages/react/src/test/dom.ts new file mode 100644 index 0000000..70556c2 --- /dev/null +++ b/packages/react/src/test/dom.ts @@ -0,0 +1,150 @@ +import { act } from '@testing-library/react' +import { vi } from 'vitest' + +/** + * jsdom has no layout. These fakes give the library what it measures: the + * root's client size, element rects from their inline position (the root sits + * at 0,0), tabs laid out left to right in their bar, a ResizeObserver the + * tests can trigger, and an animation-frame queue the tests flush. + */ + +const px = (value: string) => Number.parseFloat(value) || 0 + +export const TAB_WIDTH = 64 +export const TAB_GAP = 6 + +function rectOf(element: Element): DOMRect { + const html = element as HTMLElement + if (html.dataset?.dxSlot === 'tab') { + const bar = html.parentElement! + const barRect = rectOf(bar) + const index = Array.from( + bar.querySelectorAll('[data-dx-slot="tab"]') + ).indexOf(html) + const x = barRect.left + 8 + index * (TAB_WIDTH + TAB_GAP) + return DOMRect.fromRect({ + x, + y: barRect.top + 4, + width: TAB_WIDTH, + height: 32, + }) + } + const { left, top, width, height, transform } = html.style ?? {} + if (transform?.includes('rotate(90deg)')) { + // Rotated around its top-left corner: the visual box is to the left. + return DOMRect.fromRect({ + x: px(left) - px(height), + y: px(top), + width: px(height), + height: px(width), + }) + } + if (html.classList?.contains('dx-root')) + return DOMRect.fromRect({ + x: 0, + y: 0, + width: size.width, + height: size.height, + }) + return DOMRect.fromRect({ + x: px(left), + y: px(top), + width: px(width), + height: px(height), + }) +} + +const size = { width: 1210, height: 800 } + +export class FakeResizeObserver { + static instances: FakeResizeObserver[] = [] + connected = true + constructor(public callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this) + } + observe() {} + unobserve() {} + disconnect() { + this.connected = false + } +} + +/** jsdom has no PointerEvent; a MouseEvent with the pointer fields is enough. */ +class FakePointerEvent extends MouseEvent { + readonly pointerId: number + readonly pointerType: string + readonly isPrimary: boolean + constructor(type: string, init: PointerEventInit = {}) { + super(type, { bubbles: true, cancelable: true, ...init }) + this.pointerId = init.pointerId ?? 1 + this.pointerType = init.pointerType ?? 'mouse' + this.isPrimary = init.isPrimary ?? true + } +} + +let frames: FrameRequestCallback[] = [] + +/** Runs queued animation frames (inside `act`). */ +export function flushFrames() { + act(() => { + while (frames.length) { + const queue = frames + frames = [] + queue.forEach((callback) => callback(performance.now())) + } + }) +} + +/** Changes the layout's size and notifies resize observers (inside `act`). */ +export function resizeTo(width: number, height: number) { + size.width = width + size.height = height + act(() => { + FakeResizeObserver.instances + .filter((o) => o.connected) + .forEach((o) => o.callback([], o as unknown as ResizeObserver)) + }) + flushFrames() +} + +/** Installs the fakes. Call in `beforeEach`; `vi.restoreAllMocks` undoes the spies. */ +export function installDom(width = 1210, height = 800) { + size.width = width + size.height = height + frames = [] + FakeResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + vi.stubGlobal('PointerEvent', FakePointerEvent) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + frames.push(callback) + ) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + frames[id - 1] = () => {} + }) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('dx-root') ? size.width : 0 + } + ) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( + function (this: HTMLElement) { + return this.classList.contains('dx-root') ? size.height : 0 + } + ) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function (this: Element) { + return rectOf(this) + } + ) +} + +/** Inline rect of an element, as numbers. */ +export function styleRect(element: Element | null) { + const style = (element as HTMLElement).style + return { + x: px(style.left), + y: px(style.top), + width: px(style.width), + height: px(style.height), + } +} diff --git a/packages/react/src/test/drag.test.tsx b/packages/react/src/test/drag.test.tsx new file mode 100644 index 0000000..44799ec --- /dev/null +++ b/packages/react/src/test/drag.test.tsx @@ -0,0 +1,276 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { createRef, useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + type DynamixLayoutHandle, + type LayoutJSON, + type TabItem, +} from '..' +import { flushFrames, installDom } from './dom' + +const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + title: id.toUpperCase(), + content:

{`body ${id}`}

, +})) +const pointer = { button: 0, pointerId: 1 } + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +const move = (x: number, y: number) => { + fireEvent.pointerMove(window, { ...pointer, clientX: x, clientY: y }) + flushFrames() +} + +describe('pointer tab drag', () => { + it('a click without movement selects; a drag moves the tab where the indicator shows', () => { + const ref = createRef() + const onLayoutChange = vi.fn() + const { container } = render( + + ) + const c = screen.getByRole('tab', { name: 'C' }) + + fireEvent.pointerDown(c, { ...pointer, clientX: 700, clientY: 420 }) + move(702, 421) + expect(ref.current!.getSnapshot().drag).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 702, clientY: 421 }) + + fireEvent.pointerDown(c, { ...pointer, clientX: 700, clientY: 420 }) + move(100, 400) + expect(container.querySelector('.dx-root')).toHaveAttribute( + 'data-dx-dragging' + ) + expect(container.querySelectorAll('.dx-root-drop-zone')).toHaveLength(4) + const indicator = + container.querySelector('.dx-drop-indicator')! + expect(indicator.style.width).toBe('300px') + fireEvent.pointerUp(window, { ...pointer, clientX: 100, clientY: 400 }) + fireEvent.click(c) + + expect(container.querySelector('.dx-root')).not.toHaveAttribute( + 'data-dx-dragging' + ) + expect(container.querySelector('.dx-drop-indicator')).toBeNull() + expect(onLayoutChange).toHaveBeenCalledTimes(1) + expect(onLayoutChange.mock.calls[0][1]).toEqual({ reason: 'move' }) + const json = ref.current!.toJSON() + expect(json.root.children.map((child) => child.type)).toEqual([ + 'tabset', + 'tabset', + 'tabset', + ]) + expect(json.root.children[0]).toMatchObject({ children: [{ id: 'c' }] }) + }) + + it('Escape cancels a drag', () => { + const ref = createRef() + render() + const before = ref.current!.toJSON() + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 700, + clientY: 420, + }) + move(100, 400) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(ref.current!.getSnapshot().drag).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 100, clientY: 400 }) + expect(ref.current!.toJSON()).toBe(before) + }) + + it('inserts between tabs when dropped on a tab bar', () => { + const ref = createRef() + render() + // Tab bar of "a" spans x 0–600, y 0–40; tab "A" sits at x 8–72. + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 700, + clientY: 420, + }) + move(20, 20) + expect(ref.current!.getSnapshot().drag!.target).toEqual({ + type: 'tab', + tabId: 'a', + position: 'before', + }) + fireEvent.pointerUp(window, { ...pointer, clientX: 20, clientY: 20 }) + expect(ref.current!.getSnapshot().tabsets.get('ts-a')!.tabIds).toEqual([ + 'c', + 'a', + ]) + }) + + it('drags a whole tabset from its tab bar background', () => { + const ref = createRef() + render() + const bar = screen.getAllByRole('tablist')[2] + fireEvent.pointerDown(bar, { ...pointer, clientX: 1100, clientY: 425 }) + move(300, 400) + expect(ref.current!.getSnapshot().drag!.source).toEqual({ + type: 'tabset', + tabsetId: 'ts-c', + }) + fireEvent.pointerUp(window, { ...pointer, clientX: 300, clientY: 400 }) + expect(ref.current!.getSnapshot().tabs.get('c')!.tabsetId).toBe('ts-a') + }) + + it('leaves maximized mode when a drag starts', () => { + const ref = createRef() + render() + act(() => { + ref.current!.maximize('ts-c') + }) + fireEvent.pointerDown(screen.getByRole('tab', { name: 'C' }), { + ...pointer, + clientX: 20, + clientY: 20, + }) + move(300, 300) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBeNull() + fireEvent.pointerUp(window, { ...pointer, clientX: 300, clientY: 300 }) + }) +}) + +describe('controlled and uncontrolled', () => { + it('uncontrolled: starts from defaultLayout and ignores later changes to it', () => { + const ref = createRef() + const first = { + version: 2, + root: { + type: 'row', + id: 'r', + weight: 100, + direction: 'vertical', + children: [ + { + type: 'tabset', + id: 'top', + weight: 100, + activeTabId: 'a', + children: [ + { type: 'tab', id: 'a' }, + { type: 'tab', id: 'b' }, + { type: 'tab', id: 'c' }, + ], + }, + ], + }, + } as LayoutJSON + const { rerender } = render( + + ) + expect(ref.current!.getSnapshot().tabsets.get('top')!.tabIds).toEqual([ + 'a', + 'b', + 'c', + ]) + rerender( + + ) + expect(ref.current!.toJSON().root.id).toBe('r') + }) + + it('controlled: follows the parent, and reverts changes the parent rejects', () => { + const ref = createRef() + let accept = true + const payloads: LayoutJSON[] = [] + function App() { + const [layout, setLayout] = useState( + undefined + ) + const [current, setCurrent] = useState(null) + return ( + { + payloads.push(next) + if (accept) setCurrent(next) + else setLayout((l) => l) + }} + /> + ) + } + render() + act(() => { + ref.current!.selectTab('b') + ref.current!.moveTab('c', { type: 'root', position: 'bottom' }) + }) + expect(payloads.at(-1)!.version).toBe(2) + expect(ref.current!.toJSON()).toBe(payloads.at(-1)) + const accepted = ref.current!.toJSON() + + accept = false + act(() => { + ref.current!.moveTab('a', { type: 'root', position: 'top' }) + }) + expect(ref.current!.toJSON()).toEqual(accepted) + }) + + it('controlled: loads a new layout from the parent (v1 or v2)', () => { + const ref = createRef() + const v1 = { + typNode: 'row', + nodName: 'root', + uidNode: 'root', + nodPart: 100, + nodKids: [ + { + typNode: 'tabset', + nodName: '', + uidNode: 'x', + nodPart: 100, + nodOpen: 'b', + nodKids: [ + { + typNode: 'tab', + nodName: 'a', + uidNode: '1', + nodPart: 100, + }, + { + typNode: 'tab', + nodName: 'b', + uidNode: '2', + nodPart: 100, + }, + { + typNode: 'tab', + nodName: 'c', + uidNode: '3', + nodPart: 100, + }, + ], + }, + ], + } as const + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { rerender } = render( + + ) + rerender() + expect(ref.current!.getSnapshot().tabsets.get('x')!.activeTabId).toBe( + 'b' + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('v1 layout')) + }) +}) diff --git a/packages/react/src/test/render.test.tsx b/packages/react/src/test/render.test.tsx new file mode 100644 index 0000000..307734f --- /dev/null +++ b/packages/react/src/test/render.test.tsx @@ -0,0 +1,271 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + type DynamixLayoutHandle, + type TabItem, + type TabProps, +} from '..' +import { createRef } from 'react' +import { installDom, styleRect } from './dom' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content:

editor body

}, + { id: 'terminal', title: 'Terminal', content:

terminal body

}, + { + id: 'preview', + title: 'Preview', + content:

preview body

, + closable: true, + }, +] + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe(' rendering', () => { + it('renders tabs, contents and splitters at their positions', () => { + const { container } = render() + expect(screen.getAllByRole('tablist')).toHaveLength(3) + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ + 'Editor', + 'Terminal', + 'Preview', + ]) + expect(screen.getByText('editor body')).toBeInTheDocument() + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2) + const editorPanel = screen.getByText('editor body').parentElement! + expect(styleRect(editorPanel)).toEqual({ + x: 0, + y: 40, + width: 600, + height: 760, + }) + expect(container.querySelector('.dx-root')).not.toHaveAttribute( + 'data-dx-measuring' + ) + }) + + it('follows container resizes from a ResizeObserver', async () => { + const { resizeTo } = await import('./dom') + render() + resizeTo(810, 600) + expect( + styleRect(screen.getByText('editor body').parentElement) + ).toEqual({ x: 0, y: 40, width: 400, height: 560 }) + }) + + it('applies padding, classNames and styles', () => { + const { container } = render( + + ) + const root = container.querySelector('.dx-root')! + expect(root).toHaveClass('app', 'root-x') + expect(screen.getAllByRole('tab')[0]).toHaveClass( + 'dx-tab', + 'tab-x', + 'active-x' + ) + expect(container.querySelector('.dx-splitter')).toHaveClass('split-x') + const bar = screen.getAllByRole('tablist')[0] + expect(bar.style.background).toBe('red') + expect(styleRect(bar).x).toBe(10) + expect(styleRect(bar).y).toBe(5) + }) + + it('accepts custom slot components', () => { + render( + {`${tab.id}${isActive ? '*' : ''}`} + ) + }, + }} + /> + ) + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ + 'editor*', + 'terminal*', + 'preview*', + ]) + }) + + it('gives a working imperative handle through ref', () => { + const ref = createRef() + const callback = vi.fn() + render() + render() + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ moveTab: expect.any(Function) }) + ) + expect(ref.current!.element).toHaveClass('dx-root') + act(() => { + ref.current!.moveTab('preview', { + type: 'root', + position: 'bottom', + }) + }) + expect(ref.current!.toJSON().root.direction).toBe('vertical') + }) +}) + +describe('accessibility', () => { + it('follows the WAI-ARIA tabs pattern', () => { + render() + const [editor] = screen.getAllByRole('tab') + const panel = document.getElementById( + editor.getAttribute('aria-controls')! + )! + expect(panel).toHaveAttribute('role', 'tabpanel') + expect(panel).toHaveAttribute('aria-labelledby', editor.id) + expect(editor).toHaveAttribute('aria-selected', 'true') + expect(editor).toHaveAttribute('tabindex', '0') + expect(screen.getAllByRole('tablist')[0]).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ) + }) + + it('moves focus and selection with arrow keys, Home and End', () => { + const ref = createRef() + render() + act(() => { + ref.current!.moveTab('terminal', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + }) + const [editor, terminal] = screen.getAllByRole('tab') + act(() => editor.focus()) + fireEvent.keyDown(editor, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(terminal) + expect(terminal).toHaveAttribute('aria-selected', 'true') + expect(editor).toHaveAttribute('tabindex', '-1') + fireEvent.keyDown(terminal, { key: 'Home' }) + expect(document.activeElement).toBe(screen.getAllByRole('tab')[0]) + }) + + it('selects only on Enter with manual activation', () => { + const ref = createRef() + render() + act(() => { + ref.current!.moveTab('terminal', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ref.current!.selectTab('editor') + }) + const [editor, terminal] = screen.getAllByRole('tab') + fireEvent.keyDown(editor, { key: 'ArrowRight' }) + expect(terminal).toHaveAttribute('aria-selected', 'false') + fireEvent.keyDown(terminal, { key: 'Enter' }) + expect(terminal).toHaveAttribute('aria-selected', 'true') + }) + + it('follows the window splitter pattern and resizes from the keyboard', () => { + const { container } = render() + const splitter = + container.querySelector('[role="separator"]')! + expect(splitter).toHaveAttribute('aria-orientation', 'vertical') + expect(splitter).toHaveAttribute('aria-valuenow', '50') + expect(splitter).toHaveAttribute( + 'aria-label', + 'Resize Editor and Terminal' + ) + expect(splitter).toHaveAttribute('tabindex', '0') + const before = styleRect( + screen.getByText('editor body').parentElement + ).width + fireEvent.keyDown(splitter, { key: 'ArrowRight' }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(before + 10) + fireEvent.keyDown(splitter, { key: 'ArrowLeft', shiftKey: true }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(before - 40) + fireEvent.keyDown(splitter, { key: 'Home' }) + expect( + styleRect(screen.getByText('editor body').parentElement).width + ).toBe(40) + expect(splitter.getAttribute('aria-valuenow')).toBe('3') + }) + + it('closes closable tabs from the button and the Delete key', () => { + const onTabClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Close Preview' })) + fireEvent.keyDown(screen.getByRole('tab', { name: /Preview/ }), { + key: 'Delete', + }) + fireEvent.keyDown(screen.getByRole('tab', { name: 'Editor' }), { + key: 'Delete', + }) + expect(onTabClose.mock.calls).toEqual([['preview'], ['preview']]) + }) + + it('maximizes and folds from the toolbar and shortcuts', () => { + const ref = createRef() + const { container } = render() + fireEvent.click(screen.getAllByRole('button', { name: 'Maximize' })[0]) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBe('ts-editor') + expect( + container.querySelectorAll('.dx-tab-bar[data-dx-hidden]') + ).toHaveLength(2) + const root = container.querySelector('.dx-root')! + fireEvent.pointerDown(screen.getByRole('tab', { name: 'Editor' })) + fireEvent.keyDown(root, { altKey: true, code: 'Equal' }) + expect(ref.current!.getSnapshot().maximizedTabsetId).toBeNull() + fireEvent.keyDown(root, { altKey: true, code: 'Minus' }) + expect( + ref.current!.getSnapshot().tabsets.get('ts-editor')!.isFolded + ).toBe(true) + expect(screen.getAllByRole('tablist')[0]).toHaveAttribute( + 'aria-orientation', + 'vertical' + ) + }) + + it('moves a tab with the keyboard (move mode)', () => { + const ref = createRef() + render() + const editor = screen.getByRole('tab', { name: 'Editor' }) + fireEvent.keyDown(editor, { key: 'm', ctrlKey: true, shiftKey: true }) + expect(screen.getByRole('status').textContent).toMatch( + /^Move Editor: into Terminal/ + ) + fireEvent.keyDown(editor, { key: 'Enter' }) + expect(ref.current!.getSnapshot().tabs.get('editor')!.tabsetId).toBe( + 'ts-terminal' + ) + expect(screen.getByRole('status').textContent).toBe('Moved Editor.') + }) +}) diff --git a/packages/react/src/test/rerender.test.tsx b/packages/react/src/test/rerender.test.tsx new file mode 100644 index 0000000..6a10df9 --- /dev/null +++ b/packages/react/src/test/rerender.test.tsx @@ -0,0 +1,280 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { memo, Profiler, useEffect, useState, type ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + DynamixLayout, + DynamixLayoutProvider, + useDynamixLayout, + useLayoutActions, + useLayoutState, + useTab, + type TabItem, +} from '..' +import { flushFrames, installDom, styleRect } from './dom' + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +/** Counts mounts and renders of a tab's content. */ +function Probe({ id, log }: { id: string; log: string[] }) { + log.push(`render ${id}`) + useEffect(() => { + log.push(`mount ${id}`) + return () => void log.push(`unmount ${id}`) + }, [id, log]) + return {`body ${id}`} +} + +describe('re-renders', () => { + it('an inline tabs array with the same ids does no engine work and remounts nothing', () => { + const log: string[] = [] + const onLayoutChange = vi.fn() + function App({ tick }: { tick: number }) { + // A new array and new elements on every render, as most apps write it. + const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + title: `${id}${tick}`, + content: , + })) + return + } + const { rerender } = render() + const before = screen.getAllByRole('tab').map((t) => t.id) + log.length = 0 + rerender() + rerender() + expect(log.filter((entry) => !entry.startsWith('render'))).toEqual([]) + expect(screen.getAllByRole('tab').map((t) => t.id)).toEqual(before) + expect(screen.getAllByRole('tab')[0].textContent).toBe('a2') + expect(onLayoutChange).not.toHaveBeenCalled() + }) + + it('moving a tab keeps its content mounted (and in place in the DOM)', () => { + const log: string[] = [] + const tabs: TabItem[] = ['a', 'b', 'c'].map((id) => ({ + id, + content: , + })) + render() + const contentA = screen.getByText('body a').parentElement! + log.length = 0 + act(() => { + fireEvent.keyDown(screen.getByRole('tab', { name: 'a' }), { + key: 'm', + ctrlKey: true, + shiftKey: true, + }) + fireEvent.keyDown(screen.getByRole('tab', { name: 'a' }), { + key: 'Enter', + }) + }) + expect( + log.filter( + (entry) => + entry.startsWith('mount') || entry.startsWith('unmount') + ) + ).toEqual([]) + expect(screen.getByText('body a').parentElement).toBe(contentA) + }) + + it('selecting a tab re-renders only the components that read it', () => { + const renders: Record = {} + const ActiveTab = memo(function ActiveTab({ + tabsetId, + }: { + tabsetId: string + }) { + const active = useLayoutState( + (s) => s.tabsets.get(tabsetId)?.activeTabId + ) + renders[tabsetId] = (renders[tabsetId] ?? 0) + 1 + return {active} + }) + let select: (id: string) => void = () => {} + function Actions() { + select = useLayoutActions().selectTab + return null + } + function App() { + const tabs = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const layout = useDynamixLayout({ tabs, defaultLayout: twoTabsets }) + return ( + +
+ + + +
+
+ ) + } + render() + const start = { ...renders } + act(() => select('c')) + expect(screen.getByTestId('right').textContent).toBe('c') + expect(renders.right).toBe(start.right + 1) + expect(renders.left).toBe(start.left) + }) + + it('a splitter drag commits nothing to React; positions come from the fast path', () => { + const commits: string[] = [] + const tabs: TabItem[] = ['a', 'b'].map((id) => ({ + id, + content: {`body ${id}`}, + })) + const { container } = render( + commits.push(phase)}> + + + ) + const splitter = + container.querySelector('[role="separator"]')! + const pointer = { button: 0, pointerId: 1 } + fireEvent.pointerDown(splitter, { + ...pointer, + clientX: 600, + clientY: 10, + }) + const afterStart = commits.length + for (const x of [550, 500, 450, 400]) { + fireEvent.pointerMove(window, { + ...pointer, + clientX: x, + clientY: 10, + }) + flushFrames() + } + expect(commits.length).toBe(afterStart) + expect(styleRect(screen.getByText('body a').parentElement).width).toBe( + 395 + ) + expect(styleRect(splitter).x).toBe(395) + expect(splitter.getAttribute('aria-valuenow')).toBe('33') + fireEvent.pointerUp(window, { ...pointer, clientX: 400, clientY: 10 }) + }) +}) + +const twoTabsets = { + version: 2 as const, + root: { + type: 'row' as const, + id: 'root', + weight: 100, + direction: 'horizontal' as const, + children: [ + { + type: 'tabset' as const, + id: 'left', + weight: 100, + activeTabId: 'a', + children: [{ type: 'tab' as const, id: 'a' }], + }, + { + type: 'tabset' as const, + id: 'right', + weight: 100, + activeTabId: 'b', + children: [ + { type: 'tab' as const, id: 'b' }, + { type: 'tab' as const, id: 'c' }, + ], + }, + ], + }, +} + +describe('headless hooks', () => { + it('throw a clear error outside a layout', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + function Lonely() { + useTab('a') + return null + } + expect(() => render()).toThrow( + '`useTab` must be used inside or .' + ) + spy.mockRestore() + }) + + it('build a custom layout with prop getters that merge user props', () => { + const onClick = vi.fn() + function Tab({ id }: { id: string }) { + const { getTabProps, isActive } = useTab(id) + return ( + + ) + } + function Content({ id }: { id: string }): ReactNode { + const { getTabContentProps } = useTab(id) + return
{`content ${id}`}
+ } + function App() { + const [tabs] = useState([{ id: 'a' }, { id: 'b' }, { id: 'c' }]) + const layout = useDynamixLayout({ tabs, defaultLayout: twoTabsets }) + return ( + +
+ {layout.tabs.map((tab) => ( + + ))} + {layout.tabIds.map((id) => ( + + ))} +
+
+ ) + } + render() + const c = screen.getByRole('tab', { name: 'c' }) + expect(c).toHaveClass('dx-tab', 'mine') + fireEvent.click(c) + expect(onClick).toHaveBeenCalledTimes(1) + expect(screen.getByRole('tab', { name: 'c!' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + expect(screen.getByText('content c')).not.toHaveAttribute( + 'data-dx-hidden' + ) + expect(screen.getByText('content b')).toHaveAttribute('data-dx-hidden') + }) + + it('lets a user handler stop the library handler with preventDefault', () => { + function Tab({ id }: { id: string }) { + const { getTabProps } = useTab(id) + return ( + + ) + } + function App() { + const layout = useDynamixLayout({ + tabs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + defaultLayout: twoTabsets, + }) + return ( + +
+ {layout.tabs.map((tab) => ( + + ))} +
+
+ ) + } + render() + fireEvent.click(screen.getByRole('tab', { name: 'c' })) + expect(screen.getByRole('tab', { name: 'c' })).toHaveAttribute( + 'aria-selected', + 'false' + ) + }) +}) diff --git a/packages/react/src/test/ssr.test.tsx b/packages/react/src/test/ssr.test.tsx new file mode 100644 index 0000000..aa405cf --- /dev/null +++ b/packages/react/src/test/ssr.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment node +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { DynamixLayout, type TabItem } from '..' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content:

editor

}, + { id: 'a tab/with "odd" id', title: 'Odd', content:

odd

}, +] + +describe('server rendering', () => { + it('renders without a DOM', () => { + expect(typeof window).toBe('undefined') + const html = renderToString() + expect(html).toContain('data-dx-measuring') + expect(html).toContain('role="tablist"') + expect(html).toContain('editor') + }) + + it('produces the same markup every time (hydrates without mismatches)', () => { + const first = renderToString() + const second = renderToString() + expect(second).toBe(first) + }) + + it('encodes tab ids into valid DOM ids', () => { + const html = renderToString() + const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]) + expect(ids.length).toBeGreaterThan(0) + for (const id of ids) expect(id).toMatch(/^[A-Za-z0-9_\-«»:]+$/) + }) +}) diff --git a/packages/react/src/test/strict-mode.test.tsx b/packages/react/src/test/strict-mode.test.tsx new file mode 100644 index 0000000..d4bc3bd --- /dev/null +++ b/packages/react/src/test/strict-mode.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DynamixLayout, type TabItem } from '..' +import { FakeResizeObserver, installDom } from './dom' + +const tabs: TabItem[] = [ + { id: 'a', content: 'A' }, + { id: 'b', content: 'B' }, +] + +beforeEach(() => installDom()) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('StrictMode', () => { + it('leaves no listeners or observers behind after unmount', () => { + const added = vi.spyOn(window, 'addEventListener') + const removed = vi.spyOn(window, 'removeEventListener') + const { unmount, container } = render( + + + + ) + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(2) + expect( + FakeResizeObserver.instances.filter((o) => o.connected) + ).toHaveLength(1) + // Unmount in the middle of a drag: its window listeners must go too. + fireEvent.pointerDown(container.querySelector('[role="separator"]')!, { + button: 0, + isPrimary: true, + pointerId: 1, + clientX: 600, + clientY: 10, + }) + expect(added.mock.calls.map(([type]) => type)).toContain('pointermove') + unmount() + expect(FakeResizeObserver.instances.every((o) => !o.connected)).toBe( + true + ) + const count = (spy: typeof added) => + spy.mock.calls.reduce>( + (acc, [type]) => ({ ...acc, [type]: (acc[type] ?? 0) + 1 }), + {} + ) + expect(count(removed)).toEqual(count(added)) + }) + + it('creates the engine once per layout, even when React double-invokes', () => { + const { container } = render( + + + + ) + const ids = Array.from(container.querySelectorAll('[role="tab"]')).map( + (tab) => tab.id + ) + expect(new Set(ids).size).toBe(2) + }) +}) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 0000000..0973d3a --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,258 @@ +import type { + Direction, + DragState, + DropTarget, + Layout, + LayoutChangeReason, + LayoutJSON, + LayoutTreeV1, + Side, + SplitterState, + TabState, + TabsetState, +} from '@dynamix-layout/core' +import type { + ComponentType, + CSSProperties, + HTMLAttributes, + ReactNode, + Ref, + RefCallback, +} from 'react' + +/** A tab: its id, what its tab shows, and its content. */ +export interface TabItem { + /** Unique, stable id. It's what saved layouts store. */ + id: string + /** Tab label. @default id */ + title?: ReactNode + content: ReactNode + /** Show a close button (and allow Delete); closing calls `onTabClose`. @default false */ + closable?: boolean + /** Where the tab goes the first time it appears. @default the last active tabset */ + target?: DropTarget +} + +/** A tab for the headless hooks, where content is rendered by you. */ +export type HeadlessTabItem = Omit & { content?: ReactNode } + +/** Elements you can style through `classNames` and `styles`. */ +export type LayoutSlot = + | 'root' + | 'panel' + | 'tabBar' + | 'tab' + | 'activeTab' + | 'tabClose' + | 'tabContent' + | 'splitter' + | 'dropIndicator' + | 'rootDropZone' + | 'toolbar' + | 'toolbarButton' + +/** DOM props a slot component receives; spread them on its root element and forward `ref`. */ +export type SlotProps = + HTMLAttributes & { + ref?: Ref + [dataAttribute: `data-${string}`]: string | boolean | undefined + } + +/** Props of the `Panel` slot: the box of a tabset, behind its tab bar and content. */ +export type PanelProps = SlotProps & { tabset: TabsetState } + +/** Props of the `TabBar` slot. Children are the tabs and the toolbar. */ +export type TabBarProps = SlotProps & { + tabset: TabsetState + /** Drawn as a vertical strip (a folded tabset in a side-by-side row). */ + isRotated: boolean + children: ReactNode +} + +/** Props of the `Tab` slot. */ +export type TabProps = SlotProps & { + tab: HeadlessTabItem + isActive: boolean + isDragging: boolean + /** Present when the tab is closable. */ + onClose?: () => void + /** Props for the close button, when the tab is closable. */ + closeButtonProps?: SlotProps +} + +/** Props of the `TabContent` slot. */ +export type TabContentProps = SlotProps & { + tab: HeadlessTabItem + isActive: boolean + children: ReactNode +} + +/** Props of the `Splitter` slot. */ +export type SplitterProps = SlotProps & { + splitter: SplitterState + isDragging: boolean +} + +/** Props of the `DropIndicator` slot. */ +export type DropIndicatorProps = SlotProps & { target: DropTarget } + +/** Props of the `RootDropZone` slot: a target at one edge of the layout, shown while dragging. */ +export type RootDropZoneProps = SlotProps & { side: Side; isActive: boolean } + +/** Props of the `TabsetToolbar` slot (maximize and fold buttons). */ +export interface TabsetToolbarProps { + tabset: TabsetState + isRotated: boolean + canMaximize: boolean + canFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void + className?: string + style?: CSSProperties + buttonClassName?: string + buttonStyle?: CSSProperties +} + +/** Components that replace the default slots. Slot components must forward `ref`. */ +export interface LayoutComponents { + Panel: ComponentType + TabBar: ComponentType + Tab: ComponentType + TabContent: ComponentType + Splitter: ComponentType + DropIndicator: ComponentType + RootDropZone: ComponentType + TabsetToolbar: ComponentType +} + +/** Padding inside the layout, in px. */ +export type LayoutPadding = + number | { top?: number; right?: number; bottom?: number; left?: number } + +/** Options shared by `` and `useDynamixLayout`. */ +export interface LayoutBehaviorOptions { + /** Uncontrolled: the initial layout (v1 or v2). Later changes are ignored. */ + defaultLayout?: LayoutJSON | LayoutTreeV1 + /** Controlled: the layout to show (v1 or v2). Update it from `onLayoutChange`. */ + layout?: LayoutJSON | LayoutTreeV1 + /** Called after every committed change, never on mount or for drag frames. */ + onLayoutChange?: ( + layout: LayoutJSON, + details: { reason: LayoutChangeReason } + ) => void + /** Called when a closable tab is closed; remove it from `tabs` to close it. */ + onTabClose?: (tabId: string) => void + /** @default { width: 40, height: 40 } */ + minPanelSize?: { width: number; height: number } + /** @default 10 */ + splitterSize?: number + /** @default 40 */ + tabBarHeight?: number + /** @default true */ + showTabBar?: boolean + /** @default 0 */ + padding?: LayoutPadding + /** @default true */ + allowMaximize?: boolean + /** Requires the tab bar. @default true */ + allowFold?: boolean + /** @default true */ + maximizeOnDoubleClick?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the focused tabset. @default true */ + keyboardShortcuts?: boolean + /** WAI-ARIA tabs activation: select on focus, or on Enter/Space. @default 'automatic' */ + tabActivation?: 'automatic' | 'manual' + /** Container resize throttle; 0 means once per animation frame. @default 0 */ + resizeThrottleMs?: number +} + +/** Props of ``. */ +export interface DynamixLayoutProps + extends + LayoutBehaviorOptions, + Omit< + HTMLAttributes, + 'children' | 'defaultValue' | 'onChange' + > { + tabs: readonly TabItem[] + /** Replace any default slot component. */ + components?: Partial + /** Extra class names, by slot. */ + classNames?: Partial> + /** Extra styles, by slot (positioning is applied last and always wins). */ + styles?: Partial> +} + +/** Actions available anywhere inside a layout. Every function is stable. */ +export type LayoutActions = Pick< + Layout, + | 'moveTab' + | 'moveTabset' + | 'selectTab' + | 'maximize' + | 'restore' + | 'toggleMaximize' + | 'fold' + | 'unfold' + | 'toggleFold' + | 'reset' + | 'toJSON' + | 'getSnapshot' +> + +/** The imperative handle `ref` gives on ``. */ +export interface DynamixLayoutHandle extends LayoutActions { + /** The root element. */ + readonly element: HTMLDivElement | null + /** Moves keyboard focus to a tab. */ + focusTab(tabId: string): void +} + +/** + * A prop getter: merges your props with the library's (see `mergeProps`). + * The element type follows the `ref` you pass, and the returned `ref` is a + * callback that fits any element, so the result can be spread on a `
`, + * ` + + + Reload the page: the layout is restored. + +
+ + setTabs((current) => current.filter((tab) => tab.id !== id)) + } + /> +
+ ) +} diff --git a/examples/react/src/examples/CustomComponentsExample.tsx b/examples/react/src/examples/CustomComponentsExample.tsx index f3db371..08797ad 100644 --- a/examples/react/src/examples/CustomComponentsExample.tsx +++ b/examples/react/src/examples/CustomComponentsExample.tsx @@ -1,18 +1,18 @@ -import Editor from './components/Editor' -import { Terminal } from './components/Terminal' +import Editor from '../components/Editor' +import { Terminal } from '../components/Terminal' import { DynamixLayout, type LayoutTreeV1, type TabItem, } from '@dynamix-layout/react' -import { IframePreview } from './components/IframePreview' +import { IframePreview } from '../components/IframePreview' import '@dynamix-layout/react/styles.css' -import './App.css' +import '../App.css' -import { classNames, components } from './components/layout-config' -import { Slider } from './components/ui/slider' -import { cn } from './lib/utils' -import { Card } from './components/ui/card' +import { classNames, components } from '../components/layout-config' +import { Slider } from '../components/ui/slider' +import { cn } from '../lib/utils' +import { Card } from '../components/ui/card' import { useState } from 'react' const dummyTsCode = ` @@ -216,13 +216,14 @@ const tabs: TabItem[] = [ { id: 'terminal', title: 'Terminal', content: }, ] -function App() { +/** Level 1 with custom slot components (shadcn-style), a saved v1 layout and live size controls. */ +export default function CustomComponentsExample() { const [minTabHeight, setMinTabHeight] = useState(40) const [minTabWidth, setMinTabWidth] = useState(40) const [bondWidth, setBondWidth] = useState(10) return ( -
+
{
) } - -export default App diff --git a/examples/react/src/examples/HeadlessExample.tsx b/examples/react/src/examples/HeadlessExample.tsx new file mode 100644 index 0000000..e686c81 --- /dev/null +++ b/examples/react/src/examples/HeadlessExample.tsx @@ -0,0 +1,131 @@ +import { + DynamixLayoutProvider, + useDynamixLayout, + useSplitter, + useTab, + useTabset, + type HeadlessTabItem, +} from '@dynamix-layout/react' + +const tabs: HeadlessTabItem[] = [ + { id: 'one', title: 'One' }, + { id: 'two', title: 'Two' }, + { id: 'three', title: 'Three' }, +] + +function Tab({ tabId }: { tabId: string }) { + const { getTabProps, isActive, item } = useTab(tabId) + return ( + + ) +} + +function Tabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
+
+ {tabset.tabIds.map((id) => ( + + ))} +
+ + ) +} + +function Content({ tabId }: { tabId: string }) { + const { getTabContentProps, item } = useTab(tabId) + return ( +
+ Content of {item?.title} +
+ ) +} + +function Splitter({ splitterId }: { splitterId: string }) { + const { getSplitterProps, isDragging } = useSplitter(splitterId) + return ( +
+ ) +} + +/** Level 2: no default components or styles, only hooks and prop getters. */ +export default function HeadlessExample() { + const { + controller, + getRootProps, + tabsets, + splitters, + tabIds, + dropIndicator, + } = useDynamixLayout({ + tabs, + tabBarHeight: 36, + splitterSize: 6, + padding: 8, + }) + return ( + +
+ {tabsets.map((tabset) => ( + + ))} + {tabIds.map((id) => ( + + ))} + {splitters.map((splitter) => ( + + ))} + {dropIndicator && ( +
+ )} +
+ + ) +} From 3636b10bd4499e87c12282748ec374d59254837b Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:35:54 +0530 Subject: [PATCH 19/41] build: exports maps, peer deps, CJS types, use client banner and a React 18 check - Every package ships ESM (`index.js`) and CommonJS (`index.cjs`). The CommonJS build used a `.js` extension in a `"type": "module"` package, so Node loaded it as ESM and `require()` failed. - `exports` maps put `types` first in each condition; CommonJS gets `index.d.cts`. Declarations are rolled into one file, built against the published core instead of its sources. - React: `react/jsx-runtime` and `react-dom/*` are external (the bundle inlined React 19's runtime), peers are `^18.0.0 || ^19.0.0`, the bundle starts with 'use client', IIFE/UMD builds are gone, and the stylesheet is `styles.css` (`style.css` stays as an alias). - Solid: `solid-js` is a peer dependency, with the same exports layout. - Core: one UMD build for CDNs (`unpkg`), no `browser` field, and a one-line licence banner instead of the full text. - `sideEffects` lists only CSS. publint and attw report no problems. - A `react-18` CI job installs the packed packages into a project that only has React 18 and renders on the server and in jsdom. --- .github/workflows/ci.yml | 18 +++++ packages/core/package.json | 27 ++++--- packages/core/tsconfig.json | 2 +- packages/core/vite.config.ts | 28 +++---- packages/react/package.json | 44 ++++++----- packages/react/tsconfig.build.json | 8 ++ packages/react/tsconfig.json | 8 +- packages/react/vite.config.ts | 68 +++++----------- packages/solid/package.json | 43 ++++++---- packages/solid/tsconfig.build.json | 8 ++ packages/solid/tsconfig.json | 8 +- packages/solid/vite.config.ts | 64 ++++----------- pnpm-lock.yaml | 122 ++--------------------------- scripts/react18-smoke/package.json | 9 +++ scripts/react18-smoke/smoke.mjs | 44 +++++++++++ 15 files changed, 214 insertions(+), 287 deletions(-) create mode 100644 packages/react/tsconfig.build.json create mode 100644 packages/solid/tsconfig.build.json create mode 100644 scripts/react18-smoke/package.json create mode 100644 scripts/react18-smoke/smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0450e..24c4e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,21 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/ci-setup - uses: ./.github/actions/ci-checks + + react-18: + # The React package must work with React 18 as well as 19: install the + # packed packages into a project that only has React 18. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/ci-setup + - name: Build and pack + run: | + pnpm --filter @dynamix-layout/core --filter @dynamix-layout/react build + mkdir -p /tmp/pack + pnpm --filter @dynamix-layout/core --filter @dynamix-layout/react exec pnpm pack --pack-destination /tmp/pack + - name: Smoke test with React 18 + working-directory: scripts/react18-smoke + run: | + npm install --no-audit --no-fund --no-save react@18 react-dom@18 jsdom@26 /tmp/pack/*.tgz + npm test diff --git a/packages/core/package.json b/packages/core/package.json index 4fbc59a..80c9496 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,24 +21,30 @@ ], "repository": { "type": "git", - "url": "https://github.com/xcode-studio/dynamix-layout.git", + "url": "git+https://github.com/xcode-studio/dynamix-layout.git", "directory": "packages/core" }, "bugs": { "url": "https://github.com/xcode-studio/dynamix-layout/issues" }, "homepage": "https://github.com/xcode-studio/dynamix-layout#readme", - "main": "./dist/core.cjs.js", - "module": "./dist/core.es.js", - "browser": "./dist/core.iife.js", - "types": "./dist/types/index.d.ts", - "typings": "./dist/types/index.d.ts", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.umd.js", "exports": { ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/core.es.js", - "require": "./dist/core.cjs.js" - } + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -51,7 +57,6 @@ "publishConfig": { "access": "public" }, - "type": "module", "engines": { "node": ">=22.16.0", "pnpm": ">=10.13.1" diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 043975b..a228d36 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -7,7 +7,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, - "outDir": "./dist/types", + "outDir": "./dist", "moduleResolution": "Node", "rootDir": "./src", "composite": true diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index b8e0f14..9e3d628 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -3,11 +3,8 @@ import { resolve } from 'path' import dts from 'vite-plugin-dts' import fs from 'fs' import { visualizer } from 'rollup-plugin-visualizer' -import stripComments from 'vite-plugin-strip-comments' -const { version } = JSON.parse( - fs.readFileSync(resolve(__dirname, 'package.json'), 'utf-8') -) +const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) export default defineConfig({ define: { @@ -17,24 +14,23 @@ export default defineConfig({ sourcemap: 'hidden', lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'dynamix.layout.core', - fileName: (format) => `core.${format}.js`, - formats: ['cjs', 'es', 'iife', 'umd'], + name: 'DynamixLayoutCore', + // `.cjs` so Node loads the CommonJS build as CommonJS in this `"type": "module"` package. + fileName: (format) => ({ es: 'index.js', cjs: 'index.cjs', umd: 'index.umd.js' })[format as 'es'], + formats: ['es', 'cjs', 'umd'], }, + rollupOptions: { output: { banner: '/*! @dynamix-layout/core | MIT License */' } }, }, plugins: [ dts({ - outDir: 'dist/types', + outDir: 'dist', // Types-only modules never reach the bundle graph; list sources explicitly. include: ['src'], - exclude: ['node_modules/**', 'src/test/**'], + exclude: ['node_modules/**', 'test/**'], + // One self-contained declaration file, copied to index.d.cts for `require`. + rollupTypes: true, + afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), - visualizer({ - filename: 'core.html', - gzipSize: true, - brotliSize: true, - template: 'treemap', - }), - stripComments({ type: 'none' }), + visualizer({ filename: 'core.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], }) diff --git a/packages/react/package.json b/packages/react/package.json index 959ea99..3449ac6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,27 +27,36 @@ ], "repository": { "type": "git", - "url": "https://github.com/xcode-studio/dynamix-layout", + "url": "git+https://github.com/xcode-studio/dynamix-layout.git", "directory": "packages/react" }, "bugs": { "url": "https://github.com/xcode-studio/dynamix-layout/issues" }, "homepage": "https://github.com/xcode-studio/dynamix-layout#readme", - "main": "./dist/index.cjs.js", - "module": "./dist/index.es.js", - "types": "./dist/types/index.d.ts", - "typings": "./dist/types/index.d.ts", - "style": "./dist/index.css", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "style": "./dist/styles.css", "exports": { ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/index.es.js", - "require": "./dist/index.cjs.js" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, - "./styles.css": "./dist/index.css", - "./style.css": "./dist/index.css" + "./styles.css": "./dist/styles.css", + "./style.css": "./dist/styles.css", + "./package.json": "./package.json" }, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "LICENSE", @@ -55,13 +64,9 @@ "README.md", "!**/*.map" ], - "sideEffects": [ - "./dist/index.css" - ], "publishConfig": { "access": "public" }, - "type": "module", "engines": { "node": ">=22.16.0", "pnpm": ">=10.13.1" @@ -77,11 +82,11 @@ "test": "vitest" }, "dependencies": { - "@dynamix-layout/core": "workspace:*" + "@dynamix-layout/core": "workspace:^" }, "peerDependencies": { - "react": "^0.0.1", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@repo/eslint-config": "workspace:*", @@ -92,7 +97,6 @@ "react-dom": "^19.3.0", "typescript": "^5.9.3", "vite": "^7.3.6", - "vite-plugin-dts": "^4.5.4", - "vite-plugin-lib-inject-css": "^2.2.2" + "vite-plugin-dts": "^4.5.4" } } diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json new file mode 100644 index 0000000..b74039e --- /dev/null +++ b/packages/react/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + // Declarations resolve @dynamix-layout/core from its built package, as users will. + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": {} + }, + "exclude": ["node_modules", "src/test"] +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index d683038..031f960 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, - "outDir": "./dist/types", + "outDir": "./dist", "moduleResolution": "Node", "rootDir": "src", "allowSyntheticDefaultImports": true, @@ -23,10 +23,8 @@ "vitest/globals", "@testing-library/jest-dom", "@testing-library/react" - ], - "composite": true + ] }, "include": ["src/**/*", "src/types/**/*"], - "exclude": ["node_modules"], - "references": [{ "path": "../core" }] + "exclude": ["node_modules"] } diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 0b0c592..aa47bd2 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,86 +1,58 @@ /// import { defineConfig } from 'vite' import { resolve } from 'path' +import fs from 'fs' import dts from 'vite-plugin-dts' import { visualizer } from 'rollup-plugin-visualizer' -import stripComments from 'vite-plugin-strip-comments' -import fs from 'fs' - -const license = fs.readFileSync(resolve(__dirname, '../../LICENSE'), 'utf-8') export default defineConfig({ - define: { - __LICENSE__: JSON.stringify(license), - }, - server: { - port: 5174, - open: false, - }, + server: { port: 5174, open: false }, test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', - server: { - deps: { - inline: ['@dynamix-layout/core'], - }, - }, + server: { deps: { inline: ['@dynamix-layout/core'] } }, }, plugins: [ dts({ - outDir: 'dist/types', + outDir: 'dist', + tsconfigPath: './tsconfig.build.json', // Types-only modules never reach the bundle graph; list sources explicitly. include: ['src'], + exclude: ['node_modules/**', 'src/test/**'], // Keep `@dynamix-layout/core` as a package import in the .d.ts files; the // tsconfig path to its sources is only for developing in this repo. aliasesExclude: [/^@dynamix-layout\/core/], pathsToAliases: false, - exclude: ['node_modules/**', 'src/test/**'], - staticImport: true, - }), - visualizer({ - filename: 'react.html', - gzipSize: true, - brotliSize: true, - template: 'treemap', + // One self-contained declaration file, copied to index.d.cts for `require`. + rollupTypes: true, + afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), - stripComments({ type: 'none' }), + visualizer({ filename: 'react.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], build: { - target: 'esnext', + target: 'es2020', sourcemap: 'hidden', lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'dynamix.layout.react', - fileName: (format) => `index.${format}.js`, - formats: ['cjs', 'es', 'iife', 'umd'], + // `.cjs` so Node loads the CommonJS build as CommonJS in this `"type": "module"` package. + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + formats: ['es', 'cjs'], + cssFileName: 'styles', }, - cssCodeSplit: true, rollupOptions: { - external: ['react', 'react-dom', '@dynamix-layout/core'], + // Subpaths too: bundling `react/jsx-runtime` would tie the package to one React version. + external: [/^react($|\/)/, /^react-dom($|\/)/, /^@dynamix-layout\/core($|\/)/], output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - '@dynamix-layout/core': 'DynamixLayoutCore', - }, - preserveModules: false, + // Next.js App Router: everything here uses hooks or the DOM. + banner: "'use client';", }, }, }, resolve: { - preserveSymlinks: true, alias: [ { find: '@', replacement: resolve(__dirname, './src') }, - { - find: '@dynamix-layout/core', - replacement: resolve(__dirname, '../core/src'), - }, + { find: '@dynamix-layout/core', replacement: resolve(__dirname, '../core/src') }, ], }, - css: { - modules: { - localsConvention: 'camelCaseOnly', - }, - }, }) diff --git a/packages/solid/package.json b/packages/solid/package.json index 827996c..13feb12 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -26,26 +26,36 @@ ], "repository": { "type": "git", - "url": "https://github.com/xcode-studio/dynamix-layout", + "url": "git+https://github.com/xcode-studio/dynamix-layout.git", "directory": "packages/solid" }, "bugs": { "url": "https://github.com/xcode-studio/dynamix-layout/issues" }, "homepage": "https://github.com/xcode-studio/dynamix-layout#readme", - "main": "./dist/index.cjs.js", - "module": "./dist/index.es.js", - "types": "./dist/types/index.d.ts", - "typings": "./dist/types/index.d.ts", - "style": "./dist/index.css", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "style": "./dist/styles.css", "exports": { ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/index.es.js", - "require": "./dist/index.cjs.js" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, - "./style.css": "./dist/index.css" + "./styles.css": "./dist/styles.css", + "./style.css": "./dist/styles.css", + "./package.json": "./package.json" }, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "LICENSE", @@ -53,13 +63,9 @@ "README.md", "!**/*.map" ], - "sideEffects": [ - "./dist/index.css" - ], "publishConfig": { "access": "public" }, - "type": "module", "engines": { "node": ">=22.16.0", "pnpm": ">=10.13.1" @@ -84,10 +90,13 @@ "typescript": "^5.9.3", "vite": "^7.3.6", "vite-plugin-solid": "2.11.14", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "solid-js": "^1.9.15" }, "dependencies": { - "@dynamix-layout/core": "workspace:*", - "solid-js": "^1.9.15" + "@dynamix-layout/core": "workspace:^" + }, + "peerDependencies": { + "solid-js": "^1.8.0" } } diff --git a/packages/solid/tsconfig.build.json b/packages/solid/tsconfig.build.json new file mode 100644 index 0000000..b74039e --- /dev/null +++ b/packages/solid/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + // Declarations resolve @dynamix-layout/core from its built package, as users will. + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": {} + }, + "exclude": ["node_modules", "src/test"] +} diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json index c1e65b2..200a81c 100644 --- a/packages/solid/tsconfig.json +++ b/packages/solid/tsconfig.json @@ -10,7 +10,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, - "outDir": "./dist/types", + "outDir": "./dist", "moduleResolution": "Node", "rootDir": "src", "allowSyntheticDefaultImports": true, @@ -20,10 +20,8 @@ "@dynamix-layout/core/*": ["../core/src/*"] }, "baseUrl": ".", - "types": ["vitest/globals", "@testing-library/jest-dom", "solid-js"], - "composite": true + "types": ["vitest/globals", "@testing-library/jest-dom", "solid-js"] }, "include": ["src/**/*", "src/types/**/*"], - "exclude": ["node_modules"], - "references": [{ "path": "../core" }] + "exclude": ["node_modules"] } diff --git a/packages/solid/vite.config.ts b/packages/solid/vite.config.ts index 5ea181d..5f7cd69 100644 --- a/packages/solid/vite.config.ts +++ b/packages/solid/vite.config.ts @@ -3,86 +3,56 @@ import { defineConfig } from 'vite' import solidPlugin from 'vite-plugin-solid' -import stripComments from 'vite-plugin-strip-comments' import { visualizer } from 'rollup-plugin-visualizer' import dts from 'vite-plugin-dts' import { resolve } from 'path' import fs from 'fs' -const license = fs.readFileSync(resolve(__dirname, '../../LICENSE'), 'utf-8') - export default defineConfig({ - define: { - __LICENSE__: JSON.stringify(license), - }, - server: { - port: 5174, - open: false, - }, + server: { port: 5174, open: false }, test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts', testTransformMode: { web: ['/[jt]sx?$/'] }, - deps: { - inline: [/@solidjs\/start/, /solid-js/], - }, + deps: { inline: [/@solidjs\/start/, /solid-js/] }, }, plugins: [ solidPlugin(), dts({ + outDir: 'dist', + tsconfigPath: './tsconfig.build.json', + // Types-only modules never reach the bundle graph; list sources explicitly. include: ['src'], - // Keep `@dynamix-layout/core` as a package import in the .d.ts files; the - // tsconfig path to its sources is only for developing in this repo. + exclude: ['node_modules/**', 'src/test/**'], + // Keep `@dynamix-layout/core` as a package import in the .d.ts files. aliasesExclude: [/^@dynamix-layout\/core/], pathsToAliases: false, - outDir: 'dist/types', - exclude: ['node_modules/**', 'src/test/**'], - staticImport: true, - }), - visualizer({ - filename: 'react.html', - gzipSize: true, - brotliSize: true, - template: 'treemap', + // One self-contained declaration file, copied to index.d.cts for `require`. + rollupTypes: true, + afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), - stripComments({ type: 'none' }), + visualizer({ filename: 'solid.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], build: { - target: 'esnext', + target: 'es2020', sourcemap: 'hidden', lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'dynamix.layout.solid', - fileName: (format) => `index.${format}.js`, + // `.cjs` so Node loads the CommonJS build as CommonJS in this `"type": "module"` package. + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), formats: ['es', 'cjs'], + cssFileName: 'styles', }, - cssCodeSplit: true, rollupOptions: { - external: ['solid-js', 'solid-js/web', '@dynamix-layout/core'], - output: { - globals: { - 'solid-js': 'solidJs', - 'solid-js/web': 'solidJsWeb', - '@dynamix-layout/core': 'DynamixLayoutCore', - }, - preserveModules: false, - }, + external: [/^solid-js($|\/)/, /^@dynamix-layout\/core($|\/)/], }, }, resolve: { alias: [ { find: '@', replacement: resolve(__dirname, './src') }, - { - find: '@dynamix-layout/core', - replacement: resolve(__dirname, '../core/src'), - }, + { find: '@dynamix-layout/core', replacement: resolve(__dirname, '../core/src') }, ], conditions: ['development', 'browser'], }, - css: { - modules: { - localsConvention: 'camelCaseOnly', - }, - }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74a57ee..4a56ae9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -541,7 +541,7 @@ importers: packages/react: dependencies: '@dynamix-layout/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core devDependencies: '@repo/eslint-config': @@ -571,18 +571,12 @@ importers: vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4(@types/node@24.19.1)(rollup@4.64.0)(typescript@5.9.3)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) - vite-plugin-lib-inject-css: - specifier: ^2.2.2 - version: 2.2.2(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) packages/solid: dependencies: '@dynamix-layout/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core - solid-js: - specifier: ^1.9.15 - version: 1.9.15 devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -599,6 +593,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + solid-js: + specifier: ^1.9.15 + version: 1.9.15 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -626,64 +623,6 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@ast-grep/napi-darwin-arm64@0.36.3': - resolution: {integrity: sha512-uM0Hrm5gcHqaBL64ktmPBFMTorTlPKWsUfi0E2Cg09GJfeYWvZmicCqgd7qVtjURmQvFQdb4JSqHIkJvws6Uqw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@ast-grep/napi-darwin-x64@0.36.3': - resolution: {integrity: sha512-wEMeQw8lRL66puG2m8m0kDRQDtubygj59HA/cmut2V5SPx/13BN3wuEk6JPv97gqGUCUGhG2+5Z6UZ/Ll2q01Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@ast-grep/napi-linux-arm64-gnu@0.36.3': - resolution: {integrity: sha512-sMsTMaUjW7SM8KPbLviCSBuM4zgJcwvie1yZI92HKSlFzC7ABe7X7UvyUREB+JwqccDVEL5yOJAjqB8eFSCizw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@ast-grep/napi-linux-arm64-musl@0.36.3': - resolution: {integrity: sha512-2XRmNYuovZu0Pa4J3or4PKMkQZnXXfpVcCrPwWB/2ytX7XUo+TWLgYE8rPVnJOyw5zujkveFb0XUrro9mQgLzw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@ast-grep/napi-linux-x64-gnu@0.36.3': - resolution: {integrity: sha512-mTwPRbBi1feGqR2b5TWC5gkEDeRi8wfk4euF5sKNihfMGHj6pdfINHQ3QvLVO4C7z0r/wgWLAvditFA0b997dg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@ast-grep/napi-linux-x64-musl@0.36.3': - resolution: {integrity: sha512-tMGPrT+zuZzJK6n1cD1kOii7HYZE9gUXjwtVNE/uZqXEaWP6lmkfoTMbLjnxEe74VQbmaoDGh1/cjrDBnqC6Uw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@ast-grep/napi-win32-arm64-msvc@0.36.3': - resolution: {integrity: sha512-7pFyr9+dyV+4cBJJ1I57gg6PDXP3GBQeVAsEEitzEruxx4Hb4cyNro54gGtlsS+6ty+N0t004tPQxYO2VrsPIg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@ast-grep/napi-win32-ia32-msvc@0.36.3': - resolution: {integrity: sha512-MPAgccH9VscRaFuEBMzDGPS+3c4cKNVGIVJ7WSNa1nZtLQ0eFEaPJ7pyDnCezgVSxfNFVYBvKyyF/vcm7Qc9+A==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@ast-grep/napi-win32-x64-msvc@0.36.3': - resolution: {integrity: sha512-TIVtuSbXhty9kaSEfr4ULWx5PAuUeGgUkFaR60lmOs7sGTWgpig+suwKfTmevoAblFknCW/aMHOwziwJoUZA6A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@ast-grep/napi@0.36.3': - resolution: {integrity: sha512-ExypohE8L7FvKBHxu7UpwcV9XVfyS+AqNZKyKIfxYwJyD9l7Gw6pmMYd7J2uopJsPEIUf44/emEFds6nFUx/dw==} - engines: {node: '>= 10'} - '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -7564,11 +7503,6 @@ packages: vite: optional: true - vite-plugin-lib-inject-css@2.2.2: - resolution: {integrity: sha512-NF30p0GwtfSAmVlxo2NgPXM2rEdtgV7LFi4lkzajKD7P3Ru/ZAFmI533M0Z5qyMZpvNMxVGkewzpjD0HOWtbDQ==} - peerDependencies: - vite: '*' - vite-plugin-solid@2.11.14: resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} peerDependencies: @@ -7918,45 +7852,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@ast-grep/napi-darwin-arm64@0.36.3': - optional: true - - '@ast-grep/napi-darwin-x64@0.36.3': - optional: true - - '@ast-grep/napi-linux-arm64-gnu@0.36.3': - optional: true - - '@ast-grep/napi-linux-arm64-musl@0.36.3': - optional: true - - '@ast-grep/napi-linux-x64-gnu@0.36.3': - optional: true - - '@ast-grep/napi-linux-x64-musl@0.36.3': - optional: true - - '@ast-grep/napi-win32-arm64-msvc@0.36.3': - optional: true - - '@ast-grep/napi-win32-ia32-msvc@0.36.3': - optional: true - - '@ast-grep/napi-win32-x64-msvc@0.36.3': - optional: true - - '@ast-grep/napi@0.36.3': - optionalDependencies: - '@ast-grep/napi-darwin-arm64': 0.36.3 - '@ast-grep/napi-darwin-x64': 0.36.3 - '@ast-grep/napi-linux-arm64-gnu': 0.36.3 - '@ast-grep/napi-linux-arm64-musl': 0.36.3 - '@ast-grep/napi-linux-x64-gnu': 0.36.3 - '@ast-grep/napi-linux-x64-musl': 0.36.3 - '@ast-grep/napi-win32-arm64-msvc': 0.36.3 - '@ast-grep/napi-win32-ia32-msvc': 0.36.3 - '@ast-grep/napi-win32-x64-msvc': 0.36.3 - '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -16038,13 +15933,6 @@ snapshots: - rollup - supports-color - vite-plugin-lib-inject-css@2.2.2(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): - dependencies: - '@ast-grep/napi': 0.36.3 - magic-string: 0.30.21 - picocolors: 1.1.1 - vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) - vite-plugin-solid@2.11.14(@testing-library/jest-dom@6.9.1)(solid-js@1.9.15)(vite@6.4.3(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.7 diff --git a/scripts/react18-smoke/package.json b/scripts/react18-smoke/package.json new file mode 100644 index 0000000..b47abb7 --- /dev/null +++ b/scripts/react18-smoke/package.json @@ -0,0 +1,9 @@ +{ + "name": "react18-smoke", + "private": true, + "type": "module", + "description": "Checks the packed React adapter against React 18. Dependencies are installed by CI.", + "scripts": { + "test": "node smoke.mjs" + } +} diff --git a/scripts/react18-smoke/smoke.mjs b/scripts/react18-smoke/smoke.mjs new file mode 100644 index 0000000..37b1358 --- /dev/null +++ b/scripts/react18-smoke/smoke.mjs @@ -0,0 +1,44 @@ +/** + * Installs the packed @dynamix-layout/core and @dynamix-layout/react into a + * project that only has React 18, then renders on the server and in jsdom. + * Run by the `react-18` CI job (see .github/workflows/ci.yml). + */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { createElement as h, act, version } from 'react' +import { renderToString } from 'react-dom/server' + +assert.match(version, /^18\./, `expected React 18, got ${version}`) +const tabs = ['editor', 'terminal', 'preview'].map((id) => ({ id, title: id.toUpperCase(), content: h('p', null, `${id} body`) })) +const { DynamixLayout } = await import('@dynamix-layout/react') + +// Server render: no DOM, deterministic markup. +const html = renderToString(h(DynamixLayout, { tabs })) +assert.ok(html.includes('role="tablist"'), 'server markup has tab lists') +assert.equal(renderToString(h(DynamixLayout, { tabs })), html, 'server markup is deterministic') + +// Client render in jsdom. +const dom = new JSDOM('
', { pretendToBeVisual: true }) +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, IS_REACT_ACT_ENVIRONMENT: true }) +const { createRoot } = await import('react-dom/client') +const reasons = [] +const root = createRoot(document.getElementById('root')) +await act(async () => root.render(h(DynamixLayout, { tabs, onLayoutChange: (_, details) => reasons.push(details.reason) }))) +const tabEls = [...document.querySelectorAll('[role=tab]')] +assert.deepEqual(tabEls.map((tab) => tab.textContent), ['EDITOR', 'TERMINAL', 'PREVIEW']) + +const key = (init) => act(async () => tabEls[0].dispatchEvent(new dom.window.KeyboardEvent('keydown', { bubbles: true, ...init }))) +await key({ key: 'm', ctrlKey: true, shiftKey: true }) +await key({ key: 'Enter' }) +assert.deepEqual(reasons, ['move'], 'keyboard move mode moved the tab') + +await act(async () => root.unmount()) +assert.equal(document.getElementById('root').innerHTML, '', 'unmounted cleanly') + +// CommonJS consumers. +const require = createRequire(import.meta.url) +assert.equal(typeof require('@dynamix-layout/core').createLayout, 'function') +assert.equal(typeof require('@dynamix-layout/react').useTab, 'function') + +console.log('React 18 smoke test passed') From a31dc4c2aef4facb8b80f94b9e3d97c36e148ccc Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:36:31 +0530 Subject: [PATCH 20/41] chore: changesets for 2.0.0 of core, react and solid --- .changeset/v2-core.md | 27 +++++++++++++++++++++++++++ .changeset/v2-react.md | 30 ++++++++++++++++++++++++++++++ .changeset/v2-solid.md | 12 ++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 .changeset/v2-core.md create mode 100644 .changeset/v2-react.md create mode 100644 .changeset/v2-solid.md diff --git a/.changeset/v2-core.md b/.changeset/v2-core.md new file mode 100644 index 0000000..c802b4b --- /dev/null +++ b/.changeset/v2-core.md @@ -0,0 +1,27 @@ +--- +'@dynamix-layout/core': major +--- + +**v2: an instance-based, pure layout engine.** See the migration guide for every rename. + +- **`createLayout(options)` replaces `new DynamixLayoutCore()`.** Each instance is independent: there is no module-level state, no static caches, no timers, and nothing touches the DOM. Two layouts on a page, or one per server request, no longer interfere. +- **Immutable snapshots** (`getSnapshot()` / `subscribe()`) share unchanged parts by reference: `tabsets`, `tabs`, `splitters`, `rects` and `drag`. +- **Typed actions** that return whether anything changed and never throw: `moveTab`, `moveTabset`, `selectTab`, `addTab`, `removeTab`, `setTabs`, `resizeSplitter`, `moveSplitterBy`, `maximize`/`restore`, `fold`/`unfold`, plus one drag model for tabs and splitters (`startDrag`, `updateDrag`, `endDrag`, `cancelDrag`) and hit testing (`getDropTarget`, `listDropTargets`). +- **`onLayoutChange(layout, reason)`** reports committed changes. +- **New saved format, `LayoutJSON`** (`version: 2`): readable names, explicit directions, and tab ids as the stable identity. +- **Old layouts:** v1 trees load automatically, or convert them with `migrateLayoutFromV1`. They render pixel-identically, as checked against layouts recorded from v1. +- **Deterministic ids**, safe for server rendering; no `crypto.randomUUID()`, which threw outside secure contexts. +- **Rects are relative to the layout root.** v1 misplaced layouts that weren't at the viewport's top-left. +- **No import-time side effects:** the console banner and `window.__DYNAMIX_LAYOUT__` are gone. Use `version` instead. +- **Fixed:** + - nested rows left behind after some moves; + - stale sizes when every open panel had weight 0; + - a row stuck at its minimum next to a folded tabset; + - the root staying oversized after the container shrank; + - center drops inserting before the last tab (they now append). +- **Packaging:** the CommonJS build loads with `require()` (it was ESM under a `.cjs.js` name), and exports maps give each condition its own types. + +**Breaking:** +- `DynamixLayoutCore`, `Node`, `Bond`, `Node.cache`, `Queue`, `createReactiveState`, the comparators, the drop-preview and DOM helpers, and the `LayoutTree`/`NodeOptions` types are removed. +- `updateTree(src, des, area)` becomes `moveTab`/`moveTabset(id, target)`, with `'contain'` renamed to `'center'`. +- `updateDimension` becomes `setContainerRect`, and the debounce arguments are gone; throttle in the adapter instead. diff --git a/.changeset/v2-react.md b/.changeset/v2-react.md new file mode 100644 index 0000000..26022b2 --- /dev/null +++ b/.changeset/v2-react.md @@ -0,0 +1,30 @@ +--- +'@dynamix-layout/react': major +--- + +**v2: a small component API, headless hooks, accessibility and SSR.** See the migration guide for every rename. + +- **``**: + - `tabs` is a list of objects (`{ id, title, content, closable, target }`); + - `defaultLayout` (uncontrolled) or `layout` (controlled) takes v1 or v2 JSON, and `onLayoutChange(layout, { reason })` reports changes; + - `components`, `classNames` and `styles` replace the wrapper and styling props; + - `ref` gives an imperative handle. +- **The engine is created once and never rebuilt by prop changes.** An inline `tabs` array with unchanged ids does no engine work and remounts nothing, and moving a tab never remounts its content. +- **Headless API:** `useDynamixLayout` + `DynamixLayoutProvider`, `useTabset`, `useTab`, `useSplitter`, `useLayoutState` (re-renders only for the selected value), `useLayoutActions` and `useDragState`. Prop getters merge your refs, classes, styles and handlers. +- **Pointer-event dragging** for tabs, tabsets and splitters: touch long-press, Escape cancels, and it works over iframes. Drags and resizes never re-render React. +- **ResizeObserver** follows the container, so sidebars and flex parents resize the layout. +- **Accessibility:** WAI-ARIA tabs and window splitter patterns, keyboard resizing, keyboard move mode (Mod+Shift+M), and a live region. +- **SSR-safe and deterministic**, with a `'use client'` entry for the Next.js App Router. Works with React 18 and 19; the bundle no longer inlines React 19's JSX runtime. +- **Fixed:** callback refs are honoured, `layoutTree` changes are no longer ignored, and duplicate DOM ids are gone. + +**Breaking:** +- Removed: + - the v1 props and wrapper components (`WrapTab*`, `SliderElement`, `HoverElement`, `RootSplitterHoverEl`); + - `getTabOutput`; + - the v1 `useDynamixLayout` return shape. +- `updateJSON` becomes `onLayoutChange`, which receives v2 JSON and is not called on mount. +- `ref` returns a `DynamixLayoutHandle` (`handle.element` is the root). +- Keyboard shortcuts only act when focus is inside the layout. +- Default classes are `.dx-*`, and data attributes are `data-dx-*`. +- The stylesheet is `@dynamix-layout/react/styles.css` (`style.css` still works). +- React 17 is no longer supported. diff --git a/.changeset/v2-solid.md b/.changeset/v2-solid.md new file mode 100644 index 0000000..21950fc --- /dev/null +++ b/.changeset/v2-solid.md @@ -0,0 +1,12 @@ +--- +'@dynamix-layout/solid': major +--- + +**Runs on the v2 core.** `` keeps its props, default components and drag-and-drop, but each component now has its own engine instance. Two Solid layouts on a page no longer share state, and a layout away from the viewport's top-left is placed correctly. + +**Breaking:** +- `layoutTree` accepts v2 JSON (v1 trees are migrated automatically), and `updateJSON` receives v2 `LayoutJSON`. +- `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed, and tab ids are the tab labels. +- `useDynamixLayout` returns a new shape. +- The slider timeout options no longer apply; splitters update once per frame. +- `solid-js` is a peer dependency. From 12fa5e5fc1722dae62db031730d3aa325124fd66 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:38:35 +0530 Subject: [PATCH 21/41] refactor(react): split layout parts and keyboard move mode; add coverage tooling DynamixLayout.tsx keeps the root component and LayoutParts.tsx the memoized per-tabset, tab, content and splitter parts; keyboard move mode gets its own module. rootDir moves to the declaration-only tsconfig so the dev typecheck, which reads core from source, is clean again. Adds @vitest/coverage-v8 and tests for the public core exports (no v1 leftovers, no window global), custom id factories, setOptions, moveTabset, targeted addTab, splitter sources in drop helpers and the production duplicate-id path. Core coverage: 93.8% statements, 89.5% branches, 96.1% functions, 96.1% lines. --- .gitignore | 1 + package.json | 1 + packages/core/test/v2/public-api.test.ts | 173 +++++++++++++++ .../react/src/components/DynamixLayout.tsx | 206 +----------------- packages/react/src/components/LayoutParts.tsx | 206 ++++++++++++++++++ .../react/src/internal/keyboard-move-mode.ts | 96 ++++++++ packages/react/src/internal/keyboard.ts | 94 +------- packages/react/tsconfig.build.json | 3 +- packages/react/tsconfig.json | 1 - packages/solid/tsconfig.build.json | 3 +- packages/solid/tsconfig.json | 1 - pnpm-lock.yaml | 66 +++++- 12 files changed, 546 insertions(+), 305 deletions(-) create mode 100644 packages/core/test/v2/public-api.test.ts create mode 100644 packages/react/src/components/LayoutParts.tsx create mode 100644 packages/react/src/internal/keyboard-move-mode.ts diff --git a/.gitignore b/.gitignore index 7ef234b..7940894 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ pids lib-cov react.html core.html +solid.html # Coverage directory used by tools like istanbul coverage *.lcov diff --git a/package.json b/package.json index 97c330a..7502c4c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@testing-library/react": "^16.3.3", "@types/node": "^24.19.1", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "^4.1.11", "eslint": "^9.39.5", "eslint-plugin-react": "^7.37.5", diff --git a/packages/core/test/v2/public-api.test.ts b/packages/core/test/v2/public-api.test.ts new file mode 100644 index 0000000..412b895 --- /dev/null +++ b/packages/core/test/v2/public-api.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import * as api from '../../src' +import { createLayout, applyRect, version } from '../../src' +import { isRow, isTab, isTabset } from '../../src/model/guards' +import { createIdGenerator } from '../../src/ids' + +const BOX = { x: 0, y: 0, width: 1210, height: 800 } +const create = (extra: Partial[0]> = {}) => { + const layout = createLayout({ + tabs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + onWarning: () => {}, + ...extra, + }) + layout.setContainerRect(BOX) + return layout +} + +describe('public exports', () => { + it('exposes the v2 API and nothing from v1', () => { + for (const name of [ + 'createLayout', + 'migrateLayoutFromV1', + 'isLayoutV1', + 'DynamixLayoutError', + 'LAYOUT_VERSION', + 'getTabBarPlacement', + 'getTabContentRect', + 'getRootDropZoneRect', + 'applyRect', + 'createFrameScheduler', + 'version', + ]) + expect(api).toHaveProperty(name) + for (const name of [ + 'DynamixLayoutCore', + 'Node', + 'Bond', + 'Queue', + 'createReactiveState', + 'getTabsetDropPreview', + ]) + expect(api).not.toHaveProperty(name) + expect(typeof version).toBe('string') + }) + + it('has no import-time side effects on window', async () => { + expect( + (globalThis as { __DYNAMIX_LAYOUT__?: unknown }).__DYNAMIX_LAYOUT__ + ).toBeUndefined() + }) + + it('applyRect positions an element', () => { + const style: Record = {} + applyRect({ style } as unknown as HTMLElement, { + x: 1, + y: 2, + width: 3, + height: 4, + }) + expect(style).toEqual({ + left: '1px', + top: '2px', + width: '3px', + height: '4px', + }) + }) + + it('node guards', () => { + const tab = { type: 'tab', id: 't' } as const + expect([isTab(tab), isTabset(tab), isRow(tab)]).toEqual([ + true, + false, + false, + ]) + }) +}) + +describe('createLayout options and less common actions', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('uses a custom createId', () => { + let n = 0 + const layout = create({ createId: (kind) => `${kind}#${++n}` }) + expect([...layout.getSnapshot().tabsets.keys()]).toEqual([ + 'tabset#1', + 'tabset#3', + 'tabset#4', + ]) + const ids = createIdGenerator((kind, hint) => `${kind}:${hint ?? 'x'}`) + expect(ids.createId('tabset', 'q')).toBe('tabset:q') + }) + + it('setOptions resizes without rebuilding', () => { + const layout = create() + const root = layout.getSnapshot().root + layout.setOptions({ + splitterSize: 20, + minPanelSize: { width: 100, height: 60 }, + }) + expect(layout.getSnapshot().root).toBe(root) + expect( + [...layout.getSnapshot().rects.splitters.values()][0].width + ).toBe(20) + }) + + it('moves tabsets, adds tabs at a target, and removes tabs', () => { + const layout = create() + expect( + layout.moveTabset('ts-c', { + type: 'tab', + tabId: 'a', + position: 'after', + }) + ).toBe(true) + expect(layout.getSnapshot().tabsets.get('ts-a')!.tabIds).toEqual([ + 'a', + 'c', + ]) + expect( + layout.addTab({ id: 'd' }, { type: 'root', position: 'bottom' }) + ).toBe(true) + expect(layout.addTab({ id: 'd' })).toBe(false) + expect(layout.getSnapshot().root.direction).toBe('vertical') + expect(layout.removeTab('d')).toBe(true) + expect(layout.removeTab('nope')).toBe(false) + }) + + it('drop helpers ignore splitter sources', () => { + const layout = create() + const [id] = layout.getSnapshot().splitters.keys() + expect( + layout.getDropTarget( + { x: 10, y: 10 }, + { type: 'splitter', splitterId: id } + ) + ).toBeNull() + expect( + layout.listDropTargets({ type: 'splitter', splitterId: id }) + ).toEqual([]) + const target = layout.getDropTarget( + { x: 100, y: 400 }, + { type: 'tab', tabId: 'c' } + )! + expect(layout.getDropIndicatorRect(target)).toEqual({ + x: 0, + y: 0, + width: 300, + height: 800, + }) + }) + + it('keeps the first duplicate tab in production and warns', () => { + vi.stubEnv('NODE_ENV', 'production') + const warnings: string[] = [] + const layout = createLayout({ + tabs: [{ id: 'a' }, { id: 'a' }], + onWarning: (w) => warnings.push(w.code), + }) + expect([...layout.getSnapshot().tabs.keys()]).toEqual(['a']) + expect(warnings).toEqual(['DUPLICATE_TAB_ID']) + }) + + it('the default warning handler logs only in development', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + createLayout({ tabs: [{ id: 'a' }] }).selectTab('nope') + expect(warn).toHaveBeenCalledWith('[dynamix-layout] Unknown tab "nope"') + warn.mockClear() + vi.stubEnv('NODE_ENV', 'production') + createLayout({ tabs: [{ id: 'a' }] }).selectTab('nope') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/packages/react/src/components/DynamixLayout.tsx b/packages/react/src/components/DynamixLayout.tsx index 9f5ce1c..60aa04c 100644 --- a/packages/react/src/components/DynamixLayout.tsx +++ b/packages/react/src/components/DynamixLayout.tsx @@ -1,29 +1,29 @@ import { forwardRef, - memo, useImperativeHandle, useRef, useState, - type CSSProperties, type ForwardedRef, type ForwardRefExoticComponent, type RefAttributes, - type MouseEvent, - type PointerEvent, type ReactElement, type Ref, } from 'react' import { LayoutContext, fromPublicController } from '../context/layout-context' import { useDynamixLayout } from '../hooks/use-dynamix-layout' -import { useSplitter } from '../hooks/use-splitter' -import { useTab } from '../hooks/use-tab' -import { useTabset } from '../hooks/use-tabset' import type { DynamixLayoutHandle, DynamixLayoutProps, LayoutComponents, - LayoutSlot, } from '../types' +import { + ContentView, + sameParts, + slot, + SplitterView, + TabsetView, + type Parts, +} from './LayoutParts' import { DropIndicator } from './DropIndicator' import { LiveRegion } from './LiveRegion' import { Panel } from './Panel' @@ -45,169 +45,6 @@ const DEFAULT_COMPONENTS: LayoutComponents = { TabsetToolbar, } -/** Rendering settings shared by the parts below. */ -interface Parts { - components: LayoutComponents - classNames: Partial> - styles: Partial> - showTabBar: boolean - allowMaximize: boolean - allowFold: boolean -} - -const slot = (parts: Parts, name: LayoutSlot) => ({ - className: parts.classNames[name], - style: parts.styles[name], -}) - -const TabView = memo(function TabView({ - tabId, - parts, -}: { - tabId: string - parts: Parts -}) { - const { item, isActive, isDragging, close, getTabProps } = useTab(tabId) - if (!item) return null - const props = getTabProps({ - className: - [parts.classNames.tab, isActive && parts.classNames.activeTab] - .filter(Boolean) - .join(' ') || undefined, - style: { - ...parts.styles.tab, - ...(isActive ? parts.styles.activeTab : {}), - }, - }) - const closeButtonProps = item.closable - ? { - className: ['dx-tab-close', parts.classNames.tabClose] - .filter(Boolean) - .join(' '), - style: parts.styles.tabClose, - 'aria-label': `Close ${typeof item.title === 'string' ? item.title : item.id}`, - tabIndex: -1, - onPointerDown: (event: PointerEvent) => event.stopPropagation(), - onClick: (event: MouseEvent) => { - event.stopPropagation() - close() - }, - } - : undefined - const Component = parts.components.Tab - return ( - - ) -}) - -const TabsetView = memo(function TabsetView({ - tabsetId, - parts, -}: { - tabsetId: string - parts: Parts -}) { - const { - tabset, - isRotated, - getPanelProps, - getTabBarProps, - toggleMaximize, - toggleFold, - } = useTabset(tabsetId) - if (!tabset) return null - const { - Panel: PanelComponent, - TabBar: TabBarComponent, - TabsetToolbar: Toolbar, - } = parts.components - const canMaximize = parts.allowMaximize && tabset.canMaximize - const canFold = parts.allowFold && tabset.canFold && !tabset.isMaximized - return ( - <> - - {parts.showTabBar && ( - - {tabset.tabIds.map((tabId) => ( - - ))} - {(canMaximize || canFold) && ( - - )} - - )} - - ) -}) - -const ContentView = memo(function ContentView({ - tabId, - parts, -}: { - tabId: string - parts: Parts -}) { - const { item, isActive, getTabContentProps } = useTab(tabId) - if (!item) return null - const Component = parts.components.TabContent - return ( - - {item.content} - - ) -}) - -const SplitterView = memo(function SplitterView({ - splitterId, - parts, -}: { - splitterId: string - parts: Parts -}) { - const { splitter, isDragging, getSplitterProps } = useSplitter(splitterId) - if (!splitter) return null - const Component = parts.components.Splitter - return ( - - ) -}) - function DynamixLayoutImpl( props: DynamixLayoutProps, ref: ForwardedRef @@ -375,33 +212,6 @@ function DynamixLayoutImpl( ) } -function sameParts(a: Parts, b: Parts): boolean { - const shallow = (x: object, y: object) => { - const keys = Object.keys(x) - return ( - keys.length === Object.keys(y).length && - keys.every( - (k) => - (x as Record)[k] === - (y as Record)[k] - ) - ) - } - return ( - a.showTabBar === b.showTabBar && - a.allowMaximize === b.allowMaximize && - a.allowFold === b.allowFold && - shallow(a.components, b.components) && - shallow(a.classNames, b.classNames) && - Object.keys({ ...a.styles, ...b.styles }).every((k) => - shallow( - (a.styles as Record)[k] ?? {}, - (b.styles as Record)[k] ?? {} - ) - ) - ) -} - /** * A docking layout: tabs in tabsets, split by draggable splitters, with tab * drag-and-drop, maximize and fold. Most apps only need this component. diff --git a/packages/react/src/components/LayoutParts.tsx b/packages/react/src/components/LayoutParts.tsx new file mode 100644 index 0000000..d5dcdc3 --- /dev/null +++ b/packages/react/src/components/LayoutParts.tsx @@ -0,0 +1,206 @@ +import { + memo, + type CSSProperties, + type MouseEvent, + type PointerEvent, +} from 'react' +import { useSplitter } from '../hooks/use-splitter' +import { useTab } from '../hooks/use-tab' +import { useTabset } from '../hooks/use-tabset' +import type { LayoutComponents, LayoutSlot } from '../types' + +/** + * The pieces renders for each tabset, tab, tab content and + * splitter. Each is memoized and subscribes only to its own state. + */ + +/** Rendering settings shared by the parts below. */ +export interface Parts { + components: LayoutComponents + classNames: Partial> + styles: Partial> + showTabBar: boolean + allowMaximize: boolean + allowFold: boolean +} + +export const slot = (parts: Parts, name: LayoutSlot) => ({ + className: parts.classNames[name], + style: parts.styles[name], +}) + +export const TabView = memo(function TabView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, isDragging, close, getTabProps } = useTab(tabId) + if (!item) return null + const props = getTabProps({ + className: + [parts.classNames.tab, isActive && parts.classNames.activeTab] + .filter(Boolean) + .join(' ') || undefined, + style: { + ...parts.styles.tab, + ...(isActive ? parts.styles.activeTab : {}), + }, + }) + const closeButtonProps = item.closable + ? { + className: ['dx-tab-close', parts.classNames.tabClose] + .filter(Boolean) + .join(' '), + style: parts.styles.tabClose, + 'aria-label': `Close ${typeof item.title === 'string' ? item.title : item.id}`, + tabIndex: -1, + onPointerDown: (event: PointerEvent) => event.stopPropagation(), + onClick: (event: MouseEvent) => { + event.stopPropagation() + close() + }, + } + : undefined + const Component = parts.components.Tab + return ( + + ) +}) + +export const TabsetView = memo(function TabsetView({ + tabsetId, + parts, +}: { + tabsetId: string + parts: Parts +}) { + const { + tabset, + isRotated, + getPanelProps, + getTabBarProps, + toggleMaximize, + toggleFold, + } = useTabset(tabsetId) + if (!tabset) return null + const { + Panel: PanelComponent, + TabBar: TabBarComponent, + TabsetToolbar: Toolbar, + } = parts.components + const canMaximize = parts.allowMaximize && tabset.canMaximize + const canFold = parts.allowFold && tabset.canFold && !tabset.isMaximized + return ( + <> + + {parts.showTabBar && ( + + {tabset.tabIds.map((tabId) => ( + + ))} + {(canMaximize || canFold) && ( + + )} + + )} + + ) +}) + +export const ContentView = memo(function ContentView({ + tabId, + parts, +}: { + tabId: string + parts: Parts +}) { + const { item, isActive, getTabContentProps } = useTab(tabId) + if (!item) return null + const Component = parts.components.TabContent + return ( + + {item.content} + + ) +}) + +export const SplitterView = memo(function SplitterView({ + splitterId, + parts, +}: { + splitterId: string + parts: Parts +}) { + const { splitter, isDragging, getSplitterProps } = useSplitter(splitterId) + if (!splitter) return null + const Component = parts.components.Splitter + return ( + + ) +}) + +/** Whether two sets of rendering settings are equal by content (they are usually inline objects). */ +export function sameParts(a: Parts, b: Parts): boolean { + const shallow = (x: object, y: object) => { + const keys = Object.keys(x) + return ( + keys.length === Object.keys(y).length && + keys.every( + (k) => + (x as Record)[k] === + (y as Record)[k] + ) + ) + } + return ( + a.showTabBar === b.showTabBar && + a.allowMaximize === b.allowMaximize && + a.allowFold === b.allowFold && + shallow(a.components, b.components) && + shallow(a.classNames, b.classNames) && + Object.keys({ ...a.styles, ...b.styles }).every((k) => + shallow( + (a.styles as Record)[k] ?? {}, + (b.styles as Record)[k] ?? {} + ) + ) + ) +} diff --git a/packages/react/src/internal/keyboard-move-mode.ts b/packages/react/src/internal/keyboard-move-mode.ts new file mode 100644 index 0000000..6533b73 --- /dev/null +++ b/packages/react/src/internal/keyboard-move-mode.ts @@ -0,0 +1,96 @@ +import type { DropTarget } from '@dynamix-layout/core' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import type { LayoutController } from './controller' + +/** Keyboard move mode: a tab drag driven by arrow keys (Mod+Shift+M, Enter drops, Escape cancels). */ +interface MoveMode { + readonly tabId: string + readonly targets: readonly DropTarget[] + index: number +} +const moveModes = new WeakMap() + +function describeTarget( + controller: LayoutController, + target: DropTarget +): string { + const snapshot = controller.engine.getSnapshot() + const label = (tabId: string | undefined) => + tabId ? (controller.labels.get(tabId) ?? tabId) : '' + if (target.type === 'root') return `${target.position} edge of the layout` + if (target.type === 'tab') + return `${target.position} ${label(target.tabId)}` + const tabset = snapshot.tabsets.get(target.tabsetId) + const name = label(tabset?.tabIds[0]) + return target.position === 'center' + ? `into ${name}` + : `${target.position} of ${name}` +} + +function showMoveTarget(controller: LayoutController, mode: MoveMode) { + const target = mode.targets[mode.index] + controller.engine.setDragTarget(target, controller.measure()) + const name = controller.labels.get(mode.tabId) ?? mode.tabId + controller.announce( + `Move ${name}: ${describeTarget(controller, target)}. Enter to drop, Escape to cancel.` + ) +} + +export function startMoveMode( + controller: LayoutController, + tabId: string +): boolean { + const source = { type: 'tab', tabId } as const + const targets = controller.engine.listDropTargets(source) + if (targets.length === 0 || !controller.engine.startDrag(source)) + return false + const mode = { tabId, targets, index: 0 } + moveModes.set(controller, mode) + showMoveTarget(controller, mode) + return true +} + +export function handleMoveMode( + controller: LayoutController, + event: ReactKeyboardEvent +): boolean { + const mode = moveModes.get(controller) + if (!mode) return false + const name = controller.labels.get(mode.tabId) ?? mode.tabId + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + mode.index = (mode.index + 1) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'ArrowLeft': + case 'ArrowUp': + mode.index = + (mode.index - 1 + mode.targets.length) % mode.targets.length + showMoveTarget(controller, mode) + break + case 'Enter': + case ' ': + moveModes.delete(controller) + controller.engine.endDrag() + controller.announce(`Moved ${name}.`) + break + case 'Escape': + case 'Tab': + moveModes.delete(controller) + controller.engine.cancelDrag() + controller.announce(`Move of ${name} cancelled.`) + if (event.key === 'Tab') return true + break + default: + return true + } + event.preventDefault() + return true +} + +/** Ends keyboard move mode, if active (on blur or unmount). */ +export function cancelMoveMode(controller: LayoutController): void { + if (!moveModes.delete(controller)) return + controller.engine.cancelDrag() +} diff --git a/packages/react/src/internal/keyboard.ts b/packages/react/src/internal/keyboard.ts index 5f82fd8..cef9b7c 100644 --- a/packages/react/src/internal/keyboard.ts +++ b/packages/react/src/internal/keyboard.ts @@ -1,6 +1,8 @@ -import type { DropTarget } from '@dynamix-layout/core' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import type { LayoutController } from './controller' +import { handleMoveMode, startMoveMode } from './keyboard-move-mode' + +export { cancelMoveMode } from './keyboard-move-mode' /** Splitter keyboard steps, in px. */ const STEP = 10 @@ -8,96 +10,6 @@ const LARGE_STEP = 50 const isModifier = (event: ReactKeyboardEvent) => event.metaKey || event.ctrlKey -/** Keyboard move mode: a tab drag driven by arrow keys (Mod+Shift+M, Enter drops, Escape cancels). */ -interface MoveMode { - readonly tabId: string - readonly targets: readonly DropTarget[] - index: number -} -const moveModes = new WeakMap() - -function describeTarget( - controller: LayoutController, - target: DropTarget -): string { - const snapshot = controller.engine.getSnapshot() - const label = (tabId: string | undefined) => - tabId ? (controller.labels.get(tabId) ?? tabId) : '' - if (target.type === 'root') return `${target.position} edge of the layout` - if (target.type === 'tab') - return `${target.position} ${label(target.tabId)}` - const tabset = snapshot.tabsets.get(target.tabsetId) - const name = label(tabset?.tabIds[0]) - return target.position === 'center' - ? `into ${name}` - : `${target.position} of ${name}` -} - -function showMoveTarget(controller: LayoutController, mode: MoveMode) { - const target = mode.targets[mode.index] - controller.engine.setDragTarget(target, controller.measure()) - const name = controller.labels.get(mode.tabId) ?? mode.tabId - controller.announce( - `Move ${name}: ${describeTarget(controller, target)}. Enter to drop, Escape to cancel.` - ) -} - -function startMoveMode(controller: LayoutController, tabId: string): boolean { - const source = { type: 'tab', tabId } as const - const targets = controller.engine.listDropTargets(source) - if (targets.length === 0 || !controller.engine.startDrag(source)) - return false - const mode = { tabId, targets, index: 0 } - moveModes.set(controller, mode) - showMoveTarget(controller, mode) - return true -} - -function handleMoveMode( - controller: LayoutController, - event: ReactKeyboardEvent -): boolean { - const mode = moveModes.get(controller) - if (!mode) return false - const name = controller.labels.get(mode.tabId) ?? mode.tabId - switch (event.key) { - case 'ArrowRight': - case 'ArrowDown': - mode.index = (mode.index + 1) % mode.targets.length - showMoveTarget(controller, mode) - break - case 'ArrowLeft': - case 'ArrowUp': - mode.index = - (mode.index - 1 + mode.targets.length) % mode.targets.length - showMoveTarget(controller, mode) - break - case 'Enter': - case ' ': - moveModes.delete(controller) - controller.engine.endDrag() - controller.announce(`Moved ${name}.`) - break - case 'Escape': - case 'Tab': - moveModes.delete(controller) - controller.engine.cancelDrag() - controller.announce(`Move of ${name} cancelled.`) - if (event.key === 'Tab') return true - break - default: - return true - } - event.preventDefault() - return true -} - -/** Ends keyboard move mode, if active (on blur or unmount). */ -export function cancelMoveMode(controller: LayoutController): void { - if (!moveModes.delete(controller)) return - controller.engine.cancelDrag() -} - /** * Keys on a focused tab (WAI-ARIA tabs pattern): arrows and Home/End move * focus along the tab bar (selecting with automatic activation), Enter/Space diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json index b74039e..757fb74 100644 --- a/packages/react/tsconfig.build.json +++ b/packages/react/tsconfig.build.json @@ -2,7 +2,8 @@ // Declarations resolve @dynamix-layout/core from its built package, as users will. "extends": "./tsconfig.json", "compilerOptions": { - "paths": {} + "paths": {}, + "rootDir": "src" }, "exclude": ["node_modules", "src/test"] } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 031f960..4b8f6bc 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -11,7 +11,6 @@ "declaration": true, "outDir": "./dist", "moduleResolution": "Node", - "rootDir": "src", "allowSyntheticDefaultImports": true, "noImplicitAny": true, "paths": { diff --git a/packages/solid/tsconfig.build.json b/packages/solid/tsconfig.build.json index b74039e..757fb74 100644 --- a/packages/solid/tsconfig.build.json +++ b/packages/solid/tsconfig.build.json @@ -2,7 +2,8 @@ // Declarations resolve @dynamix-layout/core from its built package, as users will. "extends": "./tsconfig.json", "compilerOptions": { - "paths": {} + "paths": {}, + "rootDir": "src" }, "exclude": ["node_modules", "src/test"] } diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json index 200a81c..5727c4a 100644 --- a/packages/solid/tsconfig.json +++ b/packages/solid/tsconfig.json @@ -12,7 +12,6 @@ "declaration": true, "outDir": "./dist", "moduleResolution": "Node", - "rootDir": "src", "allowSyntheticDefaultImports": true, "noImplicitAny": true, "paths": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a56ae9..4f09a2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vitest/ui': specifier: ^4.1.11 version: 4.1.11(vitest@4.1.11) @@ -94,7 +97,7 @@ importers: version: 0.0.6 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) apps/site: dependencies: @@ -607,7 +610,7 @@ importers: version: 2.11.14(@testing-library/jest-dom@6.9.1)(solid-js@1.9.15)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) packages/typescript-config: {} @@ -812,6 +815,10 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -3356,6 +3363,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -3620,6 +3636,9 @@ packages: resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -8063,6 +8082,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -11022,6 +11043,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -11066,7 +11101,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) + vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) '@vitest/utils@4.1.11': dependencies: @@ -11359,6 +11394,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -12233,8 +12274,8 @@ snapshots: '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)) @@ -12257,7 +12298,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12268,22 +12309,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -12294,7 +12335,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 @@ -16021,7 +16062,7 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) - vitest@4.1.11(@types/node@24.19.1)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): + vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3)) @@ -16045,6 +16086,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 26.1.0 transitivePeerDependencies: From a5b931725b077c41ee3e0f9fd057116d469ae3f8 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:40:37 +0530 Subject: [PATCH 22/41] feat(solid): re-export LayoutJSON, LayoutTreeV1 and the migration helpers --- packages/solid/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 2a00c29..94260b3 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -3,3 +3,9 @@ export * from './components/Layout' export * from './hooks/useLayout' export * from './components/Default' export * from './types' +export { + isLayoutV1, + migrateLayoutFromV1, + type LayoutJSON, + type LayoutTreeV1, +} from '@dynamix-layout/core' From c0a15082d7baed048ee8caaf04f5560bde974316 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 00:40:37 +0530 Subject: [PATCH 23/41] docs: v2 READMEs, repository links and site install snippets Rewrites the root and package READMEs for v2 (pitch, demo, install, quick start, links to the docs), points every repository link at xcode-studio/dynamix-layout, fixes the documented stylesheet path, and updates the site's install snippets to the v2 API. --- CONTRIBUTING.md | 4 +- README.md | 184 +++++---------- SECURITY.md | 2 +- apps/site/src/components/Footer.tsx | 18 +- apps/site/src/components/Header.tsx | 14 +- apps/site/src/components/HeroSection.tsx | 2 +- .../src/components/InstallationSection.tsx | 37 ++- packages/core/README.md | 215 ++--------------- packages/react/README.md | 223 +++--------------- packages/solid/README.md | 39 +-- 10 files changed, 158 insertions(+), 580 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6bfa430..1c80565 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,11 +6,11 @@ First off, thank you for considering contributing to Dynamix Layout! It's people ### Reporting Bugs -If you find a bug, please open an issue on our [GitHub Issues page](https://github.com/akash-aman/dynamix-layout/issues). Please include a clear title and description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. +If you find a bug, please open an issue on our [GitHub Issues page](https://github.com/xcode-studio/dynamix-layout/issues). Please include a clear title and description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. ### Suggesting Enhancements -If you have an idea for an enhancement, please open an issue on our [GitHub Issues page](https://github.com/akash-aman/dynamix-layout/issues). Please provide a clear title and a detailed description of your suggestion. +If you have an idea for an enhancement, please open an issue on our [GitHub Issues page](https://github.com/xcode-studio/dynamix-layout/issues). Please provide a clear title and a detailed description of your suggestion. ### Pull Requests diff --git a/README.md b/README.md index 38ec780..c26fac8 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ # 🧩 Dynamix Layout -**Create dynamic, dockable, and resizable layouts with ease, similar to editors like VS Code.** +**Dockable, resizable tab layouts like VS Code's, for React and Solid, built on a framework-agnostic core.**

- -NPM - -NPM +npm: @dynamix-layout/react + + +npm: @dynamix-layout/solid -NPM +npm: @dynamix-layout/core -License +License

@@ -31,156 +31,84 @@

-## Overview - -Dynamix Layout is a powerful JavaScript library designed to help you build complex, multi-panel user interfaces. It provides a core engine for managing the layout logic and a dedicated React wrapper for seamless integration into your React applications. If you've ever wanted to create a user experience with draggable tabs and resizable panels, Dynamix Layout is the tool for you. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- -

- + Live Demo

---- - -![Demo](https://raw.githubusercontent.com/akash-aman/dynamix-layout/main/assets/demo1.gif) - ---- - -## ✨ Key Features - -- **Draggable Tabs**: Easily drag and drop tabs to rearrange them or create new panel splits. -- **Resizable Panels**: Users can click and drag the space between panels to resize them. -- **Dynamic Splits**: Split any panel horizontally or vertically by dropping a tab onto its edge. -- **Save & Restore**: Serialize the entire layout state to JSON and restore it later. -- **Framework-Agnostic Core**: The core logic is written in pure TypeScript with zero dependencies. -- **Official React Wrapper**: A feature-rich React component (``) and hook (`useLayout`) for easy integration. - ---- - -## 📚 Packages in this Monorepo - -This repository contains the following packages: - -### │ 📦 @dynamix-layout/core +![Dragging tabs between panels and resizing them](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) -The core, framework-agnostic layout engine. It handles all the complex logic of tree management, dimension calculation, and state updates. You can use this package to integrate Dynamix Layout with any framework (Vue, Svelte, Angular, etc.) or with vanilla JavaScript. +Your tabs live in **tabsets**, arranged in **rows** and separated by draggable **splitters**. Users drag tabs to rearrange or split panels, resize them, and maximize or fold a panel. The layout is plain JSON you can save and restore. -➡️ **[View the detailed `@dynamix-layout/core` README](./packages/core/README.md)** +## Features -### │ 🚀 @dynamix-layout/react - -The official React component library for Dynamix Layout. It provides a simple-to-use `` component and an advanced `useDynamixLayout` hook that handles all the rendering, state management, and event binding for you. This is the recommended package for all React developers. - -➡️ **[View the detailed `@dynamix-layout/react` README](./packages/react/README.md)** - -### │ 🚀 @dynamix-layout/solid - -The official SolidJS wrapper for `@dynamix-layout/core`. It provides a flexible `` component and an advanced `useDynamixLayout` hook to create fully dynamic, resizable, and draggable tab-based layouts with ease. - -➡️ **[View the detailed `@dynamix-layout/solid` README](./packages/solid/README.md)** - ---- +- **Drag tabs** onto another tab bar, onto a panel's side to split it, or onto a layout edge. Works with mouse, pen and touch. +- **Resize** with splitters that respect minimum sizes. Only the two neighbouring panels move. +- **Maximize** a panel, or **fold** it to a strip, and get back the exact previous size. +- **Tab content never remounts** when tabs move, so editors, terminals and iframes keep their state. +- **Save and restore** the layout as versioned JSON. Layouts saved by v1 load automatically. +- **Accessible**: WAI-ARIA tabs and window splitter patterns, keyboard resizing, and a keyboard move mode. +- **SSR-safe and fast**: deterministic server markup, and drags and resizes never re-render React. +- **Three levels of API**: one component, headless hooks, or the framework-agnostic core. -## 📖 Examples +## Packages -This repository contains the following examples: +| Package | | +|---|---| +| [`@dynamix-layout/react`](./packages/react) | `` and headless hooks for React 18 and 19 | +| [`@dynamix-layout/solid`](./packages/solid) | `` for SolidJS | +| [`@dynamix-layout/core`](./packages/core) | The engine: tree, geometry, drag-and-drop and serialization, with no framework | -| Example | Source Link | Live Demo Link | -|----------------|---------------------------|-------------------------------| -| React | [React](./examples/react) | [dx.xcode.cx](https://dx.xcode.cx) | -| Next.js | [Next.js](./examples/nextjs) | [nextjs.dx.xcode.cx](https://nextjs.dx.xcode.cx) | -| SolidJS | [SolidJS](./examples/solid) | [solidjs.dx.xcode.cx](https://solidjs.dx.xcode.cx) | -| SolidStart | [SolidStart](./examples/solidstart)| [solidstart.dx.xcode.cx](https://solidstart.dx.xcode.cx) | - ---- - -## 📦 Installation - -For React applications, you will need both the `react` and `core` packages. - -```bash -npm install @dynamix-layout/react @dynamix-layout/core -``` - -For Solid applications, you will need both the `solid` and `core` packages. +## Quick start (React) ```bash -npm install @dynamix-layout/solid @dynamix-layout/core +npm install @dynamix-layout/react ``` ---- - -## 🏁 Basic Usage - -Here's how easy it is to get started with the React component: - -```jsx +```tsx import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/style.css' +import '@dynamix-layout/react/styles.css' -function App() { - - const tabList = [ - ['editor',
Editor
], - ['preview',
Preview
], - ['terminal',
Terminal
], - ] +const tabs = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: }, +] +export function App() { return ( - +
+ +
) } - -export default App ``` -Here's how easy it is to get started with the SolidJS component: +To save the layout, store what `onLayoutChange` gives you and pass it back as `defaultLayout`; see [Persisting layouts](./docs/guides/persisting-layouts.md). -```jsx -import { DynamixLayout } from '@dynamix-layout/solid' -import '@dynamix-layout/solid/style.css' +## Documentation -function App() { +- [Getting started](./docs/getting-started.md): concepts, the first layout, and saving and restoring. +- [API reference](./docs/api/README.md): every component, hook and function. +- Guides: + - [Custom tabs and splitters](./docs/guides/custom-components.md) + - [Theming](./docs/guides/theming.md) + - [Persisting layouts](./docs/guides/persisting-layouts.md) + - [Next.js and SSR](./docs/guides/nextjs-and-ssr.md) + - [Keyboard and accessibility](./docs/guides/accessibility.md) + - [Multiple layouts](./docs/guides/multiple-layouts.md) + - [Using the core without React](./docs/guides/core-without-react.md) + - [Performance](./docs/guides/performance.md) +- [Migrating from v1](./docs/migration-v1-to-v2.md). +- [Examples](./examples): React (basic, custom components, headless, controlled), Next.js, Solid and SolidStart. - const tabList = [ - ['editor',
Editor
], - ['preview',
Preview
], - ['terminal',
Terminal
], - ]; +## Contributing - return ( - - ) -} - -export default App -``` - ---- - -## 🤝 Contributing - -Contributions, issues, and feature requests are welcome\! Feel free to check the [issues page](https://github.com/akash-aman/dynamix-layout/issues). - -## 📝 License - -This project is [MIT](./LICENSE) licensed. +Issues and pull requests are welcome; see [CONTRIBUTING.md](./CONTRIBUTING.md) and the [issue tracker](https://github.com/xcode-studio/dynamix-layout/issues). --- -
- -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) - ### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) -
+[MIT License](./LICENSE) diff --git a/SECURITY.md b/SECURITY.md index ac75864..863b5c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ The team takes the security of our project seriously. We appreciate your efforts If you discover a security vulnerability, please report it to us privately. **Do not create a public GitHub issue.** -We encourage you to report vulnerabilities through **[GitHub's Private Vulnerability Reporting](https://github.com/akash-aman/dynamix-layout/security)** feature. You can do this by going to the "Security" tab of our repository and clicking on "Report a vulnerability". +We encourage you to report vulnerabilities through **[GitHub's Private Vulnerability Reporting](https://github.com/xcode-studio/dynamix-layout/security)** feature. You can do this by going to the "Security" tab of our repository and clicking on "Report a vulnerability". Alternatively, you can email us at **[sir.akashaman@gmail.com]**. diff --git a/apps/site/src/components/Footer.tsx b/apps/site/src/components/Footer.tsx index 96d5a91..0c13bce 100644 --- a/apps/site/src/components/Footer.tsx +++ b/apps/site/src/components/Footer.tsx @@ -60,7 +60,7 @@ const Footer = () => {
  • {
  • {
  • {
  • {
    • {
    • {
    • {/*
    • - + Discord
    • */}
    • { asChild > { Demo { Docs { {/* Actions */}
      @@ -93,7 +93,7 @@ const Header = () => { asChild > @@ -133,7 +133,7 @@ const Header = () => { Demo { Installation { asChild > diff --git a/apps/site/src/components/HeroSection.tsx b/apps/site/src/components/HeroSection.tsx index 7fa0c51..12c937a 100644 --- a/apps/site/src/components/HeroSection.tsx +++ b/apps/site/src/components/HeroSection.tsx @@ -58,7 +58,7 @@ const HeroSection = () => { asChild > diff --git a/apps/site/src/components/InstallationSection.tsx b/apps/site/src/components/InstallationSection.tsx index 6dff592..3aad2b6 100644 --- a/apps/site/src/components/InstallationSection.tsx +++ b/apps/site/src/components/InstallationSection.tsx @@ -16,32 +16,29 @@ const InstallationSection = () => { } const installCommandsReact = { - npm: 'npm install @dynamix-layout/react @dynamix-layout/core', - yarn: 'yarn add @dynamix-layout/react @dynamix-layout/core', - pnpm: 'pnpm add @dynamix-layout/react @dynamix-layout/core', + npm: 'npm install @dynamix-layout/react', + yarn: 'yarn add @dynamix-layout/react', + pnpm: 'pnpm add @dynamix-layout/react', } const installCommandsSolidJS = { - npm: 'npm install @dynamix-layout/solid @dynamix-layout/core', - yarn: 'yarn add @dynamix-layout/solid @dynamix-layout/core', - pnpm: 'pnpm add @dynamix-layout/solid @dynamix-layout/core', + npm: 'npm install @dynamix-layout/solid solid-js', + yarn: 'yarn add @dynamix-layout/solid solid-js', + pnpm: 'pnpm add @dynamix-layout/solid solid-js', } const basicExampleReact = `import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/style.css' +import '@dynamix-layout/react/styles.css' -function App() { +const tabs = [ + { id: 'editor', title: 'Editor', content:
      Editor
      }, + { id: 'preview', title: 'Preview', content:
      Preview
      }, + { id: 'terminal', title: 'Terminal', content:
      Terminal
      }, +] - const tabList = [ - ['editor',
      Editor
      ], - ['preview',
      Preview
      ], - ['terminal',
      Terminal
      ], - ] - - return ( - - ) +function App() { + return } export default App` @@ -301,7 +298,7 @@ export default App` asChild >
      @@ -314,7 +311,7 @@ export default App` asChild > @@ -322,7 +319,7 @@ export default App` {/* */} diff --git a/packages/core/README.md b/packages/core/README.md index 19e7cbb..1e396b4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,207 +1,38 @@ -
      +# @dynamix-layout/core -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +The framework-agnostic engine behind Dynamix Layout. It handles the layout tree (rows, tabsets and tabs), geometry and minimum sizes, splitters, drag-and-drop targets, maximize and fold, and versioned serialization. It has no dependencies, no global state and no DOM access. Every `createLayout()` is an independent instance. -
      +Use it directly to build an adapter for another framework, or through [`@dynamix-layout/react`](https://www.npmjs.com/package/@dynamix-layout/react) or [`@dynamix-layout/solid`](https://www.npmjs.com/package/@dynamix-layout/solid). -# 🚀 @dynamix-layout/core +## Install -## Overview - -**@dynamix-layout/core** is the **engine** that powers the **creation**, **management**, and **dynamic modification** of your layout structures. It handles the **internal logic** of the layout tree, including **node creation**, **dimension calculation**, and **state updates**. This package works with a **hierarchical tree structure** composed of `Node` objects and exposes a **reactive state** for seamless integration with modern UI frameworks. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- - -![alt text](https://raw.githubusercontent.com/akash-aman/dynamix-layout/main/assets/demo1.gif) - ---- - -## Core Classes - -The package is built around three primary classes: `DynamixLayoutCore`, `Node`, and `Bond`. - -1. **`DynamixLayoutCore`**: The main controller class that you instantiate to create and manage the entire layout. It serves as the primary public API. -2. **`Node`**: The fundamental building block of the layout tree. Each `Node` can be a `row`, a `tabset`, or a `tab`. -3. **`Bond`**: A special object that represents the draggable separator between two nodes, allowing for resizing. - ---- - -## `DynamixLayoutCore` Class - -The `DynamixLayoutCore` class is the central point of control for your entire layout configuration. - -### Constructor - -When initializing the `DynamixLayoutCore` class, you provide a single **options object**. - -`new DynamixLayoutCore(options)` - -- **`options`** (`object`): An object containing configuration properties. - - **`tabs`** (`string[]`): An array of tab names to generate an initial binary tree layout. Defaults to `[]`. - - **`tree`** (`LayoutTree | null`): A serialized JSON representation of a previously saved layout (`LayoutTree` object). If provided, it will recreate that specific layout. Defaults to `null`. - - **`tabsIds`** (`Map`): A map where keys are tab names and values are their unique IDs. This is useful for persisting tab identities across sessions. - - **`minW`** (`number`): The minimum width for a `tabset`. Defaults to `40`. - - **`minH`** (`number`): The minimum height for a `tabset`. Defaults to `40`. - - **`bond`** (`number`): The width or height (in pixels) of the draggable separator (`Bond`) between nodes. Defaults to `10`. - -### Reactive Properties - -The layout's state is exposed through static reactive properties on the `Node` class. You can subscribe to these to drive your UI. - -- **`Node.cache.nodOpts`**: A reactive map of all `tabset` nodes and their options. -- **`Node.cache.bndOpts`**: A reactive map of all `bond` objects. -- **`Node.cache.tabOpts`**: A reactive map of all `tab` nodes within their respective tabsets. - -### Methods - -#### 🌳 `updateTree(src, des, layout)` - -This is the core method for rearranging the layout. It moves a source node (`src`) to a new position relative to a destination node (`des`). - -- **`src`** (`string`): The unique ID of the node to move (can be a `tab` or a `tabset`). -- **`des`** (`string`): The unique ID of the destination node. -- **`layout`** (`'top' | 'bottom' | 'left' | 'right' | 'contain'`): Specifies how the source should be placed relative to the destination. - - `top`, `bottom`, `left`, `right`: Places the source node by splitting the destination area. - - `contain`: Places a source `tab` into the destination `tabset`. - -**Returns**: `boolean` - `true` if the update was successful, `false` otherwise. - -```javascript -// Moves the node with 'sidebar-id' to be to the left of 'main-content-id' -layout.updateTree('sidebar-id', 'main-content-id', 'left') +```bash +npm install @dynamix-layout/core ``` ---- +## Quick start -#### 📏 `updateDimension(dim, disableTimeout, timeout)` +```ts +import { createLayout } from '@dynamix-layout/core' -Recalculates all node dimensions within the layout, typically after the main container has been resized. - -- **`dim`** (`{ w, h, x, y }`): An object representing the new dimensions of the root container. -- **`disableTimeout`** (`boolean`): If `true`, the update runs instantly. Otherwise, it's debounced. Defaults to `false`. -- **`timeout`** (`number`): The debounce delay in milliseconds. Defaults to `2`. - - - -```javascript -// On window resize, update the layout dimensions -layout.updateDimension({ - w: window.innerWidth, - h: window.innerHeight, - x: 0, - y: 0, +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }, { id: 'preview' }], + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), }) -``` - ---- - -#### ↔️ `updateSlider(id, dim, disableTimeout, timeout)` - -Updates the layout in response to a user dragging a `Bond` (slider). - -- **`id`** (`string`): The unique ID of the `Bond` being moved. -- **`dim`** (`{ x, y }`): An object with the new coordinates of the slider. -- **`disableTimeout`** (`boolean`): If `true`, the update runs instantly. Defaults to `false`. -- **`timeout`** (`number`): The debounce delay in milliseconds. Defaults to `2`. - - -```javascript -// When a user drags a separator -layout.updateSlider('bond-123', { x: 450, y: 300 }) -``` - ---- - -#### 🗑️ `clearAllCache()` - -Clears all internal caches for nodes, bonds, dimensions, and other layout data. Use this when completely destroying or resetting the layout. - -```javascript -layout.clearAllCache() -``` - ---- - -## `Node` Class - -The `Node` represents a single element in the layout tree. You will typically interact with Node data through the reactive properties rather than manipulating `Node` objects directly. - -### Constructor - -`new Node(options)` - -- **`options`** (`object`): An object containing node properties like `type`, `host`, `name`, `part`, `dims`, `unId`, and `open`. - -### Static Cache (`Node.cache`) - -The `Node` class contains a static `cache` object. This centralized cache holds the state for the entire layout, including maps of all nodes, dimensions, and the **reactive options** that your UI will consume. **Direct manipulation of the cache is not recommended.** - -### Methods - -#### 💾 `toJSON()` - -Serializes the node and its entire subtree into a JSON object (`LayoutTree`). This is used to save the current state of the layout. - -```javascript -const rootNode = DynamixLayoutCore._root // Access the static root node -const layoutJSON = rootNode.toJSON() -// localStorage.setItem('savedLayout', JSON.stringify(layoutJSON)); -``` - -## Example Usage - -```javascript -import { DynamixLayoutCore, Node } from '@dynamix-layout/core' - -// 1. Initialize the Layout class -// Provide an options object to the constructor -const layout = new DynamixLayoutCore({ - tabs: ['editor', 'terminal', 'preview'], -}) - -// ...or restore a layout from a saved JSON object -// const savedLayout = JSON.parse(localStorage.getItem('savedLayout')); -// const layout = new DynamixLayoutCore({ tree: savedLayout }); - -// 2. Set the initial dimensions of your container -layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) - -// 3. Subscribe to reactive state to render your UI. -const unsubscribe = Node.cache.nodOpts.onChange((nodeOptionsMap) => { - console.log('Layout updated!', nodeOptionsMap) - // Your UI rendering logic here... +layout.subscribe((snapshot) => { + for (const [id, rect] of snapshot.rects.tabsets) draw(id, rect) }) - -// 4. Move a tab to a new location -// (In a real app, you'd get these IDs from the reactive state) -setTimeout(() => { - const terminalTabId = 'some-unique-id-for-terminal-tab' - const editorTabsetId = 'some-unique-id-for-editor-tabset' - - // Move the terminal tab into the editor's tabset - layout.updateTree(terminalTabId, editorTabsetId, 'contain') -}, 2000) - -// 5. Clean up when the component unmounts -// unsubscribe(); -// layout.clearAllCache(); +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) +layout.moveTab('terminal', { type: 'root', position: 'bottom' }) ``` ---- - -
      - -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +## Documentation -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) +- [Using the core without React](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/guides/core-without-react.md) +- [`createLayout` API](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/create-layout.md) +- [`LayoutJSON`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/layout-json.md) +- [`migrateLayoutFromV1`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/migrate-layout-from-v1.md) +- [Migrating from v1](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md) -
      +MIT © [Akash Aman](https://linktr.ee/akash_aman) diff --git a/packages/react/README.md b/packages/react/README.md index 0ebaaee..4311254 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,215 +1,54 @@ -
      +# @dynamix-layout/react -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +Dockable, resizable tab layouts for React 18 and 19. Drag tabs to rearrange or split panels, resize them with splitters, maximize or fold a panel, and save the layout as JSON. -
      +![Dragging tabs between panels and resizing them](https://raw.githubusercontent.com/xcode-studio/dynamix-layout/main/assets/demo1.gif) -## 🚀 @dynamix-layout/react - -### The official React wrapper for `@dynamix-layout/core` - -## Overview - -**@dynamix-layout/react** brings the power of the `@dynamix-layout/core` engine to React applications. It provides a flexible `` component and an advanced `useDynamixLayout` hook to create fully dynamic, resizable, and draggable tab-based layouts with ease. Build complex UIs like VS Code or JSFiddle in minutes. - ---- - -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) - ---- - -![Demo](https://raw.githubusercontent.com/akash-aman/dynamix-layout/main/assets/demo1.gif) - ---- - -## 📦 Installation +## Install ```bash -npm install @dynamix-layout/react @dynamix-layout/core +npm install @dynamix-layout/react ``` ---- - -## 🏁 Getting Started - -The easiest way to get started is by using the `` component. Provide it with an array of `[id, component]` tuples. +## Quick start -```jsx -import React from 'react' +```tsx import { DynamixLayout } from '@dynamix-layout/react' -import '@dynamix-layout/react/dist/layout.css' // Don't forget to import the default styles +import '@dynamix-layout/react/styles.css' -function App() { - // 1. Define your tabs as an array of [id, component] tuples - const myTabs = [ - ['editor',
      This is my editor!
      ], - ['terminal',
      This is the terminal.
      ], - ['preview',
      Live preview here.
      ], - ] +const tabs = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: , closable: true }, +] +export function App() { + const [saved] = useState(() => JSON.parse(localStorage.getItem('layout') ?? 'null') ?? undefined) return ( -
      - {/* 2. Render the DynamixLayout component with your tabs */} - -
      - ) -} - -export default App -``` - ---- - -## 🧩 `` Component API - -The `` component is the primary way to use this package. It's highly configurable through its props. - -### Core Functionality - -| Prop | Type | Description | Default | -| ---------------------- | ----------------------- | ------------------------------------------------------------------------------------- | ---------------------------- | -| **`tabs`** (required) | `[string, ReactNode][]` | An array of `[id, component]` tuples for your tabs. | ` ` | -| `layoutTree` | `LayoutTree` | A serialized layout object from a previous session to restore a saved layout. | `null` | -| `tabNames` | `Map` | An optional map to provide friendly display names for your tabs in the tab bar. | `new Map()` | -| `enableTabbar` | `boolean` | If `true`, renders the draggable tab bar on top of each tab panel. | `true` | -| `tabHeadHeight` | `number` | The height of the tab bar in pixels. | `40` | -| `pad` | `{ t, b, l, r }` | Padding for the root layout container in pixels. | `{ t: 0, b: 0, l: 0, r: 0 }` | -| `minTabWidth` | `number` | Minimum width of a tab panel in pixels. | `40` | -| `minTabHeight` | `number` | Minimum height of a tab panel in pixels. | `40` | -| `bondWidth` | `number` | The width/height of the draggable slider between panels in pixels. | `10` | -| `rootId` | `string` | The HTML `id` for the root `
      ` element of the layout. | `"dynamix-layout-root"` | -| `disableResizeTimeout` | `boolean` | Disables debounce on window resize for faster updates. Can impact performance. | `true` | -| `disableSliderTimeout` | `boolean` | Disables debounce when dragging a slider. | `true` | -| `windowResizeTimeout` | `number` | Debounce timeout in milliseconds for window resize events. | `2` | -| `sliderUpdateTimeout` | `number` | Debounce timeout in milliseconds for slider drag events. | `2` | -| `...props` | `HTMLAttributes` | Standard HTML attributes like `style` and `className` are passed to the root `
      `. | ` ` | - -### 🎨 Customization with Wrapper Components - -You can completely change the look and feel of the layout by providing your own React components for rendering different parts of the UI. - -| Prop | Description | -| --------------- | ----------------------------------------------------------------------------- | -| `WrapTabPanel` | A component to wrap the entire tab panel (tab bar + tab content area). | -| `WrapTabHead` | A component to wrap the tab bar that contains the tab labels. | -| `WrapTabLabel` | A component for an individual, clickable tab label in the tab bar. | -| `WrapTabBody` | A component to wrap the content of a single tab. | -| `SliderElement` | A component for the draggable slider used to resize panels. | -| `HoverElement` | A component that displays the visual drop zone indicator when dragging a tab. | - -**Example: Custom Slider** - -```jsx -import { DynamixLayout } from '@dynamix-layout/react' -import React from 'react' - -const MyCustomSlider = React.forwardRef((props, ref) => ( -
      -)) - -function App() { - // ... (myTabs definition) - return -} -``` - -### Styling Props - -For finer-grained control, you can pass `style` objects or `className` strings to the default wrapper components without replacing them entirely. - -| Prop | Type | Target Element | -| --------------------------- | --------------------- | -------------------- | -| `tabPanelElementStyles` | `React.CSSProperties` | `WrapTabPanel` | -| `tabPanelElementClass` | `string` | `WrapTabPanel` | -| `tabHeadElementStyles` | `React.CSSProperties` | `WrapTabHead` | -| `tabHeadElementClass` | `string` | `WrapTabHead` | -| `tabLabelElementStyles` | `React.CSSProperties` | `WrapTabLabel` | -| `tabLabelElementClass` | `string` | `WrapTabLabel` | -| `tabBodyElementStyles` | `React.CSSProperties` | `WrapTabBody` | -| `tabBodyElementClass` | `string` | `WrapTabBody` | -| `sliderElementStyles` | `React.CSSProperties` | `SliderElement` | -| `sliderElementClass` | `string` | `SliderElement` | -| `hoverElementStyles` | `React.CSSProperties` | `HoverElement` | -| `hoverElementClass` | `string` | `HoverElement` | -| `RootSplitterHoverElStyles` | `React.CSSProperties` | Root edge drop zones | -| `RootSplitterHoverElClass` | `string` | Root edge drop zones | - ---- - -## ⚙️ Advanced Usage: The `useDynamixLayout` Hook - -For ultimate control, you can use the `useDynamixLayout` hook to build your own layout renderer from scratch. It contains all the state, refs, and event handlers needed to power the layout. - -### Options - -The hook accepts an object with the same props as the `` component, with two key differences: - -1. It requires a `tabOutput` prop, generated from your `tabs` array. -2. It requires a `dimensions` function that returns the layout container's size and position. - -### Return Value - -The hook returns an object with everything you need to build your UI, including: - -- **State**: `tabsets` and `sliders` (Maps containing the layout data to render). -- **Refs**: `tabsetsRef`, `slidersRef`, `hoverElementRef`, etc., to connect to your DOM elements. -- **Event Handlers**: `onDragStart`, `onDragEnd`, `onDragOver`, `onPointerDown`, `updateActiveTab`, etc. -- **Instance**: `layoutInstance` (The raw instance of `@dynamix-layout/core` for advanced actions like saving state). - -### Example: Saving a Layout - -- `updateJSON`: A callback function that receives the complete layout state object (LayoutTree) every time a change occurs (like dragging a tab or resizing a panel). You can use this to save the state to localStorage, a database, or anywhere else. - -```jsx -import { tabs } from './comp' -import { DynamixLayout } from '@dynamix-layout/react' -import type { LayoutTree } from '@dynamix-layout/core' -import '@dynamix-layout/solid/style.css' - -function App() { - - const UpdateJSON = (layoutTree: LayoutTree) => { - console.log(layoutTree); - }; - - return ( - <> +
      localStorage.setItem('layout', JSON.stringify(layout))} /> - +
      ) } - -export default App - ``` ---- +The layout fills its parent, so give the parent a size. + +## Three levels of API -
      +- **``**: one component. Customize it with `components`, `classNames`, `styles` and CSS variables. +- **Headless hooks**: `useDynamixLayout`, `useTabset`, `useTab` and `useSplitter` return prop getters for a fully custom UI. +- **State and actions anywhere**: `useLayoutState(selector)` and `useLayoutActions()`. -[![Patreon](https://img.shields.io/badge/Patreon-Support-F96854?style=for-the-badge&logo=patreon)](https://www.patreon.com/akashaman) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-FFDD00?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/akashaman) -[![Hire Me](https://img.shields.io/badge/Hire%20Me-Email-blue?style=for-the-badge&logo=gmail)](mailto:sir.akashaman@gmail.com) +## Documentation -### Made with ❤️ by [Akash Aman](https://linktr.ee/akash_aman) +- [Getting started](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/getting-started.md) +- [API reference](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/README.md) +- [Guides](https://github.com/xcode-studio/dynamix-layout/blob/main/README.md#documentation) +- [Migrating from v1](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md) -
      +MIT © [Akash Aman](https://linktr.ee/akash_aman) diff --git a/packages/solid/README.md b/packages/solid/README.md index f05b4e1..6ebcedf 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -73,8 +73,8 @@ The `` component is the primary way to use this package. It's h | Prop | Type | Description | Default | | :--- | :--- | :--- | :--- | | **`tabs`** (required) | `[string, JSX.Element][]` | An array of `[id, component]` tuples for your tabs. | | -| `layoutTree` | `LayoutTree` | A serialized layout object from a previous session to restore a saved layout. | `null` | -| `tabNames` | `Map` | An optional map to provide friendly display names for your tabs. | `new Map()` | +| `layoutTree` | `LayoutJSON \| LayoutTreeV1` | A saved layout to restore. Layouts saved by v1 are migrated automatically. | `undefined` | +| `tabNames` | `Map` | Display names for your tabs, keyed by tab id. | `undefined` | | `enableTabbar` | `boolean` | If `true`, renders the draggable tab bar on top of each tab panel. | `true` | | `tabHeadHeight` | `number` | The height of the tab bar in pixels. | `40` | | `pad` | `{ t, b, l, r }` | Padding for the root layout container in pixels. | `{ t: 0, b: 0, l: 0, r: 0 }` | @@ -83,9 +83,9 @@ The `` component is the primary way to use this package. It's h | `bondWidth` | `number` | The width/height of the draggable slider between panels in pixels. | `10` | | `rootId` | `string` | The HTML `id` for the root `
      ` element of the layout. | `"dynamix-layout-root"` | | `disableResizeTimeout` | `boolean` | Disables debounce on window resize for faster updates. Can impact performance. | `true` | -| `disableSliderTimeout` | `boolean` | Disables debounce when dragging a slider. | `true` | +| `disableSliderTimeout` | `boolean` | No effect since 2.0: splitters update once per animation frame. | `true` | | `windowResizeTimeout` | `number` | Debounce timeout in milliseconds for window resize events. | `2` | -| `sliderUpdateTimeout` | `number` | Debounce timeout in milliseconds for slider drag events. | `2` | +| `sliderUpdateTimeout` | `number` | No effect since 2.0. | `2` | | `...props` | `JSX.HTMLAttributes` | Standard HTML attributes like `style` and `class` are passed to the root `
      `. | | ### 🎨 Customization with Wrapper Components @@ -106,7 +106,7 @@ You can completely change the look and feel of the layout by providing your own ```jsx import { DynamixLayout } from '@dynamix-layout/solid'; import type { Component, JSX } from 'solid-js'; -immport { MyCustomSlider } from "./compoments"; +import { MyCustomSlider } from './components'; function App() { // ... (myTabs definition from "Getting Started") @@ -137,43 +137,26 @@ For finer-grained control, you can pass `style` objects or `class` strings to th ----- -## ⚙️ Advanced Usage: The `useDynamixLayout` Hook - -For ultimate control, you can use the `useDynamixLayout` hook to build your own layout renderer from scratch. It contains all the state, refs, and event handlers needed to power the layout, fully integrated with Solid's reactive system. - -### Options - -The hook accepts an object with the same props as the `` component, with two key differences: - -1. It requires a `tabOutput` prop, generated from your `tabs` array using the exported `getTabOutput` helper. -2. It requires a `dimensions` prop, which is a **signal or function** that returns the layout container's size and position. - -### Return Value - -The hook returns an object with everything you need to build your UI, including: - - - **Reactive State**: `tabsets` and `sliders` (Maps containing the layout data, wrapped in SolidJS signals). - - **Refs**: `tabsetsRef`, `slidersRef`, `hoverElementRef`, etc., which are Maps for you to populate with element references using Solid's `ref` directive. - - **Event Handlers**: `onDragStart`, `onDragEnd`, `onDragOver`, `onPointerDown`, `updateActiveTab`, etc. - - **Instance**: `layoutInstance` (A signal containing the raw instance of `@dynamix-layout/core` for advanced actions like saving state). +## ⚙️ The `useDynamixLayout` hook +The Solid `useDynamixLayout` binds a `@dynamix-layout/core` instance to Solid signals for ``. Its shape changed in 2.0 and it is mainly an implementation detail of the component. For a custom renderer, build on [`createLayout`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/guides/core-without-react.md) directly. A headless Solid API, matching the React hooks, is planned. ----- ### Example: Saving a Layout -- `updateJSON`: A callback function that receives the complete layout state object (LayoutTree) every time a change occurs (like dragging a tab or resizing a panel). You can use this to save the state to localStorage, a database, or anywhere else. +- `updateJSON` receives the layout as [`LayoutJSON`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/layout-json.md) (`version: 2`) once on mount and after every change (a drop, a splitter release, selecting a tab, maximize or fold). Save it to localStorage, a database, or anywhere else, and pass it back as `layoutTree`. ```jsx import { tabs } from './comp' import { DynamixLayout } from '@dynamix-layout/solid' -import type { LayoutTree } from '@dynamix-layout/core' +import type { LayoutJSON } from '@dynamix-layout/solid' import '@dynamix-layout/solid/style.css' function App() { - const UpdateJSON = (layoutTree: LayoutTree) => { - console.log(layoutTree); + const UpdateJSON = (layout: LayoutJSON) => { + localStorage.setItem('layout', JSON.stringify(layout)); }; return ( From 574121f8b4960692ebbdbbb62f2b0996d9f30efc Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 07:52:51 +0530 Subject: [PATCH 24/41] fix(core): report invalid weights found while migrating v1 layouts migrateLayoutFromV1 replaced a negative or non-numeric nodPart with 100 without telling anyone. It now reports INVALID_WEIGHT with the path, like the v2 parser. --- packages/core/src/serialize/migrate-v1.ts | 21 +++++++++++++-------- packages/core/test/v2/serialize.test.ts | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/core/src/serialize/migrate-v1.ts b/packages/core/src/serialize/migrate-v1.ts index bad68e5..1affdfe 100644 --- a/packages/core/src/serialize/migrate-v1.ts +++ b/packages/core/src/serialize/migrate-v1.ts @@ -14,12 +14,8 @@ export function isLayoutV1(input: unknown): input is LayoutTreeV1 { ) } -const weightOf = (node: LayoutTreeV1) => - typeof node.nodPart === 'number' && - Number.isFinite(node.nodPart) && - node.nodPart >= 0 - ? node.nodPart - : 100 +const isValidWeight = (part: unknown): part is number => + typeof part === 'number' && Number.isFinite(part) && part >= 0 /** * Converts a layout saved by v1 to the v2 format, losslessly: @@ -49,6 +45,15 @@ export function migrateLayoutFromV1( options: { onWarning?: WarningHandler } = {} ): LayoutJSON { const warn: WarningHandler = options.onWarning ?? (() => {}) + const weightOf = (node: LayoutTreeV1, path: string) => { + if (isValidWeight(node.nodPart)) return node.nodPart + warn({ + code: 'INVALID_WEIGHT', + message: 'Invalid weight replaced by 100', + path: `${path}.nodPart`, + }) + return 100 + } if (!isLayoutV1(tree)) throw new DynamixLayoutError('INVALID_LAYOUT', 'Not a v1 layout', '') @@ -99,7 +104,7 @@ export function migrateLayoutFromV1( return { type: 'tabset', id: nodeId(node, 'ts'), - weight: weightOf(node), + weight: weightOf(node, path), ...(children.length > 0 ? { activeTabId: hasOpen ? open : children[0].id } : {}), @@ -138,7 +143,7 @@ export function migrateLayoutFromV1( return { type: 'row', id: nodeId(node, 'row'), - weight: weightOf(node), + weight: weightOf(node, path), direction, children, } diff --git a/packages/core/test/v2/serialize.test.ts b/packages/core/test/v2/serialize.test.ts index 201e06a..9399b1d 100644 --- a/packages/core/test/v2/serialize.test.ts +++ b/packages/core/test/v2/serialize.test.ts @@ -311,6 +311,7 @@ describe('migrateLayoutFromV1', () => { ]) expect(warnings.map((w) => w.code)).toEqual([ 'INVALID_ACTIVE_TAB', + 'INVALID_WEIGHT', 'DUPLICATE_TAB_ID', ]) }) From 2b5f17aa4ecda8fe8fcdf7e83c4280fda9c58e77 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 07:56:11 +0530 Subject: [PATCH 25/41] docs: getting started, API reference, guides and the v1 to v2 migration guide - getting-started: concepts, the first layout, saving and restoring, and adding, closing and selecting tabs. - api/: one page per export (DynamixLayout, useDynamixLayout and the provider, useTabset, useTab, useSplitter, useLayoutState, useLayoutActions, useDragState, createLayout, LayoutJSON, migrateLayoutFromV1), each with the signature, a table of every option, the return value, examples and common pitfalls. - guides/: custom components, theming, persisting layouts, Next.js and SSR, keyboard and accessibility, multiple layouts, the core without React, and performance (with measured bundle sizes). - migration-v1-to-v2: steps, the full rename table, before/after code for each breaking change, behaviour changes and saved layouts. --- docs/api/README.md | 28 ++++ docs/api/create-layout.md | 141 +++++++++++++++++ docs/api/dynamix-layout.md | 113 ++++++++++++++ docs/api/layout-json.md | 87 +++++++++++ docs/api/migrate-layout-from-v1.md | 59 +++++++ docs/api/use-drag-state.md | 23 +++ docs/api/use-dynamix-layout.md | 109 +++++++++++++ docs/api/use-layout-actions.md | 33 ++++ docs/api/use-layout-state.md | 36 +++++ docs/api/use-splitter.md | 39 +++++ docs/api/use-tab.md | 41 +++++ docs/api/use-tabset.md | 43 ++++++ docs/getting-started.md | 115 ++++++++++++++ docs/guides/accessibility.md | 64 ++++++++ docs/guides/core-without-react.md | 97 ++++++++++++ docs/guides/custom-components.md | 71 +++++++++ docs/guides/multiple-layouts.md | 18 +++ docs/guides/nextjs-and-ssr.md | 54 +++++++ docs/guides/performance.md | 26 ++++ docs/guides/persisting-layouts.md | 97 ++++++++++++ docs/guides/theming.md | 90 +++++++++++ docs/migration-v1-to-v2.md | 239 +++++++++++++++++++++++++++++ 22 files changed, 1623 insertions(+) create mode 100644 docs/api/README.md create mode 100644 docs/api/create-layout.md create mode 100644 docs/api/dynamix-layout.md create mode 100644 docs/api/layout-json.md create mode 100644 docs/api/migrate-layout-from-v1.md create mode 100644 docs/api/use-drag-state.md create mode 100644 docs/api/use-dynamix-layout.md create mode 100644 docs/api/use-layout-actions.md create mode 100644 docs/api/use-layout-state.md create mode 100644 docs/api/use-splitter.md create mode 100644 docs/api/use-tab.md create mode 100644 docs/api/use-tabset.md create mode 100644 docs/getting-started.md create mode 100644 docs/guides/accessibility.md create mode 100644 docs/guides/core-without-react.md create mode 100644 docs/guides/custom-components.md create mode 100644 docs/guides/multiple-layouts.md create mode 100644 docs/guides/nextjs-and-ssr.md create mode 100644 docs/guides/performance.md create mode 100644 docs/guides/persisting-layouts.md create mode 100644 docs/guides/theming.md create mode 100644 docs/migration-v1-to-v2.md diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..530fde4 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,28 @@ +# API reference + +## `@dynamix-layout/react` + +| Export | What it is | +|---|---| +| [`DynamixLayout`](./dynamix-layout.md) | The component. Most apps only need this. | +| [`useDynamixLayout`](./use-dynamix-layout.md) and `DynamixLayoutProvider` | Headless layout: state and prop getters for a custom UI | +| [`useTabset`](./use-tabset.md) | One tabset: its panel and tab bar | +| [`useTab`](./use-tab.md) | One tab: its button and its content | +| [`useSplitter`](./use-splitter.md) | One splitter | +| [`useLayoutState`](./use-layout-state.md) | Subscribe to part of the layout state | +| [`useLayoutActions`](./use-layout-actions.md) | Stable actions (move, select, maximize, fold, …) | +| [`useDragState`](./use-drag-state.md) | The drag in progress | +| `Panel`, `TabBar`, `Tab`, `TabContent`, `Splitter`, `DropIndicator`, `RootDropZone`, `TabsetToolbar` | The default slot components, for composing your own ([guide](../guides/custom-components.md)) | + +The React package also re-exports from core: `migrateLayoutFromV1`, `isLayoutV1`, `DynamixLayoutError` and the layout types. + +## `@dynamix-layout/core` + +| Export | What it is | +|---|---| +| [`createLayout`](./create-layout.md) | Creates a layout instance (engine) | +| [`LayoutJSON`](./layout-json.md) | The saved-layout format, `LAYOUT_VERSION` | +| [`migrateLayoutFromV1`](./migrate-layout-from-v1.md) and `isLayoutV1` | Convert layouts saved by v1 | +| `DynamixLayoutError` | Thrown for invalid input ([codes](./create-layout.md#errors-and-warnings)) | +| `getTabBarPlacement`, `getTabContentRect`, `getRootDropZoneRect`, `applyRect`, `createFrameScheduler` | Helpers for writing adapters ([guide](../guides/core-without-react.md)) | +| `version` | The package version | diff --git a/docs/api/create-layout.md b/docs/api/create-layout.md new file mode 100644 index 0000000..90c1b26 --- /dev/null +++ b/docs/api/create-layout.md @@ -0,0 +1,141 @@ +# `createLayout` + +Creates a layout engine instance. It's framework-agnostic: no DOM access, no timers, no module-level state. Every instance is independent, so you can run several layouts on a page, or one per server request. + +```ts +import { createLayout } from '@dynamix-layout/core' + +function createLayout(options: LayoutOptions): Layout +``` + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly TabInit[]` | required | The open tabs: `{ id: string; target?: DropTarget }`. Order only matters for the default layout. | +| `initialLayout` | `LayoutJSON \| LayoutTreeV1 \| null` | default layout | A saved layout. v1 trees are migrated automatically, with a `MIGRATED_FROM_V1` warning. | +| `minPanelSize` | `{ width: number; height: number }` | `{ width: 40, height: 40 }` | Smallest tabset size. | +| `splitterSize` | `number` | `10` | Splitter thickness. | +| `foldedSize` | `number` | `minPanelSize.height` | Size of a folded tabset along its row; usually the tab bar height. | +| `createId` | `(kind: 'row' \| 'tabset', hint?: string) => string` | deterministic | Id factory for rows and tabsets. The default gives `ts-` and `row-`, the same on server and client. | +| `onLayoutChange` | `(layout: LayoutJSON, reason: LayoutChangeReason) => void` | | After every committed change. Not called for drag frames, container resizes or `load()`. | +| `onWarning` | `(warning: LayoutWarning) => void` | `console.warn` in development | Non-fatal problems such as repaired saved layouts or unknown ids. | + +Default layout: each tab gets its own tabset, nested with alternating directions: `t1 | (t2 / (t3 | …))`. + +## The `Layout` instance + +Actions return `true` when they changed the layout and `false` when they were refused or did nothing. They never throw. + +| Method | Description | +|---|---| +| `getSnapshot()` | The current [snapshot](#snapshots). | +| `subscribe(listener)` | Called with the new snapshot after every change. Returns an unsubscribe function. | +| `setContainerRect(rect)` | The area to fill. `x`/`y` offset everything, which is how adapters apply padding. | +| `setTabs(tabs)` | Replace the open tabs. Unlisted tabs are removed; new ones are added (at their `target`, else the last active tabset) and activated. | +| `setOptions({ minPanelSize?, splitterSize?, foldedSize? })` | Change sizes without rebuilding. | +| `moveTab(tabId, target)` | Move a tab. See [`DropTarget`](#droptarget). | +| `moveTabset(tabsetId, target)` | Move a whole tabset (into another tabset, beside one, or to an edge). | +| `selectTab(tabId)` | Make a tab active. | +| `addTab(tab, target?)` | Add one tab. | +| `removeTab(tabId)` | Remove one tab. Emptied tabsets and single-child rows are dissolved. | +| `resizeSplitter(splitterId, point)` | Move a splitter so its centre is at `point`, clamped to the neighbours' minimum sizes. | +| `moveSplitterBy(splitterId, delta)` | Move a splitter by `delta` px along its row (keyboard resizing). | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize one tabset over the whole layout; the others stay in place, hidden. Needs at least two tabsets. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold to a strip along its row. Folding a row's last open child unfolds the most recently folded sibling. Needs a sibling. | +| `startDrag(source)` | Start a drag of `{ type: 'tab', tabId }`, `{ type: 'tabset', tabsetId }` or `{ type: 'splitter', splitterId }`. Tab and tabset drags leave maximized mode first. | +| `updateDrag(point, measurements?)` | Move the drag. Tab drags update `snapshot.drag.target`; splitter drags move the splitter. Never calls `onLayoutChange`. | +| `setDragTarget(target, measurements?)` | Set the target directly (keyboard move mode). | +| `endDrag()` | Commit: move the tab, or keep the splitter position, with one `onLayoutChange`. | +| `cancelDrag()` | End without moving; a dragged splitter goes back. | +| `getDropTarget(point, source, measurements?)` | Hit testing without starting a drag. | +| `getDropIndicatorRect(target, measurements?)` | The indicator rect for a target. | +| `listDropTargets(source)` | Every valid target, in reading order. | +| `getSplitterBounds(splitterId)` | `{ value, min, max }` in px, for ARIA. | +| `load(layout)` | Replace the layout (v1 or v2) without calling `onLayoutChange`. | +| `reset()` | Back to `initialLayout`, or the default layout of the current tabs. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). Returns the same object until the layout changes. | +| `destroy()` | Remove all listeners. Later actions are ignored with a `DESTROYED` warning. | + +`measurements` (`DropMeasurements`) describes the tab bars as rendered: `{ tabBars: Map }`, in layout-root coordinates. The engine can't know label widths, so adapters measure them once when a drag starts. Without measurements, tab bars aren't targets, but panels and edges still are. + +### `DropTarget` + +```ts +type DropTarget = + | { type: 'tabset'; tabsetId: string; position: 'top' | 'bottom' | 'left' | 'right' | 'center' } + | { type: 'tab'; tabId: string; position: 'before' | 'after' } + | { type: 'root'; position: 'top' | 'bottom' | 'left' | 'right' } +``` + +- **`tabset` + `center`:** append to that tabset. +- **`tabset` + a side:** beside it. If the side runs across the tabset's row, the tabset is wrapped in a new row. +- **`tab`:** into that tab's tabset, before or after it. +- **`root`:** at an edge of the whole layout. + +Refused moves: + +- a tab onto itself; +- a lone tab onto its own tabset; +- a tabset onto itself or its own tabs; +- the only tabset, or the only tab, in the layout. + +## Snapshots + +```ts +interface LayoutSnapshot { + root: RowNode // the tree + tabsets: ReadonlyMap // id, tabIds, activeTabId, parentDirection, isFolded, isMaximized, isHidden, canFold, canMaximize + splitters: ReadonlyMap // id, rowId, direction, beforeId, afterId, isLocked, isHidden + tabs: ReadonlyMap // id, tabsetId, isActive, isVisible + rects: LayoutRects // container, rows, tabsets, splitters (layout-root coordinates) + maximizedTabsetId: string | null + drag: DragState | null // source, target, indicator +} +``` + +Snapshots are immutable and **share unchanged parts by reference**: + +- A container resize changes `rects`, but not `tabsets`, `splitters` or `tabs`. +- Selecting a tab replaces one `TabsetState` and two `TabState`s; every other entry keeps its identity. + +Compare with `===` at any depth. In development the snapshot objects are frozen. + +## Errors and warnings + +`createLayout` and `load` throw `DynamixLayoutError` for input they can't use: + +| `code` | When | +|---|---| +| `INVALID_LAYOUT` | Malformed saved layout. `error.path` says where, e.g. `root.children[1].weight`. | +| `UNSUPPORTED_VERSION` | A `version` other than 2. | +| `DUPLICATE_TAB_ID` | Duplicate ids in `tabs`, in development. In production the first one wins and a warning is reported. | + +`onWarning` receives `{ code, message, path? }` with these codes: + +- `MIGRATED_FROM_V1` +- `DUPLICATE_TAB_ID` +- `INVALID_ACTIVE_TAB` +- `INVALID_WEIGHT` +- `UNKNOWN_ID` +- `DESTROYED` + +## Example + +```ts +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }], + initialLayout: JSON.parse(localStorage.getItem('layout') ?? 'null'), + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) + +const unsubscribe = layout.subscribe((snapshot) => render(snapshot)) +layout.setContainerRect({ x: 0, y: 0, width: 1200, height: 800 }) + +// A splitter drag: transient frames, one change at the end. +layout.startDrag({ type: 'splitter', splitterId }) +layout.updateDrag({ x: 400, y: 300 }) +layout.endDrag() +``` + +More in [Using the core without React](../guides/core-without-react.md). diff --git a/docs/api/dynamix-layout.md b/docs/api/dynamix-layout.md new file mode 100644 index 0000000..766f158 --- /dev/null +++ b/docs/api/dynamix-layout.md @@ -0,0 +1,113 @@ +# `` + +A docking layout: tabs in tabsets, split by draggable splitters, with tab drag-and-drop, maximize and fold. Built on [`useDynamixLayout`](./use-dynamix-layout.md). + +```tsx +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' +``` + +## Signature + +```ts +const DynamixLayout: ForwardRefExoticComponent> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly TabItem[]` | required | The open tabs. See [`TabItem`](#tabitem). | +| `defaultLayout` | `LayoutJSON \| LayoutTreeV1` | default layout | Uncontrolled: the initial layout. Later changes are ignored. | +| `layout` | `LayoutJSON \| LayoutTreeV1` | | Controlled: the layout to show. Update it from `onLayoutChange`. | +| `onLayoutChange` | `(layout: LayoutJSON, details: { reason: LayoutChangeReason }) => void` | | After every committed change. `reason` is `'move'`, `'resize'`, `'select'`, `'fold'`, `'maximize'`, `'tabs'` or `'reset'`. Not called on mount, on container resize, or during a drag. | +| `onTabClose` | `(tabId: string) => void` | | Called by a closable tab's close button and the Delete key. Remove the tab from `tabs` to close it. | +| `minPanelSize` | `{ width: number; height: number }` | `{ width: 40, height: 40 }` | Smallest tabset size. The height is never less than `tabBarHeight`. | +| `splitterSize` | `number` | `10` | Splitter thickness, in px. | +| `tabBarHeight` | `number` | `40` | Tab bar height, and the size of a folded strip. | +| `showTabBar` | `boolean` | `true` | Hiding the tab bar also disables folding. | +| `padding` | `number \| { top?, right?, bottom?, left? }` | `0` | Space between the root's edge and the layout, in px. | +| `allowMaximize` | `boolean` | `true` | Shows the maximize button, and enables double-click and the shortcut. | +| `allowFold` | `boolean` | `true` | Shows the fold button and enables the shortcut. | +| `maximizeOnDoubleClick` | `boolean` | `true` | Double-click a tab bar to maximize or restore. | +| `keyboardShortcuts` | `boolean` | `true` | Alt/Option + "+" maximizes and Alt/Option + "-" folds the focused tabset. | +| `tabActivation` | `'automatic' \| 'manual'` | `'automatic'` | Arrow keys select tabs as they move focus (`automatic`), or only move focus (`manual`; Enter or Space selects). | +| `resizeThrottleMs` | `number` | `0` | Throttle for container resizes; `0` means once per animation frame. | +| `components` | `Partial` | | Replace any slot component. See [custom components](../guides/custom-components.md). | +| `classNames` | `Partial>` | | Extra class names, by slot. | +| `styles` | `Partial>` | | Extra styles, by slot. Positioning styles always win. | +| `id`, `className`, `style`, `aria-*`, `on*`, … | `HTMLAttributes` | | Passed to the root `
      `. `id` also prefixes the DOM ids used for ARIA. | + +Slots (`LayoutSlot`): `root`, `panel`, `tabBar`, `tab`, `activeTab`, `tabClose`, `tabContent`, `splitter`, `dropIndicator`, `rootDropZone`, `toolbar`, `toolbarButton`. + +### `TabItem` + +| Field | Type | Default | Description | +|---|---|---|---| +| `id` | `string` | required | Unique and stable; saved layouts store it. | +| `title` | `ReactNode` | `id` | The tab's label. Text titles are also used in ARIA labels. | +| `content` | `ReactNode` | required | Rendered once, kept mounted while the tab exists. | +| `closable` | `boolean` | `false` | Shows a close button; closing calls `onTabClose`. | +| `target` | `DropTarget` | last active tabset | Where the tab goes the first time it appears. | + +## Imperative handle + +`ref` gives a `DynamixLayoutHandle`. Every function is stable: + +| Member | Description | +|---|---| +| `element` | The root `
      `, or `null` before mount. | +| `moveTab(tabId, target)` / `moveTabset(tabsetId, target)` | Move to a [`DropTarget`](./create-layout.md#droptarget). Return `true` when something moved. | +| `selectTab(tabId)` | Make a tab active. | +| `focusTab(tabId)` | Move keyboard focus to a tab. | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize a tabset over the whole layout, and restore. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold a tabset to a strip, and unfold. | +| `reset()` | Back to `defaultLayout`, or the default layout of the current tabs. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). | +| `getSnapshot()` | The current [snapshot](./create-layout.md#snapshots). | + +There is no `addTab`/`removeTab` on the handle: change `tabs` instead. `tabs` is the single source of truth for which tabs exist. + +## Examples + +**Persisted, with custom tabs and theming:** + +```tsx + save(layout)} + minPanelSize={{ width: 120, height: 80 }} + splitterSize={6} + tabBarHeight={36} + padding={8} + components={{ Tab: MyTab }} + classNames={{ tab: 'my-tab', activeTab: 'is-active', splitter: 'my-splitter' }} + styles={{ tabBar: { background: '#111' } }} +/> +``` + +**Controlled** ([guide](../guides/persisting-layouts.md#controlled-mode)): + +```tsx +const [layout, setLayout] = useState() + +``` + +**Driving it from code:** + +```tsx +const ref = useRef(null) + +// later +ref.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +``` + +## Common pitfalls + +- **Nothing shows.** The layout fills its parent, so the parent needs a height. +- **Unstyled layout.** Import `@dynamix-layout/react/styles.css` once. +- **A tab didn't close.** Closing calls `onTabClose`; the tab stays until you remove it from `tabs`. +- **Ids must be unique and stable.** Changing a tab's id is a remove plus an add, so its content remounts. Changing only `title` or `content` is cheap and doesn't touch the layout. +- **Controlled mode reverts changes you don't store.** If `onLayoutChange` doesn't update `layout`, the user's change is undone. Use `defaultLayout` if you only want to observe changes. +- **Custom slot components must forward `ref`** (`forwardRef` on React 18). diff --git a/docs/api/layout-json.md b/docs/api/layout-json.md new file mode 100644 index 0000000..4d604f6 --- /dev/null +++ b/docs/api/layout-json.md @@ -0,0 +1,87 @@ +# `LayoutJSON` + +The saved-layout format: what `onLayoutChange` and `toJSON()` give you, and what `defaultLayout`, `layout`, `initialLayout` and `load()` accept. + +```ts +const LAYOUT_VERSION = 2 + +interface LayoutJSON { + version: 2 + root: RowJSON + /** Id of the maximized tabset, if any. */ + maximizedTabsetId?: string +} + +interface RowJSON { + type: 'row' + id: string + /** Share of the parent's space above the minimum sizes (like CSS flex-grow). Ignored on the root. */ + weight: number + direction: 'horizontal' | 'vertical' + children: (RowJSON | TabsetJSON)[] +} + +interface TabsetJSON { + type: 'tabset' + id: string + weight: number + activeTabId?: string + /** Written only when true. */ + isFolded?: boolean + children: TabJSON[] +} + +interface TabJSON { + type: 'tab' + /** The id you gave the tab. */ + id: string +} +``` + +## Example + +```json +{ + "version": 2, + "root": { + "type": "row", "id": "root", "weight": 100, "direction": "horizontal", + "children": [ + { "type": "tabset", "id": "ts-editor", "weight": 140, "activeTabId": "editor", + "children": [{ "type": "tab", "id": "editor" }] }, + { "type": "row", "id": "row-1", "weight": 60, "direction": "vertical", + "children": [ + { "type": "tabset", "id": "ts-terminal", "weight": 100, "activeTabId": "terminal", + "children": [{ "type": "tab", "id": "terminal" }] }, + { "type": "tabset", "id": "ts-preview", "weight": 100, "activeTabId": "preview", "isFolded": true, + "children": [{ "type": "tab", "id": "preview" }] } + ] } + ] + } +} +``` + +## Semantics + +- **Weights are proportions, not pixels.** Each row gives its children their minimum size, then shares the rest by weight. Folded tabsets get no share. A weight of `0` is valid: it's what a panel dragged to its minimum gets. So a saved layout keeps its proportions at any container size, and resizing the container never changes the JSON. +- **Directions alternate.** A row's children never have its own direction; such rows are merged when loading. The root may be vertical. +- **Tabs are identified by your ids.** Rows and tabsets have generated ids that are kept across saves; `maximizedTabsetId` refers to one. +- **Splitters and pixel sizes are not stored.** Splitters are derived from the tree. + +## Loading + +Saved layouts are validated and repaired: + +- Invalid weights become 100. +- An `activeTabId` that isn't in the tabset falls back to the first tab. +- Duplicate tabs keep their first occurrence. +- Empty tabsets and rows are removed. +- Single-child rows are dissolved. + +Each repair is reported through `onWarning`. Input that can't be used throws `DynamixLayoutError` with the path of the problem. + +The saved layout and the open `tabs` are reconciled: + +- Tabs missing from `tabs` are removed. +- Tabs missing from the layout are added to the first tabset (they don't become active). + +Layouts saved by v1 (`{ typNode, nodName, … }`) are recognized and migrated; see [`migrateLayoutFromV1`](./migrate-layout-from-v1.md). diff --git a/docs/api/migrate-layout-from-v1.md b/docs/api/migrate-layout-from-v1.md new file mode 100644 index 0000000..a857a48 --- /dev/null +++ b/docs/api/migrate-layout-from-v1.md @@ -0,0 +1,59 @@ +# `migrateLayoutFromV1` and `isLayoutV1` + +Converts a layout saved by v1 (`DynamixLayoutCore._root.toJSON()`, or the `updateJSON` payload) to [`LayoutJSON`](./layout-json.md). + +You rarely need to call it: `createLayout`, `layout.load()`, and the React and Solid components accept v1 trees and migrate them, with a `MIGRATED_FROM_V1` warning. Call it to convert stored data once, for example in a database migration. + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +function isLayoutV1(input: unknown): input is LayoutTreeV1 +function migrateLayoutFromV1(tree: LayoutTreeV1, options?: { onWarning?: (warning: LayoutWarning) => void }): LayoutJSON +``` + +| Parameter | Type | Description | +|---|---|---| +| `tree` | `LayoutTreeV1` | The v1 layout. | +| `options.onWarning` | `(warning) => void` | Receives each repair. Default: ignore. | + +Returns the equivalent v2 layout. Throws `DynamixLayoutError('INVALID_LAYOUT')` when `tree` isn't a v1 layout. + +## Example + +```ts +const stored = JSON.parse(localStorage.getItem('layout') ?? 'null') +if (isLayoutV1(stored)) { + localStorage.setItem('layout', JSON.stringify(migrateLayoutFromV1(stored))) +} +``` + +## Mapping + +| v1 | v2 | +|---|---| +| `typNode` | `type` | +| row and tabset `uidNode` | `id` (kept, so `nodMaxd` still resolves) | +| tab `nodName` | tab `id`. A tab's identity in v1 was its label; tab `uidNode`s were regenerated every session and are dropped. | +| `nodPart` | `weight` (same meaning) | +| depth | `direction`: the root and even depths are `horizontal`, odd depths `vertical` | +| tabset `nodOpen` | `activeTabId` | +| tabset `nodFold: true` | `isFolded: true` | +| root `nodMaxd` | `maximizedTabsetId` | + +**Migrated layouts render pixel-identically.** This was checked against 112 layouts recorded from the v1 engine. The exceptions are layouts saved in states caused by v1 bugs; for those, v2 shows the intended result: +- a row stuck at its minimum size next to a folded tabset; +- stale sizes when every open panel had weight 0. + +## Edge cases + +| v1 input | Result | +|---|---| +| A root with one nested row (left by some v1 moves) | The row is flattened into the root, which becomes vertical. The rendering is identical. | +| `nodOpen` naming a missing tab | The first tab is active (`INVALID_ACTIVE_TAB` warning). | +| Duplicate tab names | The first one wins, later ones are dropped (`DUPLICATE_TAB_ID`). | +| `nodPart` that is negative or `NaN` | `100` (`INVALID_WEIGHT`). `0` and fractions are kept. | +| Empty tabsets or rows | Removed. | +| A tab directly inside a row | Wrapped in a tabset `ts-`. | +| A root that is a tabset | Wrapped in a root row. | +| `nodMaxd` naming a missing tabset | Dropped. | +| `bond` entries, unknown fields | Ignored. | diff --git a/docs/api/use-drag-state.md b/docs/api/use-drag-state.md new file mode 100644 index 0000000..a994594 --- /dev/null +++ b/docs/api/use-drag-state.md @@ -0,0 +1,23 @@ +# `useDragState` + +The drag in progress, or `null`. + +```ts +function useDragState(): DragState | null + +interface DragState { + source: { type: 'tab'; tabId: string } | { type: 'tabset'; tabsetId: string } | { type: 'splitter'; splitterId: string } + target: DropTarget | null // where it would land; null over nothing valid + indicator: Rect | null // the drop indicator rect, in layout-root coordinates +} +``` + +It changes when a drag starts or ends, or when the target changes, not on every pointer move. Use it for drag feedback, such as dimming the source or showing a "drop here" hint. Tab drags themselves are started by `useTab().getTabProps` and `useTabset().getTabBarProps`. + +```tsx +function DragHint() { + const drag = useDragState() + if (!drag || drag.source.type === 'splitter') return null + return
      {drag.target ? 'Release to drop' : 'Drag onto a panel'}
      +} +``` diff --git a/docs/api/use-dynamix-layout.md b/docs/api/use-dynamix-layout.md new file mode 100644 index 0000000..7b50718 --- /dev/null +++ b/docs/api/use-dynamix-layout.md @@ -0,0 +1,109 @@ +# `useDynamixLayout` and `DynamixLayoutProvider` + +The headless layout. It gives you state, prop getters and actions, and you render every element yourself. [``](./dynamix-layout.md) is built on it. + +```ts +function useDynamixLayout(options: UseDynamixLayoutOptions): UseDynamixLayoutResult +function DynamixLayoutProvider(props: { controller: LayoutController; children?: ReactNode }): ReactElement +``` + +## Options + +Everything `` accepts except `components`, `classNames`, `styles` and the root's HTML attributes, plus: + +| Option | Type | Default | Description | +|---|---|---|---| +| `tabs` | `readonly HeadlessTabItem[]` | required | Like [`TabItem`](./dynamix-layout.md#tabitem), but `content` is optional: you render content yourself. | +| `id` | `string` | `useId()` | Prefix for the DOM ids used in ARIA relationships. | +| `defaultLayout`, `layout`, `onLayoutChange`, `onTabClose`, `minPanelSize`, `splitterSize`, `tabBarHeight`, `showTabBar`, `padding`, `allowMaximize`, `allowFold`, `maximizeOnDoubleClick`, `keyboardShortcuts`, `tabActivation`, `resizeThrottleMs` | | | Same as [``](./dynamix-layout.md#props). | + +## Returns + +| Field | Type | Stable? | Description | +|---|---|---|---| +| `controller` | `LayoutController` | yes | Pass to `` so the other hooks work. | +| `getRootProps` | `PropGetter` | yes | Props for the root element: a ref (it observes the root's size), `position: relative`, keyboard shortcuts and focus tracking. | +| `tabsets` | `readonly TabsetState[]` | until the tabsets change | Each tabset's id, tab ids, active tab, fold and maximize state. Not affected by resizes or drag frames. | +| `splitters` | `readonly SplitterState[]` | until the splitters change | | +| `tabs` | `readonly TabState[]` | until the tabs change | Each tab's tabset and whether it's active and visible. | +| `tabIds` | `readonly string[]` | until the ids change | Tab ids in `tabs` order. **Render tab contents in this order:** it never changes when tabs move, so content never remounts. | +| `dropIndicator` | `{ target: DropTarget; props: SlotProps } \| null` | changes with the target | Present while a drag has a valid target. Spread `props` on an element. | +| `rootDropZones` | `readonly { side, isActive, props }[]` | changes with the drag | The four layout-edge targets, present while a tab or tabset is dragged. | +| `actions` | `LayoutActions` | yes | Same as [`useLayoutActions`](./use-layout-actions.md). | + +**Positions are not part of the returned state.** The library writes `left`/`top`/`width`/`height` to the elements you spread its props on. That's why pointer moves and resizes never re-render your components; see [Performance](../guides/performance.md). + +## Example + +```tsx +import { + DynamixLayoutProvider, + useDynamixLayout, + useSplitter, + useTab, + useTabset, +} from '@dynamix-layout/react' + +function MyLayout({ tabs }: { tabs: HeadlessTabItem[] }) { + const { controller, getRootProps, tabsets, splitters, tabIds, dropIndicator } = useDynamixLayout({ tabs }) + return ( + +
      + {tabsets.map((tabset) => )} + {tabIds.map((id) => )} + {splitters.map((splitter) => )} + {dropIndicator &&
      } +
      + + ) +} + +function MyTabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
      +
      + {tabset.tabIds.map((id) => )} +
      + + ) +} + +function MyTab({ tabId }: { tabId: string }) { + const { item, getTabProps } = useTab(tabId) + return +} + +function MyContent({ tabId }: { tabId: string }) { + const { item, getTabContentProps } = useTab(tabId) + return
      {item?.content}
      +} + +function MySplitter({ splitterId }: { splitterId: string }) { + const { getSplitterProps } = useSplitter(splitterId) + return
      +} +``` + +The full working version is in [`examples/react/src/examples/HeadlessExample.tsx`](../../examples/react/src/examples/HeadlessExample.tsx). + +## Prop getters + +Every `getXProps(props?)` merges your props with the library's: + +- `className`s are joined; +- `style`s are merged, with positioning applied last; +- `ref`s are combined; +- for event handlers, yours runs first. Call `event.preventDefault()` in it to skip the library's handler. + +Every other prop the library sets (ARIA, `data-*`, `id`, `tabIndex`) wins, so accessibility stays correct. + +Headless layouts work without the stylesheet: positioned elements get `position: absolute` and the `hidden` attribute inline. Import `styles.css` for the default look, or the `[data-dx-hidden] { display: none !important }` rule if your elements set their own `display`. + +## Pitfalls + +- **The hooks need the provider.** Without it they throw: `` `useTab` must be used inside or . `` +- **Render each tab's content once,** as a direct child of the root, in `tabIds` order, not inside its tabset. The library positions it over the right panel. +- **Spread the prop getters' props, and keep their `ref`.** Without the ref, an element can't be positioned or measured. diff --git a/docs/api/use-layout-actions.md b/docs/api/use-layout-actions.md new file mode 100644 index 0000000..b92202c --- /dev/null +++ b/docs/api/use-layout-actions.md @@ -0,0 +1,33 @@ +# `useLayoutActions` + +Layout actions from anywhere inside a layout. The object and every function in it keep their identity for the layout's lifetime, so they're safe in effect dependencies and memoized callbacks. + +```ts +function useLayoutActions(): LayoutActions +``` + +| Action | Description | +|---|---| +| `moveTab(tabId, target)` | Move a tab to a [`DropTarget`](./create-layout.md#droptarget). Returns `true` if it moved. | +| `moveTabset(tabsetId, target)` | Move a whole tabset. | +| `selectTab(tabId)` | Make a tab active. | +| `maximize(tabsetId)`, `restore()`, `toggleMaximize(tabsetId)` | Maximize over the whole layout, and restore. | +| `fold(tabsetId)`, `unfold(tabsetId)`, `toggleFold(tabsetId)` | Fold to a strip, and unfold. | +| `reset()` | Back to the initial layout. | +| `toJSON()` | The current [`LayoutJSON`](./layout-json.md). | +| `getSnapshot()` | The current snapshot (doesn't subscribe). | + +Actions return `false` instead of throwing when they're refused (for example, moving the only tabset) or when the id is unknown (with a development warning). + +## Example + +```tsx +function OpenInSplit({ tabId }: { tabId: string }) { + const { moveTab, selectTab } = useLayoutActions() + return ( + + ) +} +``` diff --git a/docs/api/use-layout-state.md b/docs/api/use-layout-state.md new file mode 100644 index 0000000..b1f029b --- /dev/null +++ b/docs/api/use-layout-state.md @@ -0,0 +1,36 @@ +# `useLayoutState` + +Reads part of the layout state from anywhere inside a layout, and re-renders only when that part changes. + +```ts +function useLayoutState(selector: (snapshot: LayoutSnapshot) => T, isEqual?: (a: T, b: T) => boolean): T +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `selector` | `(snapshot) => T` | required | Picks a value from the [snapshot](./create-layout.md#snapshots). | +| `isEqual` | `(a, b) => boolean` | `Object.is` | Decides whether the value changed. | + +Snapshots share unchanged parts by reference: maps, entries and rects keep their identity until they change. So selecting an object, like `s.tabsets.get(id)`, re-renders only when that tabset changes. Built on `useSyncExternalStore`, so it's safe with concurrent rendering and SSR. + +## Examples + +```tsx +const activeTabId = useLayoutState((s) => s.tabsets.get(tabsetId)?.activeTabId) +const isMaximized = useLayoutState((s) => s.maximizedTabsetId !== null) +const tabCount = useLayoutState((s) => s.tabs.size) +``` + +Derived arrays need an `isEqual`, or they re-render on every change: + +```tsx +const ids = useLayoutState( + (s) => [...s.tabs.keys()], + (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) +) +``` + +## Pitfalls + +- **Don't select `s.rects`** unless you need positions. They change on every resize and drag frame. +- **It must be used inside a layout** (`` or ``). diff --git a/docs/api/use-splitter.md b/docs/api/use-splitter.md new file mode 100644 index 0000000..d27025d --- /dev/null +++ b/docs/api/use-splitter.md @@ -0,0 +1,39 @@ +# `useSplitter` + +State and prop getters for one splitter. + +```ts +function useSplitter(splitterId: string): UseSplitterResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `splitter` | `SplitterState \| undefined` | `id`, `rowId`, `direction`, `beforeId`, `afterId`, `isLocked` (next to a folded tabset), `isHidden` (while maximized). | +| `isDragging` | `boolean` | Being dragged. | +| `direction` | `'horizontal' \| 'vertical' \| undefined` | Direction of the row it divides. A `horizontal` row has a vertical bar. | +| `value`, `min`, `max` | `number` | The size of the panel before the splitter and its limits, in px, **as of the last render**. They aren't reactive, because they change on every drag frame. | +| `getSplitterProps` | `PropGetter` | Described below. | + +`getSplitterProps` gives the element: + +- `role="separator"` and `aria-orientation`; +- `aria-valuenow`, `aria-valuemin` and `aria-valuemax` as percentages, kept current by the library; +- `aria-controls` and an `aria-label` built from the neighbours' active tabs; +- `tabIndex`, plus `data-dx-direction` and `data-dx-locked`; +- dragging with pointer events, and resizing with arrow keys, Home and End. + +## Example + +```tsx +function Splitter({ splitterId }: { splitterId: string }) { + const { isDragging, getSplitterProps } = useSplitter(splitterId) + return
      +} +``` + +## Pitfalls + +- **Give it a cursor and `touch-action: none`** (`styles.css` does): `ew-resize` for `data-dx-direction="horizontal"`, `ns-resize` for vertical. +- **Locked splitters can't be dragged.** Unfold the folded neighbour first. diff --git a/docs/api/use-tab.md b/docs/api/use-tab.md new file mode 100644 index 0000000..149a08c --- /dev/null +++ b/docs/api/use-tab.md @@ -0,0 +1,41 @@ +# `useTab` + +State and prop getters for one tab: its button in the tab bar and its content. + +```ts +function useTab(tabId: string): UseTabResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `tab` | `TabState \| undefined` | `id`, `tabsetId`, `isActive`, `isVisible`. | +| `item` | `HeadlessTabItem \| undefined` | The item you passed in `tabs` (`title`, `content`, `closable`, …). | +| `isActive` | `boolean` | Shown in its tabset. | +| `isDragging` | `boolean` | Being dragged. | +| `select()` | `() => void` | Stable. | +| `close()` | `() => void` | Stable. Calls `onTabClose` when the tab is closable. | +| `getTabProps` | `PropGetter` | `role="tab"`, `id`, `aria-selected`, `aria-controls`, roving `tabIndex`, `data-state="active\|inactive"`. Click selects, dragging moves the tab, and keys follow the [tabs pattern](../guides/accessibility.md). | +| `getTabContentProps` | `PropGetter` | `role="tabpanel"`, `aria-labelledby`, positioned over the tabset and hidden while inactive. | + +Re-renders when this tab's state or its item changes. + +## Example + +```tsx +function Tab({ tabId }: { tabId: string }) { + const { item, isActive, close, getTabProps } = useTab(tabId) + return ( +
      + {item?.title} + {item?.closable && } +
      + ) +} +``` + +## Pitfalls + +- **Stop `pointerdown` on buttons inside a tab,** or pressing them starts a tab drag. +- **Render content with `getTabContentProps` once per tab,** not inside the tab bar. See [`useDynamixLayout`](./use-dynamix-layout.md#pitfalls). diff --git a/docs/api/use-tabset.md b/docs/api/use-tabset.md new file mode 100644 index 0000000..5801ddc --- /dev/null +++ b/docs/api/use-tabset.md @@ -0,0 +1,43 @@ +# `useTabset` + +State and prop getters for one tabset. + +```ts +function useTabset(tabsetId: string): UseTabsetResult +``` + +## Returns + +| Field | Type | Description | +|---|---|---| +| `tabset` | `TabsetState \| undefined` | `id`, `tabIds`, `activeTabId`, `parentDirection`, `isFolded`, `isMaximized`, `isHidden`, `canFold`, `canMaximize`. `undefined` once the tabset no longer exists. | +| `isRotated` | `boolean` | The tab bar is drawn as a vertical strip (folded in a side-by-side row). | +| `getPanelProps` | `PropGetter` | The tabset's box, positioned by the library. Put backgrounds and borders here. | +| `getTabBarProps` | `PropGetter` | `role="tablist"` and `aria-orientation`. Dragging its background moves the whole tabset, and double-clicking it maximizes. It carries `data-dx-folded`, `data-dx-maximized`, `data-dx-rotated` and `data-dx-hidden`. | +| `toggleMaximize()` | `() => void` | Stable. | +| `toggleFold()` | `() => void` | Stable. | + +Re-renders when this tabset's state changes (its tabs, active tab, fold or maximize), not on resizes or drags. + +## Example + +```tsx +function Tabset({ tabsetId }: { tabsetId: string }) { + const { tabset, getPanelProps, getTabBarProps, toggleMaximize } = useTabset(tabsetId) + if (!tabset) return null + return ( + <> +
      +
      + {tabset.tabIds.map((id) => )} + +
      + + ) +} +``` + +## Pitfalls + +- **The tab bar is rotated with `transform: rotate(90deg)`** when `isRotated` is true; keep `transform-origin: 0 0` (as in `styles.css`) if you style it. +- **Buttons inside the tab bar don't start tabset drags,** and neither do tabs, links or inputs. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3523482 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,115 @@ +# Getting started + +This guide uses React. The concepts are the same in [Solid](../packages/solid/README.md) and in the [core without a framework](./guides/core-without-react.md). + +## Concepts + +A layout is a **tree**: + +- A **tab** is one of your views, such as an editor, a terminal or a preview. You give each tab a stable `id`. +- A **tabset** is a group of tabs that shows one at a time, with a **tab bar** on top. It's drawn inside a **panel**. +- A **row** lays out its children (tabsets or more rows) side by side (`horizontal`) or stacked (`vertical`). Rows nest with alternating directions. The root is a row. +- A **splitter** sits between two neighbours in a row. Dragging it resizes those two and nothing else. + +Each row and tabset has a **weight**. A row gives every child its minimum size, then shares the remaining space in proportion to the weights, like CSS `flex-grow`. Splitter drags change the weights, which is why a saved layout keeps its proportions in any window size. + +Users change the tree by: + +- **dragging a tab** onto another tab bar (to join it), onto a panel's side (to split it), or onto an edge of the layout; +- **dragging a tab bar** to move a whole tabset; +- **dragging splitters**; +- **maximizing** a tabset over the whole layout, or **folding** it to a strip. Both restore the exact previous size. + +The tree is plain JSON ([`LayoutJSON`](./api/layout-json.md)) that you can save and restore. + +## The first layout + +```bash +npm install @dynamix-layout/react +``` + +```tsx +import { DynamixLayout, type TabItem } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const tabs: TabItem[] = [ + { id: 'editor', title: 'Editor', content: }, + { id: 'terminal', title: 'Terminal', content: }, + { id: 'preview', title: 'Preview', content: }, +] + +export function App() { + return ( +
      + +
      + ) +} +``` + +- **The layout fills its parent.** Give the parent a height. It follows the parent's size with a `ResizeObserver`, so sidebars and flex layouts work. +- **Without a saved layout,** every tab gets its own tabset, arranged as `editor | (terminal / preview)`. +- **Tab ids must be unique and stable.** They identify tabs in saved layouts. Duplicate ids throw in development. +- **Content is mounted once** and is never remounted when tabs move, so editors, terminals and iframes keep their state. + +## Saving and restoring + +`onLayoutChange` is called after every change the user commits: a drop, a splitter release, selecting a tab, maximize or fold, and tabs being added or removed. It isn't called on mount or for each pointer move. Store what it gives you and pass it back as `defaultLayout`: + +```tsx +const STORAGE_KEY = 'my-app:layout' + +export function App() { + const [saved] = useState(() => { + const json = localStorage.getItem(STORAGE_KEY) + return json ? JSON.parse(json) : undefined + }) + return ( +
      + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout))} + /> +
      + ) +} +``` + +The saved layout and `tabs` don't have to match: + +- A tab in `tabs` that's missing from the saved layout (a feature you shipped since) is added to the first tabset. +- A tab in the saved layout that's missing from `tabs` is removed. + +Layouts saved by v1 are migrated automatically; see [Persisting layouts](./guides/persisting-layouts.md). + +## Adding, closing and selecting tabs + +`tabs` is the list of open tabs. Add an item to open a tab, and remove it to close one. A new tab opens in the tabset the user last used, or where its `target` says: + +```tsx +const [tabs, setTabs] = useState(initialTabs) + +const openFile = (path: string) => + setTabs((current) => [...current, { id: path, title: basename(path), content: , closable: true }]) + + setTabs((current) => current.filter((tab) => tab.id !== id))} +/> +``` + +To control the layout from code, use the `ref` handle ([`DynamixLayoutHandle`](./api/dynamix-layout.md#imperative-handle)) or [`useLayoutActions`](./api/use-layout-actions.md): + +```tsx +const layout = useRef(null) +layout.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +layout.current?.selectTab('preview') +``` + +## Next steps + +- Restyle it: [Theming](./guides/theming.md), or replace parts with [custom components](./guides/custom-components.md). +- Build a completely custom UI with the [headless hooks](./api/use-dynamix-layout.md). +- Render on the server: [Next.js and SSR](./guides/nextjs-and-ssr.md). +- Keyboard users: [Keyboard and accessibility](./guides/accessibility.md). diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md new file mode 100644 index 0000000..615ca84 --- /dev/null +++ b/docs/guides/accessibility.md @@ -0,0 +1,64 @@ +# Keyboard and accessibility + +Every part follows a WAI-ARIA pattern and can be used without a mouse. + +## Tabs ([Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)) + +- **Roles:** the tab bar is `role="tablist"` (`aria-orientation="vertical"` on a folded strip). Each tab is `role="tab"` with `aria-selected` and `aria-controls`. Its content is `role="tabpanel"` with `aria-labelledby`. +- **Focus:** one tab per tab bar is focusable (roving `tabindex`), so Tab moves between tab bars and contents. + +| Key (on a tab) | Action | +|---|---| +| ← / → (↑ / ↓ on a folded strip) | Focus the previous/next tab, wrapping. With `tabActivation="automatic"` (default) it's also selected. | +| Home / End | First / last tab | +| Enter / Space | Select the focused tab (for `tabActivation="manual"`) | +| Delete / Backspace | Close the tab, if it's closable (calls `onTabClose`) | +| Ctrl/⌘ + Shift + M | Start **move mode** (below) | + +## Moving tabs from the keyboard + +Move mode is a keyboard drag: + +1. **Start.** Focus a tab and press **Ctrl/⌘ + Shift + M**. +2. **Choose a target.** **Arrow keys** cycle through every valid target: each tabset's center and sides, then the layout's edges. The drop indicator shows the target, and a polite live region announces it, e.g. "Move Terminal: right of Editor". +3. **Drop or cancel.** **Enter** or **Space** drops; **Escape** or **Tab** cancels. + +From code, use [`moveTab`](../api/use-layout-actions.md) with a [`DropTarget`](../api/create-layout.md#droptarget). + +## Splitters ([Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)) + +- **Roles:** each splitter is `role="separator"` with `aria-orientation`. `aria-valuenow`, `aria-valuemin` and `aria-valuemax` are the panel's share of the pair, in percent. `aria-controls` points at the panel before it, and the `aria-label` reads e.g. "Resize Editor and Terminal". +- **Focus:** every splitter is focusable. A splitter next to a folded panel is `aria-disabled`. + +| Key (on a splitter) | Action | +|---|---| +| ← / → (side-by-side panels), ↑ / ↓ (stacked panels) | Move 10px | +| Shift + arrow | Move 50px | +| Home / End | Move to the minimum / maximum | +| Enter | Fold or unfold the panel before it (when folding is allowed) | + +## Maximize and fold + +The toolbar buttons are real buttons: +- Maximize has `aria-label` "Maximize" or "Restore" and `aria-pressed`. +- Fold has `aria-label` "Fold" or "Unfold" and `aria-expanded`. + +| Shortcut (focus inside the layout) | Action | +|---|---| +| Alt/Option + "+" | Maximize or restore the focused tabset | +| Alt/Option + "-" | Fold or unfold the focused tabset | + +The shortcuts act on the tabset that contains the focus, or that you last clicked. They only listen inside the layout, so several layouts on a page don't conflict. Turn them off with `keyboardShortcuts={false}`. Double-clicking a tab bar also maximizes it (`maximizeOnDoubleClick`). + +## Pointer and touch + +- **Mouse and pen:** a tab drag starts after the pointer moves 4px, so clicks still select tabs. +- **Touch:** a drag starts after a 350ms long press; a quick swipe scrolls the tab bar instead. +- **Cancel:** Escape cancels any drag. + +## Your own components + +The prop getters (`getTabProps`, `getSplitterProps`, …) apply all of the above. If you replace a slot or build a [headless](../api/use-dynamix-layout.md) UI: +- spread their props onto real focusable elements; +- don't remove `role`, `aria-*` or `tabIndex`; +- keep a visible focus style. `styles.css` uses `--dx-focus-ring`. diff --git a/docs/guides/core-without-react.md b/docs/guides/core-without-react.md new file mode 100644 index 0000000..adff63a --- /dev/null +++ b/docs/guides/core-without-react.md @@ -0,0 +1,97 @@ +# Using the core without React + +`@dynamix-layout/core` holds all the layout logic and has no dependencies. An adapter for another framework, or a vanilla app, does five things: + +1. **Create** an instance: `createLayout({ tabs, initialLayout, onLayoutChange })`. +2. **Measure** the container and call `setContainerRect`, again on every resize. +3. **Render** from snapshots: `subscribe((snapshot) => …)`. +4. **Translate input** into actions: clicks, drags and keys. +5. **Clean up** with `destroy()`. + +```ts +import { + createLayout, + createFrameScheduler, + getTabBarPlacement, + getTabContentRect, + type LayoutSnapshot, +} from '@dynamix-layout/core' + +const TAB_BAR = 36 +const root = document.querySelector('#layout')! +const layout = createLayout({ + tabs: [{ id: 'editor' }, { id: 'terminal' }, { id: 'preview' }], + foldedSize: TAB_BAR, + minPanelSize: { width: 80, height: TAB_BAR }, + onLayoutChange: (json) => localStorage.setItem('layout', JSON.stringify(json)), +}) + +// 2. Measure. Rects are relative to the root's padding box. +const measure = () => layout.setContainerRect({ x: 0, y: 0, width: root.clientWidth, height: root.clientHeight }) +const resizeFrame = createFrameScheduler(measure) +new ResizeObserver(() => resizeFrame.schedule(null)).observe(root) +measure() + +// 3. Render. +layout.subscribe(render) +render(layout.getSnapshot()) + +function render(snapshot: LayoutSnapshot) { + for (const [id, tabset] of snapshot.tabsets) { + const rect = snapshot.rects.tabsets.get(id)! + const bar = getTabBarPlacement(rect, tabset, TAB_BAR) // rotated when folded side by side + drawTabBar(id, bar.rect, bar.isRotated, tabset.tabIds, tabset.activeTabId, tabset.isHidden) + } + for (const [id, tab] of snapshot.tabs) { + const tabsetRect = snapshot.rects.tabsets.get(tab.tabsetId)! + positionContent(id, getTabContentRect(tabsetRect, TAB_BAR), tab.isVisible) + } + for (const [id, splitter] of snapshot.splitters) drawSplitter(id, snapshot.rects.splitters.get(id)!, splitter) + drawIndicator(snapshot.drag?.indicator ?? null) +} +``` + +## Input + +Coordinates passed to the engine are relative to the root: `clientX - rootBox.left - root.clientLeft`. + +**Splitters:** + +```ts +splitterEl.addEventListener('pointerdown', (event) => { + if (!layout.startDrag({ type: 'splitter', splitterId })) return // locked or maximized + splitterEl.setPointerCapture(event.pointerId) + const frame = createFrameScheduler((p: Point) => layout.updateDrag(p)) + const move = (e: PointerEvent) => frame.schedule(toRoot(e)) + const up = () => { + frame.flush() + layout.endDrag() // one onLayoutChange + splitterEl.removeEventListener('pointermove', move) + } + splitterEl.addEventListener('pointermove', move) + splitterEl.addEventListener('pointerup', up, { once: true }) +}) +``` + +**Tabs:** + +1. On drag start, measure the tab bars (`DropMeasurements`: each tab bar's rect and its tabs' rects) and call `startDrag({ type: 'tab', tabId })`. +2. On move, call `updateDrag(point, measurements)`; `snapshot.drag.target` and `snapshot.drag.indicator` follow the pointer. +3. On release, call `endDrag()`. Escape calls `cancelDrag()`. + +Without measurements, drops onto tab bars aren't available, but panels and layout edges still work. + +**Everything else** maps to one call each: +- `selectTab(id)` on click; +- `toggleMaximize(tabsetId)` and `toggleFold(tabsetId)`; +- `moveSplitterBy(id, ±10)` for arrow keys; +- `listDropTargets(source)` and `setDragTarget(target)` for a keyboard move mode. + +## Rendering tips + +- **Render each tab's content once** and position it with `getTabContentRect`. Never move content between parents; iframes reload when moved. +- **Snapshots share unchanged parts by reference**, so compare with `===` to skip work: unchanged `rects.tabsets.get(id)` objects mean that tabset didn't move. +- **A rotated tab bar** (`isRotated`) is an element `rect.width` wide and `rect.height` tall, rotated 90° around its top-left corner (`transform-origin: 0 0`). It covers the folded strip. +- **Hidden tabsets** (`isHidden`, while another tabset is maximized) should stay mounted but invisible. + +The [Solid adapter](../../packages/solid/src/hooks/useLayout.ts) is a complete example in about 250 lines. diff --git a/docs/guides/custom-components.md b/docs/guides/custom-components.md new file mode 100644 index 0000000..cad43b8 --- /dev/null +++ b/docs/guides/custom-components.md @@ -0,0 +1,71 @@ +# Custom tab and splitter components + +`` renders every part through a **slot component** you can replace with `components`: + +| Slot | Default | Receives (besides DOM props) | +|---|---|---| +| `Panel` | `
      ` behind the tabset | `tabset` | +| `TabBar` | `
      ` | `tabset`, `isRotated`, `children` (tabs and the toolbar) | +| `Tab` | title, plus a close button when closable | `tab` (your item), `isActive`, `isDragging`, `onClose?`, `closeButtonProps?` | +| `TabContent` | `
      ` | `tab`, `isActive`, `children` (the content) | +| `Splitter` | a bar with a CSS grip | `splitter`, `isDragging` | +| `DropIndicator` | a highlighted box | `target` | +| `RootDropZone` | a handle on each layout edge, shown while dragging | `side`, `isActive` | +| `TabsetToolbar` | maximize and fold buttons | `tabset`, `isRotated`, `canMaximize`, `canFold`, `onToggleMaximize`, `onToggleFold`, `className`, `style`, `buttonClassName`, `buttonStyle` | + +The DOM props contain everything that makes the part work: ARIA attributes, `data-*` state, event handlers, the positioning `style`, and a `ref`. **Spread them on your root element and forward the `ref`.** Destructure the state props first, so they don't reach the DOM. + +## A custom tab + +```tsx +import { forwardRef, type Ref } from 'react' +import { DynamixLayout, type TabProps } from '@dynamix-layout/react' + +const MyTab = forwardRef(function MyTab( + { tab, isActive, isDragging, onClose, closeButtonProps, className, ...props }, + ref +) { + return ( +
      } + {...props} + className={`${className} my-tab ${isActive ? 'my-tab--active' : ''} ${isDragging ? 'my-tab--dragging' : ''}`} + > + + {tab.title ?? tab.id} + {onClose && closeButtonProps && } +
      + ) +}) + + +``` + +`closeButtonProps` stops the press from starting a drag, labels the button, and calls `onTabClose`. Use it for your close button. + +## A custom splitter + +```tsx +const MySplitter = forwardRef(function MySplitter( + { splitter, isDragging, className, ...props }, + ref +) { + return ( +
      + +
      + ) +}) +``` + +The default stylesheet draws a grip with `.dx-splitter::after`. Add `after:hidden` (Tailwind) or `.my-splitter::after { display: none }` if you draw your own. + +## React 18 and 19 + +Slot components must forward `ref`. On React 18 that means `forwardRef`, as above. On React 19 you can take `ref` as a regular prop instead. + +## When to use the headless hooks instead + +Use `components` when the default structure is fine and you only want different markup or behaviour inside a part. To change the structure itself, such as tab bars below the content or a single shared tab bar, use [`useDynamixLayout`](../api/use-dynamix-layout.md) and render everything yourself. + +A complete example with shadcn-style tabs is in [`examples/react/src/components/wrapper.tsx`](../../examples/react/src/components/wrapper.tsx). diff --git a/docs/guides/multiple-layouts.md b/docs/guides/multiple-layouts.md new file mode 100644 index 0000000..d0419c5 --- /dev/null +++ b/docs/guides/multiple-layouts.md @@ -0,0 +1,18 @@ +# Multiple layouts on one page + +Every `` (and every `createLayout()`) is a separate instance with its own state. Put as many on a page as you like: + +```tsx +
      + + +
      +``` + +- **DOM ids** used for ARIA are scoped by React's `useId`, or by the `id` prop when you pass one, so they never collide. +- **Keyboard shortcuts** listen on each layout's root, so only the layout that has focus reacts. +- **Hooks** (`useTab`, `useLayoutState`, …) use the nearest layout. +- **Drags stay inside their layout.** Moving tabs between two layouts isn't supported. +- **Tab ids only need to be unique within a layout.** + +v1 kept its state in module globals, so two layouts on a page overwrote each other. That's fixed in v2. diff --git a/docs/guides/nextjs-and-ssr.md b/docs/guides/nextjs-and-ssr.md new file mode 100644 index 0000000..817abf9 --- /dev/null +++ b/docs/guides/nextjs-and-ssr.md @@ -0,0 +1,54 @@ +# Next.js and server rendering + +`@dynamix-layout/react` renders on the server and hydrates without warnings: + +- **No `window` or `document` at import or during render.** All measuring happens in effects. +- **The first render is deterministic.** Rows and tabsets get ids derived from your tab ids (`ts-editor`), and DOM ids come from React's `useId`. Server and client produce the same markup. +- **Before the first measurement** the root has `data-dx-measuring`, and the stylesheet hides its children. On the client the layout measures itself before the first paint, so there's no flash. + +## App Router + +The package's entry starts with `'use client'`, so you can render `` from a client component. Tab content is often interactive, so the page is usually a client component too: + +```tsx +// app/page.tsx +'use client' +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' +import { tabs } from './tabs' + +export default function Page() { + return +} +``` + +There's no need for `dynamic(() => import(…), { ssr: false })`; v1 needed it. You can still use it for heavy tab content such as editors. + +Server components can read and migrate saved layouts with the core, which has no client code: + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +const saved = await db.getLayout(user.id) +const layout = isLayoutV1(saved) ? migrateLayoutFromV1(saved) : saved +``` + +## Restoring a saved layout without a mismatch + +The server and the client must start from the same layout. If you keep it in `localStorage`, the server can't read it. Either: + +- store it where the server can read it (a cookie or your database) and pass it as `defaultLayout` on both sides; or +- render the layout only after mount when you restore from `localStorage`: + +```tsx +const [saved, setSaved] = useState(undefined) +useEffect(() => setSaved(load() ?? null), []) +if (saved === undefined) return null +return +``` + +## Pitfalls + +- **Give the layout a height.** `100vh`, or a parent with a fixed or flex height. In the App Router, a `body` with `height: 100%` doesn't fill the viewport by itself. +- **Import the stylesheet once,** in the page or in `app/layout.tsx`. +- **Two copies of `@types/react`** (common in monorepos with mixed React versions) cause "cannot be used as a JSX component" errors. Make sure your app resolves one copy; see the `paths` in [the Next.js example's tsconfig](../../examples/nextjs/tsconfig.json). diff --git a/docs/guides/performance.md b/docs/guides/performance.md new file mode 100644 index 0000000..032e5ec --- /dev/null +++ b/docs/guides/performance.md @@ -0,0 +1,26 @@ +# Performance + +## What re-renders + +- **Pointer moves never re-render React.** During splitter drags, drop targeting and container resizes, the engine computes new rects, and an internal writer applies `left`/`top`/`width`/`height` directly to the elements. Components render the same positions from the current snapshot whenever React does render, so the two always agree. +- **Components re-render only for state they read.** Each tab, tabset and splitter part subscribes to its own entry of the snapshot. Entries keep their identity until they change, so selecting a tab re-renders that tabset's parts and nothing else. +- **The engine is created once per layout.** Changing `minPanelSize`, `splitterSize` or `tabBarHeight` updates it. Changing `tabs` updates it only when the set of ids changes. + +## Your side + +- **Inline `tabs` arrays are fine.** A new array with the same ids does no engine work, and a tab re-renders only if its own `title`, `content`, `closable` or `target` changed. Elements created inline (`content: `) are new objects on every render, so that tab's content re-renders with its parent, as it would anywhere in React. Memoize heavy content, or keep the `tabs` array in state, if that matters. +- **Content is never remounted** when tabs move, and keeps its DOM position, so iframes don't reload. Changing a tab's `id` remounts it. +- **`useLayoutState` selectors should return stable values:** primitives, or objects from the snapshot. Derived arrays need an `isEqual`. +- **`resizeThrottleMs`** limits container updates during window resizes; the default `0` updates once per animation frame. +- **Hidden tab content stays mounted** (inactive tabs, panels hidden behind a maximized one). To pause expensive work in hidden content, read `useTab(id).isActive` or `useLayoutState((s) => s.tabs.get(id)?.isVisible)`. + +## Sizes + +Minified and gzipped, measured on the 2.0 build: + +| Package | Size | +|---|---| +| `@dynamix-layout/core` | 9.5 kB | +| `@dynamix-layout/react` | 9.5 kB, plus core (React is a peer dependency) | +| `@dynamix-layout/solid` | 5.3 kB, plus core | +| `styles.css` (React) | 1.3 kB | diff --git a/docs/guides/persisting-layouts.md b/docs/guides/persisting-layouts.md new file mode 100644 index 0000000..28c38bc --- /dev/null +++ b/docs/guides/persisting-layouts.md @@ -0,0 +1,97 @@ +# Persisting layouts + +A layout is plain JSON ([`LayoutJSON`](../api/layout-json.md)). Save it from `onLayoutChange` and pass it back on the next visit. + +## Uncontrolled (most apps) + +`defaultLayout` is read once; after that the component owns the layout. + +```tsx +const STORAGE_KEY = 'my-app:layout' + +function load(): LayoutJSON | undefined { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? JSON.parse(json) : undefined + } catch { + return undefined + } +} + +export function Workspace() { + const [saved] = useState(load) + return ( + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout))} + /> + ) +} +``` + +**When `onLayoutChange` is called:** +- after a drop (`'move'`); +- when a splitter is released, or moved with the keyboard (`'resize'`); +- when a tab is selected (`'select'`); +- when a tabset is folded (`'fold'`) or maximized (`'maximize'`); +- when tabs are added or removed (`'tabs'`); +- after `reset()` (`'reset'`). + +**When it isn't called:** +- on mount; +- on container resizes, since weights are proportions and resizing doesn't change the JSON; +- for each frame of a drag. + +Saving on every call is fine. If you persist to a server, debounce it. + +## Controlled mode + +Pass `layout` and update it from `onLayoutChange`. The layout always shows what you pass: + +```tsx +const [layout, setLayout] = useState(load) + + { + setLayout(next) + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + }} +/> +``` + +- **Changes you don't store are reverted.** A change that `onLayoutChange` doesn't store is undone before the next paint. You can also store a modified copy. +- **You can replace the layout at any time,** for example to switch between saved workspaces: set `layout` to another `LayoutJSON`, or a v1 tree. +- **Drag frames are not changes.** A splitter drag moves freely and is reported once, on release. + +The [controlled example](../../examples/react/src/examples/ControlledExample.tsx) persists to localStorage and adds and closes tabs. + +## Saved layouts and the `tabs` you pass + +Apps change; a saved layout can mention tabs you no longer have, and miss new ones: + +- tabs in `tabs` but not in the saved layout are **added** to the first tabset; +- tabs in the saved layout but not in `tabs` are **removed**. + +Tabs are matched by `id`, so keep ids stable across releases. + +## Layouts saved by v1 + +v1 saved a different shape (`{ typNode, nodName, nodKids, … }`). Pass it to `defaultLayout`, `layout` or `load()` as it is: it's migrated automatically and renders the same. In development you'll see a one-time warning suggesting you save it again. To convert stored layouts in bulk, use [`migrateLayoutFromV1`](../api/migrate-layout-from-v1.md). + +## Validation + +Saved data can be corrupted or hand-edited. Repairable problems are fixed and reported through `onWarning` (core) or `console.warn` (development). These include invalid weights, an unknown active tab, duplicate tabs and empty nodes. Unusable input (not a layout, or an unknown `version`) throws a `DynamixLayoutError` with the path of the problem. If you load untrusted data, catch it: + +```tsx +function safeLayout(json: unknown) { + try { + createLayout({ tabs: [], initialLayout: json as LayoutJSON }) // validate only + return json as LayoutJSON + } catch { + return undefined + } +} +``` diff --git a/docs/guides/theming.md b/docs/guides/theming.md new file mode 100644 index 0000000..553d7ca --- /dev/null +++ b/docs/guides/theming.md @@ -0,0 +1,90 @@ +# Theming + +From the lightest to the most control: + +1. **CSS variables** on `.dx-root`, or any ancestor that sets them on `.dx-root`. +2. **`classNames` and `styles`**, by slot. +3. **State attributes** in your own CSS. +4. **[Custom components](./custom-components.md)**. + +Import the default stylesheet once: + +```ts +import '@dynamix-layout/react/styles.css' +``` + +## CSS variables + +| Variable | Default | Used for | +|---|---|---| +| `--dx-font` | `inherit` | Root font | +| `--dx-tab-bar-bg` | `#dfdfdf` | Tab bar background (the toolbar inherits it) | +| `--dx-tab-bg` | `#dfdfdf` | Inactive tab | +| `--dx-tab-active-bg` | `#ffffff` | Active tab | +| `--dx-tab-color` | `inherit` | Tab text | +| `--dx-tab-radius` | `4px` | Tab corners | +| `--dx-tab-gap` | `6px` | Space between tabs | +| `--dx-splitter-bg` | `#ffffff` | Splitter | +| `--dx-splitter-hover-bg` | `#e8f2ff` | Splitter on hover | +| `--dx-grip-color` | `#9a9a9a` | Splitter grip dots | +| `--dx-drop-indicator-bg` | `rgba(0, 175, 249, 0.35)` | Drop indicator fill | +| `--dx-drop-indicator-border` | `2px dashed rgb(0, 196, 42)` | Drop indicator border | +| `--dx-root-drop-zone-bg` | `#0081f9` | Layout-edge drop handles | +| `--dx-focus-ring` | `2px solid #0081f9` | Keyboard focus outline | + +A dark theme: + +```css +.dark .dx-root { + --dx-tab-bar-bg: #18181b; + --dx-tab-bg: #27272a; + --dx-tab-active-bg: #3f3f46; + --dx-tab-color: #e4e4e7; + --dx-splitter-bg: #09090b; + --dx-splitter-hover-bg: #27272a; + --dx-grip-color: #52525b; +} +``` + +## `classNames` and `styles` + +Both are keyed by slot: `root`, `panel`, `tabBar`, `tab`, `activeTab`, `tabClose`, `tabContent`, `splitter`, `dropIndicator`, `rootDropZone`, `toolbar` and `toolbarButton`. Class names are added next to the defaults (`.dx-tab`, …). Styles are merged under the positioning styles, so `left`/`top`/`width`/`height` always come from the layout. + +```tsx + +``` + +Inline `classNames` and `styles` objects are compared by content, so creating them inline doesn't re-render the layout's parts. + +## Classes and state attributes + +| Element | Class | Attributes | +|---|---|---| +| root | `.dx-root` | `data-dx-measuring` (before the first measurement), `data-dx-dragging` | +| panel | `.dx-panel` | `data-dx-hidden` | +| tab bar | `.dx-tab-bar` | `data-dx-folded`, `data-dx-maximized`, `data-dx-rotated`, `data-dx-has-toolbar`, `data-dx-hidden` | +| tab | `.dx-tab` | `data-state="active \| inactive"` | +| close button | `.dx-tab-close` | | +| tab content | `.dx-tab-content` | `data-dx-hidden` | +| splitter | `.dx-splitter` | `data-dx-direction`, `data-dx-locked`, `data-dx-hidden` | +| drop indicator | `.dx-drop-indicator` | | +| edge drop zone | `.dx-root-drop-zone` | `data-dx-side`, `data-dx-active` | +| toolbar | `.dx-toolbar`, `.dx-toolbar-button` | | + +Examples: + +```css +.dx-tab-bar[data-dx-folded] { background: var(--strip-bg); } +.dx-root[data-dx-dragging] .dx-tab-content { opacity: 0.85; } +.dx-splitter[data-dx-direction='horizontal']:hover { background: var(--accent); } +``` + +**Hiding uses `[data-dx-hidden]`** (`display: none !important` in the stylesheet) plus the `hidden` attribute. Don't override `display` on hidden elements. + +## Tailwind + +`classNames` works with utility classes as they are. To react to state, use data variants: `data-[state=active]:bg-white` on tabs, or `group-data-[dx-dragging]:…` with `group` on the root. See the [custom components example](../../examples/react/src/components/layout-config.ts). diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..933fbf9 --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,239 @@ +# Migrating from v1 to v2 + +v2 replaces the global v1 engine with independent instances. It simplifies the React API from 46 props to one small component plus hooks, and adds a versioned save format. **Layouts your users saved with v1 keep working**: v2 loads them as they are, and they render the same. + +## Steps + +1. **Upgrade.** `npm install @dynamix-layout/react@2` (or `@dynamix-layout/solid@2`). `@dynamix-layout/core` is now a dependency of the adapters, so you only need it if you import from it. +2. **Change the stylesheet import** to `@dynamix-layout/react/styles.css`. +3. **Convert `tabs`** from tuples to objects, and fold `tabNames` into `title`. +4. **Rename the layout props.** `layoutTree` becomes `defaultLayout` (or `layout` for controlled mode), and `updateJSON` becomes `onLayoutChange`, which now receives v2 JSON. +5. **Rename the size props:** `minPanelSize`, `splitterSize`, `tabBarHeight`, `showTabBar`, `padding`. +6. **Replace the wrapper and styling props** with `components`, `classNames` and `styles`. +7. **Remove the debounce props**; use `resizeThrottleMs` if you need throttling. +8. If you used `ref`, `useDynamixLayout` or `@dynamix-layout/core` directly, see the sections below. +9. If your CSS targets `.DefaultWrap*` classes or `data-uid`/`data-type`, switch to the [`.dx-*` classes and `data-dx-*` attributes](./guides/theming.md#classes-and-state-attributes). + +Saved layouts need no changes. You may want to convert them once; see [Saved layouts](#saved-layouts). + +## React: before and after + +```tsx +// v1 +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/dist/layout.css' + +], + ['terminal', ], + ]} + tabNames={new Map([['editor', 'Editor'], ['terminal', 'Terminal']])} + layoutTree={saved} + updateJSON={(tree) => save(tree)} + minTabWidth={120} + minTabHeight={80} + bondWidth={6} + tabHeadHeight={36} + pad={{ t: 8, b: 8, l: 8, r: 8 }} + WrapTabLabel={MyLabel} + SliderElement={MySlider} + tabLabelElementClass="my-tab" + sliderElementStyles={{ background: '#222' }} + disableResizeTimeout={false} + windowResizeTimeout={16} +/> + +// v2 +import { DynamixLayout } from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + + }, + { id: 'terminal', title: 'Terminal', content: }, + ]} + defaultLayout={saved} // v1 layouts load as they are + onLayoutChange={(layout) => save(layout)} + minPanelSize={{ width: 120, height: 80 }} + splitterSize={6} + tabBarHeight={36} + padding={8} + components={{ Tab: MyTab, Splitter: MySplitter }} + classNames={{ tab: 'my-tab' }} + styles={{ splitter: { background: '#222' } }} + resizeThrottleMs={16} +/> +``` + +## Rename table + +### React props + +| v1 | v2 | +|---|---| +| `tabs: [id, node][]` + `tabNames` | `tabs: { id, title?, content, closable?, target? }[]` | +| `layoutTree` | `defaultLayout` (uncontrolled) / `layout` (controlled) | +| `updateJSON(tree)` | `onLayoutChange(layout, { reason })` | +| `minTabWidth` / `minTabHeight` | `minPanelSize: { width, height }` | +| `bondWidth` | `splitterSize` | +| `tabHeadHeight` | `tabBarHeight` | +| `enableTabbar` | `showTabBar` | +| `pad: { t, b, l, r }` | `padding: number \| { top, right, bottom, left }` | +| `enableMaximize` / `enableCollapse` / `enableDoubleClickMaximize` | `allowMaximize` / `allowFold` / `maximizeOnDoubleClick` | +| `keyboardShortcuts` | unchanged (now scoped to focus inside the layout) | +| `WrapTabPanel` | `components.Panel` (now a visible box behind each tabset) | +| `WrapTabHead` | `components.TabBar` | +| `WrapTabLabel` | `components.Tab` | +| `WrapTabBody` | `components.TabContent` | +| `SliderElement` | `components.Splitter` | +| `HoverElement` | `components.DropIndicator` | +| `RootSplitterHoverEl` | `components.RootDropZone` | +| `TabsetToolbar` | `components.TabsetToolbar` | +| `tabPanelElementStyles` / `…Class` | `styles.panel` / `classNames.panel` | +| `tabHeadElementStyles` / `…Class` | `styles.tabBar` / `classNames.tabBar` | +| `tabLabelElementStyles` / `…Class` | `styles.tab` / `classNames.tab` (also `activeTab`, `tabClose`) | +| `tabBodyElementStyles` / `…Class` | `styles.tabContent` / `classNames.tabContent` | +| `sliderElementStyles` / `…Class` | `styles.splitter` / `classNames.splitter` | +| `hoverElementStyles` / `…Class` | `styles.dropIndicator` / `classNames.dropIndicator` | +| `RootSplitterHoverElStyles` / `…Class` | `styles.rootDropZone` / `classNames.rootDropZone` | +| `disableResizeTimeout` + `windowResizeTimeout` | `resizeThrottleMs` (`0` = once per frame) | +| `disableSliderTimeout` + `sliderUpdateTimeout` | removed: splitters always update once per frame | +| `rootId` | `id` (optional) | +| `ref` → root `
      ` | `ref` → `DynamixLayoutHandle` (`handle.element` is the `
      `) | + +### Toolbar props (`TabsetToolbar`) + +| v1 | v2 | +|---|---| +| `maximized`, `folded` | `tabset.isMaximized`, `tabset.isFolded` | +| `rotated` | `isRotated` | +| `rowIsHorizontal` | `tabset.parentDirection === 'horizontal'` | +| `showMaximize`, `showFold` | `canMaximize`, `canFold` | +| `onToggleMaximize`, `onToggleFold` | unchanged | + +### React exports + +| v1 | v2 | +|---|---| +| `useDynamixLayout` (31 values: refs, DnD handlers, setters) | a new headless [`useDynamixLayout`](./api/use-dynamix-layout.md), plus `useTabset`, `useTab`, `useSplitter`, `useLayoutState`, `useLayoutActions`, `useDragState` | +| `getTabOutput`, `TabInput`, `TabOutput`, `TabEntry`, `DivFC`, `useDynamixLayoutOptions` | removed | +| `DefaultWrapTabLabel`, `DefaultWrapTabHead`, … | `Tab`, `TabBar`, `TabContent`, `Panel`, `Splitter`, `DropIndicator`, `RootDropZone`, `TabsetToolbar` | +| `LayoutProps` | `DynamixLayoutProps` | +| `.DefaultWrapTabLabel` etc. CSS classes | `.dx-tab`, `.dx-tab-bar`, `.dx-tab-content`, `.dx-panel`, `.dx-splitter`, `.dx-drop-indicator`, `.dx-root-drop-zone` | +| `data-uid`, `data-type` | `data-dx-id`, `data-dx-slot` | +| `@dynamix-layout/react/dist/layout.css` (never existed) / `style.css` | `@dynamix-layout/react/styles.css` (`style.css` still works) | + +### Core + +| v1 | v2 | +|---|---| +| `new DynamixLayoutCore(options)` + statics `_root`, `_tree`, `_minW`, `_minH`, `_bond`, `_inst` | `createLayout(options)`, one independent instance each | +| `Node`, `Bond`, `Node.cache` | `layout.getSnapshot()` / `subscribe()` | +| `LayoutTree` | `LayoutJSON` (`LayoutTreeV1` describes the old shape) | +| `NodeOptions` | `TabsetState`, `SplitterState`, `TabState`, plus `snapshot.rects` | +| `Dimension { w, h, x, y }` | `Rect { x, y, width, height }` | +| `typNode`, `uidNode`, `nodName` | `type`, `id` (tabs: the tab's id, which was `nodName`) | +| `nodPart` | `weight` | +| `nodeDir` (boolean, inverted for tabsets) | `direction` on rows, `parentDirection` on tabsets | +| `nodDims` | `snapshot.rects.tabsets.get(id)` | +| `nodOpen` | `activeTabId` / `isActive` | +| `nodKids` | `children` / `tabIds` | +| `nodFold`, `nodMaxd` | `isFolded`, `maximizedTabsetId` / `isMaximized` | +| `nodHidden`, `nodLocked`, `nodFoldable`, `nodMaximizable` | `isHidden`, `isLocked`, `canFold`, `canMaximize` | +| `minW`, `minH` | `minPanelSize` | +| `bond` | `splitterSize` | +| `collapsedSize` | `foldedSize` | +| `tabs: string[]` | `tabs: { id, target? }[]` | +| `tree` | `initialLayout` | +| `updateDimension(dim, disableTimeout?, timeout?)` | `setContainerRect(rect)` | +| `updateSlider` / `updateSliderDimension` | `resizeSplitter(id, point)`, `moveSplitterBy(id, delta)`, or `startDrag` / `updateDrag` / `endDrag` | +| `updateTree(src, des, area)` | `moveTab(tabId, target)` / `moveTabset(tabsetId, target)`; `'contain'` is now `'center'` | +| `collapse` / `expand` / `toggleCollapse` | `fold` / `unfold` / `toggleFold` | +| `maximizedId` | `snapshot.maximizedTabsetId` | +| `DynamixLayoutCore._root.toJSON()` | `layout.toJSON()` | +| `clearAllCache()` | `destroy()` | +| `Queue`, `createReactiveState`, `areNodeOptions*Equal`, iterators, `insertNode*`, `removeKid`, … | removed (internal) | +| `getTabsetDropPreview`, `getNavbarDropPreview`, `getRootSplitPreview` | `layout.getDropTarget` + `layout.getDropIndicatorRect` | +| `setElementRect`, `placeTabbar`, `getTabbarPlacement`, `getTabBodyRect` | `applyRect`, `getTabBarPlacement`, `getTabContentRect` | +| console banner, `window.__DYNAMIX_LAYOUT__` | removed; `version` export | + +## Behaviour changes + +| Change | Why | +|---|---| +| `onLayoutChange` isn't called on mount, and receives `LayoutJSON` (`version: 2`). | Mount isn't a change; the new format has readable names and stable tab ids. | +| A tab in `tabs` that's missing from the saved layout is **added** (v1 silently left it out). A saved tab missing from `tabs` is **removed** (v1 showed a label with no content). | `tabs` is the single source of truth for which tabs exist. | +| Duplicate tab ids throw in development. | v1 merged their contents silently. | +| Dropping a tab in the middle of a panel **appends** it. | v1 inserted it before the last tab. | +| Splitting beside a folded tabset unfolds it. | v1 left the new row stuck at its minimum size. | +| Tab dragging uses pointer events (touch long-press, Escape cancels). HTML5 drag events on custom wrappers no longer fire; use `useDragState`. | Touch support, one drag model, no fake drag image. | +| Keyboard shortcuts act only when focus is inside the layout. | Several layouts on a page no longer all react. | +| The layout can sit anywhere on the page. | v1 placed content wrongly unless the layout was at the viewport's top-left. | +| Changing `tabs` updates the layout instead of rebuilding it. | v1 reset the layout to its initial state whenever `tabs` changed identity. | +| React 18 works; React 17 isn't supported. | The v1 bundle inlined React 19's JSX runtime. | + +## `ref` and actions + +```tsx +// v1: ref was the root div; there was no API to move or select tabs. +// v2: +const layout = useRef(null) + + +layout.current?.element // the root div +layout.current?.moveTab('terminal', { type: 'root', position: 'bottom' }) +layout.current?.selectTab('editor') +layout.current?.toggleMaximize('ts-editor') +``` + +Inside the layout, [`useLayoutActions()`](./api/use-layout-actions.md) gives the same actions without a ref. + +## Core: before and after + +```ts +// v1 +import { DynamixLayoutCore, Node } from '@dynamix-layout/core' +const engine = new DynamixLayoutCore({ tabs: ['a', 'b'], tree: saved, minW: 40, minH: 40, bond: 10 }) +engine.updateDimension({ x: 0, y: 0, w: 800, h: 600 }, true) +Node.cache.nodOpts.onChange((tabsets) => tabsets.forEach((t) => draw(t.uidNode, t.nodDims))) +engine.updateTree(tabUid, tabsetUid, 'contain') +const json = DynamixLayoutCore._root.toJSON() + +// v2 +import { createLayout } from '@dynamix-layout/core' +const layout = createLayout({ tabs: [{ id: 'a' }, { id: 'b' }], initialLayout: saved, minPanelSize: { width: 40, height: 40 }, splitterSize: 10 }) +layout.setContainerRect({ x: 0, y: 0, width: 800, height: 600 }) +layout.subscribe((s) => s.tabsets.forEach((t) => draw(t.id, s.rects.tabsets.get(t.id)!))) +layout.moveTab('a', { type: 'tabset', tabsetId: 'ts-b', position: 'center' }) +const json = layout.toJSON() +``` + +## Solid + +Solid keeps its v1 props and default components; it runs on the v2 core underneath. Changes: + +- `updateJSON` receives `LayoutJSON`, and `layoutTree` accepts it (v1 trees still load). +- `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed. +- `solid-js` is a peer dependency. +- The slider timeout options no longer have an effect. + +## Saved layouts + +You don't need to do anything: pass a v1 layout wherever a layout is accepted and it's migrated on load. To convert stored data once, for example so a backend can read it: + +```ts +import { isLayoutV1, migrateLayoutFromV1 } from '@dynamix-layout/core' + +for (const row of await db.layouts.all()) { + if (isLayoutV1(row.data)) await db.layouts.update(row.id, { data: migrateLayoutFromV1(row.data) }) +} +``` + +Migration keeps: +- tabset and row ids; +- weights, so proportions are identical; +- the active tab; +- folded and maximized state. + +Tab ids become the v1 tab labels. Damaged layouts are repaired, and each repair is reported. See [`migrateLayoutFromV1`](./api/migrate-layout-from-v1.md#edge-cases) for every case. From 274b3b3e7650cf81b2a7c4a0d841ecef777a2568 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:01:23 +0530 Subject: [PATCH 26/41] fix(react): keep getRootProps stable across the first measurement getRootProps listed the measured state as a dependency, so its identity changed once after mount, contrary to the documented guarantee. The flag now lives on the controller, which getters read at call time, and a test checks that every function from useDynamixLayout and useTab keeps its identity across renders. --- .../react/src/hooks/use-dynamix-layout.ts | 6 ++-- packages/react/src/internal/controller.ts | 3 ++ packages/react/src/internal/use-controller.ts | 6 ++-- packages/react/src/test/rerender.test.tsx | 34 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/react/src/hooks/use-dynamix-layout.ts b/packages/react/src/hooks/use-dynamix-layout.ts index 371435e..1ab2843 100644 --- a/packages/react/src/hooks/use-dynamix-layout.ts +++ b/packages/react/src/hooks/use-dynamix-layout.ts @@ -62,7 +62,7 @@ export function useDynamixLayout( options: UseDynamixLayoutOptions ): UseDynamixLayoutResult { const reactId = useId() - const { core, contextValue, rootRef, isMeasured, tabIds } = useController( + const { core, contextValue, rootRef, tabIds } = useController( options, options.id ?? reactId ) @@ -81,7 +81,7 @@ export function useDynamixLayout( className: 'dx-root', // Positioned children need a containing block, with or without the stylesheet. style: { position: 'relative' as const }, - 'data-dx-measuring': isMeasured ? undefined : '', + 'data-dx-measuring': core.isMeasured ? undefined : '', 'data-dx-dragging': core.engine.getSnapshot().drag ? '' : undefined, @@ -92,7 +92,7 @@ export function useDynamixLayout( onFocusCapture: (event: FocusEvent) => trackFocusedTabset(core, event.target), }), - [core, rootRef, isMeasured] + [core, rootRef] ) as PropGetter const indicatorRef = useCallback( diff --git a/packages/react/src/internal/controller.ts b/packages/react/src/internal/controller.ts index 898dc9f..7fc2d3b 100644 --- a/packages/react/src/internal/controller.ts +++ b/packages/react/src/internal/controller.ts @@ -46,6 +46,8 @@ export interface LayoutController { options: ControllerOptions /** The root element, once mounted. */ root: HTMLElement | null + /** The container has been measured (the first render can't know its size). */ + isMeasured: boolean /** Tabset the user last pointed at or focused (target of keyboard shortcuts). */ focusedTabsetId: string | null /** Plain-text tab titles for announcements and ARIA labels (ids when titles aren't text). */ @@ -132,6 +134,7 @@ export function createController( ids: createDomIds(init.idBase), options: init.options, root: null, + isMeasured: false, focusedTabsetId: null, labels: new Map(), register(slot, id, element) { diff --git a/packages/react/src/internal/use-controller.ts b/packages/react/src/internal/use-controller.ts index ab07047..45a40e5 100644 --- a/packages/react/src/internal/use-controller.ts +++ b/packages/react/src/internal/use-controller.ts @@ -142,7 +142,8 @@ export function useController( }) const [root, setRoot] = useState(null) - const [isMeasured, setMeasured] = useState(false) + // State only to re-render once measured; getters read `core.isMeasured`. + const [, setMeasured] = useState(false) const rootRef = useCallback( (element: HTMLElement | null) => { core.root = element @@ -154,6 +155,7 @@ export function useController( if (!root) return const stopObserving = observeContainer(core, root) const stopWriting = startRectWriter(core) + core.isMeasured = true setMeasured(true) return () => { stopObserving() @@ -175,5 +177,5 @@ export function useController( ) const tabIds = useMemo(() => (key === '' ? [] : key.split('\u0000')), [key]) - return { core, contextValue, rootRef, isMeasured, tabIds } + return { core, contextValue, rootRef, tabIds } } diff --git a/packages/react/src/test/rerender.test.tsx b/packages/react/src/test/rerender.test.tsx index 6a10df9..100053d 100644 --- a/packages/react/src/test/rerender.test.tsx +++ b/packages/react/src/test/rerender.test.tsx @@ -278,3 +278,37 @@ describe('headless hooks', () => { ) }) }) + +describe('stability', () => { + it('keeps every function from useDynamixLayout and useTab stable across renders', () => { + const seen: unknown[][] = [] + function Probe() { + const { getTabProps, getTabContentProps, select, close } = + useTab('a') + seen.push([getTabProps, getTabContentProps, select, close]) + return null + } + function App({ tick }: { tick: number }) { + const layout = useDynamixLayout({ + tabs: [{ id: 'a', title: `A${tick}` }, { id: 'b' }], + }) + seen.push([layout.getRootProps, layout.controller, layout.actions]) + return ( + +
      + +
      +
      + ) + } + const { rerender } = render() + rerender() + const layoutRenders = seen.filter((entry) => entry.length === 3) + const tabRenders = seen.filter((entry) => entry.length === 4) + expect(layoutRenders.length).toBeGreaterThan(1) + for (const entry of [...layoutRenders.slice(1)]) + entry.forEach((fn, i) => expect(fn).toBe(layoutRenders[0][i])) + for (const entry of [...tabRenders.slice(1)]) + entry.forEach((fn, i) => expect(fn).toBe(tabRenders[0][i])) + }) +}) From 3f6a93cb168efc0848eae67874377d362a6259e2 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:02:37 +0530 Subject: [PATCH 27/41] docs(core, react): examples and tags in the TSDoc of every public export Adds @example to the slot components, useTabset, useTab, useSplitter, useDragState and the core helpers (isLayoutV1, getRootDropZoneRect, getTabBarPlacement, getTabContentRect, applyRect, createFrameScheduler, DynamixLayoutError, version), with @param and @returns where they were missing. Slot examples forward refs, as React 18 requires. --- packages/core/src/dom/apply-rect.ts | 4 ++++ packages/core/src/dom/frame-scheduler.ts | 6 ++++++ packages/core/src/drop/drop-target.ts | 10 +++++++++- packages/core/src/errors.ts | 7 +++++++ packages/core/src/geometry/tab-bar.ts | 14 +++++++++++++- packages/core/src/index.ts | 6 +++++- packages/core/src/serialize/migrate-v1.ts | 8 +++++++- packages/react/src/components/DropIndicator.tsx | 8 +++++++- packages/react/src/components/Panel.tsx | 10 +++++++++- packages/react/src/components/RootDropZone.tsx | 8 +++++++- packages/react/src/components/Splitter.tsx | 9 ++++++++- packages/react/src/components/Tab.tsx | 9 ++++++++- packages/react/src/components/TabBar.tsx | 9 ++++++++- packages/react/src/components/TabContent.tsx | 9 ++++++++- packages/react/src/components/TabsetToolbar.tsx | 6 ++++++ packages/react/src/hooks/use-drag-state.ts | 4 ++++ packages/react/src/hooks/use-splitter.ts | 6 ++++++ packages/react/src/hooks/use-tab.ts | 6 ++++++ packages/react/src/hooks/use-tabset.ts | 12 ++++++++++++ 19 files changed, 140 insertions(+), 11 deletions(-) diff --git a/packages/core/src/dom/apply-rect.ts b/packages/core/src/dom/apply-rect.ts index 7e24e53..8213b8c 100644 --- a/packages/core/src/dom/apply-rect.ts +++ b/packages/core/src/dom/apply-rect.ts @@ -3,6 +3,10 @@ import type { Rect } from '../model/types' /** * Positions an absolutely positioned element. Adapters use this on their fast * path (drags), so pointer moves never need a framework re-render. + * @param element - An absolutely positioned element inside the layout root. + * @param rect - Where to put it, in layout-root coordinates. + * @example + * layout.subscribe((s) => s.rects.tabsets.forEach((rect, id) => applyRect(panels.get(id)!, rect))) */ export function applyRect(element: HTMLElement, rect: Rect): void { const { style } = element diff --git a/packages/core/src/dom/frame-scheduler.ts b/packages/core/src/dom/frame-scheduler.ts index d805fb1..ac76b68 100644 --- a/packages/core/src/dom/frame-scheduler.ts +++ b/packages/core/src/dom/frame-scheduler.ts @@ -10,6 +10,12 @@ export interface FrameScheduler { /** * Coalesces bursts of updates (pointer events can fire several times per * frame) into at most one `onFlush` call per animation frame. + * @param onFlush - Called with the latest scheduled value, at most once per frame. + * @returns `schedule`, `flush` and `cancel`. + * @example + * const frame = createFrameScheduler((point: Point) => layout.updateDrag(point)) + * element.addEventListener('pointermove', (e) => frame.schedule(toRootPoint(e))) + * element.addEventListener('pointerup', () => { frame.flush(); layout.endDrag() }) */ export function createFrameScheduler( onFlush: (value: T) => void diff --git a/packages/core/src/drop/drop-target.ts b/packages/core/src/drop/drop-target.ts index 9e5670b..6a8dca9 100644 --- a/packages/core/src/drop/drop-target.ts +++ b/packages/core/src/drop/drop-target.ts @@ -32,7 +32,15 @@ const contains = (rect: Rect, point: Point) => point.y >= rect.y && point.y <= rect.y + rect.height -/** Rect of the drop zone on one edge of the layout, centred on that edge. */ +/** + * Rect of the drop zone on one edge of the layout, centred on that edge. + * @param container - The layout's container rect (`snapshot.rects.container`). + * @param side - The edge. + * @param options - Zone length (share of the edge) and thickness. + * @returns The zone rect, in layout-root coordinates. + * @example + * const zone = getRootDropZoneRect(snapshot.rects.container, 'left') + */ export function getRootDropZoneRect( container: Rect, side: Side, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 407b88c..3273e36 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -5,6 +5,13 @@ export type DynamixLayoutErrorCode = /** * Thrown for invalid input: malformed saved layouts, unsupported versions, * and duplicate tab ids in development. Actions never throw. + * @example + * try { + * layout.load(JSON.parse(stored)) + * } catch (error) { + * if (error instanceof DynamixLayoutError) console.warn(error.code, error.path) + * else throw error + * } */ export class DynamixLayoutError extends Error { readonly code: DynamixLayoutErrorCode diff --git a/packages/core/src/geometry/tab-bar.ts b/packages/core/src/geometry/tab-bar.ts index cc6d9ae..525f757 100644 --- a/packages/core/src/geometry/tab-bar.ts +++ b/packages/core/src/geometry/tab-bar.ts @@ -15,6 +15,11 @@ export interface TabBarPlacement { /** * @param tabset - The tabset's rect and state. * @param tabBarHeight - Height of a horizontal tab bar. + * @returns The rect to give the tab bar element, and whether to rotate it. + * @example + * const { rect, isRotated } = getTabBarPlacement(snapshot.rects.tabsets.get(id)!, snapshot.tabsets.get(id)!, 40) + * applyRect(tabBar, rect) + * tabBar.style.transform = isRotated ? 'rotate(90deg)' : '' */ export function getTabBarPlacement( tabsetRect: Rect, @@ -31,7 +36,14 @@ export function getTabBarPlacement( return { rect: { x, y, width, height: tabBarHeight }, isRotated: false } } -/** Area of a tabset below its tab bar; never negative when squeezed. */ +/** + * Area of a tabset below its tab bar; never negative when squeezed. + * @param tabsetRect - The tabset's rect. + * @param tabBarHeight - Height of its tab bar (0 without one). + * @returns The content area. + * @example + * applyRect(content, getTabContentRect(snapshot.rects.tabsets.get(tab.tabsetId)!, 40)) + */ export function getTabContentRect( tabsetRect: Rect, tabBarHeight: number diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fd272e..d2333d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,10 @@ export { declare const __VERSION__: string -/** The version of this package, for debugging (replaces v1's console banner). */ +/** + * The version of this package, for debugging (replaces v1's console banner). + * @example + * console.info('dynamix-layout', version) + */ export const version: string = typeof __VERSION__ === 'undefined' ? 'dev' : __VERSION__ diff --git a/packages/core/src/serialize/migrate-v1.ts b/packages/core/src/serialize/migrate-v1.ts index 1affdfe..266fada 100644 --- a/packages/core/src/serialize/migrate-v1.ts +++ b/packages/core/src/serialize/migrate-v1.ts @@ -4,7 +4,13 @@ import { layoutFromJSON } from './from-json' import type { LayoutJSON, LayoutTreeV1, RowJSON, TabsetJSON } from './schema' import { toLayoutJSON } from './to-json' -/** @returns `true` when `input` looks like a layout saved by v1. */ +/** + * @returns `true` when `input` looks like a layout saved by v1. + * @param input - Anything, e.g. parsed JSON from storage. + * @example + * const saved = JSON.parse(localStorage.getItem('layout') ?? 'null') + * const layout = isLayoutV1(saved) ? migrateLayoutFromV1(saved) : saved + */ export function isLayoutV1(input: unknown): input is LayoutTreeV1 { return ( typeof input === 'object' && diff --git a/packages/react/src/components/DropIndicator.tsx b/packages/react/src/components/DropIndicator.tsx index dbc611c..11ea191 100644 --- a/packages/react/src/components/DropIndicator.tsx +++ b/packages/react/src/components/DropIndicator.tsx @@ -1,7 +1,13 @@ import { forwardRef } from 'react' import type { DropIndicatorProps } from '../types' -/** Default `DropIndicator`: highlights where a dragged tab would land. */ +/** + * Default `DropIndicator`: highlights where a dragged tab would land. + * @example + * const MyIndicator = forwardRef(function MyIndicator({ target, ...props }, ref) { + * return
      {target.type === 'root' ? 'Dock here' : null}
      + * }) + */ export const DropIndicator = forwardRef( function DropIndicator({ target, ...props }, ref) { return
      diff --git a/packages/react/src/components/Panel.tsx b/packages/react/src/components/Panel.tsx index 5c0098d..a36cce2 100644 --- a/packages/react/src/components/Panel.tsx +++ b/packages/react/src/components/Panel.tsx @@ -1,7 +1,15 @@ import { forwardRef } from 'react' import type { PanelProps } from '../types' -/** Default `Panel`: the box of a tabset, behind its tab bar and content. */ +/** + * Default `Panel`: the box of a tabset, behind its tab bar and content. + * @example + * // Wrap the default (slot components must forward `ref`): + * const MyPanel = forwardRef(function MyPanel(props, ref) { + * return + * }) + * + */ export const Panel = forwardRef(function Panel( { tabset, ...props }, ref diff --git a/packages/react/src/components/RootDropZone.tsx b/packages/react/src/components/RootDropZone.tsx index 40ac0df..e5a46aa 100644 --- a/packages/react/src/components/RootDropZone.tsx +++ b/packages/react/src/components/RootDropZone.tsx @@ -1,7 +1,13 @@ import { forwardRef } from 'react' import type { RootDropZoneProps } from '../types' -/** Default `RootDropZone`: a handle at one edge of the layout, shown while dragging a tab. */ +/** + * Default `RootDropZone`: a handle at one edge of the layout, shown while dragging a tab. + * @example + * const MyZone = forwardRef(function MyZone({ side, isActive, ...props }, ref) { + * return
      + * }) + */ export const RootDropZone = forwardRef( function RootDropZone({ side, isActive, ...props }, ref) { return ( diff --git a/packages/react/src/components/Splitter.tsx b/packages/react/src/components/Splitter.tsx index 12b7541..4c7d34a 100644 --- a/packages/react/src/components/Splitter.tsx +++ b/packages/react/src/components/Splitter.tsx @@ -1,7 +1,14 @@ import { forwardRef } from 'react' import type { SplitterProps } from '../types' -/** Default `Splitter`: a bar with a grip (drawn in CSS). */ +/** + * Default `Splitter`: a bar with a grip (drawn in CSS). + * @example + * const MySplitter = forwardRef(function MySplitter({ splitter, isDragging, ...props }, ref) { + * return
      + * }) + * + */ export const Splitter = forwardRef( function Splitter({ splitter, isDragging, ...props }, ref) { return
      diff --git a/packages/react/src/components/Tab.tsx b/packages/react/src/components/Tab.tsx index 9d3a632..17859a4 100644 --- a/packages/react/src/components/Tab.tsx +++ b/packages/react/src/components/Tab.tsx @@ -1,7 +1,14 @@ import { forwardRef, type HTMLAttributes, type Ref } from 'react' import type { TabProps } from '../types' -/** Default `Tab`: the title, plus a close button when the tab is closable. */ +/** + * Default `Tab`: the title, plus a close button when the tab is closable. + * @example + * // Wrap the default tab, e.g. to add a tooltip: + * const MyTab = forwardRef(function MyTab(props, ref) { + * return + * }) + */ export const Tab = forwardRef(function Tab( { tab, isActive, isDragging, onClose, closeButtonProps, ...props }, ref diff --git a/packages/react/src/components/TabBar.tsx b/packages/react/src/components/TabBar.tsx index 6b326b7..109c23d 100644 --- a/packages/react/src/components/TabBar.tsx +++ b/packages/react/src/components/TabBar.tsx @@ -1,7 +1,14 @@ import { forwardRef } from 'react' import type { TabBarProps } from '../types' -/** Default `TabBar`: a scrollable row of tabs with the toolbar at its end. */ +/** + * Default `TabBar`: a scrollable row of tabs with the toolbar at its end. + * @example + * const MyTabBar = forwardRef(function MyTabBar({ tabset, isRotated, children, ...props }, ref) { + * return
      {children}
      + * }) + * + */ export const TabBar = forwardRef(function TabBar( { tabset, isRotated, children, ...props }, ref diff --git a/packages/react/src/components/TabContent.tsx b/packages/react/src/components/TabContent.tsx index 051079c..de0ba16 100644 --- a/packages/react/src/components/TabContent.tsx +++ b/packages/react/src/components/TabContent.tsx @@ -1,7 +1,14 @@ import { forwardRef } from 'react' import type { TabContentProps } from '../types' -/** Default `TabContent`: holds a tab's content; positioned by the library. */ +/** + * Default `TabContent`: holds a tab's content; positioned by the library. + * @example + * const MyContent = forwardRef(function MyContent({ tab, isActive, children, ...props }, ref) { + * return
      {children}
      + * }) + * + */ export const TabContent = forwardRef( function TabContent({ tab, isActive, children, ...props }, ref) { return ( diff --git a/packages/react/src/components/TabsetToolbar.tsx b/packages/react/src/components/TabsetToolbar.tsx index 3284345..4de641f 100644 --- a/packages/react/src/components/TabsetToolbar.tsx +++ b/packages/react/src/components/TabsetToolbar.tsx @@ -29,6 +29,12 @@ function Icon({ path, rotate }: { path: string; rotate: number }) { /** * Default `TabsetToolbar`: maximize/restore and fold/unfold buttons at the end * of a tab bar, shown on hover and always on folded strips. + * @param props - The tabset, what it can do, and the toggle handlers. + * @example + * function MyToolbar({ tabset, canMaximize, onToggleMaximize }: TabsetToolbarProps) { + * return canMaximize ? : null + * } + * */ export function TabsetToolbar({ tabset, diff --git a/packages/react/src/hooks/use-drag-state.ts b/packages/react/src/hooks/use-drag-state.ts index b238777..8185b06 100644 --- a/packages/react/src/hooks/use-drag-state.ts +++ b/packages/react/src/hooks/use-drag-state.ts @@ -7,6 +7,10 @@ import { useLayoutState } from './use-layout-state' * pointer move. * * @throws When used outside a layout. + * @returns The drag source, target and indicator rect, or `null`. + * @example + * const drag = useDragState() + * const isDraggingTab = drag?.source.type === 'tab' */ export function useDragState(): DragState | null { return useLayoutState((snapshot) => snapshot.drag) diff --git a/packages/react/src/hooks/use-splitter.ts b/packages/react/src/hooks/use-splitter.ts index a253f38..7fa937b 100644 --- a/packages/react/src/hooks/use-splitter.ts +++ b/packages/react/src/hooks/use-splitter.ts @@ -34,6 +34,12 @@ export interface UseSplitterResult { * * @param splitterId - The splitter's id (from `useDynamixLayout().splitters`). * @throws When used outside a layout. + * @returns State and the prop getter. + * @example + * function MySplitter({ splitterId }: { splitterId: string }) { + * const { getSplitterProps } = useSplitter(splitterId) + * return
      + * } */ export function useSplitter(splitterId: string): UseSplitterResult { const { core, tabs } = useLayoutContext('useSplitter') diff --git a/packages/react/src/hooks/use-tab.ts b/packages/react/src/hooks/use-tab.ts index 3843b36..c15a1a6 100644 --- a/packages/react/src/hooks/use-tab.ts +++ b/packages/react/src/hooks/use-tab.ts @@ -31,6 +31,12 @@ export interface UseTabResult { * * @param tabId - The tab's id. * @throws When used outside a layout. + * @returns State, stable actions and prop getters. + * @example + * function MyTab({ tabId }: { tabId: string }) { + * const { item, getTabProps } = useTab(tabId) + * return + * } */ export function useTab(tabId: string): UseTabResult { const { core, tabs } = useLayoutContext('useTab') diff --git a/packages/react/src/hooks/use-tabset.ts b/packages/react/src/hooks/use-tabset.ts index 2d0822d..008b3d8 100644 --- a/packages/react/src/hooks/use-tabset.ts +++ b/packages/react/src/hooks/use-tabset.ts @@ -30,6 +30,18 @@ const isInteractive = (target: EventTarget) => * * @param tabsetId - The tabset's id. * @throws When used outside a layout. + * @returns State, stable actions and prop getters. + * @example + * function MyTabset({ tabsetId }: { tabsetId: string }) { + * const { tabset, getPanelProps, getTabBarProps } = useTabset(tabsetId) + * if (!tabset) return null + * return ( + * <> + *
      + *
      {tabset.tabIds.map((id) => )}
      + * + * ) + * } */ export function useTabset(tabsetId: string): UseTabsetResult { const { core } = useLayoutContext('useTabset') From 177ec54c3d0d7a9cc298258025baa34d55b35854 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:03:35 +0530 Subject: [PATCH 28/41] build: let consumers on Node 18+ install the packages; drop a dead turbo env var The published packages required node >=22.16.0 and pnpm >=10.13.1, which made installs fail (or warn) for apps on Node 18 or 20 with strict engine checks, although the ES2020 builds run there. They now declare node >=18; the repo's own Node and pnpm versions stay in the root package.json. Also removes UPDATE_V1_FIXTURES from turbo.json, left over from the v1 fixture recorder. --- packages/core/package.json | 3 +-- packages/react/package.json | 3 +-- packages/solid/package.json | 3 +-- turbo.json | 1 - 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 80c9496..a87c615 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,8 +58,7 @@ "access": "public" }, "engines": { - "node": ">=22.16.0", - "pnpm": ">=10.13.1" + "node": ">=18" }, "scripts": { "dev": "vite build --watch", diff --git a/packages/react/package.json b/packages/react/package.json index 3449ac6..96acb31 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -68,8 +68,7 @@ "access": "public" }, "engines": { - "node": ">=22.16.0", - "pnpm": ">=10.13.1" + "node": ">=18" }, "scripts": { "dev": "vite build --watch", diff --git a/packages/solid/package.json b/packages/solid/package.json index 13feb12..f6d2fc9 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -67,8 +67,7 @@ "access": "public" }, "engines": { - "node": ">=22.16.0", - "pnpm": ">=10.13.1" + "node": ">=18" }, "scripts": { "dev": "vite build --watch", diff --git a/turbo.json b/turbo.json index 960c2af..eead293 100644 --- a/turbo.json +++ b/turbo.json @@ -16,7 +16,6 @@ }, "test": { "dependsOn": ["^build"], - "env": ["UPDATE_V1_FIXTURES"], "outputs": [] }, "dev": { From 7c0da84c4092c61a363b10f84dc59d282c352831 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:03:50 +0530 Subject: [PATCH 29/41] docs(v2): mark the design as implemented; note Node 18 support in the changesets --- .changeset/v2-core.md | 2 +- .changeset/v2-react.md | 2 ++ .changeset/v2-solid.md | 2 ++ docs/v2/02-design.md | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/v2-core.md b/.changeset/v2-core.md index c802b4b..16be852 100644 --- a/.changeset/v2-core.md +++ b/.changeset/v2-core.md @@ -19,7 +19,7 @@ - a row stuck at its minimum next to a folded tabset; - the root staying oversized after the container shrank; - center drops inserting before the last tab (they now append). -- **Packaging:** the CommonJS build loads with `require()` (it was ESM under a `.cjs.js` name), and exports maps give each condition its own types. +- **Packaging:** the CommonJS build loads with `require()` (it was ESM under a `.cjs.js` name), exports maps give each condition its own types, and the package installs on Node 18 and later (it required Node 22). **Breaking:** - `DynamixLayoutCore`, `Node`, `Bond`, `Node.cache`, `Queue`, `createReactiveState`, the comparators, the drop-preview and DOM helpers, and the `LayoutTree`/`NodeOptions` types are removed. diff --git a/.changeset/v2-react.md b/.changeset/v2-react.md index 26022b2..2aafe28 100644 --- a/.changeset/v2-react.md +++ b/.changeset/v2-react.md @@ -17,6 +17,8 @@ - **SSR-safe and deterministic**, with a `'use client'` entry for the Next.js App Router. Works with React 18 and 19; the bundle no longer inlines React 19's JSX runtime. - **Fixed:** callback refs are honoured, `layoutTree` changes are no longer ignored, and duplicate DOM ids are gone. +- **Installs on Node 18 and later** (1.x required Node 22). + **Breaking:** - Removed: - the v1 props and wrapper components (`WrapTab*`, `SliderElement`, `HoverElement`, `RootSplitterHoverEl`); diff --git a/.changeset/v2-solid.md b/.changeset/v2-solid.md index 21950fc..e609978 100644 --- a/.changeset/v2-solid.md +++ b/.changeset/v2-solid.md @@ -4,6 +4,8 @@ **Runs on the v2 core.** `` keeps its props, default components and drag-and-drop, but each component now has its own engine instance. Two Solid layouts on a page no longer share state, and a layout away from the viewport's top-left is placed correctly. +- **Installs on Node 18 and later** (1.x required Node 22). + **Breaking:** - `layoutTree` accepts v2 JSON (v1 trees are migrated automatically), and `updateJSON` receives v2 `LayoutJSON`. - `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed, and tab ids are the tab labels. diff --git a/docs/v2/02-design.md b/docs/v2/02-design.md index a85420f..40181d8 100644 --- a/docs/v2/02-design.md +++ b/docs/v2/02-design.md @@ -1,6 +1,6 @@ # dynamix-layout v2 — Phase 2 design -Status: **proposal, waiting for approval**. Builds on [01-audit.md](./01-audit.md); `F*`, `R*`, `P*` and `B*` refer to items there. +Status: **approved and implemented** on the `v2` branch. The final API is documented in [`docs/api`](../api/README.md) and [the migration guide](../migration-v1-to-v2.md); where the implementation differs from this proposal, the docs are authoritative. Differences found during implementation are recorded in §0 and §2.2. Builds on [01-audit.md](./01-audit.md); `F*`, `R*`, `P*` and `B*` refer to items there. No source code changes in this phase. --- From 22c7ddd30e7a3cb7b2a09b13cf506edeb1e34505 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:32:52 +0530 Subject: [PATCH 30/41] feat(solid): reactive tabs, closable tabs and onReady `tabs` is now reactive (new or removed entries update the same engine; existing content stays mounted). A third tuple element { title, closable } adds a close button that calls onTabClose, and onReady hands over the engine for actions from code. All additive. --- .changeset/v2-solid.md | 5 ++ packages/solid/src/components/Layout.tsx | 55 +++++++++++++++++-- packages/solid/src/components/layout.css | 16 ++++++ packages/solid/src/test/port.test.tsx | 67 +++++++++++++++++++++++- packages/solid/src/types/index.ts | 31 ++++++++++- 5 files changed, 166 insertions(+), 8 deletions(-) diff --git a/.changeset/v2-solid.md b/.changeset/v2-solid.md index e609978..467e278 100644 --- a/.changeset/v2-solid.md +++ b/.changeset/v2-solid.md @@ -6,6 +6,11 @@ - **Installs on Node 18 and later** (1.x required Node 22). +**New:** +- `tabs` is reactive: add an entry to open a tab, remove it to close one. Existing tabs keep their content mounted. +- Closable tabs: a third tuple element `{ title, closable }` and an `onTabClose` prop. +- `onReady(layout)` gives the engine for actions from code (`maximize`, `fold`, `selectTab`, `moveTab`, `reset`, `toJSON`, …). + **Breaking:** - `layoutTree` accepts v2 JSON (v1 trees are migrated automatically), and `updateJSON` receives v2 `LayoutJSON`. - `getTabOutput` and the `TabEntry`/`TabInput`/`TabOutput` types are removed, and tab ids are the tab labels. diff --git a/packages/solid/src/components/Layout.tsx b/packages/solid/src/components/Layout.tsx index 3e8d3b9..67eb002 100644 --- a/packages/solid/src/components/Layout.tsx +++ b/packages/solid/src/components/Layout.tsx @@ -4,7 +4,16 @@ import { type Rect, type Side, } from '@dynamix-layout/core' -import { createMemo, For, Show, type JSX, type ParentComponent } from 'solid-js' +import { + createEffect, + createMemo, + For, + on, + onMount, + Show, + type JSX, + type ParentComponent, +} from 'solid-js' import { DefaultHoverElement, DefaultSliderElement, @@ -39,7 +48,7 @@ const sameIds = (a: readonly string[], b: readonly string[]) => export const DynamixLayout: ParentComponent = (props) => { let root: HTMLDivElement | undefined /* eslint-disable solid/reactivity -- read once, like the v1 component */ - const tabs = props.tabs + const initialTabs = props.tabs const tabHeadHeight = props.tabHeadHeight ?? DEFAULT_SIZE const enableTabbar = props.enableTabbar ?? true const enableMaximize = props.enableMaximize ?? true @@ -48,7 +57,7 @@ export const DynamixLayout: ParentComponent = (props) => { const rootId = props.rootId ?? 'dynamix-layout-root' const layout = useDynamixLayout({ - tabIds: tabs.map(([id]) => id), + tabIds: initialTabs.map(([id]) => id), layoutTree: props.layoutTree, updateJSON: props.updateJSON, enableTabbar, @@ -88,7 +97,19 @@ export const DynamixLayout: ParentComponent = (props) => { const splitterIds = createMemo(() => [...snapshot().splitters.keys()], [], { equals: sameIds, }) - const title = (tabId: string) => props.tabNames?.get(tabId) ?? tabId + const entry = (tabId: string) => props.tabs.find(([id]) => id === tabId) + const title = (tabId: string) => + entry(tabId)?.[2]?.title ?? props.tabNames?.get(tabId) ?? tabId + + // `tabs` is reactive: push new or removed ids into the same engine. + createEffect( + on( + () => props.tabs.map(([id]) => id).join('\u0000'), + () => layout.engine.setTabs(props.tabs.map(([id]) => ({ id }))), + { defer: true } + ) + ) + onMount(() => props.onReady?.(layout.engine)) return (
      = (props) => { } > {title(tabId)} + + + )} @@ -253,7 +298,7 @@ export const DynamixLayout: ParentComponent = (props) => { }} - + {([tabId, content]) => { const tab = () => snapshot().tabs.get(tabId) const body = () => { diff --git a/packages/solid/src/components/layout.css b/packages/solid/src/components/layout.css index 00c4de8..26d7b99 100644 --- a/packages/solid/src/components/layout.css +++ b/packages/solid/src/components/layout.css @@ -82,3 +82,19 @@ [data-dx-hidden] { display: none !important; } + +/* --- Close button of closable tabs --- */ +.DefaultTabClose { + margin-left: 6px; + padding: 0 4px; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; +} +.DefaultTabClose:hover { + background: rgba(0, 0, 0, 0.1); +} diff --git a/packages/solid/src/test/port.test.tsx b/packages/solid/src/test/port.test.tsx index f581ea9..23a10ee 100644 --- a/packages/solid/src/test/port.test.tsx +++ b/packages/solid/src/test/port.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@solidjs/testing-library' import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LayoutJSON } from '@dynamix-layout/core' +import type { Layout, LayoutJSON } from '@dynamix-layout/core' import type { JSX } from 'solid-js' import { DynamixLayout } from '..' @@ -125,3 +125,68 @@ describe('Solid adapter on the v2 core', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('v1 layout')) }) }) + +describe('Solid additions for the feature showcase', () => { + it('adds and removes tabs reactively without remounting existing content', async () => { + const { createSignal } = await import('solid-js') + let mounts = 0 + const Probe = (p: { id: string }) => { + mounts++ + return

      {`${p.id} body`}

      + } + const [list, setList] = createSignal<[string, JSX.Element][]>([ + ['editor', ], + ['terminal', ], + ]) + render(() => ) + expect(mounts).toBe(2) + setList([...list(), ['notes', ]]) + expect(screen.getByText('notes body')).toBeInTheDocument() + expect( + screen.getByText('notes', { selector: '[data-type="tab"]' }) + ).toBeInTheDocument() + setList(list().filter(([id]) => id !== 'terminal')) + expect(screen.queryByText('terminal body')).toBeNull() + expect(mounts).toBe(3) + }) + + it('closable tabs call onTabClose', () => { + const closed: string[] = [] + render(() => ( + editor body

      ], + [ + 'notes', +

      notes body

      , + { title: 'Notes', closable: true }, + ], + ]} + onTabClose={(id) => closed.push(id)} + /> + )) + expect(screen.getByText('Notes')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Close notes' })) + expect(closed).toEqual(['notes']) + expect( + screen.queryByRole('button', { name: 'Close editor' }) + ).toBeNull() + }) + + it('onReady gives the engine for actions from code', () => { + let engine: Layout | undefined + render(() => ( + (engine = layout)} + /> + )) + expect(engine).toBeDefined() + const [first] = engine!.getSnapshot().tabsets.keys() + engine!.maximize(first) + expect(engine!.getSnapshot().maximizedTabsetId).toBe(first) + expect( + document.querySelectorAll('[data-tabbar][data-dx-hidden]') + ).toHaveLength(1) + }) +}) diff --git a/packages/solid/src/types/index.ts b/packages/solid/src/types/index.ts index df4947f..74fde91 100644 --- a/packages/solid/src/types/index.ts +++ b/packages/solid/src/types/index.ts @@ -1,12 +1,39 @@ -import type { LayoutJSON, LayoutTreeV1, Rect } from '@dynamix-layout/core' +import type { + Layout, + LayoutJSON, + LayoutTreeV1, + Rect, +} from '@dynamix-layout/core' import { JSX } from 'solid-js' export type DivFC = ( props: { children?: JSX.Element } & JSX.HTMLAttributes ) => JSX.Element +/** Per-tab options: the third element of a `tabs` entry. */ +export interface TabOptions { + /** Tab label. @default `tabNames.get(id)`, else the id */ + title?: JSX.Element + /** Show a close button; clicking it calls `onTabClose`. @default false */ + closable?: boolean +} + +/** One tab: `[id, content]` or `[id, content, options]`. */ +export type TabItem = [id: string, content: JSX.Element, options?: TabOptions] + export interface LayoutProps { - tabs: [string, JSX.Element][] + /** + * The open tabs. Reactive: add an entry to open a tab, remove it to close + * one; existing tabs keep their content mounted. + */ + tabs: TabItem[] + /** Called when a closable tab's close button is clicked; remove it from `tabs` to close it. */ + onTabClose?: (tabId: string) => void + /** + * Receives the layout engine once mounted, for actions from code: + * `maximize`, `restore`, `fold`, `unfold`, `selectTab`, `moveTab`, `reset`, `toJSON`, … + */ + onReady?: (layout: Layout) => void enableTabbar?: boolean WrapTabPanel?: DivFC WrapTabLabel?: ( From 7c08008dfac6a80c875bef9907ae482442af73a6 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:33:46 +0530 Subject: [PATCH 31/41] docs(examples): React showcase with every feature on one page Drag, split, resize, close, add (anywhere or at a target), fold, maximize and restore (toolbar, double-click, shortcuts and API buttons), select and move from code, keyboard move mode, localStorage persistence and reset, with a live log of onLayoutChange reasons. It's the default page of the React example. --- examples/react/src/App.tsx | 6 +- .../react/src/examples/ShowcaseExample.tsx | 202 ++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 examples/react/src/examples/ShowcaseExample.tsx diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 8c3f98b..f55b74a 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,6 +1,10 @@ import { lazy, Suspense, useEffect, useState } from 'react' const EXAMPLES = { + showcase: { + label: 'All features', + Component: lazy(() => import('./examples/ShowcaseExample')), + }, custom: { label: 'Custom components', Component: lazy(() => import('./examples/CustomComponentsExample')), @@ -22,7 +26,7 @@ type ExampleKey = keyof typeof EXAMPLES const fromHash = (): ExampleKey => { const key = window.location.hash.slice(1) - return key in EXAMPLES ? (key as ExampleKey) : 'custom' + return key in EXAMPLES ? (key as ExampleKey) : 'showcase' } /** One page per API level, picked by the URL hash (#basic, #headless, …). */ diff --git a/examples/react/src/examples/ShowcaseExample.tsx b/examples/react/src/examples/ShowcaseExample.tsx new file mode 100644 index 0000000..db60195 --- /dev/null +++ b/examples/react/src/examples/ShowcaseExample.tsx @@ -0,0 +1,202 @@ +import { useRef, useState, type ReactNode } from 'react' +import { + DynamixLayout, + type DynamixLayoutHandle, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const STORAGE_KEY = 'dynamix-layout:showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: ReactNode) => ( +
      + {children} +
      +) + +const HELP = ( +
        +
      • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
      • +
      • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
      • +
      • + Fold or maximize with the buttons at the end of a tab + bar (hover it), or double-click a tab bar to maximize. +
      • +
      • + Keyboard: arrows move between tabs, Alt+={' '} + maximizes, Alt+- folds, Ctrl/ + ⌘+Shift+M moves the focused tab. +
      • +
      • + Close tabs with × (or Delete), add them + with the buttons above. The layout is saved to localStorage: reload + the page. +
      • +
      +) + +const initialTabs: TabItem[] = [ + { id: 'guide', title: 'Guide', content: panel('#fffde7', HELP) }, + { + id: 'editor', + title: 'Editor', + content: panel('#e3f2fd', 'Editor: focus or click to select'), + }, + { + id: 'terminal', + title: 'Terminal', + content: panel( + '#212121', + $ _ + ), + }, + { + id: 'preview', + title: 'Preview', + content: panel('#e8f5e9', 'Preview'), + closable: true, + }, + { + id: 'notes', + title: 'Notes', + content: panel('#fce4ec', 'Notes (closable)'), + closable: true, + }, +] + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function ShowcaseExample() { + const layout = useRef(null) + const [saved] = useState(loadSaved) + const [tabs, setTabs] = useState(initialTabs) + const [log, setLog] = useState([]) + const counter = useRef(0) + + const tabsetOf = (tabId: string) => + layout.current?.getSnapshot().tabs.get(tabId)?.tabsetId + const act = (name: string, run: () => boolean | void) => { + const result = run() + if (result === false) + setLog((current) => + [`${name}: not possible right now`, ...current].slice(0, 8) + ) + } + + const addTab = (target?: TabItem['target']) => { + const id = `new-${++counter.current}` + setTabs((current) => [ + ...current, + { + id, + title: `New ${counter.current}`, + content: panel('#ede7f6', `Tab ${id}`), + closable: true, + target, + }, + ]) + } + + const button = (label: string, onClick: () => void) => ( + + ) + + return ( +
      +
      + {button('Add tab', () => addTab())} + {button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) + )} + {button('Maximize / restore Editor', () => + act('Maximize', () => + layout.current?.toggleMaximize(tabsetOf('editor') ?? '') + ) + )} + {button('Fold / unfold Terminal', () => + act('Fold', () => + layout.current?.toggleFold(tabsetOf('terminal') ?? '') + ) + )} + {button('Select Preview', () => + act('Select', () => layout.current?.selectTab('preview')) + )} + {button('Move Notes next to Editor', () => + act('Move', () => + layout.current?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) + )} + {button('Reset layout', () => { + setTabs(initialTabs) + layout.current?.reset() + })} + {button('Forget saved layout', () => + localStorage.removeItem(STORAGE_KEY) + )} +
      +
      + {log.length + ? log.join(' · ') + : 'Changes appear here (onLayoutChange reasons).'} +
      + { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + setLog((current) => [reason, ...current].slice(0, 8)) + }} + onTabClose={(id) => + setTabs((current) => current.filter((tab) => tab.id !== id)) + } + /> +
      + ) +} From da54b6b77c160250e22469fdc4b66c08b76bd59a Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:35:38 +0530 Subject: [PATCH 32/41] docs(examples): Solid showcase with every feature --- examples/solid/src/App.tsx | 192 ++++++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 14 deletions(-) diff --git a/examples/solid/src/App.tsx b/examples/solid/src/App.tsx index 7bbd242..879419e 100644 --- a/examples/solid/src/App.tsx +++ b/examples/solid/src/App.tsx @@ -1,22 +1,186 @@ -import { tabs } from './comp' -import { DynamixLayout } from '@dynamix-layout/solid' +import { createSignal, For, type JSX } from 'solid-js' +import { + DynamixLayout, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/solid' +import type { Layout } from '@dynamix-layout/core' import '@dynamix-layout/solid/style.css' import './App.css' -function App() { +const STORAGE_KEY = 'dynamix-layout:solid-showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: JSX.Element) => ( +
      + {children} +
      +) + +const guide = ( +
        +
      • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
      • +
      • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
      • +
      • + Fold or maximize with the buttons at the end of a tab + bar, or double-click a tab bar. Alt+= maximizes and Alt+- folds the + panel you last clicked. +
      • +
      • + Close tabs with ×, add them with the buttons above. + The layout is saved to localStorage: reload the page. +
      • +
      +) + +const initialTabs = (): TabItem[] => [ + ['guide', panel('#fffde7', guide), { title: 'Guide' }], + ['editor', panel('#e3f2fd', 'Editor'), { title: 'Editor' }], + [ + 'terminal', + panel('#212121', $ _), + { title: 'Terminal' }, + ], + [ + 'preview', + panel('#e8f5e9', 'Preview'), + { title: 'Preview', closable: true }, + ], + [ + 'notes', + panel('#fce4ec', 'Notes (closable)'), + { title: 'Notes', closable: true }, + ], +] + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function App() { + let layout: Layout | undefined + let counter = 0 + const [tabs, setTabs] = createSignal(initialTabs()) + const [log, setLog] = createSignal([]) + const saved = loadSaved() + + const tabsetOf = (tabId: string) => + layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + const note = (entry: string) => + setLog((current) => [entry, ...current].slice(0, 8)) + const act = (name: string, done: boolean | undefined) => { + if (done === false) note(`${name}: not possible right now`) + } + const addTab = () => { + const id = `new-${++counter}` + setTabs([ + ...tabs(), + [ + id, + panel('#ede7f6', `Tab ${id}`), + { title: `New ${counter}`, closable: true }, + ], + ]) + } + + const actions: [string, () => void][] = [ + ['Add tab', addTab], + [ + 'Maximize / restore Editor', + () => act('Maximize', layout?.toggleMaximize(tabsetOf('editor'))), + ], + [ + 'Fold / unfold Terminal', + () => act('Fold', layout?.toggleFold(tabsetOf('terminal'))), + ], + ['Select Preview', () => act('Select', layout?.selectTab('preview'))], + [ + 'Move Notes next to Editor', + () => + act( + 'Move', + layout?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ), + ], + [ + 'Reset layout', + () => { + setTabs(initialTabs()) + layout?.reset() + }, + ], + ['Forget saved layout', () => localStorage.removeItem(STORAGE_KEY)], + ] + return ( - <> +
      +
      + + {([label, run]) => } + +
      +
      + {log().length ? log().join(' · ') : 'Changes appear here.'} +
      console.log(layoutTree)} - tabs={tabs} - pad={{ t: 0, b: 0, l: 0, r: 0 }} - tabHeadHeight={40} - bondWidth={10} - minTabHeight={40} - minTabWidth={100} + tabs={tabs()} + layoutTree={saved} + onReady={(engine) => (layout = engine)} + onTabClose={(id) => + setTabs(tabs().filter(([tabId]) => tabId !== id)) + } + updateJSON={(json) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + note('saved') + }} + tabHeadHeight={36} + bondWidth={6} + minTabWidth={120} + minTabHeight={80} /> - +
      ) } - -export default App From eb055974de745eb1857a7e6338642aa0798c90fa Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:40:38 +0530 Subject: [PATCH 33/41] docs(examples): plain JavaScript showcase on the core A small DOM renderer (examples/vanilla/src/dom-layout.ts) over @dynamix-layout/core with the same feature set as the React and Solid showcases: tab and tab bar drags, splitters, closable tabs, add, fold, maximize/restore, select, move, persistence and reset. --- examples/vanilla/README.md | 18 + examples/vanilla/index.html | 12 + examples/vanilla/package.json | 21 ++ examples/vanilla/src/dom-layout.ts | 506 +++++++++++++++++++++++++++++ examples/vanilla/src/main.ts | 158 +++++++++ examples/vanilla/src/style.css | 283 ++++++++++++++++ examples/vanilla/src/vite-env.d.ts | 1 + examples/vanilla/tsconfig.json | 17 + examples/vanilla/vite.config.ts | 7 + pnpm-lock.yaml | 28 +- 10 files changed, 1043 insertions(+), 8 deletions(-) create mode 100644 examples/vanilla/README.md create mode 100644 examples/vanilla/index.html create mode 100644 examples/vanilla/package.json create mode 100644 examples/vanilla/src/dom-layout.ts create mode 100644 examples/vanilla/src/main.ts create mode 100644 examples/vanilla/src/style.css create mode 100644 examples/vanilla/src/vite-env.d.ts create mode 100644 examples/vanilla/tsconfig.json create mode 100644 examples/vanilla/vite.config.ts diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md new file mode 100644 index 0000000..ab36ce6 --- /dev/null +++ b/examples/vanilla/README.md @@ -0,0 +1,18 @@ +# Plain JavaScript example + +Dynamix Layout with no framework: [`src/dom-layout.ts`](./src/dom-layout.ts) renders `@dynamix-layout/core` snapshots with plain DOM, and [`src/main.ts`](./src/main.ts) is the same all-features showcase as the React and Solid examples. + +```bash +pnpm --filter @repo/vanilla-example dev # http://localhost:3012 +``` + +What it shows: + +- **Drag** a tab onto another tab bar, onto a panel's side to split it, or onto a layout edge; drag a tab bar to move the whole panel. Escape cancels. +- **Resize** with splitters (pointer, or arrow keys when focused). +- **Close** closable tabs with × or Delete; **add** tabs, optionally at a target (`{ type: 'root', position: 'bottom' }`). +- **Fold** (collapse) and **maximize/restore** from the tab bar toolbar, by double-clicking a tab bar, with Alt+- / Alt+=, or from code. +- **Select** and **move** tabs from code, **persist** the layout to localStorage, **reset** it. +- Tab content is created once and never re-parented, so form state (try the Editor's textarea) survives every move. + +`createDomLayout(root, options)` returns `{ engine, setTabs, destroy }`; `engine` is the core [`Layout`](../../docs/api/create-layout.md), so every action is one call. diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html new file mode 100644 index 0000000..deae5fb --- /dev/null +++ b/examples/vanilla/index.html @@ -0,0 +1,12 @@ + + + + + + Dynamix Layout: plain JavaScript + + +
      + + + diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json new file mode 100644 index 0000000..d40f223 --- /dev/null +++ b/examples/vanilla/package.json @@ -0,0 +1,21 @@ +{ + "name": "@repo/vanilla-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@dynamix-layout/core": "workspace:*", + "typescript": "~5.8.3", + "vite": "^7.3.6" + }, + "packageManager": "pnpm@10.13.1", + "engines": { + "node": "^22.0.0", + "pnpm": "^10.0.0" + } +} diff --git a/examples/vanilla/src/dom-layout.ts b/examples/vanilla/src/dom-layout.ts new file mode 100644 index 0000000..2f43662 --- /dev/null +++ b/examples/vanilla/src/dom-layout.ts @@ -0,0 +1,506 @@ +import { + applyRect, + createFrameScheduler, + createLayout, + getTabBarPlacement, + getTabContentRect, + type DragSource, + type DropMeasurements, + type DropTarget, + type Layout, + type LayoutChangeReason, + type LayoutJSON, + type LayoutSnapshot, + type Point, + type Rect, + type TabBarMeasurement, + type TabsetState, +} from '@dynamix-layout/core' + +/** A tab as the app describes it. */ +export interface TabDef { + readonly id: string + readonly title: string + /** Created once and never moved between parents, so its state survives every move. */ + readonly content: HTMLElement + readonly closable?: boolean + /** Where the tab goes the first time it appears. */ + readonly target?: DropTarget +} + +export interface DomLayoutOptions { + readonly tabs: readonly TabDef[] + readonly initialLayout?: LayoutJSON | null + readonly tabBarHeight?: number + readonly splitterSize?: number + readonly padding?: number + readonly minPanelSize?: { width: number; height: number } + readonly onLayoutChange?: ( + layout: LayoutJSON, + reason: LayoutChangeReason + ) => void + /** The × button or Delete key asks to close a closable tab. */ + readonly onTabClose?: (tabId: string) => void +} + +export interface DomLayout { + readonly engine: Layout + /** Replaces the open tabs; content elements of kept tabs stay where they are. */ + setTabs(tabs: readonly TabDef[]): void + destroy(): void +} + +/** Pixels a pointer must travel before a press on a tab becomes a drag. */ +const DRAG_THRESHOLD = 4 + +const el = ( + tag: K, + className: string, + parent?: HTMLElement +) => { + const element = document.createElement(tag) + element.className = className + parent?.append(element) + return element +} + +const relativeRect = (rect: DOMRect, origin: Point): Rect => ({ + x: rect.left - origin.x, + y: rect.top - origin.y, + width: rect.width, + height: rect.height, +}) + +/** + * Renders a layout into `root` with plain DOM: tab bars, close buttons, + * fold/maximize toolbars, splitters and a drop indicator, all driven by the + * engine's snapshots. Every interaction is one engine call. + */ +export function createDomLayout( + root: HTMLElement, + options: DomLayoutOptions +): DomLayout { + const tabBarHeight = options.tabBarHeight ?? 36 + const padding = options.padding ?? 0 + let defs = new Map(options.tabs.map((tab) => [tab.id, tab])) + + const engine = createLayout({ + tabs: options.tabs, + initialLayout: options.initialLayout, + splitterSize: options.splitterSize ?? 6, + minPanelSize: options.minPanelSize ?? { + width: 80, + height: tabBarHeight, + }, + foldedSize: tabBarHeight, + onLayoutChange: options.onLayoutChange, + }) + + root.classList.add('dx-root') + const panels = new Map() + const bars = new Map() + const barStates = new Map() + const contents = new Map() + const splitters = new Map() + const indicator = el('div', 'dx-drop-indicator', root) + indicator.hidden = true + + // --- Coordinates and measurements --- + + const toRootPoint = (event: PointerEvent): Point => { + const box = root.getBoundingClientRect() + return { + x: event.clientX - box.left - root.clientLeft, + y: event.clientY - box.top - root.clientTop, + } + } + + const measure = (): DropMeasurements => { + const box = root.getBoundingClientRect() + const origin = { + x: box.left + root.clientLeft, + y: box.top + root.clientTop, + } + const tabBars = new Map() + bars.forEach((bar, tabsetId) => { + if (bar.hidden) return + tabBars.set(tabsetId, { + rect: relativeRect(bar.getBoundingClientRect(), origin), + isRotated: bar.hasAttribute('data-dx-rotated'), + tabs: Array.from( + bar.querySelectorAll('.dx-tab'), + (tab) => ({ + id: tab.dataset.id!, + rect: relativeRect(tab.getBoundingClientRect(), origin), + }) + ), + }) + }) + return { tabBars } + } + + const resize = () => + engine.setContainerRect({ + x: padding, + y: padding, + width: Math.max(0, root.clientWidth - padding * 2), + height: Math.max(0, root.clientHeight - padding * 2), + }) + const resizeFrame = createFrameScheduler(resize) + const observer = new ResizeObserver(() => resizeFrame.schedule(null)) + observer.observe(root) + + // --- Pointer drags (tabs, tab bars, splitters) --- + + let stopDrag: (() => void) | null = null + + /** + * Follows a press: `onClick` if it never moved past the threshold, + * otherwise a drag of `source` that ends in `endDrag` (or `cancelDrag` on Escape). + */ + const trackPress = ( + event: PointerEvent, + source: DragSource, + onClick?: () => void + ) => { + if (event.button !== 0 || stopDrag) return + const start = { x: event.clientX, y: event.clientY } + const isSplitter = source.type === 'splitter' + let dragging = false + let measurements: DropMeasurements | undefined + const frame = createFrameScheduler((point: Point) => + engine.updateDrag(point, measurements) + ) + + const begin = () => { + if (!isSplitter) measurements = measure() + dragging = engine.startDrag(source) + if (dragging) root.setAttribute('data-dx-dragging', '') + return dragging + } + const move = (e: PointerEvent) => { + if ( + !dragging && + Math.hypot(e.clientX - start.x, e.clientY - start.y) < + DRAG_THRESHOLD + ) + return + if (!dragging && !begin()) return finish() + frame.schedule(toRootPoint(e)) + } + const up = () => { + if (dragging) { + frame.flush() + engine.endDrag() + } else onClick?.() + finish() + } + const key = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + frame.cancel() + if (dragging) engine.cancelDrag() + finish() + } + const finish = () => { + frame.cancel() + root.removeAttribute('data-dx-dragging') + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + window.removeEventListener('keydown', key) + stopDrag = null + } + + stopDrag = () => { + if (dragging) engine.cancelDrag() + finish() + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + window.addEventListener('keydown', key) + // A splitter drags from the first pixel; tabs wait for the threshold. + if (isSplitter && !begin()) finish() + } + + // --- Tab bars --- + + const closeTab = (tabId: string) => { + if (defs.get(tabId)?.closable) options.onTabClose?.(tabId) + } + + const toolbarButton = ( + parent: HTMLElement, + label: string, + text: string, + run: () => void + ) => { + const button = el('button', 'dx-toolbar-button', parent) + button.type = 'button' + button.title = label + button.setAttribute('aria-label', label) + button.textContent = text + button.addEventListener('pointerdown', (e) => e.stopPropagation()) + button.addEventListener('click', run) + } + + const fillTabBar = (bar: HTMLElement, tabset: TabsetState) => { + bar.replaceChildren() + bar.setAttribute('role', 'tablist') + for (const tabId of tabset.tabIds) { + const def = defs.get(tabId) + const isActive = tabId === tabset.activeTabId + const tab = el('div', 'dx-tab', bar) + tab.dataset.id = tabId + tab.dataset.state = isActive ? 'active' : 'inactive' + tab.setAttribute('role', 'tab') + tab.setAttribute('aria-selected', String(isActive)) + tab.tabIndex = isActive ? 0 : -1 + el('span', 'dx-tab-title', tab).textContent = def?.title ?? tabId + if (def?.closable) { + const close = el('button', 'dx-tab-close', tab) + close.type = 'button' + close.textContent = '×' + close.tabIndex = -1 + close.setAttribute('aria-label', `Close ${def.title}`) + close.addEventListener('pointerdown', (e) => + e.stopPropagation() + ) + close.addEventListener('click', () => closeTab(tabId)) + } + tab.addEventListener('pointerdown', (e) => { + e.stopPropagation() + trackPress(e, { type: 'tab', tabId }, () => { + engine.selectTab(tabId) + focusTab(tabId) + }) + }) + tab.addEventListener('keydown', (e) => onTabKey(e, tabset, tabId)) + } + + const toolbar = el('div', 'dx-toolbar', bar) + if (tabset.canFold) + toolbarButton( + toolbar, + tabset.isFolded ? 'Unfold' : 'Fold', + tabset.isFolded ? '▸' : '▾', + () => engine.toggleFold(tabset.id) + ) + if (tabset.canMaximize) + toolbarButton( + toolbar, + tabset.isMaximized ? 'Restore' : 'Maximize', + tabset.isMaximized ? '❐' : '□', + () => engine.toggleMaximize(tabset.id) + ) + bar.toggleAttribute( + 'data-dx-has-toolbar', + toolbar.childElementCount > 0 + ) + } + + const focusTab = (tabId: string) => + root + .querySelector( + `.dx-tab[data-id="${CSS.escape(tabId)}"]` + ) + ?.focus() + + const onTabKey = (e: KeyboardEvent, tabset: TabsetState, tabId: string) => { + const index = tabset.tabIds.indexOf(tabId) + const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0 + if (step) { + const next = + tabset.tabIds[ + (index + step + tabset.tabIds.length) % tabset.tabIds.length + ] + engine.selectTab(next) + focusTab(next) + } else if (e.key === 'Delete') closeTab(tabId) + else if (e.altKey && e.key === '=') engine.toggleMaximize(tabset.id) + else if (e.altKey && e.key === '-') engine.toggleFold(tabset.id) + else return + e.preventDefault() + } + + const createTabBar = (tabsetId: string) => { + const bar = el('div', 'dx-tab-bar', root) + // Pressing the empty part of a tab bar drags the whole tabset. + bar.addEventListener('pointerdown', (e) => + trackPress(e, { type: 'tabset', tabsetId }) + ) + bar.addEventListener('dblclick', (e) => { + if ((e.target as HTMLElement).closest('.dx-toolbar')) return + engine.toggleMaximize(tabsetId) + }) + return bar + } + + // --- Splitters --- + + const createSplitter = (splitterId: string) => { + const splitter = el('div', 'dx-splitter', root) + splitter.tabIndex = 0 + splitter.setAttribute('role', 'separator') + splitter.addEventListener('pointerdown', (e) => + trackPress(e, { type: 'splitter', splitterId }) + ) + splitter.addEventListener('keydown', (e) => { + const delta = { + ArrowLeft: -10, + ArrowUp: -10, + ArrowRight: 10, + ArrowDown: 10, + }[e.key] + if (!delta) return + e.preventDefault() + engine.moveSplitterBy(splitterId, delta) + }) + return splitter + } + + // --- Rendering --- + + /** Creates, updates and removes one element per id in `ids`. */ + const sync = ( + elements: Map, + items: ReadonlyMap, + create: (id: string) => HTMLElement, + update: (element: HTMLElement, item: T, id: string) => void + ) => { + elements.forEach((element, id) => { + if (items.has(id)) return + element.remove() + elements.delete(id) + }) + items.forEach((item, id) => { + let element = elements.get(id) + if (!element) elements.set(id, (element = create(id))) + update(element, item, id) + }) + } + + const render = (snapshot: LayoutSnapshot) => { + const { rects } = snapshot + + sync( + panels, + snapshot.tabsets, + () => el('div', 'dx-panel', root), + (panel, tabset, id) => { + panel.hidden = tabset.isHidden + applyRect(panel, rects.tabsets.get(id)!) + } + ) + + sync(bars, snapshot.tabsets, createTabBar, (bar, tabset, id) => { + // Snapshots share unchanged tabset states, so `!==` means "redraw the tabs". + if (barStates.get(id) !== tabset || !bar.firstChild) { + fillTabBar(bar, tabset) + barStates.set(id, tabset) + } + for (const tabId of tabset.tabIds) { + const title = defs.get(tabId)?.title ?? tabId + const label = bar.querySelector( + `.dx-tab[data-id="${CSS.escape(tabId)}"] .dx-tab-title` + ) + if (label && label.textContent !== title) + label.textContent = title + } + const placement = getTabBarPlacement( + rects.tabsets.get(id)!, + tabset, + tabBarHeight + ) + bar.hidden = tabset.isHidden + bar.toggleAttribute('data-dx-rotated', placement.isRotated) + bar.toggleAttribute('data-dx-folded', tabset.isFolded) + bar.style.transform = placement.isRotated ? 'rotate(90deg)' : '' + applyRect(bar, placement.rect) + }) + barStates.forEach((_, id) => { + if (!snapshot.tabsets.has(id)) barStates.delete(id) + }) + + sync( + contents, + snapshot.tabs, + (id) => { + const content = el('div', 'dx-tab-content', root) + content.setAttribute('role', 'tabpanel') + const def = defs.get(id) + if (def) content.append(def.content) + return content + }, + (content, tab) => { + content.hidden = !tab.isVisible + if (tab.isVisible) + applyRect( + content, + getTabContentRect( + rects.tabsets.get(tab.tabsetId)!, + tabBarHeight + ) + ) + } + ) + + sync( + splitters, + snapshot.splitters, + createSplitter, + (splitter, state, id) => { + splitter.hidden = state.isHidden + splitter.dataset.dxDirection = state.direction + splitter.toggleAttribute('data-dx-locked', state.isLocked) + splitter.setAttribute( + 'aria-orientation', + state.direction === 'horizontal' ? 'vertical' : 'horizontal' + ) + const bounds = engine.getSplitterBounds(id) + if (bounds) { + splitter.setAttribute( + 'aria-valuenow', + String(Math.round(bounds.value)) + ) + splitter.setAttribute( + 'aria-valuemin', + String(Math.round(bounds.min)) + ) + splitter.setAttribute( + 'aria-valuemax', + String(Math.round(bounds.max)) + ) + } + applyRect(splitter, rects.splitters.get(id)!) + } + ) + + const box = snapshot.drag?.indicator + indicator.hidden = !box + if (box) applyRect(indicator, box) + } + + const unsubscribe = engine.subscribe(render) + resize() + render(engine.getSnapshot()) + + return { + engine, + setTabs(tabs) { + defs = new Map(tabs.map((tab) => [tab.id, tab])) + barStates.clear() // titles or closable may have changed + engine.setTabs(tabs) + render(engine.getSnapshot()) + }, + destroy() { + stopDrag?.() + resizeFrame.cancel() + observer.disconnect() + unsubscribe() + engine.destroy() + root.replaceChildren() + root.classList.remove('dx-root') + }, + } +} diff --git a/examples/vanilla/src/main.ts b/examples/vanilla/src/main.ts new file mode 100644 index 0000000..4ea2a08 --- /dev/null +++ b/examples/vanilla/src/main.ts @@ -0,0 +1,158 @@ +import type { DropTarget, LayoutJSON } from '@dynamix-layout/core' +import { createDomLayout, type TabDef } from './dom-layout' +import './style.css' + +const STORAGE_KEY = 'dynamix-layout:vanilla-showcase' + +const loadSaved = (): LayoutJSON | null => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : null + } catch { + return null + } +} + +const panel = (background: string, html: string) => { + const element = document.createElement('div') + element.className = 'panel' + element.style.background = background + element.innerHTML = html + return element +} + +const HELP = `
        +
      • Drag a tab onto another tab bar, onto a panel's side to split it, or onto a layout edge.
      • +
      • Drag a tab bar to move the whole panel. Drag a splitter (or focus it and use the arrows) to resize.
      • +
      • Fold or maximize with the buttons at the end of a tab bar (hover it), or double-click a tab bar to maximize.
      • +
      • Keyboard: arrows move between tabs, Alt+= maximizes, Alt+- folds, Escape cancels a drag.
      • +
      • Close tabs with × (or Delete), add them with the buttons above. The layout is saved to localStorage: reload the page.
      • +
      ` + +const initialTabs: TabDef[] = [ + { id: 'guide', title: 'Guide', content: panel('#fffde7', HELP) }, + { + id: 'editor', + title: 'Editor', + content: panel( + '#e3f2fd', + 'Editor: type here, then move the tab. The text survives because content is never re-created.
      ' + ), + }, + { + id: 'terminal', + title: 'Terminal', + content: panel('#212121', '$ _'), + }, + { + id: 'preview', + title: 'Preview', + content: panel('#e8f5e9', 'Preview'), + closable: true, + }, + { + id: 'notes', + title: 'Notes', + content: panel('#fce4ec', 'Notes (closable)'), + closable: true, + }, +] + +const app = document.querySelector('#app')! +const controls = document.createElement('div') +controls.className = 'controls' +const log = document.createElement('div') +log.className = 'log' +log.textContent = 'Changes appear here (onLayoutChange reasons).' +const root = document.createElement('div') +app.append(controls, log, root) + +let tabs = initialTabs +let counter = 0 +const entries: string[] = [] +const write = (entry: string) => { + entries.unshift(entry) + log.textContent = entries.slice(0, 8).join(' · ') +} + +const layout = createDomLayout(root, { + tabs, + initialLayout: loadSaved(), + padding: 6, + splitterSize: 6, + tabBarHeight: 36, + minPanelSize: { width: 120, height: 80 }, + onLayoutChange: (json, reason) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + write(reason) + }, + onTabClose: (id) => setTabs(tabs.filter((tab) => tab.id !== id)), +}) +const { engine } = layout + +const setTabs = (next: TabDef[]) => { + tabs = next + layout.setTabs(tabs) +} + +const tabsetOf = (tabId: string) => + engine.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + +/** Runs an engine action and logs it when the engine refuses it. */ +const act = (name: string, run: () => boolean) => { + if (!run()) write(`${name}: not possible right now`) +} + +const addTab = (target?: DropTarget) => { + const id = `new-${++counter}` + setTabs([ + ...tabs, + { + id, + title: `New ${counter}`, + content: panel('#ede7f6', `Tab ${id}`), + closable: true, + target, + }, + ]) +} + +const button = (label: string, onClick: () => void) => { + const element = document.createElement('button') + element.type = 'button' + element.textContent = label + element.addEventListener('click', onClick) + controls.append(element) +} + +button('Add tab', () => addTab()) +button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) +) +button('Maximize / restore Editor', () => + act('Maximize', () => engine.toggleMaximize(tabsetOf('editor'))) +) +button('Fold / unfold Terminal', () => + act('Fold', () => engine.toggleFold(tabsetOf('terminal'))) +) +button('Select Preview', () => act('Select', () => engine.selectTab('preview'))) +button('Move Notes next to Editor', () => + act('Move', () => + engine.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) +) +button('Reset layout', () => { + setTabs(initialTabs) + engine.reset() +}) +button('Forget saved layout', () => localStorage.removeItem(STORAGE_KEY)) + +// Vite HMR: tear the old instance down before the module re-runs. +import.meta.hot?.dispose(() => { + layout.destroy() + app.replaceChildren() +}) diff --git a/examples/vanilla/src/style.css b/examples/vanilla/src/style.css new file mode 100644 index 0000000..985bac3 --- /dev/null +++ b/examples/vanilla/src/style.css @@ -0,0 +1,283 @@ +/* The same look as @dynamix-layout/react/styles.css, for plain DOM. */ +.dx-root { + --dx-font: inherit; + --dx-tab-bar-bg: #dfdfdf; + --dx-tab-bg: #dfdfdf; + --dx-tab-active-bg: #ffffff; + --dx-tab-color: inherit; + --dx-tab-radius: 4px; + --dx-tab-gap: 6px; + --dx-splitter-bg: #ffffff; + --dx-splitter-hover-bg: #e8f2ff; + --dx-grip-color: #9a9a9a; + --dx-drop-indicator-bg: rgba(0, 175, 249, 0.35); + --dx-drop-indicator-border: 2px dashed rgb(0, 196, 42); + --dx-root-drop-zone-bg: #0081f9; + --dx-focus-ring: 2px solid #0081f9; + + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + box-sizing: border-box; + font-family: var(--dx-font); +} + +/* Server render and first client render: nothing is measured yet. */ +.dx-root[data-dx-measuring] > * { + visibility: hidden; +} + +.dx-root *, +.dx-root *::before, +.dx-root *::after { + box-sizing: border-box; +} + +.dx-panel, +.dx-tab-bar, +.dx-tab-content, +.dx-splitter, +.dx-drop-indicator, +.dx-root-drop-zone { + position: absolute; +} + +.dx-panel { + z-index: 1; +} + +/* --- Tab bar and tabs --- */ +.dx-tab-bar { + z-index: 3; + display: flex; + align-items: center; + gap: var(--dx-tab-gap); + padding-left: 8px; + background: var(--dx-tab-bar-bg); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + transform-origin: 0 0; + touch-action: pan-x; + cursor: default; +} +.dx-tab-bar::-webkit-scrollbar { + display: none; +} +.dx-tab-bar[data-dx-has-toolbar] { + padding-right: 64px; +} + +.dx-tab { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-radius: var(--dx-tab-radius); + background: var(--dx-tab-bg); + color: var(--dx-tab-color); + font-size: 14px; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.dx-tab[data-state='active'] { + background: var(--dx-tab-active-bg); +} +.dx-tab:focus-visible, +.dx-splitter:focus-visible, +.dx-toolbar-button:focus-visible { + outline: var(--dx-focus-ring); + outline-offset: -2px; +} + +.dx-tab-close { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-tab-close:hover { + background: rgba(0, 0, 0, 0.08); +} + +/* --- Tab content --- */ +.dx-tab-content { + z-index: 2; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} +.dx-tab-content::-webkit-scrollbar { + display: none; +} +/* While dragging, iframes and editors inside tabs must not swallow pointer events. */ +.dx-root[data-dx-dragging] .dx-tab-content > * { + pointer-events: none; +} +.dx-root[data-dx-dragging], +.dx-root[data-dx-dragging] * { + cursor: grabbing; +} + +/* --- Splitters --- */ +.dx-splitter { + z-index: 4; + display: grid; + place-items: center; + background: var(--dx-splitter-bg); + touch-action: none; +} +.dx-splitter[data-dx-direction='horizontal'] { + cursor: ew-resize; +} +.dx-splitter[data-dx-direction='vertical'] { + cursor: ns-resize; +} +.dx-splitter:hover { + background: var(--dx-splitter-hover-bg); +} +.dx-splitter[data-dx-locked] { + pointer-events: none; +} +/* A grip of three dots across the bar. */ +.dx-splitter::after { + content: ''; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--dx-grip-color); + box-shadow: + 0 -6px 0 var(--dx-grip-color), + 0 6px 0 var(--dx-grip-color); +} +.dx-splitter[data-dx-direction='vertical']::after { + box-shadow: + -6px 0 0 var(--dx-grip-color), + 6px 0 0 var(--dx-grip-color); +} + +/* --- Drag feedback --- */ +.dx-drop-indicator { + z-index: 6; + background: var(--dx-drop-indicator-bg); + border: var(--dx-drop-indicator-border); + border-radius: 10px; + pointer-events: none; + transition: all 0.15s ease; +} + +.dx-root-drop-zone { + z-index: 5; + background: var(--dx-root-drop-zone-bg); + opacity: 0.6; + pointer-events: none; +} +.dx-root-drop-zone[data-dx-active] { + opacity: 1; +} +.dx-root-drop-zone[data-dx-side='left'] { + border-radius: 0 16px 16px 0; +} +.dx-root-drop-zone[data-dx-side='right'] { + border-radius: 16px 0 0 16px; +} +.dx-root-drop-zone[data-dx-side='top'] { + border-radius: 0 0 16px 16px; +} +.dx-root-drop-zone[data-dx-side='bottom'] { + border-radius: 16px 16px 0 0; +} + +/* --- Tabset toolbar (maximize / fold) --- */ +/* Shown while the tab bar is hovered or focused; folded strips always show it. */ +.dx-toolbar { + position: absolute; + top: 0; + right: 6px; + height: 100%; + display: flex; + align-items: center; + gap: 2px; + padding-left: 6px; + /* Opaque, so overflowing tabs slide underneath instead of showing through. */ + background: inherit; + opacity: 0; + transition: opacity 0.15s ease; +} +.dx-tab-bar:hover .dx-toolbar, +.dx-tab-bar[data-dx-folded] .dx-toolbar, +.dx-toolbar:focus-within { + opacity: 1; +} +@media (hover: none) { + .dx-toolbar { + opacity: 1; + } +} + +.dx-toolbar-button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; +} +.dx-toolbar-button:hover { + background-color: rgba(0, 0, 0, 0.08); +} + +.dx-root [hidden] { + display: none !important; +} + +/* --- Page --- */ +html, +body, +#app { + height: 100%; + margin: 0; + font-family: system-ui, sans-serif; +} +#app { + display: grid; + grid-template-rows: auto auto 1fr; +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border-bottom: 1px solid #eee; +} +.controls button { + padding: 4px 10px; +} +.log { + min-height: 20px; + padding: 4px 8px; + font-size: 12px; + color: #666; +} +.panel { + height: 100%; + padding: 16px; + line-height: 1.6; +} +.panel ul { + margin: 0; + padding-left: 18px; +} diff --git a/examples/vanilla/src/vite-env.d.ts b/examples/vanilla/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/vanilla/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vanilla/tsconfig.json b/examples/vanilla/tsconfig.json new file mode 100644 index 0000000..b3d4465 --- /dev/null +++ b/examples/vanilla/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/vanilla/vite.config.ts b/examples/vanilla/vite.config.ts new file mode 100644 index 0000000..cb4096c --- /dev/null +++ b/examples/vanilla/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 3012, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f09a2b..573a7dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,6 +487,18 @@ importers: specifier: ^0.5.11 version: 0.5.11(@netlify/blobs@9.1.2)(@parcel/watcher@2.6.0)(@types/node@24.19.1)(db0@0.3.4)(ioredis@5.11.1)(jiti@2.7.0)(lightningcss@1.32.0)(srvx@1.0.5)(terser@5.51.2)(yaml@2.8.3) + examples/vanilla: + devDependencies: + '@dynamix-layout/core': + specifier: workspace:* + version: link:../../packages/core + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@24.19.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(yaml@2.8.3) + packages/core: devDependencies: '@changesets/cli': @@ -12274,8 +12286,8 @@ snapshots: '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)) @@ -12298,7 +12310,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12309,22 +12321,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -12335,7 +12347,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 From 02f0e59a9bef4e2e5776cf9f7c460679c81a7b3c Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:40:39 +0530 Subject: [PATCH 34/41] docs: link the showcase examples, document new Solid props --- README.md | 7 ++++++- docs/guides/core-without-react.md | 2 +- packages/solid/README.md | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c26fac8..a79763e 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,12 @@ To save the layout, store what `onLayoutChange` gives you and pass it back as `d - [Using the core without React](./docs/guides/core-without-react.md) - [Performance](./docs/guides/performance.md) - [Migrating from v1](./docs/migration-v1-to-v2.md). -- [Examples](./examples): React (basic, custom components, headless, controlled), Next.js, Solid and SolidStart. +- [Examples](./examples): every one has an **all-features showcase** (drag, split, resize, close, add, fold, maximize, restore, persist, reset): + - [React](./examples/react) (plus custom components, headless and controlled demos) + - [Next.js](./examples/nextjs) + - [Solid](./examples/solid) + - [SolidStart](./examples/solidstart) + - [Plain JavaScript](./examples/vanilla) on `@dynamix-layout/core`, no framework ## Contributing diff --git a/docs/guides/core-without-react.md b/docs/guides/core-without-react.md index adff63a..3790e88 100644 --- a/docs/guides/core-without-react.md +++ b/docs/guides/core-without-react.md @@ -94,4 +94,4 @@ Without measurements, drops onto tab bars aren't available, but panels and layou - **A rotated tab bar** (`isRotated`) is an element `rect.width` wide and `rect.height` tall, rotated 90° around its top-left corner (`transform-origin: 0 0`). It covers the folded strip. - **Hidden tabsets** (`isHidden`, while another tabset is maximized) should stay mounted but invisible. -The [Solid adapter](../../packages/solid/src/hooks/useLayout.ts) is a complete example in about 250 lines. +[`examples/vanilla`](../../examples/vanilla) is a complete plain-DOM renderer in about 500 lines ([`dom-layout.ts`](../../examples/vanilla/src/dom-layout.ts)): tab and tab bar drags with measurements, splitters, close buttons, fold and maximize toolbars, keyboard support and persistence. The [Solid adapter](../../packages/solid/src/hooks/useLayout.ts) is another one. diff --git a/packages/solid/README.md b/packages/solid/README.md index 6ebcedf..935b986 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -72,7 +72,9 @@ The `` component is the primary way to use this package. It's h | Prop | Type | Description | Default | | :--- | :--- | :--- | :--- | -| **`tabs`** (required) | `[string, JSX.Element][]` | An array of `[id, component]` tuples for your tabs. | | +| **`tabs`** (required) | `TabItem[]` | `[id, component, options?]` tuples. `options` is `{ title?, closable? }`. The array is reactive: add or remove entries and only those tabs mount or unmount. | | +| `onTabClose` | `(tabId: string) => void` | Called by the × button of a `closable` tab. Remove the tab from `tabs` to close it. | `undefined` | +| `onReady` | `(layout: Layout) => void` | Receives the core engine once mounted, for `toggleMaximize`, `toggleFold`, `selectTab`, `moveTab`, `reset` and the rest of the [core API](../../docs/api/create-layout.md). | `undefined` | | `layoutTree` | `LayoutJSON \| LayoutTreeV1` | A saved layout to restore. Layouts saved by v1 are migrated automatically. | `undefined` | | `tabNames` | `Map` | Display names for your tabs, keyed by tab id. | `undefined` | | `enableTabbar` | `boolean` | If `true`, renders the draggable tab bar on top of each tab panel. | `true` | From 570bf83446eb15c79f49876d96eb24bdc7b02fd6 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:45:42 +0530 Subject: [PATCH 35/41] docs(examples): Solid showcase adds a tab at a target --- examples/solid/src/App.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/examples/solid/src/App.tsx b/examples/solid/src/App.tsx index 879419e..8143aba 100644 --- a/examples/solid/src/App.tsx +++ b/examples/solid/src/App.tsx @@ -4,7 +4,7 @@ import { type LayoutJSON, type TabItem, } from '@dynamix-layout/solid' -import type { Layout } from '@dynamix-layout/core' +import type { DropTarget, Layout } from '@dynamix-layout/core' import '@dynamix-layout/solid/style.css' import './App.css' @@ -89,20 +89,25 @@ export default function App() { const act = (name: string, done: boolean | undefined) => { if (done === false) note(`${name}: not possible right now`) } - const addTab = () => { + const addTab = (target?: DropTarget) => { const id = `new-${++counter}` - setTabs([ - ...tabs(), - [ - id, - panel('#ede7f6', `Tab ${id}`), - { title: `New ${counter}`, closable: true }, - ], - ]) + const entry: TabItem = [ + id, + panel('#ede7f6', `Tab ${id}`), + { title: `New ${counter}`, closable: true }, + ] + // `tabs` places new tabs in the last active tabset; to drop one at a + // target, add it to the engine first and `tabs` keeps it where it is. + if (target) layout?.addTab({ id }, target) + setTabs([...tabs(), entry]) } const actions: [string, () => void][] = [ - ['Add tab', addTab], + ['Add tab', () => addTab()], + [ + 'Add tab at the bottom', + () => addTab({ type: 'root', position: 'bottom' }), + ], [ 'Maximize / restore Editor', () => act('Maximize', layout?.toggleMaximize(tabsetOf('editor'))), From da78c49691b2fcec4c546418c8eefcb0550017ac Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:45:42 +0530 Subject: [PATCH 36/41] docs(examples): Next.js all-features showcase at /showcase Client-only layout restore keeps server and client markup identical. --- examples/nextjs/src/app/page.tsx | 26 +- examples/nextjs/src/app/showcase/Showcase.tsx | 232 ++++++++++++++++++ examples/nextjs/src/app/showcase/page.tsx | 8 + 3 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 examples/nextjs/src/app/showcase/Showcase.tsx create mode 100644 examples/nextjs/src/app/showcase/page.tsx diff --git a/examples/nextjs/src/app/page.tsx b/examples/nextjs/src/app/page.tsx index 2db6bcc..1469ad6 100644 --- a/examples/nextjs/src/app/page.tsx +++ b/examples/nextjs/src/app/page.tsx @@ -1,4 +1,5 @@ 'use client' +import Link from 'next/link' import { DynamixLayout } from '@dynamix-layout/react' import '@dynamix-layout/react/styles.css' import { tabs } from './comp' @@ -6,10 +7,25 @@ import { tabs } from './comp' // Rendered on the server too: v2's first render is deterministic, so it hydrates cleanly. export default function Home() { return ( - console.log(layout)} - style={{ height: '100vh', width: '100vw' }} - /> +
      + + console.log(layout)} + style={{ flex: 1, minHeight: 0, width: '100vw' }} + /> +
      ) } diff --git a/examples/nextjs/src/app/showcase/Showcase.tsx b/examples/nextjs/src/app/showcase/Showcase.tsx new file mode 100644 index 0000000..32c56f8 --- /dev/null +++ b/examples/nextjs/src/app/showcase/Showcase.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useEffect, useRef, useState, type ReactNode } from 'react' +import Link from 'next/link' +import { + DynamixLayout, + type DynamixLayoutHandle, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/react' +import '@dynamix-layout/react/styles.css' + +const STORAGE_KEY = 'dynamix-layout:showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: ReactNode) => ( +
      + {children} +
      +) + +const HELP = ( +
        +
      • + Drag a tab onto another tab bar, onto a panel's side to + split it, or onto a layout edge. +
      • +
      • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
      • +
      • + Fold or maximize with the buttons at the end of a tab + bar (hover it), or double-click a tab bar to maximize. +
      • +
      • + Keyboard: arrows move between tabs, Alt+={' '} + maximizes, Alt+- folds, Ctrl/ + ⌘+Shift+M moves the focused tab. +
      • +
      • + Close tabs with × (or Delete), add them + with the buttons above. The layout is saved to localStorage: reload + the page. +
      • +
      +) + +const initialTabs: TabItem[] = [ + { id: 'guide', title: 'Guide', content: panel('#fffde7', HELP) }, + { + id: 'editor', + title: 'Editor', + content: panel('#e3f2fd', 'Editor: focus or click to select'), + }, + { + id: 'terminal', + title: 'Terminal', + content: panel( + '#212121', + $ _ + ), + }, + { + id: 'preview', + title: 'Preview', + content: panel('#e8f5e9', 'Preview'), + closable: true, + }, + { + id: 'notes', + title: 'Notes', + content: panel('#fce4ec', 'Notes (closable)'), + closable: true, + }, +] + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function Showcase() { + const layout = useRef(null) + // localStorage only exists in the browser: read it after mount, then render the layout + // (client-only), so the server HTML and the first client render always match. + const [saved, setSaved] = useState() + const [mounted, setMounted] = useState(false) + useEffect(() => { + setSaved(loadSaved()) + setMounted(true) + }, []) + const [tabs, setTabs] = useState(initialTabs) + const [log, setLog] = useState([]) + const counter = useRef(0) + + const tabsetOf = (tabId: string) => + layout.current?.getSnapshot().tabs.get(tabId)?.tabsetId + const act = (name: string, run: () => boolean | void) => { + const result = run() + if (result === false) + setLog((current) => + [`${name}: not possible right now`, ...current].slice(0, 8) + ) + } + + const addTab = (target?: TabItem['target']) => { + const id = `new-${++counter.current}` + setTabs((current) => [ + ...current, + { + id, + title: `New ${counter.current}`, + content: panel('#ede7f6', `Tab ${id}`), + closable: true, + target, + }, + ]) + } + + // Tailwind's preflight strips button borders: restore the look of the React example. + const button = (label: string, onClick: () => void) => ( + + ) + + return ( +
      +
      + + ← Basic demo + + {button('Add tab', () => addTab())} + {button('Add tab at the bottom', () => + addTab({ type: 'root', position: 'bottom' }) + )} + {button('Maximize / restore Editor', () => + act('Maximize', () => + layout.current?.toggleMaximize(tabsetOf('editor') ?? '') + ) + )} + {button('Fold / unfold Terminal', () => + act('Fold', () => + layout.current?.toggleFold(tabsetOf('terminal') ?? '') + ) + )} + {button('Select Preview', () => + act('Select', () => layout.current?.selectTab('preview')) + )} + {button('Move Notes next to Editor', () => + act('Move', () => + layout.current?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ) + )} + {button('Reset layout', () => { + setTabs(initialTabs) + layout.current?.reset() + })} + {button('Forget saved layout', () => + localStorage.removeItem(STORAGE_KEY) + )} +
      +
      + {log.length + ? log.join(' · ') + : 'Changes appear here (onLayoutChange reasons).'} +
      + {mounted ? ( + { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + setLog((current) => [reason, ...current].slice(0, 8)) + }} + onTabClose={(id) => + setTabs((current) => + current.filter((tab) => tab.id !== id) + ) + } + /> + ) : ( +
      + )} +
      + ) +} diff --git a/examples/nextjs/src/app/showcase/page.tsx b/examples/nextjs/src/app/showcase/page.tsx new file mode 100644 index 0000000..62bd728 --- /dev/null +++ b/examples/nextjs/src/app/showcase/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from 'next' +import Showcase from './Showcase' + +export const metadata: Metadata = { title: 'Dynamix Layout showcase' } + +export default function ShowcasePage() { + return +} From 3aa635fcd5ef3895149538afd011f3ded09d70bf Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:45:42 +0530 Subject: [PATCH 37/41] docs(examples): SolidStart all-features showcase at /showcase --- .../solidstart/src/components/Showcase.tsx | 208 ++++++++++++++++++ examples/solidstart/src/routes/index.tsx | 11 +- examples/solidstart/src/routes/showcase.tsx | 26 +++ 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 examples/solidstart/src/components/Showcase.tsx create mode 100644 examples/solidstart/src/routes/showcase.tsx diff --git a/examples/solidstart/src/components/Showcase.tsx b/examples/solidstart/src/components/Showcase.tsx new file mode 100644 index 0000000..6df5064 --- /dev/null +++ b/examples/solidstart/src/components/Showcase.tsx @@ -0,0 +1,208 @@ +import { batch, createSignal, For, type JSX } from 'solid-js' +import { + DynamixLayout, + type LayoutJSON, + type TabItem, +} from '@dynamix-layout/solid' +import type { DropTarget, Layout } from '@dynamix-layout/core' +import '@dynamix-layout/solid/style.css' + +// This module touches `localStorage` and the DOM, so the route loads it with +// `clientOnly`: it never runs during server rendering, and hydration has +// nothing to mismatch. + +const STORAGE_KEY = 'dynamix-layout:solidstart-showcase' + +const loadSaved = (): LayoutJSON | undefined => { + try { + const json = localStorage.getItem(STORAGE_KEY) + return json ? (JSON.parse(json) as LayoutJSON) : undefined + } catch { + return undefined + } +} + +const panel = (background: string, children: JSX.Element) => ( +
      + {children} +
      +) + +const guide = () => ( +
        +
      • + Drag a tab onto another tab bar, onto a panel's side to split + it, or onto a layout edge. +
      • +
      • + Drag a tab bar to move the whole panel.{' '} + Drag a splitter to resize. +
      • +
      • + Fold or maximize with the buttons at the end of a tab + bar, or double-click a tab bar. Alt+= maximizes and Alt+- folds the + panel you last clicked. +
      • +
      • + Close tabs with ×, add them with the buttons above. + The layout is saved to localStorage: reload the page. +
      • +
      +) + +const initialTabs = (): TabItem[] => [ + ['guide', panel('#fffde7', guide()), { title: 'Guide' }], + ['editor', panel('#e3f2fd', 'Editor'), { title: 'Editor' }], + [ + 'terminal', + panel('#212121', $ _), + { title: 'Terminal' }, + ], + [ + 'preview', + panel('#e8f5e9', 'Preview'), + { title: 'Preview', closable: true }, + ], + [ + 'notes', + panel('#fce4ec', 'Notes (closable)'), + { title: 'Notes', closable: true }, + ], +] + +/** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ +export default function Showcase() { + let layout: Layout | undefined + let counter = 0 + const [tabs, setTabs] = createSignal(initialTabs()) + const [log, setLog] = createSignal([]) + const saved = loadSaved() + + const tabsetOf = (tabId: string) => + layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' + const note = (entry: string) => + setLog((current) => [entry, ...current].slice(0, 8)) + const act = (name: string, done: boolean | undefined) => { + if (done === false) note(`${name}: not possible right now`) + } + const addTab = (target?: DropTarget) => { + const id = `new-${++counter}` + const entry: TabItem = [ + id, + panel('#ede7f6', `Tab ${id}`), + { title: `New ${counter}`, closable: true }, + ] + batch(() => { + // The Solid adapter places tabs added through `tabs` by default; + // to drop one at a target, add it to the engine first. The + // reactive `tabs` sync then keeps it where it is. + if (target) layout?.addTab({ id }, target) + setTabs([...tabs(), entry]) + }) + } + + const actions: [string, () => void][] = [ + ['Add tab', () => addTab()], + [ + 'Add tab at the bottom', + () => addTab({ type: 'root', position: 'bottom' }), + ], + [ + 'Maximize / restore Editor', + () => act('Maximize', layout?.toggleMaximize(tabsetOf('editor'))), + ], + [ + 'Fold / unfold Terminal', + () => act('Fold', layout?.toggleFold(tabsetOf('terminal'))), + ], + ['Select Preview', () => act('Select', layout?.selectTab('preview'))], + [ + 'Move Notes next to Editor', + () => + act( + 'Move', + layout?.moveTab('notes', { + type: 'tab', + tabId: 'editor', + position: 'after', + }) + ), + ], + [ + 'Reset layout', + () => { + setTabs(initialTabs()) + layout?.reset() + }, + ], + [ + 'Forget saved layout', + () => { + localStorage.removeItem(STORAGE_KEY) + note('saved layout forgotten') + }, + ], + ] + + return ( +
      +
      + + {([label, run]) => ( + + )} + +
      +
      + {log().length ? log().join(' · ') : 'Changes appear here.'} +
      + (layout = engine)} + onTabClose={(id) => + setTabs(tabs().filter(([tabId]) => tabId !== id)) + } + updateJSON={(json) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) + note('saved') + }} + tabHeadHeight={36} + bondWidth={6} + minTabWidth={120} + minTabHeight={80} + /> +
      + ) +} diff --git a/examples/solidstart/src/routes/index.tsx b/examples/solidstart/src/routes/index.tsx index 846312a..0d87a28 100644 --- a/examples/solidstart/src/routes/index.tsx +++ b/examples/solidstart/src/routes/index.tsx @@ -35,7 +35,16 @@ export default function Home() { //---------------------------------- return ( -
      +
      + {/* 4. Use a component. It will only render its children when the `when` condition is true. Since `isClient()` is false on the server, nothing inside the diff --git a/examples/solidstart/src/routes/showcase.tsx b/examples/solidstart/src/routes/showcase.tsx new file mode 100644 index 0000000..fe73790 --- /dev/null +++ b/examples/solidstart/src/routes/showcase.tsx @@ -0,0 +1,26 @@ +import { Title } from '@solidjs/meta' +import { clientOnly } from '@solidjs/start' + +// The showcase reads its saved layout from localStorage, so it renders on the +// client only. The server sends the page shell and the fallback. +const Showcase = clientOnly(() => import('../components/Showcase')) + +export default function ShowcasePage() { + return ( +
      + SolidStart - Showcase + + Loading layout…
      } + /> + + ) +} From 38c3c711d94f9b475d8f4e26ab4e68955e2b61e5 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:52:34 +0530 Subject: [PATCH 38/41] build: keep the MIT licence comment in every bundle Rollup's output.banner is added before Vite minifies, and the minifier dropped it, so core shipped without the comment the design called for and react and solid had none. A shared plugin now adds it after minification, on line 1 (after 'use client') so source maps stay aligned. --- packages/core/vite.config.ts | 3 ++- packages/react/vite.config.ts | 2 ++ packages/solid/vite.config.ts | 2 ++ scripts/build/license-banner.ts | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 scripts/build/license-banner.ts diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 9e3d628..2820d47 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'path' import dts from 'vite-plugin-dts' import fs from 'fs' import { visualizer } from 'rollup-plugin-visualizer' +import { licenseBanner } from '../../scripts/build/license-banner' const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) @@ -19,7 +20,6 @@ export default defineConfig({ fileName: (format) => ({ es: 'index.js', cjs: 'index.cjs', umd: 'index.umd.js' })[format as 'es'], formats: ['es', 'cjs', 'umd'], }, - rollupOptions: { output: { banner: '/*! @dynamix-layout/core | MIT License */' } }, }, plugins: [ dts({ @@ -31,6 +31,7 @@ export default defineConfig({ rollupTypes: true, afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), + licenseBanner('@dynamix-layout/core'), visualizer({ filename: 'core.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], }) diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index aa47bd2..2261883 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -4,6 +4,7 @@ import { resolve } from 'path' import fs from 'fs' import dts from 'vite-plugin-dts' import { visualizer } from 'rollup-plugin-visualizer' +import { licenseBanner } from '../../scripts/build/license-banner' export default defineConfig({ server: { port: 5174, open: false }, @@ -28,6 +29,7 @@ export default defineConfig({ rollupTypes: true, afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), + licenseBanner('@dynamix-layout/react'), visualizer({ filename: 'react.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], build: { diff --git a/packages/solid/vite.config.ts b/packages/solid/vite.config.ts index 5f7cd69..5a3d8ae 100644 --- a/packages/solid/vite.config.ts +++ b/packages/solid/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vite' import solidPlugin from 'vite-plugin-solid' import { visualizer } from 'rollup-plugin-visualizer' +import { licenseBanner } from '../../scripts/build/license-banner' import dts from 'vite-plugin-dts' import { resolve } from 'path' import fs from 'fs' @@ -32,6 +33,7 @@ export default defineConfig({ rollupTypes: true, afterBuild: () => fs.copyFileSync(resolve(__dirname, 'dist/index.d.ts'), resolve(__dirname, 'dist/index.d.cts')), }), + licenseBanner('@dynamix-layout/solid'), visualizer({ filename: 'solid.html', gzipSize: true, brotliSize: true, template: 'treemap' }), ], build: { diff --git a/scripts/build/license-banner.ts b/scripts/build/license-banner.ts new file mode 100644 index 0000000..3a85812 --- /dev/null +++ b/scripts/build/license-banner.ts @@ -0,0 +1,24 @@ +import type { Plugin } from 'vite' + +const DIRECTIVE = /^(['"])use client\1;?/ + +/** + * Starts every chunk with `/*! | MIT License *\/`. Rollup's + * `output.banner` is added before Vite minifies, and the minifier drops it, so + * this runs in `generateBundle`, after minification. The comment goes on the + * first line (after a `'use client'` directive) so source maps stay aligned. + */ +export function licenseBanner(name: string): Plugin { + const banner = `/*! ${name} | MIT License */ ` + return { + name: 'dynamix-license-banner', + apply: 'build', + generateBundle(_, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk') continue + const directive = chunk.code.match(DIRECTIVE)?.[0] ?? '' + chunk.code = `${directive}${banner}${chunk.code.slice(directive.length)}` + } + }, + } +} From 37a6e44301d8275136ad044c1e50b2f3566ac1b1 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 09:00:12 +0530 Subject: [PATCH 39/41] feat(solid): updateJSON receives the change reason The second argument is the core's LayoutChangeReason, or 'mount' for the call on mount. Existing one-argument callbacks keep working. --- .changeset/v2-solid.md | 1 + packages/solid/README.md | 2 +- packages/solid/src/hooks/useLayout.ts | 4 ++-- packages/solid/src/test/port.test.tsx | 16 ++++++++++++++++ packages/solid/src/types/index.ts | 13 ++++++++++--- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.changeset/v2-solid.md b/.changeset/v2-solid.md index 467e278..396c215 100644 --- a/.changeset/v2-solid.md +++ b/.changeset/v2-solid.md @@ -10,6 +10,7 @@ - `tabs` is reactive: add an entry to open a tab, remove it to close one. Existing tabs keep their content mounted. - Closable tabs: a third tuple element `{ title, closable }` and an `onTabClose` prop. - `onReady(layout)` gives the engine for actions from code (`maximize`, `fold`, `selectTab`, `moveTab`, `reset`, `toJSON`, …). +- `updateJSON(layout, reason)` also says why it was called: `'move'`, `'resize'`, `'select'`, `'fold'`, `'maximize'`, `'tabs'`, `'reset'`, or `'mount'` for the first call (type `LayoutUpdateReason`). **Breaking:** - `layoutTree` accepts v2 JSON (v1 trees are migrated automatically), and `updateJSON` receives v2 `LayoutJSON`. diff --git a/packages/solid/README.md b/packages/solid/README.md index 935b986..6c84437 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -147,7 +147,7 @@ The Solid `useDynamixLayout` binds a `@dynamix-layout/core` instance to Solid si ### Example: Saving a Layout -- `updateJSON` receives the layout as [`LayoutJSON`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/layout-json.md) (`version: 2`) once on mount and after every change (a drop, a splitter release, selecting a tab, maximize or fold). Save it to localStorage, a database, or anywhere else, and pass it back as `layoutTree`. +- `updateJSON` receives the layout as [`LayoutJSON`](https://github.com/xcode-studio/dynamix-layout/blob/main/docs/api/layout-json.md) (`version: 2`) once on mount and after every change (a drop, a splitter release, selecting a tab, maximize or fold). Its second argument says why: `'mount'` for the first call, then `'move'`, `'resize'`, `'select'`, `'fold'`, `'maximize'`, `'tabs'` or `'reset'`. Save it to localStorage, a database, or anywhere else, and pass it back as `layoutTree`. ```jsx import { tabs } from './comp' diff --git a/packages/solid/src/hooks/useLayout.ts b/packages/solid/src/hooks/useLayout.ts index be2bee7..20f3653 100644 --- a/packages/solid/src/hooks/useLayout.ts +++ b/packages/solid/src/hooks/useLayout.ts @@ -58,7 +58,7 @@ export const useDynamixLayout = ( splitterSize: options.bondWidth, // A folded tabset shrinks to its tab bar. foldedSize: options.enableTabbar ? options.tabHeadHeight : minHeight, - onLayoutChange: (json) => options.updateJSON?.(json), + onLayoutChange: (json, reason) => options.updateJSON?.(json, reason), }) const [snapshot, setSnapshot] = createSignal(engine.getSnapshot(), { @@ -217,7 +217,7 @@ export const useDynamixLayout = ( window.addEventListener('resize', onResize) window.addEventListener('keydown', onKeyDown) // v1 behaviour, kept for the minimal port: report the layout once mounted. - options.updateJSON?.(engine.toJSON()) + options.updateJSON?.(engine.toJSON(), 'mount') onCleanup(() => { clearTimeout(timer) diff --git a/packages/solid/src/test/port.test.tsx b/packages/solid/src/test/port.test.tsx index 23a10ee..37bda21 100644 --- a/packages/solid/src/test/port.test.tsx +++ b/packages/solid/src/test/port.test.tsx @@ -189,4 +189,20 @@ describe('Solid additions for the feature showcase', () => { document.querySelectorAll('[data-tabbar][data-dx-hidden]') ).toHaveLength(1) }) + + it('passes the change reason to updateJSON', () => { + const reasons: string[] = [] + let engine: Layout | undefined + render(() => ( + (engine = layout)} + updateJSON={(_, reason) => reasons.push(reason)} + /> + )) + const [first] = engine!.getSnapshot().tabsets.keys() + engine!.maximize(first) + engine!.selectTab('terminal') + expect(reasons).toEqual(['mount', 'maximize', 'select']) + }) }) diff --git a/packages/solid/src/types/index.ts b/packages/solid/src/types/index.ts index 74fde91..d50c836 100644 --- a/packages/solid/src/types/index.ts +++ b/packages/solid/src/types/index.ts @@ -1,11 +1,15 @@ import type { Layout, + LayoutChangeReason, LayoutJSON, LayoutTreeV1, Rect, } from '@dynamix-layout/core' import { JSX } from 'solid-js' +/** Why `updateJSON` was called. */ +export type LayoutUpdateReason = LayoutChangeReason | 'mount' + export type DivFC = ( props: { children?: JSX.Element } & JSX.HTMLAttributes ) => JSX.Element @@ -63,8 +67,11 @@ export interface LayoutProps { class?: string style?: JSX.CSSProperties bondWidth?: number - /** Called with the v2 layout after every change, and once on mount. */ - updateJSON?: (layout: LayoutJSON) => void + /** + * Called with the v2 layout after every change, and once on mount. + * `reason` says why: the core's `LayoutChangeReason`, or `'mount'`. + */ + updateJSON?: (layout: LayoutJSON, reason: LayoutUpdateReason) => void minTabHeight?: number minTabWidth?: number sliderUpdateTimeout?: number @@ -115,7 +122,7 @@ export interface TabsetToolbarProps { export interface useDynamixLayoutOptions { tabIds: string[] layoutTree?: LayoutJSON | LayoutTreeV1 - updateJSON?: (layout: LayoutJSON) => void + updateJSON?: (layout: LayoutJSON, reason: LayoutUpdateReason) => void enableTabbar: boolean /** The area to fill, relative to the root element. */ container: () => Rect From a0c59e981151375bc036c9dea6fd626ce17a21ea Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 09:00:12 +0530 Subject: [PATCH 40/41] docs(examples): showcases restore added and closed tabs on reload The saved layout lists every open tab, so each showcase rebuilds its tabs from it. The Solid showcases log change reasons instead of "saved". --- examples/nextjs/src/app/showcase/Showcase.tsx | 58 ++++++++++++++----- .../react/src/examples/ShowcaseExample.tsx | 49 +++++++++++----- examples/solid/src/App.tsx | 47 +++++++++++---- .../solidstart/src/components/Showcase.tsx | 47 +++++++++++---- examples/vanilla/src/main.ts | 50 +++++++++++----- 5 files changed, 184 insertions(+), 67 deletions(-) diff --git a/examples/nextjs/src/app/showcase/Showcase.tsx b/examples/nextjs/src/app/showcase/Showcase.tsx index 32c56f8..8d9396d 100644 --- a/examples/nextjs/src/app/showcase/Showcase.tsx +++ b/examples/nextjs/src/app/showcase/Showcase.tsx @@ -8,6 +8,7 @@ import { type LayoutJSON, type TabItem, } from '@dynamix-layout/react' +import type { RowJSON } from '@dynamix-layout/core' import '@dynamix-layout/react/styles.css' const STORAGE_KEY = 'dynamix-layout:showcase' @@ -83,6 +84,35 @@ const initialTabs: TabItem[] = [ }, ] +const newTab = (n: number, target?: TabItem['target']): TabItem => ({ + id: `new-${n}`, + title: `New ${n}`, + content: panel('#ede7f6', `Tab new-${n}`), + closable: true, + target, +}) +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs + const known = new Map(initialTabs.map((tab) => [tab.id, tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + /** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ export default function Showcase() { const layout = useRef(null) @@ -90,13 +120,20 @@ export default function Showcase() { // (client-only), so the server HTML and the first client render always match. const [saved, setSaved] = useState() const [mounted, setMounted] = useState(false) - useEffect(() => { - setSaved(loadSaved()) - setMounted(true) - }, []) const [tabs, setTabs] = useState(initialTabs) const [log, setLog] = useState([]) const counter = useRef(0) + useEffect(() => { + const savedLayout = loadSaved() + const restored = restoreTabs(savedLayout) + setSaved(savedLayout) + setTabs(restored) + counter.current = Math.max( + 0, + ...restored.map((tab) => newTabNumber(tab.id)) + ) + setMounted(true) + }, []) const tabsetOf = (tabId: string) => layout.current?.getSnapshot().tabs.get(tabId)?.tabsetId @@ -109,17 +146,8 @@ export default function Showcase() { } const addTab = (target?: TabItem['target']) => { - const id = `new-${++counter.current}` - setTabs((current) => [ - ...current, - { - id, - title: `New ${counter.current}`, - content: panel('#ede7f6', `Tab ${id}`), - closable: true, - target, - }, - ]) + const tab = newTab(++counter.current, target) + setTabs((current) => [...current, tab]) } // Tailwind's preflight strips button borders: restore the look of the React example. diff --git a/examples/react/src/examples/ShowcaseExample.tsx b/examples/react/src/examples/ShowcaseExample.tsx index db60195..7238081 100644 --- a/examples/react/src/examples/ShowcaseExample.tsx +++ b/examples/react/src/examples/ShowcaseExample.tsx @@ -5,6 +5,7 @@ import { type LayoutJSON, type TabItem, } from '@dynamix-layout/react' +import type { RowJSON } from '@dynamix-layout/core' import '@dynamix-layout/react/styles.css' const STORAGE_KEY = 'dynamix-layout:showcase' @@ -80,13 +81,44 @@ const initialTabs: TabItem[] = [ }, ] +const newTab = (n: number, target?: TabItem['target']): TabItem => ({ + id: `new-${n}`, + title: `New ${n}`, + content: panel('#ede7f6', `Tab new-${n}`), + closable: true, + target, +}) +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs + const known = new Map(initialTabs.map((tab) => [tab.id, tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + /** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ export default function ShowcaseExample() { const layout = useRef(null) const [saved] = useState(loadSaved) - const [tabs, setTabs] = useState(initialTabs) + const [tabs, setTabs] = useState(() => restoreTabs(saved)) const [log, setLog] = useState([]) - const counter = useRef(0) + const counter = useRef( + Math.max(0, ...tabs.map((tab) => newTabNumber(tab.id))) + ) const tabsetOf = (tabId: string) => layout.current?.getSnapshot().tabs.get(tabId)?.tabsetId @@ -99,17 +131,8 @@ export default function ShowcaseExample() { } const addTab = (target?: TabItem['target']) => { - const id = `new-${++counter.current}` - setTabs((current) => [ - ...current, - { - id, - title: `New ${counter.current}`, - content: panel('#ede7f6', `Tab ${id}`), - closable: true, - target, - }, - ]) + const tab = newTab(++counter.current, target) + setTabs((current) => [...current, tab]) } const button = (label: string, onClick: () => void) => ( diff --git a/examples/solid/src/App.tsx b/examples/solid/src/App.tsx index 8143aba..6081c06 100644 --- a/examples/solid/src/App.tsx +++ b/examples/solid/src/App.tsx @@ -4,7 +4,7 @@ import { type LayoutJSON, type TabItem, } from '@dynamix-layout/solid' -import type { DropTarget, Layout } from '@dynamix-layout/core' +import type { DropTarget, Layout, RowJSON } from '@dynamix-layout/core' import '@dynamix-layout/solid/style.css' import './App.css' @@ -74,13 +74,40 @@ const initialTabs = (): TabItem[] => [ ], ] +const newTab = (n: number): TabItem => [ + `new-${n}`, + panel('#ede7f6', `Tab new-${n}`), + { title: `New ${n}`, closable: true }, +] +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs() + const known = new Map(initialTabs().map((tab) => [tab[0], tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + /** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ export default function App() { let layout: Layout | undefined - let counter = 0 - const [tabs, setTabs] = createSignal(initialTabs()) - const [log, setLog] = createSignal([]) const saved = loadSaved() + const [tabs, setTabs] = createSignal(restoreTabs(saved)) + const [log, setLog] = createSignal([]) + let counter = Math.max(0, ...tabs().map(([id]) => newTabNumber(id))) const tabsetOf = (tabId: string) => layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' @@ -90,12 +117,8 @@ export default function App() { if (done === false) note(`${name}: not possible right now`) } const addTab = (target?: DropTarget) => { - const id = `new-${++counter}` - const entry: TabItem = [ - id, - panel('#ede7f6', `Tab ${id}`), - { title: `New ${counter}`, closable: true }, - ] + const entry = newTab(++counter) + const id = entry[0] // `tabs` places new tabs in the last active tabset; to drop one at a // target, add it to the engine first and `tabs` keeps it where it is. if (target) layout?.addTab({ id }, target) @@ -177,9 +200,9 @@ export default function App() { onTabClose={(id) => setTabs(tabs().filter(([tabId]) => tabId !== id)) } - updateJSON={(json) => { + updateJSON={(json, reason) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) - note('saved') + if (reason !== 'mount') note(reason) }} tabHeadHeight={36} bondWidth={6} diff --git a/examples/solidstart/src/components/Showcase.tsx b/examples/solidstart/src/components/Showcase.tsx index 6df5064..75373ba 100644 --- a/examples/solidstart/src/components/Showcase.tsx +++ b/examples/solidstart/src/components/Showcase.tsx @@ -4,7 +4,7 @@ import { type LayoutJSON, type TabItem, } from '@dynamix-layout/solid' -import type { DropTarget, Layout } from '@dynamix-layout/core' +import type { DropTarget, Layout, RowJSON } from '@dynamix-layout/core' import '@dynamix-layout/solid/style.css' // This module touches `localStorage` and the DOM, so the route loads it with @@ -77,13 +77,40 @@ const initialTabs = (): TabItem[] => [ ], ] +const newTab = (n: number): TabItem => [ + `new-${n}`, + panel('#ede7f6', `Tab new-${n}`), + { title: `New ${n}`, closable: true }, +] +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | undefined): TabItem[] => { + if (!saved) return initialTabs() + const known = new Map(initialTabs().map((tab) => [tab[0], tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + /** Every feature on one page: drag, split, resize, close, add, fold, maximize, restore, persist, reset. */ export default function Showcase() { let layout: Layout | undefined - let counter = 0 - const [tabs, setTabs] = createSignal(initialTabs()) - const [log, setLog] = createSignal([]) const saved = loadSaved() + const [tabs, setTabs] = createSignal(restoreTabs(saved)) + const [log, setLog] = createSignal([]) + let counter = Math.max(0, ...tabs().map(([id]) => newTabNumber(id))) const tabsetOf = (tabId: string) => layout?.getSnapshot().tabs.get(tabId)?.tabsetId ?? '' @@ -93,12 +120,8 @@ export default function Showcase() { if (done === false) note(`${name}: not possible right now`) } const addTab = (target?: DropTarget) => { - const id = `new-${++counter}` - const entry: TabItem = [ - id, - panel('#ede7f6', `Tab ${id}`), - { title: `New ${counter}`, closable: true }, - ] + const entry = newTab(++counter) + const id = entry[0] batch(() => { // The Solid adapter places tabs added through `tabs` by default; // to drop one at a target, add it to the engine first. The @@ -194,9 +217,9 @@ export default function Showcase() { onTabClose={(id) => setTabs(tabs().filter(([tabId]) => tabId !== id)) } - updateJSON={(json) => { + updateJSON={(json, reason) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(json)) - note('saved') + if (reason !== 'mount') note(reason) }} tabHeadHeight={36} bondWidth={6} diff --git a/examples/vanilla/src/main.ts b/examples/vanilla/src/main.ts index 4ea2a08..f8bd60b 100644 --- a/examples/vanilla/src/main.ts +++ b/examples/vanilla/src/main.ts @@ -1,4 +1,4 @@ -import type { DropTarget, LayoutJSON } from '@dynamix-layout/core' +import type { DropTarget, LayoutJSON, RowJSON } from '@dynamix-layout/core' import { createDomLayout, type TabDef } from './dom-layout' import './style.css' @@ -58,6 +58,35 @@ const initialTabs: TabDef[] = [ }, ] +const newTab = (n: number, target?: DropTarget): TabDef => ({ + id: `new-${n}`, + title: `New ${n}`, + content: panel('#ede7f6', `Tab new-${n}`), + closable: true, + target, +}) +const newTabNumber = (id: string) => Number(/^new-(\d+)$/.exec(id)?.[1] ?? 0) + +/** Tab ids in a saved layout, so added and closed tabs survive a reload too. */ +const tabIdsOf = (row: RowJSON): string[] => + row.children.flatMap((child) => + child.type === 'row' + ? tabIdsOf(child) + : child.children.map((tab) => tab.id) + ) + +/** The tabs a saved layout had open, or the initial ones. */ +const restoreTabs = (saved: LayoutJSON | null): TabDef[] => { + if (!saved) return initialTabs + const known = new Map(initialTabs.map((tab) => [tab.id, tab])) + return tabIdsOf(saved.root).flatMap((id) => { + const tab = + known.get(id) ?? + (newTabNumber(id) ? newTab(newTabNumber(id)) : null) + return tab ? [tab] : [] + }) +} + const app = document.querySelector('#app')! const controls = document.createElement('div') controls.className = 'controls' @@ -67,8 +96,9 @@ log.textContent = 'Changes appear here (onLayoutChange reasons).' const root = document.createElement('div') app.append(controls, log, root) -let tabs = initialTabs -let counter = 0 +const saved = loadSaved() +let tabs = restoreTabs(saved) +let counter = Math.max(0, ...tabs.map((tab) => newTabNumber(tab.id))) const entries: string[] = [] const write = (entry: string) => { entries.unshift(entry) @@ -77,7 +107,7 @@ const write = (entry: string) => { const layout = createDomLayout(root, { tabs, - initialLayout: loadSaved(), + initialLayout: saved, padding: 6, splitterSize: 6, tabBarHeight: 36, @@ -104,17 +134,7 @@ const act = (name: string, run: () => boolean) => { } const addTab = (target?: DropTarget) => { - const id = `new-${++counter}` - setTabs([ - ...tabs, - { - id, - title: `New ${counter}`, - content: panel('#ede7f6', `Tab ${id}`), - closable: true, - target, - }, - ]) + setTabs([...tabs, newTab(++counter, target)]) } const button = (label: string, onClick: () => void) => { From 46b6fe77bf9c66cef8d390c448d2b2fc83ab91dd Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 09:00:12 +0530 Subject: [PATCH 41/41] chore: ignore .DS_Store --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7940894..c867420 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# macOS +.DS_Store