Skip to content

Commit c4db402

Browse files
os-zhuangclaude
andauthored
refactor(views): ListView's aria and sharing are the spec sub-shapes (#2890) (#2949)
Last rename batch in the ListView vocabulary migration. `aria` is now the spec's AriaPropsSchema: `label` → `ariaLabel`, `describedBy` → `ariaDescribedBy`, folded at the ListView boundary like every other legacy key. Two things fall out of adopting the spec shape: `role` becomes authorable (the region hardcoded `role="region"`; it now reads `aria.role` and falls back), and `aria.live` stays as a documented local extension — no spec counterpart, and dropping it would silently disable a shipped capability. `sharing` is now the spec's ViewSharingSchema ({ type, lockedBy }), imported by reference; the local four-key object is gone. `visibility` collapses onto the two ownership kinds the spec models (only `private` is personal), and a bare `enabled: true` maps to `personal` — the badge the user already saw, since the old title fell back to 'private'. Visible change: the share badge tooltip shows the spec ownership type, so `visibility: 'team'` reads "Sharing: collaborative". The four-value audience has no spec home and nothing but that tooltip consumed it; keeping a second audience enum alive would re-open the fork this issue closes. Also fixes the spec bridge, which was doing the opposite of its job: given a spec-shaped `sharing`, transformListView DOWNGRADED it — inventing a legacy `visibility` and an `enabled` flag the renderer then had to fold back. Both sides speak ViewSharing now, so it passes through. `conditionalFormatting` and `exportOptions` are deliberately NOT folded: both objectui shapes are supersets carrying capability the spec cannot express (the {field, operator, value} rule form; maxRecords / includeHeaders / fileNamePrefix). Folding them onto the narrower spec shapes would delete working features — they want promotion upstream, not a rename. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5319bf1 commit c4db402

8 files changed

Lines changed: 181 additions & 40 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/types": minor
4+
"@object-ui/plugin-list": minor
5+
"@object-ui/react": minor
6+
---
7+
8+
refactor(views): ListView's `aria` and `sharing` are the spec sub-shapes (#2890 scope A step 5)
9+
10+
Last rename batch in the ListView vocabulary migration.
11+
12+
**`aria`** is now the spec's `AriaPropsSchema`: `label``ariaLabel`,
13+
`describedBy``ariaDescribedBy`, folded at the ListView boundary like every
14+
other legacy key. Two things fall out of adopting the spec shape:
15+
16+
- `role` becomes authorable. The list region hardcoded `role="region"`; it now
17+
reads `aria.role` and falls back to `region`.
18+
- `aria.live` stays as a documented local extension — it has no spec
19+
counterpart, and dropping it would silently disable a shipped capability.
20+
Promote it rather than growing that extension.
21+
22+
**`sharing`** is now the spec's `ViewSharingSchema` (`{ type, lockedBy }`),
23+
imported by reference — the local four-key object is gone. The legacy pair folds
24+
in: `visibility` collapses onto the two ownership kinds the spec models (only
25+
`private` is `personal`; `team` / `organization` / `public` are all
26+
`collaborative`), and a bare `enabled: true` maps to `personal`, which is the
27+
badge the user already saw (the old title fell back to `'private'`).
28+
29+
*Visible change*: the share badge's tooltip shows the spec ownership type, so a
30+
view authored with `visibility: 'team'` reads "Sharing: collaborative" instead
31+
of "Sharing: team". The four-value audience has no spec home and nothing but
32+
that tooltip consumed it; keeping a second audience enum alive would re-open the
33+
fork this issue closes.
34+
35+
Also fixes the **spec bridge**, which was doing the opposite of its job: given a
36+
spec-shaped `sharing`, `transformListView` *downgraded* it — inventing a legacy
37+
`visibility` audience and an `enabled` flag that the renderer then had to fold
38+
back. Both sides speak `ViewSharing` now, so it passes through.
39+
40+
`conditionalFormatting` and `exportOptions` are deliberately **not** folded.
41+
Both objectui shapes are supersets carrying capability the spec cannot express —
42+
the `{ field, operator, value }` rule form, and `maxRecords` / `includeHeaders`
43+
/ `fileNamePrefix`. Folding them onto the narrower spec shapes would delete
44+
working features; they want promotion upstream, not a rename.

