From 3d0f4c7a8dd1772c8366b0f628042166355bd56e Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sat, 16 May 2026 13:51:34 +0200 Subject: [PATCH] feat(lib): add @data-slot/resizable --- bun.lock | 51 +- packages/resizable/README.md | 206 ++++ packages/resizable/package.json | 42 + packages/resizable/src/index.test.ts | 545 +++++++++++ packages/resizable/src/index.ts | 894 ++++++++++++++++++ packages/resizable/tsdown.config.ts | 12 + website/package.json | 3 +- .../src/components/examples/Resizable.astro | 153 +++ website/src/pages/index.astro | 11 + 9 files changed, 1896 insertions(+), 21 deletions(-) create mode 100644 packages/resizable/README.md create mode 100644 packages/resizable/package.json create mode 100644 packages/resizable/src/index.test.ts create mode 100644 packages/resizable/src/index.ts create mode 100644 packages/resizable/tsdown.config.ts create mode 100644 website/src/components/examples/Resizable.astro diff --git a/bun.lock b/bun.lock index 74cff236..dd31e0ff 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 00000000..b53e3238 --- /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 +
+
Left
+
+
Right
+
+ + +``` + +## 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
+
+
B
+
+``` + +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 00000000..3ac07fdf --- /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 00000000..352168bc --- /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 = ` +
+
A
+
+
B
+
+ `; + 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 = ` +
+
A
+
B
+
+ `; + 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 = ` +
+
A
+
+
B
+
+
+
C
+
+
D
+
+ `; + const controllers = create(); + expect(controllers).toHaveLength(2); + controllers.forEach((c) => c.destroy()); + }); + + it("allows re-initialization after destroy", () => { + document.body.innerHTML = ` +
+
A
+
+
B
+
+ `; + 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 00000000..ad840c6b --- /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 + *
+ *
A
+ *
+ *
B
+ *
+ * ``` + */ +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 00000000..8268a562 --- /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 365d8591..46d14954 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 00000000..14ca37f2 --- /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 3264e046..79802908 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) + );