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
15 changes: 15 additions & 0 deletions .changeset/12744-icon-sidebar-label-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/layout': patch
'@object-ui/app-shell': patch
---

In the collapsed (icon-mode) sidebar, a click on an icon reaches its row again. Before, a hidden group label could take the click instead (objectui#12744).

The `SidebarGroupLabel` primitive hides a label in icon mode with `opacity-0` and `-mt-8` only. The invisible label stayed hit-testable and lay over the lower half of the row just above its group. A centre click on that row's icon landed on the label. A collapsible group's label toggled its group, the Recent label toggled Recent, and the row did not navigate.

The labels that sit below a row now take no pointer events while the primitive hides them. The condition is the primitive's own `group-data-[state=collapsed]:group-data-[collapsible=icon]:` variant:

- `@object-ui/layout`: `NavigationRenderer`'s collapsible group label, and its Favorites label (it sits below the Area switcher in a multi-area app);
- `@object-ui/app-shell`: `UnifiedSidebar`'s Recent label.

In the expanded sidebar nothing changes. A group's label still toggles its group by mouse and keyboard, and the Recent label still opens and closes Recent. The primitive itself is a Shadcn-synced file and is not edited: the class rides on the `className` each call site passes. Nothing is added to either package entry: no export, prop or type member.
16 changes: 15 additions & 1 deletion packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ import { AppMark } from './AppMark.js';
import { CONSOLE_BELOW_TOP_BAR } from './consoleTopBar.js';
import { SidebarItemMenu, sidebarItemDesignPath, favoriteDesignPath } from './SidebarItemMenu.js';

/**
* The class the Recent group's label carries so that, once the sidebar
* collapses to icons, the hidden label takes no pointer events
* (objectui#12744). The `SidebarGroupLabel` primitive hides a label there with
* `opacity-0` and `-mt-8` only, so the invisible label lies over the lower half
* of the navigation row just above it; this label has a click handler, so a
* click on that row's icon toggled Recent instead of navigating. The variant is
* the primitive's own hiding condition: in the expanded sidebar the label is
* visible and still opens and closes Recent. `NavigationRenderer` (in
* `@object-ui/layout`) gives its group labels the same class.
*/
const HIDDEN_ICON_LABEL_NO_POINTER =
'group-data-[state=collapsed]:group-data-[collapsible=icon]:pointer-events-none';

/**
* Lazy-resolved Lucide icon — see ../utils/getIcon for impl.
* The local symbol is kept for backwards compat with existing call sites
Expand Down Expand Up @@ -542,7 +556,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
{recentItems.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel
className="flex items-center gap-1.5 cursor-pointer select-none"
className={`flex items-center gap-1.5 cursor-pointer select-none ${HIDDEN_ICON_LABEL_NO_POINTER}`}
onClick={() => setRecentExpanded(prev => !prev)}
>
<ChevronRight className={`h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}`} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* UnifiedSidebar — in the collapsed (icon-mode) sidebar the hidden Recent
* label takes no pointer events (objectui#12744).
*
* The `SidebarGroupLabel` primitive hides a label there with `opacity-0` and
* `-mt-8` only, so the invisible Recent label lay over the lower half of the
* navigation row just above it, and a click on that row's icon toggled Recent
* instead of navigating (measured in a browser; the readings are on the pull
* request). The label now carries a pointer-events-none variant, and the pins
* DERIVE that variant from the primitive's own `…:opacity-0` token, so it is
* checked to be the condition the primitive hides the label under. The test
* DOM computes no Tailwind, so the pins read the class list.
*
* Harness: the provider / chrome mocks of
* `UnifiedSidebar.recentPackage-11863.test.tsx`; `@object-ui/layout` and
* `@object-ui/components` (the real `SidebarProvider`, `Sidebar` and
* `SidebarGroupLabel`) stay REAL.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';

/** What the recent-items store holds. */
const recent = vi.hoisted(() => ({ items: [] as unknown[] }));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
}),
useObjectLabel: () => ({
objectLabel: ({ label }: { label?: string }) => label,
viewLabel: (_o: string, _v: string, fallback?: string) => fallback,
dashboardLabel: ({ label }: { label?: string }) => label,
appLabel: ({ label }: { label?: string }) => label,
}),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: null, activeOrganization: null }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
}));