packages/core/src/utils/__tests__/normalize-list-view.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,55 @@ describe('normalizeListViewSchema (#2890)', () => {
204204
});
205205
});
206206

207+
describe('aria / sharing → the spec sub-shapes', () => {
208+
it('folds the legacy ARIA spellings onto the spec AriaProps keys', () => {
209+
const out = normalizeListViewSchema({
210+
viewType: 'grid',
211+
aria: { label: 'Accounts', describedBy: 'hint', live: 'polite' },
212+
}) as Record<string, Record<string, unknown>>;
213+
expect(out.aria).toEqual({ ariaLabel: 'Accounts', ariaDescribedBy: 'hint', live: 'polite' });
214+
});
215+
216+
it('keeps `role` and lets the canonical spellings win', () => {
217+
const out = normalizeListViewSchema({
218+
viewType: 'grid',
219+
aria: { ariaLabel: 'canonical', label: 'legacy', role: 'grid' },
220+
}) as Record<string, Record<string, unknown>>;
221+
expect(out.aria).toEqual({ ariaLabel: 'canonical', role: 'grid' });
222+
});
223+
224+
it('collapses the visibility audience onto the spec ownership type', () => {
225+
// Only `private` is personal; every wider audience is collaborative.
226+
for (const [visibility, type] of Object.entries({
227+
private: 'personal',
228+
team: 'collaborative',
229+
organization: 'collaborative',
230+
public: 'collaborative',
231+
})) {
232+
const out = normalizeListViewSchema({ viewType: 'grid', sharing: { visibility } }) as Record<string, unknown>;
233+
expect(out.sharing).toEqual({ type });
234+
}
235+
});
236+
237+
it('maps a bare `enabled: true` to `personal` — the badge it used to render', () => {
238+
const out = normalizeListViewSchema({ viewType: 'grid', sharing: { enabled: true } }) as Record<string, unknown>;
239+
expect(out.sharing).toEqual({ type: 'personal' });
240+
});
241+
242+
it('leaves `enabled: false` without a type, so the share badge stays hidden', () => {
243+
const out = normalizeListViewSchema({ viewType: 'grid', sharing: { enabled: false } }) as Record<string, unknown>;
244+
expect(out.sharing).toEqual({});
245+
});
246+
247+
it('preserves `lockedBy` and lets an explicit `type` win over `visibility`', () => {
248+
const out = normalizeListViewSchema({
249+
viewType: 'grid',
250+
sharing: { type: 'collaborative', visibility: 'private', lockedBy: 'u1' },
251+
}) as Record<string, unknown>;
252+
expect(out.sharing).toEqual({ type: 'collaborative', lockedBy: 'u1' });
253+
});
254+
});
255+
207256
describe('viewType defaulting', () => {
208257
it('defaults a missing view kind to the renderable `grid`', () => {
209258
expect(normalizeListViewSchema({ type: 'list-view' })).toEqual({ type: 'list-view', viewType: 'grid' });

packages/core/src/utils/normalize-list-view.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,24 @@ const SHOW_FLAG_TO_USER_ACTION: Record<string, string> = {
7575
showColor: 'rowColor',
7676
};
7777

78+
/**
79+
* Legacy `sharing.visibility` → the spec's `ViewSharing.type`. The spec models
80+
* two ownership kinds; objectui's four-value audience enum collapses onto them:
81+
* only `private` is personal, everything wider is collaborative.
82+
*/
83+
const VISIBILITY_TO_SHARING_TYPE: Record<string, 'personal' | 'collaborative'> = {
84+
private: 'personal',
85+
team: 'collaborative',
86+
organization: 'collaborative',
87+
public: 'collaborative',
88+
};
89+
90+
/** Legacy ARIA spellings → the spec's `AriaProps` keys. */
91+
const ARIA_KEY_ALIASES: Record<string, string> = {
92+
label: 'ariaLabel',
93+
describedBy: 'ariaDescribedBy',
94+
};
95+
7896
/**
7997
* ObjectUI's `list-view` node historically used a different vocabulary from
8098
* `@objectstack/spec` for the same concepts (`fields` where the spec says
@@ -116,6 +134,10 @@ const SHOW_FLAG_TO_USER_ACTION: Record<string, string> = {
116134
* stays absent, because the defaults are per-toggle (search/sort/filter/
117135
* rowHeight/group default ON, hideFields/rowColor default OFF) and belong to
118136
* the renderer, not to the vocabulary bridge.
137+
* - `aria: { label, describedBy }` → the spec's `AriaProps`
138+
* (`{ ariaLabel, ariaDescribedBy }`), and `sharing: { visibility, enabled }`
139+
* → the spec's `ViewSharing` (`{ type }`) — #2890 scope A step 5. `aria.live`
140+
* survives untouched: it has no spec counterpart.
119141
* - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys
120142
* carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in
121143
* objectui — every consumer passes the value straight to `$filter`. The spec
@@ -140,11 +162,15 @@ export function normalizeListViewSchema<T>(schema: T): T {
140162
const foldFilter = Array.isArray(legacyFilters);
141163
const legacyFlags = Object.keys(SHOW_FLAG_TO_USER_ACTION).filter((k) => typeof s[k] === 'boolean');
142164
const foldDescription = typeof s.showDescription === 'boolean';
165+
const aria = isRecord(s.aria) ? s.aria : undefined;
166+
const foldAria = !!aria && Object.keys(ARIA_KEY_ALIASES).some((k) => aria[k] !== undefined);
167+
const sharing = isRecord(s.sharing) ? s.sharing : undefined;
168+
const foldSharing = !!sharing && (sharing.visibility !== undefined || sharing.enabled !== undefined);
143169
const viewType = s.viewType;
144170
const defaultViewKind = !viewType || viewType === 'list';
145171
if (
146172
!foldColumns && !foldRowHeight && !foldFilter && !legacyFlags.length &&
147-
!foldDescription && !defaultViewKind
173+
!foldDescription && !foldAria && !foldSharing && !defaultViewKind
148174
) {
149175
return schema;
150176
}
@@ -183,6 +209,35 @@ export function normalizeListViewSchema<T>(schema: T): T {
183209
delete next.showDescription;
184210
next.appearance = appearance;
185211
}
212+
if (foldAria && aria) {
213+
const nextAria: Record<string, unknown> = { ...aria };
214+
for (const [legacy, canonical] of Object.entries(ARIA_KEY_ALIASES)) {
215+
if (nextAria[canonical] === undefined && aria[legacy] !== undefined) {
216+
nextAria[canonical] = aria[legacy];
217+
}
218+
delete nextAria[legacy];
219+
}
220+
next.aria = nextAria;
221+
}
222+
if (foldSharing && sharing) {
223+
const nextSharing: Record<string, unknown> = { ...sharing };
224+
if (nextSharing.type === undefined) {
225+
const visibility = typeof sharing.visibility === 'string' ? sharing.visibility : undefined;
226+
// `enabled: true` with no audience is under-specified legacy input. It
227+
// used to render the share badge titled "private", so it maps to
228+
// `personal` — preserving what the user saw rather than adopting the
229+
// spec's `collaborative` default and silently relabeling the badge.
230+
const resolved = visibility
231+
? VISIBILITY_TO_SHARING_TYPE[visibility]
232+
: sharing.enabled === true
233+
? 'personal'
234+
: undefined;
235+
if (resolved) nextSharing.type = resolved;
236+
}
237+
delete nextSharing.visibility;
238+
delete nextSharing.enabled;
239+
next.sharing = nextSharing;
240+
}
186241
if (defaultViewKind) next.viewType = 'grid';
187242
return next as T;
188243
}

packages/plugin-list/src/ListView.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,10 +1744,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
17441744
<div
17451745
ref={pullRef}
17461746
className={cn('flex flex-col h-full bg-background relative min-w-0 overflow-hidden', className)}
1747-
{...(schema.aria?.label ? { 'aria-label': schema.aria.label } : {})}
1748-
{...(schema.aria?.describedBy ? { 'aria-describedby': schema.aria.describedBy } : {})}
1747+
{...(schema.aria?.ariaLabel ? { 'aria-label': schema.aria.ariaLabel as string } : {})}
1748+
{...(schema.aria?.ariaDescribedBy ? { 'aria-describedby': schema.aria.ariaDescribedBy } : {})}
17491749
{...(schema.aria?.live ? { 'aria-live': schema.aria.live } : {})}
1750-
role="region"
1750+
role={schema.aria?.role ?? 'region'}
17511751
aria-busy={loading || undefined}
17521752
data-state={loading ? 'loading' : 'idle'}
17531753
>
@@ -2242,12 +2242,12 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
22422242
)}
22432243

22442244
{/* Share — supports both ObjectUI visibility model and spec personal/collaborative model */}
2245-
{(schema.sharing?.enabled || schema.sharing?.type) && (
2245+
{schema.sharing?.type && (
22462246
<Button
22472247
variant="ghost"
22482248
size="sm"
22492249
className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
2250-
title={`Sharing: ${schema.sharing?.visibility || schema.sharing?.type || 'private'}`}
2250+
title={`Sharing: ${schema.sharing.type}`}
22512251
data-testid="share-button"
22522252
>
22532253
<Share2 className="h-3.5 w-3.5 mr-1.5" />
@@ -2271,7 +2271,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
22712271

22722272
{/* --- Separator: Print/Share/Export | Search --- */}
22732273
{(() => {
2274-
const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && exportPermitted);
2274+
const hasLeftSideItems = schema.allowPrinting || !!schema.sharing?.type || (resolvedExportOptions && exportPermitted);
22752275
return toolbarFlags.showSearch && hasLeftSideItems ? (
22762276
<div className="h-5 w-px bg-border/50 mx-1 shrink-0" />
22772277
) : null;

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,9 @@ describe('ListView', () => {
584584
renderWithProvider(<ListView schema={schema} />);
585585
const shareButton = screen.getByTestId('share-button');
586586
expect(shareButton).toBeInTheDocument();
587-
expect(shareButton).toHaveAttribute('title', 'Sharing: team');
587+
// #2890: the badge shows the spec's ownership type. The legacy four-value
588+
// audience (`team`) collapses onto it — only `private` is personal.
589+
expect(shareButton).toHaveAttribute('title', 'Sharing: collaborative');
588590
});
589591

590592
it('should not render share button when sharing is not enabled', () => {

packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,30 +340,28 @@ describe('P1 SpecBridge Protocol Alignment', () => {
340340
// P2 Sharing / ExportOptions / Pagination Protocol Alignment
341341
// ========================================================================
342342
describe('P2 sharing/exportOptions/pagination alignment', () => {
343-
it('should normalize spec sharing { type: personal } to { visibility: private, enabled: true }', () => {
343+
// #2890: `sharing` is the spec's `ViewSharing` shape on BOTH sides now, so
344+
// the bridge passes it through. It used to downgrade it here — inventing a
345+
// legacy `visibility` audience and an `enabled` flag that the renderer then
346+
// had to fold back into `type`.
347+
it('should pass spec sharing { type: personal } through unchanged', () => {
344348
const bridge = new SpecBridge();
345349
const node = bridge.transformListView({
346350
name: 'personal_view',
347351
sharing: { type: 'personal', lockedBy: 'admin@example.com' },
348352
});
349353

350-
expect(node.sharing).toBeDefined();
351-
expect(node.sharing.visibility).toBe('private');
352-
expect(node.sharing.enabled).toBe(true);
353-
expect(node.sharing.type).toBe('personal');
354-
expect(node.sharing.lockedBy).toBe('admin@example.com');
354+
expect(node.sharing).toEqual({ type: 'personal', lockedBy: 'admin@example.com' });
355355
});
356356

357-
it('should normalize spec sharing { type: collaborative } to { visibility: team, enabled: true }', () => {
357+
it('should pass spec sharing { type: collaborative } through unchanged', () => {
358358
const bridge = new SpecBridge();
359359
const node = bridge.transformListView({
360360
name: 'collab_view',
361361
sharing: { type: 'collaborative' },
362362
});
363363

364-
expect(node.sharing.visibility).toBe('team');
365-
expect(node.sharing.enabled).toBe(true);
366-
expect(node.sharing.type).toBe('collaborative');
364+
expect(node.sharing).toEqual({ type: 'collaborative' });
367365
});
368366

369367
it('should preserve ObjectUI sharing format without overriding', () => {

packages/react/src/spec-bridge/bridges/list-view.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,10 @@ export const bridgeListView: BridgeFn<ListViewSpec> = (
116116

117117
// P1.6 — i18n & ARIA
118118
if (spec.aria) node.aria = spec.aria;
119-
if (spec.sharing) {
120-
// Normalize spec sharing format: map type → visibility, set enabled = true
121-
const sharing: Record<string, any> = { ...spec.sharing };
122-
if (sharing.type && !sharing.visibility) {
123-
sharing.visibility = sharing.type === 'collaborative' ? 'team' : 'private';
124-
}
125-
if (sharing.type && sharing.enabled == null) {
126-
sharing.enabled = true;
127-
}
128-
node.sharing = sharing;
129-
}
119+
// `sharing` is already the spec's `ViewSharing` shape on both sides (#2890) —
120+
// this used to DOWNGRADE it here, inventing a legacy `visibility` audience and
121+
// an `enabled` flag that the renderer then had to fold back.
122+
if (spec.sharing) node.sharing = spec.sharing;
130123
if (spec.hiddenFields) node.hiddenFields = spec.hiddenFields;
131124
if (spec.fieldOrder) node.fieldOrder = spec.fieldOrder;
132125
if (spec.description) node.description = spec.description;

packages/types/src/zod/objectql.zod.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
SelectionConfigSchema as SpecSelectionConfigSchema,
3232
PaginationConfigSchema as SpecPaginationConfigSchema,
3333
UserActionsConfigSchema as SpecUserActionsConfigSchema,
34+
AriaPropsSchema as SpecAriaPropsSchema,
3435
} from '@objectstack/spec/ui';
3536
import { BaseSchema } from './base.zod.js';
3637

@@ -309,7 +310,6 @@ const SpecListViewFields = SpecListViewSchema
309310
description: true,
310311
userFilters: true,
311312
userActions: true,
312-
sharing: true,
313313
aria: true,
314314
conditionalFormatting: true,
315315
exportOptions: true,
@@ -456,16 +456,16 @@ export const ListViewSchema = BaseSchema
456456
userFilters: UserFiltersSchema.optional().describe('User filters configuration (accepts legacy tab shapes)'),
457457
// Spec `userActions` + the three toolbar toggles it does not model yet (#2890).
458458
userActions: UserActionsSchema.optional().describe('User action toggles for the view toolbar'),
459-
sharing: z.object({
460-
visibility: z.enum(['private', 'team', 'organization', 'public']).optional(),
461-
enabled: z.boolean().optional(),
462-
type: z.enum(['personal', 'collaborative']).optional(),
463-
lockedBy: z.string().optional(),
464-
}).optional().describe('Sharing configuration'),
465-
aria: z.object({
466-
label: z.string().optional(),
467-
describedBy: z.string().optional(),
468-
live: z.enum(['polite', 'assertive', 'off']).optional(),
459+
// `sharing` is the spec's `ViewSharingSchema`, imported by reference above.
460+
// The legacy `{ visibility, enabled }` pair folds into it at the ListView
461+
// boundary (#2890) — see `normalizeListViewSchema`.
462+
// ARIA — the spec's `AriaPropsSchema` (`ariaLabel` / `ariaDescribedBy` /
463+
// `role`) plus `live`, which has no spec counterpart. The legacy
464+
// `{ label, describedBy }` spellings fold into the canonical ones at the
465+
// ListView boundary (#2890).
466+
aria: SpecAriaPropsSchema.extend({
467+
live: z.enum(['polite', 'assertive', 'off']).optional()
468+
.describe('aria-live politeness for the list region (objectui-only — promote rather than grow this extension)'),
469469
}).optional().describe('ARIA attributes'),
470470
conditionalFormatting: z.array(z.union([
471471
z.object({

0 commit comments

Comments
 (0)