From 404411989b77adef5c124c9af4a4e7084c2ffc4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:55:06 +0000 Subject: [PATCH 1/7] feat(layout,app-shell): the sidebar menu is not user-reorderable; the pinned section keeps the user's order (objectui#12059) The runtime sidebar drew a drag grip on every menu row and kept a private per-user menu order in localStorage. Arranging an app's navigation is Studio's job, so the menu reorder is retired and personal ordering moves to the pinned section, persisted with the pins in the favorites store. - layout: NavigationRenderer retires enableReorder / onReorder and every menu sortable path; adds pinnedOrder and onPinnedReorder. A pinned row is dragged by the row itself (no grip, cursor-grab, insertion line), and from the keyboard through dnd-kit's KeyboardSensor (Space / arrows / Space, Escape cancels). AppSchemaRenderer stops forwarding the retired props. - app-shell: UnifiedSidebar drops useNavOrder (objectui-nav-order-APP is no longer read or written) and passes the pinned order. The favorites store appends a new nav pin and gains reorderNavPins; useNavPins gains reorderPins. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/context/FavoritesProvider.tsx | 82 ++- packages/app-shell/src/hooks/useNavPins.ts | 18 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 178 +----- ...fiedSidebar.groupedNavOrder-11626.test.tsx | 350 ----------- packages/layout/src/AppSchemaRenderer.tsx | 18 +- packages/layout/src/NavigationRenderer.tsx | 570 ++++++++---------- ...hemaRenderer.mobileTabOrder-11395.test.tsx | 4 +- .../src/__tests__/AppSchemaRenderer.test.tsx | 14 +- ...tionRenderer.groupedReorder-11626.test.tsx | 275 --------- ...igationRenderer.listNesting-11690.test.tsx | 33 +- ...igationRenderer.pinnedOrder-12059.test.tsx | 260 ++++++++ 11 files changed, 634 insertions(+), 1168 deletions(-) delete mode 100644 packages/app-shell/src/layout/__tests__/UnifiedSidebar.groupedNavOrder-11626.test.tsx delete mode 100644 packages/layout/src/__tests__/NavigationRenderer.groupedReorder-11626.test.tsx create mode 100644 packages/layout/src/__tests__/NavigationRenderer.pinnedOrder-12059.test.tsx diff --git a/packages/app-shell/src/context/FavoritesProvider.tsx b/packages/app-shell/src/context/FavoritesProvider.tsx index f16616ee5d..2dcc6b44da 100644 --- a/packages/app-shell/src/context/FavoritesProvider.tsx +++ b/packages/app-shell/src/context/FavoritesProvider.tsx @@ -98,9 +98,18 @@ interface FavoritesContextValue { isPinned: (id: string) => boolean; /** * Set of NavigationItem ids that are currently pinned via a `type === 'nav'` - * favorite. Memoised; safe to use as a dependency. + * favorite, in the user's pinned order (objectui#12059): the order of the + * nav favorites in `favorites`. Memoised; safe to use as a dependency. */ pinnedNavIds: Set; + /** + * Put the named nav pins in this order (objectui#12059). `navIds` lists the + * `navId`s of nav favorites as the user arranged them; they take the + * positions those favorites already hold, in the order given, so a pin that + * is not named (one another app's sidebar draws, say) and every content + * favorite keep their places. An id with no nav favorite is ignored. + */ + reorderNavPins: (navIds: string[]) => void; } // --------------------------------------------------------------------------- @@ -148,6 +157,51 @@ function capByBucket(items: FavoriteItem[]): FavoriteItem[] { return result; } +/** + * `items` with `entry` added. A content favorite goes first (the most recent + * star leads). A nav pin goes LAST: the nav favorites' order is the user's + * pinned order, and a new pin joins it at the end (objectui#12059). When that + * takes the nav bucket past its cap, the earliest-pinned nav favorite rolls + * off, as the oldest did when pins were prepended. + */ +function withAddedFavorite(items: FavoriteItem[], entry: FavoriteItem): FavoriteItem[] { + if (entry.type !== 'nav') return capByBucket([entry, ...items]); + const next = [...items, entry]; + const navPins = next.filter(f => f.type === 'nav'); + if (navPins.length <= MAX_NAV_PINS) return capByBucket(next); + const earliest = navPins + .slice(0, -1) + .reduce((a, b) => (b.favoritedAt < a.favoritedAt ? b : a)); + return capByBucket(next.filter(f => f !== earliest)); +} + +/** + * `items` with the nav favorites named by `navIds` put in that order, in the + * positions they already hold; `items` itself when nothing moves. + */ +function withNavPinOrder(items: FavoriteItem[], navIds: string[]): FavoriteItem[] { + const byNavId = new Map(); + for (const f of items) { + if (f.type === 'nav' && f.navId && !byNavId.has(f.navId)) byNavId.set(f.navId, f); + } + const wanted: FavoriteItem[] = []; + const seen = new Set(); + for (const id of navIds) { + const f = byNavId.get(id); + if (f && !seen.has(id)) { wanted.push(f); seen.add(id); } + } + const moving = new Set(wanted); + let k = 0; + let changed = false; + const next = items.map(f => { + if (!moving.has(f)) return f; + const placed = wanted[k++]; + if (placed !== f) changed = true; + return placed; + }); + return changed ? next : items; +} + function loadFavorites(userId?: string | null): FavoriteItem[] { try { const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId)); @@ -362,10 +416,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) { (item: Omit) => { setFavorites(prev => { if (prev.some(f => f.id === item.id)) return prev; - const updated = capByBucket([ - { ...item, favoritedAt: new Date().toISOString() }, - ...prev, - ]); + const updated = withAddedFavorite(prev, { ...item, favoritedAt: new Date().toISOString() }); commit(updated); return updated; }); @@ -390,10 +441,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) { const exists = prev.some(f => f.id === item.id); const updated = exists ? prev.filter(f => f.id !== item.id) - : capByBucket([ - { ...item, favoritedAt: new Date().toISOString() }, - ...prev, - ]); + : withAddedFavorite(prev, { ...item, favoritedAt: new Date().toISOString() }); commit(updated); return updated; }); @@ -445,6 +493,18 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) { [commit], ); + const reorderNavPins = useCallback( + (navIds: string[]) => { + setFavorites(prev => { + const updated = withNavPinOrder(prev, navIds); + if (updated === prev) return prev; + commit(updated); + return updated; + }); + }, + [commit], + ); + const value = useMemo(() => { const pinnedNavIds = new Set(); for (const f of favorites) { @@ -461,8 +521,9 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) { setPinned, isPinned: (id: string) => favorites.some(f => f.id === id && !!f.pinned), pinnedNavIds, + reorderNavPins, }; - }, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned]); + }, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned, reorderNavPins]); return ( @@ -497,6 +558,7 @@ export function useFavorites(): FavoritesContextValue { setPinned: () => {}, isPinned: () => false, pinnedNavIds: new Set(), + reorderNavPins: () => {}, }; } return ctx; diff --git a/packages/app-shell/src/hooks/useNavPins.ts b/packages/app-shell/src/hooks/useNavPins.ts index 0e7f2dbd30..3127a0d624 100644 --- a/packages/app-shell/src/hooks/useNavPins.ts +++ b/packages/app-shell/src/hooks/useNavPins.ts @@ -7,6 +7,11 @@ * the originating `NavigationItem.id`. Backed by the same backend channel as * regular favorites (UserDataAdapter), so pins now sync across devices. * + * The pins keep the user's order (objectui#12059): `pinnedIds` lists them in + * it, a new pin joins it at the end, an unpin leaves it, and `reorderPins` + * rearranges it. The order is the nav favorites' order in that same store, so + * it syncs with the pins and never lives in localStorage alone. + * * Migration from the old `objectui-nav-pins` localStorage key happens once * on `` mount — see `migrateLegacyNavPins`. * @@ -52,6 +57,7 @@ export function useNavPins() { removeFavorite, setPinned, pinnedNavIds, + reorderNavPins, } = useFavorites(); const togglePin = useCallback( @@ -135,9 +141,19 @@ export function useNavPins() { } }, [favorites, removeFavorite]); + /** The pinned nav ids, in the user's pinned order. */ const pinnedIds = useMemo(() => Array.from(pinnedNavIds), [pinnedNavIds]); - return { pinnedIds, togglePin, isPinned, applyPins, clearPins }; + /** + * Store a new pinned order: `orderedIds` are nav ids as the user arranged + * them. Pins it does not name (another app's, say) keep their places. + */ + const reorderPins = useCallback( + (orderedIds: string[]) => reorderNavPins(orderedIds), + [reorderNavPins], + ); + + return { pinnedIds, togglePin, isPinned, applyPins, clearPins, reorderPins }; } // Re-export the type for legacy import paths. diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 3c0930e3da..bdcf01fdbf 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -70,146 +70,6 @@ import { } from './ContextSelectors.js'; import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js'; -// --------------------------------------------------------------------------- -// useNavOrder – localStorage-persisted drag-and-drop reorder for nav items -// --------------------------------------------------------------------------- - -/** - * The stored key of the top level's order. A group's order is stored beside it - * under the group's `id` (objectui#11626): the one key a group has that holds - * across reloads and locales (its label is translated). A spec-valid `id` is - * snake_case starting with a letter, so no group can be keyed `__root__`. - */ -const ROOT_ORDER_KEY = '__root__'; - -/** - * One level in its saved order: the saved ids first, in the saved order, then - * the entries the saved order does not name, in the order the app lists them. - * An id saved for an entry that is gone is skipped. - * - * Each entry of a level with a saved order carries its position there as - * `order` (0, 1, 2, …). The renderer sorts every level by `order` (the spec's - * "Sort order within the same level"), so for an app that authors `order` a - * saved order left in array position only was sorted straight back into the - * app's own, and a drag snapped back (objectui#11626). A level with no saved - * order is returned untouched and keeps following the app. - */ -function applyLevelOrder(items: NavigationItem[], saved: string[] | undefined): NavigationItem[] { - if (!saved) return items; - const byId = new Map(items.map(i => [i.id, i])); - const ordered: NavigationItem[] = []; - for (const id of saved) { - const item = byId.get(id); - if (item) { ordered.push(item); byId.delete(id); } - } - byId.forEach(item => ordered.push(item)); - return ordered.map((item, idx) => (item.order === idx ? item : { ...item, order: idx })); -} - -/** Every group's saved order applied to its children, at every depth. */ -function applyGroupOrders( - items: NavigationItem[], - orderMap: Record, -): NavigationItem[] { - let changed = false; - const next = items.map(item => { - if (item.type !== 'group' || !item.children?.length) return item; - const children = applyGroupOrders(applyLevelOrder(item.children, orderMap[item.id]), orderMap); - if (children === item.children) return item; - changed = true; - return { ...item, children }; - }); - return changed ? next : items; -} - -/** Each group's children, by group `id`, at every depth. */ -function groupChildren( - items: NavigationItem[], - into: Map = new Map(), -): Map { - for (const item of items) { - if (item.type !== 'group') continue; - const children = item.children ?? []; - into.set(item.id, children); - groupChildren(children, into); - } - return into; -} - -/** The same entries, in the same places, each with the same `order`. */ -const sameLevel = (a: NavigationItem[], b: NavigationItem[]) => - a.length === b.length && a.every((item, i) => item.id === b[i].id && item.order === b[i].order); - -function useNavOrder(appName: string) { - const storageKey = `objectui-nav-order-${appName}`; - - const [orderMap, setOrderMap] = React.useState>(() => { - try { - const raw = localStorage.getItem(storageKey); - if (!raw) return {}; - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(parsed as Record)) { - if (Array.isArray(v) && v.every((i: unknown) => typeof i === 'string')) { - result[k] = v as string[]; - } - } - return result; - } catch { - return {}; - } - }); - - const persist = React.useCallback( - (next: Record) => { - setOrderMap(next); - try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* full */ } - }, - [storageKey], - ); - - const applyOrder = React.useCallback( - (items: NavigationItem[]): NavigationItem[] => - applyGroupOrders(applyLevelOrder(items, orderMap[ROOT_ORDER_KEY]), orderMap), - [orderMap], - ); - - /** - * `drawn` is the tree this sidebar handed the renderer. A move within a group - * comes back as the top-level list with that group's children reordered - * (objectui#11626), so the group whose children now differ from the drawn - * ones is the group that moved, and only its order is stored: the top level - * and every other group keep following the app until the user moves them. - * A report in which no group's children moved is a move among top-level - * entries, stored under `__root__` exactly as it was before groups had one. - * - * Children are compared by id AND `order`. The moved level comes back with - * its positions as `order` (0, 1, 2, …); the renderer had sorted it by - * `order` first, so where an app authors `order` the move can land on the - * drawn ARRAY's own id sequence, and only the positions tell it apart. - */ - const handleReorder = React.useCallback( - (reorderedItems: NavigationItem[], drawn: NavigationItem[]) => { - const before = groupChildren(drawn); - const movedGroups: Record = {}; - groupChildren(reorderedItems).forEach((children, groupId) => { - const was = before.get(groupId); - if (was && !sameLevel(was, children)) movedGroups[groupId] = children.map(c => c.id); - }); - if (Object.keys(movedGroups).length > 0) { - persist({ ...orderMap, ...movedGroups }); - return; - } - const ids = reorderedItems.map(i => i.id); - persist({ ...orderMap, [ROOT_ORDER_KEY]: ids }); - }, - [orderMap, persist], - ); - - return { applyOrder, handleReorder }; -} - /** * Lazy-resolved Lucide icon — see ../utils/getIcon for impl. * The local symbol is kept for backwards compat with existing call sites @@ -306,9 +166,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { // sidebar renders exactly as it did before the key had a reader. const appLogo: string | undefined = context === 'app' ? activeApp?.branding?.logo : undefined; - // Drag-reorder and pin persistence - const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home'); - const { togglePin, applyPins } = useNavPins(); + // Pin persistence, and the pinned section's order (objectui#12059). The app's + // own menu is drawn in the order the app authors: arranging it is Studio's + // job, so this sidebar offers no menu reorder and keeps no personal menu + // order. A stored `objectui-nav-order-` key from before is not read. + const { togglePin, applyPins, pinnedIds, reorderPins } = useNavPins(); // Visibility evaluation const { evaluator } = useExpressionContext(); @@ -539,16 +401,10 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { }); }, [activeApp?.name, basePath, context, navigationItems, studioHomeSearch, t]); - // Apply saved order and pin state - const processedNavigation = React.useMemo(() => { - const ordered = applyOrder(studioNavigationItems); - return applyPins(ordered); - }, [studioNavigationItems, applyOrder, applyPins]); - // The drawn tree goes along with a report, so the store can tell which - // level moved (objectui#11626). - const handleNavReorder = React.useCallback( - (reorderedItems: NavigationItem[]) => handleReorder(reorderedItems, processedNavigation), - [handleReorder, processedNavigation], + // Apply pin state + const processedNavigation = React.useMemo( + () => applyPins(studioNavigationItems), + [studioNavigationItems, applyPins], ); // Recent section collapsed by default @@ -674,8 +530,8 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { checkDocTarget={checkDocTarget} enablePinning={!isMobile} onPinToggle={togglePin} - enableReorder={!isMobile} - onReorder={handleNavReorder} + pinnedOrder={pinnedIds} + onPinnedReorder={reorderPins} resolveTargetLabel={resolveNavTargetLabel} locale={language} onAction={dispatchNavAction} @@ -784,13 +640,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { surviving top-level links keep byte-identical hrefs. Deliberately NOT forwarded from the app arm: - - `enablePinning` / `enableReorder` — both persist under - `useNavOrder(activeApp?.name || 'home')`, and in the home - context `activeApp` resolves to the FIRST app rather than to - home, so home navigation would adopt that app's saved root - order. A pinned section would also land directly above this - arm's own "Starred" group. Separate product decisions, not part - of unflattening a group. + - `enablePinning` (and the pinned section's order) — a pinned + section would land directly above this arm's own "Starred" + group. A separate product decision, not part of unflattening + a group. (The app arm's menu reorder, also left out here, is + retired: objectui#12059.) (Until `9c60144b5` this list also named `resolveGroupLabel` / `resolveItemLabel`. Those props are gone from the renderer entirely — they were unreachable for every real nav entry, and diff --git a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.groupedNavOrder-11626.test.tsx b/packages/app-shell/src/layout/__tests__/UnifiedSidebar.groupedNavOrder-11626.test.tsx deleted file mode 100644 index 3a0130edcb..0000000000 --- a/packages/app-shell/src/layout/__tests__/UnifiedSidebar.groupedNavOrder-11626.test.tsx +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * UnifiedSidebar — a personal order made within a group of a grouped app - * menu is stored under that group's `id` and restored on the next load - * (objectui#11626). - * - * The store (`useNavOrder`, localStorage `objectui-nav-order-`) kept one - * order, `__root__`, and the renderer drew no grip on a grouped menu, so on - * every stock app the shell's `enableReorder` did nothing. Triage's direction - * (`5986793209`): an order per group key beside `__root__`; a group's key is its - * `id`, which holds across reloads and locales where its label does not. - * - * This pins the WIRING in the real sidebar: the real `NavigationRenderer` - * reports a move through `onReorder`, the real store writes it, and a fresh - * mount reads it back. The group-free app's `__root__` record is pinned - * byte-for-byte, because it must not change. - * - * Instrument: a module mock of `@dnd-kit/core` that renders the REAL - * `DndContext` and records its `onDragEnd` by context `id` (the one - * `NavigationRenderer.groupedReorder-11626.test.tsx` uses, after - * `plugin-kanban`'s `sameColumnDropIsNotClaimed-8826.test.tsx`). - * Harness: the provider / chrome mocks of - * `UnifiedSidebar.navLabelInheritsTarget-9868.test.tsx`; `@object-ui/layout` - * and `@object-ui/components` stay REAL. - */ - -import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import React from 'react'; -import { render, screen, act, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import type { NavigationItem } from '@object-ui/types'; - -type DragEnd = (event: { active: { id: string }; over: { id: string } | null }) => void; - -const dnd = vi.hoisted(() => ({ byContext: new Map() })); -// The nav ids the (mocked) pin store answers as pinned. -const pins = vi.hoisted(() => ({ ids: new Set() })); - -vi.mock('@dnd-kit/core', async (importOriginal) => { - const actual = await importOriginal(); - const ReactMod = await import('react'); - const CapturingDndContext = (props: Record) => { - dnd.byContext.set(String(props.id ?? '(no id)'), props.onDragEnd as DragEnd); - return ReactMod.createElement(actual.DndContext, props as never); - }; - return { ...actual, DndContext: CapturingDndContext }; -}); - -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', -})); - -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: [] }) })); -vi.mock('../../hooks/useFavorites', () => ({ - useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }), -})); -vi.mock('../../hooks/useNavPins', () => ({ - useNavPins: () => ({ - togglePin: vi.fn(), - applyPins: (items: NavigationItem[]) => { - const walk = (list: NavigationItem[]): NavigationItem[] => - list.map((item) => ({ - ...item, - ...(item.type !== 'separator' && pins.ids.has(item.id) ? { pinned: true } : {}), - ...(item.children ? { children: walk(item.children) } : {}), - }) as NavigationItem); - return walk(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 STORE = 'objectui-nav-order-crm'; - -const entry = (id: string, label: string, extra: Partial = {}): NavigationItem => - ({ id, type: 'object', objectName: id, label, ...extra }) as NavigationItem; - -/** The stock showcase's shape: top-level entries, then groups keyed by `id`. */ -const GROUPED: NavigationItem[] = [ - entry('nav_map', 'Map'), - entry('nav_start', 'Start'), - { - id: 'grp_sales', - type: 'group', - // A translated label: the store must not key on it. - label: 'Sales', - children: [entry('nav_accounts', 'Accounts'), entry('nav_contacts', 'Contacts'), entry('nav_leads', 'Leads')], - }, - { - id: 'grp_ops', - type: 'group', - label: 'Operations', - children: [entry('nav_tasks', 'Tasks'), entry('nav_projects', 'Projects')], - }, -]; - -function sidebarUi(navigation: NavigationItem[]) { - metadataState = { - apps: [{ name: 'crm', label: 'CRM', active: true, navigation }], - objects: [], - }; - return ( - - - - - - ); -} - -function drop(contextId: string, activeId: string, overId: string) { - const onDragEnd = dnd.byContext.get(contextId); - expect(onDragEnd, `a DndContext with id ${contextId} was mounted`).toBeTypeOf('function'); - act(() => onDragEnd!({ active: { id: activeId }, over: { id: overId } })); -} - -const stored = () => JSON.parse(localStorage.getItem(STORE) ?? 'null'); -/** The link texts in document order, limited to the given names. */ -const linkOrder = (names: string[]) => - screen - .getAllByRole('link') - .map((a) => a.textContent ?? '') - .filter((text) => names.includes(text)); - -beforeEach(() => { - localStorage.clear(); - dnd.byContext.clear(); - pins.ids = new Set(); -}); - -describe('UnifiedSidebar — a grouped app menu keeps a personal order per group (objectui#11626)', () => { - it('the shell draws a grip on the grouped menu', () => { - const { container } = render(sidebarUi(GROUPED)); - expect(container.querySelectorAll('[aria-label="Drag to reorder"]')).toHaveLength(7); - }); - - it('a move within one group stores that group’s order under its id, and nothing else', () => { - render(sidebarUi(GROUPED)); - - drop('nav-reorder-group-grp_sales', 'nav_leads', 'nav_accounts'); - - expect(stored()).toEqual({ grp_sales: ['nav_leads', 'nav_accounts', 'nav_contacts'] }); - // Drawn in the new order at once. - expect(linkOrder(['Accounts', 'Contacts', 'Leads'])).toEqual(['Leads', 'Accounts', 'Contacts']); - }); - - it('the order is restored after a reload, and the rest of the menu follows the app', () => { - const first = render(sidebarUi(GROUPED)); - drop('nav-reorder-group-grp_sales', 'nav_leads', 'nav_accounts'); - first.unmount(); - - render(sidebarUi(GROUPED)); - expect(linkOrder(['Accounts', 'Contacts', 'Leads'])).toEqual(['Leads', 'Accounts', 'Contacts']); - expect(linkOrder(['Map', 'Start'])).toEqual(['Map', 'Start']); - expect(linkOrder(['Tasks', 'Projects'])).toEqual(['Tasks', 'Projects']); - }); - - it('a second group’s move is stored beside the first; a top-level move beside both', () => { - render(sidebarUi(GROUPED)); - - drop('nav-reorder-group-grp_sales', 'nav_leads', 'nav_accounts'); - drop('nav-reorder-group-grp_ops', 'nav_projects', 'nav_tasks'); - expect(stored()).toEqual({ - grp_sales: ['nav_leads', 'nav_accounts', 'nav_contacts'], - grp_ops: ['nav_projects', 'nav_tasks'], - }); - - drop('nav-reorder-top-nav_map', 'nav_start', 'nav_map'); - expect(stored()).toEqual({ - grp_sales: ['nav_leads', 'nav_accounts', 'nav_contacts'], - grp_ops: ['nav_projects', 'nav_tasks'], - __root__: ['nav_start', 'nav_map', 'grp_sales', 'grp_ops'], - }); - expect(linkOrder(['Map', 'Start'])).toEqual(['Start', 'Map']); - expect(linkOrder(['Tasks', 'Projects'])).toEqual(['Projects', 'Tasks']); - }); - - it('a pinned entry and a gated-away entry keep working through a move', () => { - pins.ids = new Set(['nav_contacts']); - const navigation: NavigationItem[] = [ - { - id: 'grp_sales', - type: 'group', - label: 'Sales', - children: [ - entry('nav_accounts', 'Accounts'), - entry('nav_hidden', 'Hidden', { visible: false } as Partial), - entry('nav_contacts', 'Contacts'), - ], - }, - ]; - const first = render(sidebarUi(navigation)); - const favorites = () => within(screen.getByText('Favorites').closest('[data-sidebar="group"]') as HTMLElement); - expect(favorites().getByRole('link', { name: 'Contacts' })).toBeInTheDocument(); - - drop('nav-reorder-group-grp_sales', 'nav_contacts', 'nav_accounts'); - - // The gated-away id keeps its place in the stored order; it stays hidden. - expect(stored()).toEqual({ grp_sales: ['nav_contacts', 'nav_accounts', 'nav_hidden'] }); - expect(screen.queryByRole('link', { name: 'Hidden' })).not.toBeInTheDocument(); - first.unmount(); - - render(sidebarUi(navigation)); - expect(favorites().getByRole('link', { name: 'Contacts' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Hidden' })).not.toBeInTheDocument(); - }); -}); - -describe('UnifiedSidebar — a saved order holds where the app authors `order` (objectui#11626)', () => { - // The renderer sorts every level by `order`. A saved order applied by array - // position alone was sorted straight back into the app's: stored, never drawn. - const AUTHORED: NavigationItem[] = [ - { - id: 'grp_sales', - type: 'group', - label: 'Sales', - children: [ - entry('nav_accounts', 'Accounts', { order: 1 }), - entry('nav_contacts', 'Contacts', { order: 2 }), - entry('nav_leads', 'Leads', { order: 3 }), - ], - }, - ]; - - it('a moved group is drawn in its new order at once and after a reload', () => { - const first = render(sidebarUi(AUTHORED)); - expect(linkOrder(['Accounts', 'Contacts', 'Leads'])).toEqual(['Accounts', 'Contacts', 'Leads']); - - drop('nav-reorder-group-grp_sales', 'nav_leads', 'nav_accounts'); - - expect(stored()).toEqual({ grp_sales: ['nav_leads', 'nav_accounts', 'nav_contacts'] }); - expect(linkOrder(['Accounts', 'Contacts', 'Leads'])).toEqual(['Leads', 'Accounts', 'Contacts']); - first.unmount(); - - render(sidebarUi(AUTHORED)); - expect(linkOrder(['Accounts', 'Contacts', 'Leads'])).toEqual(['Leads', 'Accounts', 'Contacts']); - }); - - it('a move that lands on the listed sequence is still that group’s move, not a top-level one', () => { - // Listed Alpha, Beta; authored `order` draws Beta first. Dragging Alpha - // above Beta reports the group as Alpha, Beta: the listed id sequence. - const navigation: NavigationItem[] = [ - entry('nav_top', 'Top'), - { - id: 'grp_ab', - type: 'group', - label: 'AB', - children: [entry('nav_alpha', 'Alpha', { order: 2 }), entry('nav_beta', 'Beta', { order: 1 })], - }, - ]; - render(sidebarUi(navigation)); - expect(linkOrder(['Alpha', 'Beta'])).toEqual(['Beta', 'Alpha']); - - drop('nav-reorder-group-grp_ab', 'nav_alpha', 'nav_beta'); - - expect(stored()).toEqual({ grp_ab: ['nav_alpha', 'nav_beta'] }); - expect(linkOrder(['Alpha', 'Beta'])).toEqual(['Alpha', 'Beta']); - }); -}); - -describe('UnifiedSidebar — a group-free app’s `__root__` record is unchanged (objectui#11626)', () => { - const FLAT: NavigationItem[] = [entry('nav_a', 'Alpha'), entry('nav_b', 'Beta'), entry('nav_c', 'Gamma')]; - - it('a move stores `__root__` byte-for-byte as before, and a reload applies it', () => { - const first = render(sidebarUi(FLAT)); - drop('(no id)', 'nav_c', 'nav_a'); - - expect(localStorage.getItem(STORE)).toBe('{"__root__":["nav_c","nav_a","nav_b"]}'); - first.unmount(); - - render(sidebarUi(FLAT)); - expect(linkOrder(['Alpha', 'Beta', 'Gamma'])).toEqual(['Gamma', 'Alpha', 'Beta']); - }); - - it('a stored `__root__` record written before this change still reads back', () => { - localStorage.setItem(STORE, '{"__root__":["nav_b","nav_c","nav_a"]}'); - render(sidebarUi(FLAT)); - expect(linkOrder(['Alpha', 'Beta', 'Gamma'])).toEqual(['Beta', 'Gamma', 'Alpha']); - }); - - it('where the app authors `order`, the record is the same and a saved order is now drawn', () => { - const authored = FLAT.map((item, i) => ({ ...item, order: i + 1 })); - const first = render(sidebarUi(authored)); - drop('(no id)', 'nav_c', 'nav_a'); - - expect(localStorage.getItem(STORE)).toBe('{"__root__":["nav_c","nav_a","nav_b"]}'); - expect(linkOrder(['Alpha', 'Beta', 'Gamma'])).toEqual(['Gamma', 'Alpha', 'Beta']); - first.unmount(); - - render(sidebarUi(authored)); - expect(linkOrder(['Alpha', 'Beta', 'Gamma'])).toEqual(['Gamma', 'Alpha', 'Beta']); - }); -}); diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 5800337731..890204b30a 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -141,11 +141,9 @@ export interface AppSchemaRendererProps { /** Called when a navigation item is pinned or unpinned */ onPinToggle?: (itemId: string, pinned: boolean, item?: NavigationItem, basePath?: string) => void; - /** Enable drag-to-reorder for navigation items */ - enableReorder?: boolean; - - /** Called when navigation items are reordered via drag */ - onReorder?: (reorderedItems: NavigationItem[]) => void; + // RETIRED (objectui#12059): `enableReorder` / `onReorder`. They only handed + // `NavigationRenderer`'s menu reorder through, which is retired: an app's + // menu order is authored in Studio, and every user sees it. Do not re-add. } // --------------------------------------------------------------------------- @@ -417,8 +415,6 @@ function InternalSidebar({ enableSearch, enablePinning, onPinToggle, - enableReorder, - onReorder, }: { schema: AppComponentSchema; basePath: string; @@ -438,8 +434,6 @@ function InternalSidebar({ enableSearch?: boolean; enablePinning?: boolean; onPinToggle?: (itemId: string, pinned: boolean, item?: NavigationItem, basePath?: string) => void; - enableReorder?: boolean; - onReorder?: (reorderedItems: NavigationItem[]) => void; }) { // The app logo is `branding.logo`, an image URL (objectui#10827) — the one // spelling `@objectstack/spec` declares. An icon NAME comes from `icon`, the @@ -517,8 +511,6 @@ function InternalSidebar({ searchQuery={searchQuery} enablePinning={enablePinning} onPinToggle={onPinToggle} - enableReorder={enableReorder} - onReorder={onReorder} /> {/* Extra sidebar content slot (e.g. favorites, recent items) */} @@ -580,8 +572,6 @@ export function AppSchemaRenderer({ enableSearch, enablePinning, onPinToggle, - enableReorder, - onReorder, }: AppSchemaRendererProps) { // Default evaluators const evalVis: VisibilityEvaluator = evalVisProp ?? ((expr) => { @@ -675,8 +665,6 @@ export function AppSchemaRenderer({ enableSearch={enableSearch} enablePinning={enablePinning} onPinToggle={onPinToggle} - enableReorder={enableReorder} - onReorder={onReorder} /> ); diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 3da52e94a0..a544f3198a 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -17,7 +17,9 @@ * Enhanced with: * - Search filtering across navigation tree * - Pin/favorite navigation items (pinned items in "Favorites" section) - * - Drag-to-reorder navigation items via @dnd-kit + * - The pinned section in the user's order, rearranged by dragging a row + * (via @dnd-kit). The app's own menu is never reorderable here: its order + * is authored in Studio (objectui#12059). * * @module NavigationRenderer */ @@ -27,7 +29,6 @@ import { Link, useLocation } from 'react-router-dom'; import { ChevronRight, FileText, - GripVertical, Pin, PinOff, Star, @@ -40,15 +41,17 @@ import { PointerSensor, useSensor, useSensors, + type DragCancelEvent, type DragEndEvent, - type DraggableAttributes, type DraggableSyntheticListeners, + type KeyboardSensorOptions, } from '@dnd-kit/core'; import { SortableContext, - verticalListSortingStrategy, useSortable, arrayMove, + sortableKeyboardCoordinates, + type SortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { @@ -222,27 +225,29 @@ export interface NavigationRendererProps { basePath?: string, ) => void; + // RETIRED (objectui#12059): `enableReorder` / `onReorder`, drag-to-reorder of + // the app's menu. A menu's order is authored in Studio and every user sees + // that order (maintainer ruling on objectui#12059, reversing objectui#11626). + // Personal ordering lives in the pinned section only — `pinnedOrder` / + // `onPinnedReorder` below. Do not re-add a menu reorder here. + /** - * Enable drag-to-reorder for navigation items. - * - * An entry moves within its own level only: among the top-level entries of a - * menu with no groups, among one group's children, or, in a grouped menu, - * among a run of top-level entries between two groups. It never moves into or - * out of a group (objectui#11626): which group an entry sits in is the app's - * structure, not a personal order. While `searchQuery` narrows a grouped - * menu, the menu offers no grip, because a narrowed group shows only some of - * its children. + * The ids of the pinned entries in the user's order (objectui#12059). The + * pinned section draws its entries in this order; a pinned entry it does not + * name follows, in menu order. Omitted ⇒ the pinned section follows the menu. */ - enableReorder?: boolean; + pinnedOrder?: string[]; /** - * Called when navigation items are reordered via drag, always with the - * top-level list. After a move among top-level entries, that list is - * reordered. After a move within a group, the top-level list is as drawn and - * that group's `children` are reordered (objectui#11626). The moved level's - * entries carry their new positions as `order` (0, 1, 2, …). + * Makes the pinned section reorderable (objectui#12059): a pinned row is + * dragged by the row itself, with no grip at rest (`cursor: grab`, and an + * insertion line while dragging), or from the keyboard with dnd-kit's + * keyboard sensor (Space picks the focused row up, the arrow keys move it, + * Space drops it, Escape cancels). Called on a drop with the ids of the rows + * the section draws, in their new order. Only the pinned section reorders; + * the menu itself never does. */ - onReorder?: (reorderedItems: NavigationItem[]) => void; + onPinnedReorder?: (pinnedIds: string[]) => void; // RETIRED (objectui#11299): `resolveObjectLabel` / `resolveDashboardLabel` / // `resolveViewLabel`, the three convention resolvers of the retired @@ -1123,66 +1128,43 @@ export function filterNavigationItems( }, []); } -/** Minimum drag distance in pixels to activate reorder */ -const DRAG_ACTIVATION_DISTANCE = 5; - // --------------------------------------------------------------------------- -// Within-level reorder for a grouped menu (objectui#11626) +// The pinned section's own order (objectui#12059) // --------------------------------------------------------------------------- +/** Minimum pointer travel, in pixels, before a press on a pinned row becomes a drag; less is a click. */ +const DRAG_ACTIVATION_DISTANCE = 5; + /** - * One level moved: the entry `activeId` taken out and put where `overId` was, - * every entry of the level carrying its new position as `order`, which is how - * the group-free arm reports a move too. `null` when either id is not in - * `level`. - * - * `level` is the WHOLE level as the renderer orders it, gated-away entries - * included, so the entries a user cannot see keep their places relative to the - * ones the user moved, and the reported level loses none of them. + * The keyboard drag of a pinned row: Space picks the focused row up and drops + * it, the arrow keys move it, Escape cancels. Not Enter, which dnd-kit binds + * too by default: a pinned row is a link (or an action entry's button), and + * Enter is how the keyboard follows it. */ -function moveWithinLevel( - level: NavigationItem[], - activeId: string, - overId: string, -): NavigationItem[] | null { - const oldIndex = level.findIndex((i) => i.id === activeId); - const newIndex = level.findIndex((i) => i.id === overId); - if (oldIndex === -1 || newIndex === -1) return null; - return arrayMove(level, oldIndex, newIndex).map((item, idx) => ({ ...item, order: idx })); -} +const PINNED_KEYBOARD_SENSOR: KeyboardSensorOptions = { + coordinateGetter: sortableKeyboardCoordinates, + keyboardCodes: { start: ['Space'], cancel: ['Escape'], end: ['Space'] }, +}; -/** `items` with the children of the group `groupId` replaced, at any depth. */ -function withGroupChildren( - items: NavigationItem[], - groupId: string, - children: NavigationItem[], -): NavigationItem[] { - return items.map((item) => { - if (item.type !== 'group') return item; - if (item.id === groupId) return { ...item, children }; - if (!item.children?.length) return item; - return { ...item, children: withGroupChildren(item.children, groupId, children) }; - }); -} +/** The other rows hold still while a pinned row is dragged; the insertion line shows where it lands. */ +const holdRowsStill: SortingStrategy = () => null; -/** - * Reports a move within the group `groupId`: its children, already moved by - * {@link moveWithinLevel}. Provided by the grouped arm of - * {@link NavigationRenderer}. `null` means the menu offers no grip on a group's - * children: reorder is off, the menu has no groups, or a search narrows it. - */ -type GroupChildrenReorder = (groupId: string, reorderedChildren: NavigationItem[]) => void; -const GroupReorderContext = React.createContext(null); +/** Whether a drag was started from the keyboard; any other drag was started by a pointer. */ +const startedByKeyboard = (event: { activatorEvent: Event | null }) => + event.activatorEvent?.type === 'keydown'; /** - * Whether `item` draws anything: the decisions `NavigationItemRenderer` takes - * before it returns `null`, asked through the same shared guard statement and - * predicate. A sortable wrapper is put only around an entry that draws, so a - * gated-away entry does not leave an empty wrapper behind as a drop target. + * Swallows the click that ends a pointer drag of a pinned row. The dragged row + * follows the pointer, so the button is released over the row's own link and + * the browser clicks it. dnd-kit stops that click's propagation, so the + * router's `Link` never handles it, but nothing prevents its default: the + * browser would follow the link with a full page load. The guard lasts as long + * as dnd-kit's own (50ms after the drop). */ -function drawsNavItem(item: NavigationItem, options: NavigationVisibilityOptions): boolean { - if (item.type === 'separator') return passesNavItemGuards(item, options); - return hasVisibleNavigationItems([item], options); +function swallowClickAfterPointerDrag(): void { + const swallow = (event: MouseEvent) => event.preventDefault(); + window.addEventListener('click', swallow, { capture: true }); + window.setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 50); } /** The props every row renderer takes besides its `item`. */ @@ -1202,38 +1184,51 @@ interface NavRowProps { } /** - * One level of a grouped menu as a sortable list: its own `DndContext`, so a - * drag starts, moves and drops within this list only and no other list is a - * drop target. The rows are the group-free arm's `SortableNavigationItem`. + * The pinned section as a sortable list: its own `DndContext`, so a pinned row + * moves among the pinned rows only. A drop reports the ids of the rows drawn + * here, in their new order. */ -function SortableNavigationList({ - contextId, +function PinnedNavigationList({ items, - onMove, + onReorder, rowProps, }: { - contextId: string; items: NavigationItem[]; - onMove: (activeId: string, overId: string) => void; + onReorder: (pinnedIds: string[]) => void; rowProps: NavRowProps; }) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), - useSensor(KeyboardSensor), + useSensor(KeyboardSensor, PINNED_KEYBOARD_SENSOR), ); + const ids = items.map((item) => item.id); const handleDragEnd = (event: DragEndEvent) => { + if (!startedByKeyboard(event)) swallowClickAfterPointerDrag(); const { active, over } = event; if (!over || active.id === over.id) return; - onMove(String(active.id), String(over.id)); + const from = ids.indexOf(String(active.id)); + const to = ids.indexOf(String(over.id)); + if (from === -1 || to === -1) return; + onReorder(arrayMove(ids, from, to)); + }; + + const handleDragCancel = (event: DragCancelEvent) => { + if (!startedByKeyboard(event)) swallowClickAfterPointerDrag(); }; return ( - - i.id)} strategy={verticalListSortingStrategy}> + + {items.map((item) => ( - + ))} @@ -1241,69 +1236,45 @@ function SortableNavigationList({ ); } -/** A sortable row's grip: dnd-kit's activator node setter, its ARIA attributes and its listeners. */ -interface NavDragHandle { - activator: (element: HTMLElement | null) => void; - attributes: DraggableAttributes; +/** + * What a sortable row hands the element that starts its drag, the row's own + * link (or an action entry's button): dnd-kit's activator ref, the listeners + * of both sensors, and the id of dnd-kit's keyboard instructions. No grip and + * no extra focus stop: the element a user already reaches is the handle. + */ +interface NavDragActivator { + ref: (element: HTMLElement | null) => void; listeners: DraggableSyntheticListeners; + describedBy: string; } -/** The drag grip drawn at the start of a sortable row; the row's one drag activator. */ -function NavDragGrip({ - handle: { activator, attributes, listeners }, - t, -}: { - handle: NavDragHandle; - t?: NavigationRendererProps['t']; -}) { - return ( - - - - ); +/** + * The props that make a row's own link or button its drag activator. dnd-kit's + * `role="button"` / `tabIndex` are left off, so a link stays a link. Releasing + * Space is held back: Space picks the row up and drops it, and on an action + * entry's button its release would also press the button. + */ +function dragActivatorProps(activator: NavDragActivator) { + return { + ref: activator.ref, + ...activator.listeners, + 'aria-describedby': activator.describedBy, + onKeyUp: (event: React.KeyboardEvent) => { + if (event.code === 'Space') event.preventDefault(); + }, + }; } -// --------------------------------------------------------------------------- -// SortableNavigationItem (drag-reorder wrapper) -// --------------------------------------------------------------------------- +/** What a sortable list hands the row it wraps: dnd-kit's node ref and transform for the row's own `
  • `, and the insertion line. */ +interface NavRowNode { + ref: (element: HTMLElement | null) => void; + style: React.CSSProperties; + /** Where the dragged row would land, drawn as a line on this row's edge. */ + dropIndicator: 'before' | 'after' | null; +} -function SortableNavigationItem({ - item, - basePath, - evalVis, - checkPerm, - checkCap, - checkDocTarget, - onAction, - enablePinning, - onPinToggle, - enableReorder, - resolveTargetLabel, - locale, - t: tProp, - templateContext, -}: { - item: NavigationItem; - basePath: string; - evalVis: VisibilityEvaluator; - checkPerm: PermissionChecker; - checkCap: CapabilityChecker; - checkDocTarget?: DocTargetChecker; - onAction?: (item: NavigationItem) => void; - enablePinning?: boolean; - onPinToggle?: (itemId: string, pinned: boolean, item?: NavigationItem, basePath?: string) => void; - enableReorder?: boolean; - resolveTargetLabel?: NavTargetLabelResolver; - locale?: string; - t?: (key: string, options?: any) => string; - templateContext?: NavTemplateContext; -}) { +/** One pinned row, sortable by the row itself. */ +function PinnedNavigationRow({ item, rowProps }: { item: NavigationItem; rowProps: NavRowProps }) { const { attributes, listeners, @@ -1312,7 +1283,10 @@ function SortableNavigationItem({ transform, transition, isDragging, - } = useSortable({ id: item.id, disabled: !enableReorder }); + isOver, + activeIndex, + index, + } = useSortable({ id: item.id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -1320,42 +1294,35 @@ function SortableNavigationItem({ opacity: isDragging ? 0.5 : undefined, zIndex: isDragging ? 10 : undefined, }; + const dropIndicator = + isOver && !isDragging && activeIndex !== -1 ? (activeIndex < index ? 'after' : 'before') : null; - // The grip is the drag activator: dnd-kit's `attributes` (`role="button"`, - // `tabIndex={0}`, the sortable ARIA description) go on it together with the - // `listeners`, so the one element a keyboard can focus is the one the - // KeyboardSensor listens on. On the row wrapper they made every row a - // focusable "button" that no key could start a drag from (objectui#11626). - // - // The sortable NODE is the row's own `
  • ` (`row`), not a wrapper around - // it: a `
    ` between the menu's `
      ` and its `
    • `s broke the list - // for assistive tech — a list whose children are not items, and items with - // no list (axe `list` / `listitem`, objectui#11690). return ( ); } -/** What a sortable list hands the row it wraps: dnd-kit's node ref and transform, for the row's own `
    • `. */ -interface NavRowNode { - ref: (element: HTMLElement | null) => void; - style: React.CSSProperties; +/** The insertion line on a row's edge while a pinned row is dragged over it. */ +function DropIndicator({ at }: { at: 'before' | 'after' }) { + return ( +