From fe3f8f5491389311c30813090f878c27575beedc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:32:12 +0000 Subject: [PATCH 1/3] fix(layout,app-shell): a hidden sidebar group label takes no pointer events in icon mode (objectui#12744) In the collapsed icon sidebar the SidebarGroupLabel primitive hides a label with opacity-0 and -mt-8 only, so the invisible label stays hit-testable over the lower half of the row just above its group. A centre click on that row's icon toggled the group (or Recent) instead of navigating. The labels measured to intercept now carry the primitive's own hiding condition as a pointer-events-none variant, through the className each call site passes (the primitive is a synced Shadcn file): NavigationRenderer's collapsible group label and its Favorites label, and UnifiedSidebar's Recent label. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../app-shell/src/layout/UnifiedSidebar.tsx | 16 +++++++++++- packages/layout/src/NavigationRenderer.tsx | 26 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index e2556b7acb..c1d484813a 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -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 @@ -542,7 +556,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { {recentItems.length > 0 && ( setRecentExpanded(prev => !prev)} > diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 7c111e01d5..af715ecb53 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -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 // --------------------------------------------------------------------------- @@ -1538,7 +1560,7 @@ function NavigationItemRenderer({ const group = ( - + {groupLabel} 0; const favoritesSection = showPinned || hasExtraFavorites ? ( - + {tProp ? tProp('console.nav.favorites', { defaultValue: 'Favorites' }) : 'Favorites'} From 5b24626ba0caf59c32c56c9bd764a940442e215c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:34:05 +0000 Subject: [PATCH 2/3] test(layout,app-shell): pin the icon-mode hidden labels' pointer-events variant (objectui#12744) Collapsed to icons, NavigationRenderer's collapsible group label and Favorites label and UnifiedSidebar's Recent label carry pointer-events-none under the variant the primitive hides them with (derived from its opacity-0 token). In the expanded sidebar none carries an unconditional pointer-events-none, a group label still toggles its group by mouse and keyboard, and the Recent label still opens and closes Recent. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- ...Sidebar.iconModeRecentLabel-12744.test.tsx | 176 ++++++++++++++++++ ...nderer.iconModeLabelHitTest-12744.test.tsx | 126 +++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeRecentLabel-12744.test.tsx create mode 100644 packages/layout/src/__tests__/NavigationRenderer.iconModeLabelHitTest-12744.test.tsx diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeRecentLabel-12744.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeRecentLabel-12744.test.tsx new file mode 100644 index 0000000000..f4a193f6c6 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeRecentLabel-12744.test.tsx @@ -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>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => 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>()), + useAuth: () => ({ user: null, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => ({ + ...(await importOriginal()), + 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 ( + + + + + + ); +} + +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(); + }); +}); diff --git a/packages/layout/src/__tests__/NavigationRenderer.iconModeLabelHitTest-12744.test.tsx b/packages/layout/src/__tests__/NavigationRenderer.iconModeLabelHitTest-12744.test.tsx new file mode 100644 index 0000000000..92ed3b8b38 --- /dev/null +++ b/packages/layout/src/__tests__/NavigationRenderer.iconModeLabelHitTest-12744.test.tsx @@ -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 => + ({ 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), + { + id: 'grp_sales', + type: 'group', + label: 'Sales', + expanded: true, + children: [entry('nav_accounts'), entry('nav_contacts')], + } as NavigationItem, +]; + +function renderSidebar(open: boolean) { + return render( + + + + + {}} /> + + + + , + ); +} + +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(); + }); +}); From 6a571d032cd18784e53f10c87fcbbd40a262fe2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:41:01 +0000 Subject: [PATCH 3/3] chore(changeset): icon-mode sidebar label click fix (objectui#12744) Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12744-icon-sidebar-label-click.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/12744-icon-sidebar-label-click.md diff --git a/.changeset/12744-icon-sidebar-label-click.md b/.changeset/12744-icon-sidebar-label-click.md new file mode 100644 index 0000000000..4edc3f7022 --- /dev/null +++ b/.changeset/12744-icon-sidebar-label-click.md @@ -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.