diff --git a/bun.lock b/bun.lock
index 74cff23..dd31e0f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "data-slot-monorepo",
@@ -14,137 +15,144 @@
},
"packages/accordion": {
"name": "@data-slot/accordion",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/alert-dialog": {
"name": "@data-slot/alert-dialog",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/collapsible": {
"name": "@data-slot/collapsible",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/combobox": {
"name": "@data-slot/combobox",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/command": {
"name": "@data-slot/command",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/core": {
"name": "@data-slot/core",
- "version": "0.2.162",
+ "version": "0.2.166",
},
"packages/dialog": {
"name": "@data-slot/dialog",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/dropdown-menu": {
"name": "@data-slot/dropdown-menu",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/hover-card": {
"name": "@data-slot/hover-card",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/navigation-menu": {
"name": "@data-slot/navigation-menu",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/popover": {
"name": "@data-slot/popover",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/radio-group": {
"name": "@data-slot/radio-group",
- "version": "0.2.162",
+ "version": "0.2.166",
+ "dependencies": {
+ "@data-slot/core": "workspace:*",
+ },
+ },
+ "packages/resizable": {
+ "name": "@data-slot/resizable",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/select": {
"name": "@data-slot/select",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/slider": {
"name": "@data-slot/slider",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/switch": {
"name": "@data-slot/switch",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/tabs": {
"name": "@data-slot/tabs",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/toggle": {
"name": "@data-slot/toggle",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/toggle-group": {
"name": "@data-slot/toggle-group",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/tooltip": {
"name": "@data-slot/tooltip",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/core": "workspace:*",
},
},
"packages/ui": {
"name": "@data-slot/ui",
- "version": "0.2.162",
+ "version": "0.2.166",
"dependencies": {
"@data-slot/accordion": "workspace:*",
"@data-slot/alert-dialog": "workspace:*",
@@ -175,6 +183,7 @@
"@data-slot/dialog": "^0.1.2",
"@data-slot/dropdown-menu": "^0.2.2",
"@data-slot/popover": "^0.1.2",
+ "@data-slot/resizable": "workspace:*",
"@data-slot/tabs": "^0.1.2",
"@data-slot/tooltip": "^0.1.2",
"@fontsource/geist-mono": "^5.2.7",
@@ -244,6 +253,8 @@
"@data-slot/radio-group": ["@data-slot/radio-group@workspace:packages/radio-group"],
+ "@data-slot/resizable": ["@data-slot/resizable@workspace:packages/resizable"],
+
"@data-slot/select": ["@data-slot/select@workspace:packages/select"],
"@data-slot/slider": ["@data-slot/slider@workspace:packages/slider"],
diff --git a/packages/resizable/README.md b/packages/resizable/README.md
new file mode 100644
index 0000000..b53e323
--- /dev/null
+++ b/packages/resizable/README.md
@@ -0,0 +1,206 @@
+# @data-slot/resizable
+
+Headless resizable panel groups for vanilla JavaScript. Accessible, unstyled, tiny.
+
+Inspired by [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels)
+so that shadcn/ui-style component libraries can use it as a drop-in primitive.
+
+## Installation
+
+```bash
+npm install @data-slot/resizable
+```
+
+## Quick Start
+
+```html
+
+
+
+```
+
+## API
+
+### `create(scope?)`
+
+Auto-discover and bind all resizable groups in a scope (defaults to `document`).
+
+```typescript
+import { create } from "@data-slot/resizable";
+
+const controllers = create(); // Returns ResizableController[]
+```
+
+### `createResizable(root, options?)`
+
+Create a controller for a specific element.
+
+```typescript
+import { createResizable } from "@data-slot/resizable";
+
+const resizable = createResizable(element, {
+ direction: "horizontal",
+ keyboardResizeBy: 10,
+ onLayoutChange: (layout) => console.log(layout),
+});
+```
+
+### Options
+
+| Option | Type | Default | Description |
+| ------------------ | ---------------------------- | -------------- | -------------------------------- |
+| `direction` | `"horizontal" \| "vertical"` | `"horizontal"` | Layout axis |
+| `keyboardResizeBy` | `number` | `10` | Percent moved per arrow keypress |
+| `onLayoutChange` | `(layout: number[]) => void` | `undefined` | Called when the layout changes |
+
+### Data Attributes
+
+Options can also be set via data attributes. JS options take precedence.
+
+On the root:
+
+| Attribute | Type | Default | Description |
+| ------------------------- | ------ | ------------ | -------------------------------- |
+| `data-direction` | string | `horizontal` | Layout axis |
+| `data-keyboard-resize-by` | number | `10` | Percent moved per arrow keypress |
+
+On each `resizable-panel`:
+
+| Attribute | Type | Default | Description |
+| --------------------- | ------- | ---------- | ----------------------- |
+| `data-default-size` | number | even split | Initial size (%) |
+| `data-min-size` | number | `0` | Minimum size (%) |
+| `data-max-size` | number | `100` | Maximum size (%) |
+| `data-collapsible` | boolean | `false` | Pane can collapse |
+| `data-collapsed-size` | number | `0` | Size (%) when collapsed |
+
+### Controller
+
+| Method/Property | Description |
+| ------------------------- | ------------------------------------------------------ |
+| `layout` | Current layout as `number[]` of percentages (readonly) |
+| `setLayout(sizes)` | Set the full layout (validated/clamped) |
+| `resizePane(index, size)` | Resize a pane to `size`% |
+| `collapse(index)` | Collapse a collapsible pane |
+| `expand(index)` | Expand a collapsed pane |
+| `isCollapsed(index)` | Whether a pane is collapsed |
+| `isExpanded(index)` | Whether a pane is expanded |
+| `getSize(index)` | Current size (%) of a pane |
+| `destroy()` | Cleanup all listeners and global styles |
+
+## Markup Structure
+
+```html
+
+```
+
+A group needs at least one `resizable-panel`, and exactly one
+`resizable-handle` between each adjacent pair of panes.
+
+## Styling
+
+The component sets `flex` styles on the root and panes directly so it works
+with no CSS at all. Use `data-*` attributes for visual styling:
+
+```css
+/* Style the handle */
+[data-slot="resizable-handle"] {
+ width: 4px;
+ background: #ccc;
+}
+
+[data-slot="resizable"][data-direction="vertical"] [data-slot="resizable-handle"] {
+ width: 100%;
+ height: 4px;
+}
+
+/* Active drag / keyboard focus */
+[data-slot="resizable-handle"][data-active] {
+ background: #2563eb;
+}
+
+/* Collapsed pane */
+[data-slot="resizable-panel"][data-collapsed] {
+ opacity: 0;
+}
+```
+
+Add a CSS transition on `flex-grow` for animated collapse/expand:
+
+```css
+[data-slot="resizable-panel"] {
+ transition: flex-grow 0.2s ease;
+}
+```
+
+## Accessibility
+
+The component automatically handles:
+
+- `role="separator"` on each handle
+- `aria-orientation` (perpendicular to the layout direction)
+- `aria-controls` linking each handle to its preceding pane
+- `aria-valuemin` / `aria-valuemax` / `aria-valuenow` reflecting live constraints
+- Keyboard resizing: Arrow keys, `Home`/`End` (extremes), `Enter` (toggle
+ collapse), `F6` (cycle focus between handles); `Shift` for larger steps
+- Unique ID generation for the root, panes, and handles
+
+## Persisting Layout
+
+There is no built-in persistence, but reading and restoring the split is a
+one-liner:
+
+```javascript
+import { createResizable } from "@data-slot/resizable";
+
+const el = document.querySelector('[data-slot="resizable"]');
+const saved = localStorage.getItem("layout:sidebar");
+
+const resizable = createResizable(el, {
+ onLayoutChange(layout) {
+ localStorage.setItem("layout:sidebar", JSON.stringify(layout));
+ },
+});
+
+if (saved) resizable.setLayout(JSON.parse(saved));
+```
+
+## Events
+
+### Outbound Events
+
+```javascript
+element.addEventListener("resizable:change", (e) => {
+ console.log("Layout:", e.detail.layout);
+});
+
+element.addEventListener("resizable:dragging", (e) => {
+ console.log("Dragging:", e.detail.dragging);
+});
+```
+
+### Inbound Events
+
+| Event | Detail | Description |
+| --------------- | ---------------------- | ------------------------------- |
+| `resizable:set` | `{ layout: number[] }` | Set the layout programmatically |
+
+```javascript
+element.dispatchEvent(new CustomEvent("resizable:set", { detail: { layout: [30, 70] } }));
+```
+
+## License
+
+MIT
diff --git a/packages/resizable/package.json b/packages/resizable/package.json
new file mode 100644
index 0000000..3ac07fd
--- /dev/null
+++ b/packages/resizable/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@data-slot/resizable",
+ "version": "0.2.166",
+ "keywords": [
+ "data-slot",
+ "headless",
+ "panels",
+ "resizable",
+ "split-view",
+ "splitter",
+ "ui",
+ "vanilla"
+ ],
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/bejamas/data-slot.git",
+ "directory": "packages/resizable"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsdown"
+ },
+ "dependencies": {
+ "@data-slot/core": "workspace:*"
+ }
+}
diff --git a/packages/resizable/src/index.test.ts b/packages/resizable/src/index.test.ts
new file mode 100644
index 0000000..352168b
--- /dev/null
+++ b/packages/resizable/src/index.test.ts
@@ -0,0 +1,545 @@
+import { describe, expect, it } from "bun:test";
+import { createResizable, create } from "./index";
+import { clearRootBinding, setRootBinding } from "../../core/src/index";
+
+describe("Resizable", () => {
+ const ROOT_BINDING_KEY = "@data-slot/resizable";
+
+ const markup = (opts?: { direction?: string; panes?: Array> }) => {
+ const direction = opts?.direction ?? "horizontal";
+ const panes = opts?.panes ?? [{ "data-default-size": "50" }, { "data-default-size": "50" }];
+ const paneEls = panes
+ .map((attrs, i) => {
+ const attrStr = Object.entries(attrs)
+ .map(([k, v]) => `${k}="${v}"`)
+ .join(" ");
+ const handle = i < panes.length - 1 ? `` : "";
+ return `Pane ${i}
${handle}`;
+ })
+ .join("");
+ document.body.innerHTML = `
+
+ ${paneEls}
+
+ `;
+ return document.getElementById("root")!;
+ };
+
+ const setup = (opts?: Parameters[0]) => {
+ const root = markup(opts);
+ const panes = Array.from(
+ root.querySelectorAll('[data-slot="resizable-panel"]'),
+ ) as HTMLElement[];
+ const handles = Array.from(
+ root.querySelectorAll('[data-slot="resizable-handle"]'),
+ ) as HTMLElement[];
+ const controller = createResizable(root);
+ return { root, panes, handles, controller };
+ };
+
+ // jsdom does not implement layout; force a deterministic group size.
+ const mockGroupSize = (root: HTMLElement, size: number, horizontal = true) => {
+ Object.defineProperty(root, "getBoundingClientRect", {
+ configurable: true,
+ value: () =>
+ ({
+ width: horizontal ? size : 0,
+ height: horizontal ? 0 : size,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => {},
+ }) as DOMRect,
+ });
+ };
+
+ it("initializes with an even default layout", () => {
+ const { controller } = setup({
+ panes: [{}, {}],
+ });
+ expect(controller.layout).toEqual([50, 50]);
+ controller.destroy();
+ });
+
+ it("respects data-default-size on panes", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "30" }, { "data-default-size": "70" }],
+ });
+ expect(controller.layout).toEqual([30, 70]);
+ controller.destroy();
+ });
+
+ it("distributes remaining space among panes without a default size", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "40" }, {}, {}],
+ });
+ const [a, b, c] = controller.layout;
+ expect(a).toBe(40);
+ expect(b).toBeCloseTo(30);
+ expect(c).toBeCloseTo(30);
+ controller.destroy();
+ });
+
+ it("sets flex-grow style on each pane", () => {
+ const { panes, controller } = setup({
+ panes: [{ "data-default-size": "25" }, { "data-default-size": "75" }],
+ });
+ // CSS normalizes the number, so `25` not `25.0` regardless of how it's set.
+ expect(parseFloat(panes[0].style.flexGrow)).toBeCloseTo(25);
+ expect(parseFloat(panes[1].style.flexGrow)).toBeCloseTo(75);
+ controller.destroy();
+ });
+
+ it("clamps a default layout that exceeds constraints", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "90", "data-max-size": "60" }, { "data-default-size": "10" }],
+ });
+ const [a, b] = controller.layout;
+ expect(a).toBeLessThanOrEqual(60);
+ expect(a + b).toBeCloseTo(100);
+ controller.destroy();
+ });
+
+ it('sets role="separator" and aria-orientation on handles', () => {
+ const { handles, controller } = setup();
+ expect(handles[0].getAttribute("role")).toBe("separator");
+ expect(handles[0].getAttribute("aria-orientation")).toBe("vertical");
+ controller.destroy();
+ });
+
+ it('uses aria-orientation="horizontal" for a vertical group', () => {
+ const { handles, controller } = setup({ direction: "vertical" });
+ expect(handles[0].getAttribute("aria-orientation")).toBe("horizontal");
+ controller.destroy();
+ });
+
+ it("sets aria-controls linking handle to the preceding pane", () => {
+ const { handles, panes, controller } = setup();
+ expect(handles[0].getAttribute("aria-controls")).toBe(panes[0].id);
+ controller.destroy();
+ });
+
+ it("sets aria-value attributes on handles", () => {
+ const { handles, controller } = setup({
+ panes: [{ "data-default-size": "40" }, { "data-default-size": "60" }],
+ });
+ expect(handles[0].getAttribute("aria-valuenow")).toBe("40");
+ expect(handles[0].getAttribute("aria-valuemin")).toBe("0");
+ expect(handles[0].getAttribute("aria-valuemax")).toBe("100");
+ controller.destroy();
+ });
+
+ it("adds a default tabindex to handles", () => {
+ const { handles, controller } = setup();
+ expect(handles[0].getAttribute("tabindex")).toBe("0");
+ controller.destroy();
+ });
+
+ it("respects an author-provided tabindex", () => {
+ document.body.innerHTML = `
+
+ `;
+ const root = document.getElementById("root")!;
+ const controller = createResizable(root);
+ const handle = root.querySelector('[data-slot="resizable-handle"]')!;
+ expect(handle.getAttribute("tabindex")).toBe("-1");
+ controller.destroy();
+ });
+
+ it("sets data-state / data-expanded on panes", () => {
+ const { panes, controller } = setup();
+ expect(panes[0].getAttribute("data-state")).toBe("expanded");
+ expect(panes[0].hasAttribute("data-expanded")).toBe(true);
+ expect(panes[0].hasAttribute("data-collapsed")).toBe(false);
+ controller.destroy();
+ });
+
+ it("resizes via the imperative API", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ controller.resizePane(0, 70);
+ expect(controller.layout[0]).toBeCloseTo(70);
+ expect(controller.layout[1]).toBeCloseTo(30);
+ controller.destroy();
+ });
+
+ it("clamps imperative resize to minSize", () => {
+ const { controller } = setup({
+ panes: [
+ { "data-default-size": "50", "data-min-size": "20" },
+ { "data-default-size": "50", "data-min-size": "20" },
+ ],
+ });
+ controller.resizePane(0, 5);
+ expect(controller.layout[0]).toBeGreaterThanOrEqual(20);
+ expect(controller.layout[1]).toBeLessThanOrEqual(80);
+ controller.destroy();
+ });
+
+ it("collapses and expands a collapsible pane", () => {
+ const { controller } = setup({
+ panes: [
+ {
+ "data-default-size": "50",
+ "data-min-size": "20",
+ "data-collapsible": "true",
+ "data-collapsed-size": "0",
+ },
+ { "data-default-size": "50" },
+ ],
+ });
+
+ expect(controller.isCollapsed(0)).toBe(false);
+ controller.collapse(0);
+ expect(controller.isCollapsed(0)).toBe(true);
+ expect(controller.layout[0]).toBeCloseTo(0);
+ expect(controller.layout[1]).toBeCloseTo(100);
+
+ controller.expand(0);
+ expect(controller.isCollapsed(0)).toBe(false);
+ expect(controller.layout[0]).toBeCloseTo(50);
+ controller.destroy();
+ });
+
+ it("expand restores the pre-collapse size", () => {
+ const { controller } = setup({
+ panes: [
+ {
+ "data-default-size": "35",
+ "data-min-size": "10",
+ "data-collapsible": "true",
+ "data-collapsed-size": "0",
+ },
+ { "data-default-size": "65" },
+ ],
+ });
+ controller.collapse(0);
+ controller.expand(0);
+ expect(controller.layout[0]).toBeCloseTo(35);
+ controller.destroy();
+ });
+
+ it("collapse is a no-op for non-collapsible panes", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ controller.collapse(0);
+ expect(controller.layout).toEqual([50, 50]);
+ controller.destroy();
+ });
+
+ it("updates the data-collapsed attribute when collapsed", () => {
+ const { panes, controller } = setup({
+ panes: [
+ {
+ "data-default-size": "50",
+ "data-collapsible": "true",
+ "data-collapsed-size": "0",
+ "data-min-size": "15",
+ },
+ { "data-default-size": "50" },
+ ],
+ });
+ controller.collapse(0);
+ expect(panes[0].getAttribute("data-state")).toBe("collapsed");
+ expect(panes[0].hasAttribute("data-collapsed")).toBe(true);
+ controller.destroy();
+ });
+
+ it("reports getSize / isExpanded correctly", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "60" }, { "data-default-size": "40" }],
+ });
+ expect(controller.getSize(0)).toBe(60);
+ expect(controller.isExpanded(0)).toBe(true);
+ controller.destroy();
+ });
+
+ it("setLayout validates and normalizes", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ controller.setLayout([20, 80]);
+ expect(controller.layout[0]).toBeCloseTo(20);
+ expect(controller.layout[1]).toBeCloseTo(80);
+ controller.destroy();
+ });
+
+ it("setLayout renormalizes a layout that does not sum to 100", () => {
+ const { controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ controller.setLayout([30, 30]);
+ const sum = controller.layout[0] + controller.layout[1];
+ expect(sum).toBeCloseTo(100);
+ controller.destroy();
+ });
+
+ it("emits resizable:change with the layout", () => {
+ const { root, controller } = setup();
+ let last: number[] | undefined;
+ root.addEventListener("resizable:change", (e) => {
+ last = (e as CustomEvent).detail.layout;
+ });
+ controller.resizePane(0, 65);
+ expect(last).toBeDefined();
+ expect(last![0]).toBeCloseTo(65);
+ controller.destroy();
+ });
+
+ it("calls onLayoutChange callback", () => {
+ const root = markup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ let last: number[] | undefined;
+ const controller = createResizable(root, {
+ onLayoutChange: (layout) => {
+ last = layout;
+ },
+ });
+ controller.resizePane(0, 60);
+ expect(last![0]).toBeCloseTo(60);
+ controller.destroy();
+ });
+
+ it("responds to the inbound resizable:set event", () => {
+ const { root, controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ root.dispatchEvent(new CustomEvent("resizable:set", { detail: { layout: [25, 75] } }));
+ expect(controller.layout[0]).toBeCloseTo(25);
+ controller.destroy();
+ });
+
+ it("drags a handle with pointer events", () => {
+ const { root, handles, controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ mockGroupSize(root as HTMLElement, 1000, true);
+
+ handles[0].dispatchEvent(new MouseEvent("mousedown", { clientX: 500, bubbles: true }));
+ document.body.dispatchEvent(new MouseEvent("mousemove", { clientX: 600, bubbles: true }));
+ // Moved +100px of 1000px == +10%.
+ expect(controller.layout[0]).toBeCloseTo(60);
+ expect(controller.layout[1]).toBeCloseTo(40);
+
+ window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ controller.destroy();
+ });
+
+ it("ignores movement when not dragging", () => {
+ const { root, controller } = setup();
+ mockGroupSize(root as HTMLElement, 1000, true);
+ document.body.dispatchEvent(new MouseEvent("mousemove", { clientX: 600, bubbles: true }));
+ expect(controller.layout).toEqual([50, 50]);
+ controller.destroy();
+ });
+
+ it("sets data-active during a pointer drag", () => {
+ const { root, handles, controller } = setup();
+ mockGroupSize(root as HTMLElement, 1000, true);
+ handles[0].dispatchEvent(new MouseEvent("mousedown", { clientX: 500, bubbles: true }));
+ expect(handles[0].getAttribute("data-active")).toBe("pointer");
+ window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ expect(handles[0].hasAttribute("data-active")).toBe(false);
+ controller.destroy();
+ });
+
+ it("resizes with the keyboard arrow keys", () => {
+ const { handles, controller } = setup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ handles[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
+ // default keyboardResizeBy is 10
+ expect(controller.layout[0]).toBeCloseTo(60);
+ controller.destroy();
+ });
+
+ it("Home / End keys jump to extremes", () => {
+ const { handles, controller } = setup({
+ panes: [
+ { "data-default-size": "50", "data-min-size": "10" },
+ { "data-default-size": "50", "data-min-size": "10" },
+ ],
+ });
+ handles[0].dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
+ expect(controller.layout[0]).toBeCloseTo(10);
+ handles[0].dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
+ expect(controller.layout[0]).toBeCloseTo(90);
+ controller.destroy();
+ });
+
+ it("Enter toggles collapse from the keyboard", () => {
+ const { handles, controller } = setup({
+ panes: [
+ {
+ "data-default-size": "50",
+ "data-min-size": "20",
+ "data-collapsible": "true",
+ "data-collapsed-size": "0",
+ },
+ { "data-default-size": "50" },
+ ],
+ });
+ handles[0].dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ expect(controller.isCollapsed(0)).toBe(true);
+ controller.destroy();
+ });
+
+ it('sets data-active="keyboard" on focus', () => {
+ const { handles, controller } = setup();
+ handles[0].dispatchEvent(new FocusEvent("focus"));
+ expect(handles[0].getAttribute("data-active")).toBe("keyboard");
+ controller.destroy();
+ });
+
+ it("supports more than two panes", () => {
+ const { handles, controller } = setup({
+ panes: [
+ { "data-default-size": "33" },
+ { "data-default-size": "33" },
+ { "data-default-size": "34" },
+ ],
+ });
+ expect(handles).toHaveLength(2);
+ expect(controller.layout).toHaveLength(3);
+ controller.resizePane(0, 50);
+ expect(controller.layout[0]).toBeCloseTo(50);
+ expect(controller.layout.reduce((a, b) => a + b, 0)).toBeCloseTo(100);
+ controller.destroy();
+ });
+
+ it("throws when there are no panes", () => {
+ document.body.innerHTML = ``;
+ const root = document.getElementById("root")!;
+ expect(() => createResizable(root)).toThrow();
+ });
+
+ it("throws when handle count does not match pane count", () => {
+ document.body.innerHTML = `
+
+ `;
+ const root = document.getElementById("root")!;
+ expect(() => createResizable(root)).toThrow();
+ });
+
+ it("cleans up listeners and global styles on destroy", () => {
+ const { root, handles, controller } = setup();
+ mockGroupSize(root as HTMLElement, 1000, true);
+ handles[0].dispatchEvent(new MouseEvent("mousedown", { clientX: 500, bubbles: true }));
+ controller.destroy();
+ // After destroy, body mousemove should not move anything (no controller).
+ document.body.dispatchEvent(new MouseEvent("mousemove", { clientX: 800, bubbles: true }));
+ expect(document.head.querySelector("style")).toBeNull();
+ });
+
+ describe("data attributes", () => {
+ it("reads data-direction from the root", () => {
+ const { root, controller } = setup({ direction: "vertical" });
+ expect(root.getAttribute("data-direction")).toBe("vertical");
+ controller.destroy();
+ });
+
+ it("JS direction option overrides data-direction", () => {
+ const root = markup({ direction: "vertical" });
+ const controller = createResizable(root, { direction: "horizontal" });
+ expect((root as HTMLElement).style.flexDirection).toBe("row");
+ controller.destroy();
+ });
+
+ it("reads data-keyboard-resize-by", () => {
+ const root = markup({
+ panes: [{ "data-default-size": "50" }, { "data-default-size": "50" }],
+ });
+ root.setAttribute("data-keyboard-resize-by", "25");
+ const controller = createResizable(root);
+ const handle = root.querySelector('[data-slot="resizable-handle"]')!;
+ handle.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
+ expect(controller.layout[0]).toBeCloseTo(75);
+ controller.destroy();
+ });
+ });
+
+ describe("root binding", () => {
+ it("reuses the existing controller for duplicate direct binds", () => {
+ const { root, controller } = setup();
+ expect(createResizable(root)).toBe(controller);
+ controller.destroy();
+ });
+
+ it("reuses a controller bound by another module copy", () => {
+ const { root, controller } = setup();
+ controller.destroy();
+
+ const foreign = { destroy() {} } as ReturnType;
+ setRootBinding(root, ROOT_BINDING_KEY, foreign);
+ expect(createResizable(root)).toBe(foreign);
+ clearRootBinding(root, ROOT_BINDING_KEY, foreign);
+ });
+
+ it("create() skips roots bound by another module copy", () => {
+ const { root, controller } = setup();
+ controller.destroy();
+
+ const foreign = { destroy() {} } as ReturnType;
+ setRootBinding(root, ROOT_BINDING_KEY, foreign);
+ expect(create()).toHaveLength(0);
+ clearRootBinding(root, ROOT_BINDING_KEY, foreign);
+ });
+
+ it("allows rebinding after destroy", () => {
+ const { root, controller } = setup();
+ controller.destroy();
+ const rebound = createResizable(root);
+ expect(rebound).not.toBe(controller);
+ rebound.destroy();
+ });
+ });
+
+ describe("create()", () => {
+ it("binds all resizable groups and returns controllers", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const controllers = create();
+ expect(controllers).toHaveLength(2);
+ controllers.forEach((c) => c.destroy());
+ });
+
+ it("allows re-initialization after destroy", () => {
+ document.body.innerHTML = `
+
+ `;
+ const first = create();
+ expect(first).toHaveLength(1);
+ first[0]?.destroy();
+ const second = create();
+ expect(second).toHaveLength(1);
+ second[0]?.destroy();
+ });
+ });
+});
diff --git a/packages/resizable/src/index.ts b/packages/resizable/src/index.ts
new file mode 100644
index 0000000..ad840c6
--- /dev/null
+++ b/packages/resizable/src/index.ts
@@ -0,0 +1,894 @@
+import {
+ getRoots,
+ getDataBool,
+ reuseRootBinding,
+ hasRootBinding,
+ setRootBinding,
+ clearRootBinding,
+ ensureId,
+ on,
+ emit,
+} from "@data-slot/core";
+
+/* -------------------------------------------------------------------------------------------------
+ * Local DOM helpers
+ *
+ * `@data-slot/core` exposes `getPart` for a single slotted element. Resizable needs to query
+ * *all* matching parts (multiple panes / handles), and to read numeric data-* attributes. These
+ * helpers stay local so the package does not depend on core API surface beyond what collapsible
+ * already relies on. If core later exports `getParts` / `getDataNum`, swap these for the imports.
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Collect all `data-slot="{part}"` descendants that belong to *this* group,
+ * i.e. excluding any that live inside a nested `data-slot="resizable"`.
+ *
+ * Note: we deliberately avoid the `:scope >` combinator. Bun's test DOM
+ * (HappyDOM) does not reliably resolve `:scope` in `querySelectorAll`, so a
+ * direct-child selector silently returns nothing there. A descendant query
+ * plus an ownership check is correct in every engine and still supports
+ * authors wrapping panes in extra layout elements.
+ */
+const getParts = (root: Element, part: string): T[] =>
+ Array.from(root.querySelectorAll(`[data-slot="${part}"]`)).filter(
+ (el) => el.closest('[data-slot="resizable"]') === root,
+ );
+
+const getDataNum = (el: Element, camelKey: string): number | null => {
+ const value = (el as HTMLElement).dataset?.[camelKey];
+ if (value == null || value === "") return null;
+ const num = Number.parseFloat(value);
+ return Number.isFinite(num) ? num : null;
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * Types
+ * -----------------------------------------------------------------------------------------------*/
+
+export type ResizableDirection = "horizontal" | "vertical";
+
+export interface PaneConstraints {
+ collapsedSize?: number;
+ collapsible?: boolean;
+ defaultSize?: number;
+ maxSize?: number;
+ minSize?: number;
+}
+
+export interface ResizableOptions {
+ /** Layout axis. @default "horizontal" */
+ direction?: ResizableDirection;
+ /**
+ * Amount (in %) to resize by per keyboard arrow press.
+ * Shift increases this to a full jump. @default 10
+ */
+ keyboardResizeBy?: number;
+ /** Called whenever the layout changes, with sizes as percentages. */
+ onLayoutChange?: (layout: number[]) => void;
+}
+
+export interface ResizableController {
+ /** Current layout as an array of percentages (one entry per pane). */
+ readonly layout: number[];
+ /** Imperatively set the full layout. Values are validated/clamped. */
+ setLayout(layout: number[]): void;
+ /** Resize a pane (by index) to a target size in %. */
+ resizePane(paneIndex: number, size: number): void;
+ /** Collapse a collapsible pane by index. */
+ collapse(paneIndex: number): void;
+ /** Expand a collapsed pane by index back to its prior (or min) size. */
+ expand(paneIndex: number): void;
+ /** Whether the pane at index is currently collapsed. */
+ isCollapsed(paneIndex: number): boolean;
+ /** Whether the pane at index is currently expanded. */
+ isExpanded(paneIndex: number): boolean;
+ /** Get the current size (in %) of the pane at index. */
+ getSize(paneIndex: number): number;
+ /** Cleanup all event listeners and global styles. */
+ destroy(): void;
+}
+
+type ResizeEvent = KeyboardEvent | MouseEvent | TouchEvent;
+
+interface DragState {
+ handleIndex: number;
+ initialCursorPosition: number;
+ initialLayout: number[];
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * Constants
+ * -----------------------------------------------------------------------------------------------*/
+
+const ROOT_BINDING_KEY = "@data-slot/resizable";
+const DUPLICATE_BINDING_WARNING =
+ "[@data-slot/resizable] createResizable() called more than once for the same root. Returning the existing controller. Destroy it before rebinding with new options.";
+
+const PRECISION = 10;
+const RESIZE_KEYS = ["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "End", "Home"];
+
+/* -------------------------------------------------------------------------------------------------
+ * Numeric helpers (ported from PaneForge / react-resizable-panels)
+ * -----------------------------------------------------------------------------------------------*/
+
+const roundTo = (value: number, decimals: number): number =>
+ Number.parseFloat(value.toFixed(decimals));
+
+const compareWithTolerance = (
+ actual: number,
+ expected: number,
+ fractionDigits: number = PRECISION,
+): number => Math.sign(roundTo(actual, fractionDigits) - roundTo(expected, fractionDigits));
+
+const almostEqual = (
+ actual: number,
+ expected: number,
+ fractionDigits: number = PRECISION,
+): boolean => compareWithTolerance(actual, expected, fractionDigits) === 0;
+
+const arraysEqual = (a: number[], b: number[]): boolean => {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i += 1) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+};
+
+const assert = (condition: unknown, message = "Assertion failed"): void => {
+ if (!condition) throw new Error(`[@data-slot/resizable] ${message}`);
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * Constraint solver
+ * -----------------------------------------------------------------------------------------------*/
+
+const adjustedSizeForCollapsible = (
+ size: number,
+ collapsible: boolean | undefined,
+ collapsedSize: number,
+ minSize: number,
+): number => {
+ if (!collapsible) return minSize;
+ const halfway = (collapsedSize + minSize) / 2;
+ return compareWithTolerance(size, halfway) < 0 ? collapsedSize : minSize;
+};
+
+const resizePaneSize = (
+ constraints: PaneConstraints[],
+ index: number,
+ initialSize: number,
+): number => {
+ const c = constraints[index];
+ assert(c != null, "Pane constraints should not be null.");
+ const { collapsedSize = 0, collapsible, maxSize = 100, minSize = 0 } = c;
+
+ let next = initialSize;
+ if (compareWithTolerance(next, minSize) < 0) {
+ next = adjustedSizeForCollapsible(next, collapsible, collapsedSize, minSize);
+ }
+ next = Math.min(maxSize, next);
+ return Number.parseFloat(next.toFixed(PRECISION));
+};
+
+/**
+ * Adjusts the layout based on the delta of the resize handle.
+ * All units are percentages. Ported from react-resizable-panels via PaneForge.
+ */
+const adjustLayoutByDelta = (
+ delta: number,
+ prevLayout: number[],
+ constraints: PaneConstraints[],
+ pivotIndices: number[],
+ trigger: "imperative-api" | "keyboard" | "mouse-or-touch",
+): number[] => {
+ if (almostEqual(delta, 0)) return prevLayout;
+
+ const nextLayout = [...prevLayout];
+ const [firstPivot, secondPivot] = pivotIndices;
+ let deltaApplied = 0;
+
+ if (trigger === "keyboard") {
+ {
+ const index = delta < 0 ? secondPivot : firstPivot;
+ const c = constraints[index];
+ assert(c);
+ if (c.collapsible) {
+ const prevSize = prevLayout[index];
+ assert(prevSize != null);
+ const { collapsedSize = 0, minSize = 0 } = c;
+ if (almostEqual(prevSize, collapsedSize)) {
+ const localDelta = minSize - prevSize;
+ if (compareWithTolerance(localDelta, Math.abs(delta)) > 0) {
+ delta = delta < 0 ? 0 - localDelta : localDelta;
+ }
+ }
+ }
+ }
+ {
+ const index = delta < 0 ? firstPivot : secondPivot;
+ const c = constraints[index];
+ assert(c);
+ if (c.collapsible) {
+ const prevSize = prevLayout[index];
+ assert(prevSize != null);
+ const { collapsedSize = 0, minSize = 0 } = c;
+ if (almostEqual(prevSize, minSize)) {
+ const localDelta = prevSize - collapsedSize;
+ if (compareWithTolerance(localDelta, Math.abs(delta)) > 0) {
+ delta = delta < 0 ? 0 - localDelta : localDelta;
+ }
+ }
+ }
+ }
+ }
+
+ {
+ const increment = delta < 0 ? 1 : -1;
+ let index = delta < 0 ? secondPivot : firstPivot;
+ let maxAvailableDelta = 0;
+
+ while (true) {
+ const prevSize = prevLayout[index];
+ assert(prevSize != null);
+ const maxSafeSize = resizePaneSize(constraints, index, 100);
+ maxAvailableDelta += maxSafeSize - prevSize;
+ index += increment;
+ if (index < 0 || index >= constraints.length) break;
+ }
+
+ const minAbsDelta = Math.min(Math.abs(delta), Math.abs(maxAvailableDelta));
+ delta = delta < 0 ? 0 - minAbsDelta : minAbsDelta;
+ }
+
+ {
+ const pivotIndex = delta < 0 ? firstPivot : secondPivot;
+ let index = pivotIndex;
+ while (index >= 0 && index < constraints.length) {
+ const deltaRemaining = Math.abs(delta) - Math.abs(deltaApplied);
+ const prevSize = prevLayout[index];
+ assert(prevSize != null);
+ const unsafeSize = prevSize - deltaRemaining;
+ const safeSize = resizePaneSize(constraints, index, unsafeSize);
+
+ if (!almostEqual(prevSize, safeSize)) {
+ deltaApplied += prevSize - safeSize;
+ nextLayout[index] = safeSize;
+ if (
+ deltaApplied.toPrecision(3).localeCompare(Math.abs(delta).toPrecision(3), undefined, {
+ numeric: true,
+ }) >= 0
+ ) {
+ break;
+ }
+ }
+ if (delta < 0) index -= 1;
+ else index += 1;
+ }
+ }
+
+ if (almostEqual(deltaApplied, 0)) return prevLayout;
+
+ {
+ const pivotIndex = delta < 0 ? secondPivot : firstPivot;
+ const prevSize = prevLayout[pivotIndex];
+ assert(prevSize != null);
+ const unsafeSize = prevSize + deltaApplied;
+ const safeSize = resizePaneSize(constraints, pivotIndex, unsafeSize);
+ nextLayout[pivotIndex] = safeSize;
+
+ if (!almostEqual(safeSize, unsafeSize)) {
+ let deltaRemaining = unsafeSize - safeSize;
+ const innerPivot = delta < 0 ? secondPivot : firstPivot;
+ let index = innerPivot;
+ while (index >= 0 && index < constraints.length) {
+ const prevInner = nextLayout[index];
+ assert(prevInner != null);
+ const unsafeInner = prevInner + deltaRemaining;
+ const safeInner = resizePaneSize(constraints, index, unsafeInner);
+ if (!almostEqual(prevInner, safeInner)) {
+ deltaRemaining -= safeInner - prevInner;
+ nextLayout[index] = safeInner;
+ }
+ if (almostEqual(deltaRemaining, 0)) break;
+ if (delta > 0) index -= 1;
+ else index += 1;
+ }
+ }
+ }
+
+ const totalSize = nextLayout.reduce((sum, size) => sum + size, 0);
+ if (!almostEqual(totalSize, 100)) return prevLayout;
+
+ return nextLayout;
+};
+
+const getUnsafeDefaultLayout = (constraints: PaneConstraints[]): number[] => {
+ const layout = new Array(constraints.length);
+ let numWithSizes = 0;
+ let remaining = 100;
+
+ for (let i = 0; i < constraints.length; i += 1) {
+ const { defaultSize } = constraints[i];
+ if (defaultSize != null) {
+ numWithSizes += 1;
+ layout[i] = defaultSize;
+ remaining -= defaultSize;
+ }
+ }
+
+ for (let i = 0; i < constraints.length; i += 1) {
+ const { defaultSize } = constraints[i];
+ if (defaultSize != null) continue;
+ const numRemaining = constraints.length - numWithSizes;
+ const size = remaining / numRemaining;
+ numWithSizes += 1;
+ layout[i] = size;
+ remaining -= size;
+ }
+
+ return layout;
+};
+
+const validateLayout = (prevLayout: number[], constraints: PaneConstraints[]): number[] => {
+ const nextLayout = [...prevLayout];
+ const total = nextLayout.reduce((acc, cur) => acc + cur, 0);
+
+ if (nextLayout.length !== constraints.length) {
+ throw new Error(
+ `[@data-slot/resizable] Invalid ${constraints.length} pane layout: ${nextLayout
+ .map((s) => `${s}%`)
+ .join(", ")}`,
+ );
+ }
+
+ if (!almostEqual(total, 100)) {
+ for (let i = 0; i < constraints.length; i += 1) {
+ nextLayout[i] = (100 / total) * nextLayout[i];
+ }
+ }
+
+ let remaining = 0;
+ for (let i = 0; i < constraints.length; i += 1) {
+ const unsafe = nextLayout[i];
+ const safe = resizePaneSize(constraints, i, unsafe);
+ if (unsafe !== safe) {
+ remaining += unsafe - safe;
+ nextLayout[i] = safe;
+ }
+ }
+
+ if (!almostEqual(remaining, 0)) {
+ for (let i = 0; i < constraints.length; i += 1) {
+ const prev = nextLayout[i];
+ const unsafe = prev + remaining;
+ const safe = resizePaneSize(constraints, i, unsafe);
+ if (prev !== safe) {
+ remaining -= safe - prev;
+ nextLayout[i] = safe;
+ if (almostEqual(remaining, 0)) break;
+ }
+ }
+ }
+
+ return nextLayout;
+};
+
+const calculateAriaValues = (
+ layout: number[],
+ constraints: PaneConstraints[],
+ pivotIndices: number[],
+): { valueMax: number; valueMin: number; valueNow: number } => {
+ let currentMin = 0;
+ let currentMax = 100;
+ let totalMin = 0;
+ let totalMax = 0;
+ const firstIndex = pivotIndices[0];
+
+ for (let i = 0; i < constraints.length; i += 1) {
+ const { maxSize = 100, minSize = 0 } = constraints[i];
+ if (i === firstIndex) {
+ currentMin = minSize;
+ currentMax = maxSize;
+ } else {
+ totalMin += minSize;
+ totalMax += maxSize;
+ }
+ }
+
+ return {
+ valueMax: Math.min(currentMax, 100 - totalMin),
+ valueMin: Math.max(currentMin, 100 - totalMax),
+ valueNow: layout[firstIndex],
+ };
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * Global cursor style (shared across all groups, like PaneForge)
+ * -----------------------------------------------------------------------------------------------*/
+
+type CursorState =
+ | "horizontal"
+ | "horizontal-max"
+ | "horizontal-min"
+ | "vertical"
+ | "vertical-max"
+ | "vertical-min";
+
+let cursorStyleEl: HTMLStyleElement | null = null;
+let cursorState: CursorState | null = null;
+
+const cursorFor = (state: CursorState): string => {
+ switch (state) {
+ case "horizontal":
+ return "ew-resize";
+ case "horizontal-max":
+ return "w-resize";
+ case "horizontal-min":
+ return "e-resize";
+ case "vertical":
+ return "ns-resize";
+ case "vertical-max":
+ return "n-resize";
+ case "vertical-min":
+ return "s-resize";
+ }
+};
+
+const setGlobalCursor = (state: CursorState, doc: Document): void => {
+ if (cursorState === state) return;
+ cursorState = state;
+ if (cursorStyleEl === null) {
+ cursorStyleEl = doc.createElement("style");
+ doc.head.appendChild(cursorStyleEl);
+ }
+ cursorStyleEl.innerHTML = `*{cursor: ${cursorFor(state)}!important;}`;
+};
+
+const resetGlobalCursor = (): void => {
+ if (cursorStyleEl === null) return;
+ cursorStyleEl.parentNode?.removeChild(cursorStyleEl);
+ cursorStyleEl = null;
+ cursorState = null;
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * Geometry helpers
+ * -----------------------------------------------------------------------------------------------*/
+
+const cursorPosition = (dir: ResizableDirection, e: ResizeEvent): number => {
+ const horizontal = dir === "horizontal";
+ if (e.type.startsWith("mouse")) {
+ const me = e as MouseEvent;
+ return horizontal ? me.clientX : me.clientY;
+ }
+ if (e.type.startsWith("touch")) {
+ const te = e as TouchEvent;
+ const t = te.touches[0];
+ assert(t, "Expected a touch point");
+ return horizontal ? t.screenX : t.screenY;
+ }
+ throw new Error(`[@data-slot/resizable] Unsupported event type "${e.type}"`);
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * createResizable
+ * -----------------------------------------------------------------------------------------------*/
+
+const readPaneConstraints = (pane: HTMLElement): PaneConstraints => {
+ const constraints: PaneConstraints = {};
+ const defaultSize = getDataNum(pane, "defaultSize");
+ const minSize = getDataNum(pane, "minSize");
+ const maxSize = getDataNum(pane, "maxSize");
+ const collapsedSize = getDataNum(pane, "collapsedSize");
+ const collapsible = getDataBool(pane, "collapsible");
+ if (defaultSize != null) constraints.defaultSize = defaultSize;
+ if (minSize != null) constraints.minSize = minSize;
+ if (maxSize != null) constraints.maxSize = maxSize;
+ if (collapsedSize != null) constraints.collapsedSize = collapsedSize;
+ if (collapsible != null) constraints.collapsible = collapsible;
+ return constraints;
+};
+
+/**
+ * Create a resizable panel group controller for a root element.
+ *
+ * Expected markup:
+ * ```html
+ *
+ * ```
+ */
+export function createResizable(
+ root: Element,
+ options: ResizableOptions = {},
+): ResizableController {
+ const existing = reuseRootBinding(
+ root,
+ ROOT_BINDING_KEY,
+ DUPLICATE_BINDING_WARNING,
+ );
+ if (existing) return existing;
+
+ const direction =
+ options.direction ??
+ (root.getAttribute("data-direction") as ResizableDirection | null) ??
+ "horizontal";
+ const keyboardResizeBy = options.keyboardResizeBy ?? getDataNum(root, "keyboardResizeBy") ?? 10;
+ const onLayoutChange = options.onLayoutChange;
+
+ const panes = getParts(root, "resizable-panel");
+ const handles = getParts(root, "resizable-handle");
+
+ if (!panes || panes.length === 0) {
+ throw new Error("Resizable requires at least one resizable-panel slot");
+ }
+ if (handles.length !== panes.length - 1) {
+ throw new Error(
+ `Resizable expects exactly ${panes.length - 1} handle(s) for ${panes.length} panes, got ${handles.length}`,
+ );
+ }
+
+ const win = root.ownerDocument?.defaultView ?? window;
+ const doc = root.ownerDocument ?? document;
+ const isHorizontal = direction === "horizontal";
+
+ const constraints: PaneConstraints[] = panes.map(readPaneConstraints);
+ let layout = validateLayout(getUnsafeDefaultLayout(constraints), constraints);
+ const sizeBeforeCollapse = new Map();
+ let dragState: DragState | null = null;
+ let prevDelta = 0;
+ const cleanups: Array<() => void> = [];
+
+ // ---- DOM wiring -------------------------------------------------------------
+
+ ensureId(root, "resizable");
+ (root as HTMLElement).style.display = "flex";
+ (root as HTMLElement).style.flexDirection = isHorizontal ? "row" : "column";
+ (root as HTMLElement).style.overflow = "hidden";
+ root.setAttribute("data-slot", "resizable");
+ root.setAttribute("data-direction", direction);
+
+ panes.forEach((pane) => {
+ ensureId(pane, "resizable-panel");
+ pane.setAttribute("data-direction", direction);
+ pane.style.flexBasis = "0";
+ pane.style.flexShrink = "1";
+ pane.style.overflow = "hidden";
+ // flex-grow is set by applyLayout() below, which is the single source of
+ // truth for sizing. (CSS normalizes the number on assignment, so the
+ // serialized value is engine-defined, e.g. "25" — never rely on "25.0".)
+ });
+
+ handles.forEach((handle, i) => {
+ ensureId(handle, "resizable-handle");
+ handle.setAttribute("role", "separator");
+ handle.setAttribute("data-direction", direction);
+ handle.setAttribute("aria-orientation", isHorizontal ? "vertical" : "horizontal");
+ if (!handle.hasAttribute("tabindex")) handle.setAttribute("tabindex", "0");
+ handle.style.touchAction = "none";
+ handle.style.userSelect = "none";
+ (handle.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = "none";
+ handle.setAttribute("aria-controls", panes[i].id);
+ });
+
+ const setDataState = (): void => {
+ panes.forEach((pane, i) => {
+ const collapsed = isPaneCollapsed(i);
+ pane.setAttribute("data-state", collapsed ? "collapsed" : "expanded");
+ if (collapsed) {
+ pane.setAttribute("data-collapsed", "");
+ pane.removeAttribute("data-expanded");
+ } else {
+ pane.setAttribute("data-expanded", "");
+ pane.removeAttribute("data-collapsed");
+ }
+ });
+ };
+
+ const applyLayout = (): void => {
+ panes.forEach((pane, i) => {
+ pane.style.flexGrow = panes.length === 1 ? "1" : layout[i].toPrecision(3);
+ pane.style.pointerEvents = dragState !== null ? "none" : "";
+ });
+ handles.forEach((handle, i) => {
+ const { valueMax, valueMin, valueNow } = calculateAriaValues(layout, constraints, [i, i + 1]);
+ handle.setAttribute("aria-valuemax", `${Math.round(valueMax)}`);
+ handle.setAttribute("aria-valuemin", `${Math.round(valueMin)}`);
+ handle.setAttribute("aria-valuenow", valueNow != null ? `${Math.round(valueNow)}` : "");
+ });
+ setDataState();
+ };
+
+ const commitLayout = (next: number[]): void => {
+ if (arraysEqual(layout, next)) return;
+ layout = next;
+ applyLayout();
+ emit(root, "resizable:change", { layout: [...layout] });
+ onLayoutChange?.([...layout]);
+ };
+
+ function isPaneCollapsed(index: number): boolean {
+ const c = constraints[index];
+ const size = layout[index];
+ if (typeof size !== "number") return false;
+ const { collapsedSize = 0, collapsible } = c;
+ return collapsible === true && almostEqual(size, collapsedSize);
+ }
+
+ function isPaneExpanded(index: number): boolean {
+ const { collapsedSize = 0, collapsible } = constraints[index];
+ return !collapsible || layout[index] > collapsedSize;
+ }
+
+ const pivotForHandle = (handleIndex: number): [number, number] => [handleIndex, handleIndex + 1];
+
+ // ---- Resize handlers --------------------------------------------------------
+
+ const deltaForKeyboard = (e: KeyboardEvent): number => {
+ let step = 10;
+ if (e.shiftKey) step = 100;
+ else if (keyboardResizeBy != null) step = keyboardResizeBy;
+
+ switch (e.key) {
+ case "ArrowDown":
+ return isHorizontal ? 0 : step;
+ case "ArrowLeft":
+ return isHorizontal ? -step : 0;
+ case "ArrowRight":
+ return isHorizontal ? step : 0;
+ case "ArrowUp":
+ return isHorizontal ? 0 : -step;
+ case "End":
+ return 100;
+ case "Home":
+ return -100;
+ default:
+ return 0;
+ }
+ };
+
+ const runResize = (handleIndex: number, event: ResizeEvent): void => {
+ const prevLayout = layout;
+ const pivotIndices = pivotForHandle(handleIndex);
+ const keyboard = event.type === "keydown";
+
+ let delta: number;
+ if (keyboard) {
+ delta = deltaForKeyboard(event as KeyboardEvent);
+ } else if (dragState != null) {
+ const groupRect = (root as HTMLElement).getBoundingClientRect();
+ const groupSize = isHorizontal ? groupRect.width : groupRect.height;
+ const offsetPixels = cursorPosition(direction, event) - dragState.initialCursorPosition;
+ delta = groupSize === 0 ? 0 : (offsetPixels / groupSize) * 100;
+ } else {
+ return;
+ }
+ if (delta === 0) return;
+
+ if (doc.dir === "rtl" && isHorizontal) delta = -delta;
+
+ const base = dragState?.initialLayout ?? prevLayout;
+ const next = adjustLayoutByDelta(
+ delta,
+ base,
+ constraints,
+ pivotIndices,
+ keyboard ? "keyboard" : "mouse-or-touch",
+ );
+ const changed = !arraysEqual(prevLayout, next);
+
+ if (event.type.startsWith("mouse") || event.type.startsWith("touch")) {
+ if (prevDelta !== delta) {
+ prevDelta = delta;
+ if (!changed) {
+ if (isHorizontal) {
+ setGlobalCursor(delta < 0 ? "horizontal-min" : "horizontal-max", doc);
+ } else {
+ setGlobalCursor(delta < 0 ? "vertical-min" : "vertical-max", doc);
+ }
+ } else {
+ setGlobalCursor(isHorizontal ? "horizontal" : "vertical", doc);
+ }
+ }
+ }
+
+ if (changed) commitLayout(next);
+ };
+
+ // ---- Drag lifecycle ---------------------------------------------------------
+
+ const onMove = (e: Event): void => {
+ if (dragState == null) return;
+ e.preventDefault();
+ runResize(dragState.handleIndex, e as ResizeEvent);
+ };
+
+ const stopDragging = (): void => {
+ resetGlobalCursor();
+ if (dragState != null) {
+ const handle = handles[dragState.handleIndex];
+ handle.removeAttribute("data-active");
+ handle.blur();
+ }
+ dragState = null;
+ applyLayout();
+ emit(root, "resizable:dragging", { dragging: false });
+ };
+
+ const startDragging = (handleIndex: number, e: ResizeEvent): void => {
+ e.preventDefault();
+ const handle = handles[handleIndex];
+ if (handle.getAttribute("data-disabled") === "true") return;
+ dragState = {
+ handleIndex,
+ initialCursorPosition: cursorPosition(direction, e),
+ initialLayout: [...layout],
+ };
+ handle.setAttribute("data-active", "pointer");
+ applyLayout();
+ emit(root, "resizable:dragging", { dragging: true });
+ };
+
+ // ---- Listeners --------------------------------------------------------------
+
+ handles.forEach((handle, handleIndex) => {
+ cleanups.push(on(handle, "mousedown", (e) => startDragging(handleIndex, e as MouseEvent)));
+ cleanups.push(
+ on(handle, "touchstart", (e) => startDragging(handleIndex, e as TouchEvent), {
+ passive: false,
+ }),
+ );
+ cleanups.push(on(handle, "mouseup", stopDragging));
+ cleanups.push(on(handle, "touchend", stopDragging));
+ cleanups.push(on(handle, "touchcancel", stopDragging));
+ cleanups.push(on(handle, "focus", () => handle.setAttribute("data-active", "keyboard")));
+ cleanups.push(
+ on(handle, "blur", () => {
+ if (dragState?.handleIndex !== handleIndex) {
+ handle.removeAttribute("data-active");
+ }
+ }),
+ );
+ cleanups.push(
+ on(handle, "keydown", (e) => {
+ const ke = e as KeyboardEvent;
+ if (handle.getAttribute("data-disabled") === "true" || ke.defaultPrevented) {
+ return;
+ }
+ if (RESIZE_KEYS.includes(ke.key)) {
+ ke.preventDefault();
+ runResize(handleIndex, ke);
+ return;
+ }
+ if (ke.key === "Enter") {
+ // Toggle collapse on the pane before the handle.
+ ke.preventDefault();
+ const c = constraints[handleIndex];
+ const size = layout[handleIndex];
+ const { collapsedSize = 0, collapsible, minSize = 0 } = c;
+ if (size == null || !collapsible) return;
+ const delta = almostEqual(size, collapsedSize) ? minSize - size : collapsedSize - size;
+ commitLayout(
+ adjustLayoutByDelta(
+ delta,
+ layout,
+ constraints,
+ pivotForHandle(handleIndex),
+ "keyboard",
+ ),
+ );
+ return;
+ }
+ if (ke.key === "F6") {
+ ke.preventDefault();
+ const order = ke.shiftKey
+ ? (handleIndex - 1 + handles.length) % handles.length
+ : (handleIndex + 1) % handles.length;
+ handles[order].focus();
+ }
+ }),
+ );
+ });
+
+ const body = doc.body;
+ cleanups.push(on(body, "mousemove", onMove));
+ cleanups.push(on(body, "touchmove", onMove, { passive: false }));
+ cleanups.push(on(body, "mouseleave", onMove));
+ cleanups.push(on(body, "contextmenu", stopDragging));
+ cleanups.push(on(win, "mouseup", stopDragging));
+ cleanups.push(on(win, "touchend", stopDragging));
+
+ // ---- Imperative API ---------------------------------------------------------
+
+ const resizePane = (paneIndex: number, unsafeSize: number): void => {
+ const isLast = paneIndex === panes.length - 1;
+ const pivotIndices: [number, number] = isLast
+ ? [paneIndex - 1, paneIndex]
+ : [paneIndex, paneIndex + 1];
+ const current = layout[paneIndex];
+ const delta = isLast ? current - unsafeSize : unsafeSize - current;
+ commitLayout(adjustLayoutByDelta(delta, layout, constraints, pivotIndices, "imperative-api"));
+ };
+
+ const collapse = (paneIndex: number): void => {
+ const c = constraints[paneIndex];
+ if (!c.collapsible) return;
+ const { collapsedSize = 0 } = c;
+ const current = layout[paneIndex];
+ if (almostEqual(current, collapsedSize)) return;
+ sizeBeforeCollapse.set(paneIndex, current);
+ const isLast = paneIndex === panes.length - 1;
+ const pivotIndices: [number, number] = isLast
+ ? [paneIndex - 1, paneIndex]
+ : [paneIndex, paneIndex + 1];
+ const delta = isLast ? current - collapsedSize : collapsedSize - current;
+ commitLayout(adjustLayoutByDelta(delta, layout, constraints, pivotIndices, "imperative-api"));
+ };
+
+ const expand = (paneIndex: number): void => {
+ const c = constraints[paneIndex];
+ if (!c.collapsible) return;
+ const { collapsedSize = 0, minSize = 0 } = c;
+ const current = layout[paneIndex];
+ if (!almostEqual(current, collapsedSize)) return;
+ const prev = sizeBeforeCollapse.get(paneIndex);
+ const baseSize = prev != null && prev >= minSize ? prev : minSize;
+ const isLast = paneIndex === panes.length - 1;
+ const pivotIndices: [number, number] = isLast
+ ? [paneIndex - 1, paneIndex]
+ : [paneIndex, paneIndex + 1];
+ const delta = isLast ? current - baseSize : baseSize - current;
+ commitLayout(adjustLayoutByDelta(delta, layout, constraints, pivotIndices, "imperative-api"));
+ };
+
+ applyLayout();
+ emit(root, "resizable:change", { layout: [...layout] });
+
+ // Inbound control event.
+ cleanups.push(
+ on(root, "resizable:set", (e) => {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.layout && Array.isArray(detail.layout)) {
+ commitLayout(validateLayout(detail.layout as number[], constraints));
+ }
+ }),
+ );
+
+ const controller: ResizableController = {
+ get layout() {
+ return [...layout];
+ },
+ setLayout: (next) => commitLayout(validateLayout(next, constraints)),
+ resizePane,
+ collapse,
+ expand,
+ isCollapsed: isPaneCollapsed,
+ isExpanded: isPaneExpanded,
+ getSize: (i) => layout[i],
+ destroy: () => {
+ stopDragging();
+ cleanups.forEach((fn) => fn());
+ cleanups.length = 0;
+ clearRootBinding(root, ROOT_BINDING_KEY, controller);
+ },
+ };
+
+ setRootBinding(root, ROOT_BINDING_KEY, controller);
+ return controller;
+}
+
+/**
+ * Find and bind all resizable groups in a scope.
+ * Returns an array of controllers for programmatic access.
+ */
+export function create(scope: ParentNode = document): ResizableController[] {
+ const controllers: ResizableController[] = [];
+ for (const root of getRoots(scope, "resizable")) {
+ if (hasRootBinding(root, ROOT_BINDING_KEY)) continue;
+ controllers.push(createResizable(root));
+ }
+ return controllers;
+}
diff --git a/packages/resizable/tsdown.config.ts b/packages/resizable/tsdown.config.ts
new file mode 100644
index 0000000..8268a56
--- /dev/null
+++ b/packages/resizable/tsdown.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["esm", "cjs"],
+ dts: true,
+ clean: true,
+ treeshake: true,
+ external: ["@data-slot/core"],
+ hash: false,
+ minify: true,
+});
diff --git a/website/package.json b/website/package.json
index 365d859..46d1495 100644
--- a/website/package.json
+++ b/website/package.json
@@ -1,7 +1,7 @@
{
"name": "data-slot-docs",
- "type": "module",
"version": "0.0.1",
+ "type": "module",
"scripts": {
"check-sizes": "bun run scripts/check-sizes.ts",
"dev": "bun run check-sizes && astro dev",
@@ -16,6 +16,7 @@
"@data-slot/dialog": "^0.1.2",
"@data-slot/dropdown-menu": "^0.2.2",
"@data-slot/popover": "^0.1.2",
+ "@data-slot/resizable": "^0.2.166",
"@data-slot/tabs": "^0.1.2",
"@data-slot/tooltip": "^0.1.2",
"@fontsource/geist-mono": "^5.2.7",
diff --git a/website/src/components/examples/Resizable.astro b/website/src/components/examples/Resizable.astro
new file mode 100644
index 0000000..14ca37f
--- /dev/null
+++ b/website/src/components/examples/Resizable.astro
@@ -0,0 +1,153 @@
+---
+import { Code } from "astro:components";
+import ExampleBlock from "../ExampleBlock.astro";
+
+const cssCode = `
+
+
Panel A
+
Drag the handle to resize.
+
+
+
+
Panel B
+
Keyboard: arrow keys adjust size.
+
+
+
+`;
+
+const tailwindCode = `
+
+
Panel A
+
Drag the handle to resize.
+
+
+
+
Panel B
+
Keyboard: arrow keys adjust size.
+
+
`;
+---
+
+
+
+
+
Panel A
+
Drag the handle to resize.
+
+
+
+
Panel B
+
Keyboard: arrow keys adjust size.
+
+
+
+
+
+
Panel A
+
Drag the handle to resize.
+
+
+
+
Panel B
+
Keyboard: arrow keys adjust size.
+
+
+
+
+
+
+
+
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index 3264e04..7980290 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -26,6 +26,7 @@ import Switch from "../components/examples/Switch.astro";
import RadioGroup from "../components/examples/RadioGroup.astro";
import Toggle from "../components/examples/Toggle.astro";
import ToggleGroup from "../components/examples/ToggleGroup.astro";
+import Resizable from "../components/examples/Resizable.astro";
const quickStartHtml = `
@@ -191,6 +192,10 @@ bun add @data-slot/dialog`}
Toggle Group
+
+
Resizable
+
+
~~~
@@ -249,6 +254,7 @@ bun add @data-slot/dialog`}
import { createRadioGroup } from "../../../packages/radio-group/dist/index.js";
import { createToggle } from "../../../packages/toggle/dist/index.js";
import { createToggleGroup } from "../../../packages/toggle-group/dist/index.js";
+ import { createResizable } from "../../../packages/resizable/dist/index.js";
// Initialize all components
document.querySelectorAll('[data-slot="tabs"]').forEach((el) => createTabs(el as HTMLElement));
@@ -319,4 +325,9 @@ bun add @data-slot/dialog`}
document.querySelectorAll('[data-slot="toggle-group"]').forEach((el) =>
createToggleGroup(el as HTMLElement)
);
+
+ // Resizable
+ document.querySelectorAll('[data-slot="resizable"]').forEach((el) =>
+ createResizable(el as HTMLElement)
+ );