Skip to content

Commit 3c5b221

Browse files
Merge pull request #82 from RtlZeroMemory/core-components-tabs-accordion
feat(core): add missing navigation/core widgets
2 parents 5b9aae5 + 7600d74 commit 3c5b221

28 files changed

Lines changed: 2520 additions & 5 deletions

docs/widgets/accordion.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Accordion
2+
3+
Displays stacked sections that can be expanded or collapsed.
4+
5+
## Usage
6+
7+
```ts
8+
ui.accordion({
9+
id: "faq",
10+
items: [
11+
{ key: "install", title: "How do I install?", content: ui.text("Use npm or bun") },
12+
{ key: "mouse", title: "Mouse support?", content: ui.text("Yes") },
13+
],
14+
expanded: state.expanded,
15+
onChange: (expanded) => app.update({ expanded }),
16+
allowMultiple: false,
17+
})
18+
```
19+
20+
## Props
21+
22+
| Prop | Type | Default | Description |
23+
|---|---|---|---|
24+
| `id` | `string` | **required** | Stable accordion widget id |
25+
| `items` | `Array<{ key: string; title: string; content: VNode }>` | **required** | Accordion sections |
26+
| `expanded` | `string[]` | **required** | Expanded section keys |
27+
| `onChange` | `(expanded: readonly string[]) => void` | **required** | Called when expansion changes |
28+
| `allowMultiple` | `boolean` | `false` | Allows multiple expanded sections |
29+
| `key` | `string` | - | Reconciliation key |
30+
31+
## Keyboard Behavior
32+
33+
- `Up/Down`: moves focus between section headers.
34+
- `Enter/Space`: toggles the focused section.
35+
- `Tab/Shift+Tab`: enters/leaves accordion header focus.

docs/widgets/breadcrumb.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Breadcrumb
2+
3+
Shows a hierarchical path. Parent items can be clickable; the last item is
4+
always treated as the current location.
5+
6+
## Usage
7+
8+
```ts
9+
ui.breadcrumb({
10+
items: [
11+
{ label: "Home", onPress: () => navigate("/") },
12+
{ label: "Docs", onPress: () => navigate("/docs") },
13+
{ label: "Tabs" },
14+
],
15+
separator: " > ",
16+
})
17+
```
18+
19+
## Props
20+
21+
| Prop | Type | Default | Description |
22+
|---|---|---|---|
23+
| `items` | `Array<{ label: string; onPress?: () => void }>` | **required** | Breadcrumb entries |
24+
| `separator` | `string` | `" > "` | Text between items |
25+
| `id` | `string` | auto-generated | Optional stable id |
26+
| `key` | `string` | - | Reconciliation key |
27+
28+
## Keyboard Behavior
29+
30+
- `Tab/Shift+Tab`: moves across clickable breadcrumb items.
31+
- `Enter`: activates the focused breadcrumb item.
32+
- The last breadcrumb item is not clickable.

docs/widgets/index.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ Interactive form controls:
6565
| [Select](select.md) | Dropdown selection | Yes | `beta` |
6666
| [Field](field.md) | Form field wrapper with label/error | No | `beta` |
6767

68+
### Navigation
69+
70+
Navigation and wayfinding widgets:
71+
72+
| Widget | Description | Focusable | Stability |
73+
|--------|-------------|-----------|-----------|
74+
| [Tabs](tabs.md) | Switch between related content panels | Yes | `beta` |
75+
| [Accordion](accordion.md) | Expand/collapse stacked sections | Yes | `beta` |
76+
| [Breadcrumb](breadcrumb.md) | Hierarchical location path with jumps | Yes | `beta` |
77+
| [Pagination](pagination.md) | Navigate paged datasets | Yes | `beta` |
78+
6879
### Data Display
6980

7081
Tables, lists, and trees:

docs/widgets/pagination.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Pagination
2+
3+
Renders page navigation controls for paged datasets.
4+
5+
## Usage
6+
7+
```ts
8+
ui.pagination({
9+
id: "results-pages",
10+
page: state.page,
11+
totalPages: state.totalPages,
12+
onChange: (page) => app.update({ page }),
13+
showFirstLast: true,
14+
})
15+
```
16+
17+
## Props
18+
19+
| Prop | Type | Default | Description |
20+
|---|---|---|---|
21+
| `id` | `string` | **required** | Stable pagination widget id |
22+
| `page` | `number` | **required** | Current page (1-based) |
23+
| `totalPages` | `number` | **required** | Total page count |
24+
| `onChange` | `(page: number) => void` | **required** | Called when page changes |
25+
| `showFirstLast` | `boolean` | `false` | Shows first/last page controls |
26+
| `key` | `string` | - | Reconciliation key |
27+
28+
## Keyboard Behavior
29+
30+
- `Left/Right`: moves to previous/next page when available.
31+
- `Home/End`: jumps to first/last page when `showFirstLast` is enabled.
32+
- `Tab/Shift+Tab`: moves across focusable pagination controls.

