diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..227c072 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,572 @@ +# Architecture + +How this library actually works, top to bottom. Read after the README. + +## The core idea: CSS Grid is the source of truth + +A `SplitGrid` container is one DOM element with `display: grid` and a single +inline custom property `--sp-tracks`: + +```html +
+
+
+
+
+
+
+``` + +```css +.sp-container[data-direction="row"] { + grid-template-columns: var(--sp-tracks); + grid-auto-rows: 100%; + transition: grid-template-columns 750ms cubic-bezier(.77, 0, .175, 1); +} +``` + +The browser does all the layout, animation, and clamping. JS only writes the +`--sp-tracks` string — typically once per drag frame, once per programmatic +size change, never per child element. + +That gives you a very small surface: **whoever writes `--sp-tracks` controls +the world.** The whole runtime is one giant funnel into `writeTracks()`. + +## The data model + +Three core types in [`src/types.ts`](./src/types.ts): + +```ts +enum PanelDirection { Row = 'row', Column = 'column' } + +type LengthInput = number | `${number}px` | `${number}%` | 'auto' | string; +type Length = { unit: 'px' | 'pct' | 'fr', value: number }; + +interface Leaf { + id: string; + data?: T; + bounds?: { size?: LengthInput, min?: LengthInput, max?: LengthInput }; +} + +interface Container { + id: string; + direction: PanelDirection; + children: Array>; // required internally + bounds?: Bounds; + resizer?: ResizerSpec; +} + +interface ContainerInput { + id: string; + direction: PanelDirection; + children?: Array>; // optional — for declarative APIs + bounds?: Bounds; + resizer?: ResizerSpec; +} +``` + +`PanelDirection` is a real string enum: `'row'` literals don't typecheck +at TS-checked call sites. Template-string scope (``) still passes the string at runtime — Vue +templates aren't fully type-checked the same way as TS files. + +`Container` and `ContainerInput` are the same shape except `children`: +`ContainerInput`'s is optional, used by ``'s `root` prop +because declarative `` slot children register themselves +during setup. The wrapper normalizes via `?? []` before handing the +definition to `new SplitGrid()`, which sees the strict `Container`. + +The `bounds` on a `Leaf` describes the leaf's *slot* in its parent's axis — +not the leaf's content size. `bounds.size` is the *initial* size; the runtime +remembers a `sizes: Length[]` array on each container that the user (or +imperative API) has since adjusted. The `bounds.size` field is the +"definition" anchor that `reset()` restores to and `isAtDefault()` compares +against. + +`Length` has three normalized units: + +- `'px'` — fixed. +- `'pct'` — percentage. Resolved by CSS against the **full grid container + axis**, including resizer tracks. Internally always stored as + *containerAxisPx-relative* (see "Two denominators" below). +- `'fr'` — flex share. Represents `'auto'` input. Emitted to the track string + as `minmax(, 1fr)`. Whenever a layout op needs to do exact math on a + panel currently in `fr` units, `freezeFrTracks` converts it to `pct` first. + +## The runtime: `SplitGrid` ([`src/SplitGrid.ts`](./src/SplitGrid.ts)) + +Owns: + +- `byId: Map` — flat index for O(1) lookups. +- `rootEl: ContainerState` — the root container's runtime state (DOM el, + current sizes, maximize state, availPx). +- `dragStates: WeakMap` — per-resizer + pointer-event adapters, disposed when their element detaches. +- `subscribers: Set` — fanout for `onChange` events. + +Public methods fall into three groups: + +### Structural + +- `addChild(parentId, def, index?)` +- `removeChild(id)` +- `swap(idA, idB)` — moves nodes between positions (and between containers). +- `syncChildren(containerId, defs)` — diff against current children, emit + add/remove/swap as needed. + +All four route through `mutateStructure(c, fn)`, a private helper that +wraps the per-container boilerplate every structural mutation needs: +detach resizers → run the mutator → renormalize pct sizes → clear max +state → reindex `indexInParent` → rebuild resizers → writeTracks with +`animate: false`. Cross-parent swap calls it twice (once per affected +parent). Adding a new structural method gets these steps for free — +critically, without forgetting to clear the maximize snapshot or +renormalize pct sizes after the track count changes. + +### Sizing + +- `setSize`, `setBounds`, `equalize`, `reset` — direct manipulation. + `setSize` and `setBounds` short-circuit on reference-equal inputs to + defend against Vue's shallow-watcher recursion. +- `maximize`, `minimize`, `toggleMaximize` — single-panel state changes. + Both `maximize` and `toggleExpand` record `parent.max = { id, + restore: Length[] }` (a snapshot of sizes pre-maximize) so `isMaximized` + reflects the state and `toggleExpand` can revert. `minimize` (and the + reverting half of `toggleExpand`) clear `parent.max` back to `null`. + The "at most one maximized child per container" invariant is + structural — `max` is a single field, not a Map. +- `toggleExpand` — alternates "maximize ↔ restore prior layout via snapshot". +- `expandNext` / `expandPrev` — walk the maximize state forward/back through + a container's children. + +### Data + +- `setData`, `setDataArray`, `swapData`, `moveData` — change `leaf.data` + without touching positions. Drag-drop uses these. + +### Queries + +- `get(id)`, `getSize(id)`, `isMaximized`, `isAtDefault`, `getRawDefinition`. +- `settle(containerId?, opts?)` — `Promise` that resolves on + `transitionend` for the affected container(s), with a timeout fallback so + you never hang on a no-op write. + +## The freeze/animate dance + +This is the trickiest invariant to keep straight. + +CSS can interpolate between two `length-percentage` track values smoothly +(e.g. animating `200px` → `50%`). It **cannot** smoothly interpolate between +a `length-percentage` and a flex-fr value (e.g. `minmax(0, 1fr)` → `max(0, 50%)`) +— the two CSS function shapes are different, so the browser falls back to a +discrete jump. Mid-animation you see a flash of container background. + +So before any animation that crosses that boundary, `freezeFrTracks(c)`: + +1. Reads each child's current rendered px size via `getBoundingClientRect`. +2. Converts to `pct` relative to the full container axis. +3. Writes those concrete pct sizes into `c.sizes`. +4. Sets `data-no-animate` on the container, calls `writeTracks`, then forces + a synchronous layout flush by reading `offsetWidth`, then removes + `data-no-animate`. +5. The caller then does its animated write. + +The result: animation always goes pct → pct, smoothly. + +`data-no-animate` is also set transiently by `addChild` / `removeChild` / +`swap` — the track *count* changes there, and CSS can't animate between +different numbers of tracks. The snap is intentional. + +`data-dragging` does the same thing for live drag — every pointermove fires +a `writeTracks` and we don't want the transition to chase the cursor. + +## The percentage denominator (and how the brand keeps it honest) + +CSS Grid resolves percentage track values against the **full grid +container** (`width` / `height`), including resizer tracks. The runtime +must agree — anything resolving a `pct` length against a different +denominator drifts versus what CSS paints. + +| What | Denominator | +|---------------------|---------------------| +| `bounds.min` / `bounds.max` resolution | `containerAxisPx` | +| `setSize(id, '50%')` resolution | `containerAxisPx` | +| Stored `pct` sizes in `c.sizes` | `containerAxisPx` | +| Sibling-rebalance budget | `availPx` (content area = container − resizer tracks) | + +The first three rules used to mix denominators — that was the bug +shape this codebase kept almost re-introducing. Now everything that +resolves a pct to px uses `containerAxisPx`. The sibling-rebalance +math in `applyTargetSize` uses `availPx` only for the budget split +(how much room siblings get after the target takes its share), not +for resolving any specific length. + +To stop the bug from coming back: `ContainerAxisPx` is a **branded +type** (nominal `number`) in `length.ts`. `toPx`, `pxToPct`, +`clampToBounds`, `boundsMinPx`, `parseLengthToPxAxis`, +`resizerTracksPx`, `dragHandle`'s axis param, and `PointerDragState`'s +internal `containerAxisPx` field all take `ContainerAxisPx`, not +`number`. Producers stamp at the boundary via `asContainerAxis(n)`. +A careless `toPx(l, parent.availPx)` is a compile error. + +The browser project also runs a property test (`invariant.browser.spec.ts`) +that asserts "rendered tracks sum to container width within ±3px" after +random sequences of public layout ops (8 op kinds, 1–12 ops per +sequence, 40 runs). fast-check's shrinker turns any new drift into a +minimal repro automatically — it has already caught two real bugs +(`absorbVacancy` leaving 988px of dead space when the removed panel was +maximized; `makeRoom` scaling pct sizes below their `bounds.min`, +producing 4px CSS-vs-JS divergence). + +`resizerTracksPx(c, axisPx)` returns the total resizer-track budget. It +counts: + +- `(children.length − 1)` inner dividers. +- `+1` if `resizer.first` and there's at least one child. +- `+1` if `resizer.last`. + +Same formula `trackString` uses to build the actual track string — they +must stay in lockstep, or `availPx` (which `refreshAvail` and `measureAll` +derive as `containerAxisPx − resizerTracksPx`) reports a budget that +disagrees with what CSS leaves the content tracks. + +### Iterative pin-and-reshare + +Two layout ops use the same shape of algorithm: `equalize` and `makeRoom`. +Both have to distribute a pct budget across N children while honoring +each child's `bounds.min` as a floor: + +1. Compute a candidate share = `budget / freeCount`. +2. Any free child whose min pct exceeds the share pins at its min; remove + from free set; subtract its min from the budget. +3. Repeat until no more pinning is needed. +4. All remaining free children get the same final share. + +Without the iterative pinning, naive proportional scaling lets a child's +stored pct fall below its `bounds.min`. CSS then clamps the rendered +track up to the min while the runtime believes the stored value is the +truth — the rendered total overflows the container by the gap. This is +the bug shape the property test caught in `makeRoom`. + +## The drag + +Two layers: the pure math in `drag.ts` and the pointer-event adapter in +`pointerDrag.ts`. + +### `drag.ts` — pure push-cascade with LIFO recovery + +A pure function: given current px sizes, the drag-start snapshot, a handle +index, and a delta, return how much was actually applied and mutate the +sizes array in place. No DOM, no state outside the arguments. + +1. **Plan grow side.** Walk outward from the handle on the grow side, + accumulating each panel's *deficit* relative to its drag-start snapshot. + These are panels that were shrunk earlier in the drag and have room to + restore. Reverse the order so we restore furthest-first — that gives the + "last shrunk, first restored" UX users expect when reversing direction. +2. **Plan shrink side.** Walk outward from the handle on the shrink side, + accumulating each panel's room above its min. Closest-first. +3. **Transfer `min(want, growCap, takeCap)`** from one side to the other. + Any leftover after restoring snapshots flows into expanding the + immediate neighbor past its snapshot, up to its `max`. + +### `pointerDrag.ts` — `PointerDragState` class + +Owns the pointer-event lifecycle around the pure math. Replaced an +inline 80-line closure in `SplitGrid.attachDrag` whose three weaknesses +were: + +- The `pointerup`-only cleanup leaked global listeners on `pointercancel`, + window blur, and mid-drag DOM detach. +- Closure state couldn't be inspected from tests without a real grid. +- No explicit lifecycle. + +`PointerDragState` has an `idle ↔ active` lifecycle and a single +`end(reason)` cleanup funnel. Termination routes: `'up'` | `'cancel'` | +`'blur'` | `'unmount'`. All four call `end`, which releases pointer +capture, clears `data-dragging`, and removes every global listener. + +SplitGrid stores instances in a `WeakMap` +keyed by resizer element and disposes them in `detachResizers`. Full-grid +teardown walks every `.sp-resizer` in the host and disposes — necessary +because the WeakMap entries are still reachable via JS until the elements +GC, and we don't want their global listeners hanging around in that window. + +The adapter takes a `DragAdapter` interface (resizer-track px math, +child-element lookup, post-apply hook, optional log) so the class +doesn't depend on `SplitGrid` directly. Tests in `pointerDrag.dom.spec.ts` +supply a stub adapter and dispatch synthetic PointerEvents. + +## The Vue wrapper ([`src/vue/`](./src/vue/)) + +Three components: + +### `` + +A real `
` (NOT `display: contents` — that prevents +devtools highlighting and breaks any `class="w-100 h-100"` the consumer +applies). Owns a `SplitGrid` instance and four `shallowReactive` collections: + +- `leafEntries: Map` — leaf DOM elements created by the + runtime, paired with their `` definition. +- `resizerEntries: Map` — divider elements + adjacent + node info, keyed by `${containerId}#${handleIdx}`. +- `panelStates: Map` — reactive per-panel state + (`isMaximized`, `isDragging`, `size`, container-only `childSizes` / + `maximizedChildId`, etc.) that the `#leaf` slot scope reads. +- `ownedResizers: Set` — divider keys claimed by a ``'s + per-panel `#resizer` slot so the wrapper-level slot can skip them + without double-teleporting. + +Template: a hidden `
` collects the default +slot (where consumers put their ``s) — the components instantiate +their setups and register, but their visual output is `display: none`. Then +`` pairs ship slot content from `` into the matching +leaf element, and from `#leaf` / `#resizer` into the runtime-created DOM. + +The `provide`/`inject` channel publishes a `SplitGridContext` so child +components can call `useSplitGrid()` / `usePanelState(id)` without prop +drilling, plus a `ChildRegistry` so nested `` / `` +can register themselves with their nearest enclosing container. + +**Parent components** (the consumer that mounts `` itself) +can't `inject`, so the ref-exposed `getPanelState(id)` is the equivalent +hook for that side. It reads from the same reactive `panelStates` map, so +a `computed(() => gridRef.value?.getPanelState('root'))` in the parent +re-runs on every layout change that touches the id. The dashboard +consumer uses this for toolbar disabled-state computeds (`childSizes`, +`maximizedChildId`). + +### `` + +On setup: + +1. Injects the nearest `ChildRegistry`. +2. Resolves an id (explicit `panelId` prop or auto-generated from the Vue + instance uid). +3. Builds a `Leaf` def from props. +4. Calls `registry.registerChild(leaf)` — which either pushes into the + wrapper's `pendingChildren` (before mount) or dispatches to + `grid.addChild` (after mount). +5. `onBeforeUnmount`: calls `unregisterChild(id)`. + +Two ``s render the slot content: + +- Default slot (``) into the leaf element the + runtime created. +- `#resizer` slot into the leading divider (the divider between this panel + and its previous sibling, or the `resizer.first` edge for the first + child). Ownership is published to `context.ownedResizers` so the + wrapper-level `#resizer` slot skips dividers a panel has claimed. + +Shallow watchers on `size`/`min`/`max`/`data` propagate prop changes to +`grid.setBounds` and `grid.setData`. **Never bind an inline object literal** +to `:data` — every render creates a new reference, the watcher fires, +`setData` re-renders the wrapper, and you loop. Use a `ref` or `computed` +so reference equality holds when nothing changed. + +### `` + +A nested container node. Same registration pattern: registers with its +enclosing `ChildRegistry` as a `Container` def, then publishes its OWN +`ChildRegistry` for any ``s inside it. The pending-children +buffer at this level becomes the container's `children` array at mount. + +### Why `Teleport` + +It would be simpler to render slot content into the ``'s own +DOM and have the runtime move it. But: + +- The runtime creates the leaf element with the right class, dataset + attributes (`data-id`, etc.), and grid-item positioning. +- Vue owns the slot vnode lifecycle. Moving DOM around behind Vue's back + breaks unmount cleanup. + +Teleport gives us both: Vue keeps the vnode tree consistent (and cleans up +on unmount), while the actual DOM child lives where the runtime wants it. + +## The CSS layer ([`src/styles.css`](./src/styles.css)) + +About 140 lines of plain CSS. Two responsibilities: + +1. **Geometry.** `.sp-container { display: grid; transition: grid-template-* + …; }` plus direction-keyed track templates and the `data-no-animate` / + `data-dragging` opt-outs. +2. **Default indicator.** `.sp-resizer-handle` is a real centered `
` + (not a `::before` pseudo — that prevented the handle from receiving its + own dblclick if we wanted to). Themable via `--sp-indicator-*` CSS + variables. + +Hiding the default handle when a consumer teleports custom content into a +divider: `.sp-resizer:has(> *:not(.sp-resizer-handle)) > .sp-resizer-handle +{ display: none; }`. Vue's `` puts slot children directly into +the target, so `:has()` is a reliable "the consumer customized this" +signal. + +The host wrapper (`.sp-vue-host`) is a real block element with +`width: 100%; height: 100%; min-*: 0; position: relative` — consumer +classes (`w-100`, flex utilities) attach to a box, devtools can highlight +it, and the inner grid container has a defined parent to size against. + +## Layout-change events + +Every state-mutating method calls `this.emit(reason, container, nodeIds)`, +which fans out to subscribers and the configured `onChange` callback. + +```ts +interface LayoutChangeEvent { + containerId: string; + nodeIds: string[]; // empty for "everyone in the container" events + reason: 'drag' | 'set-size' | 'set-bounds' | 'maximize' | 'minimize' + | 'toggle-expand' | 'equalize' | 'reset' | 'add-child' + | 'remove-child' | 'swap' | 'set-direction' | … +} +``` + +The Vue wrapper subscribes once and translates these into reactive map +updates — refreshing only the panel states the event touched. + +`freezeFrTracks` deliberately does NOT emit a change event; it's a +measurement-only intermediate step on the way to the real change, and +emitting from it would double-fire. + +## Build + +Vite library mode, two entry points: + +- `splitpanel` — the framework-free core. Imports `./styles.css` as a side + effect so the published `dist/splitpanel.css` actually ships the layout + rules. (Nothing else imports the stylesheet; without this side effect the + CSS bundle is empty.) +- `vue` — the Vue 3 wrapper. `external: ['vue']` so we never bundle Vue. + +Both produce ESM + CJS + `.d.ts`. The Vue SFCs' ` diff --git a/demo/DraggableExample.vue b/demo/DraggableExample.vue new file mode 100644 index 0000000..74e6896 --- /dev/null +++ b/demo/DraggableExample.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/demo/DynamicExample.vue b/demo/DynamicExample.vue new file mode 100644 index 0000000..9a0c4e7 --- /dev/null +++ b/demo/DynamicExample.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/demo/VueResizerExample.vue b/demo/VueResizerExample.vue new file mode 100644 index 0000000..99121cf --- /dev/null +++ b/demo/VueResizerExample.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/eslint.config.js b/eslint.config.js index f389736..14509bf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -84,7 +84,9 @@ export default [ allowInterfaces: 'with-single-extends', }], '@typescript-eslint/no-unused-vars': ['error'], - '@typescript-eslint/no-use-before-define': ['error'], + // Function declarations are hoisted, so use-before-define is safe at + // runtime for them — let `let`/`const`/`class`/etc keep the check. + '@typescript-eslint/no-use-before-define': ['error', { functions: false }], '@typescript-eslint/no-shadow': ['error'], 'no-shadow': 'off', 'no-unused-vars': 'off', diff --git a/index.html b/index.html index d79704e..9511ddd 100644 --- a/index.html +++ b/index.html @@ -3,19 +3,214 @@ - Vite + TS + SplitGrid examples + -
- - - +
+

SplitGrid examples

+

+ Drag any divider to resize — push-cascade kicks in once a neighbor hits its min. + Double-click a divider to maximize the panel closer to the layout edge; double-click again to restore. + Open the console for debug logs (on the IDE example). +

+
+ +
+
+

1. IDE-style layout

+

+ Three panels with mixed units. Sidebar has bounds 240px default, + clamped to 160–400. Middle is a nested column with an + auto-fill editor (min 120px) and a 30% console. + Inspector starts at 20% with min 120px. + Debug logs are on for this one — exposed as window.ide. +

+
+
+ +
+

2. Four panels, no constraints

+

+ Every child is auto (1fr); no min or max. + Panels can collapse all the way to 0 — useful for seeing the + unconstrained default behavior. +

+
+
+ +
+

3. Vertical (column) layout

+

+ Top toolbar fixed at 56px, main area takes the rest, + bottom status bar fixed at 32px. +

+
+
+ +
+

4. Deep nesting

+

+ Row → column → row, three levels deep. Each container manages its own grid; + dragging a divider only retracks its own container. +

+
+
+ +
+

5. Custom resizer rendering

+

+ The resizer.render hook receives the divider element so the + consumer can style it freely. Here the divider is wider (16px), + dark, and shows a grip glyph. +

+
+
+ +
+

6. Mixed units

+

+ 200px fixed + auto (fr) + 25% + + auto. Demonstrates that fixed-px tracks stay put on container + resize while auto tracks absorb the change. +

+
+
+ +
+

7. Custom resizer via Vue slot

+

+ Uses the <SplitGridView> #resizer + scoped slot to render rich content inside each divider. Resizer + track is 28px wide (vs the 6–8px default), + and the slot receives before / after + references to the adjacent panel definitions — used here to label + each side. Drag still works because the slot content has + pointer-events: none; the wrapper's drag wiring + continues to own the actual resize gesture. +

+
+
+ +
+

8. Dynamic add/remove (Vue wrapper)

+

+ Backed by <SplitGridView> from + @madronejs/splitpanel/vue. The toolbar calls + addChild / removeChild on the component ref; + the #leaf scoped slot renders each panel's content + (including its own close button). Vue's reactivity flows through + <Teleport> into the leaf elements that SplitGrid + manages. +

+
+
+ +
+

9. Declarative tree (Vue components)

+

+ The entire layout is written as + <SplitPanel> / <SplitContainer> + tags in the template — no imperative addChild calls. + Adding / removing tabs is a plain + tabs.push(...) / filter(...); + v-if toggles the console and inspector. Each panel's + default slot is teleported into the SplitGrid-managed element, so + contents move with the panels. Most panels have no + panel-id — auto-generated from each component's uid. +

+
+
+ +
+

10. Drag-drop reorder

+

+ Pointer-event-based drag-drop via configureDraggable. + Grab a tile by its handle and drop on another tile — + the default onDrop calls swapData, so the + tile contents swap while the slots stay anchored. Reactive flags + panel.isDragging / panel.isDropTarget off + the slot scope drive the visual feedback. A ghost element (cloned + from the source) follows the cursor — anchor / custom renderer + configurable via ghostAnchor and ghostRender. +

+
+
+
+ + diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..a3f8b8b --- /dev/null +++ b/main.ts @@ -0,0 +1,176 @@ +import { createApp } from 'vue'; +import { SplitGrid, type SplitGridConfig } from './src'; +import DeclarativeExample from './demo/DeclarativeExample.vue'; +import DraggableExample from './demo/DraggableExample.vue'; +import DynamicExample from './demo/DynamicExample.vue'; +import VueResizerExample from './demo/VueResizerExample.vue'; +import './src/styles.css'; + +type Demo = { label: string }; +type DemoConfig = SplitGridConfig; + +/** Mount a SplitGrid into the element matched by `selector`. */ +function mountExample(selector: string, config: DemoConfig): SplitGrid { + const host = document.querySelector(selector); + + if (!host) throw new Error(`No element matches ${selector}`); + + const grid = new SplitGrid(config); + + grid.mount(host); + return grid; +} + +/** Shared leaf renderer — writes the panel's label into the cell. */ +const renderLeaf: DemoConfig['renderLeaf'] = ({ data, el, id }) => { + el.textContent = data?.label ?? id; + el.title = 'Double-click an adjacent divider to maximize this side'; +}; + +/** Sugar for declaring a leaf with a human-readable label. */ +const leaf = (id: string, opts: { size?: string; min?: string; max?: string; note?: string } = {}) => { + const parts = [opts.size && `size ${opts.size}`, opts.min && `min ${opts.min}`, opts.max && `max ${opts.max}`] + .filter(Boolean) + .join(', '); + const label = opts.note ?? `${id}${parts ? ` (${parts})` : ''}`; + const bounds = opts.size || opts.min || opts.max + ? { size: opts.size as any, min: opts.min as any, max: opts.max as any } + : undefined; + + return { id, data: { label }, bounds }; +}; + +// 1. IDE-style layout — the canonical example, debug on. +const ide = mountExample('#example-ide', { + debug: true, + renderLeaf, + root: { + id: 'ide-root', + direction: 'row', + resizer: { size: 8 }, + children: [ + leaf('sidebar', { size: '240px', min: '160px', max: '400px' }), + { + id: 'middle', + direction: 'column', + resizer: { size: 8 }, + children: [ + leaf('editor', { size: 'auto', min: '120px' }), + leaf('console', { size: '30%', min: '80px' }), + ], + }, + leaf('inspector', { size: '20%', min: '120px' }), + ], + }, +}); + +// 2. Four auto-fill panels with no constraints. +mountExample('#example-fourfr', { + renderLeaf, + root: { + id: 'four-root', + direction: 'row', + children: [leaf('A'), leaf('B'), leaf('C'), leaf('D')], + }, +}); + +// 3. Vertical layout — header / main / footer. +mountExample('#example-vertical', { + renderLeaf, + root: { + id: 'vert-root', + direction: 'column', + children: [ + leaf('toolbar', { size: '56px', min: '40px', max: '120px' }), + leaf('main'), + leaf('status', { size: '32px', min: '24px', max: '64px' }), + ], + }, +}); + +// 4. Deep nesting — row > column > row. +mountExample('#example-nested', { + renderLeaf, + root: { + id: 'nested-root', + direction: 'row', + children: [ + { + id: 'left-column', + direction: 'column', + bounds: { size: '280px', min: '180px' }, + children: [ + leaf('files', { min: '80px' }), + { + id: 'left-bottom-row', + direction: 'row', + bounds: { size: '40%', min: '120px' }, + children: [leaf('outline', { min: '60px' }), leaf('git', { min: '60px' })], + }, + ], + }, + leaf('canvas', { note: 'canvas (1fr)' }), + ], + }, +}); + +// 5. Custom resizer rendering. +mountExample('#example-custom-resizer', { + renderLeaf, + resizer: { + size: 16, + render: (el) => { + el.style.background = '#2d3748'; + el.style.display = 'flex'; + el.style.alignItems = 'center'; + el.style.justifyContent = 'center'; + el.style.color = '#fff'; + el.style.fontSize = '14px'; + // Replace the default `::before` grip dots — a real glyph reads better. + el.textContent = '⋮'; + }, + }, + root: { + id: 'custom-root', + direction: 'row', + children: [leaf('left'), leaf('right')], + }, +}); + +// 6. Mixed units in a single row. +mountExample('#example-mixed', { + renderLeaf, + root: { + id: 'mixed-root', + direction: 'row', + children: [ + leaf('fixed', { size: '200px', min: '120px' }), + leaf('flex-1', { note: 'auto (1fr)' }), + leaf('quarter', { size: '25%', min: '120px' }), + leaf('flex-2', { note: 'auto (1fr)' }), + ], + }, +}); + +// Surface the IDE grid for devtools poking. +(globalThis as unknown as { ide: SplitGrid }).ide = ide; + +// 7. Dynamic add/remove — uses the Vue wrapper to mount its own subtree. +const dynamicMount = document.querySelector('#example-dynamic'); + +if (dynamicMount) createApp(DynamicExample).mount(dynamicMount); + +// 8. Custom resizer via the Vue wrapper's `#resizer` scoped slot. +const resizerMount = document.querySelector('#example-vue-resizer'); + +if (resizerMount) createApp(VueResizerExample).mount(resizerMount); + +// 9. Declarative tree — / with v-if / v-for. +const declarativeMount = document.querySelector('#example-declarative'); + +if (declarativeMount) createApp(DeclarativeExample).mount(declarativeMount); + +// 10. Drag-drop reorder — configureDraggable, swapData on drop. +const draggableMount = document.querySelector('#example-draggable'); + +if (draggableMount) createApp(DraggableExample).mount(draggableMount); diff --git a/main.tsx b/main.tsx deleted file mode 100644 index e922092..0000000 --- a/main.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - createApp, defineComponent, reactive, computed, watch, toRaw, h, -} from 'vue'; -import Madrone, { MadroneVue3 } from '@madronejs/core'; -import { SplitPanel } from './src'; -// import SplitPanelView from './src/render/webComponent'; -import VueSplitPanelView from './src/render/vue3'; -import configureAnimate from './src/plugins/animate'; -import configureDraggable from './src/plugins/draggable'; - -import './src/style.scss'; -import './testStyle.scss'; - -Madrone.use(MadroneVue3({ - reactive, computed, watch, toRaw, -})); - -function createTestPanel() { - const panel = SplitPanel.create({ - id: 'foo', - resizeElSize: 20, - showFirstResizeEl: true, - children: [ - { id: 'foo1', data: 'data1' }, - { - id: 'foo2', - // direction: 'column', - // children: [ - // { id: 'foo5', data: 'data2' }, - // { id: 'foo6', data: 'data3' }, - // ], - }, - { id: 'foo7', constraints: { size: '10%' }, data: 'data4' }, - // { id: 'foo8', constraints: { size: '20%' } }, - { id: 'foo3' }, - { id: 'foo4' }, - // { id: 'foo5' }, - ], - }); - - panel.setAnimateStrategy(configureAnimate()); - panel.setDraggableStrategy(configureDraggable({ - onDrop: (evt) => { - evt.target.swapData(evt.panel); - }, - ghostAnchor: () => ({ x: 0.5, y: 0.5 }), - })); - - return panel; -} - -const vSplitPanel = createTestPanel(); - -(window as any).splitPanel = vSplitPanel; - -// vue app -const app = createApp(defineComponent({ - name: 'TestApp', - render: () => ( - - {{ - resize: (scope) => ( -
- {`${scope.panel.id}: ${scope.panel.sizeInfo.formatted}`} -
- ), - item: (scope) => ( -
- {`${scope.panel.id}: ${scope.panel.sizeInfo.formatted}`} -
handle
-
data: {scope.panel.data}
-
- ), - }} -
- ), -})); - -app.mount('#app'); - -// // web component -// SplitPanelView.register().then(() => { -// const el = document.querySelector('#webc') as SplitPanelView; - -// el.splitPanel = createTestPanel(); -// }); diff --git a/package.json b/package.json index 370c5b1..23980d8 100644 --- a/package.json +++ b/package.json @@ -1,43 +1,51 @@ { "name": "@madronejs/splitpanel", - "version": "0.1.0", + "version": "0.2.0", "private": false, "license": "MIT", "type": "module", "files": [ "dist/*.js", "dist/*.mjs", + "dist/*.cjs", "dist/*.css", "types/**/*.d.ts" ], "keywords": [ "splitpanel", "splitpane", + "split-grid", "panel", "drag", "draggable", "resizable", - "madrone", - "vue", - "web components" + "css-grid" ], "types": "types/index.d.ts", "exports": { ".": { "types": "./types/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": "./dist/splitpanel.js", + "require": "./dist/splitpanel.cjs" + }, + "./vue": { + "types": "./types/vue/index.d.ts", + "import": "./dist/vue.js", + "require": "./dist/vue.cjs" }, "./style": "./dist/splitpanel.css" }, "scripts": { "dev": "vite", "test": "vitest", + "test:node": "vitest --project node", + "test:browser": "vitest --project browser", "lint": "eslint src/ --ext .js,.jsx,.ts,.tsx", "build-dev": "NODE_ENV=development pnpm build-all", "build-types": "rm -rf types/* && vue-tsc -p ./tsconfig.json && tsc-alias -p tsconfig.json", "build": "rm -rf dist/* && vite build", "build-all": "pnpm build-types && pnpm build", + "watch": "vite build --watch", "preview": "vite preview", "all": "pnpm build-types && pnpm lint && CI=true pnpm test" }, @@ -46,16 +54,20 @@ "@eslint/eslintrc": "~3.3.5", "@eslint/js": "~10.0.1", "@stylistic/eslint-plugin": "~5.10.0", - "@types/animejs": "^3.1.13", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", "@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue-jsx": "^5.1.4", + "@vitest/browser": "^4.1.6", + "@vitest/browser-playwright": "^4.1.6", + "@vue/test-utils": "^2.4.10", "eslint": "~10.2.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "^4.8.0", "globals": "~17.5.0", - "sass": "^1.97.3", + "happy-dom": "^20.9.0", + "playwright": "^1.60.0", "tsc-alias": "^1.8.16", "typescript": "^6.0.2", "typescript-eslint": "8.58.1", @@ -63,16 +75,14 @@ "vitest": "^4.1.4", "vue-tsc": "^3.2.6" }, - "dependencies": { - "drag-drop-touch": "^1.3.1", - "lodash": "^4.18.1", - "uuid": "^13.0.0" - }, "peerDependencies": { - "@madronejs/core": "^2.0.0", - "animejs": "^4.0.2", "vue": "^3.5.12" }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + }, "engines": { "node": "^22.14 || ^24.14", "pnpm": ">= 10" @@ -84,6 +94,14 @@ "mimimatch": ">9.0.7", "rollup": "4.59.0", "semver": ">=7.7.3" - } + }, + "ignoredOptionalDependencies": [ + "less", + "lightningcss", + "sass", + "sass-embedded", + "stylus", + "sugarss" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddafe87..69f87ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,85 +13,82 @@ importers: .: dependencies: - '@madronejs/core': - specifier: ^2.0.0 - version: 2.0.0(vue@3.5.13(typescript@6.0.2)) - animejs: - specifier: ^4.0.2 - version: 4.0.2 - drag-drop-touch: - specifier: ^1.3.1 - version: 1.3.1 - lodash: - specifier: ^4.18.1 - version: 4.18.1 - uuid: - specifier: ^13.0.0 - version: 13.0.0 vue: specifier: ^3.5.12 - version: 3.5.13(typescript@6.0.2) + version: 3.5.34(typescript@6.0.3) devDependencies: '@eslint/compat': specifier: ~2.0.5 - version: 2.0.5(eslint@10.2.0) + version: 2.0.5(eslint@10.2.1) '@eslint/eslintrc': specifier: ~3.3.5 version: 3.3.5 '@eslint/js': specifier: ~10.0.1 - version: 10.0.1(eslint@10.2.0) + version: 10.0.1(eslint@10.2.1) '@stylistic/eslint-plugin': specifier: ~5.10.0 - version: 5.10.0(eslint@10.2.0) - '@types/animejs': - specifier: ^3.1.13 - version: 3.1.13 + version: 5.10.0(eslint@10.2.1) '@typescript-eslint/eslint-plugin': specifier: ^8.58.1 - version: 8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2) + version: 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.58.1 - version: 8.58.1(eslint@10.2.0)(typescript@6.0.2) + version: 8.59.3(eslint@10.2.1)(typescript@6.0.3) '@vitejs/plugin-vue': specifier: ^6.0.4 - version: 6.0.4(vite@7.3.1(sass@1.97.3))(vue@3.5.13(typescript@6.0.2)) + version: 6.0.6(vite@7.3.3(@types/node@25.6.2))(vue@3.5.34(typescript@6.0.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(sass@1.97.3))(vue@3.5.13(typescript@6.0.2)) + version: 5.1.5(vite@7.3.3(@types/node@25.6.2))(vue@3.5.34(typescript@6.0.3)) + '@vitest/browser': + specifier: ^4.1.6 + version: 4.1.6(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6) + '@vitest/browser-playwright': + specifier: ^4.1.6 + version: 4.1.6(playwright@1.60.0)(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6) + '@vue/test-utils': + specifier: ^2.4.10 + version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) eslint: specifier: ~10.2.0 - version: 10.2.0 + version: 10.2.1 eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0) + version: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1) eslint-plugin-unicorn: specifier: ^64.0.0 - version: 64.0.0(eslint@10.2.0) + version: 64.0.0(eslint@10.2.1) + fast-check: + specifier: ^4.8.0 + version: 4.8.0 globals: specifier: ~17.5.0 version: 17.5.0 - sass: - specifier: ^1.97.3 - version: 1.97.3 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + playwright: + specifier: ^1.60.0 + version: 1.60.0 tsc-alias: specifier: ^1.8.16 - version: 1.8.16 + version: 1.8.17 typescript: specifier: ^6.0.2 - version: 6.0.2 + version: 6.0.3 typescript-eslint: specifier: 8.58.1 - version: 8.58.1(eslint@10.2.0)(typescript@6.0.2) + version: 8.58.1(eslint@10.2.1)(typescript@6.0.3) vite: specifier: ^7.3.1 - version: 7.3.1(sass@1.97.3) + version: 7.3.3(@types/node@25.6.2) vitest: specifier: ^4.1.4 - version: 4.1.4(vite@7.3.1(sass@1.97.3)) + version: 4.1.6(@types/node@25.6.2)(@vitest/browser-playwright@4.1.6)(happy-dom@20.9.0)(vite@7.3.3(@types/node@25.6.2)) vue-tsc: specifier: ^3.2.6 - version: 3.2.6(typescript@6.0.2) + version: 3.2.8(typescript@6.0.3) packages: @@ -99,8 +96,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -119,8 +116,8 @@ packages: resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -173,17 +170,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -217,158 +209,161 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -425,12 +420,16 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -441,6 +440,10 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -457,15 +460,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@madronejs/core@2.0.0': - resolution: {integrity: sha512-ubLSa1m+So+J3OjmA59HhpWhWyeTDEEqE0SKhVAISFOYjanPizuuWyTK1Wfq5Bj4PbV8r4SnqeVKLpcv9oFlYw==} - engines: {node: ^22.14 || ^24.14, pnpm: '>=10'} - peerDependencies: - vue: ^3.0.0 - peerDependenciesMeta: - vue: - optional: true - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -478,99 +472,21 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@rolldown/pluginutils@1.0.0-rc.2': - resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} - '@rolldown/pluginutils@1.0.0-rc.6': - resolution: {integrity: sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA==} + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} @@ -722,9 +638,6 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 - '@types/animejs@3.1.13': - resolution: {integrity: sha512-yWg9l1z7CAv/TKpty4/vupEh24jDGUZXv4r26StRkpUPQm04ztJaftgpto8vwdFs8SiTq6XfaPKCSI+wjzNMvQ==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -737,12 +650,24 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -751,6 +676,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.59.3': + resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.3 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.58.1': resolution: {integrity: sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -758,22 +691,45 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.59.3': + resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.58.1': resolution: {integrity: sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.58.1': resolution: {integrity: sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.58.1': resolution: {integrity: sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.58.1': resolution: {integrity: sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -781,16 +737,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.59.3': + resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.58.1': resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.1': resolution: {integrity: sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.58.1': resolution: {integrity: sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -798,29 +771,51 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.58.1': resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-vue-jsx@5.1.4': - resolution: {integrity: sha512-70LmoVk9riR7qc4W2CpjsbNMWTPnuZb9dpFKX1emru0yP57nsc9k8nhLA6U93ngQapv5VDIUq2JatNfLbBIkrA==} + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue-jsx@5.1.5': + resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.0.0 - '@vitejs/plugin-vue@6.0.4': - resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/expect@4.1.4': - resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + '@vitest/browser-playwright@4.1.6': + resolution: {integrity: sha512-4csoeyl/qwHyxU2zNL0++WaoDr8YJDXOQPwWPNJoTZ+QzcdO3INYKgF5Zfz730Io7zbkuv914aZmfQ+QE+1Hvw==} + peerDependencies: + playwright: '*' + vitest: 4.1.6 - '@vitest/mocker@4.1.4': - resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + '@vitest/browser@4.1.6': + resolution: {integrity: sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==} + peerDependencies: + vitest: 4.1.6 + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -830,20 +825,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.4': - resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} - '@vitest/runner@4.1.4': - resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} - '@vitest/snapshot@4.1.4': - resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} - '@vitest/spy@4.1.4': - resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} - '@vitest/utils@4.1.4': - resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -870,61 +865,51 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@vue/compiler-core@3.5.13': - resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} - - '@vue/compiler-core@3.5.29': - resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} - - '@vue/compiler-core@3.5.32': - resolution: {integrity: sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==} + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} - '@vue/compiler-dom@3.5.13': - resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==} + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} - '@vue/compiler-dom@3.5.29': - resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} - '@vue/compiler-dom@3.5.32': - resolution: {integrity: sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==} + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} - '@vue/compiler-sfc@3.5.13': - resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==} + '@vue/language-core@3.2.8': + resolution: {integrity: sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==} - '@vue/compiler-sfc@3.5.29': - resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} - '@vue/compiler-ssr@3.5.13': - resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==} + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} - '@vue/compiler-ssr@3.5.29': - resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} - '@vue/language-core@3.2.6': - resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==} - - '@vue/reactivity@3.5.13': - resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} - - '@vue/runtime-core@3.5.13': - resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==} - - '@vue/runtime-dom@3.5.13': - resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==} - - '@vue/server-renderer@3.5.13': - resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==} + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} peerDependencies: - vue: 3.5.13 + vue: 3.5.34 - '@vue/shared@3.5.13': - resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} - '@vue/shared@3.5.29': - resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + '@vue/test-utils@2.4.10': + resolution: {integrity: sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true - '@vue/shared@3.5.32': - resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -936,14 +921,27 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} - animejs@4.0.2: - resolution: {integrity: sha512-f0L/kSya2RF23iMSF/VO01pMmLwlAFoiQeNAvBXhEyLzIPd2/QTBRatwGUqkVCC6seaAJYzAkGir55N4SL+h3A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -999,13 +997,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true - - baseline-browser-mapping@2.10.18: - resolution: {integrity: sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1016,34 +1009,32 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - builtin-modules@5.1.0: - resolution: {integrity: sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==} + builtin-modules@5.2.0: + resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} engines: {node: '>=18.20'} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -1054,11 +1045,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001776: - resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} - - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1071,10 +1059,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1083,6 +1067,17 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -1090,6 +1085,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1100,8 +1098,8 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} @@ -1143,10 +1141,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1155,29 +1149,33 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - drag-drop-touch@1.3.1: - resolution: {integrity: sha512-Q0/ZgsnW7VUjn+YqSnp1rvxjjPnZX5YLyVaw28einood+eTMcLzgOgHk8nyqIF9O18J68l+2htlEnbw5GsyTvQ==} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.307: - resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true - electron-to-chromium@1.5.335: - resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -1188,8 +1186,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1207,8 +1205,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -1224,8 +1222,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} eslint-module-utils@2.12.1: resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} @@ -1280,8 +1278,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.2.0: - resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1324,6 +1322,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1337,8 +1339,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1376,6 +1378,15 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1391,6 +1402,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1407,8 +1422,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.10.0: - resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -1418,6 +1433,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1438,6 +1458,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1457,8 +1481,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} ignore@5.3.2: @@ -1469,9 +1493,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@5.1.5: - resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1484,6 +1505,9 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1516,8 +1540,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -1536,8 +1560,12 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} is-glob@4.0.3: @@ -1602,6 +1630,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1643,8 +1683,8 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1668,41 +1708,53 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - mylas@2.1.13: - resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} - engines: {node: '>=12.0.0'} + mylas@2.1.14: + resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} + engines: {node: '>=16.0.0'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -1720,6 +1772,10 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + object.fromentries@2.0.8: resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} @@ -1751,6 +1807,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1769,6 +1828,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1783,14 +1846,20 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -1799,22 +1868,32 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + queue-lit@1.5.2: resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} engines: {node: '>=12'} @@ -1826,10 +1905,6 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1853,8 +1928,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} engines: {node: '>= 0.4'} hasBin: true @@ -1870,8 +1945,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -1882,18 +1957,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sass@1.97.3: - resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} - engines: {node: '>=14.0.0'} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} hasBin: true @@ -1917,8 +1982,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -1936,6 +2001,14 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1947,13 +2020,21 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -1966,6 +2047,14 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -1985,14 +2074,10 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2005,14 +2090,18 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' - tsc-alias@1.8.16: - resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} + tsc-alias@1.8.17: + resolution: {integrity: sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==} engines: {node: '>=16.20.2'} hasBin: true @@ -2046,8 +2135,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -2055,6 +2144,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2064,12 +2156,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uuid@13.0.0: - resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} - hasBin: true - - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2108,20 +2196,20 @@ packages: yaml: optional: true - vitest@4.1.4: - resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.4 - '@vitest/browser-preview': 4.1.4 - '@vitest/browser-webdriverio': 4.1.4 - '@vitest/coverage-istanbul': 4.1.4 - '@vitest/coverage-v8': 4.1.4 - '@vitest/ui': 4.1.4 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2152,20 +2240,27 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - vue-tsc@3.2.6: - resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} + vue-component-type-helpers@3.2.8: + resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} + + vue-tsc@3.2.8: + resolution: {integrity: sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==} hasBin: true peerDependencies: typescript: '>=5.0.0' - vue@3.5.13: - resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2178,8 +2273,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: @@ -2196,6 +2291,26 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2203,6 +2318,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} +ignoredOptionalDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + snapshots: '@babel/code-frame@7.29.0': @@ -2211,7 +2334,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -2219,8 +2342,8 @@ snapshots: '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -2229,13 +2352,13 @@ snapshots: debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 - semver: 7.7.3 + semver: 7.8.0 transitivePeerDependencies: - supports-color '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -2247,13 +2370,13 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 - semver: 7.7.3 + semver: 7.8.0 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 @@ -2262,7 +2385,7 @@ snapshots: '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -2319,16 +2442,12 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -2346,7 +2465,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) @@ -2356,7 +2475,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -2364,7 +2483,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -2376,96 +2495,98 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@esbuild/aix-ppc64@0.27.3': + '@blazediff/core@1.9.1': {} + + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': dependencies: - eslint: 10.2.0 + eslint: 10.2.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@2.0.5(eslint@10.2.0)': + '@eslint/compat@2.0.5(eslint@10.2.1)': dependencies: '@eslint/core': 1.2.1 optionalDependencies: - eslint: 10.2.0 + eslint: 10.2.1 '@eslint/config-array@0.23.5': dependencies: @@ -2485,7 +2606,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 @@ -2497,9 +2618,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@10.2.0)': + '@eslint/js@10.0.1(eslint@10.2.1)': optionalDependencies: - eslint: 10.2.0 + eslint: 10.2.1 '@eslint/object-schema@3.0.5': {} @@ -2508,17 +2629,31 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2538,10 +2673,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@madronejs/core@2.0.0(vue@3.5.13(typescript@6.0.2))': - optionalDependencies: - vue: 3.5.13(typescript@6.0.2) - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2552,72 +2683,18 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-x64@2.5.6': - optional: true + fastq: 1.20.1 - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - optional: true + '@one-ini/wasm@0.1.1': {} - '@parcel/watcher-linux-arm64-musl@2.5.6': + '@pkgjs/parseargs@0.11.0': optional: true - '@parcel/watcher-linux-x64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': - optional: true - - '@parcel/watcher-win32-arm64@2.5.6': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.4 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 - optional: true + '@polka/url@1.0.0-next.29': {} - '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rolldown/pluginutils@1.0.0': {} - '@rolldown/pluginutils@1.0.0-rc.6': {} + '@rolldown/pluginutils@1.0.0-rc.13': {} '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -2698,18 +2775,16 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@10.2.0)': + '@stylistic/eslint-plugin@5.10.0(eslint@10.2.1)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) - '@typescript-eslint/types': 8.58.1 - eslint: 10.2.0 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@typescript-eslint/types': 8.59.3 + eslint: 10.2.1 eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 picomatch: 4.0.4 - '@types/animejs@3.1.13': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2721,44 +2796,93 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} - '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2)': + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.2 + + '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.1(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.1(eslint@10.2.1)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.1 - '@typescript-eslint/type-utils': 8.58.1(eslint@10.2.0)(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.1(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.1(eslint@10.2.1)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.1 - eslint: 10.2.0 + eslint: 10.2.1 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.3(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/type-utils': 8.59.3(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.3(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.3 + eslint: 10.2.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.1(eslint@10.2.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.1 '@typescript-eslint/types': 8.58.1 - '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.1 debug: 4.4.3 - eslint: 10.2.0 - typescript: 6.0.2 + eslint: 10.2.1 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.1(typescript@6.0.2)': + '@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + eslint: 10.2.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@6.0.3) '@typescript-eslint/types': 8.58.1 debug: 4.4.3 - typescript: 6.0.2 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.3(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3) + '@typescript-eslint/types': 8.59.3 + debug: 4.4.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2767,47 +2891,96 @@ snapshots: '@typescript-eslint/types': 8.58.1 '@typescript-eslint/visitor-keys': 8.58.1 - '@typescript-eslint/tsconfig-utils@8.58.1(typescript@6.0.2)': + '@typescript-eslint/scope-manager@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + + '@typescript-eslint/tsconfig-utils@8.58.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@6.0.3)': dependencies: - typescript: 6.0.2 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.58.1(eslint@10.2.0)(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.58.1(eslint@10.2.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.58.1 - '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.1(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.1(eslint@10.2.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.2.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.3(eslint@10.2.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.3(eslint@10.2.1)(typescript@6.0.3) debug: 4.4.3 - eslint: 10.2.0 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + eslint: 10.2.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.58.1': {} - '@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.2)': + '@typescript-eslint/types@8.59.3': {} + + '@typescript-eslint/typescript-estree@8.58.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.58.1(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@6.0.2) + '@typescript-eslint/project-service': 8.58.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@6.0.3) '@typescript-eslint/types': 8.58.1 '@typescript-eslint/visitor-keys': 8.58.1 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.1(eslint@10.2.0)(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@typescript-eslint/project-service': 8.59.3(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.1(eslint@10.2.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) '@typescript-eslint/scope-manager': 8.58.1 '@typescript-eslint/types': 8.58.1 - '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.2) - eslint: 10.2.0 - typescript: 6.0.2 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.3) + eslint: 10.2.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.3(eslint@10.2.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3) + eslint: 10.2.1 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2816,62 +2989,97 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-vue-jsx@5.1.4(vite@7.3.1(sass@1.97.3))(vue@3.5.13(typescript@6.0.2))': + '@typescript-eslint/visitor-keys@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@25.6.2))(vue@3.5.34(typescript@6.0.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.6 + '@rolldown/pluginutils': 1.0.0 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) - vite: 7.3.1(sass@1.97.3) - vue: 3.5.13(typescript@6.0.2) + vite: 7.3.3(@types/node@25.6.2) + vue: 3.5.34(typescript@6.0.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.4(vite@7.3.1(sass@1.97.3))(vue@3.5.13(typescript@6.0.2))': + '@vitejs/plugin-vue@6.0.6(vite@7.3.3(@types/node@25.6.2))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 7.3.3(@types/node@25.6.2) + vue: 3.5.34(typescript@6.0.3) + + '@vitest/browser-playwright@4.1.6(playwright@1.60.0)(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6)': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(sass@1.97.3) - vue: 3.5.13(typescript@6.0.2) + '@vitest/browser': 4.1.6(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@25.6.2)) + playwright: 1.60.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.6.2)(@vitest/browser-playwright@4.1.6)(happy-dom@20.9.0)(vite@7.3.3(@types/node@25.6.2)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.6(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@25.6.2)) + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.6.2)(@vitest/browser-playwright@4.1.6)(happy-dom@20.9.0)(vite@7.3.3(@types/node@25.6.2)) + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite - '@vitest/expect@4.1.4': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@7.3.1(sass@1.97.3))': + '@vitest/mocker@4.1.6(vite@7.3.3(@types/node@25.6.2))': dependencies: - '@vitest/spy': 4.1.4 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(sass@1.97.3) + vite: 7.3.3(@types/node@25.6.2) - '@vitest/pretty-format@4.1.4': + '@vitest/pretty-format@4.1.6': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.4': + '@vitest/runner@4.1.6': dependencies: - '@vitest/utils': 4.1.4 + '@vitest/utils': 4.1.6 pathe: 2.0.3 - '@vitest/snapshot@4.1.4': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.4 - '@vitest/utils': 4.1.4 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.4': {} + '@vitest/spy@4.1.6': {} - '@vitest/utils@4.1.4': + '@vitest/utils@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.4 + '@vitest/pretty-format': 4.1.6 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -2899,7 +3107,7 @@ snapshots: '@babel/types': 7.29.0 '@vue/babel-helper-vue-transform-on': 2.0.1 '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0) - '@vue/shared': 3.5.29 + '@vue/shared': 3.5.34 optionalDependencies: '@babel/core': 7.29.0 transitivePeerDependencies: @@ -2911,121 +3119,85 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/parser': 7.29.0 - '@vue/compiler-sfc': 3.5.29 + '@babel/parser': 7.29.3 + '@vue/compiler-sfc': 3.5.34 transitivePeerDependencies: - supports-color - '@vue/compiler-core@3.5.13': - dependencies: - '@babel/parser': 7.29.2 - '@vue/shared': 3.5.13 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-core@3.5.29': + '@vue/compiler-core@3.5.34': dependencies: - '@babel/parser': 7.29.0 - '@vue/shared': 3.5.29 + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-core@3.5.32': + '@vue/compiler-dom@3.5.34': dependencies: - '@babel/parser': 7.29.2 - '@vue/shared': 3.5.32 - entities: 7.0.1 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-dom@3.5.13': - dependencies: - '@vue/compiler-core': 3.5.13 - '@vue/shared': 3.5.13 + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 - '@vue/compiler-dom@3.5.29': + '@vue/compiler-sfc@3.5.34': dependencies: - '@vue/compiler-core': 3.5.29 - '@vue/shared': 3.5.29 - - '@vue/compiler-dom@3.5.32': - dependencies: - '@vue/compiler-core': 3.5.32 - '@vue/shared': 3.5.32 - - '@vue/compiler-sfc@3.5.13': - dependencies: - '@babel/parser': 7.29.2 - '@vue/compiler-core': 3.5.13 - '@vue/compiler-dom': 3.5.13 - '@vue/compiler-ssr': 3.5.13 - '@vue/shared': 3.5.13 + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.8 + postcss: 8.5.14 source-map-js: 1.2.1 - '@vue/compiler-sfc@3.5.29': - dependencies: - '@babel/parser': 7.29.0 - '@vue/compiler-core': 3.5.29 - '@vue/compiler-dom': 3.5.29 - '@vue/compiler-ssr': 3.5.29 - '@vue/shared': 3.5.29 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.8 - source-map-js: 1.2.1 - - '@vue/compiler-ssr@3.5.13': - dependencies: - '@vue/compiler-dom': 3.5.13 - '@vue/shared': 3.5.13 - - '@vue/compiler-ssr@3.5.29': + '@vue/compiler-ssr@3.5.34': dependencies: - '@vue/compiler-dom': 3.5.29 - '@vue/shared': 3.5.29 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 - '@vue/language-core@3.2.6': + '@vue/language-core@3.2.8': dependencies: '@volar/language-core': 2.4.28 - '@vue/compiler-dom': 3.5.32 - '@vue/shared': 3.5.32 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 alien-signals: 3.1.2 muggle-string: 0.4.1 path-browserify: 1.0.1 picomatch: 4.0.4 - '@vue/reactivity@3.5.13': + '@vue/reactivity@3.5.34': dependencies: - '@vue/shared': 3.5.13 + '@vue/shared': 3.5.34 - '@vue/runtime-core@3.5.13': + '@vue/runtime-core@3.5.34': dependencies: - '@vue/reactivity': 3.5.13 - '@vue/shared': 3.5.13 + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 - '@vue/runtime-dom@3.5.13': + '@vue/runtime-dom@3.5.34': dependencies: - '@vue/reactivity': 3.5.13 - '@vue/runtime-core': 3.5.13 - '@vue/shared': 3.5.13 - csstype: 3.1.3 + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 - '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@6.0.2))': + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': dependencies: - '@vue/compiler-ssr': 3.5.13 - '@vue/shared': 3.5.13 - vue: 3.5.13(typescript@6.0.2) + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@6.0.3) - '@vue/shared@3.5.13': {} + '@vue/shared@3.5.34': {} - '@vue/shared@3.5.29': {} + '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.34 + js-beautify: 1.15.4 + vue: 3.5.34(typescript@6.0.3) + vue-component-type-helpers: 3.2.8 + optionalDependencies: + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) - '@vue/shared@3.5.32': {} + abbrev@2.0.0: {} acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -3033,7 +3205,7 @@ snapshots: acorn@8.16.0: {} - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -3042,7 +3214,15 @@ snapshots: alien-signals@3.1.2: {} - animejs@4.0.2: {} + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} anymatch@3.1.3: dependencies: @@ -3058,10 +3238,10 @@ snapshots: array-includes@3.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 @@ -3071,34 +3251,34 @@ snapshots: array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -3115,9 +3295,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.0: {} - - baseline-browser-mapping@2.10.18: {} + baseline-browser-mapping@2.10.29: {} binary-extensions@2.3.0: {} @@ -3126,7 +3304,11 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.5: + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3134,30 +3316,22 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001776 - electron-to-chromium: 1.5.307 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.18 - caniuse-lite: 1.0.30001787 - electron-to-chromium: 1.5.335 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - builtin-modules@5.1.0: {} + builtin-modules@5.2.0: {} call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -3171,9 +3345,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001776: {} - - caniuse-lite@1.0.30001787: {} + caniuse-lite@1.0.30001792: {} chai@6.2.2: {} @@ -3191,20 +3363,29 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - ci-info@4.4.0: {} clean-regexp@1.0.0: dependencies: escape-string-regexp: 1.0.5 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + commander@9.5.0: {} concat-map@0.0.1: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + convert-source-map@2.0.0: {} core-js-compat@3.49.0: @@ -3217,7 +3398,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csstype@3.1.3: {} + csstype@3.2.3: {} data-view-buffer@1.0.2: dependencies: @@ -3259,9 +3440,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - detect-libc@2.1.2: - optional: true - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3270,28 +3448,35 @@ snapshots: dependencies: esutils: 2.0.3 - drag-drop-touch@1.3.1: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.307: {} + eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.335: {} + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.0 - entities@4.5.0: {} + electron-to-chromium@1.5.353: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} entities@7.0.1: {} - es-abstract@1.24.0: + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 @@ -3310,7 +3495,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -3328,7 +3513,7 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 @@ -3341,13 +3526,13 @@ snapshots: typed-array-byte-offset: 1.0.4 typed-array-length: 1.0.7 unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-module-lexer@2.0.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -3358,11 +3543,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-to-primitive@1.3.0: dependencies: @@ -3370,34 +3555,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.3: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -3405,25 +3590,25 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.10 + is-core-module: 2.16.2 + resolve: 2.0.0-next.6 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@10.2.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.2.1): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.58.1(eslint@10.2.0)(typescript@6.0.2) - eslint: 10.2.0 - eslint-import-resolver-node: 0.3.9 + '@typescript-eslint/parser': 8.59.3(eslint@10.2.1)(typescript@6.0.3) + eslint: 10.2.1 + eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -3432,35 +3617,35 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 10.2.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@10.2.0) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint: 10.2.1 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@10.2.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.2.1) + hasown: 2.0.3 + is-core-module: 2.16.2 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 - semver: 7.7.3 + semver: 7.8.0 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.1(eslint@10.2.0)(typescript@6.0.2) + '@typescript-eslint/parser': 8.59.3(eslint@10.2.1)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-unicorn@64.0.0(eslint@10.2.0): + eslint-plugin-unicorn@64.0.0(eslint@10.2.1): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.49.0 - eslint: 10.2.0 + eslint: 10.2.1 find-up-simple: 1.0.1 globals: 17.5.0 indent-string: 5.0.0 @@ -3469,13 +3654,13 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.1 - semver: 7.7.4 + semver: 7.8.0 strip-indent: 4.1.1 eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esrecurse: 4.3.0 estraverse: 5.3.0 @@ -3485,19 +3670,19 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.0: + eslint@10.2.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 @@ -3546,12 +3731,16 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} expect-type@1.3.0: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -3566,14 +3755,10 @@ snapshots: fast-levenshtein@2.0.6: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -3604,6 +3789,14 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3611,15 +3804,17 @@ snapshots: function.prototype.name@1.1.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functions-have-names@1.2.3: {} + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} get-intrinsic@1.3.0: @@ -3632,7 +3827,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -3646,7 +3841,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.10.0: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -3658,6 +3853,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@17.5.0: {} @@ -3678,6 +3882,18 @@ snapshots: gopd@1.2.0: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-property-descriptors@1.0.2: @@ -3694,7 +3910,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -3702,8 +3918,6 @@ snapshots: ignore@7.0.5: {} - immutable@5.1.5: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3713,15 +3927,17 @@ snapshots: indent-string@5.0.0: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -3748,13 +3964,13 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.1.0 + builtin-modules: 5.2.0 is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.16.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-data-view@1.0.2: dependencies: @@ -3773,9 +3989,12 @@ snapshots: dependencies: call-bound: 1.0.4 - is-generator-function@1.1.0: + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 + generator-function: 2.0.1 get-proto: 1.0.1 has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 @@ -3800,7 +4019,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-set@2.0.3: {} @@ -3821,7 +4040,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 is-weakmap@2.0.2: {} @@ -3838,6 +4057,22 @@ snapshots: isexe@2.0.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -3871,7 +4106,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash@4.18.1: {} + lru-cache@10.4.3: {} lru-cache@5.1.1: dependencies: @@ -3892,34 +4127,44 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: brace-expansion: 1.1.14 - minimatch@3.1.5: + minimatch@9.0.9: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 2.1.0 minimist@1.2.8: {} + minipass@7.1.3: {} + + mrmime@2.0.1: {} + ms@2.1.3: {} muggle-string@0.4.1: {} - mylas@2.1.13: {} + mylas@2.1.14: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} - node-addon-api@7.1.1: - optional: true + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 7.8.0 - node-releases@2.0.36: {} + node-releases@2.0.38: {} - node-releases@2.0.37: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 normalize-path@3.0.0: {} @@ -3929,29 +4174,36 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 has-symbols: 1.1.0 object-keys: 1.1.1 + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -3981,6 +4233,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3993,6 +4247,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -4001,28 +4260,40 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.3: {} - picomatch@4.0.4: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 pluralize@8.0.0: {} + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} - postcss@8.5.8: + postcss@8.5.14: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 prelude-ls@1.2.1: {} + proto-list@1.2.4: {} + punycode@2.3.1: {} + pure-rand@8.4.0: {} + queue-lit@1.5.2: {} queue-microtask@1.2.3: {} @@ -4031,13 +4302,11 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@4.1.2: {} - reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -4048,7 +4317,7 @@ snapshots: regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -4063,9 +4332,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.10: + resolve@2.0.0-next.6: dependencies: - is-core-module: 2.16.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -4106,9 +4378,9 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -4125,17 +4397,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sass@1.97.3: - dependencies: - chokidar: 4.0.3 - immutable: 5.1.5 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.5.6 - - semver@7.7.3: {} - - semver@7.7.4: {} + semver@7.8.0: {} set-function-length@1.2.2: dependencies: @@ -4165,7 +4427,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -4189,48 +4451,76 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 siginfo@2.0.0: {} + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} source-map-js@1.2.1: {} stackback@0.0.2: {} - std-env@4.0.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 string.prototype.trimend@1.0.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-indent@4.1.1: {} @@ -4241,12 +4531,7 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.1.1: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + tinyexec@1.1.2: {} tinyglobby@0.2.16: dependencies: @@ -4259,17 +4544,19 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@2.5.0(typescript@6.0.2): + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 6.0.2 + typescript: 6.0.3 - tsc-alias@1.8.16: + tsc-alias@1.8.17: dependencies: chokidar: 3.6.0 commander: 9.5.0 - get-tsconfig: 4.10.0 + get-tsconfig: 4.14.0 globby: 11.1.0 - mylas: 2.1.13 + mylas: 2.1.14 normalize-path: 3.0.0 plimit-lit: 1.6.1 @@ -4292,7 +4579,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -4301,7 +4588,7 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -4310,25 +4597,25 @@ snapshots: typed-array-length@1.0.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.58.1(eslint@10.2.0)(typescript@6.0.2): + typescript-eslint@8.58.1(eslint@10.2.1)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0)(typescript@6.0.2))(eslint@10.2.0)(typescript@6.0.2) - '@typescript-eslint/parser': 8.58.1(eslint@10.2.0)(typescript@6.0.2) - '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.1(eslint@10.2.0)(typescript@6.0.2) - eslint: 10.2.0 - typescript: 6.0.2 + '@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.1)(typescript@6.0.3))(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.58.1(eslint@10.2.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.58.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.1(eslint@10.2.1)(typescript@6.0.3) + eslint: 10.2.1 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript@6.0.2: {} + typescript@6.0.3: {} unbox-primitive@1.1.0: dependencies: @@ -4337,11 +4624,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 + undici-types@7.19.2: {} update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -4353,62 +4636,68 @@ snapshots: dependencies: punycode: 2.3.1 - uuid@13.0.0: {} - - vite@7.3.1(sass@1.97.3): + vite@7.3.3(@types/node@25.6.2): dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 rollup: 4.59.0 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 optionalDependencies: + '@types/node': 25.6.2 fsevents: 2.3.3 - sass: 1.97.3 - - vitest@4.1.4(vite@7.3.1(sass@1.97.3)): - dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1(sass@1.97.3)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 + + vitest@4.1.6(@types/node@25.6.2)(@vitest/browser-playwright@4.1.6)(happy-dom@20.9.0)(vite@7.3.3(@types/node@25.6.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@25.6.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 4.0.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.1 + tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(sass@1.97.3) + vite: 7.3.3(@types/node@25.6.2) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@7.3.3(@types/node@25.6.2))(vitest@4.1.6) + happy-dom: 20.9.0 transitivePeerDependencies: - msw vscode-uri@3.1.0: {} - vue-tsc@3.2.6(typescript@6.0.2): + vue-component-type-helpers@3.2.8: {} + + vue-tsc@3.2.8(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.2.6 - typescript: 6.0.2 + '@vue/language-core': 3.2.8 + typescript: 6.0.3 - vue@3.5.13(typescript@6.0.2): + vue@3.5.34(typescript@6.0.3): dependencies: - '@vue/compiler-dom': 3.5.13 - '@vue/compiler-sfc': 3.5.13 - '@vue/runtime-dom': 3.5.13 - '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@6.0.2)) - '@vue/shared': 3.5.13 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + '@vue/shared': 3.5.34 optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 + + whatwg-mimetype@3.0.0: {} which-boxed-primitive@1.1.1: dependencies: @@ -4426,13 +4715,13 @@ snapshots: is-async-function: 2.1.1 is-date-object: 1.1.0 is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.0 + is-generator-function: 1.1.2 is-regex: 1.2.1 is-weakref: 1.1.1 isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 which-collection@1.0.2: dependencies: @@ -4441,10 +4730,10 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.19: + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -4462,6 +4751,20 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + ws@8.20.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/SplitGrid.ts b/src/SplitGrid.ts new file mode 100644 index 0000000..e370a3d --- /dev/null +++ b/src/SplitGrid.ts @@ -0,0 +1,2457 @@ +/** + * SplitGrid — vanilla-DOM runtime for a CSS Grid-based split panel. + * + * Construction walks the node tree and builds a DOM mirror: each container is + * a `
` with `display: grid`, each leaf is a + * `
`, and dividers are `
` + * elements interleaved between children. All sizing is driven by a single + * inline `--sp-tracks` per container; the browser does the layout math and + * the bounds-clamping via `clamp()` in the track string. + * + * Drag math lives in ./drag.ts, track-string generation in ./track.ts. This + * file is wiring: event handlers, snapshot management, and DOM construction. + */ + +import type { + Bounds, + Container, + Leaf, + Length, + LengthInput, + Node, + PanelDirectionInput, + ResizerSpec, +} from './types'; +import { isContainer, PanelDirection } from './types'; +import { + asContainerAxis, asPctBudget, parseLength, pxToPct, sizesFromPx, toPx, + type ContainerAxisPx, type PctBudgetPx, type StorageUnit, +} from './length'; +import { measureAxis, measureChildrenPx } from './geometry'; +import { distributeProportional } from './distribute'; +import { trackString } from './track'; +import { PointerDragState } from './pointerDrag'; + +export interface SplitGridConfig { + /** Root container definition. */ + root: Container, + /** Render hook called once per leaf when its element is created. */ + renderLeaf?: (ctx: LeafCtx) => void, + /** Default resizer spec, applied to any container that doesn't override. */ + resizer?: ResizerSpec, + /** Animation duration in ms for programmatic size changes. Defaults to 750. */ + animationMs?: number, + /** + * Fires after every successful layout mutation. Use it to persist state + * (localStorage, URL, etc.) or sync to external systems. Drag fires this + * once per accepted mousemove (typically 60–120 Hz); debounce on the + * consumer side if your handler is expensive. + */ + onChange?: (event: LayoutChangeEvent) => void, + /** + * Emit console diagnostics: each writeTracks (container id, tracks, rect), + * each drag start/move (sizes, availPx, totalAxisPx), and dblclick targets. + * Off by default; turn on while iterating on layout edge cases. + */ + debug?: boolean, +} + +/** + * Reason codes accompanying every `onChange` event. + * + * `maximize` / `minimize` are distinct from `set-size` so consumers + * persisting layout state can tell "user dragged to 100%" (set-size) + * from "user clicked the maximize affordance" (maximize). Both paths + * write the same physical px through `applySize`; only the reason + * differs. + */ +export type LayoutChangeReason = | 'drag' + | 'set-size' + | 'maximize' + | 'minimize' + | 'toggle-expand' + | 'equalize' + | 'reset' + | 'add-child' + | 'remove-child' + | 'swap' + | 'swap-data' + | 'move-data' + | 'set-data' + | 'set-direction' + | 'set-bounds'; + +export interface LayoutChangeEvent { + /** + * The container(s) whose layout changed. + * + * - String: the normal single-container case (every reason except + * cross-parent swap). + * - `[string, string]` tuple: cross-parent swap, where BOTH + * containers' children moved. Discriminate with `Array.isArray`. + * + * Same-parent swap stays a string — both nodes share a container. + */ + containerId: string | [string, string], + /** What kind of mutation produced the event. */ + reason: LayoutChangeReason, + /** + * Snapshot of the affected container's child sizes after the change. + * Cloned, so consumers can hold the reference safely. + * + * For multi-container events (cross-parent swap), this is a tuple + * whose entries correspond positionally to `containerId`: + * `sizes[0]` is `containerId[0]`'s children's sizes; `sizes[1]` is + * `containerId[1]`'s. + */ + sizes: Length[] | [Length[], Length[]], + /** + * Ids of nodes directly affected by this mutation. Used by subscribers + * for per-panel filtering. Empty when the change affects every child + * of `containerId` (drag, equalize, reset, set-direction) — consumers + * treating empty-array as "all children" handle both shapes uniformly. + */ + nodeIds: string[], +} + +/** + * Listener registered via `grid.subscribe(cb)`. Same shape as the + * `cfg.onChange` callback; both fire on every layout mutation. + */ +export type LayoutListener = (event: LayoutChangeEvent) => void; + +export interface LeafCtx { + id: string, + data: T | undefined, + el: HTMLElement, + leaf: Leaf, +} + +/** + * What `getSize` returns for a node. `px` is the rendered axis length + * (width for row containers, height for column); `pct` is the same value + * expressed as a fraction of the parent container's axis — useful for + * persisting layout as a percentage independent of viewport size. + */ +export interface PanelSizeReport { + px: number, + pct: number, +} + +/** + * Shared options for every method that mutates layout state (`setSize`, + * `toggleExpand`, `expandNext`/`Prev`, `equalize`, `reset`). The default is + * to animate via the standard transition; pass `animate: false` to apply + * the change instantly (same path drag uses). + */ +export interface LayoutOptions { + animate?: boolean, +} + +/** + * Maximize state for a container. The two fields a maximize tracks — + * which child is maxed, and what to restore to — are united so they can't + * disagree. `null` means no maximize is in effect. + */ +type Maximize = null | { id: string, restore: Length[] }; + +interface ContainerState { + node: Container, + el: HTMLElement, + /** Current size per child. Same length as node.children. */ + sizes: Length[], + /** + * Maximize state. `null` when no child is maximized; otherwise carries + * the maximized id AND the sizes snapshot captured BEFORE the maximize + * (the layout `toggleExpand` reverts to). One field instead of a paired + * `maxId` / `restore` fields — the "they always move together" invariant is + * now structural; you can't have one without the other. + */ + max: Maximize, + /** Cached available content space (parent track minus resizer tracks). */ + availPx: number, + /** Parent container; `undefined` only for the root. Mirrors LeafState. */ + parent: ContainerState | undefined, + /** Index in `parent.node.children`; `0` for the root. */ + indexInParent: number, +} + +interface LeafState { + node: Leaf, + el: HTMLElement, + parent: ContainerState, + indexInParent: number, +} + +type NodeState = ContainerState | LeafState; +type LogPayload = Record; + +const isContainerState = (s: NodeState): s is ContainerState => 'sizes' in s; + +const ANIM_VAR = '--sp-anim-ms'; + +function childElementAt(containerEl: HTMLElement, idx: number): HTMLElement | undefined { + // Content children are .sp-panel or .sp-container elements; resizers are + // .sp-resizer. We can't rely on a fixed stride because leading/trailing + // resizers (the legacy `showFirstResizeEl` analog) shift positions. Walk + // children and pick the idx-th non-resizer. + let seen = 0; + + for (const child of containerEl.children) { + if (!(child as HTMLElement).classList.contains('sp-resizer')) { + if (seen === idx) return child as HTMLElement; + + seen += 1; + } + } + return undefined; +} + +function parseLengthToPxAxis(input: LengthInput, axisPx: ContainerAxisPx): number { + const l = parseLength(input); + + if (l.unit === 'px') return l.value; + + if (l.unit === 'pct') return (l.value / 100) * axisPx; + return 0; +} + +/** + * Resolve `bounds.min`/`.max` to pixels against `containerAxisPx` — the + * FULL grid container axis, NOT `availPx` (content area). + * + * Why containerAxisPx: CSS resolves `` track values against the grid + * container, including resizer tracks. If JS clamps a panel using `availPx` + * but CSS displays the track using `containerAxisPx`, the two disagree by + * `pct * resizerTracksPx`, and the grid overflows by that much — for + * `min: 25%` over 5×20px resizers in a 1176px container, ~25px of overflow. + */ +function clampToBounds( + px: number, + bounds: { min?: LengthInput, max?: LengthInput } | undefined, + containerAxisPx: ContainerAxisPx, +): number { + const min = toPx(parseLength(bounds?.min, { unit: 'px', value: 0 }), containerAxisPx); + const max = bounds?.max == null + ? Number.POSITIVE_INFINITY + : toPx(parseLength(bounds.max), containerAxisPx); + + return Math.max(min, Math.min(max, px)); +} + +/** Same denominator rule as clampToBounds: bounds.min/max resolve against containerAxisPx. */ +function boundsMinPx(bounds: { min?: LengthInput } | undefined, containerAxisPx: ContainerAxisPx): number { + return toPx(parseLength(bounds?.min, { unit: 'px', value: 0 }), containerAxisPx); +} + +/** + * Sum every entry of `arr` except the one at `exceptIdx`. The "sum the + * siblings of `target`" pattern appears in every rebalance helper — at + * the layout level: target gets one size, siblings absorb the rest, so + * we always want `Σ arr − arr[target]` for some array of per-child px + * floors / ceilings / current sizes. Inline `for` loop on a number array + * (no allocation, no callback). + */ +function sumExceptAt(arr: readonly number[], exceptIdx: number): number { + let sum = 0; + + for (const [i, v] of arr.entries()) { + if (i !== exceptIdx) sum += v; + } + return sum; +} + +/** Mirror of `boundsMinPx` for the upper bound — `undefined` reads as +Infinity. */ +function boundsMaxPx(bounds: { max?: LengthInput } | undefined, containerAxisPx: ContainerAxisPx): number { + return bounds?.max == null + ? Number.POSITIVE_INFINITY + : toPx(parseLength(bounds.max), containerAxisPx); +} + +function formatLengthForLog(l: Length): string { + if (l.unit === 'px') return `${Math.round(l.value * 100) / 100}px`; + + if (l.unit === 'pct') return `${Math.round(l.value * 1000) / 1000}%`; + return `${l.value}fr`; +} + +/** + * Resolve when `el` finishes transitioning its `grid-template-columns` or + * `grid-template-rows`. The browser fires `transitionend` per-property, so + * the listener also filters on `propertyName` — unrelated property + * transitions (the demo's hover effects, for instance) shouldn't unblock. + * + * The fallback timeout is mandatory: `transitionend` is not guaranteed to + * fire when the value didn't actually change, when `animate: false` + * suppressed the transition, or in any browser that pauses transitions on + * detached subtrees. Without it, `settle()` could hang forever on a no-op + * mutation. + */ +function waitForContainerTransition(el: HTMLElement, timeoutMs: number): Promise { + return new Promise((resolve) => { + let done = false; + let timer: ReturnType; + + function finish(): void { + if (done) return; + + done = true; + el.removeEventListener('transitionend', onEnd); + clearTimeout(timer); + resolve(); + } + + function onEnd(e: TransitionEvent): void { + if (e.target !== el) return; + + if (e.propertyName !== 'grid-template-columns' && e.propertyName !== 'grid-template-rows') return; + + finish(); + } + + timer = setTimeout(finish, timeoutMs); + el.addEventListener('transitionend', onEnd); + }); +} + +export class SplitGrid { + private readonly cfg: SplitGridConfig; + private readonly byId = new Map(); + private readonly subscribers = new Set(); + private rootEl: ContainerState | null = null; + private host: HTMLElement | null = null; + private rootObserver: ResizeObserver | null = null; + private rafScheduled = false; + /** + * Handle for the pending `scheduleMeasure` rAF, so `unmount` can cancel + * it. Without this, a rAF in flight when the host is torn down lives on + * past unmount, and the next remount under the same host (same instance + * reused) starts with `rafScheduled = true` — quietly swallowing the + * first legitimate `scheduleMeasure` request. + */ + private rafHandle: number | null = null; + /** + * Per-resizer pointer-drag state machines, keyed by resizer element. + * Created in `attachDrag` and disposed in `detachResizers` so a structural + * mutation that drops a resizer element also tears down its event + * listeners — preventing the mid-drag-unmount listener leak the closure + * version had. + */ + private readonly dragStates = new WeakMap(); + + constructor(cfg: SplitGridConfig) { + this.cfg = cfg; + } + + /** Mount the tree into `host`. Existing children of `host` are not touched. */ + mount(host: HTMLElement): void { + if (this.host) throw new Error('SplitGrid: already mounted'); + + this.host = host; + host.style.setProperty(ANIM_VAR, `${this.cfg.animationMs ?? 750}ms`); + this.rootEl = this.buildContainer(this.cfg.root); + host.append(this.rootEl.el); + + // Initial measure happens synchronously now that the element is in the + // DOM. We then fold user-input pct values (left in `c.sizes` by + // `buildContainer` before any geometry was available) into storage + // form, and re-emit tracks so what CSS sees matches the storage frame. + // Without the synchronous pass, the first paint and the post-measure + // re-render would be visibly different. + this.measureAll(this.rootEl); + this.normalizeInitialSizes(this.rootEl); + this.writeAllTracks(this.rootEl); + + this.rootObserver = new ResizeObserver(() => this.scheduleMeasure()); + this.rootObserver.observe(this.rootEl.el); + } + + /** + * Recursively writeTracks every container in the subtree. Used by + * `mount` after the initial normalize pass; nothing else should need it + * — runtime mutators already write their own container. + */ + private writeAllTracks(c: ContainerState): void { + this.writeTracks(c, { animate: false }); + + for (const child of c.node.children) { + const s = this.byId.get(child.id); + + if (s && isContainerState(s)) this.writeAllTracks(s); + } + } + + unmount(): void { + this.rootObserver?.disconnect(); + + // Cancel any rAF the ResizeObserver enqueued before we got here. We + // also reset `rafScheduled` so a future `scheduleMeasure` (during a + // remount on the same instance) isn't silently swallowed by the + // dedup guard. + if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle); + + this.rafHandle = null; + this.rafScheduled = false; + + // Walk every container in the tree and dispose each container's + // direct-child resizers via the same `detachResizers` helper used + // for per-container churn. We have to do it BEFORE the + // host.removeChild — once the elements are detached, the WeakMap + // entries are still reachable from `this.dragStates` (via the keyed + // HTMLElement references that GC hasn't collected yet), so we'd + // leak their global listeners until the next GC pass. + // + // Going through detachResizers (instead of a host-wide + // querySelectorAll) keeps the resizer-disposal selector consistent + // (`:scope > .sp-resizer` everywhere) so a future reader doesn't + // wonder why teardown and churn use different scopes. + for (const s of this.byId.values()) { + if (isContainerState(s)) this.detachResizers(s); + } + + if (this.rootEl && this.host?.contains(this.rootEl.el)) { + this.host.removeChild(this.rootEl.el); + } + + this.rootEl = null; + this.host = null; + this.byId.clear(); + } + + /** Find a node's runtime state by id. */ + get(id: string): NodeState | undefined { return this.byId.get(id); } + + /** Element the grid was mounted into. Null before mount and after unmount. */ + get hostElement(): HTMLElement | null { return this.host; } + + /** + * Set a panel to `size` and rebalance siblings so the container stays full. + * Works for both leaves and nested containers. The request is clamped to + * the target's own min/max, then the remaining space is distributed across + * the siblings (proportional to their current sizes, above their mins). + * + * Animates because writeTracks fires under the standard transition. + */ + setSize(id: string, size: LengthInput, opts?: LayoutOptions): void { + this.applySize(id, size, 'set-size', opts); + } + + /** + * Shared body of `setSize`, `maximize`, and `minimize`. Splitting these + * along the emit reason — instead of routing maximize/minimize through + * the public `setSize` — keeps the `LayoutChangeReason` faithful to the + * user intent that triggered the mutation. Mechanically the three are + * identical: clamp the request against `bounds`, run `applyTargetSize`, + * emit with the caller's reason. + */ + private applySize(id: string, size: LengthInput, reason: LayoutChangeReason, opts?: LayoutOptions): void { + const s = this.byId.get(id); + + if (!s) return; + + const { parent } = s; + + if (!parent) return; + + if (!this.prepareForLayoutOp(parent)) return; + + const avail = parent.availPx; + const totalAxisPx = this.containerAxisPx(parent); + const targetIdx = s.indexInParent; + const { bounds } = parent.node.children[targetIdx]; + // ALL pct resolutions go through containerAxisPx — match CSS's own + // resolution of percentage tracks (which is against the full grid + // container, including resizer tracks). `setSize('100%')` resolves to + // containerAxisPx; applyTargetSize then clamps to avail so the panel + // can't exceed the content-area budget. Mixing denominators here is + // what produced the cumulative drift in the dashboard logs (sizes + // shifted by ±12.5% between adjacent writeTracks calls). + const requestedPx = toPx(parseLength(size), totalAxisPx); + const clampedPx = clampToBounds(requestedPx, bounds, totalAxisPx); + + this.applyTargetSize(parent, targetIdx, clampedPx); + this.writeTracks(parent, opts); + this.emit(reason, parent, [id]); + this.log('applySize', () => ({ + id, + reason, + requested: size, + containerId: parent.node.id, + containerAxisPx: totalAxisPx, + resizerTracksPx: this.resizerTracksPx(parent, totalAxisPx), + availPx: avail, + requestedPx, + clampedPx, + sizes: parent.sizes.map((l) => formatLengthForLog(l)), + })); + } + + /** + * Insert a node into `parentId`'s children at `index` (default: append). + * The new node uses its own `bounds.size` as its initial size; siblings + * keep theirs. CSS Grid will overflow if the new total exceeds 100% — call + * `setSize` on neighbors if you want to rebalance. + * + * Container nodes can themselves contain children; the whole subtree is + * built recursively. Duplicate ids are rejected. + */ + addChild(parentId: string, def: Node, index?: number): void { + const parent = this.byId.get(parentId); + + if (!parent || !isContainerState(parent)) { + this.log('addChild: parent not found or not a container', { parentId }); + return; + } + + if (this.byId.has(def.id)) { + this.log('addChild: duplicate id', { id: def.id }); + return; + } + + const at = Math.max(0, Math.min(index ?? parent.node.children.length, parent.node.children.length)); + + this.mutateStructure(parent, () => { + parent.node.children.splice(at, 0, def); + // Splice as user-input form first (parseLength yields container-pct + // for pct inputs); we don't know `parent`'s budget until the new + // child is in `c.sizes`, since pctBudgetPx subtracts px tracks + // including the new one. Convert immediately after. + parent.sizes.splice(at, 0, parseLength(def.bounds?.size)); + parent.sizes[at] = this.toStorageForm(parent.sizes[at], parent); + // Make room: existing pct siblings may already saturate the + // container (post-equalize / post-maximize), in which case the + // new panel would render at 0 width. See `makeRoom` for the math. + this.makeRoom(parent, at); + + const newState = isContainer(def) + ? this.buildContainer(def, parent, at) + : this.buildLeaf(def, parent, at); + const domChildren = [...parent.el.children] as HTMLElement[]; + + if (at >= domChildren.length) parent.el.append(newState.el); + else parent.el.insertBefore(newState.el, domChildren[at]); + }); + this.log('addChild', { parentId, id: def.id, at }); + this.emit('add-child', parent, [def.id]); + } + + /** + * Remove the node with `id` (and its whole subtree). The freed pct is + * absorbed proportionally by remaining pct-sized siblings so the layout + * still fills the container; px-sized siblings stay put. + * + * No-op for the root or unknown ids. + */ + removeChild(id: string): void { + const s = this.byId.get(id); + + if (!s) { + this.log('removeChild: not found', { id }); + return; + } + + const { parent } = s; + + if (!parent) { + this.log('removeChild: cannot remove root', { id }); + return; + } + + const at = s.indexInParent; + + this.mutateStructure(parent, () => { + s.el.remove(); + parent.node.children.splice(at, 1); + + const [removedSize] = parent.sizes.splice(at, 1); + + this.removeSubtreeFromMap(s); + + if (removedSize.unit === 'pct') this.absorbVacancy(parent.sizes, removedSize.value); + }); + this.log('removeChild', { id, parentId: parent.node.id, at }); + this.emit('remove-child', parent, [id]); + } + + /** + * Reconcile `containerId`'s children to match `defs` (matched by id): + * removes anything not in the list, inserts new entries at their index, + * and reorders existing entries via selection-sort-style swaps. Sizes of + * surviving panels are preserved; new panels get their `bounds.size`. + * + * For "I have an upstream list of items and want the panel list to mirror + * it" use-cases. Each underlying mutation still fires its own onChange + * (add-child / remove-child / swap), so subscribers see the granular ops. + * + * No-op for unknown ids or leaves. + */ + syncChildren(containerId: string, defs: Array>): void { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c)) { + this.log('syncChildren: not a container', { containerId }); + return; + } + + const newIds = new Set(defs.map((d) => d.id)); + + // 1. Remove children that aren't in the new list. Walk back-to-front so + // removeChild's in-place splice only shifts indices we've already + // visited. + for (let i = c.node.children.length - 1; i >= 0; i -= 1) { + const existing = c.node.children[i]; + + if (!newIds.has(existing.id)) this.removeChild(existing.id); + } + + // 2. Walk the desired order. For each position: + // - missing in container → addChild at index i + // - present at the wrong index → swap with the panel currently at i + // - already at index i → leave alone + // + // This is selection sort by id: after iteration i, position i is + // correct, and positions 0..i-1 stay correct (the panel formerly at i + // moved out to wherever the swapped-in panel came from, which is + // somewhere we haven't visited yet). + for (const [i, def] of defs.entries()) { + const currentIdx = c.node.children.findIndex((x) => x.id === def.id); + + if (currentIdx === -1) { + this.addChild(containerId, def, i); + } else if (currentIdx !== i) { + this.swap(def.id, c.node.children[i].id); + } + } + + this.log('syncChildren', { containerId, count: defs.length }); + } + + /** + * Bulk wrapper for `setData`: applies `data` to each `{ id }` in `items`, + * skipping unknown ids. Fires one set-data event per affected leaf — + * consumers that need a single coalesced event can batch with their own + * subscriber-side debounce. + */ + setDataArray(items: Array<{ id: string, data: T | undefined }>): void { + for (const { id, data } of items) { + this.setData(id, data); + } + } + + /** + * Swap two nodes' positions in the tree. Works within a container and + * across containers. Rejects (no-op + log) when one node is an ancestor + * of the other — that would create a cycle. Sizes follow the panel, not + * the slot, so each swapped panel keeps the space it had before. + * + * Resizer event handlers close over their container's state (not specific + * children), so the resizers in both affected containers are rebuilt to + * pick up the new children array. + */ + swap(idA: string, idB: string): void { + const a = this.byId.get(idA); + const b = this.byId.get(idB); + + if (!a || !b || a === b) return; + + if (!a.parent || !b.parent) { + this.log('swap: cannot swap root', { idA, idB }); + return; + } + + if (this.isAncestor(a, b) || this.isAncestor(b, a)) { + this.log('swap: ancestor/descendant conflict', { idA, idB }); + return; + } + + const pA = a.parent; + const pB = b.parent; + const iA = a.indexInParent; + const iB = b.indexInParent; + + if (pA === pB) { + // Same parent: swap the two slots in children, sizes, and DOM. + this.mutateStructure(pA, () => { + [pA.node.children[iA], pA.node.children[iB]] = [pA.node.children[iB], pA.node.children[iA]]; + [pA.sizes[iA], pA.sizes[iB]] = [pA.sizes[iB], pA.sizes[iA]]; + + const els = [...pA.el.children] as HTMLElement[]; + + [els[iA], els[iB]] = [els[iB], els[iA]]; + pA.el.replaceChildren(...els); + }); + this.emit('swap', pA, [idA, idB]); + } else { + // Cross-parent: each parent gets the other's node in the same slot. + // Size swaps too, so each panel keeps its own width when moving. + // We need to mutate both containers; `mutateStructure` runs the + // surrounding boilerplate per-container, so call it twice with + // mutators scoped to each side. The model edits happen in one go + // (inside the first mutator) because they reference both states; + // the second call only runs the per-container bookkeeping. + this.mutateStructure(pA, () => { + pA.node.children[iA] = b.node as Node; + pB.node.children[iB] = a.node as Node; + [pA.sizes[iA], pB.sizes[iB]] = [pB.sizes[iB], pA.sizes[iA]]; + + const aSlots = [...pA.el.children] as HTMLElement[]; + const bSlots = [...pB.el.children] as HTMLElement[]; + + aSlots[iA] = b.el; + bSlots[iB] = a.el; + pA.el.replaceChildren(...aSlots); + pB.el.replaceChildren(...bSlots); + + a.parent = pB; + a.indexInParent = iB; + b.parent = pA; + b.indexInParent = iA; + + // Clear BOTH maxes here, inside the single mutator. Without + // this, the second `mutateStructure(pB)` below would still + // see pB.max referencing an id whose node has just moved to + // pA — a stale window between the two mutateStructure calls. + // The window is fully synchronous today (no observer can + // read into it), but the contract is "max is cleared when + // the structural mutation completes," and the structural + // mutation is the swap, not the per-container bookkeeping. + pA.max = null; + pB.max = null; + }); + // Second container's bookkeeping: detach + rebuild resizers, + // clear max, reindex. No new mutation work — the model edits + // above already touched both sides. + this.mutateStructure(pB); + // Single composed event covering both containers — the tuple + // shape on LayoutChangeEvent.containerId / sizes preserves the + // "one mutation, one event" contract that every other reason + // already follows. + this.emit('swap', [pA, pB], [idA, idB]); + } + + this.log('swap', { idA, idB }); + } + + /** + * Replace a leaf's `data` field. The slot and its size stay in place; only + * the bound data changes. No-op for containers (data is leaf-only) or + * unknown ids. + */ + setData(id: string, data: T | undefined): void { + const s = this.byId.get(id); + + if (!s || isContainerState(s)) { + this.log('setData: not a leaf', { id }); + return; + } + + const leaf = s.node as Leaf; + + // Reference-equal: no-op. Guards against Vue's shallow watcher firing + // on a `:data` prop re-bound to the same value (or to inline literals + // that recreate the object every render — that path is also defended + // by the `` watcher comparing oldData !== newData, but a + // second layer here keeps the core API safe regardless of caller). + if (Object.is(leaf.data, data)) return; + + leaf.data = data; + + if (s.parent) this.emit('set-data', s.parent, [id]); + + this.log('setData', { id }); + } + + /** + * Swap the `data` fields of two leaves while keeping every panel in place + * (slot positions, sizes, parents — all unchanged). Useful for drag-drop + * reorder UX where the user is moving content, not the container itself. + * No-op when either id refers to a container or is unknown. + */ + swapData(idA: string, idB: string): void { + const a = this.byId.get(idA); + const b = this.byId.get(idB); + + if (!a || !b || a === b) return; + + if (isContainerState(a) || isContainerState(b)) { + this.log('swapData: only leaves carry data', { idA, idB }); + return; + } + + const la = a.node as Leaf; + const lb = b.node as Leaf; + const tmp = la.data; + + la.data = lb.data; + lb.data = tmp; + + if (a.parent) this.emit('swap-data', a.parent, [idA, idB]); + + if (b.parent && b.parent !== a.parent) this.emit('swap-data', b.parent, [idA, idB]); + + this.log('swapData', { idA, idB }); + } + + /** + * Move source's data into target's slot, shifting any data values between + * them. Same-container only — cross-parent moves are undefined (use + * `swap` or `swapData` for that). The IDs (and therefore slot positions) + * don't change; only the data flows through them. + * + * Example: `[A, B, C, D]` calling `moveData('a', 'c')` → + * - source index 0 (A), target index 2 (C) + * - splice A out of the list: `[B, C, D]` + * - insert at target's index (2): `[B, C, A, D]` + * - reassign by slot order: `a → B, b → C, c → A, d → D` + * + * Mirrors the legacy `SplitPanel.moveData` semantic used by swarm's + * drag-drop reorder. + */ + moveData(sourceId: string, targetId: string): void { + if (sourceId === targetId) return; + + const source = this.byId.get(sourceId); + const target = this.byId.get(targetId); + + if (!source || !target) return; + + if (isContainerState(source) || isContainerState(target)) { + this.log('moveData: only leaves carry data', { sourceId, targetId }); + return; + } + + // Same-parent only. Cross-parent shift would have to decide which slot + // the source frees up across containers; leave that to the consumer. + if (source.parent !== target.parent || !source.parent) { + this.log('moveData: cross-parent ignored — use swap/swapData', { sourceId, targetId }); + return; + } + + const { parent } = source; + const ordered = parent.node.children; + const srcIdx = source.indexInParent; + const tgtIdx = target.indexInParent; + // Pull current data values in slot order, splice source out, insert + // at target's index. Then reassign to the (unchanged) slot ids in order. + const data = ordered.map((c) => (c as Leaf).data); + const [moved] = data.splice(srcIdx, 1); + + data.splice(tgtIdx, 0, moved); + + for (const [i, child] of ordered.entries()) (child as Leaf).data = data[i]; + + this.emit('move-data', parent, [sourceId, targetId]); + this.log('moveData', { sourceId, targetId }); + } + + /** + * Flip a container's direction at runtime. Sizes (percentages) stay numerically + * the same; they now resolve against the new axis. If you care about preserving + * visual proportions, call `equalize` after to redistribute. + */ + setDirection(containerId: string, direction: PanelDirectionInput, opts?: LayoutOptions): void { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c)) return; + + if (c.node.direction === direction) return; + + c.node.direction = direction; + c.el.dataset.direction = direction; + // availPx is axis-dependent — remeasure for the next drag/maximize. + this.scheduleMeasure(); + this.writeTracks(c, opts); + this.emit('set-direction', c); + this.log('setDirection', { containerId, direction }); + } + + /** + * Mutate a node's `bounds` (min/max/size) and re-fit the panel into the + * new constraints. Re-clamping the current intended size and then running + * the standard proportional rebalance is the key step: a new `min` that + * lies above the current size, or a new `max` below it, would otherwise + * let CSS's `clamp()` push the rendered track size past what siblings + * know about, overflowing the container. + */ + setBounds(id: string, bounds: Partial, opts?: LayoutOptions): void { + const s = this.byId.get(id); + + if (!s) return; + + const current: Bounds = s.node.bounds ?? {}; + // Object spread copies `undefined` values — passing `{ min: undefined }` + // CLEARS the existing min rather than leaving it untouched. That's + // the intended semantic (lets `` flip + // off the min when `maybeUndef` becomes undefined), but worth knowing + // if you're calling setBounds programmatically: omit the key to keep + // a field, pass `undefined` to clear it. + const merged: Bounds = { ...current, ...bounds }; + + // Reference-stable no-op: every field of `merged` matches `current`. + // Vue's shallow watcher on `` props fires on each render + // when the consumer writes `:size="someComputed"`, `:min="..."`, etc., + // even when nothing changed. Without this guard, every render would + // re-trigger applyTargetSize + a writeTracks event — surfacing as + // "panel sizes drift on every Vue update" in the wild. Keep the + // comparison simple: same value via Object.is on each field. + if ( + Object.is(current.size, merged.size) + && Object.is(current.min, merged.min) + && Object.is(current.max, merged.max) + ) { + return; + } + + s.node.bounds = merged; + + const { parent } = s; + + if (!parent) { + // Root has no parent container to rebalance against — no sibling + // sizes change — but the bounds patch is still persisted on the + // node, and subscribers persisting bounds via onChange need to + // see the event. Skip the rebalance, fire the emit. + if (isContainerState(s)) this.emit('set-bounds', s, [id]); + + this.log('setBounds: root', { id, bounds }); + return; + } + + if (!this.prepareForLayoutOp(parent)) { + // Pre-measure / detached host: bounds patch is persisted on the + // node, but the rebalance/emit path runs against zero geometry and + // would store all-zero sizes. Skip silently; a later live op will + // pick up the fresh bounds. + this.log('setBounds: zero avail, deferred rebalance', { id, bounds }); + return; + } + + const totalAxisPx = this.containerAxisPx(parent); + const budget = this.pctBudgetPx(parent); + const targetIdx = s.indexInParent; + + // Two denominators, two roles: + // - User-supplied `merged.size` (pct, if given) is a CSS-native value — + // "50% of the container" — so it resolves against containerAxisPx. + // - The "keep current size" path reads `parent.sizes[targetIdx]`, which + // is in storage form (% of pctBudgetPx) — so it resolves against + // `budget`. Mixing them here was the drift source pre-refactor. + const rawTarget = bounds.size === undefined + ? toPx(parent.sizes[targetIdx], budget) + : toPx(parseLength(merged.size), totalAxisPx); + const targetPx = clampToBounds(rawTarget, merged, totalAxisPx); + + this.applyTargetSize(parent, targetIdx, targetPx); + this.writeTracks(parent, opts); + this.emit('set-bounds', parent, [id]); + this.log('setBounds', { id, bounds }); + } + + /** + * Make `id` as large as its bounds and parent allow. + * + * Records `parent.max = { id, restore }` (the pre-maximize snapshot) so + * `isMaximized(id)` reflects the state and a later `toggleExpand` can + * revert. If another sibling is already maxed, its snapshot is + * restored first so ours captures the natural layout, not a stacked + * one — invariant: at most one maximized child per container. + * `minimize` instead clears the state and drops the panel to 0%. + */ + maximize(id: string, opts?: LayoutOptions): void { + const s = this.byId.get(id); + const parent = s?.parent; + + if (parent) { + // Freeze fr → pct BEFORE snapshotting. If c.sizes still holds fr + // entries (any container that hasn't had a layout op yet), the + // snapshot below would persist them; the eventual toggleExpand + // restore would write fr back into c.sizes and writeTracks would + // emit `minmax(0, 1fr)` against a previously-pct track — the + // function-shape mismatch styles.css:39-45 warns against, which + // flashes container background through the transition. + // prepareForLayoutOp is idempotent; applySize will call it again + // and that pass is a no-op for fr-freeze (everything is pct now). + if (!this.prepareForLayoutOp(parent)) return; + + if (parent.max?.id === id) { + this.applySize(id, '100%', 'maximize', opts); + return; + } + + // Someone else is maximized — restore them first so our snapshot + // captures the natural state, not a stacked one. + if (parent.max) parent.sizes = [...parent.max.restore]; + + parent.max = { id, restore: [...parent.sizes] }; + } + + this.log('maximize', { id, containerId: parent?.node.id }); + this.applySize(id, '100%', 'maximize', opts); + } + + /** + * Shrink `id` to its min (or 0 if no min). Siblings absorb the freed space. + * + * If `id` was the currently-maximized child, clear `parent.max` so + * `isMaximized(id)` reads `false` afterwards. Pair `maximize` ↔ + * `minimize` for a clean "expand / collapse to zero" toggle, distinct + * from `toggleExpand` (which collapses to the prior layout via the + * snapshot). + */ + minimize(id: string, opts?: LayoutOptions): void { + const s = this.byId.get(id); + const parent = s?.parent; + + if (parent?.max?.id === id) { + parent.max = null; + } + + this.log('minimize', { id, containerId: parent?.node.id }); + this.applySize(id, '0%', 'minimize', opts); + } + + /** + * Toggle between `maximize` and `minimize` on the same panel. Different + * from `toggleExpand` — that one restores to the snapshotted prior layout + * when called on an already-maximized panel, which can read as "everything + * equalized" if the snapshot was the initial state. This one explicitly + * collapses to 0% on the second invocation, matching dblclick-on-divider + * affordances where users expect "expand / collapse" to be symmetric. + */ + toggleMaximize(id: string, opts?: LayoutOptions): void { + if (this.isMaximized(id)) this.minimize(id, opts); + else this.maximize(id, opts); + } + + /** True if `maybeAncestor` is on the parent chain of `node` (or equal). */ + private isAncestor(maybeAncestor: NodeState, node: NodeState): boolean { + let cur: ContainerState | undefined = node.parent; + + while (cur) { + if (cur === maybeAncestor) return true; + + cur = cur.parent; + } + return false; + } + + /** Remove every `.sp-resizer` child from a container's element. */ + private detachResizers(c: ContainerState): void { + // querySelectorAll returns a static NodeList; safe to mutate during iteration. + const resizers = c.el.querySelectorAll(':scope > .sp-resizer'); + + for (const r of resizers) { + // Dispose the drag state machine BEFORE removing the element. If a + // drag is in flight, `dispose` ends it with reason 'unmount' so the + // global pointer listeners are removed and dataset.dragging cleared + // — the closure-based version leaked these when a structural + // mutation dropped a resizer mid-drag. + this.dragStates.get(r)?.dispose(); + this.dragStates.delete(r); + r.remove(); + } + } + + /** + * Resolve the effective resizer spec for `c`: the node's own `resizer` + * overrides; otherwise inherit `cfg.resizer`. Every internal site that + * reads resizer config goes through this — `trackString`, the pixel + * accounting in `resizerTracksPx`, and the runtime DOM build in + * `rebuildResizers` / `buildContainer` must agree, or the inline tracks + * disagree with JS's idea of how much space they consume. + */ + private getResizerSpec(c: { node: { resizer?: ResizerSpec } } | { resizer?: ResizerSpec }): ResizerSpec | undefined { + const node = 'node' in c ? c.node : c; + + return node.resizer ?? this.cfg.resizer; + } + + /** Re-insert resizers between the (currently resizer-free) child elements. */ + private rebuildResizers(c: ContainerState): void { + const resizerSpec = this.getResizerSpec(c); + const children = [...c.el.children] as HTMLElement[]; + + // Inner dividers — one before each non-first child. + for (let i = 1; i < children.length; i += 1) { + c.el.insertBefore(this.buildResizer(c, i, resizerSpec), children[i]); + } + + // Optional decorative leading / trailing tracks, matching buildContainer. + if (resizerSpec?.first && children.length > 0) { + c.el.insertBefore(this.buildResizer(c, -1, resizerSpec), c.el.firstChild); + } + + if (resizerSpec?.last && children.length > 0) { + c.el.append(this.buildResizer(c, c.node.children.length, resizerSpec)); + } + } + + /** Refresh `indexInParent` for every sibling at or after `from`. */ + private reindexFrom(parent: ContainerState, from: number): void { + for (let i = from; i < parent.node.children.length; i += 1) { + const sibling = this.byId.get(parent.node.children[i].id); + + if (sibling) sibling.indexInParent = i; + } + } + + /** + * Wrap a structural mutation (addChild / removeChild / swap) in the + * boilerplate every such mutation needs: + * + * 1. detach resizers (handleIdx closures would otherwise go stale) + * 2. refresh `availPx` — resizer track count is about to change, so + * the budget the mutator uses must reflect the post-mutation state + * 3. run the caller's mutation — splice children/sizes, move DOM + * 4. clear `c.max` — its `restore` array's indices no longer match + * 5. reindex `indexInParent` for every child + * 6. rebuild resizers fresh against the new children + * 7. writeTracks with `animate: false` — track count can't animate + * + * Cross-parent mutations (`swap` between containers) call this once + * per affected container; the helper is per-container by design. + * + * The renormalize step that used to live here (item 3 pre-refactor) is + * gone: `c.sizes` is now stored in pct-of-pctBudget form, and the + * "saturated layout sums to 100" invariant is maintained directly by + * each mutator (`makeRoom` shrinks existing pct on insert, + * `absorbVacancy` scales survivors back up to 100 on remove). + */ + private mutateStructure(c: ContainerState, mutate?: () => void): void { + this.detachResizers(c); + mutate?.(); + c.max = null; + this.reindexFrom(c, 0); + this.rebuildResizers(c); + this.writeTracks(c, { animate: false }); + } + + /** Recursively drop the byId entries for a removed subtree. */ + private removeSubtreeFromMap(s: NodeState): void { + this.byId.delete(s.node.id); + + if (isContainerState(s)) { + for (const child of s.node.children) { + const childState = this.byId.get(child.id); + + if (childState) this.removeSubtreeFromMap(childState); + } + } + } + + /** + * After a panel is removed, scale remaining pct-sized siblings so they + * cover the saturation target (sum-to-100 of pctBudgetPx). Px-sized + * siblings stay anchored — only pct sizes rebalance. + * + * In the new storage frame the math is simpler than the old "% of + * container" version: survivors get scaled by `100 / pctSurvivorSum` + * regardless of what the removed panel's pct value was. The + * `removedPct` parameter is kept on the signature so the call sites + * still document the freed budget, but isn't used in the math itself. + * + * Edge case: when the removed panel was maximized, the surviving pct + * siblings are all at 0%. Proportional scaling can't grow zeros, so we + * fall back to "split 100 equally" — every surviving pct sibling gets + * `100 / count`. The property test caught the fall-through path + * leaving 988px of dead space on the old container-pct model. + */ + private absorbVacancy(sizes: Length[], removedPct: number): void { + if (removedPct <= 0) return; + + let pctSum = 0; + let pctCount = 0; + + for (const sz of sizes) { + if (sz.unit === 'pct') { + pctSum += sz.value; + pctCount += 1; + } + } + + if (pctCount === 0) return; + + if (pctSum <= 0) { + const share = 100 / pctCount; + + for (let i = 0; i < sizes.length; i += 1) { + if (sizes[i].unit === 'pct') sizes[i] = { unit: 'pct', value: share }; + } + return; + } + + const factor = 100 / pctSum; + + for (let i = 0; i < sizes.length; i += 1) { + const sz = sizes[i]; + + if (sz.unit === 'pct') sizes[i] = { unit: 'pct', value: sz.value * factor }; + } + } + + /** + * After a panel is inserted, shrink existing pct-sized siblings so the + * new panel has room. Inverse of `absorbVacancy`. + * + * Why this is needed: once any layout op runs (`equalize`, `setSize`, + * `maximize`), fr sizes get frozen into pct via `freezeFrTracks`. From + * then on, the pct values sum to ~100% — leaving zero space for any + * fresh `1fr` track the runtime is about to splice in. The new panel + * would render at 0 (or negative) width, pushed outside the viewport. + * + * Resolution depends on the new panel's unit: + * - `px`: anchored size; existing pct siblings make room naturally + * via CSS (px subtracts from the avail before pct/fr expand). + * No JS rebalance needed. + * - `fr` (the `bounds.size === undefined` / `'auto'` case): convert + * the new track to pct, give it an equal share of the existing + * pct budget, and scale existing pct siblings down by `N/(N+1)`. + * Preserves the total pct sum so px/other-fr siblings see the + * same containerAxisPx allocation they had before. + * - `pct`: scale existing pct siblings so they + new fit ≤ 100%. + */ + private makeRoom(c: ContainerState, newIdx: number): void { + const { sizes } = c; + const newSize = sizes[newIdx]; + + if (newSize.unit === 'px') return; + + const pctIdxs: number[] = []; + let pctSum = 0; + + for (const [i, size] of sizes.entries()) { + if (i !== newIdx && size.unit === 'pct') { + pctIdxs.push(i); + pctSum += size.value; + } + } + + // No pct siblings — fr/pct new panel will be the only flex/pct track, + // CSS resolves the rest naturally. + if (pctIdxs.length === 0) return; + + // Each existing pct sibling has a min pct. The min is expressed + // against containerAxisPx (matching CSS — what `bounds.min: '20%'` + // means to the user), but `c.sizes` stores pct against pctBudgetPx + // (the storage frame). Convert via budget so comparisons against + // sibling pct values land in the same unit. Without this, scaling + // would let CSS clamp the rendered track up to the min while the + // stored pct stays below — rendered overflow by Σ (min - stored) + // per violating sibling. Iterative pin-and-reshare (same shape as + // `equalize`) keeps stored sizes at or above each min and absorbs + // the shortage into the new panel's share. + const totalAxisPx = this.containerAxisPx(c); + const budgetPx = this.pctBudgetPx(c); + const minPctByIdx = new Map(); + + for (const i of pctIdxs) { + const minPx = boundsMinPx(c.node.children[i].bounds, totalAxisPx); + + minPctByIdx.set(i, budgetPx > 0 ? (minPx / budgetPx) * 100 : 0); + } + + if (newSize.unit === 'fr') { + // Target: each panel (existing + new) gets an even share of + // `pctSum / (N+1)`. Walk and pin any sibling whose min exceeds + // its share; re-share over the rest. Repeat until stable. + const free = new Set([...pctIdxs, newIdx]); + const pinned = new Map(); + let pool = pctSum; + + for (;;) { + const share = free.size > 0 ? pool / free.size : 0; + const toPin: number[] = []; + + for (const i of free) { + // new panel has no min + if (i !== newIdx) { + const minPct = minPctByIdx.get(i) ?? 0; + + if (minPct > share) toPin.push(i); + } + } + + if (toPin.length === 0) { + for (const i of free) pinned.set(i, share); + break; + } + + for (const i of toPin) { + const minPct = minPctByIdx.get(i) ?? 0; + + pinned.set(i, minPct); + pool -= minPct; + free.delete(i); + } + } + + for (const i of pctIdxs) { + sizes[i] = { unit: 'pct', value: pinned.get(i) ?? 0 }; + } + + sizes[newIdx] = { unit: 'pct', value: pinned.get(newIdx) ?? 0 }; + return; + } + + // newSize.unit === 'pct'. If existing pct + new would exceed 100%, + // scale existing down to make room. + if (pctSum + newSize.value <= 100) return; + + const targetSum = Math.max(0, 100 - newSize.value); + const factor = targetSum / pctSum; + + for (const i of pctIdxs) { + sizes[i] = { unit: 'pct', value: sizes[i].value * factor }; + } + } + + /** + * Replace any `fr` sizes with their currently-resolved px-as-pct values, + * without firing the transition. Run this before any programmatic write + * that should animate: CSS Grid can't smoothly interpolate between an + * `fr`-based track (e.g. `minmax(0, 1fr)`) and a `length-percentage` + * track (e.g. `max(0px, 0%)`) — the function shapes are different and + * the browser falls back to a discrete jump, which shows as a flash of + * container background through the (briefly) mis-sized track. + * + * Drag doesn't need this — it sets [data-dragging], which suppresses the + * transition entirely. + */ + private freezeFrTracks(c: ContainerState): void { + if (!c.sizes.some((sz) => sz.unit === 'fr')) return; + + // Freeze converts every track (fr AND pct — both can drift relative + // to one another between writes) to its currently-resolved pct value + // in storage form (% of pctBudgetPx). Mixing today's stored pcts + // with newly-frozen pcts would mean two values with different + // denominators in the same array, so we rewrite all of them. + const budget = this.pctBudgetPx(c); + const frozen: Length[] = c.node.children.map((_, i) => { + const childEl = childElementAt(c.el, i); + + if (!childEl) return c.sizes[i]; + + // px-sized tracks stay px-sized — only the flex (fr) and pct + // tracks need to be normalized into the new storage frame. + if (c.sizes[i].unit === 'px') return c.sizes[i]; + + const px = measureAxis(childEl, c.node.direction); + + return { unit: 'pct' as const, value: pxToPct(px, budget) }; + }); + + // `writeTracks({ animate: false })` owns the noAnimate dance (set + // dataset, set --sp-tracks, force layout flush via offsetWidth, clear + // dataset). Going through it here keeps the freeze write off the + // transition without duplicating that dance — and keeps any future + // tweak to the suppression mechanism (e.g. a different attribute) in + // one place. + c.sizes = frozen; + this.writeTracks(c, { animate: false }); + this.log('freezeFrTracks', () => ({ containerId: c.node.id, frozen: frozen.map((l) => formatLengthForLog(l)) })); + } + + /** + * Maximize the panel `id` (leaf or container) in its parent. If the same id + * is already maximized, restore. If a *different* sibling is currently + * maximized, restore that one first so only one is ever maximized at a + * time — which lets expandNext/expandPrev "move" the maximize state + * cleanly through the container. + * + * Maximize size = `min(bounds.max, avail − Σ sibling mins)`; spare space + * (when the target hits its own max) spreads across siblings. + */ + toggleExpand(id: string, opts?: LayoutOptions): void { + const s = this.byId.get(id); + + if (!s) { + this.log('toggleExpand: not found', { id }); + return; + } + + const p = s.parent; + + if (!p) { + this.log('toggleExpand: target is the root', { id }); + return; + } + + // Freeze fr → pct first; otherwise the eventual restore would animate + // between mismatched track shapes and flash the container background. + if (!this.prepareForLayoutOp(p)) return; + + // Restoring our own snapshot is just a regular swap-and-write. + if (p.max?.id === id) { + const restored = p.max.restore; + + this.log('toggleExpand: restoring snapshot', () => ({ id, restored: restored.map((l) => formatLengthForLog(l)) })); + p.sizes = [...restored]; + p.max = null; + this.writeTracks(p, opts); + this.emit('toggle-expand', p, [id]); + return; + } + + // Someone else is maximized — restore them first so the new snapshot + // we're about to take captures the natural state, not a stacked one. + if (p.max) p.sizes = [...p.max.restore]; + + p.max = { id, restore: [...p.sizes] }; + + // Maximize: cap by max-allowed, distribute the rest across siblings. + // bounds.max resolves against the FULL container (matching CSS); the + // sibling-min sum likewise comes from container-resolved mins. The + // remaining budget is content space (avail). + const avail = p.availPx; + const totalAxisPx = this.containerAxisPx(p); + const targetIdx = s.indexInParent; + const target = p.node.children[targetIdx]; + const targetMaxLen = target.bounds?.max; + const targetMax = targetMaxLen == null + ? Number.POSITIVE_INFINITY + : toPx(parseLength(targetMaxLen), totalAxisPx); + const siblingMinSum = this.sumSiblingMins(p, targetIdx); + const targetSize = Math.min(targetMax, Math.max(0, avail - siblingMinSum)); + + this.applyTargetSize(p, targetIdx, targetSize); + this.writeTracks(p, opts); + this.emit('toggle-expand', p, [id]); + this.log('toggleExpand: maximized', () => ({ + id, + containerId: p.node.id, + targetMax: Number.isFinite(targetMax) ? targetMax : 'inf', + targetSize, + sizes: p.sizes.map((l) => formatLengthForLog(l)), + })); + } + + /** + * Walk the maximize state forward through `containerId`'s children. If a + * child is currently maximized, restore it and maximize its next sibling. + * If nothing is maximized, maximize the first child. Returns the id that + * ended up maximized, or `undefined` when there's nowhere to go (already + * at the last child, or the target isn't a multi-child container). + * + * The caller doesn't have to track which panel is "selected" — the state + * lives in the container's snapshot map and is read fresh on each call. + */ + expandNext(containerId: string, opts?: LayoutOptions): string | undefined { + return this.expandRelative(containerId, 1, opts); + } + + /** Mirror of `expandNext`. Maximizes the last child when nothing is set. */ + expandPrev(containerId: string, opts?: LayoutOptions): string | undefined { + return this.expandRelative(containerId, -1, opts); + } + + private expandRelative(containerId: string, dir: 1 | -1, opts?: LayoutOptions): string | undefined { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c) || c.node.children.length === 0) return undefined; + + const currentIdx = this.findMaximizedIndex(c); + const fromIdx = currentIdx == null + ? (dir > 0 ? 0 : c.node.children.length - 1) + : currentIdx + dir; + + if (fromIdx < 0 || fromIdx >= c.node.children.length) return undefined; + + const target = c.node.children[fromIdx]; + + this.toggleExpand(target.id, opts); + return target.id; + } + + /** + * Index of the currently-maximized child in `c`, or `undefined` if none. + * `c.max` encodes "who is maxed" as a single field; at most one + * maximized child per container is therefore a structural invariant. + */ + private findMaximizedIndex(c: ContainerState): number | undefined { + if (!c.max) return undefined; + + const maxState = this.byId.get(c.max.id); + + return maxState?.indexInParent; + } + + /** True iff `id` is the panel currently maximized inside its parent. */ + isMaximized(id: string): boolean { + const s = this.byId.get(id); + + return s?.parent?.max?.id === id; + } + + /** + * Index of the currently-maximized child within `containerId`'s + * children array, or `-1` when no child is maximized (or when + * `containerId` isn't a container). Companion to `isMaximized` for + * consumers building "expand prev / expand next" UI affordances — + * they typically want the index, not the id. + */ + getMaximizedIndex(containerId: string): number { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c)) return -1; + + // Use the private O(1) helper — the maximized node's state already + // carries indexInParent, no need to scan children. + return this.findMaximizedIndex(c) ?? -1; + } + + /** + * True when `containerId`'s children currently share the same stored + * `Length` (same unit AND value), OR when the container has fewer + * than two children (no inequality is possible). Useful for "is + * equalize a no-op?" UI affordances. Returns `false` for leaves or + * unknown ids. + */ + areChildrenEqual(containerId: string): boolean { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c)) return false; + + if (c.sizes.length < 2) return true; + + const first = c.sizes[0]; + + return c.sizes.every((sz) => sz?.value === first?.value && sz?.unit === first?.unit); + } + + /** + * True iff the panel's currently rendered size matches what it was + * defined with at mount time. Compares physical pixels (defined-size + * resolved against containerAxisPx vs. measured rect), so the check + * is unit-agnostic — `bounds.size: '25%'` versus a stored `25%` of + * pctBudgetPx still answers "yes" once both round-trip to the same + * rendered px. Useful for "has the user touched this?" UX + * questions; pairs naturally with `reset()` for undo affordances. + */ + isAtDefault(id: string): boolean { + const s = this.byId.get(id); + + if (!s || !s.parent) return false; + + const definedRaw = s.node.bounds?.size; + const current = s.parent.sizes[s.indexInParent]; + + // No defined size — treat as "at default" only when the stored + // size is still fr (matches what reset would assign). Avoids + // spurious "touched" reports on fr panels. + if (definedRaw == null) return current.unit === 'fr'; + + // Compare physical px (not pct values directly) — the defined + // size is user-input form (pct meaning "% of container") while + // the stored size is storage form (pct meaning "% of + // pctBudgetPx"). Resolving both to px lets the units cancel and + // a `bounds.size: '25%'` matches a stored 25.30% value when + // both round-trip to the same rendered px. + const totalAxisPx = this.containerAxisPx(s.parent); + const budget = this.pctBudgetPx(s.parent); + const definedPx = toPx(parseLength(definedRaw), totalAxisPx); + let currentPx: number; + + if (current.unit === 'px') currentPx = current.value; + else if (current.unit === 'pct') currentPx = (current.value / 100) * budget; + // fr stored against a concrete defined size means the panel is on + // its 1fr fallback rather than the user's intent — not at default. + else return false; + + return Math.abs(definedPx - currentPx) < 0.5; + } + + /** + * Make every child of `containerId` an equal share of the available space, + * respecting per-child mins. Animates. No-op for non-containers or + * containers with fewer than 2 children. + * + * Algorithm: aim for `avail / N` per panel. If a panel's min exceeds + * that share, pin it at the min and recompute the share over the + * remaining panels with reduced avail. Repeat until the share covers + * every still-free panel's min — then split the remainder equally. + * + * In the common case (no panel has a min larger than its even share), + * every panel ends up at exactly `avail / N`. That's the property + * users mean when they say "equalize": same size, full stop. + * + * The previous implementation did `min + (avail − Σmin) / N`, which + * gave panels with bigger mins bigger TOTALS — visibly unequal when + * only some panels had mins. + */ + equalize(containerId: string, opts?: LayoutOptions): void { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c) || c.node.children.length < 2) return; + + if (!this.prepareForLayoutOp(c)) return; + + const avail = c.availPx; + const containerAxisPx = this.containerAxisPx(c); + const mins = c.node.children.map((child) => boundsMinPx(child.bounds, containerAxisPx)); + // Equal share across all children; pin mins, ignore maxes (current + // behavior — see distribute.spec.ts for the helper's contract). + // Equal weights → degenerates to `availLeft / free.size` per pass. + const weights = c.node.children.map(() => 1); + const maxes = c.node.children.map(() => Number.POSITIVE_INFINITY); + const sizesPx = distributeProportional(avail, weights, mins, maxes); + + // Storage convention: pct of pctBudgetPx (not container). + // + // - All-pct: each child stores exactly `100/N` pct — sum to 100. + // Matches what users mean by "equalize" when they inspect c.sizes. + // - Mixed: pct entries get `(100 - px share) / pctCount` each; the + // pct portion still sums to (100 - sum-of-px-pct-equivalent). + // - All-px: every entry stores `avail/N` px. The sum-to-100 pct + // invariant is vacuous here (no pct entries to enforce); the + // CSS-rendered widths are still equal at `availPx/N`. + // + // Px siblings keep their unit (see `sizesFromPx` docs for the bug + // class this defends against). The CSS pct emitted at writeTracks + // scales pct entries back down by budget/container so the rendered + // width is still `availPx/N` per panel regardless of mix. + const units: StorageUnit[] = c.sizes.map( + (sz) => (sz.unit === 'px' ? 'px' : 'pct'), + ); + + c.sizes = sizesFromPx(sizesPx, units, avail); + c.max = null; + this.writeTracks(c, opts); + this.emit('equalize', c); + this.log('equalize', () => ({ containerId, sizes: c.sizes.map((l) => formatLengthForLog(l)) })); + } + + /** + * Restore every child of `containerId` to the `bounds.size` it was defined + * with. Children with no defined size fall back to `auto` (1fr). Animates. + */ + reset(containerId: string, opts?: LayoutOptions): void { + const c = this.byId.get(containerId); + + if (!c || !isContainerState(c)) return; + + this.freezeFrTracks(c); + // `bounds.size` is user-input form (pct = "% of container"); fold each + // into storage form (pct = "% of pctBudgetPx") so c.sizes stays in + // the storage frame the rest of the runtime expects. + c.sizes = c.node.children.map( + (child) => this.toStorageForm(parseLength(child.bounds?.size), c), + ); + c.max = null; + this.writeTracks(c, opts); + this.emit('reset', c); + this.log('reset', () => ({ containerId, sizes: c.sizes.map((l) => formatLengthForLog(l)) })); + } + + /** + * Read the current resolved size for `id` (or `undefined` if not found). + * `px` is the rendered width/height; `pct` is the same value as a fraction + * of the parent container's axis. Useful for state persistence. + */ + getSize(id: string): PanelSizeReport | undefined { + const s = this.byId.get(id); + + if (!s || !s.parent) return undefined; + + const px = measureAxis(s.el, s.parent.node.direction); + const parentAxis = this.containerAxisPx(s.parent); + + return { px, pct: parentAxis > 0 ? (px / parentAxis) * 100 : 0 }; + } + + /** + * Serialize the live tree back to a plain `Container` — useful for + * persisting user-customized layouts. Returns a deep clone, so mutating + * the result is safe. + * + * Options: + * - `withCurrentSizes` (default `true`) bakes the on-screen pixel size + * of each child into `bounds.size`, so reconstructing from the result + * reproduces what the user is currently looking at. Pass `false` to + * keep the original definition's `size` (useful when persisting + * structure-only and resizing at runtime from elsewhere). + * - `includeData` (default `true`) carries each leaf's `data` payload + * through. Pass `false` to drop it — handy when `data` is large or + * not serializable. + * + * Returns `undefined` before mount (no measurable sizes yet). + */ + getRawDefinition( + opts: { withCurrentSizes?: boolean, includeData?: boolean } = {}, + ): Container | undefined { + if (!this.rootEl) return undefined; + + const withCurrentSizes = opts.withCurrentSizes !== false; + const includeData = opts.includeData !== false; + + // When withCurrentSizes is requested but the root has no measurable + // layout yet (post-mount, pre-first-paint, or a detached / display:none + // host), every measureAxis read returns 0 and every child's bounds.size + // ends up `0px` — reconstructing from that result produces a collapsed + // tree. Treat this as "not ready" and fall through to the no-sizes + // path; the structure is still useful. + if (withCurrentSizes && this.containerAxisPx(this.rootEl) <= 0) { + return this.getRawDefinition({ ...opts, withCurrentSizes: false }); + } + + const cloneBounds = (b: Bounds | undefined): Bounds | undefined => (b ? { ...b } : undefined); + + // Current size in px for the child at `indexInParent` of `parent`. + // We measure off the DOM so we capture what the user actually sees, + // including the result of CSS clamp/minmax resolving min/max bounds. + const measuredSize = (parent: ContainerState, indexInParent: number): LengthInput | undefined => { + const childEl = childElementAt(parent.el, indexInParent); + + if (!childEl) return undefined; + + // Round to integer px — sub-pixel jitter from getBoundingClientRect + // would otherwise show up as `199.99999996px` in the serialized form. + return `${Math.round(measureAxis(childEl, parent.node.direction))}px` as LengthInput; + }; + + const walk = (id: string): Node => { + const state = this.byId.get(id); + + if (!state) throw new Error(`getRawDefinition: node ${id} not found`); + + if (isContainerState(state)) { + const node = state.node as Container; + const out: Container = { + id: node.id, + direction: node.direction, + children: node.children.map((c) => walk(c.id)), + bounds: cloneBounds(node.bounds), + resizer: node.resizer ? { ...node.resizer } : undefined, + }; + + if (withCurrentSizes && state.parent) { + const size = measuredSize(state.parent, state.indexInParent); + + if (size !== undefined) out.bounds = out.bounds ? { ...out.bounds, size } : { size }; + } + return out; + } + + const leaf = state.node as Leaf; + const out: Leaf = { + id: leaf.id, + bounds: cloneBounds(leaf.bounds), + }; + + if (includeData && leaf.data !== undefined) out.data = leaf.data; + + if (withCurrentSizes) { + const size = measuredSize(state.parent, state.indexInParent); + + if (size !== undefined) out.bounds = out.bounds ? { ...out.bounds, size } : { size }; + } + return out; + }; + + return walk(this.rootEl.node.id) as Container; + } + + // ---- shared rebalance ------------------------------------------------ + + /** + * Set the child at `targetIdx` to `targetPx` and proportionally rescale + * siblings into `avail − targetPx`, with each sibling clamped to its own + * `[min, max]`. The target itself gets clamped on BOTH ends by what its + * siblings can structurally absorb: + * + * - upper: `avail − Σ siblingMin`. Can't grow so much that siblings + * would dip below their mins. + * - lower: `avail − Σ siblingMax`. Can't shrink so much that siblings + * would have to grow past their maxes — the target absorbs the + * spillover so the layout still fills the container. This is what + * keeps a `setSize(target, '0%')` from inflating a max-capped + * sibling past its max. + * + * If both clamps point opposite ways (e.g. impossible bounds), the + * upper bound wins — siblings shrinking to their mins is the safer + * direction than violating their maxes. + * + * Shared body of setSize and toggleExpand. + */ + private applyTargetSize(parent: ContainerState, targetIdx: number, targetPx: number): void { + const avail = parent.availPx; + + // Pre-measure paths (detached host, display:none, called before the + // first rAF) leave availPx = 0. Without this guard, every weight is + // zero, distributeProportional returns zeros, sizesFromPx stores + // [0%, 0%, …] and writeTracks paints a collapsed grid. The op + // can't produce a meaningful result before geometry exists — bail + // and leave c.sizes intact for the next live measurement. + if (avail <= 0) return; + + const containerAxisPx = this.containerAxisPx(parent); + const currentPx = measureChildrenPx(parent.el, parent.node.direction, parent.node.children.length, childElementAt,); + // Mins/maxes resolve against containerAxisPx (matching CSS), NOT + // availPx — otherwise the JS-computed floor/ceiling disagrees with + // what CSS displays and siblings absorb the difference into overflow. + const minPx = parent.node.children.map((child) => boundsMinPx(child.bounds, containerAxisPx)); + const maxPx = parent.node.children.map((child) => boundsMaxPx(child.bounds, containerAxisPx)); + const targetUpper = Math.max(0, avail - sumExceptAt(minPx, targetIdx)); + const targetLower = Math.max(0, avail - sumExceptAt(maxPx, targetIdx)); + const cappedTarget = Math.max(targetLower, Math.min(targetPx, targetUpper)); + + // Pin the target to `cappedTarget` via tight bounds + zero weight; + // `distributeProportional` snaps it on the first pass and water-fills + // the remaining `avail − cappedTarget` across the siblings against + // their own min/max bounds. Sibling weights come from current rendered + // sizes so dragging the divider preserves their relative ratios. + const weights = currentPx.map((w, i) => (i === targetIdx ? 0 : w)); + const pinnedMins = minPx.map((m, i) => (i === targetIdx ? cappedTarget : m)); + const pinnedMaxs = maxPx.map((m, i) => (i === targetIdx ? cappedTarget : m)); + const newSizesPx = distributeProportional(avail, weights, pinnedMins, pinnedMaxs); + + // Storage convention: c.sizes pct values are "% of pctBudgetPx", not + // "% of containerAxisPx" — so a saturated layout sums to exactly 100. + // The conversion to CSS-native % happens at writeTracks time. + // + // Siblings preserve their unit (px stays px, even when it has to + // shrink to make room for the target); the target itself always + // becomes pct so the saturation invariant holds. `sizesFromPx` + // computes the pct budget AFTER the unit decision — see its docs + // for the bug class this defends against. + const units: StorageUnit[] = parent.node.children.map((_, i) => { + if (i === targetIdx) return 'pct'; + return parent.sizes[i].unit === 'px' ? 'px' : 'pct'; + }); + + parent.sizes = sizesFromPx(newSizesPx, units, avail); + } + + private sumSiblingMins(parent: ContainerState, exceptIdx: number): number { + const axis = this.containerAxisPx(parent); + const mins = parent.node.children.map((c) => boundsMinPx(c.bounds, axis)); + + return sumExceptAt(mins, exceptIdx); + } + + /** + * Reads the container's full axis size (width for row, height for + * column). Branded as `ContainerAxisPx` so it can be passed to `toPx` + * / `pxToPct` / `clampToBounds` — the brand catches a careless caller + * who'd otherwise hand in `availPx` (which is the content-area + * budget, not the CSS-resolution denominator). + */ + private containerAxisPx(c: ContainerState): ContainerAxisPx { + return asContainerAxis(measureAxis(c.el, c.node.direction)); + } + + /** + * The pixel budget that backs `c.sizes` pct values. + * + * `c.sizes[i].value` of unit `pct` means "% of `pctBudgetPx(c)`" — NOT + * "% of the container axis." That's the storage convention every + * write path uses (`applyTargetSize`, `equalize`, `freezeFrTracks`, + * `pointerDrag.onMove`), and what `writeTracks` scales out of when + * it emits the CSS pct value. + * + * Budget = `availPx − Σpx_tracks(c.sizes)`. The "minus px tracks" part + * is the bit that distinguishes this from raw `availPx`: a pct sibling + * mixed with a px sibling should share the *remaining* content space, + * not the whole content space. Subtracting fixed px sizes here keeps + * the saturation invariant ("pct sizes sum to 100") well-defined + * regardless of mixed units. + * + * `fr` is special-cased to fill the remainder via CSS — it doesn't get + * subtracted from the budget. In practice, every internal write path + * runs `freezeFrTracks` first, so by the time storage math runs, fr + * has been folded into pct. + */ + private pctBudgetPx(c: ContainerState): PctBudgetPx { + // Recompute avail inline rather than reading the c.availPx cache. + // Structural mutators detach + re-add resizer DOM around c.sizes + // changes, and we want the budget that matches the post-mutation + // sizes — even if the ResizeObserver hasn't fired since. + // `resizerTracksPx` reads c.node.children.length, so it's already + // self-consistent with whatever the caller's mutation produced. + const total = this.containerAxisPx(c); + const avail = Math.max(0, total - this.resizerTracksPx(c, total)); + let pxSum = 0; + + for (const sz of c.sizes) { + if (sz.unit === 'px') pxSum += sz.value; + } + + return asPctBudget(Math.max(0, avail - pxSum)); + } + + /** + * Convert `c.sizes` from storage form (avail-pct of `pctBudgetPx`) to CSS + * form (container-pct of `containerAxisPx`) so the value handed to CSS + * resolves back to the same physical px. The conversion factor is + * `budget / container`; px and fr lengths pass through unchanged. + * + * This is the single boundary where stored values pivot to CSS-native + * semantics — every other site reads/writes pct in storage form. + * + * Pre-measure (container <= 0): pass through verbatim. At construction + * time `c.sizes` is still the user's original pct values (container-pct + * semantics, since they came from `parseLength`); those resolve correctly + * against CSS as-is. `normalizeInitialSizes` runs after first measure + * to fold them into storage form, after which the factor applies. + */ + private sizesForCss(c: ContainerState): Length[] { + const container = this.containerAxisPx(c); + const budget = this.pctBudgetPx(c); + + if (container <= 0 || budget <= 0) return c.sizes; + + const factor = budget / container; + + return c.sizes.map((sz) => (sz.unit === 'pct' + ? { unit: 'pct' as const, value: sz.value * factor } + : sz)); + } + + /** + * Convert a parsed `Length` from user-input form (pct meaning "% of + * container") to storage form (pct meaning "% of pctBudgetPx"). Px and + * fr pass through; pct gets the `container / budget` scale. + * + * Returns the input verbatim when geometry isn't available yet — that + * case only matters at initial mount, and `normalizeInitialSizes` + * sweeps everything to storage form once the first measure has run. + */ + private toStorageForm(l: Length, c: ContainerState): Length { + if (l.unit !== 'pct') return l; + + const container = this.containerAxisPx(c); + const budget = this.pctBudgetPx(c); + + if (container <= 0 || budget <= 0) return l; + + return { unit: 'pct', value: l.value * (container / budget) }; + } + + /** + * One-time fold of user-input pct values (left in `c.sizes` by + * `buildContainer` before geometry was available) into storage form. + * Recurses into nested containers. Called from `mount` after the + * synchronous initial `measureAll` — by then every container has a + * non-zero `availPx` and the factor is well-defined. + * + * After this pass, `c.sizes` everywhere is in storage form and the + * "stored pct sums to 100 when saturated" invariant holds. Subsequent + * mutators (`setSize`, `equalize`, `addChild`, …) maintain it. + */ + private normalizeInitialSizes(c: ContainerState): void { + const container = this.containerAxisPx(c); + const budget = this.pctBudgetPx(c); + + if (container > 0 && budget > 0) { + const factor = container / budget; + + c.sizes = c.sizes.map((sz) => (sz.unit === 'pct' + ? { unit: 'pct' as const, value: sz.value * factor } + : sz)); + } + + for (const child of c.node.children) { + const s = this.byId.get(child.id); + + if (s && isContainerState(s)) this.normalizeInitialSizes(s); + } + } + + /** + * Total px consumed by every resizer track in `c` at the given axis size. + * Match what `trackString` actually emits, or downstream pixel math drifts: + * - (children.length - 1) inner dividers between siblings + * - +1 for an optional leading decorative track (`resizer.first`) + * - +1 for an optional trailing decorative track (`resizer.last`) + * Honors the same fallback chain as `rebuildResizers`/`buildContainer`: + * the node's own `resizer` overrides; otherwise inherit `cfg.resizer`. + */ + private resizerTracksPx(c: ContainerState, axisPx: ContainerAxisPx): number { + const spec = this.getResizerSpec(c); + const resizerPx = parseLengthToPxAxis(spec?.size ?? 6, axisPx); + const numChildren = c.node.children.length; + const innerCount = Math.max(0, numChildren - 1); + const edgeCount = (spec?.first && numChildren > 0 ? 1 : 0) + + (spec?.last && numChildren > 0 ? 1 : 0); + + return resizerPx * (innerCount + edgeCount); + } + + /** + * Synchronously refresh `c.availPx` from a live measurement. The ResizeObserver + * keeps it up to date asynchronously via `scheduleMeasure`, but any layout + * method called before the first rAF fires (tests, or right after mount) + * would otherwise see `availPx === 0` and produce degenerate math. This is + * the lazy fallback: O(1) reads, cheap enough to run on every public call. + */ + private refreshAvail(c: ContainerState): void { + const total = this.containerAxisPx(c); + + c.availPx = Math.max(0, total - this.resizerTracksPx(c, total)); + } + + /** + * Entry barrier for every container-mutating layout op (setSize, + * toggleExpand, equalize). Two cheap stabilization steps: + * + * 1. `refreshAvail` — pull a fresh `availPx` measurement so the math + * uses the current rect, not a possibly-stale ResizeObserver value. + * 2. `freezeFrTracks` — concretize any fr tracks into stored pct so + * mutations work in a stable storage frame. + * + * Always run together, always at the top of the op. Pulling this out + * keeps the layout methods focused on their distinct logic and prevents + * "forgot to call freezeFrTracks first" regressions. + * + * Returns `false` when geometry isn't usable (detached host, pre-measure, + * display:none parent — anything that lands `availPx` at 0). Callers + * should bail without mutating; running the layout math on a zero + * budget stores [0%, 0%, …] and paints a collapsed grid. The freeze + * step also needs a non-zero budget — `pxToPct(px, 0)` returns + * garbage — so we short-circuit before either side runs. + */ + private prepareForLayoutOp(c: ContainerState): boolean { + this.refreshAvail(c); + + if (c.availPx <= 0) return false; + + this.freezeFrTracks(c); + return true; + } + + /** + * `data` may be a thunk to defer expensive payload construction (Array.map, + * getBoundingClientRect, etc.) until we know we're actually going to log. + * When debug is off — the common case in production — the thunk is never + * called and callers pay only the function-allocation cost. + */ + private log(label: string, data?: LogPayload | (() => LogPayload)): void { + if (!this.cfg.debug) return; + + const resolved = typeof data === 'function' ? data() : data; + + // eslint-disable-next-line no-console + console.log(`[SplitGrid] ${label}`, resolved ?? ''); + } + + /** + * Register a listener for layout-change events. Returns an unsubscribe + * function. Multiple subscribers are supported; they fire alongside + * `cfg.onChange` (which is a single-callback shortcut for the same + * delivery path). A throwing subscriber doesn't block the rest. + */ + subscribe(listener: LayoutListener): () => void { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + + /** + * Returns a Promise that resolves when the relevant container's layout + * transition finishes (or immediately if there is nothing to wait for). + * + * - No `containerId`: wait for every container in the tree to settle. + * - A leaf id, an unknown id, or a call before mount: resolves + * immediately. Leaves don't have their own transition; they're tracks + * inside a parent container. + * + * Backed by the standard `transitionend` event on the container element + * (`grid-template-columns` / `grid-template-rows`). Because `transitionend` + * is not guaranteed to fire (e.g. when `animate: false` was used or the + * value didn't actually change), every wait is also guarded by a timeout + * — `opts.timeout` (default: `animationMs + 100`). + */ + async settle( + containerId?: string, + opts: { timeout?: number } = {}, + ): Promise { + if (!this.rootEl) return; + + const containers: ContainerState[] = []; + + if (containerId === undefined) { + const walk = (c: ContainerState) => { + containers.push(c); + + for (const child of c.node.children) { + const childState = this.byId.get(child.id); + + if (childState && isContainerState(childState)) walk(childState); + } + }; + + walk(this.rootEl); + } else { + const s = this.byId.get(containerId); + + // Unknown id or leaf — nothing to settle. + if (!s || !isContainerState(s)) return; + + containers.push(s); + } + + const timeout = opts.timeout ?? (this.cfg.animationMs ?? 750) + 100; + + await Promise.all(containers.map((c) => waitForContainerTransition(c.el, timeout))); + } + + /** + * Notify the consumer that `container`'s layout changed. The sizes array + * is cloned so callers can hold on to it without seeing later mutations. + * `nodeIds` lists the nodes directly affected by this change — empty + * means "every child of `containerId` was touched" (drag, equalize, + * reset, set-direction). + * + * Pass a two-element tuple of containers (cross-parent swap) to fire a + * single composed event covering both containers; `containerId` and + * `sizes` on the event become tuples in the same positional order. + */ + private emit( + reason: LayoutChangeReason, + container: ContainerState | readonly [ContainerState, ContainerState], + nodeIds: string[] = [], + ): void { + const event: LayoutChangeEvent = Array.isArray(container) + ? { + containerId: [container[0].node.id, container[1].node.id], + reason, + sizes: [[...container[0].sizes], [...container[1].sizes]], + nodeIds, + } + : { + containerId: (container as ContainerState).node.id, + reason, + sizes: [...(container as ContainerState).sizes], + nodeIds, + }; + + // Both delivery paths get the same try/catch: a misbehaving + // consumer on one path shouldn't break the other (or block + // subsequent subscribers in the loop below). The cfg.onChange + // path used to run unwrapped, so a thrown error there aborted + // the rest of the emit before subscribers ran. + if (this.cfg.onChange) { + try { + this.cfg.onChange(event); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[SplitGrid] cfg.onChange threw:', error); + } + } + + for (const sub of this.subscribers) { + try { + sub(event); + } catch (error) { + // Surface the error without aborting subsequent subscribers — one + // misbehaving consumer shouldn't break the rest of the chain. + // eslint-disable-next-line no-console + console.error('[SplitGrid] subscriber threw:', error); + } + } + } + + // ---------------------------------------------------------------- construction + + private buildContainer(node: Container, parent: ContainerState | undefined = undefined, indexInParent = 0): ContainerState { + const el = document.createElement('div'); + + el.className = 'sp-container'; + el.dataset.direction = node.direction; + el.dataset.id = node.id; + + const state: ContainerState = { + node, + el, + sizes: node.children.map((c) => parseLength(c.bounds?.size)), + max: null, + availPx: 0, + parent, + indexInParent, + }; + + this.byId.set(node.id, state); + + const resizerSpec = this.getResizerSpec(state); + + if (resizerSpec?.first && node.children.length > 0) { + el.append(this.buildResizer(state, -1, resizerSpec)); + } + + for (const [i, child] of node.children.entries()) { + if (i > 0) el.append(this.buildResizer(state, i, resizerSpec)); + + const childEl = isContainer(child) + ? this.buildContainer(child, state, i).el + : this.buildLeaf(child, state, i).el; + + el.append(childEl); + } + + if (resizerSpec?.last && node.children.length > 0) { + el.append(this.buildResizer(state, node.children.length, resizerSpec)); + } + + this.writeTracks(state); + return state; + } + + private buildLeaf(node: Leaf, parent: ContainerState, indexInParent: number): LeafState { + const el = document.createElement('div'); + + el.className = 'sp-panel'; + el.dataset.id = node.id; + + const state: LeafState = { + node, el, parent, indexInParent, + }; + + this.byId.set(node.id, state); + + this.cfg.renderLeaf?.({ + id: node.id, data: node.data as T | undefined, el, leaf: node as Leaf, + }); + return state; + } + + private buildResizer(parent: ContainerState, handleIdx: number, spec: ResizerSpec | undefined): HTMLElement { + // Browsers don't fire a `tripleclick` event, but `MouseEvent.detail` + // counts successive clicks within the platform's repeat window. We + // gate the toggle-maximize action behind a short timer so a follow-up + // third click can promote the gesture to equalize before the maximize + // fires. Tax: every plain dblclick feels ~250ms slower than instant. + const TRIPLE_CLICK_GRACE_MS = 250; + const el = document.createElement('div'); + const numChildren = parent.node.children.length; + // -1 = leading decorative track (before first child). + // numChildren = trailing decorative track (after last child). + // 1..(numChildren-1) = inner draggable divider. + const isLeading = handleIdx === -1; + const isTrailing = handleIdx === numChildren; + const isDecorative = isLeading || isTrailing; + + el.className = isDecorative ? 'sp-resizer sp-resizer--edge' : 'sp-resizer'; + el.dataset.handle = String(handleIdx); + + if (isLeading) el.dataset.edge = 'leading'; + + if (isTrailing) el.dataset.edge = 'trailing'; + + el.setAttribute('role', 'separator'); + el.setAttribute('aria-orientation', parent.node.direction === PanelDirection.Row ? 'vertical' : 'horizontal'); + + // For an inner divider at handleIdx i: before=children[i-1], after=children[i]. + // For the leading edge (handleIdx -1): no panel before; the panel + // immediately AFTER it is children[0] — consumers reading + // `resizer.after` to label the first panel rely on this. For the + // trailing edge (handleIdx numChildren): the last panel is BEFORE it, + // nothing after. The naive `children[handleIdx]` lookup fails the + // leading case because `children[-1] === undefined`. + const before = isLeading ? undefined : parent.node.children[handleIdx - 1]; + const after = isLeading + ? parent.node.children[0] + : (isTrailing ? undefined : parent.node.children[handleIdx]); + + spec?.render?.(el, { index: handleIdx, before, after }); + + // Decorative tracks (leading / trailing): no drag — there's no panel on + // the "outside" to push around — but they DO accept the same dblclick + // / triple-click gestures as inner dividers, targeting the adjacent + // panel. Leading edge targets children[0]; trailing edge targets the + // last child. + // dblclick → toggleMaximize on adjacent + // triple-click → equalize parent + if (isDecorative) { + const targetIdx = isLeading ? 0 : numChildren - 1; + const targetId = parent.node.children[targetIdx]?.id; + + if (targetId) { + let pendingToggle: ReturnType | null = null; + + el.addEventListener('click', (e) => { + if (e.detail === 2) { + if (pendingToggle) clearTimeout(pendingToggle); + + pendingToggle = setTimeout(() => { + pendingToggle = null; + this.toggleMaximize(targetId); + }, TRIPLE_CLICK_GRACE_MS); + } else if (e.detail >= 3) { + if (pendingToggle) { + clearTimeout(pendingToggle); + pendingToggle = null; + } + + this.equalize(parent.node.id); + } + }); + } + return el; + } + + // Purely a visual affordance — the handle is positioned absolutely in + // the center of the resizer track, grows on hover, and styles via + // `--sp-indicator-*` CSS variables. The click listeners live on the + // parent resizer (NOT on the handle) so they fire regardless of + // whether the cursor was on the visible bar or the surrounding pixels. + // Consumer-customized resizers (with teleported slot content) hide + // this handle via CSS. + const handle = document.createElement('div'); + + handle.className = 'sp-resizer-handle'; + el.append(handle); + + // Click-count gestures: dblclick toggles maximize on the panel closer + // to the layout edge; triple-click escalates to equalize the whole + // container. The triple-click path cancels the pending dblclick so + // only one action fires per gesture. + let pendingToggle: ReturnType | null = null; + + el.addEventListener('click', (e) => { + if (e.detail === 2) { + if (pendingToggle) clearTimeout(pendingToggle); + + pendingToggle = setTimeout(() => { + pendingToggle = null; + + const beforeIdx = handleIdx - 1; + const afterIdx = handleIdx; + const beforeDist = Math.min(beforeIdx, numChildren - 1 - beforeIdx); + const afterDist = Math.min(afterIdx, numChildren - 1 - afterIdx); + const targetIdx = afterDist < beforeDist ? afterIdx : beforeIdx; + const targetId = parent.node.children[targetIdx].id; + + this.toggleMaximize(targetId); + }, TRIPLE_CLICK_GRACE_MS); + } else if (e.detail >= 3) { + if (pendingToggle) { + clearTimeout(pendingToggle); + pendingToggle = null; + } + + this.equalize(parent.node.id); + } + }); + + this.attachDrag(el, parent, handleIdx); + return el; + } + + // ---------------------------------------------------------------- drag + + /** + * Wire `el` up to drag-resize its container via `PointerDragState`. The + * state machine owns the pointer-event lifecycle (down → move → end); + * the adapter callbacks below glue it back to the grid's writeTracks, + * emit, and diagnostic-log paths. The instance is stored in + * `dragStates` so `detachResizers` can dispose it — without that, a + * structural mutation mid-drag would orphan the global pointermove + * listener. + */ + private attachDrag(el: HTMLElement, parent: ContainerState, handleIdx: number): void { + const state = new PointerDragState(el, parent, handleIdx, { + resizerTracksPx: (target, axisPx) => this.resizerTracksPx(target as ContainerState, axisPx), + childElementAt: (containerEl, index) => childElementAt(containerEl, index), + onDragApply: (target) => { + const c = target as ContainerState; + + this.writeTracks(c); + this.emit('drag', c); + }, + log: (label, data) => this.log(label, data as Parameters[1]), + }); + + this.dragStates.set(el, state); + } + + // ---------------------------------------------------------------- measure / write + + private scheduleMeasure(): void { + if (this.rafScheduled) return; + + this.rafScheduled = true; + this.rafHandle = requestAnimationFrame(() => { + this.rafScheduled = false; + this.rafHandle = null; + + if (!this.rootEl) return; + + this.measureAll(this.rootEl); + // Stored pct values are "% of pctBudgetPx" — `sizesForCss` scales + // them to "% of containerAxisPx" at write time. That scale factor + // depends on the current container size, so a container resize + // invalidates whatever pct string was last written. Re-emit + // tracks (animate: false — the user IS the input, we just keep + // CSS in lockstep) so panels with px-sized siblings or resizer + // tracks still fit the new bounds. + this.writeAllTracks(this.rootEl); + }); + } + + private measureAll(c: ContainerState): void { + const totalAxisPx = asContainerAxis(measureAxis(c.el, c.node.direction)); + + c.availPx = Math.max(0, totalAxisPx - this.resizerTracksPx(c, totalAxisPx)); + + // Concretize any fr sizes so nested containers can also drag correctly. + // (Only the first measure does meaningful work; subsequent rect changes + // just update availPx for the next drag start.) + for (const child of c.node.children) { + const s = this.byId.get(child.id); + + if (s && isContainerState(s)) this.measureAll(s); + } + } + + private writeTracks(c: ContainerState, opts?: LayoutOptions): void { + // Stored sizes are in "% of pctBudgetPx" form; CSS wants "% of + // containerAxisPx". `sizesForCss` does the one-way conversion at + // this single emit boundary — keep that asymmetry here and don't + // leak it back into storage by writing the scaled values to c.sizes. + const tracks = trackString(c.node, this.sizesForCss(c), this.getResizerSpec(c)); + const skip = opts?.animate === false; + + // Suppress the transition for this single write by setting the same + // attribute freezeFrTracks uses — the CSS rule short-circuits the + // transition. Forcing a layout flush before removing the attribute + // commits the new tracks in the no-animate state. + if (skip) c.el.dataset.noAnimate = ''; + + c.el.style.setProperty('--sp-tracks', tracks); + + if (skip) { + // Reading offsetWidth forces a synchronous layout flush: the browser + // has to compute styles *now* to return the value. That commits the + // track write with data-no-animate still set, so the transition is + // suppressed. Without this, all three mutations (set attr, write + // tracks, remove attr) batch into a single recalc with the attribute + // gone — and the transition runs anyway. The void-expression form + // looks like dead code to lint, hence the disable. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + c.el.offsetWidth; + delete c.el.dataset.noAnimate; + } + + if (this.cfg.debug) { + // Schedule one rAF so the browser has applied the new tracks before we + // read back the resolved widths — that's the value that matters for + // diagnosing "panel ended up at the wrong size". + requestAnimationFrame(() => { + const childRects = c.node.children.map((child, i) => { + const childEl = childElementAt(c.el, i); + + return { id: child.id, px: childEl ? measureAxis(childEl, c.node.direction) : -1 }; + }); + + this.log('writeTracks', () => ({ + containerId: c.node.id, + direction: c.node.direction, + containerAxisPx: measureAxis(c.el, c.node.direction), + tracks, + sizes: c.sizes.map((l) => formatLengthForLog(l)), + resolved: childRects, + })); + }); + } + } +} diff --git a/src/__spec__/SplitGrid.dom.spec.ts b/src/__spec__/SplitGrid.dom.spec.ts new file mode 100644 index 0000000..2a74977 --- /dev/null +++ b/src/__spec__/SplitGrid.dom.spec.ts @@ -0,0 +1,2224 @@ +// @vitest-environment happy-dom +/** + * DOM-bound tests for the SplitGrid runtime. The `// @vitest-environment` + * pragma above tells vitest to load happy-dom for this file. The + * `.dom.test.ts` suffix is a naming convention so the tree tips you off. + * + * Layout-resolved measurements (getBoundingClientRect, computed track widths) + * aren't accurate under happy-dom because no real layout engine runs. We stub + * getBoundingClientRect to return a fixed 1000×500 rect so the bits of the + * runtime that read it produce deterministic numbers. Tests that wouldn't be + * robust against that stub (e.g. asserting an exact pct after setSize) are + * deliberately not written here — they're better covered by manual demo runs. + */ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { SplitGrid } from '../SplitGrid'; +import type { LayoutChangeEvent } from '../SplitGrid'; +import type { Container } from '../types'; +import { PanelDirection } from '../types'; + +let host: HTMLElement; +const onChange = vi.fn<(e: LayoutChangeEvent) => void>(); + +beforeEach(() => { + // happy-dom returns a zero-sized rect by default; fix it so availPx > 0. + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 500, width: 1000, height: 500, toJSON: () => ({}), + } as DOMRect)); + host = document.createElement('div'); + document.body.append(host); + onChange.mockReset(); +}); + +afterEach(() => { + host.remove(); +}); + +function tree(): Container { + return { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { min: '40px' }, data: { label: 'A' } }, + { id: 'b', bounds: { min: '40px' }, data: { label: 'B' } }, + { id: 'c', bounds: { min: '40px' }, data: { label: 'C' } }, + ], + }; +} + +function mount(root: Container = tree()) { + const grid = new SplitGrid({ root, onChange }); + + grid.mount(host); + return grid; +} + +describe('mount / unmount', () => { + it('builds the DOM mirror: one container + N panels + (N-1) resizers', () => { + mount(); + + const container = host.querySelector('.sp-container')!; + + expect(container).toBeTruthy(); + expect(container.querySelectorAll(':scope > .sp-panel')).toHaveLength(3); + expect(container.querySelectorAll(':scope > .sp-resizer')).toHaveLength(2); + }); + + it('writes a `--sp-tracks` inline custom property on the container', () => { + mount(); + + const tracks = (host.querySelector('.sp-container') as HTMLElement).style.getPropertyValue('--sp-tracks'); + + expect(tracks).toContain('1fr'); + // 3 content tracks + 2 resizer tracks. + expect(tracks.split(/\s+(?=(?:[^()]|\([^()]*\))*$)/)).toHaveLength(5); + }); + + it('unmount detaches the root element', () => { + const grid = mount(); + + grid.unmount(); + expect(host.children).toHaveLength(0); + }); + + it('unmount cancels any pending scheduleMeasure rAF', () => { + // Regression: scheduleMeasure used to set rafScheduled = true and + // never cancel the rAF. If the host went away mid-frame, the + // dedup guard prevented future measure requests on a re-attached + // instance from running. The fix cancels the pending handle and + // resets the flag so a fresh scheduleMeasure can fire. + const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame'); + const cancelSpy = vi.spyOn(globalThis, 'cancelAnimationFrame'); + const grid = mount(); + + // Trigger a scheduleMeasure via ResizeObserver-equivalent path. The + // public surface doesn't expose it directly; the existing rAF call + // chain (mount → buildContainer paths, or measureAll's write) is + // enough to leave at least one rAF in flight after mount. + // To force one: bump `scheduleMeasure` via a writeTracks debug path + // is invasive; instead, drive a no-op layout that schedules nothing. + // The mount itself doesn't schedule (synchronous measure path), so + // we synthesize the case: call `scheduleMeasure` via the + // ResizeObserver callback path the constructor wired up. + // + // happy-dom doesn't fire ResizeObserver; reach into the spy and + // confirm SOME rAF was enqueued during normal lifecycle, then + // confirm unmount cancels what's pending. + rafSpy.mockClear(); + + // Force a measure schedule by emulating a resize — easier via the + // `setDirection` path which calls scheduleMeasure. + grid.setDirection('root', PanelDirection.Column); + expect(rafSpy).toHaveBeenCalled(); + + const lastHandle = rafSpy.mock.results.at(-1)?.value as number | undefined; + + grid.unmount(); + + // unmount called cancelAnimationFrame with the pending handle. + expect(cancelSpy).toHaveBeenCalled(); + + if (lastHandle != null) expect(cancelSpy).toHaveBeenCalledWith(lastHandle); + + rafSpy.mockRestore(); + cancelSpy.mockRestore(); + }); +}); + +describe('addChild / removeChild', () => { + it('appends a child by default and emits add-child', () => { + const grid = mount(); + + grid.addChild('root', { id: 'd', data: { label: 'D' } }); + + const grid2 = grid; + const container = host.querySelector('.sp-container')!; + + expect(container.querySelectorAll(':scope > .sp-panel')).toHaveLength(4); + expect(container.querySelectorAll(':scope > .sp-resizer')).toHaveLength(3); + expect(grid2.get('d')).toBeDefined(); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'add-child' })); + }); + + it('inserts at an explicit index', () => { + const grid = mount(); + + grid.addChild('root', { id: 'x', data: { label: 'X' } }, 1); + + const ids = [...host.querySelectorAll('.sp-panel')].map((el) => (el as HTMLElement).dataset.id); + + expect(ids).toEqual(['a', 'x', 'b', 'c']); + }); + + it('rejects duplicate ids without mutating', () => { + const grid = mount(); + + grid.addChild('root', { id: 'a', data: { label: 'dup' } }); + expect(host.querySelectorAll('.sp-panel')).toHaveLength(3); + }); + + it('removeChild detaches the element and clears its byId entry', () => { + const grid = mount(); + + grid.removeChild('b'); + expect(host.querySelectorAll('.sp-panel')).toHaveLength(2); + expect(grid.get('b')).toBeUndefined(); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'remove-child' })); + }); + + it('removeChild is a no-op for the root', () => { + const grid = mount(); + + grid.removeChild('root'); + expect(host.querySelectorAll('.sp-container')).toHaveLength(1); + expect(grid.get('root')).toBeDefined(); + }); + + it('suppresses the transition during add (track count change can\'t animate)', () => { + const grid = mount(); + const rootEl = host.querySelector('.sp-container') as HTMLElement; + + // Spy on `.dataset` writes is fragile; instead read the attribute back + // right after addChild. The implementation sets `data-no-animate` for + // the write, forces a layout, then removes it. The final state should + // have the attribute absent — proving the write completed without + // leaving the transition-suppress flag stuck on. + grid.addChild('root', { id: 'd' }); + expect(Object.hasOwn(rootEl.dataset, 'noAnimate')).toBe(false); + }); + + it('suppresses the transition during remove', () => { + const grid = mount(); + const rootEl = host.querySelector('.sp-container') as HTMLElement; + + grid.removeChild('b'); + expect(Object.hasOwn(rootEl.dataset, 'noAnimate')).toBe(false); + }); + + it('addChild after equalize+remove cycle keeps new panel inside the container', () => { + // Regression for two related bugs: + // 1. Once any layout op runs (equalize, maximize, setSize), fr tracks + // freeze into pct via freezeFrTracks. A fresh `1fr` track spliced + // in by addChild would be allocated zero (or negative) space and + // render outside the viewport. `makeRoom` fixes this by converting + // the new fr to its fair pct share. + // 2. Each structural mutation changes the resizer-track count, so + // the pct budget changes. The sum-to-100 invariant (stored pct + // sizes sum to 100% of pctBudgetPx) is maintained by `makeRoom` + // on insert and `absorbVacancy` on remove. Without that, drift + // compounds across cycles and the new panel renders just past + // the container edge. + const grid = mount(); + + grid.equalize('root'); + grid.removeChild('b'); + grid.removeChild('c'); + grid.addChild('root', { id: 'd' }); + grid.addChild('root', { id: 'e' }); + grid.addChild('root', { id: 'f' }); + grid.addChild('root', { id: 'g' }); + + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const { sizes } = state; + const pctSizes = sizes.filter((s) => s.unit === 'pct'); + + // All sizes should now be pct (frs converted via makeRoom on each insert). + expect(pctSizes).toHaveLength(sizes.length); + + // Storage-form invariant: pct values sum to exactly 100. The CSS + // emit at writeTracks scales by budget/container so the rendered + // tracks fit inside the actual container axis. + const sum = pctSizes.reduce((a, s) => a + s.value, 0); + + expect(sum).toBeCloseTo(100, 1); + + // Cycle-add preserves equality of surviving siblings — five panels + // at an even 1/5 share of 100% = 20% each in storage form. + for (const s of pctSizes) { + expect(s.value).toBeCloseTo(20, 1); + } + }); + + it('addChild and removeChild clear any maximize state on the container', () => { + // Regression: `maxId` / `restore` track the maximize snapshot. After a + // structural mutation the snapshot length wouldn't match `sizes.length` + // anymore, so `toggleExpand` restore would corrupt the layout. Cleared + // explicitly on every add and remove. + const grid = mount(); + + grid.toggleExpand('b'); + expect(grid.isMaximized('b')).toBe(true); + + grid.addChild('root', { id: 'x' }); + expect(grid.isMaximized('b')).toBe(false); + + grid.toggleExpand('c'); + expect(grid.isMaximized('c')).toBe(true); + + grid.removeChild('a'); + expect(grid.isMaximized('c')).toBe(false); + }); + + it('keeps the new panel in the grid after a clear-and-readd cycle', () => { + const grid = mount(); + + // Clear everything. + grid.removeChild('a'); + grid.removeChild('b'); + grid.removeChild('c'); + expect((grid.get('root') as { node: { children: unknown[] } }).node.children).toEqual([]); + // Re-add. The track string must be set such that the new panel is the + // sole track (and therefore the panel is positioned inside the grid, + // not in an implicit row). + grid.addChild('root', { id: 'fresh' }); + + const tracks = (host.querySelector('.sp-container') as HTMLElement) + .style.getPropertyValue('--sp-tracks'); + + expect(tracks).toContain('1fr'); + // No resizers needed for a single child. + expect(tracks.split(/\s+(?=(?:[^()]|\([^()]*\))*$)/)).toHaveLength(1); + }); +}); + +describe('swap (structural)', () => { + it('reorders two leaves within the same parent and emits swap', () => { + const grid = mount(); + + grid.swap('a', 'c'); + + const ids = [...host.querySelectorAll(':scope > .sp-container > .sp-panel')] + .map((el) => (el as HTMLElement).dataset.id); + + expect(ids).toEqual(['c', 'b', 'a']); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'swap' })); + }); + + it('updates indexInParent for the swapped nodes', () => { + const grid = mount(); + + grid.swap('a', 'c'); + expect(grid.get('a')?.indexInParent).toBe(2); + expect(grid.get('c')?.indexInParent).toBe(0); + }); + + it('cross-parent swap moves nodes between containers', () => { + const root: Container = { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a' }, + { + id: 'group', + direction: PanelDirection.Column, + children: [{ id: 'b' }, { id: 'c' }], + }, + ], + }; + const grid = mount(root); + + grid.swap('a', 'b'); + // `a` now lives inside `group`; `b` is at the root. + expect(grid.get('a')?.parent?.node.id).toBe('group'); + expect(grid.get('b')?.parent?.node.id).toBe('root'); + }); + + it('cross-parent swap emits a single composed event with both container ids', () => { + // Previously fired two 'swap' events, one per container. Consumers + // had to dedupe by inspecting nodeIds. The composed shape is a + // single event with containerId = [pA, pB] and sizes = [aSizes, bSizes]. + const root: Container = { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a' }, + { + id: 'group', + direction: PanelDirection.Column, + children: [{ id: 'b' }, { id: 'c' }], + }, + ], + }; + const grid = mount(root); + const swaps: LayoutChangeEvent[] = []; + + grid.subscribe((e) => { + if (e.reason === 'swap') swaps.push(e); + }); + + grid.swap('a', 'b'); + + expect(swaps).toHaveLength(1); + + const ev = swaps[0]; + + expect(Array.isArray(ev.containerId)).toBe(true); + expect(ev.containerId).toEqual(expect.arrayContaining(['root', 'group'])); + expect(Array.isArray(ev.sizes)).toBe(true); + // sizes follows containerId order: sizes[i] are containerId[i]'s child sizes. + expect((ev.sizes as unknown as unknown[]).length).toBe(2); + expect(ev.nodeIds).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('same-parent swap stays a single-container event', () => { + const grid = mount(); + const swaps: LayoutChangeEvent[] = []; + + grid.subscribe((e) => { + if (e.reason === 'swap') swaps.push(e); + }); + + grid.swap('a', 'c'); + expect(swaps).toHaveLength(1); + expect(typeof swaps[0].containerId).toBe('string'); + expect(swaps[0].containerId).toBe('root'); + }); + + it('rejects ancestor/descendant swap', () => { + const root: Container = { + id: 'root', + direction: PanelDirection.Row, + children: [{ + id: 'group', direction: PanelDirection.Column, children: [{ id: 'inner' }], + }], + }; + const grid = mount(root); + + grid.swap('group', 'inner'); + expect(grid.get('group')?.parent?.node.id).toBe('root'); + expect(grid.get('inner')?.parent?.node.id).toBe('group'); + }); +}); + +describe('setData / swapData', () => { + it('setData updates a leaf\'s data and emits set-data; sizes untouched', () => { + const grid = mount(); + const before = (grid.get('root') as { sizes: unknown[] }).sizes; + const beforeCopy = [...before]; + + grid.setData('a', { label: 'A!' }); + + const state = grid.get('a'); + + expect((state?.node as { data: { label: string } }).data.label).toBe('A!'); + expect((grid.get('root') as { sizes: unknown[] }).sizes).toEqual(beforeCopy); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-data' })); + }); + + it('swapData exchanges the data fields and keeps slots in place', () => { + const grid = mount(); + + grid.swapData('a', 'c'); + + const aData = (grid.get('a')?.node as { data: { label: string } }).data; + const cData = (grid.get('c')?.node as { data: { label: string } }).data; + + expect(aData.label).toBe('C'); + expect(cData.label).toBe('A'); + + // Slots stay in place. + const ids = [...host.querySelectorAll(':scope > .sp-container > .sp-panel')] + .map((el) => (el as HTMLElement).dataset.id); + + expect(ids).toEqual(['a', 'b', 'c']); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'swap-data' })); + }); + + it('swapData no-ops on containers', () => { + const root: Container = { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'g', direction: PanelDirection.Column, children: [{ id: 'g1' }] }, + { id: 'leaf', data: { label: 'L' } }, + ], + }; + const grid = mount(root); + const before = (grid.get('leaf')?.node as { data: unknown }).data; + + grid.swapData('g', 'leaf'); + expect((grid.get('leaf')?.node as { data: unknown }).data).toBe(before); + }); +}); + +describe('setBounds / setDirection', () => { + it('setBounds merges partial bounds and emits set-bounds', () => { + const grid = mount(); + + grid.setBounds('a', { min: '120px' }); + expect(grid.get('a')?.node.bounds?.min).toBe('120px'); + // Pre-existing fields stay (data/etc). + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-bounds' })); + }); + + it('setBounds with a `size` rebalances siblings into the requested share', () => { + const grid = mount(); + + grid.setBounds('a', { size: '40%' }); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + // Storage is "% of pctBudgetPx", so `setBounds({size: '40%'})` (user + // input = 40% of container) lands slightly *above* 40 — for a 1000px + // container with two 6px resizers the value is 40 × 1000/988 ≈ 40.49. + // The CSS emit at writeTracks scales back; the rendered panel is 40% + // of the container. Sum-to-100 is the saturation invariant in storage. + expect(sizes[0].unit).toBe('pct'); + expect(sizes[0].value).toBeGreaterThan(40); + expect(sizes[0].value).toBeLessThan(41); + expect(sizes.reduce((acc, s) => acc + s.value, 0)).toBeCloseTo(100, 1); + }); + + it('setBounds raises a panel up to a new min and shrinks siblings to fit', () => { + const grid = mount(); + + // Drive `a` to its current (small) min first. + grid.minimize('a'); + + const sizesBefore = (grid.get('root') as { sizes: Array<{ value: number }> }).sizes; + + expect(sizesBefore[0].value).toBeLessThan(10); + + // Now raise the min well above the current size. The fix is that we must + // re-clamp the stored size AND rebalance siblings — otherwise CSS's + // `clamp()` resolves `a` to the new min, siblings keep their large pcts, + // and the total tracks overflow the container. + grid.setBounds('a', { min: '300px' }); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + expect(sizes[0].unit).toBe('pct'); + // 300px ≈ 30% of the 1000px stub container. + expect(sizes[0].value).toBeGreaterThan(25); + // No overflow: pct-only sum + resizer fraction = 100%. + expect(sizes.reduce((acc, s) => acc + s.value, 0)).toBeLessThanOrEqual(100); + }); + + it('setBounds lowers a panel onto a new max and grows siblings to fill', () => { + const grid = mount(); + + // Push `a` up to a large size first. + grid.setSize('a', '80%'); + + const sizesBefore = (grid.get('root') as { sizes: Array<{ value: number }> }).sizes; + + expect(sizesBefore[0].value).toBeGreaterThan(50); + + // Cap it. Same bug as the min case, opposite direction. + grid.setBounds('a', { max: '100px' }); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + expect(sizes[0].value).toBeLessThan(15); // 100px ≈ 10% of 1000 + expect(sizes.reduce((acc, s) => acc + s.value, 0)).toBeLessThanOrEqual(100); + }); + + it('setBounds on the root persists bounds and emits set-bounds', () => { + // Root has no parent container to rebalance against, so the body's + // sibling-rebalance branch is skipped — but the bounds patch still + // needs to land in node.bounds, and subscribers persisting bounds via + // onChange need to see the change. + const grid = mount(); + + onChange.mockReset(); + grid.setBounds('root', { min: '100px', max: '900px' }); + expect(grid.get('root')?.node.bounds?.min).toBe('100px'); + expect(grid.get('root')?.node.bounds?.max).toBe('900px'); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'set-bounds', containerId: 'root', nodeIds: ['root'], + })); + }); + + it('setDirection toggles the container\'s axis attribute', () => { + const grid = mount(); + const containerEl = host.querySelector('.sp-container') as HTMLElement; + + expect(containerEl.dataset.direction).toBe('row'); + grid.setDirection('root', PanelDirection.Column); + expect(containerEl.dataset.direction).toBe('column'); + // `node` is typed as `Leaf | Container`; direction lives only on Container. + expect((grid.get('root')?.node as { direction: string }).direction).toBe('column'); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-direction' })); + }); + + it('setDirection is a no-op when the direction is unchanged', () => { + const grid = mount(); + + onChange.mockReset(); + grid.setDirection('root', PanelDirection.Row); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('layout commands (setSize, equalize, reset)', () => { + it('setSize emits set-size', () => { + const grid = mount(); + + grid.setSize('a', '30%'); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-size' })); + }); + + it('setSize is a no-op when availPx is 0 (pre-measure / detached host)', () => { + // Regression: when the host has a zero rect (mid-mount, detached, or + // display:none parent), applyTargetSize used to distribute zero across + // every child and store [0%, 0%, ...]. The next legitimate measure + // would then paint a collapsed grid until equalize/reset ran again. + const grid = mount(); + const before = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }).sizes + .map((s) => ({ ...s })); + + // Stub the rect back to zero so refreshAvail produces availPx = 0 + // *for this op only*. The mounted state still has the prior 1000×500 + // sizes from the beforeEach stub frozen into c.sizes. + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}), + } as DOMRect)); + + grid.setSize('a', '50%'); + + const after = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }).sizes; + + // Sizes are unchanged (no all-zeros write). The new behavior is the + // op short-circuits; before the fix every entry would be 0%. + for (const [i, s] of after.entries()) { + expect(s.unit).toBe(before[i].unit); + expect(s.value).toBeCloseTo(before[i].value, 3); + } + }); + + it('equalize emits equalize and writes pct sizes for every child', () => { + const grid = mount(); + + grid.equalize('root'); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string }> }); + + for (const s of sizes) expect(s.unit).toBe('pct'); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'equalize' })); + }); + + it('equalize on all-pct stores exactly 100/N per child (sum-to-100)', () => { + const grid = mount(); + + grid.equalize('root'); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + // 3 children → each stores 33.33... pct. + for (const s of sizes) { + expect(s.unit).toBe('pct'); + expect(s.value).toBeCloseTo(100 / 3, 2); + } + + expect(sizes.reduce((a, s) => a + s.value, 0)).toBeCloseTo(100, 1); + }); + + it('equalize on all-px keeps px units and rendered widths are equal', () => { + // Storage form: an all-px container stores `avail/N` px per child. + // The sum-to-100 pct invariant doesn't apply (no pct entries). + // CSS still renders equal widths via the px tracks directly. + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { size: '300px' } }, + { id: 'b', bounds: { size: '300px' } }, + { id: 'c', bounds: { size: '300px' } }, + ], + }); + + grid.equalize('root'); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + for (const s of sizes) expect(s.unit).toBe('px'); + + // 1000px container - 12px resizers = 988 avail; 988 / 3 ≈ 329.33 per child. + const expected = 988 / 3; + + for (const s of sizes) expect(s.value).toBeCloseTo(expected, 0); + }); + + it('reset restores each child\'s bounds.size (or `fr` if unset)', () => { + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { size: '25%' } }, + { id: 'b', bounds: { size: '50%' } }, + { id: 'c' }, + ], + }); + + // Drag-like mutation to dirty the sizes first. + grid.setSize('a', '10%'); + grid.reset('root'); + + const { sizes } = (grid.get('root') as { sizes: Array<{ unit: string, value: number }> }); + + // `bounds.size: '25%'` is user-input form (% of container). Storage + // is "% of pctBudgetPx" — for a 1000px container with two 6px + // resizers, budget = 988, so the stored value is 25 × 1000/988 ≈ + // 25.30. The CSS emit at writeTracks scales back so the rendered + // panel is still 25% of the container. + expect(sizes[0].unit).toBe('pct'); + expect(sizes[0].value).toBeCloseTo(25 * (1000 / 988), 2); + expect(sizes[1].unit).toBe('pct'); + expect(sizes[1].value).toBeCloseTo(50 * (1000 / 988), 2); + expect(sizes[2]).toEqual({ unit: 'fr', value: 1 }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'reset' })); + }); + + it('maximize/minimize set size to 100% / 0%', () => { + const grid = mount(); + + grid.maximize('a'); + + const afterMax = grid.get('a'); + + if (!afterMax?.parent) throw new Error('expected parent'); + + // Stored pct sums to 100 when saturated — maximized 'a' takes the + // whole pct budget, siblings drop to their mins (which are px-sized + // here so they stay px in storage). + expect(afterMax.parent.sizes[0].unit).toBe('pct'); + expect(afterMax.parent.sizes[0].value).toBeGreaterThan(50); + + grid.minimize('a'); + + const afterMin = grid.get('a'); + + if (!afterMin?.parent) throw new Error('expected parent'); + + // After minimize the target is at its lower clamp (its bounds.min: + // '40px' here), so the stored unit reflects the storage convention + // — anything except 1fr means the size was actually applied. + expect(afterMin.parent.sizes[0].unit).not.toBe('fr'); + }); + + it('maximize / minimize emit distinguishable reasons (not set-size)', () => { + // Consumers persisting layout via onChange need to tell "user dragged + // to 100%" from "panel maximized" — same final size, different intent. + // The two paths used to share `setSize`, which only ever emitted + // 'set-size'; now they emit dedicated reasons. + const grid = mount(); + const reasons: string[] = []; + + grid.subscribe((e) => reasons.push(e.reason)); + + grid.maximize('a'); + grid.minimize('a'); + + expect(reasons).toContain('maximize'); + expect(reasons).toContain('minimize'); + // The setSize path was the bug — make sure neither maximize nor + // minimize fires 'set-size' as a side effect. + expect(reasons).not.toContain('set-size'); + }); + + it('toggleMaximize emits maximize then minimize', () => { + const grid = mount(); + const reasons: string[] = []; + + grid.subscribe((e) => reasons.push(e.reason)); + + grid.toggleMaximize('a'); + grid.toggleMaximize('a'); + + expect(reasons).toEqual(['maximize', 'minimize']); + }); + + it('maximize records a snapshot so isMaximized reflects the state', () => { + const grid = mount(); + + expect(grid.isMaximized('a')).toBe(false); + grid.maximize('a'); + expect(grid.isMaximized('a')).toBe(true); + }); + + it('minimize clears the maximize snapshot', () => { + const grid = mount(); + + grid.maximize('a'); + expect(grid.isMaximized('a')).toBe(true); + grid.minimize('a'); + expect(grid.isMaximized('a')).toBe(false); + }); + + it('maximize on a second panel restores the previous maximized panel first', () => { + const grid = mount(); + + grid.maximize('a'); + grid.maximize('b'); + expect(grid.isMaximized('a')).toBe(false); + expect(grid.isMaximized('b')).toBe(true); + }); + + it('toggleMaximize alternates between maximize and minimize', () => { + const grid = mount(); + + grid.toggleMaximize('a'); + expect(grid.isMaximized('a')).toBe(true); + grid.toggleMaximize('a'); + expect(grid.isMaximized('a')).toBe(false); + + const sizeAfterMin = grid.get('a'); + + // After minimize we expect the panel to be at its min (40px in this + // tree's spec). State carries unit/value; assert it's not the default + // 1fr — that would mean we hadn't actually shrunk. + if (sizeAfterMin?.parent) { + const sz = sizeAfterMin.parent.sizes[sizeAfterMin.indexInParent]; + + expect(sz.unit).not.toBe('fr'); + } + }); +}); + +describe('toggleExpand and expandNext/Prev', () => { + it('toggleExpand snapshots on first call and restores on second', () => { + const grid = mount(); + + grid.toggleExpand('b'); + expect(grid.isMaximized('b')).toBe(true); + grid.toggleExpand('b'); + expect(grid.isMaximized('b')).toBe(false); + }); + + it('only one panel is maximized at a time within a container', () => { + const grid = mount(); + + grid.toggleExpand('a'); + grid.toggleExpand('b'); // restores `a`, then snapshots/maximizes `b` + expect(grid.isMaximized('a')).toBe(false); + expect(grid.isMaximized('b')).toBe(true); + }); + + it('expandNext maximizes the first child when nothing is currently maximized', () => { + const grid = mount(); + + expect(grid.expandNext('root')).toBe('a'); + expect(grid.isMaximized('a')).toBe(true); + }); + + it('expandNext moves the maximize state to the next sibling', () => { + const grid = mount(); + + grid.toggleExpand('a'); + + const moved = grid.expandNext('root'); + + expect(moved).toBe('b'); + expect(grid.isMaximized('a')).toBe(false); + expect(grid.isMaximized('b')).toBe(true); + }); + + it('expandNext returns undefined when the last child is already maximized', () => { + const grid = mount(); + + grid.toggleExpand('c'); + expect(grid.expandNext('root')).toBeUndefined(); + // `c` stays maximized — no-op, not an unmaximize. + expect(grid.isMaximized('c')).toBe(true); + }); + + it('maximize() snapshots the post-freeze (pct) sizes, not fr', () => { + // Regression: maximize() used to snapshot c.sizes BEFORE + // prepareForLayoutOp (which freezes fr → pct), so the restore + // array on a fresh container persisted fr entries. The eventual + // toggleExpand-back wrote those fr entries into c.sizes; writeTracks + // then emitted `minmax(0, 1fr)` against a previously-pct track — + // the function-shape mismatch styles.css warns about, which + // flashes container background through the transition. + const grid = mount(); + const root = grid.get('root'); + + if (!root || !('sizes' in root)) throw new Error('expected container'); + + // Fresh tree: bounds.size is unset on all children, so c.sizes + // is all fr after mount. + for (const sz of root.sizes) expect(sz.unit).toBe('fr'); + + grid.maximize('a'); + + // The snapshot lives on the parent's `max.restore`. With the fix, + // it must NOT contain fr entries. + const restore = (grid.get('root') as { max: { restore: Array<{ unit: string }> } | null }) + .max?.restore; + + expect(restore).toBeDefined(); + + for (const sz of restore!) expect(sz.unit).not.toBe('fr'); + + // Round-trip: toggleExpand-back writes restore into c.sizes. After + // the fix, c.sizes stays pct (no fr re-introduction). + grid.toggleExpand('a'); + + const after = (grid.get('root') as { sizes: Array<{ unit: string }> }).sizes; + + for (const sz of after) expect(sz.unit).not.toBe('fr'); + }); + + it('expandPrev cycles backward and stops at the first child', () => { + const grid = mount(); + + grid.toggleExpand('c'); + expect(grid.expandPrev('root')).toBe('b'); + expect(grid.expandPrev('root')).toBe('a'); + expect(grid.expandPrev('root')).toBeUndefined(); + expect(grid.isMaximized('a')).toBe(true); + }); + + it('expandPrev maximizes the last child when nothing is currently maximized', () => { + const grid = mount(); + + expect(grid.expandPrev('root')).toBe('c'); + expect(grid.isMaximized('c')).toBe(true); + }); + + it('returns undefined for unknown or non-container ids', () => { + const grid = mount(); + + expect(grid.expandNext('nope')).toBeUndefined(); + // Leaves can't be cycled — they have no children. + expect(grid.expandNext('a')).toBeUndefined(); + }); +}); + +describe('state queries', () => { + it('isMaximized reflects the snapshot membership', () => { + const grid = mount(); + + expect(grid.isMaximized('a')).toBe(false); + grid.toggleExpand('a'); + expect(grid.isMaximized('a')).toBe(true); + grid.toggleExpand('a'); + expect(grid.isMaximized('a')).toBe(false); + }); + + it('isMaximized is false for the root (never maximizable)', () => { + const grid = mount(); + + expect(grid.isMaximized('root')).toBe(false); + }); + + it('getMaximizedIndex returns -1 with no max, or the child index when set', () => { + const grid = mount(); + + expect(grid.getMaximizedIndex('root')).toBe(-1); + grid.maximize('b'); + expect(grid.getMaximizedIndex('root')).toBe(1); + grid.maximize('c'); + expect(grid.getMaximizedIndex('root')).toBe(2); + grid.minimize('c'); + expect(grid.getMaximizedIndex('root')).toBe(-1); + }); + + it('getMaximizedIndex returns -1 for leaves and unknown ids', () => { + const grid = mount(); + + expect(grid.getMaximizedIndex('a')).toBe(-1); // leaf + expect(grid.getMaximizedIndex('unknown')).toBe(-1); // not in tree + }); + + it('areChildrenEqual is true after equalize, false after a size change', () => { + const grid = mount(); + + grid.equalize('root'); + expect(grid.areChildrenEqual('root')).toBe(true); + grid.setSize('a', '50%'); + expect(grid.areChildrenEqual('root')).toBe(false); + grid.equalize('root'); + expect(grid.areChildrenEqual('root')).toBe(true); + }); + + it('areChildrenEqual is true for containers with fewer than two children', () => { + const grid = new SplitGrid({ + root: { + id: 'r', + direction: PanelDirection.Row, + children: [{ id: 'only' }], + }, + }); + + grid.mount(host); + expect(grid.areChildrenEqual('r')).toBe(true); + }); + + it('areChildrenEqual is false for leaves and unknown ids', () => { + const grid = mount(); + + expect(grid.areChildrenEqual('a')).toBe(false); + expect(grid.areChildrenEqual('unknown')).toBe(false); + }); + + it('isAtDefault is true on mount and after reset, false after a size change', () => { + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { size: '25%' } }, + { id: 'b', bounds: { size: '50%' } }, + { id: 'c' }, + ], + }); + + expect(grid.isAtDefault('a')).toBe(true); + expect(grid.isAtDefault('c')).toBe(true); + + grid.setSize('a', '40%'); + expect(grid.isAtDefault('a')).toBe(false); + // Siblings re-balance, so they're also off-default after a setSize. + expect(grid.isAtDefault('b')).toBe(false); + + grid.reset('root'); + expect(grid.isAtDefault('a')).toBe(true); + expect(grid.isAtDefault('b')).toBe(true); + expect(grid.isAtDefault('c')).toBe(true); + }); +}); + +describe('onChange', () => { + it('fires once per accepted layout mutation', () => { + const grid = mount(); + + onChange.mockReset(); + grid.setSize('a', '30%'); + grid.toggleExpand('b'); + grid.equalize('root'); + expect(onChange).toHaveBeenCalledTimes(3); + }); + + it('includes a cloned sizes snapshot the consumer can keep', () => { + const grid = mount(); + + onChange.mockReset(); + grid.equalize('root'); + + const event = onChange.mock.calls[0][0]; + const liveSizes = (grid.get('root') as { sizes: unknown[] }).sizes; + + expect(event.sizes).toEqual(liveSizes); + expect(event.sizes).not.toBe(liveSizes); // different array reference + }); +}); + +describe('syncChildren (bulk diff/sync)', () => { + it('removes children not present in the new defs', () => { + const grid = mount(); + + grid.syncChildren('root', [ + { id: 'a' }, + { id: 'c' }, + ]); + + const ids = (grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id); + + expect(ids).toEqual(['a', 'c']); + expect(grid.get('b')).toBeUndefined(); + }); + + it('appends new children at the end when they do not exist yet', () => { + const grid = mount(); + + grid.syncChildren('root', [ + { id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd', data: { label: 'D' } }, + ]); + + const ids = (grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id); + + expect(ids).toEqual(['a', 'b', 'c', 'd']); + }); + + it('inserts new children at the requested index', () => { + const grid = mount(); + + grid.syncChildren('root', [ + { id: 'x', data: { label: 'X' } }, + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + ]); + + const ids = (grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id); + + expect(ids).toEqual(['x', 'a', 'b', 'c']); + }); + + it('reorders existing children to match the new sequence', () => { + const grid = mount(); + + grid.syncChildren('root', [ + { id: 'c' }, { id: 'a' }, { id: 'b' }, + ]); + + const ids = (grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id); + + expect(ids).toEqual(['c', 'a', 'b']); + }); + + it('handles mixed insert + remove + reorder in one call', () => { + const grid = mount(); + + grid.syncChildren('root', [ + { id: 'new', data: { label: 'New' } }, + { id: 'c' }, + { id: 'a' }, + ]); + + const ids = (grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id); + + expect(ids).toEqual(['new', 'c', 'a']); + // `b` was not in the new defs. + expect(grid.get('b')).toBeUndefined(); + }); + + it('fires per-underlying-op events: one add-child / remove-child / swap per granular mutation', () => { + // syncChildren composes add/remove/swap; each underlying call still + // fires its own onChange (this is documented contract). Subscribers + // see N events for an N-step reconcile — never coalesces. This test + // locks the count so refactors that batch the inner calls have to + // make an intentional decision about the emit shape. + const grid = mount(); + const events: string[] = []; + + grid.subscribe((e) => events.push(e.reason)); + + // Mixed: remove b, insert d at index 0, swap a/c. Underlying ops: + // removeChild('b') → 'remove-child' + // addChild(d, 0) → 'add-child' + // swap(c, a) (positions 1↔2) → 'swap' + grid.syncChildren('root', [ + { id: 'd', data: { label: 'D' } }, + { id: 'c' }, + { id: 'a' }, + ]); + + expect(events.filter((r) => r === 'remove-child')).toHaveLength(1); + expect(events.filter((r) => r === 'add-child')).toHaveLength(1); + expect(events.filter((r) => r === 'swap')).toHaveLength(1); + }); + + it('no-ops cleanly when defs match the current children', () => { + const grid = mount(); + const before = [...(grid.get('root') as { sizes: unknown[] }).sizes]; + + grid.syncChildren('root', [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + expect((grid.get('root') as { sizes: unknown[] }).sizes).toEqual(before); + }); + + it('clears the container when given an empty list', () => { + const grid = mount(); + + grid.syncChildren('root', []); + expect((grid.get('root') as { node: { children: unknown[] } }).node.children).toEqual([]); + }); + + it('is a no-op for non-container / unknown ids', () => { + const grid = mount(); + + grid.syncChildren('nope', [{ id: 'x' }]); + grid.syncChildren('a', [{ id: 'x' }]); // 'a' is a leaf + // Tree unchanged. + expect((grid.get('root') as { node: { children: Array<{ id: string }> } }) + .node.children.map((c) => c.id)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('setDataArray (bulk data update)', () => { + it('sets data on each leaf in the list', () => { + const grid = mount(); + + grid.setDataArray([ + { id: 'a', data: { label: 'Aprime' } }, + { id: 'c', data: { label: 'Cprime' } }, + ]); + + const aData = (grid.get('a')?.node as { data: { label: string } }).data; + const cData = (grid.get('c')?.node as { data: { label: string } }).data; + + expect(aData.label).toBe('Aprime'); + expect(cData.label).toBe('Cprime'); + // `b` untouched. + expect((grid.get('b')?.node as { data: { label: string } }).data.label).toBe('B'); + }); + + it('skips unknown ids without throwing', () => { + const grid = mount(); + + expect(() => { + grid.setDataArray([ + { id: 'a', data: { label: 'A1' } }, + { id: 'nope', data: { label: 'gone' } }, + ]); + }).not.toThrow(); + expect((grid.get('a')?.node as { data: { label: string } }).data.label).toBe('A1'); + }); + + it('fires one set-data event per affected leaf', () => { + const grid = mount(); + + onChange.mockReset(); + grid.setDataArray([ + { id: 'a', data: { label: 'A1' } }, + { id: 'b', data: { label: 'B1' } }, + ]); + + const reasons = onChange.mock.calls.map((c) => (c[0] as { reason: string }).reason); + + expect(reasons.filter((r) => r === 'set-data').length).toBe(2); + }); +}); + +describe('subscribe', () => { + it('registers a callback that fires alongside cfg.onChange', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + onChange.mockReset(); + + grid.setSize('a', '30%'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(sub).toHaveBeenCalledTimes(1); + }); + + it('supports multiple subscribers', () => { + const grid = mount(); + const sub1 = vi.fn(); + const sub2 = vi.fn(); + + grid.subscribe(sub1); + grid.subscribe(sub2); + grid.equalize('root'); + expect(sub1).toHaveBeenCalledTimes(1); + expect(sub2).toHaveBeenCalledTimes(1); + }); + + it('returns an unsubscribe function', () => { + const grid = mount(); + const sub = vi.fn(); + const off = grid.subscribe(sub); + + grid.setSize('a', '20%'); + expect(sub).toHaveBeenCalledTimes(1); + + off(); + grid.setSize('a', '40%'); + expect(sub).toHaveBeenCalledTimes(1); + }); + + it('isolates subscriber errors so one bad listener does not block the rest', () => { + const grid = mount(); + const bad = vi.fn(() => { + throw new Error('boom'); + }); + const good = vi.fn(); + + grid.subscribe(bad); + grid.subscribe(good); + + // Should not throw out of setSize. + expect(() => grid.setSize('a', '30%')).not.toThrow(); + expect(good).toHaveBeenCalled(); + }); +}); + +describe('event nodeIds', () => { + it('set-data includes the affected leaf', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.setData('a', { label: 'A!' }); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'set-data', nodeIds: ['a'], + })); + }); + + it('swap-data includes both leaves', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.swapData('a', 'b'); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'swap-data', nodeIds: expect.arrayContaining(['a', 'b']), + })); + }); + + it('add-child / remove-child carry the new / removed id', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.addChild('root', { id: 'd', data: { label: 'D' } }); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'add-child', nodeIds: ['d'], + })); + + sub.mockReset(); + grid.removeChild('b'); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'remove-child', nodeIds: ['b'], + })); + }); + + it('set-size carries the resized leaf', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.setSize('a', '30%'); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'set-size', nodeIds: ['a'], + })); + }); + + it('toggle-expand carries the toggled leaf', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.toggleExpand('a'); + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'toggle-expand', nodeIds: ['a'], + })); + }); + + it('equalize / reset / drag use an empty nodeIds (every child affected)', () => { + const grid = mount(); + const sub = vi.fn(); + + grid.subscribe(sub); + grid.equalize('root'); + grid.reset('root'); + + const eqEvent = sub.mock.calls[0][0]; + const rsEvent = sub.mock.calls[1][0]; + + expect(eqEvent.nodeIds).toEqual([]); + expect(rsEvent.nodeIds).toEqual([]); + }); +}); + +describe('getRawDefinition', () => { + it('returns undefined when the grid has not been mounted', () => { + const grid = new SplitGrid({ root: tree() }); + + // Before mount: no element-derived sizes to report. Return undefined + // rather than guessing. + expect(grid.getRawDefinition()).toBeUndefined(); + }); + + it('returns a deep clone of the live tree (root + children)', () => { + const grid = mount(); + const def = grid.getRawDefinition(); + + expect(def).toBeDefined(); + expect(def!.id).toBe('root'); + expect(def!.direction).toBe('row'); + expect(def!.children?.map((c) => c.id)).toEqual(['a', 'b', 'c']); + + // Deep clone — mutating the returned definition doesn't touch the + // running grid. + def!.children!.push({ id: 'ghost' }); + expect(grid.get('ghost')).toBeUndefined(); + }); + + it('bakes current sizes into bounds.size by default', () => { + const grid = mount(); + + grid.setSize('a', '300px'); + + const def = grid.getRawDefinition(); + const a = def!.children!.find((c) => c.id === 'a')!; + + // Current size is in px (we measure off rect). The serialized form + // should reflect what the user is actually looking at, not the + // original definition's `auto`. + expect(a.bounds?.size).toMatch(/^\d+px$/); + }); + + it('preserves the original bounds.size when withCurrentSizes is false', () => { + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { size: '200px' } }, + { id: 'b' }, + ], + }); + + grid.setSize('a', '400px'); + + const def = grid.getRawDefinition({ withCurrentSizes: false }); + const a = def!.children!.find((c) => c.id === 'a')!; + + expect(a.bounds?.size).toBe('200px'); + }); + + it('falls back to structure-only when the host has no measurable rect', () => { + // Regression: withCurrentSizes:true would call measureAxis on every + // child against a zero-rect host (pre-paint, detached, display:none), + // writing "0px" into every bounds.size — reconstruction produced a + // collapsed tree. Now: detect the zero-rect host and fall through + // to the no-sizes path so structure stays useful. + const grid = mount(); + + // Stub the rect to zero AFTER mount so layout has run but the next + // getRawDefinition call sees a zero-axis host. + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}), + } as DOMRect)); + + const def = grid.getRawDefinition()!; + + expect(def).toBeDefined(); + + // No "0px" sizes leaked through — children come back without a + // baked-in bounds.size (or with whatever was originally defined). + for (const child of def.children ?? []) { + expect(child.bounds?.size).not.toBe('0px'); + } + }); + + it('round-trips: rebuilding a grid from getRawDefinition produces matching ids', () => { + const grid = mount(); + const def = grid.getRawDefinition()!; + const replica = new SplitGrid({ root: def }); + + replica.mount(document.createElement('div')); + expect(replica.get('a')).toBeDefined(); + expect(replica.get('b')).toBeDefined(); + expect(replica.get('c')).toBeDefined(); + }); + + it('includes data by default and strips it when includeData is false', () => { + const grid = mount(); + const withData = grid.getRawDefinition()!; + const withoutData = grid.getRawDefinition({ includeData: false })!; + + const aWith = withData.children!.find((c) => c.id === 'a') as { data?: { label: string } }; + const aWithout = withoutData.children!.find((c) => c.id === 'a') as { data?: { label: string } }; + + expect(aWith.data?.label).toBe('A'); + expect(aWithout.data).toBeUndefined(); + }); + + it('recurses through nested containers', () => { + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a' }, + { + id: 'mid', + direction: PanelDirection.Column, + children: [ + { id: 'mid-a' }, + { id: 'mid-b' }, + ], + }, + ], + }); + + const def = grid.getRawDefinition()!; + const mid = def.children!.find((c) => c.id === 'mid') as { direction: string, children: Array<{ id: string }> }; + + expect(mid.direction).toBe('column'); + expect(mid.children.map((c) => c.id)).toEqual(['mid-a', 'mid-b']); + }); +}); + +describe('settle (transitionend wait)', () => { + it('returns a resolved Promise when grid is not mounted', async () => { + const grid = new SplitGrid({ root: tree() }); + + // Nothing to wait on; the helper should be a no-op rather than hang. + await expect(grid.settle()).resolves.toBeUndefined(); + }); + + it('resolves when transitionend fires on the container', async () => { + const grid = mount(); + const rootEl = host.querySelector('.sp-container') as HTMLElement; + const settled = vi.fn(); + const promise = grid.settle('root').then(() => settled()); + + // Microtask flush — settle hasn't seen any event yet. + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + + rootEl.dispatchEvent(new TransitionEvent('transitionend', { + propertyName: 'grid-template-columns', bubbles: true, + })); + await promise; + expect(settled).toHaveBeenCalledTimes(1); + }); + + it('ignores transitionend for unrelated properties', async () => { + const grid = mount(); + const rootEl = host.querySelector('.sp-container') as HTMLElement; + let resolved = false; + // Keep the promise to await on at the end — `await Promise.resolve()` + // only flushes one microtask, but Promise.all → outer-async-fn adds + // a couple more hops. Awaiting the actual promise is robust. + const p = grid.settle('root').then(() => { + resolved = true; + }); + + // An unrelated transitionend (e.g. opacity, height) should not unblock. + rootEl.dispatchEvent(new TransitionEvent('transitionend', { + propertyName: 'opacity', bubbles: true, + })); + // Flush a few microtasks; resolved must still be false. + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + + rootEl.dispatchEvent(new TransitionEvent('transitionend', { + propertyName: 'grid-template-columns', bubbles: true, + })); + await p; + expect(resolved).toBe(true); + }); + + it('falls back to a timeout when no transitionend fires', async () => { + const grid = mount(); + const start = Date.now(); + + await grid.settle('root', { timeout: 30 }); + + const elapsed = Date.now() - start; + + // happy-dom never fires transitionend naturally; the helper has to + // unblock via its timeout. Be generous on the upper bound — CI is slow. + expect(elapsed).toBeGreaterThanOrEqual(25); + expect(elapsed).toBeLessThan(1000); + }); + + it('settle() with no containerId waits for every container', async () => { + const grid = mount({ + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a' }, + { + id: 'mid', + direction: PanelDirection.Column, + children: [{ id: 'm1' }, { id: 'm2' }], + }, + ], + }); + const containers = host.querySelectorAll('.sp-container'); + let resolved = false; + // Keep the promise handle so we can await it after the second dispatch + // — async-fn-around-Promise.all hops a few microtasks; awaiting the + // actual promise is more robust than counting flushes. + const p = grid.settle().then(() => { + resolved = true; + }); + + // Only the root fires — promise stays pending while inner is in flight. + (containers[0] as HTMLElement).dispatchEvent(new TransitionEvent('transitionend', { + propertyName: 'grid-template-columns', bubbles: true, + })); + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + + // Inner finishes too — now the all-settled promise resolves. + (containers[1] as HTMLElement).dispatchEvent(new TransitionEvent('transitionend', { + propertyName: 'grid-template-rows', bubbles: true, + })); + await p; + expect(resolved).toBe(true); + }); + + it('settle on a non-container id is a resolved no-op', async () => { + const grid = mount(); + + // Leaves don't animate themselves (they're inside a container's track); + // calling settle on a leaf should resolve immediately rather than wait. + await expect(grid.settle('a')).resolves.toBeUndefined(); + }); +}); + +// Helper hoisted out — eslint requires top-level scoping for this style. +function makeMoveDataTree(items: string[]): Container<{ label: string }> { + return { + id: 'root', + direction: PanelDirection.Row, + children: items.map((label) => ({ id: label.toLowerCase(), data: { label } })), + }; +} + +describe('moveData', () => { + it('moves data from source into target slot, shifting intermediates', () => { + // [A,B,C,D] move A onto C → A leaves its slot, slides into C's position; + // B and C shift left to fill the gap; D stays put. The IDs (and slot + // positions) don't change — only the data flows between them. + const grid = new SplitGrid<{ label: string }>({ root: makeMoveDataTree(['A', 'B', 'C', 'D']) }); + + grid.mount(host); + grid.moveData('a', 'c'); + + const labels = ['a', 'b', 'c', 'd'].map( + (id) => ((grid.get(id)?.node) as { data: { label: string } }).data.label, + ); + + expect(labels).toEqual(['B', 'C', 'A', 'D']); + }); + + it('moves backward correctly (later → earlier)', () => { + const grid = new SplitGrid<{ label: string }>({ root: makeMoveDataTree(['A', 'B', 'C', 'D']) }); + + grid.mount(host); + grid.moveData('d', 'b'); + + const labels = ['a', 'b', 'c', 'd'].map( + (id) => ((grid.get(id)?.node) as { data: { label: string } }).data.label, + ); + + // Source D removed → [A, B, C]; insert at target B's index (1) → [A, D, B, C] + expect(labels).toEqual(['A', 'D', 'B', 'C']); + }); + + it('emits move-data with both ids in nodeIds', () => { + const grid = new SplitGrid<{ label: string }>({ root: makeMoveDataTree(['A', 'B', 'C']) }); + const sub = vi.fn(); + + grid.mount(host); + grid.subscribe(sub); + grid.moveData('a', 'c'); + + expect(sub).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'move-data', + nodeIds: expect.arrayContaining(['a', 'c']), + })); + }); + + it('no-ops when source === target', () => { + const grid = new SplitGrid<{ label: string }>({ root: makeMoveDataTree(['A', 'B', 'C']) }); + const sub = vi.fn(); + + grid.mount(host); + grid.subscribe(sub); + grid.moveData('a', 'a'); + expect(sub).not.toHaveBeenCalled(); + }); + + it('no-ops on cross-parent moves (only same-container rearranges)', () => { + const grid = new SplitGrid<{ label: string }>({ + root: { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', data: { label: 'A' } }, + { + id: 'group', + direction: PanelDirection.Column, + children: [ + { id: 'b', data: { label: 'B' } }, + { id: 'c', data: { label: 'C' } }, + ], + }, + ], + }, + }); + + grid.mount(host); + // Move-with-shift is undefined across parents (the slot positions are + // in different containers). Cross-parent is a swap-shaped operation; + // keep moveData strict, and swap remains the cross-parent option. + grid.moveData('a', 'b'); + expect(((grid.get('a')?.node) as { data: { label: string } }).data.label).toBe('A'); + expect(((grid.get('b')?.node) as { data: { label: string } }).data.label).toBe('B'); + }); +}); + +describe('PanelDirection constant', () => { + it('Row and Column expand to the strings CSS Grid expects', () => { + // The const constants are the only spelling now (the Direction type + // alias was dropped). Their runtime values still match the strings + // CSS Grid's `grid-auto-flow` / `grid-template-*` direction families + // expect, which is what makes `direction === PanelDirection.Row` + // work both as TS comparison and as the value the runtime feeds CSS. + expect(PanelDirection.Row).toBe('row'); + expect(PanelDirection.Column).toBe('column'); + }); +}); + +describe('resizer.first / resizer.last (decorative edge resizers)', () => { + it('renders a leading resizer when `resizer.first` is true', () => { + new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }).mount(host); + + const resizers = host.querySelectorAll('.sp-resizer'); + + // 2 children → 1 inner divider + 1 leading edge = 2 total. + expect(resizers).toHaveLength(2); + + // The leading edge appears before any .sp-panel. + const first = host.querySelector('.sp-container > :first-child') as HTMLElement; + + expect(first.classList.contains('sp-resizer')).toBe(true); + expect(first.dataset.edge).toBe('leading'); + }); + + it('renders a trailing resizer when `resizer.last` is true', () => { + new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { last: true }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }).mount(host); + + const last = host.querySelector('.sp-container > :last-child') as HTMLElement; + + expect(last.classList.contains('sp-resizer')).toBe(true); + expect(last.dataset.edge).toBe('trailing'); + }); + + it('decorative edges do not bind drag (no pointerdown handler resizes anything)', () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true }, + children: [{ id: 'a', bounds: { min: '40px' } }, { id: 'b', bounds: { min: '40px' } }], + }, + }); + + grid.mount(host); + + const leading = host.querySelector('.sp-resizer[data-edge="leading"]') as HTMLElement; + const beforeTracks = (host.querySelector('.sp-container') as HTMLElement) + .style.getPropertyValue('--sp-tracks'); + + leading.dispatchEvent(new PointerEvent('pointerdown', { + clientX: 0, clientY: 0, bubbles: true, button: 0, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 100, clientY: 0, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 100, clientY: 0, bubbles: true, + })); + + const afterTracks = (host.querySelector('.sp-container') as HTMLElement) + .style.getPropertyValue('--sp-tracks'); + + expect(afterTracks).toBe(beforeTracks); + }); + + it('track string includes the extra leading/trailing tracks', () => { + new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, last: true, size: 16 }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }).mount(host); + + const tracks = (host.querySelector('.sp-container') as HTMLElement) + .style.getPropertyValue('--sp-tracks'); + // 1 leading + 2 content + 1 inner + 1 trailing = 5 tracks. + const parts = tracks.split(/\s+(?=(?:[^()]|\([^()]*\))*$)/); + + expect(parts).toHaveLength(5); + expect(parts[0]).toBe('16px'); + expect(parts.at(-1)).toBe('16px'); + }); + + it('leading-edge `resizer.after` reports children[0] (not undefined)', () => { + // Regression: the inner-divider formula `after = children[handleIdx]` + // returns children[-1] === undefined for the leading edge, leaving + // consumers with no way to identify the adjacent panel (e.g. to label + // it in a wrapper #resizer slot). Leading edge sits BEFORE children[0], + // so children[0] is what `after` should report. + const seen: Array<{ index: number, beforeId: string | undefined, afterId: string | undefined }> = []; + + new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { + first: true, + last: true, + render: (_el, ctx) => { + seen.push({ + index: ctx.index, + beforeId: ctx.before?.id, + afterId: ctx.after?.id, + }); + }, + }, + children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }, + }).mount(host); + + const leading = seen.find((s) => s.index === -1); + const trailing = seen.find((s) => s.index === 3); + const inner = seen.find((s) => s.index === 1); + + expect(leading).toEqual({ index: -1, beforeId: undefined, afterId: 'a' }); + expect(trailing).toEqual({ index: 3, beforeId: 'c', afterId: undefined }); + expect(inner).toEqual({ index: 1, beforeId: 'a', afterId: 'b' }); + }); + + it('inner-divider dblclick toggles maximize; triple-click equalizes', () => { + // Two click-count gestures: + // detail=2 (dblclick) → toggleMaximize on edge-closer adjacent panel + // (delayed by TRIPLE_CLICK_GRACE_MS so a + // follow-up third click can promote to + // equalize before the maximize fires). + // detail=3 (triple) → equalize parent container (cancels pending). + vi.useFakeTimers(); + + try { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }, + }); + + grid.mount(host); + + const divider = host.querySelector('.sp-container > .sp-resizer') as HTMLElement; + + expect(divider).toBeTruthy(); + + // detail=2 schedules toggleMaximize after the grace window. + divider.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + expect(grid.isMaximized('a')).toBe(false); + vi.advanceTimersByTime(300); + // For divider 1 in [A B C], target = A (tie broken toward "before"). + expect(grid.isMaximized('a')).toBe(true); + + // detail=2 queues pending, detail=3 cancels and equalizes. + divider.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + divider.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 3 })); + vi.advanceTimersByTime(300); + expect(grid.isMaximized('a')).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('decorative edge resizers have no handle (handle is inner-divider-only)', () => { + // Decorative edges are chrome the consumer paints into — no drag, no + // built-in handle. The dblclick / triple-click gestures still apply + // (asserted in the next test); the handle is purely visual. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, last: true }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }); + + grid.mount(host); + + const leading = host.querySelector('.sp-resizer[data-edge="leading"]') as HTMLElement; + + expect(leading.querySelector('.sp-resizer-handle')).toBeNull(); + }); + + it('decorative edge resizer dblclick expands the adjacent panel', () => { + // The leading edge is the "header bar to the left of panel[0]" the + // legacy showFirstResizeEl pattern uses. It must not be draggable + // (asserted by the test above), but it SHOULD respond to dblclick by + // maximizing the panel it sits next to — that's the main affordance + // the bar exists for. Same on the trailing edge for the last panel. + // Triple-click on either edge equalizes the parent. + vi.useFakeTimers(); + + try { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, last: true }, + children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }, + }); + + grid.mount(host); + + const leading = host.querySelector('.sp-resizer[data-edge="leading"]') as HTMLElement; + + leading.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + vi.advanceTimersByTime(300); + expect(grid.isMaximized('a')).toBe(true); + + // Restore via second dblclick (toggleMaximize → minimize once snapshotted). + leading.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + vi.advanceTimersByTime(300); + expect(grid.isMaximized('a')).toBe(false); + + const trailing = host.querySelector('.sp-resizer[data-edge="trailing"]') as HTMLElement; + + trailing.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + vi.advanceTimersByTime(300); + expect(grid.isMaximized('c')).toBe(true); + + // Triple-click on the trailing edge clears the maximize via equalize. + trailing.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })); + trailing.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 3 })); + vi.advanceTimersByTime(300); + expect(grid.isMaximized('c')).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('equalize gives equal sizes when no min exceeds the even share', () => { + // Regression: the old equalize formula was `min + (avail - Σmin) / N`. + // With mins [294, 0] and avail 1136, that gave [715, 421] — visibly + // unequal even though every min was below the even share of 568. The + // user observed this as `[48.299%, 48.299%]` shifting to + // `[60.799%, 35.799%]` between consecutive writeTracks in the + // dashboard log. The correct semantic: aim for `avail / N`; only + // pin a panel at its min when the min exceeds that share, and re- + // share over remaining panels. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + ], + }, + }); + + grid.mount(host); + grid.equalize('root'); + + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const [a, b] = state.sizes; + + if (a.unit !== 'pct' || b.unit !== 'pct') throw new Error('expected pct'); + + // 1000px container, 1 inner divider × 20px = 20px, avail = budget + // = 980. Even share is 490px. Panel a's 25% min is 250px, well + // below the share. Storage form (% of budget): each panel = 50% + // exactly, sum = 100. CSS-emit scales by budget/container = + // 980/1000 so the painted track is still 49% of container. + expect(a.value).toBeCloseTo(50, 2); + expect(b.value).toBeCloseTo(50, 2); + }); + + it('equalize pins panels whose min exceeds the even share and re-shares the rest', () => { + // When a min CAN'T be satisfied at the even share, that panel pins + // at its min and the remaining panels split the leftover. The + // iterative pin-and-reshare handles the cascade if multiple panels + // need pinning. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [ + { id: 'a', bounds: { min: '60%' } }, // min = 600, well above avail/3 + { id: 'b' }, + { id: 'c' }, + ], + }, + }); + + grid.mount(host); + grid.equalize('root'); + + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const [a, b, c] = state.sizes; + + if (a.unit !== 'pct' || b.unit !== 'pct' || c.unit !== 'pct') throw new Error('expected pct'); + + // 1000px container, 2 resizers × 20 = 40, avail = budget = 960. + // Even share would be 320. Panel a's min = 60% × 1000 = 600 > 320, + // so a pins at 600. Remaining avail = 360, shared between b and c + // = 180 each. As pct of 960 budget: a = 62.5%, b = c = 18.75%. + // Sum = 100. CSS-emit scales each by 960/1000 = 0.96 so the painted + // widths are 60%/18%/18% of container. + expect(a.value).toBeCloseTo(62.5, 2); + expect(b.value).toBeCloseTo(18.75, 2); + expect(c.value).toBeCloseTo(18.75, 2); + }); + + it('setBounds short-circuits when no field actually changed (Vue recursion guard)', () => { + // Defense against Vue's shallow watcher firing on every render of + // `` — even when the computed + // returns the same value, the watcher refires. Without this guard + // every render path through SplitPanel produces an applyTargetSize + // call and emits a 'set-bounds' onChange event. The fixture below + // attaches a subscriber to count emits. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', bounds: { min: '25%', size: '40%' } }, + { id: 'b' }, + ], + }, + }); + let emits = 0; + + grid.subscribe(() => { + emits += 1; + }); + grid.mount(host); + + const baseline = emits; + + // Same bounds → no emit, no applyTargetSize. + grid.setBounds('a', { min: '25%' }); + grid.setBounds('a', { size: '40%' }); + grid.setBounds('a', { min: '25%', size: '40%' }); + expect(emits).toBe(baseline); + + // Real change → exactly one emit. + grid.setBounds('a', { min: '30%' }); + expect(emits).toBe(baseline + 1); + }); + + it('setData short-circuits when the new value is reference-equal', () => { + // Same Vue-recursion defense for the data path. Object.is means the + // same primitive or same object reference both count as no-op. + const grid = new SplitGrid<{ label: string }>({ + root: { + id: 'root', + direction: PanelDirection.Row, + children: [{ id: 'a', data: { label: 'A' } }, { id: 'b' }], + }, + }); + let emits = 0; + + grid.subscribe(() => { + emits += 1; + }); + grid.mount(host); + + const baseline = emits; + const original = (grid.get('a') as { node: { data: { label: string } } }).node.data; + + // Setting to the same reference → no-op. + grid.setData('a', original); + expect(emits).toBe(baseline); + + // Different reference (even with equivalent shape) → counts as + // changed. This is the documented contract: `setData` is a + // reference-equality check, not a deep one. + grid.setData('a', { label: 'A' }); + expect(emits).toBe(baseline + 1); + }); + + it('setBounds (no size patch) is idempotent — does not drift sizes on repeat calls', () => { + // Regression: setBounds with patch = {min: …} reads + // `parent.sizes[targetIdx]` (a pct value stored as "% of + // containerAxisPx") and converts to px. The conversion used `avail` + // (= container − resizer tracks) as the denominator — but pct values + // are container-relative, so the resulting px was `pct × resizerTracksPx` + // smaller than the panel's actual rendered size. Each setBounds call + // shrank the target and gave the slack to siblings, drifting the + // layout further on every iteration. The dashboard hit this when + // SplitPanel's `:min` watcher fired after layout-options resolved: + // post-equalize sizes shifted from [48.3%, 48.3%] to [60.8%, 35.8%] + // on a single setBounds. + // + // Fix: all toPx denominators use containerAxisPx (matching CSS). After + // the fix, setBounds with only a min update is idempotent — the + // current size round-trips through toPx unchanged. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }); + + grid.mount(host); + grid.equalize('root'); + + const stateBefore = grid.get('root'); + + if (!stateBefore || !('sizes' in stateBefore)) throw new Error('expected container'); + + const sizesBefore = stateBefore.sizes.map((s) => (s.unit === 'pct' ? s.value : Number.NaN)); + + grid.setBounds('a', { min: '25%' }); + + const stateAfter = grid.get('root'); + + if (!stateAfter || !('sizes' in stateAfter)) throw new Error('expected container'); + + const sizesAfter = stateAfter.sizes.map((s) => (s.unit === 'pct' ? s.value : Number.NaN)); + + // Panel 'a' is at 48.something% post-equalize, well above its new + // 25% min — so the min adds a floor but doesn't grow the panel. + // Sibling 'b' should also stay put. + for (const [i, element] of sizesBefore.entries()) { + expect(sizesAfter[i]).toBeCloseTo(element, 1); + } + }); + + it('percentage bounds resolve against full container — sum of tracks fits exactly', () => { + // Regression for the JS-vs-CSS percentage drift: bounds.min/max + // resolved against availPx in JS but CSS resolves the same percentages + // against the full container including resizer tracks. With + // `min: 25%` on panel A in a 5-panel row with leading edge + 4 inner + // dividers (5 × 20px = 100px) on a 1000px-wide stub, the old code + // clamped A to 0.25 × 900 = 225px while CSS painted it at 0.25 × 1000 + // = 250px — the 25px difference cascades into overflow. + // + // Container axis in this test fixture is 1000px (from the beforeEach + // getBoundingClientRect stub). + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, size: 20 }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + { id: 'e' }, + ], + }, + }); + + grid.mount(host); + // Minimize A: requests 0%, clamped to its 25% min. The JS-resolved + // floor must match CSS's 25% of 1000 = 250px, NOT 25% of (1000-80) + // = 230px. Then siblings get (1000 - 250 - 80) / 4 = 167.5px each. + grid.minimize('a'); + + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const { sizes } = state; + + if (sizes.some((s) => s.unit !== 'pct')) { + throw new Error(`expected pct sizes, got ${sizes.map((s) => s.unit).join(',')}`); + } + + // Stored sizes are in budget-pct form, sum-to-100 invariant. avail = + // 1000 − 5 × 20 = 900, so the budget for pct sizes is 900. Panel A + // clamped to its 25% min (250px) — in budget-pct that's 250/900 ≈ + // 27.78. Siblings (4 of them) share 900 − 250 = 650 → 162.5 each + // → 162.5/900 ≈ 18.06 each. Sum = 27.78 + 4 × 18.06 = 100. + const panelPctSum = sizes.reduce((a, s) => a + s.value, 0); + + expect(panelPctSum).toBeCloseTo(100, 1); + expect(sizes[0].value).toBeCloseTo(250 / 900 * 100, 1); + }); + + it('subtracts leading + trailing resizer tracks from availPx', () => { + // Regression: refreshAvail used to only subtract `(children-1)` inner + // dividers, ignoring resizer.first / resizer.last. With `first` + `last` + // on at 20px each + 2 children, the previous formula gave a budget that + // exceeded the real container axis by 40px — every downstream pixel + // calculation (maximize, setSize, drag clamp) over-shot, and the grid + // overflowed the visible area. Container is 1000px wide in tests; + // 1 leading + 1 inner + 1 trailing = 3 resizer tracks at 20px = 60px, + // so availPx must be 940. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, last: true, size: 20 }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }); + + grid.mount(host); + + // Trigger any layout op that calls refreshAvail; maximize is the most + // sensitive — it computes target = avail - siblingMins, so an inflated + // avail produces a panel size > container. + grid.maximize('a'); + + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const aSize = state.sizes[0]; + const bSize = state.sizes[1]; + + if (aSize.unit !== 'pct' || bSize.unit !== 'pct') { + throw new Error(`expected pct sizes, got ${aSize.unit}/${bSize.unit}`); + } + + // Sizes are persisted as % of pctBudgetPx (= avail = container − + // resizers = 1000 − 60 = 940), sum-to-100 when saturated. Resolving + // each stored pct back to physical px against `budget` gives the + // panel widths; those plus the resizer total must close at 1000. + // If refreshAvail had been wrong by +40px (counting only inner + // dividers, not the leading/trailing edges), aSize would compute + // to ~100% on its own — leaving no room for b and forcing the grid + // past 1000px. + const budget = 940; + const aPx = (aSize.value / 100) * budget; + const bPx = (bSize.value / 100) * budget; + + expect(aPx + bPx + 60).toBeCloseTo(1000, 0); + }); + + it('childElementAt still resolves the correct panel when edges are enabled', () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { first: true, last: true }, + children: [ + { id: 'a', bounds: { min: '40px' } }, + { id: 'b', bounds: { min: '40px' } }, + ], + }, + }); + + grid.mount(host); + + // setSize internally relies on childElementAt to measure the + // target's current px — this passes only if the edge offset is + // accounted for. + grid.setSize('b', '300px'); + // No throw == pass. We don't assert exact pixels (happy-dom). + expect(grid.get('b')).toBeDefined(); + }); +}); diff --git a/src/__spec__/distribute.spec.ts b/src/__spec__/distribute.spec.ts new file mode 100644 index 0000000..d6bc8c6 --- /dev/null +++ b/src/__spec__/distribute.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { distributeProportional } from '../distribute'; + +const INF = Number.POSITIVE_INFINITY; + +const sum = (arr: readonly number[]): number => arr.reduce((a, b) => a + b, 0); + +describe('distributeProportional', () => { + // Common case: no bounds bite, allocation is exactly proportional to weights. + it('splits total proportional to weights when nothing clamps', () => { + const out = distributeProportional(900, [1, 2, 3], [0, 0, 0], [INF, INF, INF]); + + // 900 split 1:2:3 → 150 : 300 : 450 + expect(out[0]).toBeCloseTo(150, 5); + expect(out[1]).toBeCloseTo(300, 5); + expect(out[2]).toBeCloseTo(450, 5); + expect(sum(out)).toBeCloseTo(900, 5); + }); + + // Equal-share case (equalize semantics): all weights identical. + it('falls back to equal shares when weights are all equal', () => { + const out = distributeProportional(1000, [1, 1, 1, 1], [0, 0, 0, 0], [INF, INF, INF, INF]); + + expect(out).toEqual([250, 250, 250, 250]); + }); + + // Min snap: one entry's proportional share is below its min, so it + // gets pinned at min and the others absorb the rest. + it('snaps an entry to its min and redistributes', () => { + const out = distributeProportional(1000, [1, 1, 1], [400, 0, 0], [INF, INF, INF]); + + // entry 0 would naively get 333; clamped up to 400. Remaining 600 + // split between entries 1 and 2 → 300 each. + expect(out[0]).toBeCloseTo(400, 5); + expect(out[1]).toBeCloseTo(300, 5); + expect(out[2]).toBeCloseTo(300, 5); + expect(sum(out)).toBeCloseTo(1000, 5); + }); + + // Max snap: the bug class the helper exists to defend against — the + // marker-panel-balloons-past-max case generalized. Entry 1 hits its + // 600 max; the spillover goes back to entries with room. + it('snaps an entry to its max and redistributes', () => { + const out = distributeProportional(1000, [1, 1], [0, 0], [INF, 600]); + + // entry 1 naively gets 500 — under its 600 max — no snap. + // But pump up the total so entry 1 would overshoot: + const out2 = distributeProportional(1400, [1, 1], [0, 0], [INF, 600]); + + expect(out2[1]).toBeCloseTo(600, 5); + expect(out2[0]).toBeCloseTo(800, 5); + expect(sum(out2)).toBeCloseTo(1400, 5); + // smoke-test the under-max case + expect(sum(out)).toBeCloseTo(1000, 5); + }); + + // Cascade: one snap creates a new infeasibility, triggering a second + // pass. Worst case is O(N) passes — confirm convergence. + it('cascades multiple snaps to convergence', () => { + // total=1000, weights uniform → pass 1 share = 333. + // entry 0 min=400 → snap up (remaining 600, free=[1,2]) + // entry 1 max=200 → was 333 in pass 1 — snap down? Pass 1 only + // snaps one round; pass 2 re-evaluates with + // remaining=600 split between 1 and 2 → 300 + // each. Entry 1 max=200 < 300 → snap. Pass 3: + // remaining=400 for entry 2. + const out = distributeProportional(1000, [1, 1, 1], [400, 0, 0], [INF, 200, INF]); + + expect(out[0]).toBeCloseTo(400, 5); + expect(out[1]).toBeCloseTo(200, 5); + expect(out[2]).toBeCloseTo(400, 5); + expect(sum(out)).toBeCloseTo(1000, 5); + }); + + // Length-0 input: vacuously empty result, no infinite loop. + it('returns empty array for empty input', () => { + expect(distributeProportional(100, [], [], [])).toEqual([]); + }); + + // All weights zero → equal share fallback. + it('falls back to equal share when all weights are zero', () => { + const out = distributeProportional(900, [0, 0, 0], [0, 0, 0], [INF, INF, INF]); + + expect(out).toEqual([300, 300, 300]); + }); + + // Infeasible (Σmin > total): every entry pinned at its min, sum + // exceeds total. Caller's problem to detect — helper just stays + // non-NaN and terminates. + it('terminates when Σmin > total (every entry pinned at min)', () => { + const out = distributeProportional(100, [1, 1], [200, 200], [INF, INF]); + + expect(out).toEqual([200, 200]); + }); +}); diff --git a/src/__spec__/drag.spec.ts b/src/__spec__/drag.spec.ts new file mode 100644 index 0000000..d51d43b --- /dev/null +++ b/src/__spec__/drag.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { dragHandle } from '../drag'; +import { asContainerAxis } from '../length'; +import type { Container, LengthInput } from '../types'; +import { PanelDirection } from '../types'; + +/** + * Minimal container builder for drag tests. Each entry is the min for that + * child (or undefined for no min). We don't need any other field for the + * drag math. + */ +function container(mins: Array, maxes: Array = []): Container { + return { + id: 'root', + direction: PanelDirection.Row, + children: mins.map((min, i) => ({ + id: `c${i}`, + bounds: { + ...(min == null ? {} : { min }), + ...(maxes[i] == null ? {} : { max: maxes[i] }), + }, + })), + }; +} + +describe('dragHandle: single-handle, no cascade', () => { + it('moves length from the immediate shrink neighbor to the immediate grow neighbor on right drag', () => { + const c = container([undefined, undefined, undefined]); + const sizes = [200, 200, 200]; + const initial = [...sizes]; + const applied = dragHandle(c, sizes, initial, 1, 50, asContainerAxis(600)); + + expect(applied).toBe(50); + expect(sizes).toEqual([250, 150, 200]); + }); + + it('mirrors for a left drag', () => { + const c = container([undefined, undefined, undefined]); + const sizes = [200, 200, 200]; + const applied = dragHandle(c, sizes, [...sizes], 1, -50, asContainerAxis(600)); + + expect(applied).toBe(-50); + expect(sizes).toEqual([150, 250, 200]); + }); + + it('returns 0 for a zero delta', () => { + const c = container([undefined, undefined]); + const sizes = [100, 100]; + + expect(dragHandle(c, sizes, [100, 100], 1, 0, asContainerAxis(200))).toBe(0); + expect(sizes).toEqual([100, 100]); + }); + + it('returns 0 for an out-of-range handle index', () => { + const c = container([undefined, undefined]); + const sizes = [100, 100]; + + // 2 children → valid handles are 1 only; 0 is below, 2 is above. + expect(dragHandle(c, sizes, [100, 100], 0, 50, asContainerAxis(200))).toBe(0); + expect(dragHandle(c, sizes, [100, 100], 2, 50, asContainerAxis(200))).toBe(0); + expect(sizes).toEqual([100, 100]); + }); +}); + +describe('dragHandle: cascade through neighbor mins', () => { + it('takes from the next neighbor when the immediate one hits its min', () => { + // Right drag: middle (idx 1) is the shrink side. Its min is 100; current 200. + // Available room = 100. Then cascade into idx 2 (min 50, current 200, room 150). + const c = container([undefined, '100px', '50px']); + const sizes = [100, 200, 200]; + const applied = dragHandle(c, sizes, [...sizes], 1, 200, asContainerAxis(500)); + + expect(applied).toBe(200); + // idx 1 gave 100 to hit its min, idx 2 gave 100. + expect(sizes).toEqual([300, 100, 100]); + }); + + it('caps `applied` when every shrink neighbor bottoms out', () => { + const c = container([undefined, '100px', '50px']); + const sizes = [100, 200, 200]; + // Asking for 500 but only 100+150 = 250 are takeable. + const applied = dragHandle(c, sizes, [...sizes], 1, 500, asContainerAxis(500)); + + expect(applied).toBe(250); + expect(sizes).toEqual([350, 100, 50]); + }); + + it('respects the immediate grow neighbor\'s max', () => { + const c = container([undefined, undefined, undefined], ['200px']); + const sizes = [180, 200, 200]; + // Grow side (idx 0) starts at 180 and capped at 200 → only 20 can grow. + const applied = dragHandle(c, sizes, [...sizes], 1, 100, asContainerAxis(580)); + + expect(applied).toBe(20); + expect(sizes).toEqual([200, 180, 200]); + }); +}); + +describe('dragHandle: LIFO recovery on reverse', () => { + it('restores cascaded panels furthest-first when the drag reverses', () => { + // First, drag right 200px to force a cascade. + const c = container([undefined, '50px', '50px']); + const sizes = [100, 200, 150]; + const initial = [...sizes]; + + expect(dragHandle(c, sizes, initial, 1, 200, asContainerAxis(450))).toBe(200); + expect(sizes).toEqual([300, 50, 100]); + + // Now drag left 200 back. Expect c (was 150, now 100) to restore first, + // then b (was 200, now 50) — so c reaches 150 before b grows past 50. + const back = dragHandle(c, sizes, initial, 1, -200, asContainerAxis(450)); + + expect(back).toBe(-200); + expect(sizes).toEqual([100, 200, 150]); + }); + + it('overflows extra growth into the immediate grow neighbor after all panels are restored', () => { + // Same starting cascade — now drag left 400 (more than needed to recover). + const c = container([undefined, '50px', '50px']); + const sizes = [100, 200, 150]; + const initial = [...sizes]; + + dragHandle(c, sizes, initial, 1, 200, asContainerAxis(450)); // forward cascade + expect(sizes).toEqual([300, 50, 100]); + + // Asking for -400 — only 300 of A is available (no min on A → A_min=0). + const back = dragHandle(c, sizes, initial, 1, -400, asContainerAxis(450)); + + expect(back).toBe(-300); + // Take from A: A had 300, gives 300 → A=0. + // Give: restore C deficit (50, back to initial 150), restore B deficit + // (150, back to initial 200). 100 left over → B expands past its initial + // to 300. C lands at 150, not above. + expect(sizes).toEqual([0, 300, 150]); + }); +}); + +describe('dragHandle: immediate-neighbor min', () => { + it('floors the immediate at its min even when a small drag wouldn\'t reach it', () => { + // Regression: dragHandle read immMax but not immMin. If the caller + // passed in sizesPx with the immediate below its declared min + // (because setBounds bumped min between drags via a path that + // bypassed the wrapper's rebalance, or any other stale-state route), + // a small drag delta would do a small transfer and leave the + // immediate still below min. Now dragHandle floors the immediate + // at its min on apply. + // + // Setup: A=50, min:80. B=200. Drag right by a small amount (20px). + // Pre-fix: idx 1 gives 20 → A=70, B=180. A is below its 80 min. + // Post-fix: A is floored to 80. + const c = container(['80px', undefined]); + const sizes = [50, 200]; + const initial = [...sizes]; + + dragHandle(c, sizes, initial, 1, 20, asContainerAxis(300)); + + expect(sizes[0]).toBeGreaterThanOrEqual(80); + }); +}); + +describe('dragHandle: edge cases', () => { + it('no-ops a drag where the grow side is already capped and nothing can move', () => { + const c = container([undefined, undefined], ['200px']); + const sizes = [200, 100]; // grow side at its max + const applied = dragHandle(c, sizes, [...sizes], 1, 50, asContainerAxis(300)); + + expect(applied).toBe(0); + expect(sizes).toEqual([200, 100]); + }); + + it('handles a two-child container (only handle 1 valid)', () => { + const c = container([undefined, '40px']); + const sizes = [100, 100]; + const applied = dragHandle(c, sizes, [...sizes], 1, 80, asContainerAxis(200)); + + // Right drag: idx 1 has min 40, room 60. + expect(applied).toBe(60); + expect(sizes).toEqual([160, 40]); + }); + + it('treats pct mins relative to availPx', () => { + // 25% of 400 = 100. So idx 1's min is 100. + const c = container([undefined, '25%']); + const sizes = [200, 200]; + const applied = dragHandle(c, sizes, [...sizes], 1, 200, asContainerAxis(400)); + + expect(applied).toBe(100); + expect(sizes).toEqual([300, 100]); + }); +}); diff --git a/src/__spec__/draggable.dom.spec.ts b/src/__spec__/draggable.dom.spec.ts new file mode 100644 index 0000000..dcb395d --- /dev/null +++ b/src/__spec__/draggable.dom.spec.ts @@ -0,0 +1,474 @@ +// @vitest-environment happy-dom +/** + * Tests for the configureDraggable plugin. happy-dom doesn't run real layout, + * so we can't simulate cursor-motion-driven drag end-to-end — `elementFromPoint` + * always returns null. The drag pipeline is therefore tested by sending the + * pointer events directly to the panel elements, which gives `e.target` the + * needed shape without needing layout. + * + * What we cover: + * - default onDrop swaps data between source and target. + * - canDrag / canDrop predicates gate the cycle correctly. + * - the dispose function detaches listeners. + * - onDragChange fires with the right `{ sourceId, targetId }` shape. + * + * What we don't cover (relies on real layout): cursor-position hit-testing, + * the start-threshold (we synthesize a long-enough move directly), and any + * ghost-image positioning. Those are verified manually in the demo. + */ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { SplitGrid } from '../SplitGrid'; +import { configureDraggable } from '../draggable'; +import type { Container } from '../types'; +import { PanelDirection } from '../types'; + +let host: HTMLElement; + +beforeEach(() => { + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 400, width: 800, height: 400, toJSON: () => ({}), + } as DOMRect)); + host = document.createElement('div'); + document.body.append(host); +}); + +afterEach(() => { + host.remove(); +}); + +function tree(): Container<{ label: string }> { + return { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', data: { label: 'A' } }, + { id: 'b', data: { label: 'B' } }, + { id: 'c', data: { label: 'C' } }, + ], + }; +} + +function mount() { + const grid = new SplitGrid<{ label: string }>({ root: tree() }); + + grid.mount(host); + return grid; +} + +function panelEl(id: string): HTMLElement { + return host.querySelector(`.sp-panel[data-id="${id}"]`) as HTMLElement; +} + +/** + * Send pointer events that simulate "press on source, move over target, + * release over target". We send them on the elements directly because + * happy-dom's elementFromPoint always returns null — the plugin has a + * fallback that reads `e.target` so this still exercises the right path. + */ +function simulateDrag(source: HTMLElement, target: HTMLElement, distance = 20): void { + source.dispatchEvent(new PointerEvent('pointerdown', { + clientX: 100, clientY: 100, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 100 + distance, clientY: 100, bubbles: true, + })); + target.dispatchEvent(new PointerEvent('pointermove', { + clientX: 400, clientY: 100, bubbles: true, + })); + target.dispatchEvent(new PointerEvent('pointerup', { + clientX: 400, clientY: 100, bubbles: true, + })); +} + +describe('configureDraggable', () => { + it('default onDrop swaps data between source and target', () => { + const grid = mount(); + + configureDraggable(grid); + + simulateDrag(panelEl('a'), panelEl('c')); + + expect((grid.get('a')!.node as { data: { label: string } }).data.label).toBe('C'); + expect((grid.get('c')!.node as { data: { label: string } }).data.label).toBe('A'); + }); + + it('custom onDrop is called instead of the default', () => { + const grid = mount(); + const onDrop = vi.fn(); + + configureDraggable(grid, { onDrop }); + + simulateDrag(panelEl('a'), panelEl('b')); + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledWith(expect.objectContaining({ sourceId: 'a', targetId: 'b' })); + + // Default was NOT called — data wasn't swapped. + expect((grid.get('a')!.node as { data: { label: string } }).data.label).toBe('A'); + }); + + it('canDrag blocks the cycle for panels that fail the predicate', () => { + const grid = mount(); + const onDrop = vi.fn(); + + configureDraggable(grid, { + canDrag: (id) => id !== 'a', + onDrop, + }); + + simulateDrag(panelEl('a'), panelEl('b')); + expect(onDrop).not.toHaveBeenCalled(); + + simulateDrag(panelEl('b'), panelEl('a')); + expect(onDrop).toHaveBeenCalledTimes(1); + }); + + it('canDrop blocks drop on disallowed targets', () => { + const grid = mount(); + const onDrop = vi.fn(); + + configureDraggable(grid, { + canDrop: (_source, target) => target !== 'c', + onDrop, + }); + + simulateDrag(panelEl('a'), panelEl('c')); + expect(onDrop).not.toHaveBeenCalled(); + + simulateDrag(panelEl('a'), panelEl('b')); + expect(onDrop).toHaveBeenCalledTimes(1); + }); + + it('dropping a panel on itself is rejected', () => { + const grid = mount(); + const onDrop = vi.fn(); + + configureDraggable(grid, { onDrop }); + + simulateDrag(panelEl('a'), panelEl('a')); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it('onDragChange reports source set + target updates + clear', () => { + const grid = mount(); + const onDragChange = vi.fn(); + + configureDraggable(grid, { onDragChange }); + + simulateDrag(panelEl('a'), panelEl('b')); + + const states = onDragChange.mock.calls.map((args) => args[0] as { sourceId: string | null, targetId: string | null }); + + // First fire: drag started (source=a, target=null until pointermove hits b). + // Mid fire: target=b. + // Final fire: cleared back to null/null. + expect(states.some((s) => s.sourceId === 'a')).toBe(true); + expect(states.some((s) => s.targetId === 'b')).toBe(true); + expect(states.at(-1)).toEqual({ sourceId: null, targetId: null }); + }); + + it('dispose detaches the pointerdown listener', () => { + const grid = mount(); + const onDrop = vi.fn(); + const dispose = configureDraggable(grid, { onDrop }); + + dispose(); + simulateDrag(panelEl('a'), panelEl('b')); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it('renders a ghost element while dragging and removes it on drop', () => { + const grid = mount(); + + configureDraggable(grid); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement | null; + + expect(ghost).not.toBeNull(); + expect(ghost!.style.position).toBe('fixed'); + expect(ghost!.style.pointerEvents).toBe('none'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + + expect(document.querySelector('.sp-drag-ghost')).toBeNull(); + }); + + it('ghost follows the pointer with default bottom-center anchor', () => { + const grid = mount(); + + // The ghost is a clone of the source — own-prop overrides on the + // original don't carry over, so we mock at the prototype level for the + // anchor-math assertions to be deterministic. + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 100, bottom: 60, width: 100, height: 60, toJSON: () => ({}), + } as DOMRect)); + + configureDraggable(grid); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 200, clientY: 200, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 300, clientY: 300, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + // default anchor {x: 0.5, y: 1} → translate by (-width*0.5, -height*1) + // left = 300 - 100*0.5 = 250; top = 300 - 60*1 = 240 + expect(ghost.style.left).toBe('250px'); + expect(ghost.style.top).toBe('240px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 300, clientY: 300, bubbles: true, + })); + }); + + it('ghostAnchor overrides the cursor offset', () => { + const grid = mount(); + + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 100, bottom: 60, width: 100, height: 60, toJSON: () => ({}), + } as DOMRect)); + + configureDraggable(grid, { ghostAnchor: () => ({ x: 0, y: 0 }) }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 100, clientY: 100, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 300, clientY: 200, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + // top-left anchor: ghost left/top match the cursor exactly. + expect(ghost.style.left).toBe('300px'); + expect(ghost.style.top).toBe('200px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 300, clientY: 200, bubbles: true, + })); + }); + + // Regression: the ghost is reparented to `document.body`, detaching it + // from whatever sized box it was previously sharing with the source + // panel. Children that adapt to their container (virtualized lists + // sizing by visible area, `flex: 1` columns, etc) then grow to fill + // body. Pinning the ghost's width/height to the source's measured + // rect at mount time keeps it the size of what the user grabbed. + it('pins the ghost to the source panel\'s measured dimensions', () => { + const grid = mount(); + + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 240, bottom: 80, width: 240, height: 80, toJSON: () => ({}), + } as DOMRect)); + + configureDraggable(grid); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + expect(ghost.style.width).toBe('240px'); + expect(ghost.style.height).toBe('80px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + }); + + // Optional caps for the pinned dimensions: dashboard-style layouts have + // tall column widgets, and a full-height ghost blocks the user's view + // of the drop zone. `ghostMaxSize.height` clips the ghost without + // requiring a custom `ghostRender` factory. + it('ghostMaxSize caps the pinned dimensions', () => { + const grid = mount(); + + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 240, bottom: 500, width: 240, height: 500, toJSON: () => ({}), + } as DOMRect)); + + configureDraggable(grid, { ghostMaxSize: { height: 80 } }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + expect(ghost.style.width).toBe('240px'); + expect(ghost.style.height).toBe('80px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + }); + + // Symmetric to max: floor the ghost so a 0-sized source doesn't render + // an invisible drag affordance. + it('ghostMinSize floors the pinned dimensions', () => { + const grid = mount(); + + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 10, bottom: 10, width: 10, height: 10, toJSON: () => ({}), + } as DOMRect)); + + configureDraggable(grid, { ghostMinSize: { width: 120, height: 40 } }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + expect(ghost.style.width).toBe('120px'); + expect(ghost.style.height).toBe('40px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + }); + + // A custom ghostRender may need its own dimensions (e.g. a small drag + // icon, not a panel-sized copy). If the consumer set width/height + // explicitly, leave it alone. + it('does not overwrite width/height a custom ghostRender already set', () => { + const grid = mount(); + + const custom = document.createElement('div'); + + custom.className = 'my-custom-ghost'; + custom.style.width = '32px'; + custom.style.height = '32px'; + + const ghostRender = () => custom; + + configureDraggable(grid, { ghostRender }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + const ghost = document.querySelector('.sp-drag-ghost') as HTMLElement; + + expect(ghost.style.width).toBe('32px'); + expect(ghost.style.height).toBe('32px'); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + }); + + it('custom ghostRender replaces the default clone', () => { + const grid = mount(); + + const custom = document.createElement('div'); + + custom.className = 'my-custom-ghost'; + custom.textContent = 'dragging…'; + + const ghostRender = vi.fn(() => custom); + + configureDraggable(grid, { ghostRender }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + expect(ghostRender).toHaveBeenCalledTimes(1); + // The returned element is what's mounted (still wrapped with the marker class). + expect(document.querySelector('.my-custom-ghost')).toBe(custom); + expect(custom.classList.contains('sp-drag-ghost')).toBe(true); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + expect(document.querySelector('.sp-drag-ghost')).toBeNull(); + }); + + it('ghost: false disables ghost rendering entirely', () => { + const grid = mount(); + + configureDraggable(grid, { ghost: false }); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + expect(document.querySelector('.sp-drag-ghost')).toBeNull(); + + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 70, clientY: 50, bubbles: true, + })); + }); + + it('dispose mid-drag removes the ghost', () => { + const grid = mount(); + const dispose = configureDraggable(grid); + + panelEl('a').dispatchEvent(new PointerEvent('pointerdown', { + clientX: 50, clientY: 50, bubbles: true, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 70, clientY: 50, bubbles: true, + })); + + expect(document.querySelector('.sp-drag-ghost')).not.toBeNull(); + + dispose(); + + expect(document.querySelector('.sp-drag-ghost')).toBeNull(); + }); + + it('respects a custom `dragSelector` (only matching children initiate drag)', () => { + const grid = mount(); + const onDrop = vi.fn(); + + // Add a handle child to panel A; only it should be draggable. + const handle = document.createElement('span'); + + handle.className = 'drag-me'; + panelEl('a').append(handle); + + configureDraggable(grid, { dragSelector: '.drag-me', onDrop }); + + // Click on the bare panel A — no drag handle, should not start. + simulateDrag(panelEl('a'), panelEl('b')); + expect(onDrop).not.toHaveBeenCalled(); + + // Click on the handle inside A — should start a drag. + simulateDrag(handle, panelEl('b')); + expect(onDrop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__spec__/geometry.spec.ts b/src/__spec__/geometry.spec.ts new file mode 100644 index 0000000..df1462a --- /dev/null +++ b/src/__spec__/geometry.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { axisExtent, measureAxis, measureChildrenPx } from '../geometry'; +import { PanelDirection } from '../types'; + +const rect = (width: number, height: number): DOMRect => ({ + x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}), +} as DOMRect); + +describe('axisExtent', () => { + it('returns width for row direction', () => { + expect(axisExtent(rect(800, 400), PanelDirection.Row)).toBe(800); + }); + + it('returns height for column direction', () => { + expect(axisExtent(rect(800, 400), PanelDirection.Column)).toBe(400); + }); +}); + +describe('measureAxis', () => { + // Pure unit test — the element is a tiny stub that returns a fixed + // rect, so we don't need a real DOM environment. + it('reads the active axis from getBoundingClientRect', () => { + const el = { getBoundingClientRect: () => rect(1000, 500) } as HTMLElement; + + expect(measureAxis(el, PanelDirection.Row)).toBe(1000); + expect(measureAxis(el, PanelDirection.Column)).toBe(500); + }); +}); + +describe('measureChildrenPx', () => { + const container = {} as HTMLElement; + const child = (w: number) => ({ getBoundingClientRect: () => rect(w, 100) } as HTMLElement); + + it('maps the i-th child to its axis size via the resolver', () => { + const children = [child(100), child(200), child(300)]; + const out = measureChildrenPx(container, PanelDirection.Row, 3, (_, i) => children[i]); + + expect(out).toEqual([100, 200, 300]); + }); + + // The fallback matters: structural mutators detach + re-add resizer + // DOM, and a measurement that races the mutation should fall through + // to 0 rather than crash on a missing element. + it('returns 0 for indices the resolver can\'t find', () => { + const out = measureChildrenPx(container, PanelDirection.Row, 3, (_, i) => ( + i === 1 ? undefined : child(50) + )); + + expect(out).toEqual([50, 0, 50]); + }); + + it('returns an empty array when count is 0', () => { + expect(measureChildrenPx(container, PanelDirection.Row, 0, () => undefined)).toEqual([]); + }); +}); diff --git a/src/__spec__/invariant.browser.spec.ts b/src/__spec__/invariant.browser.spec.ts new file mode 100644 index 0000000..f51768c --- /dev/null +++ b/src/__spec__/invariant.browser.spec.ts @@ -0,0 +1,228 @@ +/** + * Property tests for the layout invariant the runtime is built around: + * + * for any sequence of public layout operations, the sum of rendered + * panel + resizer widths equals the container's measured axis width + * within ~1px of sub-pixel rounding noise. + * + * This is the bug shape we kept almost re-introducing: the + * `containerAxisPx` vs `availPx` denominator confusion produced drift + * that summed to a non-zero overflow in real CSS but passed every + * state-shape assertion in the node suite. fast-check generates random + * operation sequences and asserts the invariant after each one. If + * any sequence breaks it, the shrunk counterexample is the regression + * test for whatever just got reverted. + * + * Browser project so real CSS layout runs (happy-dom can't). Slower — + * one mount + replay per shrunk case — so the `numRuns` budget is + * intentionally modest. Bump it locally when chasing a flake. + */ +import { + afterEach, beforeEach, describe, expect, it, +} from 'vitest'; +import * as fc from 'fast-check'; +import { SplitGrid } from '../SplitGrid'; +import { PanelDirection } from '../types'; +import '../styles.css'; + +const CONTAINER_WIDTH = 1000; +const CONTAINER_HEIGHT = 400; + +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement('div'); + host.style.cssText = `width: ${CONTAINER_WIDTH}px; height: ${CONTAINER_HEIGHT}px; position: fixed; top: 0; left: 0;`; + document.body.append(host); +}); + +afterEach(() => { + host.remove(); +}); + +type Op = | { kind: 'equalize' } + | { kind: 'maximize', panel: number } + | { kind: 'minimize', panel: number } + | { kind: 'setSize', panel: number, pct: number } + | { kind: 'setBoundsMin', panel: number, pct: number } + | { kind: 'reset' } + | { kind: 'remove', panel: number } + | { kind: 'add' }; + +/** + * Replay a sequence of operations and assert that the rendered tracks + * fit the container at the end. Sub-pixel rounding gives a small + * tolerance — anything past ±2px is real drift. + * + * Operations animate by default, so we await `settle()` before reading + * the rendered widths — otherwise getBoundingClientRect returns + * mid-transition values that don't reflect the runtime's final state. + */ +function assertFitsContainer(ops: Op[]): void { + const initialChildren = [ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + ] as Array<{ id: string, bounds?: { min?: string } }>; + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 6 }, + children: initialChildren, + }, + }); + + grid.mount(host); + + let nextId = 100; + + for (const op of ops) { + const state = grid.get('root'); + + if (!state || !('sizes' in state)) throw new Error('expected container'); + + const ids = state.node.children.map((c) => c.id); + + // `animate: false` everywhere: the invariant is about the runtime's + // final intent, not what CSS shows mid-transition. Skipping animation + // also means we don't have to await settle() between every op — the + // browser commits the new tracks immediately via the `[data-no-animate]` + // path. Drops test runtime from ~30s to ~3s. + const opts = { animate: false } as const; + + switch (op.kind) { + case 'equalize': { + grid.equalize('root', opts); + break; + } + case 'reset': { + grid.reset('root', opts); + break; + } + case 'maximize': { + if (ids[op.panel]) grid.maximize(ids[op.panel], opts); + break; + } + case 'minimize': { + if (ids[op.panel]) grid.minimize(ids[op.panel], opts); + break; + } + case 'setSize': { + if (ids[op.panel]) grid.setSize(ids[op.panel], `${op.pct}%`, opts); + break; + } + case 'setBoundsMin': { + if (ids[op.panel]) grid.setBounds(ids[op.panel], { min: `${op.pct}%` }, opts); + break; + } + case 'remove': { + if (ids[op.panel] && ids.length > 1) grid.removeChild(ids[op.panel]); + break; + } + case 'add': { + nextId += 1; + grid.addChild('root', { id: `n${nextId}` }); + break; + } + default: { + // Op kinds are an exhaustive union; this branch only runs if a + // new kind is added to the union without a handler here. Throw + // so the omission surfaces immediately rather than silently + // skipping ops. + const exhaustive: never = op; + + throw new Error(`unhandled op: ${JSON.stringify(exhaustive)}`); + } + } + } + + // Read the final layout. Operations ran with `animate: false`, so the + // tracks committed synchronously — no settle() wait needed. Tracks sum + // (panels + resizers) must equal the container width. + const container = host.querySelector('.sp-container') as HTMLElement; + const containerWidth = container.getBoundingClientRect().width; + const children = [...container.children] as HTMLElement[]; + const childrenSum = children.reduce( + (a, el) => a + el.getBoundingClientRect().width, + 0, + ); + + // Tolerance: sub-pixel rounding across N tracks. With up to ~10 + // children + interleaved resizer tracks, browsers can round each track + // independently, accumulating ~0.2px of noise per pair. 3px keeps the + // assertion meaningful (a real drift bug pushes well past 3px on a + // 1000px container) while tolerating the rounding floor. + expect(Math.abs(childrenSum - containerWidth)).toBeLessThan(3); + + // Storage-form invariant: for any container whose c.sizes carries + // pct entries, those pct values sum to exactly 100 — the "saturated + // layout sums to 100 in storage form" rule from CLAUDE.md. This is + // independent of the rendered-px check above: writeTracks' CSS-emit + // boundary corrects drift on the way out, so a broken storage sum + // can render correctly and still leak through any downstream code + // that reads c.sizes (consumers, settle, getRawDefinition, etc.). + const rootState = grid.get('root'); + + if (rootState && 'sizes' in rootState) { + const pctEntries = rootState.sizes.filter((s) => s.unit === 'pct'); + + if (pctEntries.length > 0) { + const pctSum = pctEntries.reduce((a, s) => a + s.value, 0); + + expect(Math.abs(pctSum - 100)).toBeLessThan(0.5); + } + } +} + +const arbOp = fc.oneof( + fc.constant({ kind: 'equalize' as const }), + fc.constant({ kind: 'reset' as const }), + fc.constant({ kind: 'add' as const }), + fc.record({ + kind: fc.constant('maximize' as const), + panel: fc.integer({ min: 0, max: 5 }), + }), + fc.record({ + kind: fc.constant('minimize' as const), + panel: fc.integer({ min: 0, max: 5 }), + }), + fc.record({ + kind: fc.constant('setSize' as const), + panel: fc.integer({ min: 0, max: 5 }), + pct: fc.integer({ min: 0, max: 100 }), + }), + fc.record({ + kind: fc.constant('setBoundsMin' as const), + panel: fc.integer({ min: 0, max: 5 }), + // Cap to 15%. With up to ~6 panels, six mins of 15% sum to 90%, + // leaving 10% headroom for resizer tracks. Higher values produce + // sequences where user-supplied mins genuinely can't fit — CSS + // Grid would (correctly) overflow, but that's a different kind of + // failure than the drift we're hunting. + pct: fc.integer({ min: 0, max: 15 }), + }), + fc.record({ + kind: fc.constant('remove' as const), + panel: fc.integer({ min: 0, max: 5 }), + }), +); + +describe('layout invariant — tracks always sum to container width', () => { + it('holds across arbitrary operation sequences', () => { + fc.assert( + fc.property( + fc.array(arbOp, { minLength: 1, maxLength: 12 }), + (ops) => { + host.replaceChildren(); + assertFitsContainer(ops); + }, + ), + // Budget is small because each run mounts a fresh grid and forces + // multiple real layout passes. The shrinker still narrows any + // failure to a minimal repro automatically. + { numRuns: 40 }, + ); + }); +}); diff --git a/src/__spec__/layout.browser.spec.ts b/src/__spec__/layout.browser.spec.ts new file mode 100644 index 0000000..6692165 --- /dev/null +++ b/src/__spec__/layout.browser.spec.ts @@ -0,0 +1,450 @@ +/** + * Real-browser layout tests. These run in headless Chromium via + * `@vitest/browser-playwright` — every getBoundingClientRect call gets a + * real CSS Grid layout pass, which is the part happy-dom can't simulate. + * + * The node/happy-dom suite covers state-shape invariants (sizes array, + * snapshot membership, onChange reasons). This suite covers the part the + * state shape can't tell you: does the rendered layout actually fit in + * the container, with every track at the size JS thinks it has? The + * JS-vs-CSS percentage drift bug shipped because tests measured the JS + * half but never the CSS half — this is the antidote. + * + * Conventions for browser tests: + * - Mount a fresh `.host` div per test, fixed size, attached to body. + * - Tear down in afterEach (remove the host). + * - Assert on `getBoundingClientRect()` widths/heights, not on the + * internal `sizes` array — the whole point is real layout. + * - Tolerances of ~1px for cross-track sums (subpixel rounding). + */ +import { + afterEach, beforeEach, describe, expect, it, +} from 'vitest'; +import { SplitGrid } from '../SplitGrid'; +import { PanelDirection } from '../types'; +import '../styles.css'; + +const CONTAINER_WIDTH = 1000; +const CONTAINER_HEIGHT = 400; + +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement('div'); + host.style.cssText = `width: ${CONTAINER_WIDTH}px; height: ${CONTAINER_HEIGHT}px; position: fixed; top: 0; left: 0;`; + document.body.append(host); +}); + +afterEach(() => { + host.remove(); +}); + +function widthsOf(selector: string): number[] { + return [...host.querySelectorAll(selector)].map( + (el) => el.getBoundingClientRect().width, + ); +} + +function sum(arr: number[]): number { + return arr.reduce((a, b) => a + b, 0); +} + +describe('layout — real grid', () => { + it('equalize gives every panel exactly avail/N when no min exceeds the share', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + { id: 'c' }, + ], + }, + }); + + grid.mount(host); + grid.equalize('root'); + await grid.settle(); + + // 1000px container, 2 inner resizers × 20 = 40, avail = 960. + // Even share = 320. Panel a's 25% min is 250 — below share, so all + // three end up at 320. This is the regression case the node tests + // can prove via sizes but only the browser can prove via px. + const panels = widthsOf('.sp-panel'); + + expect(panels).toHaveLength(3); + + for (const w of panels) expect(w).toBeCloseTo(320, 0); + + // Grid + resizers sum to container. + const all = widthsOf('.sp-container > *'); + + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + it('maximize fills the available content area with no overflow', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + ], + }, + }); + + grid.mount(host); + grid.maximize('a'); + await grid.settle(); + + const [aw, bw] = widthsOf('.sp-panel'); + const all = widthsOf('.sp-container > *'); + + // Maximized panel takes ~all of avail. The other panel can shrink to 0 + // (no min). One 20px resizer divides them. Total must fit container. + expect(aw).toBeGreaterThan(900); + expect(bw).toBeCloseTo(0, 0); + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + // Regression for the "double-click pushes the px-sized panel off screen" + // bug. When the target's sibling is stored in px units (e.g. a fixed-size + // markers panel next to a 1fr media viewer), maximizing the target + // physically computes the right pixel split but used to convert ALL sizes + // to pct against the pre-mutation budget — which still subtracted the + // sibling's px. The resulting pct values summed to >100% and CSS + // overflowed the container. + it('maximize next to a px-sized sibling does not overflow the container', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 5 }, + children: [ + { id: 'player' }, + { id: 'markers', bounds: { size: '400px', min: '250px', max: '600px' } }, + ], + }, + }); + + grid.mount(host); + await grid.settle(); + grid.maximize('player'); + await grid.settle(); + + const [playerW, markersW] = widthsOf('.sp-panel'); + const all = widthsOf('.sp-container > *'); + + // markers compresses to its 250px min; player takes the rest of avail. + // Avail = 1000 - 5 (resizer) = 995. Player = 995 - 250 = 745. + expect(markersW).toBeCloseTo(250, 0); + expect(playerW).toBeCloseTo(745, 0); + // The cardinal sin: total layout must fit the container, not overflow. + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + // Round-trip of the dblclick gesture: maximize the unbounded panel + // (player), then minimize it back to 0%. The sibling (markers) has + // min=250 / max=600 — it must NEVER exceed either bound across the cycle. + // Pre-fix, the second dblclick (minimize) handed all the freed space to + // markers and it ballooned to ~995px. + it('minimize honors a sibling px-max — target absorbs the leftover', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 5 }, + children: [ + { id: 'player' }, + { id: 'markers', bounds: { size: '400px', min: '250px', max: '600px' } }, + ], + }, + }); + + grid.mount(host); + await grid.settle(); + grid.maximize('player'); + await grid.settle(); + grid.minimize('player'); + await grid.settle(); + + const [playerW, markersW] = widthsOf('.sp-panel'); + const all = widthsOf('.sp-container > *'); + + // markers caps at its 600 max; player absorbs the leftover so the + // layout still fills the container. + expect(markersW).toBeCloseTo(600, 0); + expect(playerW).toBeCloseTo(CONTAINER_WIDTH - 600 - 5, 0); + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + // Regression for the "drag flashes after a max/min toggle" bug. After + // maximize → minimize, the sibling is stored in px (preserve-unit) and + // the target in pct. A drag's writeback used to convert ALL sizes to + // pct against a budget computed BEFORE the reassign — same shape as + // the applyTargetSize bug, surfacing on every pointermove. The CSS + // values resolved to ~150% per track for a single paint frame before + // the next move corrected, producing a visible flash. + it('dragging the resizer after a max/min cycle does not overflow the container', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 5 }, + children: [ + { id: 'player' }, + { id: 'markers', bounds: { size: '400px', min: '250px', max: '600px' } }, + ], + }, + }); + + grid.mount(host); + await grid.settle(); + grid.maximize('player'); + await grid.settle(); + grid.minimize('player'); + await grid.settle(); + + // Now drag the inner resizer slightly to the left. + const resizer = host.querySelector('.sp-container > .sp-resizer')!; + const startX = resizer.getBoundingClientRect().left + 2; + + // Drag RIGHT — shrinking markers (away from its max), so the drag + // math has room to apply. A leftward drag would be a no-op since + // markers is already at its 600px max and can't grow further. + resizer.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, button: 0, clientX: startX, clientY: 50, pointerId: 1, + })); + globalThis.dispatchEvent(new PointerEvent('pointermove', { + bubbles: true, clientX: startX + 10, clientY: 50, + })); + globalThis.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); + await grid.settle(); + + const all = widthsOf('.sp-container > *'); + const [, markersW] = widthsOf('.sp-panel'); + + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + // markers stayed under its max. + expect(markersW).toBeLessThanOrEqual(600 + 0.5); + }); + + // Same shape as the applyTargetSize / pointer-drag fixes, but in + // `equalize`. Reachable via triple-click on a divider. The unit + // preservation has to apply here too, or the same overflow surfaces + // when the triple-click runs on a container that contains a px sibling. + it('equalize next to a px-sized sibling does not overflow the container', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 5 }, + children: [ + { id: 'player' }, + { id: 'markers', bounds: { size: '400px', min: '250px', max: '600px' } }, + ], + }, + }); + + grid.mount(host); + await grid.settle(); + // Maximize/minimize first so markers is at its 600 max and player at pct. + grid.maximize('player'); + await grid.settle(); + grid.minimize('player'); + await grid.settle(); + grid.equalize('root'); + await grid.settle(); + + const all = widthsOf('.sp-container > *'); + const [, markersW] = widthsOf('.sp-panel'); + + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + expect(markersW).toBeLessThanOrEqual(600 + 0.5); + }); + + it('minimize collapses to the min and gives the slack to siblings', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + ], + }, + }); + + grid.mount(host); + grid.minimize('a'); + await grid.settle(); + + // Panel a clamps to its 25% min = 250px. Sibling takes the rest. + const [aw, bw] = widthsOf('.sp-panel'); + const all = widthsOf('.sp-container > *'); + + expect(aw).toBeCloseTo(250, 0); + expect(bw).toBeCloseTo(CONTAINER_WIDTH - 250 - 20, 0); // 730 + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + it('resizer.first adds a leading edge track and the layout still fits', async () => { + // The bug we hunted in the dashboard: pre-fix, `resizer.first` was + // not subtracted from availPx in JS, so every downstream pixel + // computation over-shot and the grid overflowed by `resizerSize × edges`. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20, first: true, last: true }, + children: [ + { id: 'a', bounds: { min: '25%' } }, + { id: 'b' }, + { id: 'c' }, + ], + }, + }); + + grid.mount(host); + grid.maximize('a'); + await grid.settle(); + + // 1 leading + 2 inner + 1 trailing = 4 resizer tracks × 20px = 80px. + // The grid must still fit; the maximized panel takes the rest. + const all = widthsOf('.sp-container > *'); + + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + + const resizers = widthsOf('.sp-resizer'); + + expect(resizers).toHaveLength(4); + + for (const w of resizers) expect(w).toBeCloseTo(20, 0); + }); + + it('setBounds (min-only patch) is idempotent — no drift on the rendered layout', async () => { + // The dashboard's `setBounds(panel, {min: '25%'})` was shifting + // sizes from [48.299%, 48.299%] to [60.799%, 35.799%] on a single + // call — visible as a sudden 12.5% shift in rendered widths. After + // the toPx-denominator fix, the rendered layout should be stable. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 20 }, + children: [{ id: 'a' }, { id: 'b' }], + }, + }); + + grid.mount(host); + grid.equalize('root'); + await grid.settle(); + + const before = widthsOf('.sp-panel'); + + grid.setBounds('a', { min: '25%' }); + await grid.settle(); + + const after = widthsOf('.sp-panel'); + + for (const [i, element] of before.entries()) { + expect(after[i]).toBeCloseTo(element, 0); + } + }); + + it('cycle of equalize → remove → add keeps the new panel inside the container', async () => { + // The off-screen-panel bug. After equalize, pct sizes saturate the + // pct budget; a fresh `1fr` track inserted by addChild would render + // at zero width past the container edge. `makeRoom` + + // `renormalizePctSizes` fix this — every newly-added panel ends up + // with non-zero rendered width inside the container. + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + children: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }, + }); + + grid.mount(host); + grid.equalize('root'); + await grid.settle(); + grid.removeChild('b'); + grid.removeChild('c'); + await grid.settle(); + grid.addChild('root', { id: 'd' }); + grid.addChild('root', { id: 'e' }); + grid.addChild('root', { id: 'f' }); + await grid.settle(); + + const panels = widthsOf('.sp-panel'); + + expect(panels).toHaveLength(4); + + for (const w of panels) { + expect(w).toBeGreaterThan(0); + expect(w).toBeLessThanOrEqual(CONTAINER_WIDTH); + } + + const all = widthsOf('.sp-container > *'); + + expect(sum(all)).toBeCloseTo(CONTAINER_WIDTH, 0); + }); + + // Regression: after a window/container resize, the runtime updates + // `availPx` (via ResizeObserver → measureAll) but used to leave the + // CSS `--sp-tracks` string at the value written by the LAST layout op. + // CSS Grid re-resolved that stale pct string against the new + // container size, and any container with px-sized siblings (or + // resizer-track px) drifted off the new bounds. Now writeAllTracks + // fires after every measure, so the rendered widths re-match. + it('container resize re-emits tracks so px siblings + pct siblings re-fit', async () => { + const grid = new SplitGrid({ + root: { + id: 'root', + direction: PanelDirection.Row, + resizer: { size: 5 }, + children: [ + { id: 'player' }, + { id: 'markers', bounds: { size: '400px', min: '250px', max: '600px' } }, + ], + }, + }); + + grid.mount(host); + await grid.settle(); + + // Force the player's fr track to freeze into a stored pct. Without + // this, CSS resolves `minmax(0, 1fr)` dynamically against the + // current container width and there's no drift to test. After any + // layout op (setSize / equalize / maximize) the fr → pct freeze + // turns the player into a stored-pct track, which is where stale + // `--sp-tracks` would bite on resize. + grid.equalize('root'); + await grid.settle(); + grid.maximize('player'); + await grid.settle(); + + // Resize the container. The ResizeObserver fires; the runtime needs + // to re-write --sp-tracks so CSS resolves against the new width. + host.style.width = '800px'; + + // ResizeObserver dispatches off the rendering steps, then the + // runtime debounces via rAF. Four frames gives both a chance to + // settle in Chromium under load. + for (let i = 0; i < 4; i += 1) { + await new Promise((r) => requestAnimationFrame(r)); + } + + // After maximize + resize to 800: markers clamps to its 250 min, + // player takes the remainder (800 − 5 resizer − 250 = 545). The + // exact split is the point — what matters is the layout still fits + // the new container width. + expect(sum(widthsOf('.sp-container > *'))).toBeCloseTo(800, 0); + }); +}); diff --git a/src/__spec__/length.spec.ts b/src/__spec__/length.spec.ts new file mode 100644 index 0000000..69ca750 --- /dev/null +++ b/src/__spec__/length.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { + asContainerAxis, formatLength, parseLength, pxToPct, sizesFromPx, toPx, +} from '../length'; + +describe('parseLength', () => { + it('parses bare numbers as px', () => { + expect(parseLength(200)).toEqual({ unit: 'px', value: 200 }); + }); + + it('parses px strings', () => { + expect(parseLength('150px')).toEqual({ unit: 'px', value: 150 }); + expect(parseLength('-10px')).toEqual({ unit: 'px', value: -10 }); + }); + + it('parses pct strings', () => { + expect(parseLength('33.5%')).toEqual({ unit: 'pct', value: 33.5 }); + }); + + it('parses `auto` as a single fr share', () => { + expect(parseLength('auto')).toEqual({ unit: 'fr', value: 1 }); + }); + + it('falls back to the provided default for undefined input', () => { + const fallback = { unit: 'pct' as const, value: 25 }; + + expect(parseLength(undefined, fallback)).toEqual(fallback); + }); + + it('falls back when the input is unparseable', () => { + // 'nope' isn't a number, pct, px, or 'auto'. Default fallback is fr=1. + expect(parseLength('nope' as unknown as undefined)).toEqual({ unit: 'fr', value: 1 }); + }); +}); + +describe('formatLength', () => { + it('round-trips px / pct', () => { + expect(formatLength({ unit: 'px', value: 240 })).toBe('240px'); + expect(formatLength({ unit: 'pct', value: 33 })).toBe('33%'); + }); + + it('renders fr as `fr`', () => { + expect(formatLength({ unit: 'fr', value: 1 })).toBe('1fr'); + expect(formatLength({ unit: 'fr', value: 2 })).toBe('2fr'); + }); +}); + +describe('toPx', () => { + it('is identity for px', () => { + expect(toPx({ unit: 'px', value: 120 }, asContainerAxis(1000))).toBe(120); + }); + + it('resolves pct against containerAxisPx', () => { + expect(toPx({ unit: 'pct', value: 25 }, asContainerAxis(800))).toBe(200); + }); + + it('returns NaN for fr — callers must branch on unit before calling', () => { + // Previously toPx returned the denom as a "best-effort sentinel" for + // fr, which silently concealed unit-mix bugs. NaN surfaces the + // misuse at the first downstream comparison instead. + expect(toPx({ unit: 'fr', value: 1 }, asContainerAxis(800))).toBeNaN(); + }); +}); + +describe('pxToPct', () => { + it('computes the share of containerAxisPx', () => { + expect(pxToPct(200, asContainerAxis(800))).toBe(25); + }); + + it('returns 0 when containerAxisPx is non-positive (avoids /0)', () => { + expect(pxToPct(100, asContainerAxis(0))).toBe(0); + expect(pxToPct(100, asContainerAxis(-10))).toBe(0); + }); +}); + +describe('sizesFromPx', () => { + // The core property: a saturated all-pct layout sums to exactly 100. + // Without this invariant, sizesForCss scales pct entries by + // `budget/container` and overflows when the storage sum exceeds 100. + it('all-pct: storage sums to 100 when entries fill avail', () => { + const out = sizesFromPx([300, 695], ['pct', 'pct'], 995); + const sum = out.reduce((a, s) => a + (s.unit === 'pct' ? s.value : 0), 0); + + expect(out.every((s) => s.unit === 'pct')).toBe(true); + expect(sum).toBeCloseTo(100, 5); + }); + + // Sibling stored as px stays px with its new pixel value; the pct + // budget for the OTHER entries divides by `avail − newPxSum`, not + // by the caller's pre-mutation total. This is the bug the helper + // exists to prevent — three runtime sites had it independently. + it('mixed px/pct: pct entry divides by avail − new pxSum, not the old sum', () => { + // avail=995, markers stored as px=600, player as pct. + // Budget for player's pct = avail − 600 = 395, so 395px → 100%. + const out = sizesFromPx([395, 600], ['pct', 'px'], 995); + + expect(out[1]).toEqual({ unit: 'px', value: 600 }); + expect(out[0].unit).toBe('pct'); + expect((out[0] as { unit: 'pct', value: number }).value).toBeCloseTo(100, 5); + }); + + it('all-px: every entry passes through verbatim, no pct math', () => { + const out = sizesFromPx([200, 300, 500], ['px', 'px', 'px'], 1000); + + expect(out).toEqual([ + { unit: 'px', value: 200 }, + { unit: 'px', value: 300 }, + { unit: 'px', value: 500 }, + ]); + }); + + // pxSum > avail can only happen with caller-side bugs (mins overflowing + // the container); the helper clamps the budget at 0 rather than going + // negative, and pxToPct returns 0 for a non-positive denominator. Pct + // entries collapse to 0% — wrong, but not an exception, and CSS just + // shrinks them to 0 instead of NaN-ing the whole layout. + it('avail under-budgeted: pct entries collapse to 0% (non-negative budget)', () => { + const out = sizesFromPx([100, 900], ['pct', 'px'], 500); + + expect(out[1]).toEqual({ unit: 'px', value: 900 }); + expect((out[0] as { unit: 'pct', value: number }).value).toBe(0); + }); + + it('returns a fresh array — callers can assign the result without aliasing', () => { + const newPxs = [100, 200]; + const out = sizesFromPx(newPxs, ['pct', 'pct'], 300); + + expect(out).not.toBe(newPxs as unknown as typeof out); + }); +}); diff --git a/src/__spec__/pointerDrag.dom.spec.ts b/src/__spec__/pointerDrag.dom.spec.ts new file mode 100644 index 0000000..e3bf9bc --- /dev/null +++ b/src/__spec__/pointerDrag.dom.spec.ts @@ -0,0 +1,349 @@ +// @vitest-environment happy-dom +/** + * Tests for the PointerDragState adapter — the pointer-event wrapper + * around the pure `dragHandle` math in `drag.ts`. These run against a + * stub adapter, so the class can be exercised without a real SplitGrid + * tree underneath. + * + * What matters here is the lifecycle and the disposal contract — not the + * exact drag math (covered by `drag.spec.ts`) or pixel-perfect outcomes + * (happy-dom doesn't run real layout). + */ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { PointerDragState, type DragAdapter, type DragTarget } from '../pointerDrag'; +import type { Container } from '../types'; +import { PanelDirection } from '../types'; + +let host: HTMLElement; +let containerEl: HTMLElement; +let resizerEl: HTMLElement; +let panelA: HTMLElement; +let panelB: HTMLElement; + +// happy-dom returns a zero-sized rect by default; make panels and container +// concrete so drag math has real numbers to chew on. +const rectStub = (width: number) => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 100, + width, + height: 100, + toJSON: () => ({}), +} as DOMRect); + +beforeEach(() => { + host = document.createElement('div'); + containerEl = document.createElement('div'); + panelA = document.createElement('div'); + panelB = document.createElement('div'); + resizerEl = document.createElement('div'); + + containerEl.append(panelA, resizerEl, panelB); + host.append(containerEl); + document.body.append(host); + + // Stub each element's rect with a known width. Container = 1000; + // each panel = 500; resizer = 0 (the adapter computes resizer px via + // its own callback, not via the resizer element's rect). + vi.spyOn(containerEl, 'getBoundingClientRect').mockReturnValue(rectStub(1000)); + vi.spyOn(panelA, 'getBoundingClientRect').mockReturnValue(rectStub(500)); + vi.spyOn(panelB, 'getBoundingClientRect').mockReturnValue(rectStub(500)); +}); + +afterEach(() => { + host.remove(); + vi.restoreAllMocks(); +}); + +function makeTarget(): DragTarget { + const node: Container = { + id: 'root', + direction: PanelDirection.Row, + children: [{ id: 'a' }, { id: 'b' }], + }; + + return { + node, el: containerEl, sizes: [], availPx: 0, + }; +} + +function makeAdapter(extra: Partial = {}): DragAdapter & { + applies: DragTarget[], + logs: Array<{ label: string, data?: unknown }>, +} { + const applies: DragTarget[] = []; + const logs: Array<{ label: string, data?: unknown }> = []; + + return { + resizerTracksPx: () => 0, + childElementAt: (_el, i) => (i === 0 ? panelA : panelB), + onDragApply: (t) => { applies.push(t); }, + log: (label, data) => { + const resolved = typeof data === 'function' ? (data as () => unknown)() : data; + + logs.push({ label, data: resolved }); + }, + ...extra, + applies, + logs, + }; +} + +function pointerDown(el: HTMLElement, clientX: number, clientY = 50, pointerId = 1) { + el.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, button: 0, clientX, clientY, pointerId, + })); +} + +function pointerMove(clientX: number, clientY = 50) { + globalThis.dispatchEvent(new PointerEvent('pointermove', { + bubbles: true, clientX, clientY, + })); +} + +function pointerUp() { + globalThis.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); +} + +describe('PointerDragState — lifecycle', () => { + it('snapshots child sizes on pointerdown and emits a "drag start" log', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500); + expect(adapter.logs.map((l) => l.label)).toContain('drag start'); + expect(target.availPx).toBe(1000); // resizerTracksPx returned 0 + // dataset.dragging set on the container during the active drag. + expect(containerEl.dataset.dragging !== undefined).toBe(true); + }); + + it('translates pointermove deltas into sibling resizes via the adapter', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500); + pointerMove(550); // +50px to the right + expect(adapter.applies.length).toBeGreaterThan(0); + + // Sizes are written as pct of containerAxisPx (1000). The exact values + // depend on dragHandle (push-cascade math, tested separately); here we + // just assert the call happened and the sizes are sensible (sum stays + // ≈ 100, ignoring resizer tracks which we stubbed to 0). + const sumPct = target.sizes.reduce( + (a, s) => a + (s.unit === 'pct' ? s.value : 0), + 0, + ); + + expect(sumPct).toBeCloseTo(100, 0); + }); + + it('clears dataset.dragging on pointerup', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500); + pointerUp(); + expect(containerEl.dataset.dragging !== undefined).toBe(false); + expect(adapter.logs.some((l) => l.label === 'drag end')).toBe(true); + }); + + it('handles pointercancel as a terminal route (mirrors pointerup)', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500); + globalThis.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true })); + expect(containerEl.dataset.dragging !== undefined).toBe(false); + + const endLog = adapter.logs.find((l) => l.label === 'drag end'); + + expect((endLog?.data as { reason: string }).reason).toBe('cancel'); + }); + + it('handles window blur as a terminal route', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500); + globalThis.dispatchEvent(new Event('blur')); + expect(containerEl.dataset.dragging !== undefined).toBe(false); + + const endLog = adapter.logs.find((l) => l.label === 'drag end'); + + expect((endLog?.data as { reason: string }).reason).toBe('blur'); + }); + + it('ignores re-entrant pointerdowns while a drag is in flight', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + pointerDown(resizerEl, 500, 50, 1); + pointerDown(resizerEl, 600, 50, 2); // second pointer, ignored + + const startCount = adapter.logs.filter((l) => l.label === 'drag start').length; + + expect(startCount).toBe(1); + }); + + // Regression: a consumer's `#resizer` slot content can include + // interactive controls (a per-panel maximize button, etc) — pointerdowns + // on those shouldn't claim the pointer, because the drag handler's + // `setPointerCapture` + `e.preventDefault()` would suppress the + // subsequent click on the button. Discriminator: interactive elements + // (or anything marked with `[data-no-drag]`) win; everything else + // initiates a drag as normal. + it('ignores pointerdown originating on an interactive control inside the resizer', () => { + const target = makeTarget(); + const adapter = makeAdapter(); + + new PointerDragState(resizerEl, target, 1, adapter); // eslint-disable-line no-new + + const slotBtn = document.createElement('button'); + + resizerEl.append(slotBtn); + slotBtn.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, button: 0, clientX: 500, clientY: 50, pointerId: 1, + })); + + expect(adapter.logs.filter((l) => l.label === 'drag start')).toHaveLength(0); + expect(containerEl.dataset.dragging !== undefined).toBe(false); + }); + + // A consumer can opt out for non-`', + ); + + await nextTick(); + await nextTick(); + + const a = document.querySelector('[data-test="a"]') as HTMLElement; + + expect(a.dataset.max).toBe('false'); + a.click(); + await nextTick(); + expect(a.dataset.max).toBe('true'); + + // Underlying grid was actually toggled — verify via the handle too. + expect(useSplitGrid('root').isMaximized('a')).toBe(true); + }); + + it('panel.equalize operates on the parent container (no-op on root)', async () => { + let captured: ReturnType | null = null; + const Inner = defineComponent({ + setup() { + captured = usePanelState('a'); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => h('div'); + }, + }); + const Parent = defineComponent({ + components: { SplitGridView, Inner }, + template: ` + + `, + data: () => ({ children: makeChildren() }), + }); + + mount(Parent, { attachTo: document.body }); + + await nextTick(); + await nextTick(); + + const grid = useSplitGrid('root'); + + grid.setSize('a', '500px'); + await nextTick(); + + // panel.equalize should fire equalize on the parent ('root') — listen + // via the handle for the 'change' event with reason 'equalize'. + const events: Array<{ reason: string }> = []; + + grid.onChange((e) => events.push({ reason: e.reason })); + captured!.value!.equalize(); + expect(events.some((e) => e.reason === 'equalize')).toBe(true); + }); +}); + +describe('PanelState isDropZone', () => { + it('is false when no drag is active', async () => { + mountView( + '
x
', + ); + + await nextTick(); + await nextTick(); + + for (const id of ['a', 'b']) { + const el = document.querySelector(`[data-test="${id}"]`) as HTMLElement; + + expect(el.dataset.zone).toBe('false'); + } + }); + + it('flips to true on non-source panels while a drag is in progress', async () => { + const Inner = defineComponent({ + props: { id: { type: String, required: true } }, + setup(props) { + const s = usePanelState(() => props.id); + + return () => h('div', { + 'data-test': props.id, + 'data-zone': String(s.value?.isDropZone ?? false), + 'data-dragging': String(s.value?.isDragging ?? false), + }); + }, + }); + const Parent = defineComponent({ + ...twoPanelRoot({ draggable: { onDrop: () => {} } }), + components: { SplitGridView, Inner }, + template: ` + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // Commit a drag from 'a' — pointerdown + a move past threshold. No + // pointerup yet, so the state stays "drag-in-progress". + const aEl = document.querySelector('.sp-panel[data-id="a"]') as HTMLElement; + + aEl.dispatchEvent(new PointerEvent('pointerdown', { + clientX: 100, clientY: 100, bubbles: true, button: 0, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + clientX: 130, clientY: 100, bubbles: true, + })); + await nextTick(); + + const a = document.querySelector('[data-test="a"]') as HTMLElement; + const b = document.querySelector('[data-test="b"]') as HTMLElement; + + expect(a.dataset.dragging).toBe('true'); + // The source is not its own dropzone; every non-source panel is. + expect(a.dataset.zone).toBe('false'); + expect(b.dataset.zone).toBe('true'); + + // Clean up — pointerup so the global pointermove listener detaches. + document.dispatchEvent(new PointerEvent('pointerup', { + clientX: 130, clientY: 100, bubbles: true, + })); + await nextTick(); + expect((document.querySelector('[data-test="a"]') as HTMLElement).dataset.zone).toBe('false'); + expect((document.querySelector('[data-test="b"]') as HTMLElement).dataset.zone).toBe('false'); + }); +}); + +describe('PanelState: container fields (maximizedChildIndex / childrenEqual)', () => { + // These fields were added so containers can drive "is equalize a no-op?" + // and "which child is maxed?" UI affordances without subscribing to + // onChange or scanning sizes. Locked here so wrapper refactors that + // touch buildPanelState don't drop them. + it('exposes container fields and re-renders when they change', async () => { + const captured: { state: ReturnType | null } = { state: null }; + const Inner = defineComponent({ + setup() { + captured.state = usePanelState('root'); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => h('div'); + }, + }); + const Parent = defineComponent({ + components: { SplitGridView, Inner }, + template: ` + + `, + data: () => ({ children: makeChildren() }), + }); + + mount(Parent, { attachTo: document.body }); + + await nextTick(); + await nextTick(); + + expect(captured.state).toBeTruthy(); + + const initial = captured.state!.value!; + + // Initial: no child maxed; both children share `1fr` (no `:size` + // declared on the children factory), so childrenEqual is true. + expect(initial.maximizedChildIndex).toBeNull(); + expect(initial.maximizedChildId).toBeNull(); + expect(initial.childrenEqual).toBe(true); + + // Maximize 'b' (index 1). The container PanelState refreshes. + useSplitGrid('root').toggleExpand('b'); + await nextTick(); + expect(captured.state!.value!.maximizedChildIndex).toBe(1); + expect(captured.state!.value!.maximizedChildId).toBe('b'); + // Sizes diverge after maximize, so equalize would no longer be a no-op. + expect(captured.state!.value!.childrenEqual).toBe(false); + }); + + it('leaf panels report undefined for container-only fields', async () => { + const captured: { state: ReturnType | null } = { state: null }; + const Inner = defineComponent({ + setup() { + captured.state = usePanelState('a'); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => h('div'); + }, + }); + const Parent = defineComponent({ + components: { SplitGridView, Inner }, + template: ` + + `, + data: () => ({ children: makeChildren() }), + }); + + mount(Parent, { attachTo: document.body }); + + await nextTick(); + await nextTick(); + + expect(captured.state!.value!.maximizedChildIndex).toBeUndefined(); + expect(captured.state!.value!.childrenEqual).toBeUndefined(); + expect(captured.state!.value!.childSizes).toBeUndefined(); + }); +}); + +describe('ResizerState: beforeData / afterData', () => { + // Templates reading the slot prop want typed `T` payloads on either + // side of a divider without narrowing `Node` from a union first. + it('the #resizer slot scope exposes beforeData and afterData for leaf neighbors', async () => { + const wrapper = mount(SplitGridView, { + props: { + id: 'root', + direction: PanelDirection.Row, + children: [ + { id: 'a', data: { label: 'A-data' } }, + { id: 'b', data: { label: 'B-data' } }, + ], + }, + attachTo: document.body, + slots: { + leaf: '
{{ panel.id }}
', + resizer: ` +
+ :: +
+ `, + }, + }); + + await nextTick(); + await nextTick(); + + const resizer = document.querySelector('[data-resizer-test="1"]') as HTMLElement; + + expect(resizer).toBeTruthy(); + expect(resizer.dataset.beforeLabel).toBe('A-data'); + expect(resizer.dataset.afterLabel).toBe('B-data'); + + wrapper.unmount(); + }); +}); diff --git a/src/vue/__spec__/declarative.dom.spec.ts b/src/vue/__spec__/declarative.dom.spec.ts new file mode 100644 index 0000000..6ce285d --- /dev/null +++ b/src/vue/__spec__/declarative.dom.spec.ts @@ -0,0 +1,621 @@ +// @vitest-environment happy-dom +/** + * Tests for the declarative / components. + * They register with their enclosing container's child-registry via inject: + * during initial setup they push into a pendingChildren array (consumed when + * the grid is built in onMounted); after mount they call addChild directly. + * Unmount calls removeChild. + * + * Slot content from 's default slot is teleported into the leaf + * element the SplitGrid runtime creates, so v-for can move panel content + * dynamically and the layout follows. + */ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { mount } from '@vue/test-utils'; +import { + defineComponent, nextTick, onBeforeUnmount, ref, +} from 'vue'; +import SplitGridView from '../SplitGridView.vue'; +import SplitPanel from '../SplitPanel.vue'; +import SplitContainer from '../SplitContainer.vue'; +import { registry } from '../handle'; +import { PanelDirection } from '../../types'; + +beforeEach(() => { + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 400, width: 800, height: 400, toJSON: () => ({}), + } as DOMRect)); +}); + +afterEach(() => { + document.body.innerHTML = ''; + + // Tests share id="root" — drop the registry handle so the next test + // starts clean (no carry-over queued listeners or stale handles). + for (const id of registry.keys()) registry.delete(id); +}); + +function ids(): string[] { + return [...document.body.querySelectorAll('.sp-panel')] + .map((el) => (el as HTMLElement).dataset.id ?? ''); +} + +describe(': declarative initial mount', () => { + it('builds the tree from declarative children in template order', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + data: () => ({}), + template: ` + + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + expect(ids()).toEqual(['a', 'b', 'c']); + }); + + it('threads bounds props (size/min/max) through to the leaf', async () => { + let captured: { id: string, bounds: { size: string, min: string, max: string } } | null = null; + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + data: () => ({}), + methods: { + capture(grid: { get: (id: string) => unknown }) { + const a = grid.get('a') as { node: { id: string, bounds: { size: string, min: string, max: string } } }; + + captured = { id: a.node.id, bounds: a.node.bounds }; + }, + }, + template: ` + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + expect(captured).toMatchObject({ + id: 'a', + bounds: { size: '240px', min: '160px', max: '400px' }, + }); + }); +}); + +describe(': nested declarative tree', () => { + it('declares a nested container with its own children', async () => { + let captured: { id: string, direction: string, childIds: string[] } | null = null; + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel, SplitContainer }, + data: () => ({}), + methods: { + capture(grid: { get: (id: string) => unknown }) { + const m = grid.get('middle') as { node: { id: string, direction: string, children: Array<{ id: string }> } }; + + captured = { + id: m.node.id, + direction: m.node.direction, + childIds: m.node.children.map((c) => c.id), + }; + }, + }, + template: ` + + + + + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + + await nextTick(); + await nextTick(); + + // 4 leaves total. Root container has 3 children: sidebar, middle, inspector. + expect(ids()).toEqual(['sidebar', 'editor', 'console', 'inspector']); + expect(captured).toMatchObject({ + id: 'middle', + direction: PanelDirection.Column, + childIds: ['editor', 'console'], + }); + }); +}); + +describe(': dynamic v-for', () => { + it('adds panels when the v-for source grows', async () => { + const items = ref([{ id: 'a' }, { id: 'b' }]); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup: () => ({ items }), + template: ` + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + expect(ids()).toEqual(['a', 'b']); + + items.value.push({ id: 'c' }); + await nextTick(); + await nextTick(); + expect(ids()).toEqual(['a', 'b', 'c']); + }); + + it('removes panels when the v-for source shrinks', async () => { + const items = ref([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup: () => ({ items }), + template: ` + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + expect(ids()).toEqual(['a', 'b', 'c']); + + items.value = items.value.filter((entry) => entry.id !== 'b'); + await nextTick(); + await nextTick(); + expect(ids()).toEqual(['a', 'c']); + }); +}); + +describe(': slot content teleports into the leaf', () => { + it('renders the default slot inside the panel element', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + data: () => ({}), + template: ` + + + hello A + + + hello B + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + const aPanel = document.querySelector('.sp-panel[data-id="a"]') as HTMLElement; + const bPanel = document.querySelector('.sp-panel[data-id="b"]') as HTMLElement; + + expect(aPanel.querySelector('[data-test="a-content"]')?.textContent).toBe('hello A'); + expect(bPanel.querySelector('[data-test="b-content"]')?.textContent).toBe('hello B'); + }); +}); + +describe('auto-generated ids', () => { + it(' without an `id` prop gets a stable per-instance id', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + data: () => ({}), + template: ` + + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + const panelIds = ids(); + + expect(panelIds).toHaveLength(3); + // All ids should be defined, non-empty, and unique. + expect(panelIds.every((id) => id && id.length > 0)).toBe(true); + expect(new Set(panelIds).size).toBe(3); + }); + + it(' without an `id` prop also auto-generates', async () => { + let containerIds: string[] = []; + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel, SplitContainer }, + data: () => ({}), + methods: { + capture(grid: { get: (id: string) => unknown }) { + // Walk the root, collect direct-child container ids. + const root = grid.get('root') as { node: { children: Array<{ id: string, children?: unknown }> } }; + + containerIds = root.node.children + .filter((c) => 'children' in c) + .map((c) => c.id); + }, + }, + template: ` + + + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + expect(containerIds).toHaveLength(1); + expect(containerIds[0].length).toBeGreaterThan(0); + }); + + it('explicit `id` prop takes precedence over the auto value', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + data: () => ({}), + template: ` + + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + const panelIds = ids(); + + expect(panelIds).toContain('explicit-a'); + expect(panelIds).toHaveLength(2); + }); +}); + +describe('reactive props', () => { + // Shared parent factory: captures the grid instance from @ready so tests + // can read node state without dancing through `findComponent(...).vm`, + // which doesn't auto-unwrap defineExpose refs the same way. + type GridLike = { get: (id: string) => { node: Record } | undefined }; + + function makeParent(template: string, extras: Record) { + const captured: { grid: GridLike | null } = { grid: null }; + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel, SplitContainer }, + setup: () => ({ ...extras }), + methods: { + onReady(grid: GridLike) { + captured.grid = grid; + }, + }, + template: `${template}`, + }); + + return { Parent, captured }; + } + + it(' :data updates propagate to setData when the ref is swapped', async () => { + // Use a stable ref for the whole data object — the watcher is shallow, + // so the consumer must swap references rather than mutating in place + // (and definitely never `:data="{ inline: literal }"`, which creates a + // fresh object every render and infinite-loops). + const tabData = ref({ label: 'initial' }); + const { Parent, captured } = makeParent( + '', + { tabData }, + ); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + expect((captured.grid!.get('a')!.node as { data: { label: string } }).data.label).toBe('initial'); + + tabData.value = { label: 'changed' }; + await nextTick(); + expect((captured.grid!.get('a')!.node as { data: { label: string } }).data.label).toBe('changed'); + }); + + it(' :size update propagates to setBounds', async () => { + const size = ref('20%'); + const { Parent, captured } = makeParent( + '', + { size }, + ); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + const aBounds = (captured.grid!.get('a')!.node as { bounds?: { size?: string } }).bounds; + + expect(aBounds?.size).toBe('20%'); + + size.value = '40%'; + await nextTick(); + + const aAfter = (captured.grid!.get('a')!.node as { bounds?: { size?: string } }).bounds; + + expect(aAfter?.size).toBe('40%'); + }); + + it(' :min and :max update propagate as bound changes', async () => { + const min = ref('40px'); + const max = ref(undefined); + const { Parent, captured } = makeParent( + '', + { min, max }, + ); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + const before = (captured.grid!.get('a')!.node as { bounds?: { min?: string, max?: string } }).bounds; + + expect(before?.min).toBe('40px'); + expect(before?.max).toBeUndefined(); + + min.value = '120px'; + max.value = '300px'; + await nextTick(); + + const after = (captured.grid!.get('a')!.node as { bounds?: { min?: string, max?: string } }).bounds; + + expect(after?.min).toBe('120px'); + expect(after?.max).toBe('300px'); + }); + + it(' :direction update propagates to setDirection', async () => { + const direction = ref(PanelDirection.Column); + const { Parent, captured } = makeParent( + ` + + + `, + { direction }, + ); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + expect((captured.grid!.get('c')!.node as { direction: string }).direction).toBe('column'); + + direction.value = PanelDirection.Row; + await nextTick(); + expect((captured.grid!.get('c')!.node as { direction: string }).direction).toBe('row'); + }); +}); + +describe('boundary errors', () => { + it(' outside a throws a useful error', () => { + const Bad = defineComponent({ + components: { SplitPanel }, + template: '', + }); + + expect(() => mount(Bad)).toThrow(/SplitGridView/); + }); +}); + +describe(' per-panel #resizer slot', () => { + it('teleports per-panel #resizer slot into the leading divider', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup: () => ({}), + template: ` + + A + + B + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // The slot mounted into the resizer between A and B — i.e., B's leading. + const divider = document.querySelector('.sp-container > .sp-resizer') as HTMLElement; + + expect(divider).toBeTruthy(); + expect(divider.querySelector('.my-divider-before-b')).toBeTruthy(); + }); + + it('only renders for panels that actually have a leading divider (not the first child)', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` + + A + B + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // A is the first child so its #resizer slot has no leading target + // (resizer.first not enabled). B has a leading divider (the A|B inner). + expect(document.querySelector('.r-a')).toBeNull(); + expect(document.querySelector('.r-b')).toBeTruthy(); + }); + + it('first child claims the resizer.first edge as its leading divider', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` + + A + B + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // A's leading is the decorative leading edge (handleIdx -1) introduced + // by resizer.first. Slot teleports into it. + const edge = document.querySelector('.sp-resizer[data-edge="leading"]') as HTMLElement; + + expect(edge).toBeTruthy(); + expect(edge.querySelector('.r-a')).toBeTruthy(); + }); + + it('wrapper-level #resizer falls back when the panel has no #resizer slot', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` + + + A + B + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // Neither panel claimed the divider — wrapper-level slot won. + expect(document.querySelectorAll('.wrapper-divider')).toHaveLength(1); + }); + + it('per-panel slot takes precedence over the wrapper-level slot for the same divider', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` + + + A + B + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // B's leading divider (the A|B inner) is owned by B — only the panel + // slot renders. The wrapper has no other divider to render into. + expect(document.querySelector('.panel-r')).toBeTruthy(); + expect(document.querySelector('.wrapper-r')).toBeNull(); + }); + + it('releases ownership on unmount so the wrapper slot can reclaim it', async () => { + const showB = ref(true); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup: () => ({ showB }), + template: ` + + + A + B + C + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + // Initially three children: dividers A|B and B|C. B owns its leading + // (A|B); C's leading (B|C) falls back to the wrapper. + expect(document.querySelectorAll('.panel-r')).toHaveLength(1); + expect(document.querySelectorAll('.wrapper-r')).toHaveLength(1); + + // Remove B — its owned divider goes with it; the remaining A|C + // divider now has the wrapper slot mounted. + showB.value = false; + await nextTick(); + await nextTick(); + expect(document.querySelectorAll('.panel-r')).toHaveLength(0); + expect(document.querySelectorAll('.wrapper-r')).toHaveLength(1); + }); +}); + +describe('SplitGridView unmount lifecycle', () => { + // Regression: third-party leaf children (e.g. video players) frequently + // touch the document inside their own beforeUnmount hook — to look up the + // host element by id, or to read parentNode for teardown. If the wrapper + // detaches its DOM before the child's hook runs, those lookups fail with + // errors like "Cannot find element with id …" or null.parentNode. + // The contract: teleported leaf content stays attached to the document + // until after its beforeUnmount runs. + it('teleported leaf content stays in the document during its beforeUnmount', async () => { + const captured = { inDoc: false, parentNode: null as ParentNode | null }; + const Child = defineComponent({ + template: '
', + setup() { + onBeforeUnmount(() => { + const el = document.querySelector('.leaf-child') as HTMLElement | null; + + captured.inDoc = !!el && document.body.contains(el); + captured.parentNode = el?.parentNode ?? null; + }); + }, + }); + const show = ref(true); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel, Child }, + setup: () => ({ show }), + template: ` + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + await nextTick(); + await nextTick(); + + show.value = false; + await nextTick(); + await nextTick(); + + expect(captured.inDoc).toBe(true); + expect(captured.parentNode).not.toBeNull(); + }); +}); diff --git a/src/vue/__spec__/handle.spec.ts b/src/vue/__spec__/handle.spec.ts new file mode 100644 index 0000000..62d4127 --- /dev/null +++ b/src/vue/__spec__/handle.spec.ts @@ -0,0 +1,372 @@ +/** + * Pure semantics of the SplitGridHandle: queue/drain of hooks, throw boundary + * on mutating methods, attach/detach lifecycle, registry sharing by id, and + * onReady's "fire immediately if already ready" rule. + * + * No real SplitGrid here — we pass a hand-rolled fake with `subscribe` / + * mutating methods stubbed. The handle is a thin facade; what matters is the + * lifecycle around it. + * + * Node env (no DOM, no Vue setup) — these tests cover the parts of handle.ts + * that don't depend on inject/provide. + */ +import { + afterEach, describe, expect, it, vi, +} from 'vitest'; +import { + attachHandle, createHandle, detachHandle, registry, +} from '../handle'; +import type { LayoutChangeEvent } from '../../SplitGrid'; + +interface FakeGrid { + subscribe: ReturnType, + setSize: ReturnType, + maximize: ReturnType, + setDirection: ReturnType, + setBounds: ReturnType, + emit: (event: LayoutChangeEvent) => void, +} + +function makeFakeGrid(): FakeGrid { + const subs = new Set<(event: LayoutChangeEvent) => void>(); + + return { + subscribe: vi.fn((cb: (e: LayoutChangeEvent) => void) => { + subs.add(cb); + return () => subs.delete(cb); + }), + setSize: vi.fn(), + maximize: vi.fn(), + setDirection: vi.fn(), + setBounds: vi.fn(), + emit(event: LayoutChangeEvent) { + for (const cb of subs) cb(event); + }, + }; +} + +// Minimal shared-state stub: just the panelStates map the handle reads from. +function makeFakeShared() { + return { + panelStates: new Map(), + resizerEntries: new Map(), + ownedResizers: new Set(), + }; +} + +const baseEvent = (overrides: Partial = {}): LayoutChangeEvent => ({ + containerId: 'root', reason: 'set-size', sizes: [], nodeIds: ['a'], ...overrides, +}); + +afterEach(() => { + // Each test creates its own ids; tests that mutate the module registry + // should clean up after themselves. As a safety net, wipe anything left. + for (const id of registry.keys()) registry.delete(id); +}); + +describe('createHandle: identity and pre-attach state', () => { + it('exposes the id back', () => { + const h = createHandle('grid-a'); + + expect(h.id).toBe('grid-a'); + }); + + it('isReady is false pre-attach; instance is null', () => { + const h = createHandle('g'); + + expect(h.isReady.value).toBe(false); + expect(h.instance).toBeNull(); + }); + + it('reactive reads return undefined / falsy pre-attach', () => { + const h = createHandle<{ label: string }>('g'); + + expect(h.getPanelState('a')).toBeUndefined(); + expect(h.getSize('a')).toBeUndefined(); + expect(h.isMaximized('a')).toBe(false); + expect(h.isAtDefault('a')).toBe(false); + }); +}); + +describe('mutating methods throw pre-attach', () => { + it('setSize throws with a message naming the id', () => { + const h = createHandle('main'); + + expect(() => h.setSize('a', '50%')).toThrow(/main/); + expect(() => h.setSize('a', '50%')).toThrow(/not yet mounted/i); + }); + + it('every mutating method throws pre-attach', () => { + const h = createHandle('g'); + + expect(() => h.maximize('a')).toThrow(); + expect(() => h.minimize('a')).toThrow(); + expect(() => h.toggleExpand('a')).toThrow(); + expect(() => h.toggleMaximize('a')).toThrow(); + expect(() => h.expandNext('c')).toThrow(); + expect(() => h.expandPrev('c')).toThrow(); + expect(() => h.equalize('c')).toThrow(); + expect(() => h.reset('c')).toThrow(); + expect(() => h.addChild('p', { id: 'x' })).toThrow(); + expect(() => h.removeChild('x')).toThrow(); + expect(() => h.swap('a', 'b')).toThrow(); + expect(() => h.syncChildren('c', [])).toThrow(); + expect(() => h.setData('a', undefined)).toThrow(); + expect(() => h.setDataArray([])).toThrow(); + expect(() => h.swapData('a', 'b')).toThrow(); + expect(() => h.moveData('a', 'b')).toThrow(); + expect(() => h.setDirection('c', 'row')).toThrow(); + expect(() => h.setBounds('a', {})).toThrow(); + }); + + it('settle pre-attach resolves immediately', async () => { + const h = createHandle('g'); + + await expect(h.settle()).resolves.toBeUndefined(); + }); +}); + +describe('hook queue & drain', () => { + it('onChange registered pre-attach fires after attach', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onChange(cb); + expect(cb).not.toHaveBeenCalled(); + + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + expect(grid.subscribe).toHaveBeenCalledTimes(1); + + grid.emit(baseEvent({ reason: 'set-size' })); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-size' })); + }); + + it('onChange registered post-attach subscribes directly', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + const cb = vi.fn(); + + h.onChange(cb); + grid.emit(baseEvent({ reason: 'equalize' })); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('onResize fires only for size-changing reasons', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onResize(cb); + + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + grid.emit(baseEvent({ reason: 'set-size' })); + grid.emit(baseEvent({ reason: 'drag' })); + grid.emit(baseEvent({ reason: 'equalize' })); + grid.emit(baseEvent({ reason: 'reset' })); + grid.emit(baseEvent({ reason: 'toggle-expand' })); + // maximize / minimize were extracted from 'set-size' but are still + // size changes — onResize listeners should see them. + grid.emit(baseEvent({ reason: 'maximize' })); + grid.emit(baseEvent({ reason: 'minimize' })); + grid.emit(baseEvent({ reason: 'add-child' })); // structural — should NOT fire onResize + grid.emit(baseEvent({ reason: 'set-data' })); // not a size change either + + expect(cb).toHaveBeenCalledTimes(7); + }); + + it('onStructural fires only for add/remove/swap', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onStructural(cb); + + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + grid.emit(baseEvent({ reason: 'add-child' })); + grid.emit(baseEvent({ reason: 'remove-child' })); + grid.emit(baseEvent({ reason: 'swap' })); + grid.emit(baseEvent({ reason: 'set-size' })); + grid.emit(baseEvent({ reason: 'set-data' })); + + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('off() returned pre-attach removes from queue (never fires)', () => { + const h = createHandle('g'); + const cb = vi.fn(); + const off = h.onChange(cb); + + off(); + + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + grid.emit(baseEvent()); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('off() returned post-attach unsubscribes from the grid', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + const cb = vi.fn(); + const off = h.onChange(cb); + + grid.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(1); + + off(); + grid.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(1); // unchanged + }); +}); + +describe('detach lifecycle', () => { + it('detach flips isReady false and clears instance', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + expect(h.isReady.value).toBe(true); + expect(h.instance).toBe(grid); + + detachHandle(h); + expect(h.isReady.value).toBe(false); + expect(h.instance).toBeNull(); + }); + + it('mutating methods throw again after detach', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + detachHandle(h); + + expect(() => h.setSize('a', '50%')).toThrow(/not yet mounted/i); + }); + + it('queued listeners survive detach and re-fire on re-attach', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onChange(cb); + + const g1 = makeFakeGrid(); + + attachHandle(h, g1 as never, makeFakeShared()); + g1.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(1); + + detachHandle(h); + + const g2 = makeFakeGrid(); + + attachHandle(h, g2 as never, makeFakeShared()); + g2.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('detach cancels live subscriptions on the previous grid', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + const cb = vi.fn(); + + // Registered POST-attach — goes through grid.subscribe directly. + h.onChange(cb); + grid.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(1); + + detachHandle(h); + + // Emitting through the detached grid should not call the listener + // anymore (its unsubscribe ran). + grid.emit(baseEvent()); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +describe('onReady', () => { + it('fires on attach when registered pre-attach', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onReady(cb); + expect(cb).not.toHaveBeenCalled(); + + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(grid); + }); + + it('fires immediately if registered post-attach (handle already ready)', () => { + const h = createHandle('g'); + const grid = makeFakeGrid(); + + attachHandle(h, grid as never, makeFakeShared()); + + const cb = vi.fn(); + + h.onReady(cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(grid); + }); + + it('does not double-fire on subsequent attach cycles', () => { + const h = createHandle('g'); + const cb = vi.fn(); + + h.onReady(cb); + + const g1 = makeFakeGrid(); + + attachHandle(h, g1 as never, makeFakeShared()); + expect(cb).toHaveBeenCalledTimes(1); + + detachHandle(h); + + const g2 = makeFakeGrid(); + + attachHandle(h, g2 as never, makeFakeShared()); + // onReady is one-shot per registration; a re-attach is not a fresh ready. + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +describe('duplicate attach', () => { + it('attaching twice without detach throws', () => { + const h = createHandle('g'); + + attachHandle(h, makeFakeGrid() as never, makeFakeShared()); + expect(() => attachHandle(h, makeFakeGrid() as never, makeFakeShared())).toThrow(/already/i); + }); +}); + +describe('getPanelState delegates to shared map', () => { + it('reads from the panelStates map provided at attach time', () => { + const h = createHandle<{ label: string }>('g'); + const shared = makeFakeShared(); + + shared.panelStates.set('a', { id: 'a' } as never); + attachHandle(h, makeFakeGrid() as never, shared); + + expect(h.getPanelState('a')).toEqual({ id: 'a' }); + }); +}); diff --git a/src/vue/__spec__/useSplitGrid-registry.dom.spec.ts b/src/vue/__spec__/useSplitGrid-registry.dom.spec.ts new file mode 100644 index 0000000..379bcee --- /dev/null +++ b/src/vue/__spec__/useSplitGrid-registry.dom.spec.ts @@ -0,0 +1,243 @@ +// @vitest-environment happy-dom +/** + * Tier-2 registry behavior: useSplitGrid('id') called BEFORE the wrapper + * exists must return a handle, queue any listeners registered against it, + * and replay them once the wrapper mounts under the same id. + * + * Mirrors the vue-flow pattern where a parent's setup wires up `onChange` + * before the child renderer mounts. + */ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, nextTick } from 'vue'; +import SplitGridView from '../SplitGridView.vue'; +import SplitPanel from '../SplitPanel.vue'; +import { useSplitGrid } from '../composables'; +import { registry } from '../handle'; + +beforeEach(() => { + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 400, width: 800, height: 400, toJSON: () => ({}), + } as DOMRect)); +}); + +afterEach(() => { + document.body.innerHTML = ''; + + for (const id of registry.keys()) registry.delete(id); +}); + +describe('useSplitGrid(id) — pre-mount lookup', () => { + it('returns a handle even when no wrapper has claimed the id yet', () => { + let captured: ReturnType | null = null; + const Outer = defineComponent({ + setup() { + captured = useSplitGrid('main'); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => null; + }, + }); + + mount(Outer); + expect(captured).toBeTruthy(); + expect(captured!.id).toBe('main'); + expect(captured!.isReady.value).toBe(false); + }); + + it('queued onChange fires when the wrapper mounts under the same id', async () => { + const onChange = vi.fn(); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup() { + const grid = useSplitGrid('main'); + + grid.onChange(onChange); + return {}; + }, + template: ` + + + + + `, + }); + + const wrapper = mount(Parent, { attachTo: document.body }); + + await nextTick(); + await nextTick(); + + // No size has been mutated yet — onChange has fired 0 times for sure. + onChange.mockClear(); + + // Now mutate the layout via the same handle. + const handle = useSplitGrid('main'); + + handle.setSize('a', '60%'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reason: 'set-size' })); + + wrapper.unmount(); + }); + + it('onReady fires once the wrapper mounts', async () => { + // `mount()` runs the full mount cycle synchronously — including the + // wrapper's onMounted which attaches the handle and drains queued + // onReady callbacks. So by the time mount() returns, ready has + // already fired exactly once. + const ready = vi.fn(); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + setup() { + useSplitGrid('rdy').onReady(ready); + return {}; + }, + template: ` + + + + `, + }); + + mount(Parent, { attachTo: document.body }); + expect(ready).toHaveBeenCalledTimes(1); + expect(ready.mock.calls[0][0]).toBeTruthy(); // the SplitGrid instance + }); + + it('same id returns the same handle object across calls', () => { + const Outer = defineComponent({ + setup() { + const a = useSplitGrid('shared'); + const b = useSplitGrid('shared'); + + expect(a).toBe(b); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => null; + }, + }); + + mount(Outer); + }); + + it('detach (wrapper unmount) does not drop queued listeners — re-mount re-attaches', async () => { + const onChange = vi.fn(); + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + props: { mounted: Boolean }, + setup() { + const grid = useSplitGrid('toggled'); + + grid.onChange(onChange); + return {}; + }, + template: ` +
+ + + + +
+ `, + }); + + const wrapper = mount(Parent, { + props: { mounted: true }, + attachTo: document.body, + }); + + await nextTick(); + await nextTick(); + onChange.mockClear(); + + useSplitGrid('toggled').setSize('a', '40%'); + expect(onChange).toHaveBeenCalledTimes(1); + + // Unmount the child — handle survives in the registry, listener stays. + await wrapper.setProps({ mounted: false }); + expect(useSplitGrid('toggled').isReady.value).toBe(false); + + // Remount — same id, same handle picks up. + await wrapper.setProps({ mounted: true }); + await nextTick(); + await nextTick(); + expect(useSplitGrid('toggled').isReady.value).toBe(true); + + useSplitGrid('toggled').setSize('a', '70%'); + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('useSplitGrid(): no id, no wrapper in scope', () => { + it('throws if called outside a wrapper and without an id', () => { + const Bad = defineComponent({ + setup() { + useSplitGrid(); + // eslint-disable-next-line unicorn/consistent-function-scoping + return () => null; + }, + }); + + expect(() => mount(Bad)).toThrow(/SplitGridView/); + }); +}); + +describe('Duplicate mounts throw', () => { + it('mounting two wrappers with the same id throws on the second mount', async () => { + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` +
+ + + + + + +
+ `, + }); + + expect(() => mount(Parent, { attachTo: document.body })).toThrow(/already|duplicate/i); + }); + + it('losing wrapper does not leak an orphan grid into its host element', () => { + // Regression: in the original mount order — g.mount, g.subscribe, + // attachHandle — the losing wrapper's g had already installed DOM, + // a ResizeObserver, and a subscriber by the time attachHandle threw. + // onUnmounted cleaned up handle.instance (the winning grid), not the + // orphan. The fix is to pre-check the handle is free BEFORE mounting, + // so the failing wrapper never appends its grid container. + const Parent = defineComponent({ + components: { SplitGridView, SplitPanel }, + template: ` +
+
+ + + +
+
+ + + +
+
+ `, + }); + + // The throw aborts mount; the DOM tree reflects whatever side effects + // ran before the throw. Pre-fix, the second host wraps an orphan + // .sp-container. Post-fix, the second host is empty. + expect(() => mount(Parent, { attachTo: document.body })).toThrow(); + + const first = document.body.querySelector('[data-test="first"]') as HTMLElement | null; + const second = document.body.querySelector('[data-test="second"]') as HTMLElement | null; + + // First wrapper mounted normally — single grid container under it. + expect(first?.querySelectorAll('.sp-container').length ?? 0).toBe(1); + // Second wrapper bailed before mounting — no leaked container. + expect(second?.querySelectorAll('.sp-container').length ?? 0).toBe(0); + }); +}); diff --git a/src/vue/composables.ts b/src/vue/composables.ts new file mode 100644 index 0000000..4b086e4 --- /dev/null +++ b/src/vue/composables.ts @@ -0,0 +1,96 @@ +/** + * Vue composables backed by the `` provide/inject context. + * + * `useSplitGrid` is re-exported from `./handle`; it returns the vue-flow-style + * `SplitGridHandle` (methods, event hooks, reactive reads) and supports an + * optional `id` argument for cross-tree / pre-mount lookups via the module + * registry. `usePanelState` keeps its own composable layer: it folds a + * ref/getter id source into a `ComputedRef` that + * reads through `handle.getPanelState`. + */ +import { + computed, getCurrentInstance, inject, onBeforeUnmount, toValue, + type ComputedRef, +} from 'vue'; +import type { Node } from '../types'; +import { + childRegistryKey, + type ChildRegistry, type MaybeRefOrGetter, type PanelState, +} from './context'; +import { useSplitGrid } from './handle'; + +/** + * Returns a reactive computed of the panel's state. Accepts a string, a + * Vue ref, or a getter — anything `toValue` understands. Re-resolves when + * either the id source changes or the underlying state map changes, which + * happens once per relevant `grid.subscribe` event. + * + * Use this from a component sitting inside a `` slot, or any + * descendant that needs per-panel reactivity without threading slot props + * down by hand. + */ +export function usePanelState( + id: MaybeRefOrGetter, +): ComputedRef | undefined> { + // Resolve via `useSplitGrid()` (no id): throws if outside a SplitGridView + // subtree. Same boundary the previous implementation had. + const grid = useSplitGrid(); + + // The computed() establishes a dependency on toValue(id) AND on the + // handle's `getPanelState` read — which under the hood reads from the + // wrapper's shallowReactive panelStates Map. Mutating the map triggers + // re-eval here. + return computed(() => grid.getPanelState(toValue(id))); +} + +/** + * Auto-generate a stable id from the component's instance uid, with an + * optional explicit override. `` / `` both + * follow this pattern — the prefix distinguishes the kind so logs / DOM + * `data-id` are self-describing. + * + * The uid is stable for the component's lifetime; `v-for :key` swaps + * recreate the instance and the uid changes with it (which is the right + * behavior — a new instance is a new panel/container). + */ +export function useAutoId(prefix: string, explicit?: string): string { + if (explicit) return explicit; + + const instance = getCurrentInstance(); + + return `${prefix}-${instance?.uid ?? Math.random().toString(36).slice(2)}`; +} + +/** + * Inject the enclosing `ChildRegistry` (provided by `` or a + * nested ``), register `def` immediately, and unregister + * on `beforeUnmount`. Throws if invoked outside a SplitGridView tree. + * + * `componentName` shapes the error message so users see " + * must be used inside…" instead of a generic "context missing." + * + * Returns the registry so callers can keep a reference (e.g. for + * `` which provides its own registry to descendants but + * still needs to call back into the parent's). + */ +export function useChildRegistry( + componentName: string, + resolvedId: string, + def: Node, +): ChildRegistry { + const registry = inject(childRegistryKey) as ChildRegistry | undefined; + + if (!registry) { + throw new Error(`<${componentName}> must be used inside a .`); + } + + registry.registerChild(def); + + onBeforeUnmount(() => { + registry.unregisterChild(resolvedId); + }); + + return registry; +} + +export { useSplitGrid } from './handle'; diff --git a/src/vue/context.ts b/src/vue/context.ts new file mode 100644 index 0000000..72c32fd --- /dev/null +++ b/src/vue/context.ts @@ -0,0 +1,203 @@ +/** + * The provide/inject contract a `` sets up for its descendants. + * + * The wrapper publishes a context object containing: + * - `handle` — the `SplitGridHandle` facade (lifecycle, events, methods). + * - `panelStates` — a reactive Map keyed by node id, holding per-panel state + * that updates on every relevant event from `grid.subscribe`. + * + * Composables (`useSplitGrid`, `usePanelState`) read from this context rather + * than reaching into the wrapper directly, so they work inside any descendant + * component — including ones rendered via the `#leaf` slot. + */ +import type { InjectionKey, Ref } from 'vue'; +import type { LayoutOptions } from '../SplitGrid'; +import type { + Container, Leaf, Length, LengthInput, Node, PanelDirectionInput, +} from '../types'; +import type { SplitGridHandle } from './handle'; + +export interface PanelState { + /** Node id, matching what's in the tree definition. */ + id: string, + /** A leaf's `data` payload; `undefined` for container nodes. */ + data: T | undefined, + /** + * The DOM element SplitGrid created for this panel. Stable for the + * panel's lifetime — ``'s slot teleports into this element. + */ + el: HTMLElement, + /** The Length the runtime is currently using for this node's slot. */ + size: Length, + /** True iff this is the maximized panel within its parent. */ + isMaximized: boolean, + /** True iff the current size matches the definition's `bounds.size`. */ + isAtDefault: boolean, + /** + * True iff this panel is currently being dragged (only ever true when + * the `` plugin is wired up). The flag mirrors + * `draggable.onDragChange.sourceId === this.id`. + */ + isDragging: boolean, + /** + * True iff this panel is the current drop target during an active drag + * (i.e. it's about to receive an onDrop if the user releases here). + * Mirrors `draggable.onDragChange.targetId === this.id`. + */ + isDropTarget: boolean, + /** + * True iff a drag is in progress AND this panel is not the source — i.e. + * this panel is a candidate drop target. Useful for "highlight everywhere + * the user could drop right now" UI; `isDropTarget` is the more specific + * "cursor is on me right now". Both can be true at the same time on the + * cursor-target panel. + */ + isDropZone: boolean, + /** Index in `parent.node.children`; 0 for the root. */ + indexInParent: number, + /** Parent container's direction (`undefined` for the root). */ + parentDirection: PanelDirectionInput | undefined, + /** Raw node — handy when slot templates want bounds/data/etc. directly. */ + node: Leaf | Container, + /** + * Container-only: current sizes of this container's children, kept in + * lockstep with the runtime via the reactive map. Lets consumers + * watch container-level state (sizes, equality, maximized child) + * without subscribing manually to `onChange` or maintaining a version + * counter. `undefined` for leaf nodes. + */ + childSizes: Length[] | undefined, + /** + * Container-only: id of the currently-maximized child within this + * container, or `null` if none. Matches the runtime's `parent.max?.id`. + * `undefined` for leaf nodes. + */ + maximizedChildId: string | null | undefined, + /** + * Container-only: index of the currently-maximized child within this + * container's children array, or `null` if none. Companion to + * `maximizedChildId` for consumers building "expand prev / expand + * next" UI affordances — they typically want the index, not the id. + * `undefined` for leaf nodes. + */ + maximizedChildIndex: number | null | undefined, + /** + * Container-only: true when this container has 0 or 1 children, or + * when every child currently shares the same `Length` (same unit AND + * same value). Useful for "is equalize a no-op?" UI affordances — + * the toolbar's `:disabled` state on an Equalize button. `undefined` + * for leaf nodes. + */ + childrenEqual: boolean | undefined, + + /** + * Set this panel's size on its parent's axis. Sugar for + * `grid.setSize(panel.id, size, opts)` — no need to plumb the id around. + */ + setSize: (size: LengthInput, opts?: LayoutOptions) => void, + /** + * Toggle whether this panel is maximized within its parent. + */ + toggleExpand: (opts?: LayoutOptions) => void, + /** Maximize this panel within its parent. */ + maximize: (opts?: LayoutOptions) => void, + /** Minimize this panel (restore its parent's default sizing). */ + minimize: (opts?: LayoutOptions) => void, + /** Equalize sizing of the parent container's children. No-op on root. */ + equalize: (opts?: LayoutOptions) => void, + /** Reset the parent container to its definition's sizes. No-op on root. */ + reset: (opts?: LayoutOptions) => void, +} + +export interface ResizerState { + /** + * Handle index within the container. `1..(N-1)` for normal dividers, + * `-1` for the optional leading edge, `N` for the trailing edge. + */ + index: number, + /** Container id (for setSize / toggleExpand from inside the slot). */ + containerId: string, + /** Container's direction. */ + direction: PanelDirectionInput, + /** + * Node defs on either side of this divider. `before` is undefined for + * the leading decorative resizer; `after` is undefined for the trailing. + * `Node` is a union of `Leaf | Container` — only the leaf + * variant carries `data`. For ergonomic typed access to the leaf + * payload, prefer `beforeData` / `afterData` below. + */ + before: Node | undefined, + after: Node | undefined, + /** + * Leaf data payload on either side of the divider, when that side IS a + * leaf. Same shape as `PanelState.data`: typed as `T | undefined` so + * templates don't need to narrow `Node` from a union before reading. + * + * Returns `undefined` when the neighbor is a container (containers + * don't carry data) or absent (leading / trailing decorative resizer). + */ + beforeData: T | undefined, + afterData: T | undefined, +} + +/** + * One entry in the wrapper's resizer registry. Exposed via the context so + * a `` declaring its own `#resizer` slot can look up the + * trailing divider element it should teleport into. + */ +export interface ResizerEntry { + el: HTMLElement, + state: ResizerState, +} + +/** + * Context published by `` via `provide`. Keep this minimal — + * everything reactive composables need is here; nothing else should be added + * without a real consumer. + * + * `resizerEntries` is keyed by `${containerId}#${handleIdx}`. + * + * `ownedResizers` is a registry of keys whose `` is being + * provided by a child ``'s `#resizer` slot rather than the + * top-level wrapper's slot. Used to avoid double-teleports into the same + * element. Mutating it (add on mount, delete on unmount) must trigger + * re-render of the wrapper's resizer loop, so it's a reactive Set. + */ +export interface SplitGridContext { + /** The vue-flow-style facade. Resolves the SplitGrid + lifetime + events. */ + handle: SplitGridHandle, + panelStates: Map>, + resizerEntries: Map>, + ownedResizers: Set, +} + +/** + * Inject key shared by the wrapper and its composables. Typed loosely + * (`unknown` data) — the composables narrow with a type parameter at the + * call site, which is the conventional Vue pattern. + */ +export const splitGridKey: InjectionKey = Symbol('SplitGridContext'); + +/** + * Hierarchical registry published by each container component (the wrapper + * for the root, `` for nested ones). Declarative children + * call `registerChild` on the closest enclosing registry during their setup. + * + * Before the SplitGrid is built (initial mount), `registerChild` pushes into + * a pending-children buffer that the wrapper consumes in `onMounted` to + * construct the root tree. After mount, it dispatches to `grid.addChild` + * directly so v-for additions land on the live tree. + * + * `unregisterChild` only matters post-mount; before mount, the component + * never finished registering in a way that needs cleanup. + */ +export interface ChildRegistry { + containerId: string, + registerChild: (node: Node) => void, + unregisterChild: (id: string) => void, +} + +export const childRegistryKey: InjectionKey = Symbol('SplitGridChildRegistry'); + +/** Lazy ref-or-getter wrapper used by `usePanelState`. */ +export type MaybeRefOrGetter = T | Ref | (() => T); diff --git a/src/vue/handle.ts b/src/vue/handle.ts new file mode 100644 index 0000000..b22d469 --- /dev/null +++ b/src/vue/handle.ts @@ -0,0 +1,427 @@ +/** + * `SplitGridHandle` — the vue-flow-style facade returned by `useSplitGrid`. + * + * Modeled on `useVueFlow`'s composable: methods and event hooks live on a + * stable object you can grab from anywhere, with two-tier resolution — + * provide/inject for the in-tree case, a module-level registry keyed by id + * for out-of-tree or pre-mount lookups. There is no third "create on first + * use" tier; `` remains the only place a `SplitGrid` is + * actually constructed. Handles are durable facades that the wrapper + * attaches to (and detaches from) over their lifetime. + * + * Lifecycle: + * - `createHandle(id)` makes a fresh, unattached handle. + * - `attachHandle(handle, grid, shared)` wires queued hooks onto + * `grid.subscribe`, exposes the shared state for reactive reads, and + * flips `isReady` to true. Called by the wrapper on mount. + * - `detachHandle(handle)` cancels live subscriptions and clears the + * instance, but KEEPS queued listeners. Called on unmount; a remount + * under the same id replays the queue against the new grid. + * + * Two kinds of registrations: + * - Pre-attach: the handle has no live grid yet. The record is queued; + * on attach, it's subscribed; on detach, the live subscription is + * cancelled but the queue stays. Survives v-if / HMR cycles. + * - Post-attach: subscribed directly via `grid.subscribe`. On detach, + * the subscription is cancelled and forgotten — NOT replayed on the + * next attach. This mirrors what "I just want to listen while the + * grid is alive" would naturally do. + */ +import { + inject, shallowRef, type ShallowRef, +} from 'vue'; +import type { + LayoutChangeEvent, LayoutOptions, PanelSizeReport, SplitGrid, +} from '../SplitGrid'; +import type { DragChangeEvent } from '../draggable'; +import type { + Bounds, LengthInput, Node, PanelDirectionInput, +} from '../types'; +import { splitGridKey } from './context'; +import type { PanelState, SplitGridContext } from './context'; + +/** Reasons that count as a "resize" for `onResize`. */ +const RESIZE_REASONS = new Set([ + 'set-size', 'drag', 'equalize', 'reset', 'toggle-expand', 'maximize', 'minimize', +]); + +/** Reasons that count as a "structural" change for `onStructural`. */ +const STRUCTURAL_REASONS = new Set([ + 'add-child', 'remove-child', 'swap', +]); + +/** + * One hook registration record. Tracks the user's callback and, if the + * handle is attached, the live `grid.subscribe` unsubscribe. The record + * itself is the identity used for `off()` — so a queued record that gets + * subscribed on attach can still be removed by its original `off()`. + */ +interface HookRecord { + cb: (event: E) => void, + /** Live subscription off(). Null pre-attach or post-detach. */ + liveOff: (() => void) | null, +} + +/** + * Shape of the cross-wrapper state the handle needs to reach into for + * reactive reads (`getPanelState`, etc.). The wrapper hands this in at + * attach time; nothing else creates one. + */ +export type HandleShared = Omit, 'handle'>; + +/** + * Public handle interface. Mirrors `SplitGrid`'s method surface plus + * lifecycle-aware event hooks. Methods that mutate layout throw if called + * before `isReady.value` becomes true; reactive reads return undefined + * pre-attach (never throw — templates render on first paint before mount). + */ +export interface SplitGridHandle { + readonly id: string, + readonly isReady: Readonly>, + readonly instance: SplitGrid | null, + + // Event hooks — all return an off() that works in any attach state. + onReady: (cb: (grid: SplitGrid) => void) => () => void, + onChange: (cb: (event: LayoutChangeEvent) => void) => () => void, + onResize: (cb: (event: LayoutChangeEvent) => void) => () => void, + onStructural: (cb: (event: LayoutChangeEvent) => void) => () => void, + onDragChange: (cb: (event: DragChangeEvent) => void) => () => void, + + // Reactive reads — undefined / falsy pre-attach. + getPanelState: (id: string) => PanelState | undefined, + getSize: (id: string) => PanelSizeReport | undefined, + isMaximized: (id: string) => boolean, + isAtDefault: (id: string) => boolean, + /** -1 if no child is maximized (or `containerId` isn't a container). */ + getMaximizedIndex: (containerId: string) => number, + /** True for leaves treated as "trivially equal"; false for unknown ids. */ + areChildrenEqual: (containerId: string) => boolean, + + // Mutating methods — throw pre-attach. Same shape as the previous + // `SplitGridViewApi`; consumers just drop the template ref. + setSize: (id: string, size: LengthInput, opts?: LayoutOptions) => void, + maximize: (id: string, opts?: LayoutOptions) => void, + minimize: (id: string, opts?: LayoutOptions) => void, + toggleExpand: (id: string, opts?: LayoutOptions) => void, + toggleMaximize: (id: string, opts?: LayoutOptions) => void, + expandNext: (containerId: string, opts?: LayoutOptions) => string | undefined, + expandPrev: (containerId: string, opts?: LayoutOptions) => string | undefined, + equalize: (containerId: string, opts?: LayoutOptions) => void, + reset: (containerId: string, opts?: LayoutOptions) => void, + addChild: (parentId: string, node: Node, index?: number) => void, + removeChild: (id: string) => void, + swap: (idA: string, idB: string) => void, + syncChildren: (containerId: string, defs: Array>) => void, + setData: (id: string, data: T | undefined) => void, + setDataArray: (items: Array<{ id: string, data: T | undefined }>) => void, + swapData: (idA: string, idB: string) => void, + moveData: (sourceId: string, targetId: string) => void, + setDirection: (containerId: string, direction: PanelDirectionInput, opts?: LayoutOptions) => void, + setBounds: (id: string, bounds: Partial, opts?: LayoutOptions) => void, + settle: (containerId?: string, opts?: { timeout?: number }) => Promise, +} + +/** + * Module-level registry. Keyed by id; populated by `useSplitGrid(id)` + * (when no inject is available) and by the wrapper on mount. Persists for + * the lifetime of the module — handles are durable so v-if / HMR cycles + * preserve queued listeners. Exported for tests to clear between specs. + * + * Typed as `SplitGridHandle` rather than `any` so the registry + * itself doesn't silently widen the data type — each `useSplitGrid(id)` + * lookup site narrows via a single cast (see below). With `` here, a + * caller could ask for `useSplitGrid('id')` against a handle that was + * created for a different `T` and pick up the wrong type with no compiler + * complaint anywhere. + */ +export const registry = new Map>(); + +/** Brand symbol for wrapper-private operations on a handle. Not exported + * from the package index. */ +const INTERNAL = Symbol('SplitGridHandle.internal'); + +interface InternalOps { + attach: (grid: SplitGrid, shared: HandleShared) => void, + detach: () => void, + dispatchDragChange: (event: DragChangeEvent) => void, +} + +function internalOps(handle: SplitGridHandle): InternalOps { + return (handle as unknown as Record>)[INTERNAL]; +} + +/** + * Build a fresh, unattached handle. Pure factory — no DOM, no Vue. + * Exported so the wrapper and tests can drive it directly; consumers + * normally reach handles through `useSplitGrid`. + */ +export function createHandle(id: string): SplitGridHandle { + let grid: SplitGrid | null = null; + let shared: HandleShared | null = null; + const isReady = shallowRef(false); + + // Queued (pre-attach) hooks. Drained on attach; persist across detach + // cycles so a re-mount under the same id re-fires. + const queuedChange = new Set>(); + // Live (post-attach) hooks. Cleared on detach; NOT replayed. + const liveChange = new Set>(); + // Ready callbacks: one-shot, never replayed. + const queuedReady = new Set<(grid: SplitGrid) => void>(); + // DragChange has no underlying subscribe pipe — the wrapper dispatches + // by calling `dispatchDragChange` on the handle. One set; no queue/live + // distinction needed. + const dragSubs = new Set<(event: DragChangeEvent) => void>(); + + function notMountedError(): Error { + return new Error( + `useSplitGrid('${id}'): grid not yet mounted — register inside onReady ` + + 'or after isReady.value flips true.', + ); + } + + /** + * Register a layout-change callback. Pre-attach goes to `queuedChange` + * (drained on attach, survives detach); post-attach goes to + * `liveChange` (subscribed immediately, cancelled on detach). Returns + * an `off()` that handles both states. + */ + function registerChange( + cb: (event: LayoutChangeEvent) => void, + ): () => void { + const rec: HookRecord = { cb, liveOff: null }; + + if (grid) { + // Post-attach: live only. Will be cancelled on detach. + rec.liveOff = grid.subscribe(cb); + liveChange.add(rec); + return () => { + rec.liveOff?.(); + rec.liveOff = null; + liveChange.delete(rec); + }; + } + + // Pre-attach: queue. The drain step in `attachHandle` fills in liveOff. + queuedChange.add(rec); + return () => { + rec.liveOff?.(); + rec.liveOff = null; + queuedChange.delete(rec); + }; + } + + function onChange(cb: (event: LayoutChangeEvent) => void): () => void { + return registerChange(cb); + } + + function onResize(cb: (event: LayoutChangeEvent) => void): () => void { + return registerChange((event) => { + if (RESIZE_REASONS.has(event.reason)) cb(event); + }); + } + + function onStructural(cb: (event: LayoutChangeEvent) => void): () => void { + return registerChange((event) => { + if (STRUCTURAL_REASONS.has(event.reason)) cb(event); + }); + } + + function onReady(cb: (grid: SplitGrid) => void): () => void { + if (grid) { + // Already ready — fire synchronously, then hand back a no-op off. + // One-shot: the registration is consumed. + cb(grid); + return () => {}; + } + + queuedReady.add(cb); + return () => queuedReady.delete(cb); + } + + function onDragChange(cb: (event: DragChangeEvent) => void): () => void { + dragSubs.add(cb); + return () => dragSubs.delete(cb); + } + + function requireGrid(): SplitGrid { + if (!grid) throw notMountedError(); + return grid; + } + + // --- mutating methods (throw pre-attach) ---------------------------- + + const handle: SplitGridHandle = { + id, + isReady, + get instance() { return grid; }, + + onReady, + onChange, + onResize, + onStructural, + onDragChange, + + // Reactive reads. Gating on `isReady.value` does double duty: (1) the + // ternary acts as the "is attached" check (`grid` / `shared` are + // non-null exactly when `isReady.value` is true, kept in lockstep + // by attach/detach below), and (2) reading `isReady.value` registers + // the reactive dep so a `computed(() => handle.getX(...))` created + // pre-attach re-evaluates when the wrapper attaches. + getPanelState: (panelId) => (isReady.value ? shared!.panelStates.get(panelId) : undefined), + getSize: (panelId) => (isReady.value ? grid!.getSize(panelId) : undefined), + isMaximized: (panelId) => (isReady.value ? grid!.isMaximized(panelId) : false), + isAtDefault: (panelId) => (isReady.value ? grid!.isAtDefault(panelId) : false), + getMaximizedIndex: (containerId) => (isReady.value ? grid!.getMaximizedIndex(containerId) : -1), + areChildrenEqual: (containerId) => (isReady.value ? grid!.areChildrenEqual(containerId) : false), + + setSize: (panelId, size, opts) => requireGrid().setSize(panelId, size, opts), + maximize: (panelId, opts) => requireGrid().maximize(panelId, opts), + minimize: (panelId, opts) => requireGrid().minimize(panelId, opts), + toggleExpand: (panelId, opts) => requireGrid().toggleExpand(panelId, opts), + toggleMaximize: (panelId, opts) => requireGrid().toggleMaximize(panelId, opts), + expandNext: (containerId, opts) => requireGrid().expandNext(containerId, opts), + expandPrev: (containerId, opts) => requireGrid().expandPrev(containerId, opts), + equalize: (containerId, opts) => requireGrid().equalize(containerId, opts), + reset: (containerId, opts) => requireGrid().reset(containerId, opts), + addChild: (parentId, node, index) => requireGrid().addChild(parentId, node, index), + removeChild: (panelId) => requireGrid().removeChild(panelId), + swap: (a, b) => requireGrid().swap(a, b), + syncChildren: (containerId, defs) => requireGrid().syncChildren(containerId, defs), + setData: (panelId, data) => requireGrid().setData(panelId, data), + setDataArray: (items) => requireGrid().setDataArray(items), + swapData: (a, b) => requireGrid().swapData(a, b), + moveData: (s, t) => requireGrid().moveData(s, t), + setDirection: (containerId, direction, opts) => requireGrid().setDirection(containerId, direction, opts), + setBounds: (panelId, bounds, opts) => requireGrid().setBounds(panelId, bounds, opts), + // settle is special: pre-attach we resolve immediately (the same way + // the core does for unknown ids / pre-mount calls). + settle: (containerId, opts) => (grid ? grid.settle(containerId, opts) : Promise.resolve()), + }; + + // Internal hooks for the wrapper. Stored on a brand symbol so the + // public interface stays clean and consumers can't call them. + Object.defineProperty(handle, INTERNAL, { + value: { + attach(g: SplitGrid, sharedState: HandleShared): void { + if (grid) { + throw new Error( + `: a SplitGrid is already attached ` + + 'to this id. Duplicate mount?', + ); + } + + grid = g; + shared = sharedState; + + // Drain queued change subscribers — they survive detach cycles, + // so we wire each record's liveOff onto the new grid. + for (const rec of queuedChange) { + rec.liveOff = g.subscribe(rec.cb); + } + + // Flip ready BEFORE firing onReady, so callbacks observing + // `isReady.value` see the new state. + isReady.value = true; + + // One-shot ready callbacks: consume and call. + const readyCbs = [...queuedReady]; + + queuedReady.clear(); + + for (const cb of readyCbs) cb(g); + }, + detach(): void { + // Cancel all live subscriptions for queued (durable) records, + // but leave the records themselves in `queuedChange` so a + // re-attach can re-subscribe them. + for (const rec of queuedChange) { + rec.liveOff?.(); + rec.liveOff = null; + } + + // Live (post-attach) records are NOT durable; cancel and clear. + for (const rec of liveChange) rec.liveOff?.(); + + liveChange.clear(); + grid = null; + shared = null; + isReady.value = false; + }, + dispatchDragChange(event: DragChangeEvent): void { + for (const cb of dragSubs) cb(event); + }, + }, + enumerable: false, + writable: false, + }); + + return handle; +} + +/** + * Wrapper-side attach. Throws if the handle is already attached to a grid + * (i.e. a duplicate `` mount). + */ +export function attachHandle( + handle: SplitGridHandle, + grid: SplitGrid, + shared: HandleShared, +): void { + internalOps(handle).attach(grid, shared); +} + +/** Wrapper-side detach. Idempotent if the handle isn't attached. */ +export function detachHandle(handle: SplitGridHandle): void { + internalOps(handle).detach(); +} + +/** + * Wrapper-side bridge from the draggable plugin's `onDragChange` config + * field to handle subscribers. The wrapper still owns the drag state + * machinery (it needs the events to refresh PanelStates), and just + * dispatches the same event into the handle as it goes. + */ +export function dispatchDragChange( + handle: SplitGridHandle, + event: DragChangeEvent, +): void { + internalOps(handle).dispatchDragChange(event); +} + +/** + * Public composable. Three resolution modes: + * 1. Injected context (in-tree) — return its handle if it matches the + * requested id, or any id wasn't requested. + * 2. Registry lookup by id — get-or-create. Enables pre-mount setup + * wiring and cross-tree access by name. + * 3. Neither — throw with a useful message. + */ +export function useSplitGrid(id?: string): SplitGridHandle { + const injected = inject(splitGridKey, null) as SplitGridContext | null; + + if (injected && (!id || injected.handle.id === id)) { + return injected.handle; + } + + if (id) { + // The registry stores `SplitGridHandle` so it can hold + // handles for many T at once. The cast here is the single narrowing + // boundary — callers asking for a specific `T` get back that + // narrowed view; if the handle was created for a different T, the + // mismatch isn't caught at compile time (the registry has no + // per-id T to remember), but it's also not silently widened + // throughout downstream code as it was under ``. + let h = registry.get(id) as SplitGridHandle | undefined; + + if (!h) { + h = createHandle(id); + registry.set(id, h as SplitGridHandle); + } + return h; + } + + throw new Error( + 'useSplitGrid: must be called inside a component tree, ' + + 'or with an explicit id matching a mounted .', + ); +} diff --git a/src/vue/index.ts b/src/vue/index.ts new file mode 100644 index 0000000..ba5add0 --- /dev/null +++ b/src/vue/index.ts @@ -0,0 +1,10 @@ +export { default as SplitGridView } from './SplitGridView.vue'; +export { default as SplitPanel } from './SplitPanel.vue'; +export { default as SplitContainer } from './SplitContainer.vue'; +export { useAutoId, useSplitGrid, usePanelState } from './composables'; +export type { SplitGridHandle, HandleShared } from './handle'; +export type { + PanelState, ResizerState, ResizerEntry, SplitGridContext, + ChildRegistry, MaybeRefOrGetter, +} from './context'; +export { splitGridKey, childRegistryKey } from './context'; diff --git a/testStyle.scss b/testStyle.scss deleted file mode 100644 index 17d2c60..0000000 --- a/testStyle.scss +++ /dev/null @@ -1,11 +0,0 @@ -#app, #webc { - height: 500px; -} - -.h-100 { - height: 100%; -} - -.w-100 { - width: 100%; -} diff --git a/vite.config.ts b/vite.config.ts index e15815c..6d0f71b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,32 +10,21 @@ export default defineConfig({ cssMinify: true, minify: process.env.NODE_ENV === 'production', lib: { - // Single unified entry point + // Two entries: the framework-free core (`splitpanel`) and a thin Vue + // adapter (`vue`). Consumers pick the subpath that matches their stack; + // the Vue bundle is never loaded by a non-Vue consumer. entry: { - index: resolve(__dirname, 'src/index.ts'), - style: resolve(__dirname, 'src/style.ts'), + splitpanel: resolve(__dirname, 'src/index.ts'), + vue: resolve(__dirname, 'src/vue/index.ts'), }, - // the proper extensions will be added - name: 'SplitPanel', + name: 'SplitGrid', + formats: ['es', 'cjs'], + cssFileName: 'splitpanel', }, rollupOptions: { - // make sure to externalize deps that shouldn't be bundled - // into your library - external: ['vue', '@madronejs/core'], - output: { - // Provide global variables to use in the UMD build - // for externalized deps - globals: { - vue: 'Vue', - }, - }, - }, - }, - css: { - preprocessorOptions: { - scss: { - api: 'modern', - }, + // Vue is an optional peer dep — never bundle it. + external: ['vue'], + output: { globals: { vue: 'Vue' } }, }, }, resolve: { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a9c20c3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,69 @@ +/** + * Vitest configuration with two projects: + * + * - `node` — fast, no real browser. Pure functions and DOM-stub tests + * (happy-dom under a per-file `// @vitest-environment` pragma). Runs + * in milliseconds per file; this is where most assertions live. + * + * - `browser` — Playwright-driven Chromium. Real CSS Grid layout, real + * getBoundingClientRect. Catches the class of bug that the node suite + * can't see by construction — JS-vs-CSS percentage drift, resizer + * overflow, transition timing. Slower (seconds), so reserved for + * end-to-end-shaped assertions only. + * + * File globs route tests to projects. `*.browser.spec.ts` files run in + * the browser project; everything else runs in node. Both can import + * from `src/` directly. + */ +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import vuePlugin from '@vitejs/plugin-vue'; +import jsxPlugin from '@vitejs/plugin-vue-jsx'; + +const root = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + plugins: [vuePlugin(), jsxPlugin()], + resolve: { + alias: { '@': resolve(root, 'src') }, + }, + test: { + projects: [ + { + extends: true, + test: { + name: 'node', + // Default no-DOM env for pure-fn tests. happy-dom is opted in + // per-file via the `// @vitest-environment happy-dom` pragma at + // the top of `*.dom.spec.ts` files. + environment: 'node', + include: ['src/**/*.spec.ts'], + exclude: ['src/**/*.browser.spec.ts'], + }, + }, + { + extends: true, + test: { + name: 'browser', + include: ['src/**/*.browser.spec.ts'], + browser: { + enabled: true, + // Vitest 4 expects a provider factory, not a string. The + // playwright() factory wraps the Playwright driver; swap for + // webdriverio() if Playwright ever isn't available. + provider: playwright(), + // Chromium-only for now. Add 'firefox' / 'webkit' here if a + // cross-browser regression surfaces in real layout. + instances: [{ browser: 'chromium' }], + headless: true, + // No screenshots/videos in CI by default — the failure assertion + // is enough. Flip these on locally when chasing a flake. + screenshotFailures: false, + }, + }, + }, + ], + }, +});