Skip to content

Add shared UI package and hierarchical list - #484

Open
FBalint wants to merge 12 commits into
mainfrom
hierarchical-list-component
Open

FBalint wants to merge 12 commits into
mainfrom
hierarchical-list-component

Conversation

@FBalint

@FBalint FBalint commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

This is the foundation PR for shared hierarchical navigation. It introduces the reusable UI layer and component without adopting it in an application yet.

Shared UI package

  • add the private, source-exported @gadgets/ui workspace package for runtime UI shared by the Workshop and gatekeeper management apps
  • keep React and Kumo as peer dependencies so consumers share one runtime and design-system instance
  • add package-level TypeScript and Vitest configuration, root and subpath exports, ownership guidance, and lockfile entries

Hierarchical list

  • add a headless HierarchicalListPrimitive that recursively renders semantic nested lists while leaving row presentation to the consumer
  • support controlled or uncontrolled expansion, forced expand-all behavior, consumer-controlled selection, selection clearing, and custom structural/rendering slots
  • add the Kumo HierarchicalList adapter using Kumo controls and tokens, Phosphor icons, and Motion-powered insertion indicators and drag previews
  • support desktop context menus and configurable narrow-screen action drawers opened by touch long press
  • support native mouse dragging and touch-pointer dragging, optional edge auto-scroll, root/sibling/folder insertion, post-removal index normalization, and rejection of self/descendant moves
  • support Arrow Up/Down focus movement and accessible Alt+Arrow reordering, indenting, and unindenting for draggable rows
  • preserve focus after consumer-applied moves and announce successful moves through a polite live region
  • expose item flags for draggable and droppable behavior while leaving tree mutation in the consuming application

Frontend conventions

  • define @gadgets/ui as the shared runtime UI ownership boundary while keeping Kumo as the primitive and token foundation
  • add repository guidance for feature-oriented organization, component APIs, React effects/state, styling, accessibility, and behavior-focused tests
  • add the frontend-conventions skill with the full conventions for React work under packages/
  • add package-specific guidance for source exports, peer dependencies, Tailwind source scanning, and the headless-primitive/Kumo-adapter split

Scope

  • this PR adds the package and reusable component only; application adoption follows separately
  • consumers continue to own selected state and applying onMove results to their tree data

Testing

  • pnpm --filter @gadgets/ui test:run (33 tests)
  • pnpm install --lockfile-only --frozen-lockfile

Devin Review

@github-actions

Copy link
Copy Markdown

Preview: pr484-hierarchical-bf6262bd

https://pr484-hierarchical-bf6262bd-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 11, 2026

Copy link
Copy Markdown
  • High useHierarchicalListRowInteractions.ts:189: Invalid Alt+ArrowLeft/Right moves do not prevent browser history navigation.
  • High HierarchicalList.tsx:153: Narrow layouts remove the context-menu trigger, leaving actions inaccessible to mouse and keyboard users.
  • Medium HierarchicalListPrimitive.tsx:355: Focus restoration clears before deferred/transitioned consumer moves apply, so focus can be lost.
  • Low HierarchicalList.tsx:127: Truthiness checking hides valid metadata such as numeric 0.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown

Posted 6 actionable inline findings. Tests and TypeScript checks pass.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown

Posted 3 actionable inline findings. CI checks pass.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown
  • [P2] Announce moves only after success HierarchicalListPrimitive.tsx:355: A rejected onMove promise still announces that the item moved. Delay the success announcement until resolution or confirmed tree movement.

  • [P2] Scope synthetic drop events to the originating list HierarchicalListDragAndDrop.tsx:219: Releasing a touch drag over an unrelated drop zone dispatches a synthetic drop to it, potentially triggering unrelated actions.

  • [P2] Clear drag state when the source disappears HierarchicalListPrimitive.tsx:372: If items change mid-drag and remove the source, its end handler unmounts while draggedItem and previews remain active. Reconcile drag state with current items and disabled DnD.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown

Posted 3 actionable inline findings. No additional findings.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown

Posted 6 actionable inline findings.

github run

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown
  • [P2] Add packages/ui/**/*.{ts,tsx} to the React/JSX accessibility lint override at vite.config.ts:103; currently shared UI bypasses those checks.

Posted 5 additional inline findings.

github run

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +364 to +366
setMoveAnnouncement(`${item.name} moved to position ${normalizedDestination.index + 1} in ${
normalizedDestination.parent?.name ?? label
}.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Repeated move announcements are silent

When successful moves produce identical text, setMoveAnnouncement reuses the current string and React makes no live-region change. Screen readers miss the later move.

Learn more

A live region announces changes to its text content. Keeping it mounted allows the first update to be detected, but assigning its current string again creates no DOM mutation. This occurs whenever separate moves generate the same item name, destination position, and parent name.

Example: Two items named Draft are each moved to position 1 in Archive. The first move sets the region to Draft moved to position 1 in Archive. The second setter receives the same string, so React leaves the region unchanged and the second move is silent.

Recommended fix: Keep the live region mounted, but clear each completed announcement before publishing the next text in a later render. Guard delayed publication with operationId, and cancel any scheduled update during cleanup. An alternating pair of live regions is also suitable if it guarantees a content change for identical consecutive messages.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

longPressStartRef.current = null;
longPressPointerIdRef.current = null;
};
useEffect(() => cancelLongPress, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cancel an in-flight long press when it becomes ineligible. This cleanup only runs on unmount, so if longPressAction/enabled turns false while a finger is down (notably when a resize crosses the action-drawer breakpoint), the old timer still invokes the stale drawer callback. The rerender also removes the pointer-up handlers, leaving pressed set and the subsequent click suppressed. Reconcile the timer and pressed state when eligibility changes.

if (!useActionDrawer) {
return (
<ContextMenu.Root open={actionsOpen} onOpenChange={onActionsOpenChange}>
<ContextMenu.Trigger render={row} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply interaction.longPressDelayMs in this presentation too. On a wide touch device this Kumo trigger owns long press with its fixed 500 ms delay, while the custom delay is wired only into the narrow drawer path. As a result a consumer that raises the documented delay to avoid accidental action activation still gets a menu after 500 ms whenever the viewport is wider than the drawer breakpoint.

id: insertionTargetId(parent, destinationIndex),
destination: { parent, index: destinationIndex },
indicatorDepth: depth,
edge: after ? "bottom" : "top",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not draw this bottom edge on an expanded non-droppable folder row. In that case the lower half resolves to { parent: folderParent, index: index + 1 }, which places the source after the entire folder subtree, but updateDropIndicator positions this edge immediately below the folder row, above its visible children. The advertised insertion line and the resulting move therefore disagree; use the after-subtree zone/geometry for this destination.


useEffect(() => {
const draggedItem = dragController.draggedItem;
if (draggedItem && (!dragAndDrop || !findItemPosition(items, draggedItem.id))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Also end the session when the current source is no longer draggable. If props retain the same item ID but change draggable to false during a touch drag, StyledRow removes the captured handle, so its up/cancel callbacks cannot run; this condition still considers the source valid and leaves the preview and optional auto-scroll active indefinitely. Resolve the current item here and clear the controller when its drag eligibility is revoked.

@ask-bonk

ask-bonk Bot commented Sep 14, 2026

Copy link
Copy Markdown

Posted 4 actionable inline findings.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant