Skip to content

feat: add optional drag handle to component action bar - #1770

Closed
dkrasniy wants to merge 5 commits into
puckeditor:mainfrom
dkrasniy:feat/action-bar-drag-handle
Closed

feat: add optional drag handle to component action bar#1770
dkrasniy wants to merge 5 commits into
puckeditor:mainfrom
dkrasniy:feat/action-bar-drag-handle

Conversation

@dkrasniy

@dkrasniy dkrasniy commented Jul 29, 2026

Copy link
Copy Markdown

Closes #1477

Description

This PR adds an opt-in drag handle to a component's action bar. This is especially useful for slots that wrap tightly around nested content and are awkward to grab directly (#1477).

The handle is off by default and enabled via the existing dnd prop: <Puck dnd={{ enableDragHandle: true }} />.

Following the consideration in the issue, the handle is exposed to custom action bars as an additional dragHandle prop on theactionBar override — mirroring how parentAction (select parent) is passed — so consumers who override the action bar can place or omit it.

Non-breaking: whole-component-body dragging still works exactly as before.

Screenshot 2026-07-29 at 3 06 28 PM

Changes made

  • dnd.enableDragHandle — new opt-in flag, default false.
  • ActionBar.DragHandle — new export. A grip button that forwards its ref.
  • overrides.actionBar — now gets an optional dragHandle prop. Existing overrides are unaffected.
  • DraggableComponent — renders the handle at the start of the action bar when enableDragHandle && permissions.drag.
  • useSensors / DragDropContext — register both the body and the handle as drag activators.
  • Added the action-drag dictionary key ("Drag to reorder"), docs, and tests.

Known behavior

The action bar (and therefore the handle) is unmounted while the component it belongs to is being dragged. This is existing behavior from the dragFinished gate in DraggableComponent, not something this PR changes. The document-level grabbing cursor persists through the drag and the bar reappears at the dropped position. Making the bar track the drag would mean positioning it against dnd-kit's clone rather than the source element, which felt out of scope here.

How to test

  1. In apps/demo, enable the handle:

    <Puck dnd={{ enableDragHandle: true }} config={config} data={data} />
  2. Select a component — a grip handle appears at the left of the action bar. Grab it and drag to reorder the component. It should start dragging immediately, with no press-and-hold delay.

  3. Confirm existing behavior is intact: dragging the component body still reorders it (handle is additive, not a replacement),
    and still requires the short press-and-hold.

  4. Set enableDragHandle: false (or omit dnd) — no handle renders, behavior is unchanged from main.

  5. Set permissions={{ drag: false }} — the handle does not render.

  6. Override the action bar and place dragHandle yourself to confirm the prop is exposed:

    <Puck
      dnd={{ enableDragHandle: true }}
      overrides={{
        actionBar: ({ label, children, parentAction, dragHandle }) => (
          <ActionBar label={label}>
            <ActionBar.Group>{dragHandle}</ActionBar.Group>
            <ActionBar.Group>{parentAction}</ActionBar.Group>
            <ActionBar.Group>{children}</ActionBar.Group>
          </ActionBar>
        ),
      }}
    />

Summary by CodeRabbit

  • New Features

    • Added an optional drag handle for reordering components.
    • Dragging can start from the handle while preserving body-dragging behavior.
    • Added accessibility labels, disabled-state handling, grab cursors, and customizable action-bar rendering.
    • Added the default “Drag to reorder” tooltip text.
  • Documentation

    • Documented drag handles, configuration options, customization, accessibility, and usage in the API reference.
  • Tests

    • Added coverage for rendering, labeling, refs, disabled behavior, and click handling.

dkrasniy added 2 commits July 29, 2026 14:20
Add an opt-in drag handle to the component action bar, so components can
be picked up by a small grip icon instead of only by dragging the
component itself. Helps with slots that wrap tightly around nested
content and are hard to grab.

Enabled via dnd.enableDragHandle, off by default. The handle is passed to
custom action bars as a dragHandle prop on the actionBar override, the
same way parentAction is, and respects permissions.drag.

Dragging the component body still works. Registering a handle would
otherwise make dnd-kit only start drags from that handle, so useSensors
accepts an activatorElements option and DragDropContext registers both
the body and the handle.

Closes puckeditor#1477
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

@dkrasniy is attempting to deploy a commit to the Puck Team on Vercel.

A member of the Team first needs to authorize it.

@dkrasniy dkrasniy changed the title feat(core): add optional drag handle to component action bar feat: add optional drag handle to component action bar Jul 29, 2026
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
puck-demo Ready Ready Preview Jul 30, 2026 7:57am

