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)', + ), }); /**