diff --git a/.changeset/page-tabs-item-visible-when.md b/.changeset/page-tabs-item-visible-when.md new file mode 100644 index 0000000000..29566832f5 --- /dev/null +++ b/.changeset/page-tabs-item-visible-when.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": minor +--- + +Conditional tabs (#2606): `page:tabs` items accept an optional `visibleWhen` CEL predicate. When it evaluates FALSE the whole tab — header **and** panel — is omitted from the tab strip, unlike a child component's own `visibleWhen`, which hides only the panel content and leaves an empty tab header behind. The predicate binds the same environment as page-component `visibleWhen` (`record` + `current_user`, plus page state as `page.`) and is re-evaluated live when page variables change. + +Per ADR-0089 the key uses the canonical `*When` name from day one — the deprecated `visibility` / `visibleOn` aliases are **not** accepted on tab items (this surface is new; there is no legacy metadata to alias for). + +Additive and back-compatible: items without `visibleWhen` behave exactly as before. diff --git a/packages/dogfood/test/expression-conformance.ledger.ts b/packages/dogfood/test/expression-conformance.ledger.ts index 9832bf765a..0fc9357a8c 100644 --- a/packages/dogfood/test/expression-conformance.ledger.ts +++ b/packages/dogfood/test/expression-conformance.ledger.ts @@ -125,6 +125,11 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'ui/view.zod.ts:visibleWhen', 'ui/view.zod.ts:visibleOn', 'ui/component.zod.ts:onSubmit', + // Conditional tabs (framework#2606): `page:tabs` item-level visibility. + // Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on + // this new surface). Interpreted by the objectui page:tabs renderer to + // omit the whole tab (header + panel) when FALSE. + 'ui/component.zod.ts:visibleWhen', 'system/settings-manifest.zod.ts:visible', ], }, diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a2adbde26e..a4e9ccdecb 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -75,6 +75,46 @@ describe('PageTabsProps', () => { it('should reject tabs without items', () => { expect(() => PageTabsProps.parse({})).toThrow(); }); + + // Conditional tabs (#2606) — item-level `visibleWhen` (ADR-0089 canonical name). + it('should accept an item-level visibleWhen predicate (bare CEL string → envelope)', () => { + const result = PageTabsProps.parse({ + items: [ + { label: 'Contracts', visibleWhen: 'record.status == "customer"', children: [] }, + { label: 'Details', children: [] }, + ], + }); + expect(result.items[0].visibleWhen).toEqual({ + dialect: 'cel', + source: 'record.status == "customer"', + }); + // Items without the predicate are untouched — additive, back-compatible. + expect(result.items[1].visibleWhen).toBeUndefined(); + }); + + it('should accept an item-level visibleWhen Expression envelope', () => { + const result = PageTabsProps.parse({ + items: [ + { + label: 'Contracts', + visibleWhen: { dialect: 'cel', source: "page.mode != ''" }, + children: [], + }, + ], + }); + expect(result.items[0].visibleWhen).toEqual({ dialect: 'cel', source: "page.mode != ''" }); + }); + + it('does NOT accept the deprecated `visibility` alias on tab items (new surface, canonical key only)', () => { + // ADR-0089 D2 aliases exist for keys with legacy metadata; tab items never + // had a visibility key, so only canonical `visibleWhen` is declared. The + // alias is not folded — it is dropped by parse like any unknown key. + const result = PageTabsProps.parse({ + items: [{ label: 'Contracts', visibility: 'record.status == "customer"', children: [] }], + }); + expect(result.items[0].visibleWhen).toBeUndefined(); + expect('visibility' in result.items[0]).toBe(false); + }); }); describe('PageCardProps', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index b2d05780fd..8233c5e550 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -35,6 +35,19 @@ export const PageTabsProps = z.object({ items: z.array(z.object({ label: I18nLabelSchema, icon: z.string().optional(), + /** + * Conditional tab (CEL, #2606): when the predicate evaluates FALSE the + * whole tab — header *and* panel — is omitted from the strip. This is the + * item-level complement to a child component's own `visibleWhen`, which + * hides only the panel content and would leave an empty tab header behind. + * Binds the same environment as page-component `visibleWhen`: `record` + + * `current_user`, plus page state as `page.` (re-evaluated live). + * Canonical `*When` name per ADR-0089 — this key is new, so the deprecated + * `visibility` / `visibleOn` aliases are NOT accepted on tab items. + */ + visibleWhen: ExpressionInputSchema.optional().describe( + 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.`. ADR-0089 canonical name (`visibility`/`visibleOn` aliases are not accepted here).', + ), children: z.array(z.unknown()).describe('Child components') })), /** ARIA accessibility */