Request Review

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@FedericoBonel

FedericoBonel commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Hey @dkrasniy! Thank you very much for the contribution 🙏

Played with this and it looks great!

I think we want to merge it, and want to do a proper code review, but before that we're keen to merge #1735 since it introduces some new dnd behavior that might play nicely with this.

In the meantime, if you could rebase from main it would be great as it would pick up the fix for the build failure you're facing.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds ActionBar.DragHandle, connects it to sortable pointer sensors, exposes enableDragHandle and actionBar.dragHandle configuration, and documents the component and related APIs.

Changes

Action bar drag handle

Layer / File(s) Summary
Drag handle component and public API
packages/core/components/ActionBar/index.tsx, packages/core/components/ActionBar/styles.module.css, packages/core/components/ActionBar/__tests__/index.spec.tsx, packages/core/types/API/Overrides.ts
Adds the ref-forwarding ActionBar.DragHandle button, grab-state styles, the actionBar.dragHandle override type, and tests for rendering, refs, disabled state, and click propagation.
Sortable drag activation
packages/core/components/DraggableComponent/index.tsx, packages/core/components/DragDropContext/index.tsx, packages/core/lib/dnd/use-sensors.ts, packages/core/types/API/index.ts, packages/core/lib/dictionary.ts
Adds enableDragHandle, registers the sortable handle ref, renders the handle when permitted, and configures pointer sensors to activate from the component source or handle.
Drag handle documentation
apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx, apps/docs/pages/docs/api-reference/components.mdx, apps/docs/pages/docs/api-reference/components/_meta.js, apps/docs/pages/docs/api-reference/components/puck.mdx, apps/docs/pages/docs/api-reference/overrides/action-bar.mdx, apps/docs/pages/docs/api-reference/dictionary.mdx
Documents the component, DnD option, action-bar override, dictionary token, accessibility behavior, and navigation entry.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DraggableComponent
  participant DefaultActionBar
  participant ActionBarDragHandle
  participant DragDropContextClient
  participant useSensors
  participant PointerSensor

  DragDropContextClient->>useSensors: Pass activatorElements
  useSensors->>PointerSensor: Configure activatorElements
  DraggableComponent->>DefaultActionBar: Pass dragHandle
  DefaultActionBar->>ActionBarDragHandle: Render handle
  ActionBarDragHandle->>PointerSensor: Initiate dragging
Loading

