Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/widgets/accordion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Accordion

Displays stacked sections that can be expanded or collapsed.

## Usage

```ts
ui.accordion({
id: "faq",
items: [
{ key: "install", title: "How do I install?", content: ui.text("Use npm or bun") },
{ key: "mouse", title: "Mouse support?", content: ui.text("Yes") },
],
expanded: state.expanded,
onChange: (expanded) => app.update({ expanded }),
allowMultiple: false,
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Stable accordion widget id |
| `items` | `Array<{ key: string; title: string; content: VNode }>` | **required** | Accordion sections |
| `expanded` | `string[]` | **required** | Expanded section keys |
| `onChange` | `(expanded: readonly string[]) => void` | **required** | Called when expansion changes |
| `allowMultiple` | `boolean` | `false` | Allows multiple expanded sections |
| `key` | `string` | - | Reconciliation key |

## Keyboard Behavior

- `Up/Down`: moves focus between section headers.
- `Enter/Space`: toggles the focused section.
- `Tab/Shift+Tab`: enters/leaves accordion header focus.
32 changes: 32 additions & 0 deletions docs/widgets/breadcrumb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Breadcrumb

Shows a hierarchical path. Parent items can be clickable; the last item is
always treated as the current location.

## Usage

```ts
ui.breadcrumb({
items: [
{ label: "Home", onPress: () => navigate("/") },
{ label: "Docs", onPress: () => navigate("/docs") },
{ label: "Tabs" },
],
separator: " > ",
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `Array<{ label: string; onPress?: () => void }>` | **required** | Breadcrumb entries |
| `separator` | `string` | `" > "` | Text between items |
| `id` | `string` | auto-generated | Optional stable id |
| `key` | `string` | - | Reconciliation key |

## Keyboard Behavior

- `Tab/Shift+Tab`: moves across clickable breadcrumb items.
- `Enter`: activates the focused breadcrumb item.
- The last breadcrumb item is not clickable.
11 changes: 11 additions & 0 deletions docs/widgets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ Interactive form controls:
| [Select](select.md) | Dropdown selection | Yes | `beta` |
| [Field](field.md) | Form field wrapper with label/error | No | `beta` |

### Navigation

Navigation and wayfinding widgets:

| Widget | Description | Focusable | Stability |
|--------|-------------|-----------|-----------|
| [Tabs](tabs.md) | Switch between related content panels | Yes | `beta` |
| [Accordion](accordion.md) | Expand/collapse stacked sections | Yes | `beta` |
| [Breadcrumb](breadcrumb.md) | Hierarchical location path with jumps | Yes | `beta` |
| [Pagination](pagination.md) | Navigate paged datasets | Yes | `beta` |

### Data Display

Tables, lists, and trees:
Expand Down
32 changes: 32 additions & 0 deletions docs/widgets/pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Pagination

Renders page navigation controls for paged datasets.

## Usage

```ts
ui.pagination({
id: "results-pages",
page: state.page,
totalPages: state.totalPages,
onChange: (page) => app.update({ page }),
showFirstLast: true,
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Stable pagination widget id |
| `page` | `number` | **required** | Current page (1-based) |
| `totalPages` | `number` | **required** | Total page count |
| `onChange` | `(page: number) => void` | **required** | Called when page changes |
| `showFirstLast` | `boolean` | `false` | Shows first/last page controls |
| `key` | `string` | - | Reconciliation key |

## Keyboard Behavior

- `Left/Right`: moves to previous/next page when available.
- `Home/End`: jumps to first/last page when `showFirstLast` is enabled.
- `Tab/Shift+Tab`: moves across focusable pagination controls.
37 changes: 37 additions & 0 deletions docs/widgets/tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Tabs

Switches between related views with a tab bar and one active content panel.

## Usage

```ts
ui.tabs({
id: "settings-tabs",
tabs: [
{ key: "general", label: "General", content: ui.text("General settings") },
{ key: "security", label: "Security", content: ui.text("Security settings") },
],
activeTab: state.activeTab,
onChange: (key) => app.update({ activeTab: key }),
variant: "line",
position: "top",
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Stable tabs widget id |
| `tabs` | `Array<{ key: string; label: string; content: VNode }>` | **required** | Tab descriptors |
| `activeTab` | `string` | **required** | Active tab key |
| `onChange` | `(key: string) => void` | **required** | Called when active tab changes |
| `variant` | `"line" \| "enclosed" \| "pills"` | `"line"` | Tab label style variant |
| `position` | `"top" \| "bottom"` | `"top"` | Tab bar position relative to content |
| `key` | `string` | - | Reconciliation key |

## Keyboard Behavior

- `Left/Right`: switches tabs in the tab bar.
- `Tab/Shift+Tab`: enters/leaves the content focus scope.
- `Escape`: when focused in tab content, returns focus to the tab bar.
8 changes: 8 additions & 0 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ function isRoutingRelevantKind(kind: WidgetKind): boolean {
case "select":
case "checkbox":
case "radioGroup":
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination":
case "commandPalette":
case "filePicker":
case "fileTreeExplorer":
Expand Down Expand Up @@ -344,6 +348,10 @@ function isDamageGranularityKind(kind: WidgetKind): boolean {
case "select":
case "checkbox":
case "radioGroup":
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination":
case "richText":
case "badge":
case "spinner":
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ export type {
ProgressProps,
ProgressVariant,
RadioGroupProps,
AccordionItem,
AccordionProps,
BreadcrumbItem,
BreadcrumbProps,
PaginationProps,
RichTextProps,
RichTextSpan,
SelectOption,
Expand All @@ -217,6 +222,10 @@ export type {
TableColumn,
TableColumnOverflow,
TableProps,
TabsItem,
TabsPosition,
TabsProps,
TabsVariant,
TableStripeStyle,
TextProps,
TextVariant,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/layout/engine/layoutEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { layoutBoxKinds, measureBoxKinds } from "../kinds/box.js";
import { layoutCollections, measureCollections } from "../kinds/collections.js";
import { layoutGridKinds, measureGridKinds } from "../kinds/grid.js";
import { layoutLeafKind, measureLeaf } from "../kinds/leaf.js";
import { layoutNavigationKinds, measureNavigationKinds } from "../kinds/navigation.js";
import { layoutOverlays, measureOverlays } from "../kinds/overlays.js";
import { layoutSplitPaneKinds, measureSplitPaneKinds } from "../kinds/splitPane.js";
import { layoutStackKinds, measureStackKinds } from "../kinds/stack.js";
Expand Down Expand Up @@ -159,6 +160,13 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo
computed = measureLeaf(vnode, maxW, maxH, axis);
break;
}
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination": {
computed = measureNavigationKinds(vnode, maxW, maxH, measureNode);
break;
}

/* ========== Advanced Widgets (GitHub issue #136) ========== */

Expand Down Expand Up @@ -337,6 +345,12 @@ function layoutNode(
case "field": {
return layoutBoxKinds(vnode, x, y, rectW, rectH, axis, layoutNode);
}
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination": {
return layoutNavigationKinds(vnode, x, y, rectW, rectH, layoutNode);
}

/* ========== Advanced Widgets (GitHub issue #136) ========== */

Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/layout/kinds/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { VNode } from "../../index.js";
import { ok } from "../engine/result.js";
import type { LayoutTree } from "../engine/types.js";
import type { Axis, Size } from "../types.js";
import type { LayoutResult } from "../validateProps.js";

type MeasureNodeFn = (vnode: VNode, maxW: number, maxH: number, axis: Axis) => LayoutResult<Size>;

type LayoutNodeFn = (
vnode: VNode,
x: number,
y: number,
maxW: number,
maxH: number,
axis: Axis,
forcedW?: number | null,
forcedH?: number | null,
) => LayoutResult<LayoutTree>;

type NavigationVNode = Extract<
VNode,
| Readonly<{ kind: "tabs"; children: readonly VNode[] }>
| Readonly<{ kind: "accordion"; children: readonly VNode[] }>
| Readonly<{ kind: "breadcrumb"; children: readonly VNode[] }>
| Readonly<{ kind: "pagination"; children: readonly VNode[] }>
>;

function toSyntheticContainer(vnode: NavigationVNode): VNode {
if (vnode.kind === "tabs" || vnode.kind === "accordion") {
return { kind: "column", props: {}, children: vnode.children };
}
return { kind: "row", props: {}, children: vnode.children };
}

function syntheticAxis(vnode: NavigationVNode): Axis {
return vnode.kind === "tabs" || vnode.kind === "accordion" ? "column" : "row";
}

export function measureNavigationKinds(
vnode: VNode,
maxW: number,
maxH: number,
measureNode: MeasureNodeFn,
): LayoutResult<Size> {
switch (vnode.kind) {
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination": {
const navVnode = vnode as NavigationVNode;
const synthetic = toSyntheticContainer(navVnode);
return measureNode(synthetic, maxW, maxH, syntheticAxis(navVnode));
}
default:
return {
ok: false,
fatal: {
code: "ZRUI_INVALID_PROPS",
detail: "measureNavigationKinds: unexpected vnode kind",
},
};
}
}

export function layoutNavigationKinds(
vnode: VNode,
x: number,
y: number,
rectW: number,
rectH: number,
layoutNode: LayoutNodeFn,
): LayoutResult<LayoutTree> {
switch (vnode.kind) {
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination": {
const navVnode = vnode as NavigationVNode;
const synthetic = toSyntheticContainer(navVnode);
const axis = syntheticAxis(navVnode);
const res = layoutNode(synthetic, x, y, rectW, rectH, axis, rectW, rectH);
if (!res.ok) return res;
return ok({
vnode,
rect: { x, y, w: rectW, h: rectH },
children: res.value.children,
});
}
default:
return {
ok: false,
fatal: {
code: "ZRUI_INVALID_PROPS",
detail: "layoutNavigationKinds: unexpected vnode kind",
},
};
}
}
21 changes: 21 additions & 0 deletions packages/core/src/renderer/renderToDrawlist/renderTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { renderCollectionWidget } from "./widgets/collections.js";
import { renderContainerWidget } from "./widgets/containers.js";
import { renderEditorWidget } from "./widgets/editors.js";
import { renderFileWidgets } from "./widgets/files.js";
import { renderNavigationWidget } from "./widgets/navigation.js";
import { renderOverlayWidget } from "./widgets/overlays.js";

type RenderNodeTask = RuntimeInstance | null;
Expand Down Expand Up @@ -172,6 +173,26 @@ export function renderTree(
break;
}

// Navigation widgets
case "tabs":
case "accordion":
case "breadcrumb":
case "pagination": {
renderNavigationWidget(
builder,
rect,
parentStyle,
node,
layoutNode,
nodeStack,
styleStack,
layoutStack,
clipStack,
currentClip,
);
break;
}

// Basic widgets
case "text":
case "divider":
Expand Down
Loading
Loading