import { createSignal, createEffect, on, onMount, onCleanup, For, Show, type JSX } from 'solid-js'; import { defineWebComponent } from './define'; import { ResizableHandle, normalizeSize } from '../ui/resizable'; type Orientation = 'horizontal' | 'vertical'; /** Bubbling, composed intent: a descendant asks the nearest enclosing * to maximize the item containing it (filling, hiding siblings) * or to restore. Any panel content may emit it — the protocol is zero-config. */ export interface KcMaximizeIntentDetail { /** true = maximize the item containing me; false = restore. */ requested: boolean; } /** Composed, non-bubbling notification the group dispatches DOWN onto the * affected (on maximize) or the group host + the formerly * maximized item (on restore) so descendant content can sync its affordance. */ export interface KcMaximizeStateDetail { /** Whether THIS subtree's item is the maximized one. */ maximized: boolean; } /** Event type names for the cross-element maximize protocol. */ export const KC_MAXIMIZE_INTENT = 'kc-maximize-intent' as const; export const KC_MAXIMIZE_STATE = 'kc-maximize-state' as const; /** Parsed view of a `` light child. */ interface ItemInfo { el: HTMLElement; size?: string; /** Original configured size, captured once and stable across drags (for dblclick-reset). */ defaultSize?: string; min?: string; max?: string; locked: boolean; hidden: boolean; } /** Max number of panels a single group lays out (nest for more). */ const MAX_ITEMS = 3; /** Convert a px-or-% bound to `data-*` attribute values the handle understands. */ function boundAttrs(value: string | undefined): { pxAttr?: string; pctAttr?: string } { if (value === undefined || value === '') return {}; const t = value.trim(); if (t.endsWith('px')) return { pxAttr: String(parseFloat(t)) }; if (t.endsWith('%')) return { pctAttr: String(parseFloat(t)) }; if (Number.isFinite(Number(t))) return { pctAttr: t }; return {}; } interface GroupProps extends Record { /** Layout axis: `horizontal` (row, default) or `vertical` (column). */ orientation?: Orientation; /** Which item index is maximized (null = none). Declarative source of truth. */ maximizedIndex?: number | null; } interface GroupEvents extends Record { /** Fired on drag-end / keyboard resize / visibility change. `detail.sizes` = panel sizes in percent. */ 'kc-change': { sizes: number[] }; /** Observe layout maximize state. */ 'kc-maximize-change': { maximized: boolean; index: number | null }; } /** * `` — a composable, resizable multi-panel layout (up to 3 panels) * with auto-inserted draggable dividers. It lays out its `` * light children along an axis (`orientation`), reading each item's `size`, * `min`, `max`, `locked` and `hidden` attributes via a `MutationObserver`. A * divider is interactive only between two unlocked, visible panels. Emits a * `change` event (`detail.sizes`, percent) on resize / visibility change. */ defineWebComponent('kc-resizable', { orientation: 'horizontal', maximizedIndex: null, }, (props, { element, dispatch }) => { const [items, setItems] = createSignal([]); const orientation = (): Orientation => (props.orientation === 'vertical' ? 'vertical' : 'horizontal'); /** Read the current `` light children + their attributes. */ function readItems() { const all = Array.from(element.children).filter( (c): c is HTMLElement => c.tagName.toLowerCase() === 'kc-resizable-item', ); if (all.length > MAX_ITEMS) { // eslint-disable-next-line no-console console.warn( `: only the first ${MAX_ITEMS} children are laid out ` + `(got ${all.length}). Nest for more.`, ); } const capped = all.slice(0, MAX_ITEMS); const parsed: ItemInfo[] = capped.map((el) => { // Capture the ORIGINAL configured size ONCE per item, before any drag (or // `persistSizes`) overwrites the live `size` attribute. Stashed on a non- // observed data-* attr so dblclick-reset always snaps to the true default, // not to wherever the user last dragged. Empty string = no explicit default. if (!el.hasAttribute('data-kc-default-size')) { el.setAttribute('data-kc-default-size', el.getAttribute('size') ?? ''); } const defaultSize = el.getAttribute('data-kc-default-size') || undefined; return { el, size: el.getAttribute('size') ?? undefined, defaultSize, min: el.getAttribute('min') ?? undefined, max: el.getAttribute('max') ?? undefined, locked: el.hasAttribute('locked') && el.getAttribute('locked') !== 'false', // Honour both the `hidden` boolean attribute and the IDL property — Solid // (and direct `el.hidden = true`) set the property, which doesn't reflect // to the attribute on a custom element. hidden: el.hidden || (el.hasAttribute('hidden') && el.getAttribute('hidden') !== 'false'), }; }); // Assign each visible item to its panel slot by visible order; clear the // rest so hidden/extra items don't leak into a slot. let visIdx = 0; for (const info of parsed) { if (info.hidden) { info.el.removeAttribute('slot'); } else { info.el.setAttribute('slot', `p${visIdx}`); visIdx++; } } for (const el of all.slice(MAX_ITEMS)) el.removeAttribute('slot'); // BUG FIX (U10b): the MutationObserver fires for ANY subtree change, including // a slotted child re-rendering its CONTENT (e.g. a new chat message). If we // unconditionally `setItems(parsed)`, the re-keys and each panel's // flex-basis is re-derived from the item's `size` ATTRIBUTE — wiping out any // size the user dragged (which lives only as inline flex-basis on the panel // div, set by ResizableHandle.settleToPercent). The composite // never hits this because it renders Solid panels directly with no re-read. // // So: only commit a new items() array when the panel LAYOUT actually changed — // i.e. an item was added/removed, or a config attribute (size/min/max/locked/ // hidden) changed. A pure content mutation is structurally identical and is // skipped, leaving the dragged inline basis intact. if (!itemsChanged(items(), parsed)) return; setItems(parsed); } /** True when the parsed item list differs structurally (identity, order, or any * layout-affecting config attribute) from the current one. Content-only * mutations leave every field equal → returns false → no re-render. */ function itemsChanged(prev: ItemInfo[], next: ItemInfo[]): boolean { if (prev.length !== next.length) return true; for (let i = 0; i < prev.length; i++) { const a = prev[i]; const b = next[i]; if (a.el !== b.el) return true; if (a.size !== b.size || a.min !== b.min || a.max !== b.max) return true; if (a.locked !== b.locked || a.hidden !== b.hidden) return true; } return false; } const visible = () => items().filter((i) => !i.hidden); /** Compute current panel sizes (percent of container) from the rendered DOM. */ function currentSizes(): number[] { return visible().map((info) => { const panel = info.el.assignedSlot?.closest('[data-panel]') as HTMLElement | null; const target = panel ?? info.el; const parent = target.parentElement; const dim = orientation() === 'horizontal' ? 'width' : 'height'; const total = parent ? parent.getBoundingClientRect()[dim] : 0; const val = target.getBoundingClientRect()[dim]; return total > 0 ? Math.round((val / total) * 100) : 0; }); } function emitChange() { dispatch('kc-change', { sizes: currentSizes() }); } /** Guard: while writing `size` attributes back from a live layout, the * MutationObserver must NOT treat our own writes as an external structural * change (which would re-render and could fight the in-flight drag). */ let persistingSizes = false; /** * BUG FIX (U10b) — durability: a drag/keyboard resize writes inline flex-basis * onto the panel divs only; the item `size` ATTRIBUTE (the source of truth that * `readItems` re-derives basis from) is left at its initial value. Persist the * live percent back to each visible item's `size` attribute so the dragged * layout survives ANY later re-read (including a genuine add/remove relayout), * not just content-only mutations. Guarded so it doesn't trip the observer. */ function persistSizes() { const vis = visible(); const live = currentSizes(); if (vis.length !== live.length) return; persistingSizes = true; vis.forEach((info, i) => { const pct = live[i]; if (Number.isFinite(pct) && pct > 0) info.el.setAttribute('size', `${pct}%`); }); // Hold the guard across the MutationObserver microtask (it fires AFTER this // synchronous code), then clear it — otherwise the observer sees the flag // already false and re-reads our own writes. Same pattern as applyingMaximize. queueMicrotask(() => { persistingSizes = false; }); } // --- Task 2: maximize/restore core --- interface SavedItemState { el: HTMLElement; size: string | null; hidden: boolean; locked: boolean; } interface MaximizeStash { /** The element that was maximized (identity anchor for re-target + events). */ item: HTMLElement; /** Per-element stash keyed by element identity, not position. */ saved: SavedItemState[]; } const [maximized, setMaximized] = createSignal(null); /** Re-entrancy guard: while we (un)apply maximize attributes, the observer must * NOT emit a mid-flight `change`. The final relayout emits the real one. */ let applyingMaximize = false; /** Find the capped ancestor of an event target, if any. */ function findContainingItem(node: Node | null): HTMLElement | null { let el = node instanceof Element ? node : node?.parentElement ?? null; // The intent is composed; its target may be inside the artifact's shadow. // Resolve to a direct light child of THIS group. const capped = items().map((i) => i.el); while (el) { if (el instanceof HTMLElement && capped.includes(el)) return el; el = el.parentElement ?? (el.getRootNode() as ShadowRoot).host ?? null; } return null; } function readAttrState(el: HTMLElement) { return { size: el.getAttribute('size'), hidden: el.hidden || (el.hasAttribute('hidden') && el.getAttribute('hidden') !== 'false'), locked: el.hasAttribute('locked') && el.getAttribute('locked') !== 'false', }; } function setBoolAttr(el: HTMLElement, name: string, on: boolean) { if (on) el.setAttribute(name, ''); else el.removeAttribute(name); } function maximizeItem(item: HTMLElement) { const list = items(); const index = list.findIndex((i) => i.el === item); if (index < 0) return; const current = maximized(); if (current) { if (current.item === item) return; // same item → no-op restore(); // different item → re-target } applyingMaximize = true; // Capture the EFFECTIVE current % so a post-drag layout restores faithfully. const live = currentSizes(); // visible-order percents (Playwright-verified) let visIdx = 0; const saved: SavedItemState[] = list.map((info) => { const prev = readAttrState(info.el); if (!prev.hidden) { // Write the live % back as the stashed size baseline. const pct = live[visIdx++]; if (Number.isFinite(pct) && pct > 0) info.el.setAttribute('size', `${pct}%`); } return { el: info.el, size: info.el.getAttribute('size'), hidden: prev.hidden, locked: prev.locked }; }); // Hide every other item; free the maximized one to fill. list.forEach((info, i) => { if (i === index) { info.el.removeAttribute('size'); info.el.removeAttribute('locked'); setBoolAttr(info.el, 'hidden', false); } else { setBoolAttr(info.el, 'hidden', true); } }); setMaximized({ item, saved }); element.setAttribute('data-maximized', ''); item.setAttribute('data-maximized-panel', ''); readItems(); emitChange(); // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires // so its queueMicrotask(emitChange) sees the guard and skips (storm guard). queueMicrotask(() => { applyingMaximize = false; }); // Tell the maximized subtree (and only it) it is now maximized. item.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: true }, bubbles: false, composed: true })); dispatch('kc-maximize-change', { maximized: true, index }); } function restore() { const stash = maximized(); if (!stash) return; applyingMaximize = true; // Re-apply saved state keyed by element identity; items no longer in the DOM // are simply skipped. This is safe regardless of sibling additions/removals // that happened while maximized (no index drift). for (const s of stash.saved) { if (!s.el.isConnected) continue; if (s.size === null) s.el.removeAttribute('size'); else s.el.setAttribute('size', s.size); setBoolAttr(s.el, 'hidden', s.hidden); setBoolAttr(s.el, 'locked', s.locked); s.el.removeAttribute('data-maximized-panel'); } const prevItem = stash.item; setMaximized(null); element.removeAttribute('data-maximized'); readItems(); emitChange(); // Keep applyingMaximize = true until AFTER the MutationObserver microtask fires // so its queueMicrotask(emitChange) sees the guard and skips (storm guard). queueMicrotask(() => { applyingMaximize = false; }); // Broadcast restore on the host AND directly on the formerly-maximized item. element.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true })); prevItem?.dispatchEvent(new CustomEvent('kc-maximize-state', { detail: { maximized: false }, bubbles: false, composed: true })); dispatch('kc-maximize-change', { maximized: false, index: null }); } onMount(() => { readItems(); const onIntent = (e: Event) => { const ce = e as CustomEvent; e.stopPropagation(); // nearest group wins (nesting) const item = findContainingItem(ce.target as Node); if (!item) return; // outside any item → ignore if (ce.detail.requested) maximizeItem(item); else restore(); }; element.addEventListener('kc-maximize-intent', onIntent); const onKeydown = (e: KeyboardEvent) => { // Only act while maximized — the capture listener on the host already // ensures the event originates within the group. if (e.key !== 'Escape' || !maximized()) return; e.stopPropagation(); restore(); }; element.addEventListener('keydown', onKeydown, true); const mo = new MutationObserver(() => { // Skip our OWN per-drag size writes (persistSizes). Re-reading here would // setItems() → re-render the , replacing the ResizableHandle mid-drag // and dropping its pointer capture — the drag stalls after the first move. // (The flag is held across this observer microtask; see persistSizes.) if (persistingSizes) return; readItems(); const stash = maximized(); if (stash) { // Use element identity, not a positional index, to detect whether the // maximized item is still present and visible. const maximizedEl = stash.item; const isConnected = maximizedEl.isConnected && element.contains(maximizedEl); const isVisible = isConnected && !(maximizedEl.hidden || maximizedEl.hasAttribute('hidden')); if (!isConnected || !isVisible) { // The maximized item was removed or hidden out from under us → restore. restore(); return; } } if (applyingMaximize) return; // our own maximize writes — skip the emit queueMicrotask(emitChange); }); mo.observe(element, { childList: true, // Attribute changes happen on the *children*, so we // must observe the subtree (filtered to the config attributes) — not just // the host's own attributes. subtree: true, attributes: true, attributeFilter: ['size', 'locked', 'min', 'max', 'hidden'], }); onCleanup(() => { mo.disconnect(); element.removeEventListener('kc-maximize-intent', onIntent); element.removeEventListener('keydown', onKeydown, true); }); // --- Task 3: imperative host methods + declarative maximizedIndex prop --- // Imperative host API (assigned onto the element; typed by resizable.d.ts). const host = element as unknown as { maximize(i: number): void; restore(): void }; host.maximize = (i: number) => { const it = items()[i]?.el; if (it) maximizeItem(it); }; host.restore = () => restore(); // Declarative maximizedIndex → maximize/restore. Skip the initial null run. createEffect( on( () => props.maximizedIndex, (idx) => { if (idx == null) restore(); else { const it = items()[idx]?.el; if (it) maximizeItem(it); } }, { defer: true }, ), ); }); const isHoriz = () => orientation() === 'horizontal'; return ( <> {/* A resizable layout fills its container — default the host to a block that stretches, so consumers only need to give a parent (or the element) a height. Override with an explicit height on the element if needed. */}
{(info, i) => { const min = boundAttrs(info.min); const max = boundAttrs(info.max); const def = boundAttrs(info.defaultSize); const basis = normalizeSize(info.size); const prev = () => visible()[i() - 1]; const isStatic = () => !!(info.locked || prev()?.locked); return ( <> 0}> { // Persist the dragged/dblclick-reset layout back to the item // `size` attributes so it survives later re-reads, then emit. persistSizes(); queueMicrotask(emitChange); }} />
can snap this panel back to it. data-default-size={def.pxAttr} data-default-size-pct={def.pctAttr} style={{ // The panel is a flex item of the root: `flex-basis` sizes its // MAIN axis (width when horizontal, height when vertical) and the // drag/keyboard handlers rewrite that basis — so the layout spine // stays a plain flex row/column (drag math unchanged). // // For the FILL, the panel is itself a `display:grid` with a single // `minmax(0,1fr)` cell on BOTH axes. A grid item (the slotted // ``) stretches to fill its cell on both axes by // default (`place-items:stretch`), and a `1fr` track inside a // definite-sized grid is a *definite* length — so content fills // height AND width whether or not it sets `height:100%`, in both // orientations. A flex panel only stretches the CROSS axis (which is // why the previous `display:flex` panel collapsed the main axis to // content height). This mirrors how Shoelace/Web Awesome // `` lay panels out with grid for guaranteed fill, // adapted here to a per-panel grid cell (our double slot boundary // makes a slot-as-grid-item unusable — a slot with assigned nodes // has a zero box). min:0 on both axes enables shrink-to-scroll; // overflow clips (the item owns the scroll). ...(basis !== undefined ? { 'flex-basis': basis, 'flex-grow': '0', 'flex-shrink': '0' } : { flex: '1 1 0%' }), display: 'grid', 'grid-template-rows': 'minmax(0, 1fr)', 'grid-template-columns': 'minmax(0, 1fr)', 'min-width': '0', 'min-height': '0', overflow: 'hidden', }} >
); }}
); }); interface ItemProps extends Record { /** Initial main-axis size: `"280px"` (fixed) or `"25%"`/`25` (percent). Omitted → flexible. */ size?: string; /** Minimum size during resize (px or %). */ min?: string; /** Maximum size during resize (px or %). */ max?: string; /** Fix this panel's size; adjacent dividers become non-draggable. */ locked?: boolean; /** Hide this panel; its divider is dropped and the rest reflow. */ hidden?: boolean; } /** * `` — a passive config-carrier inside ``. It * renders its own slotted light content (``); the parent `` * reads its `size`/`min`/`max`/`locked`/`hidden` attributes to lay it out. */ defineWebComponent('kc-resizable-item', { size: undefined, min: undefined, max: undefined, locked: false, hidden: false, }, () => ( <> {/* The item host fills the panel's single grid cell (the panel stretches it on both axes) and OWNS the scroll: `display:block; width/height:100%; overflow:auto`. min:0 enables shrink-to-scroll. The FILL of the *slotted* content happens one level in: a `display:grid` wrapper with a single `minmax(0,1fr)` cell whose ONLY child is the ``. A grid item stretches to fill its cell on BOTH axes by default (`place-items:stretch`) into a *definite* `1fr` track, so slotted content fills width AND height whether or not it sets `height:100%`. The grid MUST be its own element wrapping the slot — NOT the host — because the element facade renders an empty portal-mount `
` (for overlays) as a sibling of the facade output; if the host itself were the grid, that portal div would become a second grid item and steal a track (collapsing content to the auto row). Scoping the grid to a wrapper whose sole child is the slot keeps exactly one grid item and is robust to the facade's portal sibling. Mirrors how Shoelace/Web Awesome `` use grid for fill. */}
) as unknown as JSX.Element);