vi.mock('@object-ui/permissions', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/permissions')>()),
usePermissions: () => ({ can: () => true, hasCapabilities: () => true }),
}));

let metadataState: { apps: unknown[]; objects: unknown[] };
vi.mock('../../providers/MetadataProvider', () => ({
useMetadata: () => metadataState,
}));

vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''),
matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) =>
apps.find((a) => a?.name === segment),
appRouteSegment: (app: { name?: string }) => app?.name,
}));

vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: recent.items }) }));
vi.mock('../../hooks/useFavorites', () => ({
useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }),
}));
vi.mock('../../hooks/useNavPins', () => ({
useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }),
}));
vi.mock('../../hooks/useNavActionDispatch', () => ({
useNavActionDispatch: () => vi.fn(),
}));
vi.mock('../../context/NavigationContext', () => ({
useNavigationContext: () => ({ context: 'app', currentAppName: 'crm' }),
}));
vi.mock('../ContextSelectors', () => ({
useAppContextSelectors: () => ({ contextValues: {}, element: null }),
contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id),
STUDIO_PACKAGE_SELECTOR_ID: 'active_package',
}));
vi.mock('../LocalizedSidebarTrigger', () => ({
LocalizedSidebarTrigger: () => null,
}));

import { SidebarProvider } from '@object-ui/components';
import { UnifiedSidebar } from '../UnifiedSidebar';

const METADATA = {
id: 'metadata:object:account',
type: 'metadata',
label: 'account',
href: '/apps/crm/metadata/object/account',
visitedAt: '2026-10-16T09:00:00.000Z',
};

/** The measured shape: a navigation row, then the Recent group right below it. */
function sidebarUi(open: boolean) {
metadataState = {
apps: [
{
name: 'crm',
label: 'CRM',
active: true,
navigation: [{ id: 'nav_accounts', type: 'object', objectName: 'account', label: 'Accounts' }],
},
],
objects: [],
};
return (
<MemoryRouter initialEntries={['/apps/crm']}>
<SidebarProvider defaultOpen={open}>
<UnifiedSidebar activeAppName="crm" />
</SidebarProvider>
</MemoryRouter>
);
}

const tokens = (el: Element) => el.className.split(/\s+/).filter(Boolean);

/** The variant prefix the primitive hides this label under, read off its `…:opacity-0` token. */
function hidingPrefix(label: Element): string {
const hide = tokens(label).find((t) => t.endsWith(':opacity-0'));
expect(hide, 'the primitive hides the label with a variant-scoped opacity-0').toBeDefined();
return hide!.slice(0, -'opacity-0'.length);
}

const recentLabel = () =>
screen.getByText('Recent').closest('[data-sidebar="group-label"]') as HTMLElement;

beforeEach(() => {
localStorage.clear();
recent.items = [METADATA];
});

