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
22 changes: 22 additions & 0 deletions docs/widgets/command-palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ ui.commandPalette({
id: "palette",
open: state.open,
query: state.query,
placeholder: "Search files and commands...",
maxVisible: 8,
loading: state.commandsLoading,
sources: [
{
id: "cmd",
Expand All @@ -25,9 +28,28 @@ ui.commandPalette({
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Widget identifier. |
| `open` | `boolean` | **required** | Whether the palette is visible. |
| `query` | `string` | **required** | Current search query. |
| `sources` | `CommandSource[]` | **required** | Command providers used to produce results. |
| `selectedIndex` | `number` | **required** | Currently highlighted result index. |
| `loading` | `boolean` | `false` | Shows a loading indicator in the input area. Async `getItems` fetches also set loading internally while pending. |
| `placeholder` | `string` | `"Search commands..."` | Text shown when `query` is empty. Keep it short so the input stays readable on narrow terminals. |
| `maxVisible` | `number` | `10` | Maximum visible result rows. Useful for capping palette height in smaller viewports. |
| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for palette background, text, and border. |
| `onQueryChange` | `(query) => void` | **required** | Called when the query changes. |
| `onSelect` | `(item) => void` | **required** | Called when a result item is selected. |
| `onClose` | `() => void` | **required** | Called when the palette should close. |
| `onSelectionChange` | `(index) => void` | - | Called when highlighted index changes. |

## Notes

- Command sources can be sync or async (`getItems` may return a `Promise`).
- Keep `query` and `selectedIndex` in your app state (controlled pattern).
- `maxVisible` accepts non-negative values; `0` hides the results list while keeping the palette input visible.

Next: [`Code editor`](code-editor.md).
12 changes: 12 additions & 0 deletions docs/widgets/dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ ui.dropdown({
})
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique dropdown identifier |
| `anchorId` | `string` | **required** | ID of anchor widget used for positioning |
| `position` | `DropdownPosition` | `"below-start"` | Placement relative to anchor |
| `items` | `DropdownItem[]` | **required** | Menu rows (including optional dividers) |
| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for menu background, text, and border |
| `onSelect` | `(item) => void` | - | Called when a selectable item is activated |
| `onClose` | `() => void` | - | Called when dropdown should close |

## Behavior

- **Arrow keys** navigate items. **Enter** selects the highlighted item.
Expand Down
1 change: 1 addition & 0 deletions docs/widgets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Interactive form controls:
|--------|-------------|-----------|
| [Button](button.md) | Clickable button with label | Yes |
| [Input](input.md) | Single-line text input | Yes |
| [Slider](slider.md) | Numeric range input | Yes |
| [Checkbox](checkbox.md) | Toggle checkbox | Yes |
| [Radio Group](radio-group.md) | Single-select options | Yes |
| [Select](select.md) | Dropdown selection | Yes |
Expand Down
1 change: 1 addition & 0 deletions docs/widgets/layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ ui.layer({
|------|------|---------|-------------|
| `id` | `string` | **required** | Layer identifier |
| `zIndex` | `number` | insertion order | Higher values render on top (clamped for deterministic ordering) |
| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional layer surface/frame colors (background fill, inherited text color, and border) |
| `backdrop` | `"none" \| "dim" \| "opaque"` | `"none"` | Backdrop behind the layer |
| `modal` | `boolean` | `false` | Block input to lower layers |
| `closeOnEscape` | `boolean` | `true` | Close on Escape key |
Expand Down
1 change: 1 addition & 0 deletions docs/widgets/layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ui.layers([

- Child order defines z-order: later children render on top.
- Use [`Layer`](layer.md) for explicit z-index or custom backdrops.
- Overlay primitives (`Modal`, `Layer`, `Dropdown`, `CommandPalette`, `Toast`) share `frameStyle` for consistent background/foreground/border customization.
- Layers enable deterministic hit testing and focus management across overlaps.
- Backdrops (from [`Modal`](modal.md) and [`Layer`](layer.md)) render behind the active overlay(s) and stack in draw order.

Expand Down
4 changes: 3 additions & 1 deletion docs/widgets/modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ ui.layers([
| `actions` | `VNode[]` | `[]` | Action row (typically buttons) |
| `width` | `number \| "auto"` | `~70%` | Preferred modal width |
| `maxWidth` | `number` | - | Maximum width constraint |
| `backdrop` | `"none" \| "dim" \| "opaque"` | `"dim"` | Backdrop style |
| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional modal frame/surface colors |
| `backdrop` | `"none" \| "dim" \| "opaque" \| { variant?, pattern?, foreground?, background? }` | `"dim"` | Backdrop preset or extended config |
| `closeOnBackdrop` | `boolean` | `true` | Close when clicking backdrop |
| `closeOnEscape` | `boolean` | `true` | Close on `Esc` |
| `onClose` | `() => void` | - | Callback when modal requests close |
Expand Down Expand Up @@ -68,6 +69,7 @@ ui.modal({
- Modals are rendered by conditionally including them in the tree (there is no `open` prop).
- Render modals inside `ui.layers(...)` so they stack above base content.
- Backdrops are rendered behind the modal. `"dim"` uses a light shade pattern; `"opaque"` clears the area behind the modal to the theme background color.
- Extended backdrop config uses object form: `variant` (preset), `pattern` (dim glyph), and optional `foreground`/`background` colors.
- `width: "auto"` sizes to content/actions and is clamped by `maxWidth` and the viewport.

## Related
Expand Down
51 changes: 51 additions & 0 deletions docs/widgets/slider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Slider

A focusable range input for numeric values.

## Usage

```typescript
import { ui } from "@rezi-ui/core";

ui.slider({
id: "volume",
value: state.volume,
min: 0,
max: 100,
step: 5,
onChange: (value) => app.update((s) => ({ ...s, volume: value })),
});
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique identifier for focus and routing |
| `value` | `number` | **required** | Current value |
| `min` | `number` | `0` | Minimum value |
| `max` | `number` | `100` | Maximum value |
| `step` | `number` | `1` | Keyboard increment/decrement step |
| `width` | `number` | auto | Track width in cells |
| `label` | `string` | - | Optional label before the slider |
| `showValue` | `boolean` | `true` | Show numeric value text |
| `onChange` | `(value: number) => void` | - | Called when value changes |
| `disabled` | `boolean` | `false` | Disable focus and interaction |
| `readOnly` | `boolean` | `false` | Keep focusable, but block value changes |
| `style` | `TextStyle` | - | Optional style override |
| `key` | `string` | - | Reconciliation key |

## Behavior

- Focusable when enabled.
- **Left/Down** decreases by `step`.
- **Right/Up** increases by `step`.
- **PageDown/PageUp** changes by 10 steps.
- **Home/End** jumps to min/max.
- `readOnly` sliders still receive focus but do not emit `onChange`.

## Related

- [Input](input.md) - Free-form text input
- [Select](select.md) - Discrete option selection
- [Field](field.md) - Labels, hints, and error display
17 changes: 13 additions & 4 deletions docs/widgets/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ Renders tabular data with column definitions, optional sorting, and row selectio
ui.table({
id: "users",
columns: [
{ key: "name", header: "Name", flex: 1, sortable: true },
{ key: "name", header: "Name", flex: 1, sortable: true, overflow: "middle" },
{ key: "role", header: "Role", width: 12 },
],
data: state.users,
getRowKey: (u) => u.id,
selection: state.selection,
selectionMode: "multi",
onSelectionChange: (keys) => app.update((s) => ({ ...s, selection: keys })),
stripeStyle: { odd: { r: 34, g: 37, b: 45 } },
borderStyle: { variant: "double", color: { r: 120, g: 130, b: 145 } },
})
```

Expand All @@ -24,9 +26,11 @@ ui.table({
| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique identifier for focus and events |
| `columns` | `TableColumn[]` | **required** | Column definitions (`key`, `header`, width/flex, sortability, renderers) |
| `columns` | `TableColumn[]` | **required** | Column definitions (`key`, `header`, width/flex, sortability, renderers, `overflow`) |
| `data` | `T[]` | **required** | Row data |
| `getRowKey` | `(row: T, index: number) => string` | **required** | Stable key for each row |
| `rowHeight` | `number` | `1` | Row height in cells. Use positive values for predictable keyboard and mouse navigation. |
| `headerHeight` | `number` | `1` | Header row height in cells when `showHeader` is `true`. |
| `selection` | `string[]` | `[]` | Currently selected row keys |
| `selectionMode` | `"none" \| "single" \| "multi"` | `"none"` | Selection behavior |
| `onSelectionChange` | `(keys: string[]) => void` | - | Called when selection changes |
Expand All @@ -38,8 +42,10 @@ ui.table({
| `virtualized` | `boolean` | `true` | Enable windowed rendering for large datasets |
| `overscan` | `number` | `3` | Extra rows rendered outside viewport |
| `showHeader` | `boolean` | `true` | Show/hide header row |
| `stripedRows` | `boolean` | `false` | Alternate row background styling |
| `border` | `"none" \| "single"` | `"none"` | Optional border rendering |
| `stripedRows` | `boolean` | `false` | Legacy stripe toggle (kept for compatibility) |
| `stripeStyle` | `{ odd?, even? }` | - | Stripe background colors. Providing this enables stripes even when `stripedRows` is `false`. |
| `border` | `"none" \| "single"` | `"none"` | Legacy border toggle (kept for compatibility) |
| `borderStyle` | `{ variant?, color? }` | - | Border glyph variant (`single`, `double`, `rounded`, `heavy`, `dashed`, `heavy-dashed`) and optional border color |

## Examples

Expand Down Expand Up @@ -73,6 +79,9 @@ ui.table({

- Tables can be virtualized; prefer virtualization for large datasets.
- Selection is tracked by row keys. Provide a stable `getRowKey`.
- `headerHeight` is ignored when `showHeader` is `false`.
- Column `overflow` defaults to `"ellipsis"` and supports `"clip"` and `"middle"` per column.
- `borderStyle` is applied only when `border !== "none"` to preserve legacy behavior.

## Related

Expand Down
1 change: 1 addition & 0 deletions docs/widgets/toast.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ui.toastContainer({
| `toasts` | `Toast[]` | **required** | Active toasts |
| `position` | `ToastPosition` | `"bottom-right"` | Container position |
| `maxVisible` | `number` | `5` | Max visible toasts |
| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for toast background, message text, and border |
| `onDismiss` | `(id) => void` | **required** | Dismiss callback |

## Mouse Behavior
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"url": "https://github.com/RtlZeroMemory/Rezi/issues"
},
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json --pretty false",
"test": "node --test --test-concurrency=1 $(find dist -type f -path '*/__tests__/*.test.js' | LC_ALL=C sort)"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/app/__tests__/widgetRenderer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,53 @@ describe("WidgetRenderer integration battery", () => {
assert.equal(renderer.getFocusedId(), "z3");
});

test("focusZone grid navigation moves by columns deterministically", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
requestRender: () => {},
});

const vnode = ui.column({}, [
ui.focusZone({ id: "grid", navigation: "grid", columns: 3, wrapAround: false }, [
ui.button({ id: "g1", label: "G1" }),
ui.button({ id: "g2", label: "G2" }),
ui.button({ id: "g3", label: "G3" }),
ui.button({ id: "g4", label: "G4" }),
ui.button({ id: "g5", label: "G5" }),
ui.button({ id: "g6", label: "G6" }),
]),
]);

const res = renderer.submitFrame(
() => vnode,
undefined,
{ cols: 60, rows: 10 },
defaultTheme,
noRenderHooks(),
);
assert.ok(res.ok);
assert.equal(renderer.getFocusedId(), null);

renderer.routeEngineEvent(keyEvent(3 /* TAB */));
assert.equal(renderer.getFocusedId(), "g1");

renderer.routeEngineEvent(keyEvent(21 /* DOWN */));
assert.equal(renderer.getFocusedId(), "g4");

renderer.routeEngineEvent(keyEvent(21 /* DOWN */));
assert.equal(renderer.getFocusedId(), "g4");

renderer.routeEngineEvent(keyEvent(23 /* RIGHT */));
assert.equal(renderer.getFocusedId(), "g5");

renderer.routeEngineEvent(keyEvent(20 /* UP */));
assert.equal(renderer.getFocusedId(), "g2");

renderer.routeEngineEvent(keyEvent(22 /* LEFT */));
assert.equal(renderer.getFocusedId(), "g1");
});

test("focusZone onEnter/onExit fire on zone transitions", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
Expand Down Expand Up @@ -468,6 +515,68 @@ describe("WidgetRenderer integration battery", () => {
assert.deepEqual(activated, ["t0"]);
});

test("slider routing clamps values and respects disabled/readOnly focus semantics", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
requestRender: () => {},
});

const changes: number[] = [];

const vnode = ui.column({}, [
ui.slider({
id: "s-enabled",
value: 9,
min: 0,
max: 10,
step: 3,
onChange: (value) => changes.push(value),
}),
ui.slider({
id: "s-disabled",
value: 4,
min: 0,
max: 10,
disabled: true,
onChange: (value) => changes.push(value),
}),
ui.slider({
id: "s-readonly",
value: 4,
min: 0,
max: 10,
readOnly: true,
onChange: (value) => changes.push(value),
}),
]);

const res = renderer.submitFrame(
() => vnode,
undefined,
{ cols: 50, rows: 10 },
defaultTheme,
noRenderHooks(),
);
assert.ok(res.ok);
assert.equal(renderer.getFocusedId(), null);

renderer.routeEngineEvent(keyEvent(3 /* TAB */));
assert.equal(renderer.getFocusedId(), "s-enabled");

renderer.routeEngineEvent(keyEvent(14 /* PAGE_UP */));
renderer.routeEngineEvent(keyEvent(12 /* HOME */));
assert.deepEqual(changes, [10, 0]);

renderer.routeEngineEvent(keyEvent(3 /* TAB */));
assert.equal(renderer.getFocusedId(), "s-readonly");
renderer.routeEngineEvent(keyEvent(23 /* RIGHT */));
assert.deepEqual(changes, [10, 0]);

renderer.routeEngineEvent(keyEvent(3 /* TAB */));
assert.equal(renderer.getFocusedId(), "s-enabled");
});

test("dropdown mouse click selects item and closes", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
Expand Down
Loading
Loading