diff --git a/.changeset/11070-sidebar-side.md b/.changeset/11070-sidebar-side.md
new file mode 100644
index 0000000000..6752791b63
--- /dev/null
+++ b/.changeset/11070-sidebar-side.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/types': minor
+---
+
+The `sidebar` node declares `side` on both faces, so the strict authoring face accepts the edge the sidebar is drawn against (objectui#11070).
+
+`side` is `'left'` (the default) or `'right'`. These are the two values the `sidebar` registration offers, and the edge shadcn's `Sidebar` pins the collapsible form to. The node hands the key to shadcn through the props it forwards, so it always drew; only the declaration was missing. `@objectstack/spec` has no `sidebar` row to take it from, so it is declared locally.
+
+- **TypeScript.** `SidebarSchema` gains `side?: 'left' | 'right'`. A typed literal carrying `side: 'right'` used to be refused as an excess property, and now compiles.
+- **zod (`@object-ui/types/zod`).** WIDENS on the strict authoring face (`StrictAnyComponentSchema`): `side` used to be refused with `unrecognized_keys`, and now parses. NARROWS on the tolerant face (`safeValidateSchema`, which `objectui validate` runs): a `side` other than the two edges used to parse unjudged, and is now refused with `invalid_value` at `side`. Nothing changes for `left` or `right`.
+- **The retired `position` key.** It stays refused by name on every face. Its refusal now also says that `side: 'right'` draws the sidebar against the right edge.
+
+BREAKING (`@object-ui/types`), for a document whose `sidebar` node carries a `side` that is neither `left` nor `right`: it no longer validates. Write `left` or `right`. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)
+
+**Clause-②: yes (widening)**: `side` widens the strict face, and its two-value enum narrows the tolerant face.
diff --git a/content/docs/components/basic/sidebar.mdx b/content/docs/components/basic/sidebar.mdx
index 2b7c314016..b9c85c97b8 100644
--- a/content/docs/components/basic/sidebar.mdx
+++ b/content/docs/components/basic/sidebar.mdx
@@ -26,6 +26,22 @@ A group is a heading `text` node followed by its items. A `separator` node divid
+## The `side` key
+
+`side` picks the edge of the viewport the collapsible form is drawn against: `left`, the default, or `right`. On the right edge the sidebar's border moves to its left side, and on a narrow viewport the sheet slides in from the right.
+
+```json
+{
+ "type": "sidebar",
+ "side": "right",
+ "children": [
+ { "type": "text", "content": "Details", "className": "px-2 text-xs text-muted-foreground" }
+ ]
+}
+```
+
+`side` shows on the collapsible form only, as `variant` does. With `collapsible: false` the sidebar is drawn in the page flow, where the page's layout places it, so every value draws the same column.
+
## Schema
```ts
@@ -43,7 +59,7 @@ const sidebar: SidebarSchema = {
};
```
-The renderer reads `collapsible` and `children`, and passes `className` to the sidebar element.
+The renderer reads `collapsible` and `children`, and passes `side`, `variant` and `className` to the sidebar element.
## Examples
diff --git a/packages/components/src/renderers/__tests__/sidebar-side-registration-11070.test.tsx b/packages/components/src/renderers/__tests__/sidebar-side-registration-11070.test.tsx
new file mode 100644
index 0000000000..024b370432
--- /dev/null
+++ b/packages/components/src/renderers/__tests__/sidebar-side-registration-11070.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#11070 round 12 — `SidebarSchema.side` takes the `sidebar`
+ * registration's own enum, held against the LIVE registration rather than a
+ * copied list.
+ *
+ * Before this round the registration offered `side` (`left` / `right`) and
+ * shadcn's `Sidebar` drew by it, while neither `@object-ui/types` face declared
+ * it: the strict authoring face (`StrictAnyComponentSchema`) refused it with
+ * `unrecognized_keys`, so `objectui validate` on that face would have told an
+ * author to delete a key that works. This file reads the registration's `side`
+ * input and asks both validator faces about every value in it, so a
+ * registration that grows a value the schema does not take turns this red.
+ *
+ * The render half holds what the declaration says about the two values,
+ * measured through the real `SchemaRenderer` and registry. The node does not
+ * name `side`: it reaches shadcn's `Sidebar` through the props the node
+ * forwards. On the collapsible form the two values draw differently and `left`
+ * is the default; on the in-flow form (`collapsible: false`) shadcn ignores
+ * `side`, so every value draws the same column.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+import { SchemaRenderer } from '@object-ui/react';
+import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod';
+import '../../renderers';
+
+afterEach(cleanup);
+
+const ITEMS = [{ type: 'text', content: 'Details' }];
+
+type Parse = { success: boolean; error?: { issues: { path: PropertyKey[] }[] } };
+const strict = StrictAnyComponentSchema as unknown as { safeParse: (v: unknown) => Parse };
+const refusedAt = (result: Parse) => (result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.join('.')));
+
+/** The `side` enum the live `sidebar` registration offers. */
+function registeredSides(): string[] {
+ const inputs = (ComponentRegistry.getMeta('sidebar')?.inputs ?? []) as { name: string; enum?: unknown[] }[];
+ const side = inputs.find((i) => i.name === 'side');
+ if (!side || !Array.isArray(side.enum)) throw new Error('the `sidebar` registration offers no `side` enum');
+ return side.enum.map(String);
+}
+
+/** The markup a node draws in a bare host (the node mounts its own provider there). */
+const markup = (node: object) => {
+ const { container } = render();
+ const html = container.innerHTML;
+ cleanup();
+ return html;
+};
+
+describe('`SidebarSchema.side` is the registration\'s enum (objectui#11070)', () => {
+ it('the registration offers a non-empty enum', () => {
+ expect(registeredSides().length).toBeGreaterThan(0);
+ });
+
+ it('every value the registration offers validates on the strict face and the tolerant face', () => {
+ const refused = registeredSides().flatMap((side) => {
+ const node = { type: 'sidebar', side, children: ITEMS };
+ return [
+ ...refusedAt(strict.safeParse(node)).map((p) => `strict ${side} @${p}`),
+ ...refusedAt(safeValidateSchema(node) as Parse).map((p) => `tolerant ${side} @${p}`),
+ ];
+ });
+ expect(refused).toEqual([]);
+ });
+
+ it('a value outside the enum is refused at `side` on both faces', () => {
+ const node = { type: 'sidebar', side: 'top', children: ITEMS };
+ expect(refusedAt(strict.safeParse(node))).toEqual(['side']);
+ expect(refusedAt(safeValidateSchema(node) as Parse)).toEqual(['side']);
+ });
+});
+
+describe('what each side draws, through the real SchemaRenderer (objectui#11070)', () => {
+ it('on the collapsible form, every offered value draws differently, and `left` is the default', () => {
+ const base = { type: 'sidebar', children: ITEMS };
+ const drawn = registeredSides().map((side) => markup({ ...base, side }));
+ expect(new Set(drawn).size).toBe(drawn.length);
+ expect(markup({ ...base, side: 'left' })).toBe(markup(base));
+ // The difference is the edge shadcn pins the panel to: the outer element
+ // names the side, and the panel is positioned at that edge.
+ const right = markup({ ...base, side: 'right' });
+ expect(right).toContain('data-side="right"');
+ expect(right).toContain('right-0');
+ expect(markup(base)).toContain('data-side="left"');
+ });
+
+ it('on the in-flow form (`collapsible: false`), every value draws the same column', () => {
+ const base = { type: 'sidebar', collapsible: false, children: ITEMS };
+ const absent = markup(base);
+ for (const side of registeredSides()) {
+ expect(markup({ ...base, side }), side).toBe(absent);
+ }
+ });
+});
diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts
index c06e00c3ce..365bb127d9 100644
--- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts
+++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts
@@ -32,7 +32,13 @@
* `list-view`, `object-gantt`, `object-map`, `object-calendar` and, since
* round 7, `object-chart` — the spec's per-element binding, which each
* block's gate-wrapped registration reads off the node through
- * `ElementDataSourceGate`.
+ * `ElementDataSourceGate`;
+ * - since round 12, `sidebar.side` — the viewport edge shadcn's `Sidebar`
+ * is drawn against, which the `sidebar` node hands it through the props it
+ * forwards. `@objectstack/spec` has no `sidebar` row, so it is declared
+ * locally and flat, with the two values the registration offers; the
+ * registration and render half is
+ * `components/src/renderers/__tests__/sidebar-side-registration-11070.test.tsx`.
*
* Each read is reasoned on the TypeScript member that declares it.
*
@@ -82,7 +88,9 @@ import type {
UrlFieldMetadata,
} from '../field-types.js';
import type { DetailViewField } from '../views.js';
+import type { SidebarSchema as Ts_SidebarSchema } from '../navigation.js';
import { DetailViewFieldSchema } from '../zod/views.zod.js';
+import { SidebarSchema } from '../zod/navigation.zod.js';
import type { ElementDataSource as SpecElementDataSource } from '@objectstack/spec/ui';
import type { Field as SpecField } from '@objectstack/spec/data';
import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js';
@@ -213,6 +221,18 @@ describe('objectui#11070 — the declared read keys parse on the strict face', (
expect(undeclared(issuesOf(StrictAnyComponentSchema, { type, ...rest, dataSource: BINDING, dataSourc: BINDING })))
.toEqual(['dataSourc']);
});
+
+ // Round 12: the shipped strict face refused `side` by name at the round's
+ // base (`unrecognized_keys` at the root), so each row is a refusal that
+ // flipped. The tolerant face kept the value unjudged there and keeps it,
+ // judged, now.
+ it.each(['left', 'right'])('`sidebar.side: %s` parses on both faces and keeps its value; `sidee` beside it is refused by name', (side) => {
+ const node = { type: 'sidebar', side, children: [{ type: 'text', content: 'Menu' }] };
+ expect(issuesOf(StrictAnyComponentSchema, node)).toBeNull();
+ const parsed = AnyComponentSchema.safeParse(node);
+ expect(parsed.success && (parsed.data as { side?: unknown }).side).toBe(side);
+ expect(undeclared(issuesOf(StrictAnyComponentSchema, { ...node, sidee: side }))).toEqual(['sidee']);
+ });
});
/* ── 2. judged by the declared type, on both faces ───────────────────────── */
@@ -250,12 +270,23 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo
['a numeric `sortField`', form({ ...GRID_ENTRY, sortField: 0 })],
['a `null` `object-chart` binding (the adapter placeholder the wrapper no longer writes)', { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: null }],
['an adapter-shaped `object-chart` binding', { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: 'objectstack' }],
+ // Round 12: `sidebar.side` is one of the two edges. At the round's base the
+ // tolerant face kept any value unjudged, so each row is red there.
+ ['a `sidebar.side` that is not an edge shadcn draws (`top`)', { type: 'sidebar', side: 'top' }],
+ ['a boolean `sidebar.side`', { type: 'sidebar', side: true }],
];
it.each(WRONG)('refuses %s', (_label, doc) => {
expect(issuesOf(AnyComponentSchema, doc)).not.toBeNull();
expect(issuesOf(StrictAnyComponentSchema, doc)).not.toBeNull();
});
+
+ it('a wrong `sidebar.side` is refused AT `side`, by the value check (round 12)', () => {
+ for (const schema of [AnyComponentSchema, StrictAnyComponentSchema]) {
+ const issues = issuesOf(schema, { type: 'sidebar', side: 'top' }) ?? [];
+ expect(issues.map((i) => [i.path.map(String).join('.'), i.code])).toEqual([['side', 'invalid_value']]);
+ }
+ });
});
/* ── 3. the retired spellings stay refused ──────────────────────────────── */
@@ -333,6 +364,20 @@ describe('objectui#11070 — the retired spellings stay refused on the strict fa
expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toContain(`widgets.0.${key}`);
});
+ // Round 12: objectui#11465 retired `position` with "delete the key" and
+ // deliberately did not point at `side`, which was undeclared then. With
+ // `side` declared, the refusal names it for an author who meant the right
+ // edge; the key itself stays retired on every face.
+ it('the retired `sidebar.position` stays refused by name, and its refusal names `side` (round 12)', () => {
+ const doc = { type: 'sidebar', position: 'right' };
+ for (const schema of [AnyComponentSchema, StrictAnyComponentSchema]) {
+ expect((issuesOf(schema, doc) ?? []).map((i) => [i.path.map(String).join('.'), i.code])).toEqual([['position', 'invalid_type']]);
+ }
+ const [issue] = (SidebarSchema.safeParse(doc).error?.issues ?? []) as unknown as { code: string; message: string }[];
+ expect(issue?.code).toBe('invalid_type');
+ expect(issue?.message).toContain("side: 'right'");
+ });
+
it('`object-chart.dataSource` stays on the NODE: in the bag it is refused by name, not taken for the binding (round 7)', () => {
// The binding is a node-level key of the spec's `PageComponentSchema`, so
// the bag (the flat mirror's own members) does not carry it.
@@ -471,6 +516,19 @@ export type assertionReferenceIsTheOnlyTargetSpelling = [
Expect, never>>,
];
export type assertionShowSubmitIsBoolean = Expect>;
+/**
+ * Round 12: `sidebar.side` is the two edges on BOTH faces — an exact match, so
+ * a widened or narrowed value on either face fails here, and a member deleted
+ * from either face fails to compile.
+ */
+type SidebarSideInput = z.input<(typeof SidebarSchema)['shape']['side']>;
+export type assertionSidebarSideIsTheTwoEdges = [
+ Expect>,
+ Expect>,
+];
+// @ts-expect-error objectui#11070 round 12 — `top` is not an edge the sidebar is drawn against.
+export const sidebarSideTop: Ts_SidebarSchema = { type: 'sidebar', side: 'top' };
+export const sidebarSideRight: Ts_SidebarSchema = { type: 'sidebar', side: 'right' };
/**
* Each binding member IS the spec's `ElementDataSource` — an exact match, not a
* mere "not `any`": `ListViewSchema` is derived from its zod mirror, and a member
diff --git a/packages/types/src/navigation.ts b/packages/types/src/navigation.ts
index b01a493295..ea91bf5c97 100644
--- a/packages/types/src/navigation.ts
+++ b/packages/types/src/navigation.ts
@@ -233,9 +233,10 @@ export interface HeaderBarSchema extends BaseSchema {
/**
* Sidebar component
*
- * The node draws its `children` inside one shadcn sidebar region, and reads
- * `collapsible` and `variant`. It composes what it draws through `children`;
- * an app's navigation lives in the app's metadata, not on this node.
+ * The node draws its `children` inside one shadcn sidebar region. It reads
+ * `collapsible`, and hands `side` and `variant` to shadcn's `Sidebar` through
+ * the props it forwards. It composes what it draws through `children`; an
+ * app's navigation lives in the app's metadata, not on this node.
*
* Nine keys this declaration used to offer had no reader and are RETIRED
* (objectui#11465, ADR-0049): `title`, `nav`, `content`, `footer`,
@@ -278,7 +279,8 @@ export interface SidebarSchema extends BaseSchema {
footer?: never;
/**
* RETIRED (objectui#11465, ADR-0049) — every value drew the sidebar at the
- * same edge. Delete the key.
+ * same edge. Delete the key. To draw the sidebar against the right edge,
+ * write `side: 'right'` (objectui#11070).
* @deprecated Nothing reads it; the zod mirror refuses it by name.
*/
position?: never;
@@ -334,6 +336,21 @@ export interface SidebarSchema extends BaseSchema {
* @default 'sidebar'
*/
variant?: 'sidebar' | 'floating' | 'inset';
+ /**
+ * The viewport edge the sidebar is drawn against — shadcn's two, the values
+ * the `sidebar` registration offers (objectui#11070). `left` pins the panel
+ * to the left edge with its border on its right; `right` pins it to the
+ * right edge with its border on its left, and on a narrow viewport the
+ * sheet slides in from the right. It shows on the collapsible form only:
+ * with `collapsible: false` the node draws an in-flow column, placed by the
+ * page's layout, and every value draws the same.
+ *
+ * Declared here, flat: `@objectstack/spec` has no `sidebar` row to take it
+ * from (`pnpm check:component-surface-parity --type ui:sidebar` prints the
+ * spec entry it reads).
+ * @default 'left'
+ */
+ side?: 'left' | 'right';
}
/**
diff --git a/packages/types/src/zod/navigation.zod.ts b/packages/types/src/zod/navigation.zod.ts
index 33d5243d59..75cd14caa2 100644
--- a/packages/types/src/zod/navigation.zod.ts
+++ b/packages/types/src/zod/navigation.zod.ts
@@ -168,11 +168,12 @@ const SIDEBAR_OPEN_STATE_OWNER =
/**
* Sidebar Schema - Sidebar component
*
- * The node draws its `children` inside one shadcn sidebar region, and reads
- * `collapsible` (objectui#10859) and `variant`. Nine keys this mirror used to
- * declare had no reader and are refused by name (objectui#11465): the node
- * composes what it draws through `children`, and an app's navigation lives in
- * its metadata, not on this node.
+ * The node draws its `children` inside one shadcn sidebar region. It reads
+ * `collapsible` (objectui#10859), and hands `side` (objectui#11070) and
+ * `variant` to shadcn's `Sidebar` through the props it forwards. Nine keys
+ * this mirror used to declare had no reader and are refused by name
+ * (objectui#11465): the node composes what it draws through `children`, and
+ * an app's navigation lives in its metadata, not on this node.
*/
export const SidebarSchema = BaseSchema.extend({
type: z.literal('sidebar'),
@@ -200,7 +201,7 @@ export const SidebarSchema = BaseSchema.extend({
position: retiredSidebarKey(
'position',
'every value drew the sidebar at the same edge; the value reached the sidebar panel only as an inert HTML attribute.',
- 'delete the key.',
+ 'delete the key. To draw the sidebar against the right edge, write `side: \'right\'` (objectui#11070).',
),
collapsible: z.boolean().optional().describe('Whether sidebar is collapsible'),
defaultCollapsed: retiredSidebarKey('defaultCollapsed', SIDEBAR_OPEN_STATE_OWNER, 'delete the key.'),
@@ -225,6 +226,16 @@ export const SidebarSchema = BaseSchema.extend({
"Sidebar variant: 'sidebar' (default), 'floating' or 'inset'. It shows on the collapsible form only; with "
+ "collapsible: false every value draws the same in-flow column. 'default' and 'bordered' are retired (objectui#11465)",
),
+ // Declared locally and flat: `@objectstack/spec` has no `sidebar` row to take
+ // it from. The values are the two the `sidebar` registration offers and
+ // shadcn's `Sidebar` draws (objectui#11070).
+ side: z.enum(['left', 'right'], {
+ error: '`side` on `sidebar` is `\'left\'` (the default) or `\'right\'`: the two viewport edges shadcn\'s '
+ + 'sidebar is drawn against, the values its registration offers (objectui#11070).',
+ }).optional().describe(
+ "Viewport edge the sidebar is drawn against: 'left' (default) or 'right'. It shows on the collapsible form "
+ + 'only; with collapsible: false every value draws the same in-flow column (objectui#11070)',
+ ),
});
/**