describe('UnifiedSidebar — the hidden Recent label takes no pointer events in icon mode (objectui#12744)', () => {
it('collapsed to icons: the Recent label stops taking pointer events exactly where the primitive hides it', () => {
const { container } = render(sidebarUi(false));
const peer = container.querySelector('.group.peer[data-state]');
expect(peer).toHaveAttribute('data-state', 'collapsed');
expect(peer).toHaveAttribute('data-collapsible', 'icon');

const label = recentLabel();
expect(tokens(label)).toContain(`${hidingPrefix(label)}pointer-events-none`);
});

it('expanded: the Recent label still takes pointer events and opens and closes Recent', async () => {
const user = userEvent.setup();
render(sidebarUi(true));
const label = recentLabel();

const prefix = hidingPrefix(label);
for (const t of tokens(label).filter((x) => x.endsWith('pointer-events-none'))) {
expect(t).toBe(`${prefix}pointer-events-none`);
}

const group = label.closest('[data-sidebar="group"]') as HTMLElement;
expect(within(group).queryByRole('link', { name: /\baccount$/ })).not.toBeInTheDocument();
await user.click(label);
expect(within(group).getByRole('link', { name: /\baccount$/ })).toHaveAttribute('href', METADATA.href);
await user.click(label);
expect(within(group).queryByRole('link', { name: /\baccount$/ })).not.toBeInTheDocument();
});
});
26 changes: 24 additions & 2 deletions packages/layout/src/NavigationRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui
// re-export `byNavOrder` from this file: `index.ts` re-exports it whole.
import { byNavOrder } from './navOrder';