Suggested reviewers: federicobonel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: adding an optional drag handle to the component action bar.
Description check ✅ Passed The description includes the required issue link, change summary, testing steps, behavior notes, and implementation details.
Linked Issues check ✅ Passed The PR adds the requested drag handle and exposes it to custom action bars through the dragHandle prop [#1477].
Out of Scope Changes check ✅ Passed The code, tests, types, dictionary entry, and documentation changes directly support the linked issue and stated feature objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/docs/pages/docs/api-reference/components/_meta.js

Oops! Something went wrong! :(

ESLint: 10.8.0

TypeError: scopeManager.addGlobals is not a function
at addDeclaredGlobals (/apps/docs/node_modules/eslint/lib/languages/js/source-code/source-code.js:221:15)
at SourceCode.finalize (/apps/docs/node_modules/eslint/lib/languages/js/source-code/source-code.js:1090:3)
at #flatVerifyWithoutProcessors (/apps/docs/node_modules/eslint/lib/linter/linter.js:1261:24)
at Linter._verifyWithFlatConfigArrayAndWithoutProcessors (/apps/docs/node_modules/eslint/lib/linter/linter.js:1349:43)
at Linter._verifyWithFlatConfigArray (/apps/docs/node_modules/eslint/lib/linter/linter.js:1416:15)
at Linter.verify (/apps/docs/node_modules/eslint/lib/linter/linter.js:861:9)
at Linter.verifyAndFix (/apps/docs/node_modules/eslint/lib/linter/linter.js:1534:20)
at verifyText (/apps/docs/node_modules/eslint/lib/eslint/eslint-helpers.js:1155:45)
at readAndVerifyFile (/apps/docs/node_modules/eslint/lib/eslint/eslint-helpers.js:1296:10)

packages/core/components/ActionBar/__tests__/index.spec.tsx

Oops! Something went wrong! :(

ESLint: 9.39.4

YAMLException: Cannot read config file: /packages/eslint-config-custom/index.mjs
Error: end of the stream or a document separator is expected (10:21)

7 | ...
8 |
9 | const newReactHooksRules = {
10 | "react-hooks/refs": "off",
--------------------------^
11 | "react-hooks/error-boundaries": "off",
12 | "react-hooks/immutability": "off",
Referenced from: /.eslintrc.js
at generateError (/node_modules/js-yaml/lib/loader.js:196:10)
at throwError (/node_modules/js-yaml/lib/loader.js:200:9)
at readDocument (/node_modules/js-yaml/lib/loader.js:1720:5)
at loadDocuments (/node_modules/js-yaml/lib/loader.js:1759:5)
at Object.load (/node_modules/js-yaml/lib/loader.js:1783:21)
at loadLegacyConfigFile (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2666:21)
at loadConfigFile (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2782:20)
at ConfigArrayFactory._loadConfigData (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3088:42)
at ConfigArrayFactory._loadExtendedShareableConfig (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3393:21)
at ConfigArrayFactory._loadExtends (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3261:25)
(node:2) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag.
(Use node --trace-warnings ... to show where the warning was created)

packages/core/components/ActionBar/index.tsx

Oops! Something went wrong! :(

ESLint: 9.39.4

YAMLException: Cannot read config file: /packages/eslint-config-custom/index.mjs
Error: end of the stream or a document separator is expected (10:21)

7 | ...
8 |
9 | const newReactHooksRules = {
10 | "react-hooks/refs": "off",
--------------------------^
11 | "react-hooks/error-boundaries": "off",
12 | "react-hooks/immutability": "off",
Referenced from: /.eslintrc.js
at generateError (/node_modules/js-yaml/lib/loader.js:196:10)
at throwError (/node_modules/js-yaml/lib/loader.js:200:9)
at readDocument (/node_modules/js-yaml/lib/loader.js:1720:5)
at loadDocuments (/node_modules/js-yaml/lib/loader.js:1759:5)
at Object.load (/node_modules/js-yaml/lib/loader.js:1783:21)
at loadLegacyConfigFile (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2666:21)
at loadConfigFile (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2782:20)
at ConfigArrayFactory._loadConfigData (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3088:42)
at ConfigArrayFactory._loadExtendedShareableConfig (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3393:21)
at ConfigArrayFactory._loadExtends (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3261:25)
(node:2) ESLintRCWarning: You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details. An eslintrc configuration file is used because you have the ESLINT_USE_FLAT_CONFIG environment variable set to false. If you want to use an eslint.config.js file, remove the environment variable. If you want to find the location of the eslintrc configuration file, use the --debug flag.
(Use node --trace-warnings ... to show where the warning was created)

  • 7 others

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request shows signs of AI-generated slop (redundant_comments). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/core/components/DraggableComponent/index.tsx (1)

57-65: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add integration coverage for drag-handle activation.

Test dnd.enableDragHandle with a pointer drag from ActionBar.DragHandle, verify that the component moves, and verify that a user without permissions.drag cannot start a drag.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/components/DraggableComponent/index.tsx` around lines 57 - 65,
Add integration coverage for the draggable component using dnd.enableDragHandle:
perform a pointer drag beginning at ActionBar.DragHandle and assert that the
component moves, then verify a user lacking permissions.drag cannot initiate the
drag.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx`:
- Around line 14-18: Update the copied example around ActionBar.Group so
dragHandle is declared through the actionBar override callback parameter, using
the actionBar: ({ dragHandle }) => ... context; preserve the existing rendered
structure.

In `@apps/docs/pages/docs/api-reference/overrides/action-bar.mdx`:
- Line 26: Update the dragHandle table link to use the lowercase `#draghandle`
fragment so it matches Nextra’s generated heading anchor.

---

Nitpick comments:
In `@packages/core/components/DraggableComponent/index.tsx`:
- Around line 57-65: Add integration coverage for the draggable component using
dnd.enableDragHandle: perform a pointer drag beginning at ActionBar.DragHandle
and assert that the component moves, then verify a user lacking permissions.drag
cannot initiate the drag.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6d6bbf3-c8ba-43da-b63f-5167ebf476ad

📥 Commits

Reviewing files that changed from the base of the PR and between a09ed29 and 1ea345e.

📒 Files selected for processing (15)
  • apps/docs/pages/docs/api-reference/components.mdx
  • apps/docs/pages/docs/api-reference/components/_meta.js
  • apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx
  • apps/docs/pages/docs/api-reference/components/puck.mdx
  • apps/docs/pages/docs/api-reference/dictionary.mdx
  • apps/docs/pages/docs/api-reference/overrides/action-bar.mdx
  • packages/core/components/ActionBar/__tests__/index.spec.tsx
  • packages/core/components/ActionBar/index.tsx
  • packages/core/components/ActionBar/styles.module.css
  • packages/core/components/DragDropContext/index.tsx
  • packages/core/components/DraggableComponent/index.tsx
  • packages/core/lib/dictionary.ts
  • packages/core/lib/dnd/use-sensors.ts
  • packages/core/types/API/Overrides.ts
  • packages/core/types/API/index.ts

Comment on lines +14 to +18
```tsx showLineNumbers {2} copy
<ActionBar>
<ActionBar.Group>{dragHandle}</ActionBar.Group>
</ActionBar>
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Declare dragHandle in the copied example.

The snippet uses dragHandle without a parameter or declaration. If a reader copies it into an override, the snippet fails with dragHandle is not defined. Show the actionBar: ({ dragHandle }) => ... context, or remove the copy option.

Proposed documentation fix
-<ActionBar>
-  <ActionBar.Group>{dragHandle}</ActionBar.Group>
-</ActionBar>
+const overrides = {
+  actionBar: ({ dragHandle }) => (
+    <ActionBar>
+      <ActionBar.Group>{dragHandle}</ActionBar.Group>
+    </ActionBar>
+  ),
+};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```tsx showLineNumbers {2} copy
<ActionBar>
<ActionBar.Group>{dragHandle}</ActionBar.Group>
</ActionBar>
```
const overrides = {
actionBar: ({ dragHandle }) => (
<ActionBar>
<ActionBar.Group>{dragHandle}</ActionBar.Group>
</ActionBar>
),
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx`
around lines 14 - 18, Update the copied example around ActionBar.Group so
dragHandle is declared through the actionBar override callback parameter, using
the actionBar: ({ dragHandle }) => ... context; preserve the existing rendered
structure.

| Prop | Example | Type |
| ------------------------------- | ---------------- | --------- |
| [`children`](#children) | `<div />` | ReactNode |
| [`dragHandle`](#dragHandle) | `<div />` | ReactNode |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n '`#dragHandle`|`#draghandle`|### `dragHandle`' \
  apps/docs/pages/docs/api-reference/overrides/action-bar.mdx \
  apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx

Repository: puckeditor/puck

Length of output: 721


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- documentation references ---'
cat -n apps/docs/pages/docs/api-reference/overrides/action-bar.mdx | sed -n '20,38p'
cat -n apps/docs/pages/docs/api-reference/components/action-bar-drag-handle.mdx | sed -n '1,16p'

printf '%s\n' '--- Nextra and slugger declarations ---'
rg -n -i '"nextra"|"github-slugger"|slug' \
  package.json package-lock.json pnpm-lock.yaml yarn.lock apps/docs 2>/dev/null | head -120

Repository: puckeditor/puck

Length of output: 2542


🌐 Web query:

Nextra 3.3.1 heading slug github-slugger default maintainCase heading IDs

💡 Result:

In Nextra 3.3.1, heading IDs are generated to ensure compatibility with how GitHub handles markdown headings, primarily using the github-slugger package [1][2][3]. The github-slugger utility defaults to converting all characters to lowercase when creating slugs [4]. The maintainCase option exists within the github-slugger API (where it defaults to false) to allow preserving the original casing of the heading text [4]. However, Nextra generally follows the default behavior of github-slugger, which aligns with GitHub's convention of using lowercase slugs [1][2][5]. Key points regarding heading slug generation in Nextra: 1. Consistency: Nextra uses github-slugger to ensure that heading IDs are generated consistently and are unique to prevent collisions [1][2]. 2. Custom IDs: Nextra supports custom header IDs via remark-header-ids (allowing syntax like {#custom-id}), which overrides the default slug generation for those specific headings [6][7]. 3. Case Sensitivity: Because the underlying github-slugger defaults to false for maintainCase, headings in Nextra are typically transformed to lowercase [4]. While the option technically exists in the library, Nextra's implementation does not enable maintainCase by default, prioritizing lowercase URLs that match standard GitHub anchor behavior [2][5][4].

Citations:


Use the lowercase #draghandle fragment.

Nextra generates lowercase heading fragments, so #dragHandle does not match ### \dragHandle``.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/docs/pages/docs/api-reference/overrides/action-bar.mdx` at line 26,
Update the dragHandle table link to use the lowercase `#draghandle` fragment so it
matches Nextra’s generated heading anchor.

@FedericoBonel

Copy link
Copy Markdown
Collaborator

Awesome stuff @dkrasniy! Thanks again for the contribution, I have picked it up and continued it in #1810

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.

Add a drag handle to the action bar

3 participants