docs/widgets/tabs.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Tabs
2+
3+
Switches between related views with a tab bar and one active content panel.
4+
5+
## Usage
6+
7+
```ts
8+
ui.tabs({
9+
id: "settings-tabs",
10+
tabs: [
11+
{ key: "general", label: "General", content: ui.text("General settings") },
12+
{ key: "security", label: "Security", content: ui.text("Security settings") },
13+
],
14+
activeTab: state.activeTab,
15+
onChange: (key) => app.update({ activeTab: key }),
16+
variant: "line",
17+
position: "top",
18+
})
19+
```
20+
21+
## Props
22+
23+
| Prop | Type | Default | Description |
24+
|---|---|---|---|
25+
| `id` | `string` | **required** | Stable tabs widget id |
26+
| `tabs` | `Array<{ key: string; label: string; content: VNode }>` | **required** | Tab descriptors |
27+
| `activeTab` | `string` | **required** | Active tab key |
28+
| `onChange` | `(key: string) => void` | **required** | Called when active tab changes |
29+
| `variant` | `"line" \| "enclosed" \| "pills"` | `"line"` | Tab label style variant |
30+
| `position` | `"top" \| "bottom"` | `"top"` | Tab bar position relative to content |
31+
| `key` | `string` | - | Reconciliation key |
32+
33+
## Keyboard Behavior
34+
35+
- `Left/Right`: switches tabs in the tab bar.
36+
- `Tab/Shift+Tab`: enters/leaves the content focus scope.
37+
- `Escape`: when focused in tab content, returns focus to the tab bar.

