From 095712408a9eeb5b5e764277754bc73ff0576cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:37:09 +0000 Subject: [PATCH] fix(app-shell): the Studio header's More trigger keeps its own name (objectui#11794) While an overflow pillar (Access) was open, the More trigger renamed itself to that pillar, hiding the word that says the control is the overflow menu. It now always reads More, the rule the Data pillar's Advanced trigger follows. The trigger keeps the active pillar styling while one of its pillars is open, and the open pillar's link inside the menu carries aria-current="page". Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/11794-more-trigger-name.md | 5 + ...DesignSurface.moreKeepsName-11794.test.tsx | 171 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 20 +- 3 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 .changeset/11794-more-trigger-name.md create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.moreKeepsName-11794.test.tsx diff --git a/.changeset/11794-more-trigger-name.md b/.changeset/11794-more-trigger-name.md new file mode 100644 index 0000000000..641998482d --- /dev/null +++ b/.changeset/11794-more-trigger-name.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word that says this control is the overflow menu. It now always reads *More*, the same rule the Data pillar's *Advanced* trigger follows. It still takes the active pillar styling while one of its pillars is open, and inside the menu that pillar's link is marked as the current page (`aria-current="page"`), so assistive technology reads where the author is. diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.moreKeepsName-11794.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.moreKeepsName-11794.test.tsx new file mode 100644 index 0000000000..2601f948c8 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.moreKeepsName-11794.test.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11794 — the Studio header's *More* trigger keeps its own name. + * + * The defect: with an overflow pillar open (today *Access*, the one entry of + * `OVERFLOW_PILLARS`, objectui#5813), the trigger renamed itself to that + * pillar ("Access ▾"), so the word that says this control is the overflow menu + * was gone. The same rule the Data pillar's *Advanced* trigger follows now + * holds here: the trigger always reads *More*. Where the author is still shows + * twice — the trigger takes the active pillar styling while one of its pillars + * is open, and inside the menu that pillar's link is the current page + * (`aria-current="page"`). + * + * The active styling is read as the class tokens an open PRIMARY pillar link + * carries, and each test that asserts them on the trigger also reads them on + * that link (or reads their absence there), so the tokens are shown to be the + * active marker rather than assumed to be one. + * + * Rendered through the real `StudioDesignSurface` and its real Radix Popover, + * as `createAppOpensInterfaces-11794` is; only data I/O and heavy docks are + * doubled. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +let clientImpl: ReturnType; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }], + }), + }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + fetchPackages: vi.fn(async () => [{ id: 'app.a', name: 'App A', writable: true, namespace: 'a' }]), + }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +function makeClient() { + return { + list: vi.fn(async (type: string) => { + if (type === 'permission') return [{ name: 'set_a', label: 'Set A' }]; + if (type === 'object') return [{ name: 'a_account' }]; + return []; + }), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (_type: string, name: string) => ({ + effective: name === 'set_a' ? { name: 'set_a', label: 'Set A', objects: { a_account: { allowRead: true } }, fields: {} } : null, + code: null, + overlay: null, + overlayScope: null, + })), + getDraft: vi.fn(async () => null), + get: vi.fn(async (type: string) => (type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null)), + save: vi.fn(async (_type: string, _name: string, body: Record) => body), + }; +} + +const MORE = t('engine.studio.more', 'en'); +const ACCESS = t('engine.studio.pillar.access', 'en'); +const INTERFACES = t('engine.studio.pillar.interfaces', 'en'); +/** The class tokens an open pillar link carries in the header row. */ +const ACTIVE = ['bg-primary/10', 'font-medium', 'text-primary']; + +beforeEach(() => { + clientImpl = makeClient(); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => [] })) as unknown as typeof fetch); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function renderOn(pillar: 'access' | 'interfaces') { + render( + + + } /> + + , + ); + const header = await screen.findByRole('banner'); + if (pillar === 'access') await screen.findByText('a_account'); + return { header, trigger: within(header).getByTestId('studio-nav-more') }; +} + +/** Open the More popover and return its Access link. */ +async function accessLinkInMenu(trigger: HTMLElement) { + fireEvent.click(trigger); + const menu = await screen.findByRole('dialog'); + return within(menu).getByRole('link', { name: ACCESS }); +} + +describe('the Studio header’s More trigger keeps its own name (objectui#11794)', () => { + it('with Access open the trigger reads More (not Access) and takes the active pillar styling', async () => { + const { header, trigger } = await renderOn('access'); + + // THE PIN: the trigger's own name, whichever of its pillars is open. + expect(trigger).toHaveTextContent(MORE); + expect(trigger).not.toHaveTextContent(ACCESS); + expect(trigger).toHaveClass(...ACTIVE); + // No primary pillar link is the open one. + expect(within(header).getByRole('link', { name: INTERFACES })).not.toHaveClass('bg-primary/10'); + }); + + it('control: with a primary pillar open the trigger reads More with no active styling, which the open pillar link has', async () => { + const { header, trigger } = await renderOn('interfaces'); + + expect(trigger).toHaveTextContent(MORE); + expect(trigger).not.toHaveClass('bg-primary/10'); + expect(trigger).not.toHaveClass('text-primary'); + // Lit control: the same tokens mark the open primary pillar. + expect(within(header).getByRole('link', { name: INTERFACES })).toHaveClass(...ACTIVE); + }); + + it('the menu marks the open pillar: Access is the current page while it is open, and not from a primary pillar', async () => { + const onAccess = await renderOn('access'); + const openLink = await accessLinkInMenu(onAccess.trigger); + expect(openLink).toHaveAttribute('aria-current', 'page'); + expect(openLink).toHaveClass(...ACTIVE); + cleanup(); + + const onInterfaces = await renderOn('interfaces'); + const closedLink = await accessLinkInMenu(onInterfaces.trigger); + expect(closedLink).not.toHaveAttribute('aria-current'); + expect(closedLink).not.toHaveClass('bg-primary/10'); + await waitFor(() => expect(closedLink).toHaveAttribute('href', '/studio/app.a/access')); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 8a64971d56..6e61ec3902 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1536,11 +1536,16 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React ))} {/* objectui#5813 — low-frequency surfaces live in "More". The - trigger takes the active pillar styling when one of them is - open, so the demotion never hides WHERE you are. Each item is - a real router Link carrying the SAME dirty-guard as the - primary pillars — an overflow entry must not become the one - door that silently discards edits. None of them is in the + trigger keeps its own name, More, whichever of them is open + (objectui#11794, the rule the Data pillar's Advanced trigger + follows): renamed to the open pillar ("Access ▾") it hid the + one word that says this is the overflow menu. Where you are + still shows twice — the trigger takes the active pillar + styling while one of its pillars is open, and inside the menu + that pillar's link is the current page (`aria-current`). Each + item is a real router Link carrying the SAME dirty-guard as + the primary pillars — an overflow entry must not become the + one door that silently discards edits. None of them is in the package-less scope (objectui#11553). */} {packageId !== null && ( @@ -1555,9 +1560,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React : 'text-muted-foreground hover:bg-muted hover:text-foreground') } > - {OVERFLOW_PILLARS.some((p) => tab === p.key) - ? t(`engine.studio.pillar.${tab}`, locale) - : t('engine.studio.more', locale)} + {t('engine.studio.more', locale)} @@ -1566,6 +1569,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React { if (tab === p.key) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;