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
15 changes: 15 additions & 0 deletions .changeset/11070-sidebar-side.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 17 additions & 1 deletion content/docs/components/basic/sidebar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ A group is a heading `text` node followed by its items. A `separator` node divid

<SchemaExample id="components-basic-sidebar/collapsible-sidebar" />

## 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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<SchemaRenderer schema={node as never} />);
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);
}
});
});
60 changes: 59 additions & 1 deletion packages/types/src/__tests__/strict-face-read-keys-11070.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 ───────────────────────── */
Expand Down Expand Up @@ -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 ──────────────────────────────── */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -471,6 +516,19 @@ export type assertionReferenceIsTheOnlyTargetSpelling = [
Expect<Equal<Extract<keyof DetailViewField, 'reference_to'>, never>>,
];
export type assertionShowSubmitIsBoolean = Expect<Equal<FormSchema['showSubmit'], boolean | undefined>>;
/**
* 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<Equal<Ts_SidebarSchema['side'], 'left' | 'right' | undefined>>,
Expect<Equal<SidebarSideInput, 'left' | 'right' | undefined>>,
];
// @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
Expand Down
25 changes: 21 additions & 4 deletions packages/types/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
}

/**
Expand Down
23 changes: 17 additions & 6 deletions packages/types/src/zod/navigation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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.'),
Expand All @@ -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)',
),
});

/**
Expand Down
Loading