/**
* The class this file's sidebar group labels carry so that, once the sidebar
* collapses to icons, a hidden label takes no pointer events (objectui#12744).
*
* In the collapsed icon sidebar the `SidebarGroupLabel` primitive hides its
* label with `opacity-0` and `-mt-8` and nothing else, so the invisible label
* stays hit-testable: it lies over the lower half of the row just above its
* group, and a click on that row's icon lands on the label instead. A
* collapsible group's label is its disclosure button, so the click toggles the
* group and the row does not navigate. The variant here is the primitive's own
* hiding condition, so the label stops taking pointer events exactly while it
* is hidden; in the expanded sidebar it is visible, and a collapsible group's
* label still toggles its group. Keyboard focus is unaffected.
*
* The primitive is a Shadcn-synced file, never edited here (AGENTS.md #7), so
* the fix rides on the `className` each call site passes, which `cn` merges
* over the primitive's classes (it sets no `pointer-events` class).
* Module-local on purpose: `index.ts` re-exports this file whole.
*/
const HIDDEN_ICON_LABEL_NO_POINTER =
'group-data-[state=collapsed]:group-data-[collapsible=icon]:pointer-events-none';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1538,7 +1560,7 @@ function NavigationItemRenderer({
const group = (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<SidebarGroup>
<SidebarGroupLabel asChild>
<SidebarGroupLabel asChild className={HIDDEN_ICON_LABEL_NO_POINTER}>
<CollapsibleTrigger className={cn('flex w-full items-center justify-between', isMobile && 'min-h-[44px] text-[15px] rounded-xl')}>
{groupLabel}
<ChevronRight
Expand Down Expand Up @@ -1826,7 +1848,7 @@ export function NavigationRenderer({
const hasExtraFavorites = React.Children.toArray(extraFavorites).length > 0;
const favoritesSection = showPinned || hasExtraFavorites ? (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-1.5">
<SidebarGroupLabel className={cn('flex items-center gap-1.5', HIDDEN_ICON_LABEL_NO_POINTER)}>
<Star className="h-3.5 w-3.5" />
{tProp ? tProp('console.nav.favorites', { defaultValue: 'Favorites' }) : 'Favorites'}
</SidebarGroupLabel>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#12744 — in the collapsed (icon-mode) sidebar a hidden group label
* takes no pointer events, so it no longer swallows the click on the row
* just above its group.
*
* The `SidebarGroupLabel` primitive hides a label there with `opacity-0` and
* `-mt-8` and nothing else. The invisible label stays hit-testable and lies
* over the lower half of the row above; a collapsible group's label is its
* disclosure button, so a centre click on that row's icon toggled the group
* instead of navigating. The browser readings (`elementFromPoint` and a click
* on every icon, before and after) are on the pull request.
*
* Instrument: the REAL `SidebarProvider` / `Sidebar` / `SidebarGroupLabel`
* from `@object-ui/components` and the real `NavigationRenderer`. The test DOM
* computes no Tailwind, so the pins read the label's class list. The
* pointer-events variant must be the very condition the primitive hides the
* label under: the expected token is DERIVED from the primitive's own
* `…:opacity-0` token, so a primitive that changes its hiding condition turns
* this red instead of leaving a variant that no longer matches.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it } from 'vitest';
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import type { NavigationItem } from '@object-ui/types';
import { Sidebar, SidebarContent, SidebarProvider } from '@object-ui/components';
import { NavigationRenderer } from '../NavigationRenderer';

const BASE = '/apps/crm';

const entry = (id: string, extra: Partial<NavigationItem> = {}): NavigationItem =>
({ id, type: 'object', objectName: id, label: id.toUpperCase(), ...extra }) as NavigationItem;

/** The measured shape: a top-level row, then a collapsible group right below it. */
const ITEMS: NavigationItem[] = [
entry('nav_home'),
entry('nav_cases', { pinned: true } as Partial<NavigationItem>),
{
id: 'grp_sales',
type: 'group',
label: 'Sales',
expanded: true,
children: [entry('nav_accounts'), entry('nav_contacts')],
} as NavigationItem,
];

function renderSidebar(open: boolean) {
return render(
<MemoryRouter initialEntries={[BASE]}>
<SidebarProvider defaultOpen={open}>
<Sidebar collapsible="icon">
<SidebarContent>
<NavigationRenderer items={ITEMS} basePath={BASE} enablePinning onPinToggle={() => {}} />
</SidebarContent>
</Sidebar>
</SidebarProvider>
</MemoryRouter>,
);
}

const tokens = (el: Element) => el.className.split(/\s+/).filter(Boolean);

/** The variant prefix the primitive hides this label under, read off its `…:opacity-0` token. */
function hidingPrefix(label: Element): string {
const hide = tokens(label).find((t) => t.endsWith(':opacity-0'));
expect(hide, 'the primitive hides the label with a variant-scoped opacity-0').toBeDefined();
return hide!.slice(0, -'opacity-0'.length);
}

const groupLabel = () => screen.getByRole('button', { name: /Sales/ });
const favoritesLabel = () =>
screen.getByText('Favorites').closest('[data-sidebar="group-label"]') as HTMLElement;

afterEach(cleanup);

describe('NavigationRenderer — a hidden group label takes no pointer events in icon mode (objectui#12744)', () => {
it('collapsed to icons: the collapsible group label and the Favorites label stop taking pointer events exactly where the primitive hides them', () => {
const { container } = renderSidebar(false);
// The condition the variant names really holds in this render.
const peer = container.querySelector('.group.peer[data-state]');
expect(peer).toHaveAttribute('data-state', 'collapsed');
expect(peer).toHaveAttribute('data-collapsible', 'icon');

for (const label of [groupLabel(), favoritesLabel()]) {
expect(label).toHaveAttribute('data-sidebar', 'group-label');
expect(tokens(label)).toContain(`${hidingPrefix(label)}pointer-events-none`);
}
});

it('expanded: the collapsible group label still takes pointer events and toggles its group by mouse and keyboard', async () => {
const user = userEvent.setup();
renderSidebar(true);
const label = groupLabel();

// Never an unconditional pointer-events-none: every such token sits behind the hiding variant.
const prefix = hidingPrefix(label);
for (const t of tokens(label).filter((x) => x.endsWith('pointer-events-none'))) {
expect(t).toBe(`${prefix}pointer-events-none`);
}
expect(tokens(favoritesLabel())).not.toContain('pointer-events-none');

expect(label).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('link', { name: 'NAV_ACCOUNTS' })).toBeInTheDocument();

await user.click(label);
expect(label).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('link', { name: 'NAV_ACCOUNTS' })).not.toBeInTheDocument();

label.focus();
expect(label).toHaveFocus();
await user.keyboard('{Enter}');
expect(label).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('link', { name: 'NAV_ACCOUNTS' })).toBeInTheDocument();
});
});
Loading