Skip to content

feat(ui): browse collections by folder with a card grid, drag-and-drop moves, and nav redesign - #17877

Draft
tylandavis wants to merge 41 commits into
mainfrom
feat/hierarchy-polish
Draft

feat(ui): browse collections by folder with a card grid, drag-and-drop moves, and nav redesign#17877
tylandavis wants to merge 41 commits into
mainfrom
feat/hierarchy-polish

Conversation

@tylandavis

Copy link
Copy Markdown
Member

What

Stacked work across the hierarchy/folder views, the list view, and the nav sidebar. Grouped by theme since it's a large branch (41 commits, 136 files).

Browse a collection by folder

  • New "By Folder" tab on any collection that belongs to a hierarchy, scoping that collection's documents to one folder tree
  • Folder navigation stays inside the collection being browsed rather than redirecting to the hierarchy view
  • Folders and documents share one selectable plane in grid mode
  • Breadcrumbs end on the current folder as plain text
  • At the root, related-collection documents are no longer listed — "everything unfiled, from everywhere" isn't a meaningful grouping

Drag and drop

  • Drag documents and folders onto folder cards, sidebar tree nodes, and breadcrumbs (to move up a level or out to root)
  • Drag preview is a staggered stack of 40px thumbnails with a count pill, snapped to the cursor
  • Cards being dragged fade to 50%
  • moveDocuments extracted so drag-drop and the Move button share one path
  • DndContext gets an explicit useId so aria-describedby matches across hydration ("Warning: Prop aria-describedby did not match." in Next.js clauderic/dnd-kit#926)

Grid view

  • Grid/table view mode toggle on the standard list view, persisted as a documentViewMode list preference (defaults to table)
  • New admin.useAsThumbnail collection option names the upload field a card renders as its thumbnail, validated like useAsTitle and defaulting to the collection's first upload field — so non-upload collections get previews too
  • Visible heading on the root band of documents not filed under a hierarchy item
  • Per-collection pagination controls are labelled, which the merged band otherwise leaves ambiguous
  • Cards on the plain collection grid are select-only, since there's nowhere to drop

Nav and account menu

  • Sidebar rows are full-bleed and match the Figma row metrics
  • User menu moved to the sidebar footer; account menu redesigned with hover submenus and a scrim
  • Admin layout constrained to viewport height, overscroll containment, nav toggle/close fixes
  • Active sidebar rows no longer change background on hover

Fixes and cleanup

  • Card selection handlers no longer shadow the dnd-kit activators
  • Deduped hierarchy sidebar tree children, which were rendering twice
  • Checkbox visuals driven by CSS state instead of React
  • Submenu hover uses :has() instead of JS
  • Hierarchy modal selection model and field input reworked; hasMany hierarchy fields now validate per-ID
  • Hierarchy card/modal styling matched to Figma; new link button style

Breaking changes

  1. useNav() / NavContext no longer expose navRef — publicly exported from @payloadcms/ui, so consumers destructuring it break. The mobile overscroll lock it powered is now handled in CSS.
  2. hierarchy:itemsMovedTo / itemsMovedToRoot changed placeholders{{title}}{{count}} {{label}}. Every non-English locale still carries the old form and needs a translateNewKeys pass.
  3. HierarchyViewData.hierarchySlug is now required — new non-optional field on an exported type, so anything constructing it fails to typecheck. Could be made optional if that matters.

Deep-import-only breaks: HierarchyColumnBrowserProps (ancestorsWithSelections → required selectedDescendantCounts), HierarchySearchProps (dropped collectionSpecificOptions / onFilterChange / selectedFilters, added required parentId), ColumnItemProps (hasSelectedDescendantsselectedDescendantCount), TreeNodeProps (dropped icon). None are in exports/client.

Notes

  • Draft, because the branch is large enough that it may be worth splitting — the nav/account-menu redesign is independent of the folder-browsing work — and because the translation strings above still need retranslating.
  • The list view now always selects useAsTitle and updatedAt, even when those columns are hidden, since the grid cards always render both.

🤖 Generated with Claude Code

tylandavis and others added 30 commits August 19, 2026 15:12
The nav scroll container is now edge to edge, with each section owning
its own inset. Links and group headers span the full sidebar width so
the gutters and the gap between rows are clickable, and the visible row
is painted by a ::before inset from that box.

Row geometry follows Figma's UI3 list row: a 24px highlight inside a
32px box, inset 8px from the sidebar edge. Group headers pick up
--color-text-secondary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Move the UserMenu component from the AppHeader to the sidebar's nav__controls,
placing it alongside the SettingsMenuButton at the bottom of the sidebar.
Update the Nav component to accept and render UserMenu with the necessary
props for custom avatar, logout button, and settings groups.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The nav close button now displays in the header regardless of screen size,
allowing users to close the sidebar from any device.
Prevent elastic scroll bounce effect in the navigation sidebar by containing
overscroll behavior, keeping scrolling within the nav boundaries.
The user menu and close button now render in a sticky-free header above
.nav__scroll, so the header stays put while only the tabs/links area
scrolls. NavWrapper takes a header prop rendered before the scroll div.
Un-revert a commented-out effect that was swept into the prior commit
by mistake.
The toggle button only needs to be visible when the sidebar is closed;
showing it while the sidebar is already open is redundant.
- Rename .nav__mobile-close to .nav__close since it's now shown at
  all screen sizes, not just mobile
- Remove the imperative overscrollBehavior toggling in NavProvider in
  favor of a plain overscroll-behavior: none on .nav__wrap, and drop
  the now-redundant scroll wrapper div in NavWrapper
- Remove stray spacing/padding from .nav__close and .nav__controls
Lock the main layout grid to height: 100vh with overflow: hidden
instead of min-height, so the sidebar and main content area are both
strictly sized to the viewport rather than allowed to grow beyond it.
- Show the user's title/email in the account trigger, using the auth
  collection's useAsTitle field with a fallback to email/username
- Theme/Language/Settings submenus now open on hover instead of
  click, using a ported Floating UI safePolygon-style funnel to
  decide when the cursor has genuinely left the trigger/panel area,
  rather than a simple bounding-box union which incorrectly stayed
  "open" over unrelated items below a short submenu
- A shared HoverSubmenuGroupProvider ensures only one of the three
  submenus can be open at a time, mirroring how hovering a sibling
  item force-closes any previously open submenu
- Submenu triggers are now rendered inside PopupList.MenuItem and
  reuse the shared icon/label classes so their spacing matches the
  Log Out row exactly
- Add an opt-in showScrim prop to Popup that renders a full-viewport
  click-capturing layer while the (outermost) popup is open, so a
  click must dismiss the menu before it can reach anything else on
  the page, and fix a related side-positioning bug where the popup's
  content sat a few pixels lower than the trigger due to internal
  scroll-container padding
Replace the JS-tracked isContentHovered approximation with a CSS-only
:has() selector querying from a shared ancestor. The submenu content
panel is portaled to document.body, so it isn't a DOM descendant of
its trigger and can't be reached with a plain descendant selector -
:has() bridges the two via body, reflecting real-time :hover state
directly instead of an approximation that could drift out of sync
during mouseout/funnel transit.

The trigger now stays brand-colored for its whole open (aria-expanded)
state by default, switching to bg-secondary only while the cursor is
actually over an item inside that specific submenu's content.
…rendering

Reconciles loadedCount/totalDocs across hydrate() calls instead of letting a
shallower fetch clobber a deeper one, and dedupes fetchPage's page concat by
id as a defense-in-depth guard against page-number drift.
Applies to sidebar rows, tree nodes, and the main nav links. Also fixes the
sidebar toggle button to a fixed square size and lets the admin layout
scroll instead of clipping content.
Generalizes ViewModeToggle out of the Hierarchy-only view into a shared
elements component backed by a new SegmentedControl, and wires it into the
default List view so any collection can switch between table and card grid.
Renames the CollectionPreferences field from hierarchyViewMode to
documentViewMode to reflect the wider scope.
Move the border, fill, focus ring and marks onto a `__visuals` sibling of
the input so every state is driven by the input's own pseudo-classes rather
than classes React has to keep in sync. Both marks are always rendered and
revealed by CSS, so an uncontrolled input that changes without React knowing
still shows the right mark.

- Write the real `indeterminate` property to the node so the mixed state is
  reported to assistive tech instead of being faked with a class
- Draw each mark twice - a wider halo stroke under the mark itself - to keep
  it legible against the brand fill
- Animate the mark in with `stroke-dashoffset`, gated so a document that
  loads already-checked doesn't replay it
- Invert `variant="muted"` to mean "keep the neutral surface when checked",
  which is now the table default, so the explicit props can be dropped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reshape the hierarchy browser around what the two modes actually mean: a
single-select modal moves one document, so its current value is the origin of
the move rather than a pre-made choice, and picking a row always replaces the
destination. Multi-select edits a set, so existing values start checked and
the checkbox is what toggles them.

Modal:
- New footer showing the move as `previous -> destination` breadcrumb chips,
  or the selection count and Clear in multi-select. Clicking a chip expands
  the browser down to that item and focuses it via a new `revealPath` handle
- Primary actions moved out of the header into the footer; the header gained
  a close button and an optional title, e.g. `Move "My Post"`
- `fetchAncestorPath` is now batched and returns titled breadcrumbs, which is
  what both the chips and the descendant counts are built from

Column browser:
- Rows are a real button rather than a `role="button"` div. Single-select
  picks and opens a row in one click, mirroring the macOS column view; in
  multi-select the row body opens children and the chevron is its own button
- Replace the has-selected-descendants dot with a count badge, so a collapsed
  branch shows how many picks live inside it
- Only single-select rows carry a selected fill - in multi-select the
  checkbox already says what is picked, and filling those rows drowns out the
  expanded fill that marks the current path
- Re-opening an already-expanded ancestor truncates columns instead of
  refetching, and the expanded path always resets so a reveal back to the
  root doesn't leave stale expansion

Field:
- Render the hierarchy field as a standard relationship input, swapping the
  "create new document" button for one that opens the browser. Drops the
  bespoke pill list in favour of the shared select
- Options are labelled with their full ancestor path while browsing so
  same-named items stay distinguishable, via new `formatOptionLabel` and
  `selectOptionFields` props on `RelationshipInput`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swap the round secondary-fill pill for the design system's filled badge:
tertiary fill, `--radius-medium`, and body-medium type at full text colour
rather than small-strong at secondary. Height is now explicit with flex
centring instead of leaning on `line-height` to size the box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a text-only "link" button variant, uses it for the hierarchy modal
clear action, matches the relationship add-new button to the secondary
style, and cleans up hierarchy card selection rings and spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tylandavis and others added 11 commits August 19, 2026 15:12
Names the upload field a card grid should render as a document's thumbnail,
validated like `useAsTitle` and defaulting to the collection's first `upload`
field so non-upload collections still get a preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Render thumbnails from `admin.useAsThumbnail` when the collection isn't
  upload-enabled, and always reserve the thumbnail slot so cards stay uniform
- Label the root band of documents that aren't filed under a hierarchy item,
  and stop listing related documents there since "everything unfiled from
  everywhere" isn't a meaningful grouping
- Name each per-collection pagination control, which the merged band otherwise
  leaves ambiguous
- Disable dragging on the flat collection grid, which has nowhere to drop
- Give `DndContext` an explicit `useId` so `aria-describedby` matches across
  hydration, and auto-fill grid columns before the plane is measured

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant