fix(layout,app-shell): drag-to-reorder works within each level of a grouped sidebar (objectui#11626) - #11656
Conversation
…rouped sidebar (objectui#11626) The sortable path existed only in NavigationRenderer's group-free arm, so on every grouped menu (every stock app) `enableReorder` drew no grip. Each group's children, and each run of top-level entries between two groups, is now a sortable list of its own; nothing moves into or out of a group. The move is reported through the existing `onReorder(reorderedItems)`: the top-level list with the moved group's children reordered. The console's nav-order store keeps an order per group `id` beside `__root__`, writes only the level that moved, and applies every level on read. The group-free arm and its `__root__` record are unchanged. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…`__root__` record (objectui#11626) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…1626) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…prop (objectui#11626) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…ent route (objectui#11626) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…`order`, and the grip is the drag activator (objectui#11626) Review of the resumed branch found two holes the grouped arm inherited from the group-free one, both measured before this change: - The renderer sorts every level by `order`. `useNavOrder` applied a saved order by array position only, so where an app authors `order` a drag was stored and sorted straight back (group and `__root__` alike). A level with a saved order now carries its positions as `order`; a level without one is untouched. The moved-group detection compares `order` as well as ids, since a move under authored `order` can land on the listed id sequence. - dnd-kit's `attributes` (role=button, tabIndex=0) sat on the row wrapper and its `listeners` on the grip, so every row was a focusable "button" no key could drag from. The grip is now the activator (`setActivatorNodeRef`), with attributes and listeners together. Spreading the old wrapper to every grouped menu would have added one dead tab stop per entry on every stock app. The stored `__root__` record of a group-free app is byte-identical. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…es (objectui#11626) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…wrapper focusable (objectui#11626) Since the grip became the drag activator, a row wrapper carries no tabIndex; what an empty one would leave behind is a drop target. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
…o refs-during-render lint warning (objectui#11626) `NavDragGrip` read `handle.ref`, `handle.attributes` and `handle.listeners` during render; the react-hooks refs rule took the whole handle for a ref and flagged all three reads (main 23 warnings in NavigationRenderer.tsx, the branch 25). The handle is destructured in the parameter list and its setter is named `activator`; the file now lints at 22, one fewer than main. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
ACCEPT: PR objectui#11656, head
|
Fixes #11626
Clause-②: no
What changes
Triage's direction on the card (
5986793209): the grouped arm ofNavigationRenderergets the group-free arm's sortable path, scoped to each group, and the sidebar's order store keeps an order per group key. A move into another group stays out of scope, because which group an entry sits in is app structure, not a personal order.@object-ui/layoutNavigationRenderer. WithenableReorderon, each group's children are a sortable list with aDndContextof their own, and so is each run of top-level entries between two groups. No list is a drop target for another. A move is reported through the existingonReorder(reorderedItems): the top-level list, with the moved group'schildrenreordered and carrying their positions asorder. A grouped menu offers no grip whilesearchQuerynarrows it.@object-ui/app-shelluseNavOrder. A group's order is stored under the group'sid, beside__root__inobjectui-nav-order-APP. Theidis a specSnakeCaseIdentifierSchema(^[a-z][a-z0-9_]*$), so it holds across reloads and locales and can never be__root__; the label is translated, so it is not the key. The store finds the moved level by diffing the report against the tree it drew, so only that level is written, and every stored level is applied on load. A group-free app's__root__record is byte-identical.The resumed review found two holes the grouped arm would have inherited from the group-free arm. Both were measured before they were fixed:
order. The renderer sorts every level byorder, and the store applied a saved order by array position only. So on an app that authorsorder, a drag was stored and then sorted straight back. Measured in both arms on the resumed head: the store was written, and the menu was still drawn in the app's order. Now a level with a saved order carries its saved positions asorder, and a level with no saved order is passed on untouched. The moved-group detection comparesorderas well as ids, because under authoredordera move can land on the listed id sequence.attributes(role="button",tabIndex=0) sat on the row wrapper while itslistenerssat on the grip. So every row was a focusable "button" from which no key could start a drag. Measured on the resumed head: 7 of 7 row wrappers were focusable, and 0 of 7 grips. Spreading that wrapper to every grouped menu would have added one dead tab stop per entry on every stock app. The grip is now the drag activator (setActivatorNodeRef), with attributes and listeners together on it. Live, a keyboard reorder now works.No export, prop, type member, callback parameter or i18n key is added.
NavigationRendererProps.enableReorderandonReorderkeep their types; their doc comments now state the within-level scope and whatonReorderreceives. No README or guide page describes nav reorder (searchedpackages/layout,packages/app-shell,content/docsanddocs), so no doc drifts.Live: a console built from this branch, against an objectstack showcase backend
The backend is the stock showcase (
objectstack dev --seed-admin --fresh), run from an objectstack worktree detached atba575886, which is 14 commits behind objectstackmainat the time of the run. Two consoles were served from source with vite against that one backend: main0baf86fand this branchbeef430. The browser was Playwright Chromium, and the page was/apps/showcase_app/showcase_task. The list endpoint/api/v1/meta/appserves every group withexpanded: false(the spec default), so each group was opened by clicking its label before it was read.0baf86fbeef430tabindex=0; no non-grip element carries the sortable description{"grp_workspace":["nav_settings","nav_my_work","nav_review_queue","nav_new_project_wizard"]}__root__written besidegrp_workspacegrp_datakeyReverse verification
Each mutation went through the anchor-checked ablation tool, with a restore armed on EXIT, INT and TERM. Each restore was proven: blob equals HEAD, and
git diff HEADis empty. Every run is against the two pinned files, and the mutations ran on committed head6eb56eb. Later commits changed no line a mutation touched; the grip refactor inbeef430is covered by the A4 pins, which pass atbeef430.groupedReorder = false)orderstamping in the storeordercases redattributesback on the row wrapperTests and gates
pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2cf8b3d3pnpm --filter @object-ui/layout buildbeef430pnpm exec vitest run packages/layout/beef430beef430pnpm exec vitest run packages/app-shell/cf8b3d3pnpm --filter @object-ui/layout type-check(its test tsconfig includessrc/**/*.test.tsx)beef430pnpm --filter @object-ui/app-shell type-check(same include)beef430pnpm --filter @object-ui/layout lintbeef430NavigationRenderer.tsxcarries 22, against 23 on main through the same spelling; the new test file carries nonepnpm --filter @object-ui/app-shell linte42f5d5UnifiedSidebar.tsxcarries 10 warnings on main and 10 at head through the same spelling: the same rules, shifted by the insertioncheck:new-line-citations,check:control-bytes,check:i18n-keys,check:i18n-dead-keys,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:test-path-roots,check:changeset-claims,check:pending-changeset-literals,check-changeset-presence.mjs,check-changeset-no-major.mjsbeef430check-governed-queue-guard.mjs --testover the five changed pathsbeef430Two readings were taken on an earlier head:
cf8b3d3). Betweencf8b3d3andbeef430onlyNavigationRenderer.tsxchanged: one comment line, and the grip destructuring its handle, which changes no behaviour.packages/app-shellis byte-identical, and the pinned app-shell file, which drives the grip through the real sidebar, was re-run atbeef430.e42f5d5).eslint.config.jsenables no type-aware linting, so a verdict is per file, and every app-shell file is byte-identical tobeef430.Not run locally, and declared to CI:
check:eager-closure, which reads a full console production build.Acceptance notes
id. Two groups that shared anidwould share an order; the spec describes theidas unique, and this change does not police that./apps/showcase_app/...no sidebar row lights and the active group loads closed, on main as on this branch. The console's own hrefs for this app use the route segmentcom.example.showcase, so the name-segment URL is a non-canonical door. The order store is keyed by the appnameeither way.UnifiedSidebar.tsxsaysenablePinningandenableReorder"both persist underuseNavOrder". Pins persist in the favorites store (useNavPins,type: 'nav'favorites), not inobjectui-nav-order-APP.Resumed run
This branch resumes a run lost to a container restart. It continues from head
75c86fe, adding commits only: no rebase, amend or force-push. The lost run's commits were reviewed as written. The direction, the per-group store, the__root__key, the cross-group refusal and the search switch-off were kept. Five commits were added on top:6eb56eb(the two holes above, with their pins),c7a5d7b(changeset),cf8b3d3(a merge oforigin/mainat0baf86f),e42f5d5(a comment the activator change had made false) andbeef430(the grip destructures its handle, which removes three refs-during-render lint warnings the first spelling had added). Nothing the lost run measured is reused: every reading above was taken in this run.Session:
https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsALGenerated by Claude Code