Resizable
A group of panels with a handle that changes their sizes.
<resizable.ResizableHandle id="resize-between" />Installation
Run the command in the app module.
gx add resizableThe command writes these files.
| File | Path in the app |
|---|---|
ResizablePanelGroup.gx |
ui/resizable/ResizablePanelGroup.gx |
ResizablePanel.gx |
ui/resizable/ResizablePanel.gx |
ResizableHandle.gx |
ui/resizable/ResizableHandle.gx |
ResizablePanelGroup.fixtures.go |
ui/resizable/ResizablePanelGroup.fixtures.go |
ResizablePanel.fixtures.go |
ui/resizable/ResizablePanel.fixtures.go |
ResizableHandle.fixtures.go |
ui/resizable/ResizableHandle.fixtures.go |
ResizableDrag.ts |
ui/resizable/ResizableDrag.ts |
styles.go |
ui/resizable/styles.go |
Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.
package resizable
props {
// Orientation sets the direction of the panels: Horizontal or Vertical.
Orientation Orientation = Horizontal
// Class adds classes to the root element.
Class string = ""
// Children are the panels, with one ResizableHandle between two panels.
Children gx.Node
// Attrs adds HTML attributes to the root element.
Attrs gx.Attrs = nil
}
<div data-slot="resizable-panel-group" data-orientation={p.orientation()} class={gx.Cx(groupClass[p.orientation()], p.Class)} {...p.Attrs}>{p.Children}</div>package resizable
props {
// Size is the first size of the panel, in percent of the group. A zero value shares the free space.
Size int = 0
// MinSize is the smallest size of the panel, in percent of the group.
MinSize int = 0
// MaxSize is the largest size of the panel, in percent of the group. A zero value is 100.
MaxSize int = 0
// Class adds classes to the content element inside the panel.
Class string = ""
// Children is the content of the panel.
Children gx.Node
// Attrs adds HTML attributes to the root element.
Attrs gx.Attrs = nil
}
<div data-slot="resizable-panel" data-min-size={p.MinSize} data-max-size={p.maxSize()} style={p.style()} class={panelClass} {...p.Attrs}>
<div class={gx.Cx(contentClass, p.Class)}>{p.Children}</div>
</div>package resizable
props {
// Id is the id of the handle.
Id string
// Label is the accessible name of the handle.
Label string = "Resize panels"
// Orientation is the direction of the panel group: Horizontal or Vertical.
Orientation Orientation = Horizontal
// Step is the size of one arrow key step, in percent of the group. A zero value is 5.
Step int = 5
// Class adds classes to the root element.
Class string = ""
// Attrs adds HTML attributes to the root element.
Attrs gx.Attrs = nil
}
<div id={p.Id} role="separator" tabindex="0" aria-label={p.label()} aria-orientation={p.ariaOrientation()} aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" data-slot="resizable-handle" class={gx.Cx(handleClass[p.orientation()], p.Class)} {...p.Attrs}>
<ResizableDrag handle={p.Id} step={p.step()} gripClass={gripClass[p.orientation()]} load="eager" />
</div>package resizable
import "github.com/alternayte/gx"
// paneClass centres the text of a demo panel.
const paneClass = "flex items-center justify-center p-4 text-sm font-medium"
var ResizablePanelGroupFixtures = gx.Fixtures[ResizablePanelGroupProps]{
"Horizontal": {Class: "h-40 max-w-md rounded-md border border-border", Children: gx.Frag(
ResizablePanel(ResizablePanelProps{Size: 40, MinSize: 20, Class: paneClass, Children: gx.Text("One")}),
ResizableHandle(ResizableHandleProps{Id: "resize-h"}),
ResizablePanel(ResizablePanelProps{Size: 60, MinSize: 30, Class: paneClass, Children: gx.Text("Two")}),
)},
"Vertical": {Orientation: Vertical, Class: "h-56 max-w-md rounded-md border border-border", Children: gx.Frag(
ResizablePanel(ResizablePanelProps{Size: 50, Class: paneClass, Children: gx.Text("Top")}),
ResizableHandle(ResizableHandleProps{Id: "resize-v", Orientation: Vertical}),
ResizablePanel(ResizablePanelProps{Size: 50, Class: paneClass, Children: gx.Text("Bottom")}),
)},
}
// ResizablePanelGroupWrap gives a fixture the width of a page column.
func ResizablePanelGroupWrap(n gx.Node) gx.Node {
return gx.El("div", gx.Attrs{{Key: "class", Value: "w-full max-w-md"}}, n)
}package resizable
import "github.com/alternayte/gx"
var ResizablePanelFixtures = gx.Fixtures[ResizablePanelProps]{
"Default": {Children: gx.Text("A panel")},
}package resizable
import "github.com/alternayte/gx"
var ResizableHandleFixtures = gx.Fixtures[ResizableHandleProps]{
"Between": {Id: "resize-between"},
}
// ResizableHandleWrap puts the handle between two panels: a handle works
// only there.
func ResizableHandleWrap(n gx.Node) gx.Node {
return ResizablePanelGroup(ResizablePanelGroupProps{Class: "h-24 max-w-md rounded-md border border-border", Children: gx.Frag(
ResizablePanel(ResizablePanelProps{Size: 50, Children: gx.Text("One")}),
n,
ResizablePanel(ResizablePanelProps{Size: 50, Children: gx.Text("Two")}),
)})
}import type { Mount } from "./ResizableDrag.props";
// The island makes one handle between two panels movable: with the pointer
// and with the arrow keys. The panels are server HTML with their first
// sizes, so with no script the page shows a fixed split.
const mount: Mount = (root, props, ctx) => {
const handle = document.getElementById(props.handle);
if (!handle) throw new Error(`no handle with the id ${props.handle}`);
const before = handle.previousElementSibling;
const after = handle.nextElementSibling;
const group = handle.parentElement;
if (!(before instanceof HTMLElement) || !(after instanceof HTMLElement) || !group) {
throw new Error("a handle needs a panel before it and a panel after it");
}
const vertical = group.getAttribute("data-orientation") === "vertical";
root.className = props.gripClass;
root.setAttribute("aria-hidden", "true");
const size = (el: HTMLElement): number => (vertical ? el.getBoundingClientRect().height : el.getBoundingClientRect().width);
const limit = (el: HTMLElement, name: string, fallback: number): number => {
const v = Number(el.getAttribute(name));
return Number.isFinite(v) && el.hasAttribute(name) ? v : fallback;
};
// A size is a part of the room that the panels of the group share, in
// percent. The handles take none of that room.
const panels = (): HTMLElement[] =>
[...group.children].filter((el): el is HTMLElement => el instanceof HTMLElement && el.getAttribute("data-slot") === "resizable-panel");
const room = (): number => panels().reduce((sum, el) => sum + size(el), 0);
const total = (): number => ((size(before) + size(after)) / room()) * 100;
const share = (): number => (size(before) / room()) * 100;
// Each panel gets its measured size as its weight, so a panel with no
// size from the server keeps its place when a different one changes.
const weigh = (): void => {
const all = panels();
const sizes = all.map(size);
const sum = sizes.reduce((a, b) => a + b, 0) || 1;
all.forEach((el, i) => (el.style.flex = `${(sizes[i] / sum) * 100} 1 0%`));
};
// set gives the first panel a size in percent of the group, inside the
// limits of both panels. The second panel takes the rest of the two.
const set = (percent: number): void => {
const both = total();
const min = Math.max(limit(before, "data-min-size", 0), both - limit(after, "data-max-size", 100));
const max = Math.min(limit(before, "data-max-size", 100), both - limit(after, "data-min-size", 0));
const value = Math.max(min, Math.min(max, percent));
before.style.flex = `${value} 1 0%`;
after.style.flex = `${both - value} 1 0%`;
handle.setAttribute("aria-valuenow", String(Math.round(value)));
handle.setAttribute("aria-valuemin", String(Math.round(min)));
handle.setAttribute("aria-valuemax", String(Math.round(max)));
handle.dispatchEvent(new CustomEvent("resize-panels", { bubbles: true, detail: { before: value, after: both - value } }));
};
const listen = { signal: ctx.abort };
let dragging = false;
handle.addEventListener(
"pointerdown",
(event) => {
dragging = true;
handle.setPointerCapture(event.pointerId);
handle.setAttribute("data-dragging", "true");
event.preventDefault();
},
listen,
);
handle.addEventListener(
"pointermove",
(event) => {
if (!dragging) return;
const box = group.getBoundingClientRect();
const start = vertical ? before.getBoundingClientRect().top : before.getBoundingClientRect().left;
const at = vertical ? event.clientY : event.clientX;
set(((at - start) / (vertical ? box.height : box.width)) * 100);
},
listen,
);
const stop = (): void => {
dragging = false;
handle.removeAttribute("data-dragging");
};
handle.addEventListener("pointerup", stop, listen);
handle.addEventListener("pointercancel", stop, listen);
handle.addEventListener(
"keydown",
(event) => {
const back = vertical ? "ArrowUp" : "ArrowLeft";
const forward = vertical ? "ArrowDown" : "ArrowRight";
switch (event.key) {
case back:
set(share() - props.step);
break;
case forward:
set(share() + props.step);
break;
case "Home":
set(0);
break;
case "End":
set(100);
break;
default:
return;
}
event.preventDefault();
},
listen,
);
// The first sizes come from the server; the handle reports them.
weigh();
set(share());
return () => {
root.className = "";
};
};
export default mount;// Package resizable is a group of panels with a handle between two panels.
// The ResizableDrag island moves a handle with the pointer and the keyboard.
package resizable
import (
"strconv"
"github.com/alternayte/gx"
)
// Orientation is the direction of a panel group.
type Orientation string
// The orientations of resizable.ResizablePanelGroup.
const (
Horizontal Orientation = "horizontal"
Vertical Orientation = "vertical"
)
// ResizableDragProps are the props of the island of one handle. The island
// changes the sizes of the panel before and the panel after the element
// with the id Handle.
type ResizableDragProps struct {
// Handle is the id of the handle element.
Handle string `json:"handle"`
// Step is the size of one arrow key step, in percent of the group.
Step int `json:"step"`
// GripClass is the classes of the grip in the middle of the handle. It
// is a Go constant, so the stylesheet build sees it.
GripClass string `json:"gripClass"`
}
var groupClass = gx.Enum[Orientation]{
Horizontal: "flex h-full w-full flex-row",
Vertical: "flex h-full w-full flex-col",
}
// panelClass is the classes of one panel. A panel with no size shares the
// free space.
const panelClass = "min-h-0 min-w-0 flex-1 overflow-auto"
// contentClass is the classes of the content element of a panel. The
// classes of the user go here: padding on the panel itself would change its
// part of the group.
const contentClass = "h-full w-full"
// handleClass is the classes of a handle: a line with a wider area for the
// pointer.
var handleClass = gx.Enum[Orientation]{
Horizontal: "relative flex w-px shrink-0 cursor-col-resize touch-none items-center justify-center bg-border outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging=true]:bg-ring",
Vertical: "relative flex h-px w-full shrink-0 cursor-row-resize touch-none items-center justify-center bg-border outline-none after:absolute after:inset-x-0 after:top-1/2 after:h-2 after:-translate-y-1/2 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging=true]:bg-ring",
}
// gripClass is the classes of the grip that the island shows on a handle.
var gripClass = gx.Enum[Orientation]{
Horizontal: "z-10 block h-4 w-1.5 rounded-xs border border-border bg-border",
Vertical: "z-10 block h-1.5 w-4 rounded-xs border border-border bg-border",
}
// orientation returns the direction; a zero value is Horizontal.
func (p ResizablePanelGroupProps) orientation() Orientation {
if p.Orientation == Vertical {
return Vertical
}
return Horizontal
}
// orientation returns the direction of the group; a zero value is
// Horizontal.
func (p ResizableHandleProps) orientation() Orientation {
if p.Orientation == Vertical {
return Vertical
}
return Horizontal
}
// ariaOrientation returns the direction of the separator line, which is
// across the direction of the group.
func (p ResizableHandleProps) ariaOrientation() string {
if p.orientation() == Vertical {
return "horizontal"
}
return "vertical"
}
// label returns the accessible name; a zero value is "Resize panels".
func (p ResizableHandleProps) label() string {
if p.Label == "" {
return "Resize panels"
}
return p.Label
}
// step returns the arrow key step; a zero value is 5.
func (p ResizableHandleProps) step() int {
if p.Step <= 0 {
return 5
}
return p.Step
}
// maxSize returns the largest size; a zero value is 100.
func (p ResizablePanelProps) maxSize() int {
if p.MaxSize <= 0 {
return 100
}
return p.MaxSize
}
// style gives a panel with a size its part of the group.
func (p ResizablePanelProps) style() gx.Style {
if p.Size <= 0 {
return ""
}
// The size is the weight of the panel in the room that the panels
// share. The handles take none of that room.
return gx.Style("flex: " + strconv.Itoa(p.Size) + " 1 0%")
}The theme must define these tokens: --border, --ring.
Usage
<resizable.ResizablePanelGroup class="h-64 rounded-md border">
<resizable.ResizablePanel size={30} minSize={20}>Sidebar</resizable.ResizablePanel>
<resizable.ResizableHandle id="sidebar-handle" />
<resizable.ResizablePanel size={70}>Content</resizable.ResizablePanel>
</resizable.ResizablePanelGroup>The panels are server HTML, and Size is the first size of a panel in percent of the group.
The island of a handle changes the sizes of the panel before it and the panel after it.
The handle sends the resize-panels event with the two sizes.
With no script the page shows the panels with their first sizes.
Examples
ResizableHandle: Between
<resizable.ResizableHandle id="resize-between" />ResizablePanel: Default
<resizable.ResizablePanel>A panel</resizable.ResizablePanel>ResizablePanelGroup: Horizontal
<resizable.ResizablePanelGroup class="h-40 max-w-md rounded-md border border-border">
<resizable.ResizablePanel size={40} minSize={20} class="flex items-center justify-center p-4 text-sm font-medium">
One
</resizable.ResizablePanel>
<resizable.ResizableHandle id="resize-h" />
<resizable.ResizablePanel size={60} minSize={30} class="flex items-center justify-center p-4 text-sm font-medium">
Two
</resizable.ResizablePanel>
</resizable.ResizablePanelGroup>ResizablePanelGroup: Vertical
<resizable.ResizablePanelGroup orientation={resizable.Vertical} class="h-56 max-w-md rounded-md border border-border">
<resizable.ResizablePanel size={50} class="flex items-center justify-center p-4 text-sm font-medium">
Top
</resizable.ResizablePanel>
<resizable.ResizableHandle id="resize-v" orientation={resizable.Vertical} />
<resizable.ResizablePanel size={50} class="flex items-center justify-center p-4 text-sm font-medium">
Bottom
</resizable.ResizablePanel>
</resizable.ResizablePanelGroup>API reference
A tag sets a prop by its name with a lower-case first letter: Class is class.
resizable.ResizableHandle
| Prop | Type | Default | Description |
|---|---|---|---|
Id |
string |
Required | Id is the id of the handle. |
Label |
string |
"Resize panels" |
Label is the accessible name of the handle. |
Orientation |
Orientation |
Horizontal |
Orientation is the direction of the panel group: Horizontal or Vertical. |
Step |
int |
5 |
Step is the size of one arrow key step, in percent of the group. A zero value is 5. |
Class |
string |
"" |
Class adds classes to the root element. |
Attrs |
gx.Attrs |
nil |
Attrs adds HTML attributes to the root element. |
resizable.ResizablePanel
| Prop | Type | Default | Description |
|---|---|---|---|
Size |
int |
0 |
Size is the first size of the panel, in percent of the group. A zero value shares the free space. |
MinSize |
int |
0 |
MinSize is the smallest size of the panel, in percent of the group. |
MaxSize |
int |
0 |
MaxSize is the largest size of the panel, in percent of the group. A zero value is 100. |
Class |
string |
"" |
Class adds classes to the content element inside the panel. |
Children |
gx.Node |
Required | Children is the content of the panel. |
Attrs |
gx.Attrs |
nil |
Attrs adds HTML attributes to the root element. |
resizable.ResizablePanelGroup
| Prop | Type | Default | Description |
|---|---|---|---|
Orientation |
Orientation |
Horizontal |
Orientation sets the direction of the panels: Horizontal or Vertical. |
Class |
string |
"" |
Class adds classes to the root element. |
Children |
gx.Node |
Required | Children are the panels, with one ResizableHandle between two panels. |
Attrs |
gx.Attrs |
nil |
Attrs adds HTML attributes to the root element. |
Do and do not
Do
- Put one
ResizableHandlebetween two panels. - Give the group a height. A vertical group needs one.
- Set
MinSizefor a panel whose content needs room.
Don't
- Do not put a handle at the start or the end of a group.
- Do not use two handles with one
Id.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the handle. |
| Arrow Left, Arrow Right | Makes the first panel smaller or larger. A vertical group uses Arrow Up and Arrow Down. |
| Home | Makes the first panel as small as its limits allow. |
| End | Makes the first panel as large as its limits allow. |