Elemente
Resizable
Eine Resizable Group ordnet Panels nebeneinander oder untereinander an, mit einem Handle an jeder Grenze zwischen zwei Panels: eine Seitenleiste neben einem Dokument, ein Editor über seiner Konsole, eine Liste neben ihrer Detailansicht. Der Nutzer zieht ein Handle, um die Grenze zu verschieben, und jedes Panel bleibt innerhalb der Größen, die die App ihm erlaubt. ResizablePanelGroup übernimmt das Verhalten: das Ziehen, das nativ Frame für Frame abläuft, die Aufteilung des Platzes, den Resize-Cursor und eine größere Touch-Fläche für den Finger. Das Aussehen liegt bei der App: Ein Handle ist ein Band ohne eigene Linie, Farbe oder eigenen Griff, und was es zeichnet, ist der Indikator der App; die Panels sind die Views der App.
import { mount, signal, Text, View } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, ui, wide } from "./palette";
// The sizes are the app's: the group reports them when a drag ends, and the
// app passes them back as each panel's `size`.
function App() {
const sidebar = signal(desktop ? 220 : 140);
const consoleSize = signal(120);
const showConsole = signal<"Shown" | "Hidden">("Shown");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
const round = (sizes: number[]) => sizes.map((s) => Math.round(s)).join(" · ");
// The handles' look: a hairline that lights up under the pointer and while dragged.
const handle = "bg-border group-hover:bg-muted-foreground group-active:bg-primary";
const label = (title: string, detail: string) => (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().text }}>{title}</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>{detail}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>resizable</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Drag a line between two panels" : "Drag a line between two panels with your finger"}
</Text>
<Split
grow
width={640}
inspector={
<>
<Panel title="sizes">
<Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>
{`sidebar ${Math.round(sidebar())} · console ${Math.round(consoleSize())}`}
</Text>
</Panel>
<Segmented label="console" options={["Shown", "Hidden"] as const} value={showConsole()} onChange={(v) => showConsole.set(v)} />
<Log title="onResize" lines={events()} minHeight={96} />
</>
}
>
<View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<ResizablePanelGroup
axis="horizontal"
handleClass={handle}
handleAccessibilityLabel="Resize the sidebar"
onResize={({ sizes }) => log(`row: ${round(sizes)}`)}
>
<ResizablePanel size={sidebar()} minSize={100} maxSize={320} onResize={({ size }) => sidebar.set(size)}>
<View style={{ flex: 1, padding: 14, gap: 10, backgroundColor: ui().inset.backgroundColor }}>
{label("Files", `${Math.round(sidebar())} pt, 100 to 320`)}
</View>
</ResizablePanel>
<ResizablePanel minSize={120}>
<ResizablePanelGroup
axis="vertical"
handleClass={handle}
handleAccessibilityLabel="Resize the console"
onResize={({ sizes }) => log(`column: ${round(sizes)}`)}
>
<ResizablePanel minSize={80}>
<View style={{ flex: 1, padding: 14 }}>{label("Editor", "Takes the room left")}</View>
</ResizablePanel>
<ResizablePanel
size={consoleSize()}
minSize={60}
fixed
visible={showConsole() === "Shown"}
onResize={({ size }) => consoleSize.set(size)}
>
<View style={{ flex: 1, padding: 14 }}>{label("Console", "Fixed: keeps its height")}</View>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{h_resizable, resizable_panel, theme};
use nsh_core::prelude::*;
fn workspace() -> impl IntoElement {
h_resizable()
.handle_style(|s| s.bg(move || theme().border()))
.handle_accessibility_label("Resize the sidebar")
.on_resize(|sizes: &Vec<Points>| log::info!("sizes: {sizes:?}"))
.child(
resizable_panel()
.size(px(240.0))
.size_range(px(160.0)..px(480.0))
.p_4()
.child(text("Files").text_color(move || theme().muted_foreground())),
)
.child(
resizable_panel()
.p_4()
.child(text("Editor").text_color(move || theme().foreground())),
)
}import { mount, signal, Text } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
function App() {
const sidebar = signal(240);
return (
<ResizablePanelGroup
axis="horizontal"
handleClass="bg-border group-hover:bg-ring group-active:bg-primary"
handleAccessibilityLabel="Resize the sidebar"
>
<ResizablePanel size={sidebar()} minSize={160} maxSize={480} onResize={({ size }) => sidebar.set(size)} class="p-4">
<Text class="text-muted-foreground">Files</Text>
</ResizablePanel>
<ResizablePanel class="p-4">
<Text class="text-foreground">Editor</Text>
</ResizablePanel>
</ResizablePanelGroup>
);
}
mount(App);ResizablePanelGroup und ResizablePanel stammen aus nsh/base, dem nsh-base-Crate in Rust. Die Panels sind die direkten Kinder der Gruppe. In Rust erzeugen h_resizable() und v_resizable() jeweils eine Gruppe entlang einer Achse (resizable_panel_group().axis(..) nimmt die Achse als Wert) und resizable_panel() ein Panel, mit einem Setter pro Prop, einem Listener pro Ereignis, den typisierten Style-Methoden und .child(..) für ihren Inhalt; .size_range(min..max) setzt beide Grenzen auf einmal.
#Größen
Die size eines Panels ist die Größe, die es haben möchte, innerhalb von minSize und maxSize. Panels ohne diese Angabe teilen sich den verbleibenden Platz gleichmäßig auf, unabhängig von ihrem Inhalt; hat jedes Panel eine Größe, wird der übrige Platz im Verhältnis dazu verteilt. Während der Nutzer ein Handle zieht, verschiebt die Gruppe den Platz zwischen den Panels im Core, ohne JS auf dem Weg:
- Wächst das Panel vor dem Handle, nimmt es Platz von den Panels dahinter, das nächstgelegene zuerst, jedes bis hinunter zu seinem
minSize, wobeifixed-Panels übersprungen werden. - Verkleinert man es unter sein
minSize, nimmt es Platz von den Panels davor. - Kein Panel wächst über sein
maxSizehinaus, und die Panels ergeben zusammen nie mehr als die Gruppe.
Endet ein Ziehen mit geänderten Größen, meldet die Gruppe die Größe jedes sichtbaren Panels in der Reihenfolge ihrer Anordnung (onResize an der Gruppe), und jedes Panel, dessen Größe sich geändert hat, meldet seine eigene (onResize am Panel). Die Panels, die ein Ziehen verschoben hat, behalten die Größen des Nutzers, bis die App ein neues size übergibt; die App bewahrt sie über die Lebensdauer der Gruppe hinaus, indem sie die gemeldeten Größen als size zurückgibt, sodass ein Layout, das sie aus dem Speicher wiederherstellt, mit den Größen öffnet, die der Nutzer hinterlassen hat. Nur Ziehen und die Schritte von Screenreadern melden: Eine Größenänderung des Fensters oder das Ein- oder Ausblenden eines Panels meldet nichts.
Wächst oder schrumpft die Gruppe (das Fenster wird in der Größe geändert), behalten die Panels mit Größe diese, und die Panels ohne Größe nehmen die Differenz auf. Ist die Gruppe für die Größen zu klein, schrumpfen die Panels, mit Ausnahme der fixed-Panels (eine Seitenleiste, ein Inspektor), und sie kehren zu ihren Größen zurück, sobald wieder Platz da ist. Ein Panel mit visible={false} beansprucht keinen Platz: Die anderen teilen sich seinen Raum, und es kehrt mit seiner Größe zurück, wenn es wieder angezeigt wird.
<ResizablePanelGroup axis="vertical" handleClass="bg-border">
<ResizablePanel>
<Editor />
</ResizablePanel>
<ResizablePanel size={180} minSize={120} fixed visible={consoleOpen()}>
<Console />
</ResizablePanel>
</ResizablePanelGroup>
#Handles
An jeder Grenze zwischen zwei sichtbaren Panels gibt es ein Handle: ein Band, 9 Punkte dick quer zur Achse, zentriert auf der Grenze, mit dem Resize-Cursor seiner Achse. Auf Touchscreens reicht seine Touch-Fläche auf jeder Seite 10 Punkte weiter, sodass auch eine einen Punkt dünne Linie leicht zu greifen ist. Das Handle ist ein group: Was es zeichnet, ist der Indikator der App, gestaltet mit handleClass / handleStyle: 1 Punkt quer zur Achse und die volle Länge der Grenze, sodass bg-border allein auf beiden Achsen eine Haarlinie ergibt, w-[3px] oder h-[3px] sie verdickt und seine Klassen group-hover: und group-active: dem Zeiger über dem Band sowie dem Drücken oder Ziehen folgen. Für mehr als eine Linie (ein Griff aus Punkten, ein Icon) rendert renderHandle den Inhalt des Indikators und bekommt index, axis und state des Handles mitgeteilt; er wird bei einer Zustandsänderung neu gezeichnet, einige Male pro Ziehen, nie bei jeder Bewegung. Ein Griff, der animiert wird, ist der Thumb des Handles (handleThumbClass / handleThumbStyle): eine View, zentriert auf der Linie, aufrecht gezeichnet und auf einer horizontalen Linie um eine Vierteldrehung gedreht, sodass eine Klasse beide Achsen bedient; die Klassen group-hover: und group-active: zeigen und animieren ihn; das Handle bleibt während des gesamten Ziehens gedrückt, wohin auch immer der Zeiger geht.
<ResizablePanelGroup
handleClass="w-[3px] items-center justify-center bg-border"
renderHandle={(handle) => (
<View class={handle.state === "dragging" ? "h-8 w-1 rounded-full bg-primary" : "h-6 w-1 rounded-full bg-muted-foreground"} />
)}
>
{/* panels */}
</ResizablePanelGroup>
disabled hält jedes Handle an seinem Platz (ein gesperrtes Layout).
#Props
#ResizablePanelGroup
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
axis |
h_resizable(), v_resizable(), .axis(..) |
"horizontal" | "vertical" |
"horizontal" |
Nebeneinander (die Handles werden nach links und rechts gezogen) oder untereinander (nach oben und unten). |
disabled |
.disabled(..) |
boolean |
false |
Kein Ziehen: Die Handles bleiben, wo sie sind. |
handleClass, handleStyle |
.handle_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Indikator jedes Handles, auf der Grenze im Band des Handles: 1 Punkt quer zur Achse und die volle Länge der Grenze, sofern nichts anderes angegeben ist (w-[3px], h-[3px]); group-hover: und group-active: folgen dem Band. |
handleThumbClass, handleThumbStyle |
.handle_thumb_class(..), .handle_thumb_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Thumb jedes Handles: ein auf der Linie zentrierter Griff, aufrecht gezeichnet wie auf einer vertikalen Linie und auf einer horizontalen um eine Vierteldrehung gedreht, sodass eine Klasse beide Achsen bedient. Er bleibt dieselbe View, während das Handle überfahren, gedrückt und gezogen wird, daher animieren die Klassen group-hover: und group-active: ihn (h-11 w-[3px] rounded-full bg-muted-foreground opacity-0 scale-y-0 transition group-hover:opacity-40 group-hover:scale-y-[0.45] group-active:opacity-90 group-active:scale-y-100). |
renderHandle |
.render_handle(|handle| ..) |
(handle: ResizeHandleContext) => JSX.Element |
keiner | Was sich in jedem Indikator befindet; wird neu gezeichnet, wenn sich state des Handles ändert. |
handleAccessibilityLabel |
.handle_accessibility_label(..) |
string |
keiner | Was Screenreader für jedes Handle sagen: was es in der Größe ändert. Sie verstellen es in Schritten von 10 Punkten. |
children |
.child(..) |
JSX.Element |
keiner | Die ResizablePanel, seine direkten Kinder. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
size-full |
Die Gruppe selbst; sie füllt ihr Elternelement, sofern ihre Klasse nichts anderes vorgibt. |
#ResizablePanel
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
size |
.size(..) |
number |
keine | Die gewünschte Länge entlang der Achse der Gruppe, in Punkten, innerhalb von minSize und maxSize; ein neuer Wert ändert die Größe. Ohne Wert teilt es sich den verbleibenden Platz. |
minSize |
.min_size(..), .size_range(..) |
number |
100 |
Die kleinste Größe, die es erhält. |
maxSize |
.max_size(..), .size_range(..) |
number |
keine Begrenzung | Die größte Größe, die es erhält. |
fixed |
.fixed(..) |
boolean |
false |
Behält seine Größe, wenn die Gruppe für die Panels zu klein ist; das Ziehen an seinem eigenen Handle ändert sie dennoch. Es nimmt einen size entgegen. |
visible |
.visible(..) |
boolean |
true |
Ausgeblendet beansprucht es keinen Platz, und die anderen teilen sich seinen Raum. |
children |
.child(..) |
JSX.Element |
keiner | Sein Inhalt. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keine | Die eigene Fläche des Panels. Seine Größe entlang der Achse bestimmt die Gruppe. |
#ResizeHandleContext
| Feld | Typ | Beschreibung |
|---|---|---|
index |
number |
Die Grenze nach dem index-ten sichtbaren Panel. |
axis |
"horizontal" | "vertical" |
Die Achse, entlang der es die Größe ändert. |
state |
"idle" | "hovered" | "pressed" | "dragging" |
Wie weit sich der Zeiger oder Finger mit ihm bewegt hat. |
#Ereignisse
| Ereignis | Rust | Beschreibung |
|---|---|---|
onResize (ResizablePanelGroup) |
.on_resize(|sizes: &Vec<Points>| ..) |
Ein Ziehen wurde beendet und hat ein Handle bewegt, oder ein Screenreader hat eines verstellt: sizes, die Größe jedes sichtbaren Panels in der Reihenfolge ihrer Anordnung (von links nach rechts, von oben nach unten). |
onResize (ResizablePanel) |
.on_resize(|size: &Points| ..) |
Ein Ziehen wurde beendet und hat die Größe dieses Panels geändert: size, die neue, die als sein size zurückgegeben wird. |
#Teile
| Teil | Enthalten in | Gestaltet mit | Zustände |
|---|---|---|---|
| Die Gruppe | ResizablePanelGroup mit class, style |
||
| Ein Panel | der Gruppe | ResizablePanel mit class, style |
|
| Der Indikator eines Handles | dem Band des Handles, auf einer Grenze | handleClass, handleStyle, renderHandle |
group-hover: unter einem Zeiger, group-active: beim Drücken oder Ziehen; state in renderHandle |
| Der Thumb eines Handles | zentriert auf der Linie eines Handles | handleThumbClass, handleThumbStyle |
group-hover:, group-active: (gedrückt, bis das Ziehen endet) |
#Barrierefreiheit
- Jedes Handle ist für Screenreader ein verstellbares Element mit
handleAccessibilityLabelals Label. Sein Wert ist die Größe des Panels davor, innerhalb vonminSizeundmaxSizedieses Panels. - Ein Wischen nach oben oder unten auf einem Handle mit VoiceOver oder die Lautstärketasten von TalkBack verschieben es um 10 Punkte, wie ein Ziehen es täte; die
Gruppe meldet es mit
onResize. Die Handles einerdisabled-Gruppe bleiben, wo sie sind. - Der Inhalt der Panels gehört der App und wird wie überall sonst vorgelesen.
Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.
#Hinweise zu Plattformen
- Desktops (macOS, Windows, Linux, GPUI) und das Web: Der Zeiger über einem Handle zeigt den Spalten- bzw. Zeilen-Resize-Cursor, und ein Ziehen mit der Maus oder einem Touchpad verschiebt die Grenze.
- iOS, Android und Mac Catalyst: Ein Finger zieht das Handle, innerhalb einer Touch-Fläche, die auf jeder Seite 10 Punkte breiter ist als das Band.
- In Right-to-left-Layouts folgen die Panels und ihre Größen der Reihenfolge des Layouts.
- Jede Plattform zeichnet, was die App gestaltet: die Indikatoren und die Panels.
- Die Splits von Dock sind Resizable Groups: Ihre Größe wird auf dieselbe Weise geändert.