diff --git a/.changeset/20694-object-grid-description-empty-state-keyboard-navigation.md b/.changeset/20694-object-grid-description-empty-state-keyboard-navigation.md new file mode 100644 index 00000000000..7ab8dd707c6 --- /dev/null +++ b/.changeset/20694-object-grid-description-empty-state-keyboard-navigation.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +The `object-grid` page block now declares `description`, `emptyState` and `keyboardNavigation`, so a page that authors them validates clean instead of having each reported as a prop the block does not declare (#20694). + +Clause-②: yes (widening) + +- **`description`** — an `I18nLabel` (a string, or an inline locale map): one line of help text the grid draws above its rows, resolved against the display locale. +- **`emptyState`** — `{ title?, message?, icon? }`, what the grid draws instead of an empty table. It is the list view's own empty-state shape, now exported as `EmptyStateSchema` (author type `EmptyState`) and taken by reference on both `list-view` and `object-grid`, so the two cannot drift apart. +- **`keyboardNavigation`** — a boolean, marked `[EXPERIMENTAL — not enforced]`: arrow-key cell navigation on the WAI-ARIA grid pattern, on by default when `editable` is set. No renderer reads it yet, so authoring it changes nothing today. + +Nothing that parsed before is refused now. The `object-grid` row still refuses every key it does not declare, and the list view's `emptyState` accepts exactly the values it accepted before the shape was extracted. One refusal message is reworded: an `action` or `button` key inside an empty state is still refused and still points at the list view's `addRecord` block, now phrased so that it also reads true on `object-grid`, which has no add-record block. + +On `object-grid`, write `emptyState.title` and `emptyState.message` as plain strings for now: the grid renderer draws both as they are and does not yet resolve an inline locale map there the way it resolves `description`, so a map in either member fails to render. + +Where it surfaces: the component-props gate (`os validate`, `os build`, `os lint`) no longer reports these three keys on an `object-grid` node, and the published JSON Schema for `ObjectGridProps` describes them. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 77bade4a181..22f03c81d2c 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1524 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1525 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1524** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **197** | **1525** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 165 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 166 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -385,7 +385,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4792af4814f..c68178e7273 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -693,6 +693,8 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **label** | `string \| Record` | optional | Grid label — used as the table caption and export file title | | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | +| **description** | `string \| Record` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale | +| **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | @@ -718,6 +720,7 @@ Sort field and direction pair | **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema | | **editable** | `boolean` | optional | Enable inline cell editing | | **singleClickEdit** | `boolean` | optional | Enter cell edit on single click (default true when editable) | +| **keyboardNavigation** | `boolean` | optional | [EXPERIMENTAL — not enforced] Arrow-key cell navigation on the WAI-ARIA grid pattern. Defaults to on when `editable` is set; a read-only grid keeps its Tab behaviour unless this is `true`. No renderer reads it yet: it is declared ahead of the grid's keyboard-navigation build, so authoring it changes nothing today | | **resizable** | `boolean` | optional | Allow column resize (read before `resizableColumns`) | | **resizableColumns** | `boolean` | optional | Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`) | | **reorderableColumns** | `boolean` | optional | Allow column drag-reorder | @@ -728,6 +731,14 @@ Sort field and direction pair | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | +### Nested Shape: `ObjectGridProps.emptyState` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string \| Record` | optional | Heading of the empty state | +| **message** | `string \| Record` | optional | Line of text below the heading | +| **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | + ### Nested Shape: `ObjectGridProps.filter[number]` View filter rule diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 18a962ee89c..92428e28528 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -59,8 +59,8 @@ nothing. The one item-name grammar itself lives in ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -160,6 +160,19 @@ Column footer summary configuration | **field** | `string` | optional | Field to aggregate (defaults to the column field) | +--- + +## EmptyState + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string \| Record` | optional | Heading of the empty state | +| **message** | `string \| Record` | optional | Line of text below the heading | +| **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | + + --- ## FormButtonConfig @@ -1128,9 +1141,9 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **message** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **icon** | `string` | optional | | +| **title** | `string \| Record` | optional | Heading of the empty state | +| **message** | `string \| Record` | optional | Line of text below the heading | +| **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | ### Nested Shape: `ListView.aria` @@ -1511,9 +1524,9 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **message** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **icon** | `string` | optional | | +| **title** | `string \| Record` | optional | Heading of the empty state | +| **message** | `string \| Record` | optional | Line of text below the heading | +| **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | ### Nested Shape: `ObjectListView.aria` diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index fc7584b8749..f9afa5ab724 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -175,6 +175,8 @@ "ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", + "EmptyState (type)", + "EmptyStateSchema (const)", "ExpandViewResult (interface)", "ExpandedViewItem (interface)", "ExpressionBindableTextKey (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7cde538aa43..f3a0fe77801 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -515,6 +515,9 @@ "ui/ElementTextProps:aria", "ui/ElementTextProps:content", "ui/ElementTextProps:variant", + "ui/EmptyState:icon", + "ui/EmptyState:message", + "ui/EmptyState:title", "ui/FormButtonConfig:label", "ui/FormButtonConfig:show", "ui/FormField:colSpan", @@ -870,12 +873,15 @@ "ui/ObjectGridProps:data", "ui/ObjectGridProps:defaultFilters", "ui/ObjectGridProps:defaultSort [RETIRED]", + "ui/ObjectGridProps:description", "ui/ObjectGridProps:editable", + "ui/ObjectGridProps:emptyState", "ui/ObjectGridProps:exportOptions", "ui/ObjectGridProps:fields", "ui/ObjectGridProps:filter", "ui/ObjectGridProps:frozenColumns", "ui/ObjectGridProps:grouping", + "ui/ObjectGridProps:keyboardNavigation", "ui/ObjectGridProps:label", "ui/ObjectGridProps:navigation", "ui/ObjectGridProps:objectName", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 31ffbe0df1b..7477d4f958c 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -114,6 +114,8 @@ "ElementRepeaterPropsSchema": "ui/ElementRepeaterProps", "ElementTextInputPropsSchema": "ui/ElementTextInputProps", "ElementTextPropsSchema": "ui/ElementTextProps", + "EmptyState": "ui/EmptyState", + "EmptyStateSchema": "ui/EmptyState", "ExpressionBindableTextKey": "ui/ExpressionBindableTextKey", "ExpressionBindableTextKeySchema": "ui/ExpressionBindableTextKey", "FormButtonConfig": "ui/FormButtonConfig", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index cd0bd4be19f..39d74176be4 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -171,6 +171,8 @@ "ElementRepeaterPropsSchema": "src/ui/component.zod.ts#ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)", "ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)", + "EmptyState": "src/ui/view.zod.ts#EmptyState (type)", + "EmptyStateSchema": "src/ui/view.zod.ts#EmptyStateSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", "ExpandedViewItem": "src/ui/view.zod.ts#ExpandedViewItem (interface)", "ExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#ExpressionBindableTextKey (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index d3c58865bf9..cf577ded7ec 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -63,6 +63,7 @@ "ui/ElementRepeaterProps", "ui/ElementTextInputProps", "ui/ElementTextProps", + "ui/EmptyState", "ui/ExpressionBindableTextKey", "ui/FormButtonConfig", "ui/FormField", diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 068c6a76fe9..b40846b6c23 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 779 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 780 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1583,6 +1583,9 @@ export type Iso_ui_responsive__StyleMapSchema = Assert, z.infer< typeof M167.CalendarConfigSchema > >>; export type Iso_ui_view__ColumnSummaryConfigSchema = Assert, z.infer< typeof M167.ColumnSummaryConfigSchema > >>; export type Iso_ui_view__ColumnSummarySchema = Assert, z.infer< typeof M167.ColumnSummarySchema > >>; +// `EmptyStateSchema` joined on #20694, extracted from the list view's inline +// `emptyState` so the `object-grid` row can take it by reference. +export type Iso_ui_view__EmptyStateSchema = Assert, z.infer< typeof M167.EmptyStateSchema > >>; export type Iso_ui_view__FormButtonConfigSchema = Assert, z.infer< typeof M167.FormButtonConfigSchema > >>; export type Iso_ui_view__GanttConfigSchema = Assert, z.infer< typeof M167.GanttConfigSchema > >>; export type Iso_ui_view__GanttQuickFilterSchema = Assert, z.infer< typeof M167.GanttQuickFilterSchema > >>; @@ -1666,7 +1669,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 779 isomorphic pins', () => { + it('still declares all 780 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2373,7 +2376,14 @@ describe('ADR-0122 type-alias convention', () => { // `interval` rename's `retiredKey()`, kept input and output equal — so the // pin was the whole of its footprint here. The M78 slot stays occupied by // the module's surviving pins. -1 removed. - expect(pins).toHaveLength(779); + // + // 779 -> 780 is #20694: the list view's inline `emptyState` shape was + // extracted into the exported `EmptyStateSchema` so the `object-grid` row + // can take it by reference, and the docs gate requires its `EmptyState` + // author alias. It has no default or transform in its tree, so it is + // pinned here (Iso_ui_view__EmptyStateSchema) rather than given an + // `EmptyStateParsed` synonym. +1 added. + expect(pins).toHaveLength(780); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/component-object-grid-declared-keys.pin.test.ts b/packages/spec/src/ui/component-object-grid-declared-keys.pin.test.ts new file mode 100644 index 00000000000..693b89f5aac --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-declared-keys.pin.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20694] `object-grid` declares `description`, `emptyState` and + * `keyboardNavigation` — the keys objectui's `ObjectGrid` reads (the first + * two) or is being built to read (the third), which the strict row refused. + * + * ## The gap this file closes + * + * `ComponentPropsMap['object-grid']` is a strict shape derived from the + * renderer's own read points, and it lagged the renderer: at the + * `.objectui-sha` pin the grid reads `schema.description` and + * `schema.emptyState`, while this row refused both by name. A metadata author + * validating against the row had a key the renderer honours reported as + * unknown, and objectui's registry could not list it either (its parity test + * refuses an input the spec does not accept). + * + * ## What is pinned, and why each half + * + * - §1 THE THREE MEMBERS PARSE, byte-identical: both `I18nLabel` forms of + * `description`, a full `emptyState`, and both booleans of + * `keyboardNavigation`. An absent member stays absent. + * - §2 THE ROW IS STILL CLOSED: an unknown top-level key is refused, and so is + * a wrong value in each new member. Each is the code AND the path, so a + * refusal for the wrong reason reds; the unknown-key control is what proves + * the widening did not open the row. + * - §3 ONE SCHEMA: the grid's `emptyState` and the list view's are + * `EmptyStateSchema` itself, and the grid answers exactly what it answers on + * every value in the corpus — triage ruled out a second shape, and a later + * copy reds the identity half even while it still agrees on today's corpus. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ComponentPropsMap, ObjectGridPropsSchema } from './component.zod'; +import { EmptyStateSchema, ListViewSchema } from './view.zod'; + +const GRID = () => ComponentPropsMap['object-grid']; +const BASE = { objectName: 'account' } as const; + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the three members parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 object-grid accepts description, emptyState and keyboardNavigation', () => { + const ACCEPTED: ReadonlyArray]> = [ + ['description as a string', { description: 'Accounts you own' }], + ['description as an inline locale map', { description: { en: 'Accounts you own', 'zh-CN': '你负责的客户' } }], + ['a full emptyState', { emptyState: { title: 'No contacts yet', message: 'Add one to get started', icon: 'users' } }], + ['an emptyState with one member', { emptyState: { message: 'Nothing here yet' } }], + ['keyboardNavigation: true', { keyboardNavigation: true }], + ['keyboardNavigation: false', { keyboardNavigation: false }], + ['all three together', { + description: 'Accounts you own', + emptyState: { title: 'No accounts' }, + keyboardNavigation: true, + editable: true, + }], + ]; + + for (const [label, props] of ACCEPTED) { + it(`parses ${label}, byte-identical`, () => { + const r = GRID().safeParse({ ...BASE, ...props }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, ...props }); + }); + } + + it('an absent member stays absent', () => { + const r = GRID().safeParse(BASE); + expect(issues(r)).toEqual([]); + expect(r.success && Object.keys(r.data)).toEqual(['objectName']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the row is still closed +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 object-grid still refuses what it does not declare', () => { + it('LIT CONTROL — an unknown top-level key is refused: unrecognized_keys at the row itself', () => { + const r = GRID().safeParse({ ...BASE, description: 'x', notAGridKey: 1 }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code: 'unrecognized_keys', path: '' }]); + expect(r.success ? [] : (r.error.issues[0] as { keys?: string[] }).keys).toEqual(['notAGridKey']); + }); + + const REFUSED: ReadonlyArray, code: string, path: string]> = [ + ['a numeric description', { description: 7 }, 'invalid_union', 'description'], + ['a string keyboardNavigation', { keyboardNavigation: 'yes' }, 'invalid_type', 'keyboardNavigation'], + ['a bare-string emptyState', { emptyState: 'Nothing here' }, 'invalid_type', 'emptyState'], + ['a numeric emptyState title', { emptyState: { title: 42 } }, 'invalid_union', 'emptyState.title'], + ['an unknown emptyState member', { emptyState: { title: 'None', notAnEmptyStateKey: 1 } }, 'unrecognized_keys', 'emptyState'], + ['an emptyState call to action', { emptyState: { action: { label: 'Add' } } }, 'unrecognized_keys', 'emptyState'], + ]; + + for (const [label, props, code, path] of REFUSED) { + it(`refuses ${label} — ${code} at ${path}`, () => { + const r = GRID().safeParse({ ...BASE, ...props }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code, path }]); + }); + } +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one schema, not a copy of its shape +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the grid and the list view hold EmptyStateSchema itself', () => { + const HELD = [ + ['object-grid', () => ObjectGridPropsSchema.shape.emptyState], + ['list-view', () => ListViewSchema.shape.emptyState], + ] as const; + + for (const [door, member] of HELD) { + it(`${door}: the emptyState member unwraps to EmptyStateSchema — the same def, by identity`, () => { + expect(member().unwrap()._zod.def).toBe(EmptyStateSchema._zod.def); + }); + } + + it('object-grid answers what EmptyStateSchema answers, one level down, on every corpus value', () => { + const corpus: unknown[] = [ + {}, + { title: 'No contacts yet', message: 'Add one to get started', icon: 'users' }, + { title: { en: 'None', 'zh-CN': '无' } }, + { title: 42 }, + { icon: 5 }, + { description: 'x' }, + { action: {} }, + { notAnEmptyStateKey: 1 }, + 'Nothing here', + null, + ]; + for (const emptyState of corpus) { + const direct = issues(EmptyStateSchema.safeParse(emptyState)).map(({ code, path }) => ({ + code, + path: path ? `emptyState.${path}` : 'emptyState', + })); + expect(issues(GRID().safeParse({ ...BASE, emptyState }))).toEqual(direct); + } + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 293e8270abf..17b0f4ff727 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -22,6 +22,10 @@ import { // read `grouping.fields[i].field` (the kanban only `fields[0]`, as its // swimlane fallback), so one declaration judges every door that carries it. GroupingConfigSchema, + // [#20694] `object-grid.emptyState` is the list view's own empty state, taken + // by reference: the grid draws the same key through the same shared + // component `ListView` does, so one declaration judges both doors. + EmptyStateSchema, } from './view.zod'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -3421,6 +3425,18 @@ const GridPageSizeSchema = z.number().int().positive(); * (:2599), `frozenColumns` (:2135), `showColumnTypeIcons` (:1296 …), * `exportOptions`/`operations` (:1697-1721), `label`/`title` (:1732, :2557), * `data`/`staticData` (:372, :386). + * + * [#20694] Three keys measured later, at the `.objectui-sha` pin + * `db11afd4967c` (objectui#11130's merge commit), in the same file: + * `description` (:5677, `resolveInlineI18nLabel(schema.description, + * displayLocale)`, drawn above the rows at :5784 / :6303 / :6342) and + * `emptyState` (:6225, drawn through `DataEmptyState` at :6233). The third, + * `keyboardNavigation`, has NO read point at that pin — zero hits under + * objectui `packages/` and `apps/` outside tests, CHANGELOGs, READMEs and + * `packages/types` (its type declaration and zod twin), against 3 hits for the + * control `schema.editable` in `ObjectGrid.tsx`. It is declared ahead of its + * reader on purpose (the BUILD objectui#11068 chose), and its describe carries + * the `[EXPERIMENTAL — not enforced]` marker that says so; see the member. */ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-grid`', @@ -3432,6 +3448,38 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ .describe('Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead'), label: I18nLabelSchema.optional().describe('Grid label — used as the table caption and export file title'), title: I18nLabelSchema.optional().describe('Fallback for `label` (the renderer reads `label || title`)'), + /** + * [#20694] One line of help text above the grid's rows, in the treatment + * `ListView` gives a view's description. Read at the pin `db11afd4967c` + * (`ObjectGrid.tsx:5677`) through `resolveInlineI18nLabel` against the + * display locale, exactly as `label` is, so both `I18nLabel` forms draw; a + * locale map with no usable entry draws no strip. Drawn by every branch that + * draws rows — the card view, the split pane and the table. + */ + description: I18nLabelSchema.optional() + .describe('One line of help text drawn above the grid\'s rows — a string, or an inline locale map resolved against the display locale'), + /** + * [#20694] What the grid draws INSTEAD of an empty table — the list view's + * own {@link EmptyStateSchema}, by reference (triage's direction: ⛔ not a + * second shape). Read at the pin `db11afd4967c` (`ObjectGrid.tsx:6225`), + * drawn through the shared `DataEmptyState` and `resolveIcon` that `ListView` + * draws a list's empty state with, and only when the grid holds no row to + * draw (no group, when grouped), nothing is loading, and the grid's own + * search box is not what emptied it. A member left out keeps the grid's + * default: the shared glyph, the table's "No results found" heading, no + * message line. + * + * ⚠️ Measured at that pin: `title` and `message` reach `DataEmptyState` as + * they are (`:6241-6242`), with no locale-map resolution, unlike + * `description` above. So of the two `I18nLabel` forms this shared shape + * declares, the grid draws the plain string only — an inline locale map is + * handed to React as a child, which throws (measured on react 19.2.8: + * "Objects are not valid as a React child"). That is a renderer gap on the + * objectui side, not a second shape here: the list view door declares the + * same members, and objectui's grid follows this declaration. + */ + emptyState: EmptyStateSchema.optional() + .describe('What the grid draws instead of an empty table: `{ title, message, icon }` — the list view\'s own empty-state shape'), columns: z.array(z.unknown()).optional() .describe('Columns: field names or column definition objects'), fields: z.array(z.unknown()).optional() @@ -3644,6 +3692,20 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ navigation: z.unknown().optional().describe('Row-click navigation config ({ mode: page | drawer | modal | split | popover | new_window | none }) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema'), editable: z.boolean().optional().describe('Enable inline cell editing'), singleClickEdit: z.boolean().optional().describe('Enter cell edit on single click (default true when editable)'), + /** + * [#20694] Declared AHEAD of its reader, deliberately: objectui#11068 chose + * to BUILD arrow-key cell navigation for the grid, and this row is the spec + * half triage folded in. At the pin `db11afd4967c` nothing reads it (see the + * block docblock above for the measurement), so an authored value changes + * nothing yet. The `[EXPERIMENTAL — not enforced]` marker in the describe is + * the liveness ledger's own spelling for a declared-but-not-enforced key; + * the `page/regions` container that holds page components is undrilled in + * the ledger (`undrilled-containers.baseline.json`), so no ledger row exists + * for any `ComponentPropsMap` key and the marker is the record. When the + * BUILD lands and the grid reads it, drop the marker in the same change. + */ + keyboardNavigation: z.boolean().optional() + .describe('[EXPERIMENTAL — not enforced] Arrow-key cell navigation on the WAI-ARIA grid pattern. Defaults to on when `editable` is set; a read-only grid keeps its Tab behaviour unless this is `true`. No renderer reads it yet: it is declared ahead of the grid\'s keyboard-navigation build, so authoring it changes nothing today'), resizable: z.boolean().optional().describe('Allow column resize (read before `resizableColumns`)'), resizableColumns: z.boolean().optional().describe('Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`)'), reorderableColumns: z.boolean().optional().describe('Allow column drag-reorder'), diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 6b06e119b34..326e374db2d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1719,6 +1719,56 @@ export const AddRecordConfigSchema = lazySchema(() => strictObject({ formView: z.string().optional().describe('Named form view to use when mode is "form" or "modal"'), }).describe('Add record entry point configuration')); +/** + * The one answer an empty state gives to an author reaching for a call to + * action inside it. Written for EVERY door that carries + * {@link EmptyStateSchema}, the `VIEW_ROW_BOUND_GUIDANCE` way: the add-record + * entry point is a list view's own `addRecord` block, and the `object-grid` + * page block declares no authorable one at all, so a sentence pointing at + * "this list view" would be a wrong answer on the grid. + */ +const EMPTY_STATE_ACTION_GUIDANCE = + 'The empty state renders text only — a `title`, a `message` and an `icon`. An "add record" ' + + 'entry point is not part of it: on a list view, configure it in that view\'s own `addRecord` block.'; + +/** + * Empty State — what a record list draws in place of rows when it has none to + * show: a heading, a line of text and an icon. + * + * [#20694] ONE declaration for every door that carries it, taken BY REFERENCE — + * the list view's `emptyState` below and the `object-grid` page block's + * (`ComponentPropsMap['object-grid'].emptyState`, `component.zod.ts`), the + * `GroupingConfigSchema` precedent (#20831). It was an inline shape on the list + * view until the grid's renderer started reading the same key; a second copy of + * the shape for the grid would be a second thing to drift, and objectui's own + * mirror of the grid's key follows this one, not the other way round. + * + * The extraction left the list view's accept set unchanged: a 28-value parse + * corpus over its four doors (`ListViewSchema`, `ObjectListViewSchema`, + * `ViewSchema.list`, the flattened overlay arm of `ViewMetadataSchema`) reads + * the same success, parsed data, issue codes and paths before and after. The + * one deliberate text change is {@link EMPTY_STATE_ACTION_GUIDANCE}, which now + * reads true on both doors. + */ +export const EmptyStateSchema = lazySchema(() => strictObject({ + surface: 'this empty state', + history: VIEW_HISTORY, + // `description`/`text`/`subtitle` are the words the neighbouring empty-state + // vocabularies use for the secondary line; here it is `message`. + aliases: { description: 'message', text: 'message', subtitle: 'message', heading: 'title', label: 'title', image: 'icon' }, + // An author wiring a CTA into the empty state is reaching for the add-record + // entry point, which is a separate block where one exists at all. + guidance: { + action: EMPTY_STATE_ACTION_GUIDANCE, + button: EMPTY_STATE_ACTION_GUIDANCE, + }, +}, { + title: I18nLabelSchema.optional().describe('Heading of the empty state'), + message: I18nLabelSchema.optional().describe('Line of text below the heading'), + icon: z.string().optional() + .describe('Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph'), +})); + /** * Kanban Settings */ @@ -2872,24 +2922,11 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Advanced: Allow Printing (Airtable Interface parity) */ allowPrinting: z.boolean().optional().describe('Allow users to print the view'), - /** Empty State */ - emptyState: strictObject({ - surface: 'this empty state', - history: VIEW_HISTORY, - // `description`/`text`/`subtitle` are the words the neighbouring empty-state - // vocabularies use for the secondary line; here it is `message`. - aliases: { description: 'message', text: 'message', subtitle: 'message', heading: 'title', label: 'title', image: 'icon' }, - // The add-record entry point is a real, separate block on this same view — - // an author wiring a CTA into the empty state is reaching for it. - guidance: { - action: 'The empty state renders text only. Configure the "add record" entry point in the `addRecord` block on this list view.', - button: 'The empty state renders text only. Configure the "add record" entry point in the `addRecord` block on this list view.', - }, - }, { - title: I18nLabelSchema.optional(), - message: I18nLabelSchema.optional(), - icon: z.string().optional(), - }).optional().describe('Empty state configuration when no records found'), + /** + * Empty State — {@link EmptyStateSchema} by reference (#20694), the same + * declaration the `object-grid` page block carries. + */ + emptyState: EmptyStateSchema.optional().describe('Empty state configuration when no records found'), /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the list view'), @@ -7146,6 +7183,12 @@ export type RowHeight = z.input; export type GroupingConfig = z.input; /** Post-parse shape of {@link GroupingConfig} — defaults applied, transforms run (ADR-0122). */ export type GroupingConfigParsed = z.infer; +/** + * Authoring shape of {@link EmptyStateSchema} — no default or transform in its + * tree, so it has no `EmptyStateParsed` (ADR-0122; pinned isomorphic in + * `type-alias-convention.pin.test.ts`). + */ +export type EmptyState = z.input; export type GalleryConfig = z.input; /** Post-parse shape of {@link GalleryConfig} — defaults applied, transforms run (ADR-0122). */ export type GalleryConfigParsed = z.infer;