Элементы
Resizable
Группа с изменяемыми размерами раскладывает панели рядом или друг над другом, с ручкой на каждой границе между двумя из них: боковая панель рядом с документом, редактор над консолью, список рядом с подробностями. Пользователь перетаскивает ручку, чтобы сдвинуть границу, и каждая панель остаётся в пределах размеров, которые разрешает приложение. ResizablePanelGroup отвечает за поведение: перетаскивание, которое выполняется нативно покадрово, распределение места, курсор изменения размера и увеличенную область касания для пальца. Внешний вид определяет приложение: ручка представляет собой полосу без собственной линии, цвета или элемента захвата, а то, что она рисует, является индикатором приложения; панели являются представлениями приложения.
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 и ResizablePanel поставляются в nsh/base, crate nsh-base для Rust. Панели являются прямыми дочерними элементами группы. В Rust h_resizable() и v_resizable() создают группу по каждой оси (resizable_panel_group().axis(..) принимает ось как значение), а resizable_panel() создаёт панель, с сеттером для каждого свойства, обработчиком для каждого события, типизированными методами стиля и .child(..) для их содержимого; .size_range(min..max) задаёт оба ограничения сразу.
#Размеры
size панели — это размер, который она хочет получить, в пределах её minSize и maxSize. Панели без него равномерно делят оставшееся место независимо от содержимого; когда размер есть у каждой панели, оставшееся место делится пропорционально им. Пока пользователь перетаскивает ручку, группа перераспределяет место между панелями в ядре, без участия JS:
- Когда панель перед ручкой растёт, она забирает место у панелей после неё, начиная с ближайшей, каждую вплоть до её
minSize, пропускаяfixed. - Когда она уменьшается ниже своего
minSize, она забирает место у панелей перед ней. - Ни одна панель не вырастает больше своего
maxSize, и сумма панелей никогда не превышает размер группы.
Когда перетаскивание завершается и размеры изменились, группа сообщает размер каждой видимой панели в порядке их расположения (onResize у группы), а каждая панель, чей размер изменился, сообщает свой собственный (onResize у панели). Панели, которые сдвинуло перетаскивание, сохраняют размеры пользователя, пока приложение не передаст новый size; приложение сохраняет их и после жизни группы, передавая сообщённые размеры обратно как size, так что раскладка, восстановленная из хранилища, открывается с теми размерами, которые оставил пользователь. Сообщают только перетаскивания и шаги скринридеров: изменение размера окна, показ или скрытие панели ничего не сообщают.
Когда группа растёт или уменьшается (изменяется размер окна), панели с размером сохраняют его, а панели без размера принимают разницу на себя. Когда группа слишком мала для заданных размеров, панели уменьшаются, за исключением fixed (боковая панель, инспектор), и возвращаются к своим размерам, когда место появляется снова. Панель с visible={false} не занимает места: остальные делят её пространство, и она возвращается со своим размером, когда снова показывается.
<ResizablePanelGroup axis="vertical" handleClass="bg-border">
<ResizablePanel>
<Editor />
</ResizablePanel>
<ResizablePanel size={180} minSize={120} fixed visible={consoleOpen()}>
<Console />
</ResizablePanel>
</ResizablePanelGroup>
#Ручки
На каждой границе между двумя видимыми панелями есть ручка: полоса толщиной 9 пунктов поперёк оси, центрированная на границе, с курсором изменения размера для своей оси. На сенсорных экранах её область касания выходит ещё на 10 пунктов в каждую сторону, поэтому даже линию толщиной в один пункт легко захватить. Ручка является group: то, что она рисует, является индикатором приложения, стилизуемым с помощью handleClass / handleStyle: 1 пункт поперёк и вся длина границы, поэтому один bg-border даёт тонкую линию на любой оси, а w-[3px] или h-[3px] делает её толще, и её классы group-hover: и group-active: следуют за указателем над полосой и за нажатием или перетаскиванием. Для чего-то большего, чем линия (захват из точек, значок), renderHandle отрисовывает содержимое индикатора, получая от ручки index, axis и state; он перерисовывается при смене состояния, несколько раз за перетаскивание, но не при каждом движении. Анимированный захват является бегунком ручки (handleThumbClass / handleThumbStyle): одно представление, центрированное на линии, нарисованное вертикально и повёрнутое на четверть оборота на горизонтальной линии, так что один класс подходит для обеих осей; его показывают и анимируют классы group-hover: и group-active:; ручка остаётся нажатой на всё время перетаскивания, куда бы ни ушёл указатель.
<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 оставляет каждую ручку на месте (заблокированная раскладка).
#Свойства
#ResizablePanelGroup
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
axis | h_resizable(), v_resizable(), .axis(..) | "horizontal" | "vertical" | "horizontal" | Рядом (ручки перетаскиваются влево и вправо) или друг над другом (вверх и вниз). |
disabled | .disabled(..) | boolean | false | Без перетаскивания: ручки остаются на месте. |
handleClass, handleStyle | .handle_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Индикатор каждой ручки, на границе внутри полосы ручки: 1 пункт поперёк оси и вся длина границы, если не указано иное (w-[3px], h-[3px]); group-hover: и group-active: следуют за полосой. |
handleThumbClass, handleThumbStyle | .handle_thumb_class(..), .handle_thumb_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Бегунок каждой ручки: захват, центрированный на линии, нарисованный вертикально, как на вертикальной линии, и повёрнутый на четверть оборота на горизонтальной, так что один класс подходит для обеих осей. Он остаётся тем же представлением, пока ручка находится под указателем, нажата и перетаскивается, поэтому классы group-hover: и group-active: анимируют его (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 | нет | Содержимое каждого индикатора, перерисовывается при изменении state ручки. |
handleAccessibilityLabel | .handle_accessibility_label(..) | string | нет | Что скринридеры произносят для каждой ручки: что она изменяет. Они меняют её по 10 пунктов за раз. |
children | .child(..) | JSX.Element | нет | ResizablePanel, её прямые дочерние элементы. |
class, style | методы стиля | string, StyleProp<ViewStyle> | size-full | Сама группа; она заполняет родителя, если её класс не говорит иначе. |
#ResizablePanel
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
size | .size(..) | number | нет | Желаемая длина вдоль оси группы, в пунктах, в пределах minSize и maxSize; новое значение изменяет размер. Без него панель делит оставшееся место. |
minSize | .min_size(..), .size_range(..) | number | 100 | Наименьший размер панели. |
maxSize | .max_size(..), .size_range(..) | number | без ограничений | Наибольший размер панели. |
fixed | .fixed(..) | boolean | false | Сохраняет свой размер, когда группа слишком мала для панелей; перетаскивание её собственной ручки всё равно изменяет его. Принимает size. |
visible | .visible(..) | boolean | true | Скрытая панель не занимает места, а остальные делят её пространство. |
children | .child(..) | JSX.Element | нет | Её содержимое. |
class, style | методы стиля | string, StyleProp<ViewStyle> | нет | Собственная поверхность панели. Её размер вдоль оси задаёт группа. |
#ResizeHandleContext
| Поле | Тип | Описание |
|---|---|---|
index | number | Граница после index-й видимой панели. |
axis | "horizontal" | "vertical" | Ось, вдоль которой она изменяет размер. |
state | "idle" | "hovered" | "pressed" | "dragging" | Насколько далеко ушли вместе с ней указатель или палец. |
#События
| Событие | Rust | Описание |
|---|---|---|
onResize (ResizablePanelGroup) | .on_resize(|sizes: &Vec<Points>| ..) | Перетаскивание завершилось и сдвинуло ручку, либо скринридер её изменил: sizes, размер каждой видимой панели в порядке их расположения (слева направо, сверху вниз). |
onResize (ResizablePanel) | .on_resize(|size: &Points| ..) | Перетаскивание завершилось и изменило размер этой панели: size, новый размер, который нужно передать обратно как её size. |
#Части
| Часть | Внутри | Стилизуется через | Состояния |
|---|---|---|---|
| Группа | ResizablePanelGroup: class, style | ||
| Панель | группа | ResizablePanel: class, style | |
| Индикатор ручки | полоса ручки, на границе | handleClass, handleStyle, renderHandle | group-hover: под указателем, group-active: при нажатии или перетаскивании; state в renderHandle |
| Бегунок ручки | по центру линии ручки | handleThumbClass, handleThumbStyle | group-hover:, group-active: (нажат до конца перетаскивания) |
#Доступность
- Каждая ручка является для скринридеров регулируемым элементом с меткой
handleAccessibilityLabel. Её значение — размер предшествующей ей панели в пределахminSizeиmaxSizeэтой панели. - Смахивание вверх или вниз по ручке в VoiceOver или клавиши громкости в TalkBack сдвигают её на 10 пунктов, как и перетаскивание; группа сообщает об этом через
onResize. Ручки группыdisabledостаются на месте. - Содержимое панелей принадлежит приложению и читается так же, как и везде.
Подробнее о метках, ролях и объявлениях в целом см. в разделе Доступность.
#Примечания по платформам
- Настольные платформы (macOS, Windows, Linux, GPUI) и веб: указатель над ручкой показывает курсор изменения размера столбца или строки, а перетаскивание мышью или тачпадом сдвигает границу.
- iOS, Android и Mac Catalyst: палец перетаскивает ручку в пределах области касания, которая шире полосы на 10 пунктов с каждой стороны.
- В раскладках справа налево панели и их размеры следуют порядку раскладки.
- Каждая платформа рисует то, что стилизует приложение: индикаторы и панели.
- Разделения в Dock являются изменяемыми группами: их размер изменяется так же.