packages/core/src/app/widgetRenderer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,10 @@ function isRoutingRelevantKind(kind: WidgetKind): boolean {
316316
case "select":
317317
case "checkbox":
318318
case "radioGroup":
319+
case "tabs":
320+
case "accordion":
321+
case "breadcrumb":
322+
case "pagination":
319323
case "commandPalette":
320324
case "filePicker":
321325
case "fileTreeExplorer":
@@ -344,6 +348,10 @@ function isDamageGranularityKind(kind: WidgetKind): boolean {
344348
case "select":
345349
case "checkbox":
346350
case "radioGroup":
351+
case "tabs":
352+
case "accordion":
353+
case "breadcrumb":
354+
case "pagination":
347355
case "richText":
348356
case "badge":
349357
case "spinner":

packages/core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ export type {
197197
ProgressProps,
198198
ProgressVariant,
199199
RadioGroupProps,
200+
AccordionItem,
201+
AccordionProps,
202+
BreadcrumbItem,
203+
BreadcrumbProps,
204+
PaginationProps,
200205
RichTextProps,
201206
RichTextSpan,
202207
SelectOption,
@@ -217,6 +222,10 @@ export type {
217222
TableColumn,
218223
TableColumnOverflow,
219224
TableProps,
225+
TabsItem,
226+
TabsPosition,
227+
TabsProps,
228+
TabsVariant,
220229
TableStripeStyle,
221230
TextProps,
222231
TextVariant,

packages/core/src/layout/engine/layoutEngine.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { layoutBoxKinds, measureBoxKinds } from "../kinds/box.js";
2929
import { layoutCollections, measureCollections } from "../kinds/collections.js";
3030
import { layoutGridKinds, measureGridKinds } from "../kinds/grid.js";
3131
import { layoutLeafKind, measureLeaf } from "../kinds/leaf.js";
32+
import { layoutNavigationKinds, measureNavigationKinds } from "../kinds/navigation.js";
3233
import { layoutOverlays, measureOverlays } from "../kinds/overlays.js";
3334
import { layoutSplitPaneKinds, measureSplitPaneKinds } from "../kinds/splitPane.js";
3435
import { layoutStackKinds, measureStackKinds } from "../kinds/stack.js";
@@ -159,6 +160,13 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo
159160
computed = measureLeaf(vnode, maxW, maxH, axis);
160161
break;
161162
}
163+
case "tabs":
164+
case "accordion":
165+
case "breadcrumb":
166+
case "pagination": {
167+
computed = measureNavigationKinds(vnode, maxW, maxH, measureNode);
168+
break;
169+
}
162170

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

@@ -337,6 +345,12 @@ function layoutNode(
337345
case "field": {
338346
return layoutBoxKinds(vnode, x, y, rectW, rectH, axis, layoutNode);
339347
}
348+
case "tabs":
349+
case "accordion":
350+
case "breadcrumb":
351+
case "pagination": {
352+
return layoutNavigationKinds(vnode, x, y, rectW, rectH, layoutNode);
353+
}
340354

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { VNode } from "../../index.js";
2+
import { ok } from "../engine/result.js";
3+
import type { LayoutTree } from "../engine/types.js";
4+
import type { Axis, Size } from "../types.js";
5+
import type { LayoutResult } from "../validateProps.js";
6+
7+
type MeasureNodeFn = (vnode: VNode, maxW: number, maxH: number, axis: Axis) => LayoutResult<Size>;
8+
9+
type LayoutNodeFn = (
10+
vnode: VNode,
11+
x: number,
12+
y: number,
13+
maxW: number,
14+
maxH: number,
15+
axis: Axis,
16+
forcedW?: number | null,
17+
forcedH?: number | null,
18+
) => LayoutResult<LayoutTree>;
19+
20+
type NavigationVNode = Extract<
21+
VNode,
22+
| Readonly<{ kind: "tabs"; children: readonly VNode[] }>
23+
| Readonly<{ kind: "accordion"; children: readonly VNode[] }>
24+
| Readonly<{ kind: "breadcrumb"; children: readonly VNode[] }>
25+
| Readonly<{ kind: "pagination"; children: readonly VNode[] }>
26+
>;
27+
28+
function toSyntheticContainer(vnode: NavigationVNode): VNode {
29+
if (vnode.kind === "tabs" || vnode.kind === "accordion") {
30+
return { kind: "column", props: {}, children: vnode.children };
31+
}
32+
return { kind: "row", props: {}, children: vnode.children };
33+
}
34+
35+
function syntheticAxis(vnode: NavigationVNode): Axis {
36+
return vnode.kind === "tabs" || vnode.kind === "accordion" ? "column" : "row";
37+
}
38+
39+
export function measureNavigationKinds(
40+
vnode: VNode,
41+
maxW: number,
42+
maxH: number,
43+
measureNode: MeasureNodeFn,
44+
): LayoutResult<Size> {
45+
switch (vnode.kind) {
46+
case "tabs":
47+
case "accordion":
48+
case "breadcrumb":
49+
case "pagination": {
50+
const navVnode = vnode as NavigationVNode;
51+
const synthetic = toSyntheticContainer(navVnode);
52+
return measureNode(synthetic, maxW, maxH, syntheticAxis(navVnode));
53+
}
54+
default:
55+
return {
56+
ok: false,
57+
fatal: {
58+
code: "ZRUI_INVALID_PROPS",
59+
detail: "measureNavigationKinds: unexpected vnode kind",
60+
},
61+
};
62+
}
63+
}
64+
65+
export function layoutNavigationKinds(
66+
vnode: VNode,
67+
x: number,
68+
y: number,
69+
rectW: number,
70+
rectH: number,
71+
layoutNode: LayoutNodeFn,
72+
): LayoutResult<LayoutTree> {
73+
switch (vnode.kind) {
74+
case "tabs":
75+
case "accordion":
76+
case "breadcrumb":
77+
case "pagination": {
78+
const navVnode = vnode as NavigationVNode;
79+
const synthetic = toSyntheticContainer(navVnode);
80+
const axis = syntheticAxis(navVnode);
81+
const res = layoutNode(synthetic, x, y, rectW, rectH, axis, rectW, rectH);
82+
if (!res.ok) return res;
83+
return ok({
84+
vnode,
85+
rect: { x, y, w: rectW, h: rectH },
86+
children: res.value.children,
87+
});
88+
}
89+
default:
90+
return {
91+
ok: false,
92+
fatal: {
93+
code: "ZRUI_INVALID_PROPS",
94+
detail: "layoutNavigationKinds: unexpected vnode kind",
95+
},
96+
};
97+
}
98+
}

packages/core/src/renderer/renderToDrawlist/renderTree.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { renderCollectionWidget } from "./widgets/collections.js";
2626
import { renderContainerWidget } from "./widgets/containers.js";
2727
import { renderEditorWidget } from "./widgets/editors.js";
2828
import { renderFileWidgets } from "./widgets/files.js";
29+
import { renderNavigationWidget } from "./widgets/navigation.js";
2930
import { renderOverlayWidget } from "./widgets/overlays.js";
3031

3132
type RenderNodeTask = RuntimeInstance | null;
@@ -172,6 +173,26 @@ export function renderTree(
172173
break;
173174
}
174175

176+
// Navigation widgets
177+
case "tabs":
178+
case "accordion":
179+
case "breadcrumb":
180+
case "pagination": {
181+
renderNavigationWidget(
182+
builder,
183+
rect,
184+
parentStyle,
185+
node,
186+
layoutNode,
187+
nodeStack,
188+
styleStack,
189+
layoutStack,
190+
clipStack,
191+
currentClip,
192+
);
193+
break;
194+
}
195+
175196
// Basic widgets
176197
case "text":
177198
case "divider":

0 commit comments

Comments
 (0)