Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/11794-more-trigger-name.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<typeof makeClient>;

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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<Record<string, unknown>>();
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<string, unknown>) => 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(
<MemoryRouter initialEntries={[`/studio/app.a/${pillar}`]}>
<Routes>
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
</Routes>
</MemoryRouter>,
);
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'));
});
});
20 changes: 12 additions & 8 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1536,11 +1536,16 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
</Link>
))}
{/* 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 && (
<Popover>
Expand All @@ -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)}
<ChevronDown className="h-3 w-3" />
</button>
</PopoverTrigger>
Expand All @@ -1566,6 +1569,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
<Link
key={p.key}
to={`/studio/${scopeSegment}/${p.key}`}
aria-current={tab === p.key ? 'page' : undefined}
onClick={(e) => {
if (tab === p.key) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
Expand Down
Loading