Skip to content

feat(Sidebar): add resizable functionality#6340

Open
mikenewbon wants to merge 9 commits intonuxt:v4from
mikenewbon:feat/sidebar-resizable
Open

feat(Sidebar): add resizable functionality#6340
mikenewbon wants to merge 9 commits intonuxt:v4from
mikenewbon:feat/sidebar-resizable

Conversation

@mikenewbon
Copy link
Copy Markdown
Contributor

🔗 Linked issue

Discussion in #6038

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

The new sidebar component is great, I have a similar pattern built into my own apps, but without the resizing I still couldn't use it. I've reused the composables from dashboard sidebar and tried to mimic as much of the patterns used there.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@github-actions github-actions Bot added the v4 #4488 label Apr 12, 2026
@mikenewbon
Copy link
Copy Markdown
Contributor Author

@benjamincanac ha, sorry about that, misread the silence! Reopening this. No rush on review, happy to address feedback whenever you get to it - was just trying to avoid being part of the spam/bloat train 😅

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

Adds optional resizable behavior to Sidebar via a new resizable?: boolean prop and sizing props (id, minSize, maxSize, defaultSize, collapsedSize). Integrates useResizable to provide size, dragging state, collapse sync with desktop open, handlers for mouse/touch/double-click, and collapse behavior. The rail slot signature now exposes resize handlers; rail interaction is conditional on resizable. Runtime sets data-dragging and CSS variables (--sidebar-width, --sidebar-width-icon) and a ref when resizable. Theme transition classes adjusted; docs, example, playground, and a test case updated.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding resizable functionality to the Sidebar component.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation for adding resizing, reusing composables from DashboardSidebar, and confirming documentation updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/runtime/components/Sidebar.vue (2)

239-241: Consider adding a clarifying comment for the sync condition.

The condition isCollapsed.value === v works correctly because modelOpen and isCollapsed have inverted semantics (when they're the same boolean value, they're actually out of sync). However, this logic is non-obvious at first glance.

💡 Suggested clarification
 watch(modelOpen, (v) => {
+  // isCollapsed and modelOpen have inverted semantics, so same values mean out-of-sync
   if (!isMobile.value && canCollapse.value && isCollapsed.value === v) collapse(!v)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` around lines 239 - 241, Add a short
clarifying comment above the watch(...) that explains the inverted semantics:
note that modelOpen and isCollapsed purposely have opposite meanings (when
modelOpen.value === isCollapsed.value they are out of sync), so the check
isCollapsed.value === v is intentional and collapse(!v) flips the UI to sync
them; keep the existing guards (isMobile, canCollapse) and reference the watch,
modelOpen, isMobile, canCollapse, isCollapsed, and collapse symbols in the
comment so future readers understand the non‑obvious boolean inversion.

388-391: Event handler pattern works but has a subtle inefficiency.

When isResizable is false, handleMouseDown($event) is still evaluated (the function call is prepared even though undefined is returned). While functionally correct, you could simplify with conditional binding.

That said, this is a minor nitpick and the current approach is readable. No action required unless you prefer the alternative pattern.

💡 Alternative using short-circuit evaluation
-            `@mousedown`="isResizable ? handleMouseDown($event) : undefined"
-            `@touchstart`="isResizable ? handleTouchStart($event) : undefined"
-            `@dblclick`="isResizable ? handleDoubleClick($event) : undefined"
+            `@mousedown`="isResizable && handleMouseDown($event)"
+            `@touchstart`="isResizable && handleTouchStart($event)"
+            `@dblclick`="isResizable && handleDoubleClick($event)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` around lines 388 - 391, The template
currently conditionally calls handlers with ternaries (e.g.,
`@mousedown`="isResizable ? handleMouseDown($event) : undefined") which still
evaluates the call expression; replace these with short-circuit bindings so the
handler function reference is only used when isResizable is true: change
`@mousedown`, `@touchstart`, and `@dblclick` to use expressions like
`@mousedown`="isResizable && handleMouseDown", `@touchstart`="isResizable &&
handleTouchStart", `@dblclick`="isResizable && handleDoubleClick" (leave
`@click`="onRailClick" as-is); this uses isResizable, handleMouseDown,
handleTouchStart, and handleDoubleClick to avoid unnecessary call-site
preparation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/runtime/components/Sidebar.vue`:
- Around line 239-241: Add a short clarifying comment above the watch(...) that
explains the inverted semantics: note that modelOpen and isCollapsed purposely
have opposite meanings (when modelOpen.value === isCollapsed.value they are out
of sync), so the check isCollapsed.value === v is intentional and collapse(!v)
flips the UI to sync them; keep the existing guards (isMobile, canCollapse) and
reference the watch, modelOpen, isMobile, canCollapse, isCollapsed, and collapse
symbols in the comment so future readers understand the non‑obvious boolean
inversion.
- Around line 388-391: The template currently conditionally calls handlers with
ternaries (e.g., `@mousedown`="isResizable ? handleMouseDown($event) : undefined")
which still evaluates the call expression; replace these with short-circuit
bindings so the handler function reference is only used when isResizable is
true: change `@mousedown`, `@touchstart`, and `@dblclick` to use expressions like
`@mousedown`="isResizable && handleMouseDown", `@touchstart`="isResizable &&
handleTouchStart", `@dblclick`="isResizable && handleDoubleClick" (leave
`@click`="onRailClick" as-is); this uses isResizable, handleMouseDown,
handleTouchStart, and handleDoubleClick to avoid unnecessary call-site
preparation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 58d6f574-ae1b-4d1f-9690-14a462550597

📥 Commits

Reviewing files that changed from the base of the PR and between 4427824 and 25fdc5d.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Sidebar-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Sidebar.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • docs/app/components/content/examples/sidebar/SidebarExample.vue
  • docs/content/docs/2.components/dashboard-sidebar.md
  • docs/content/docs/2.components/sidebar.md
  • src/runtime/components/Sidebar.vue
  • src/theme/sidebar.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 12, 2026

npm i https://pkg.pr.new/@nuxt/ui@6340

commit: 3c81255

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/components/Sidebar.vue (1)

162-171: ⚠️ Potential issue | 🟡 Minor

isCollapsed and modelOpen can diverge across viewport transitions.

On mobile → desktop the watcher on line 162 restores modelOpen from desktopOpen, but isCollapsed (owned by useResizable) is not reconciled. If the user toggled/collapsed on desktop, went to mobile (forcing modelOpen = false), then returned to desktop, the restored modelOpen may equal the stale isCollapsed in a way the "inverted-semantics" guard at line 238 won't catch reliably (no value change on modelOpen fires the watcher). Consider reconciling explicitly after restoration, e.g.:

Proposed fix
 watch(isMobile, (mobile) => {
   if (mobile) {
     desktopOpen.value = modelOpen.value
     modelOpen.value = false
   } else {
     modelOpen.value = desktopOpen.value
+    if (canCollapse.value && isCollapsed.value === modelOpen.value) {
+      collapse(!modelOpen.value)
+    }
   }
 }, { immediate: true })

Also applies to: 233-239

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` around lines 162 - 171, The watcher on
isMobile restores modelOpen from desktopOpen but doesn't reconcile the
isCollapsed state from useResizable, causing divergence; update the watcher so
that after restoring modelOpen.value = desktopOpen.value you also explicitly set
isCollapsed.value (or call the useResizable sync method) to the correct value
(e.g., isCollapsed.value = !modelOpen.value or the project’s equivalent
inversion) so both modelOpen and isCollapsed remain consistent when switching
mobile↔desktop; ensure the assignment happens after the modelOpen restore (use
nextTick if needed) so any dependent watchers run correctly.
🧹 Nitpick comments (1)
src/runtime/components/Sidebar.vue (1)

219-224: railCursor only reflects the maxSize boundary, not minSize.

At minSize (when still expanded, not yet snapped to collapsedSize), the cursor stays bidirectional instead of indicating the only valid direction. Consider mirroring the maxSize branch for expandedWidth.value <= props.minSize.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` around lines 219 - 224, The railCursor
computed currently only handles the maxSize boundary; add a mirrored branch that
checks expandedWidth.value <= props.minSize (and only when not collapsed) and
returns the opposite directional cursor to indicate only expansion is allowed —
specifically, in the new branch return props.side === 'left' ? 'e-resize' :
'w-resize'; update the railCursor computation (which references isResizable,
isCollapsed, expandedWidth, props.maxSize and props.minSize) to include this
minSize branch before falling back to 'ew-resize'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Sidebar.vue`:
- Line 194: Make sidebarId reactive and avoid double-prefixing by generating a
stable fallback id once (const generatedId = useId()), then create sidebarId as
a computed: const sidebarId = computed(() => props.id ??
`sidebar-${generatedId}`); this way a user-supplied props.id is used verbatim
and later changes to props.id are reflected. Also update the useResizable call
to receive the actual string (pass sidebarId.value or unwrap it where storageKey
is built) so the persisted storageKey matches the consumer-provided id.
- Around line 209-212: The code unconditionally forces modelOpen.value = false
when a persisted collapsed state exists, which can override a parent-controlled
v-model:open; update the setup logic around canCollapse, isCollapsed and
modelOpen so you only apply the persisted collapse when the parent did not
explicitly provide an open value (i.e., detect absence of an external
v-model:open and only then set modelOpen.value = false), or add a boolean prop
(e.g., persistent) and guard the sync with that prop (apply the persisted state
only when persistent is true); adjust the check where modelOpen is set to
consult either the absence of an external open prop or the new persistent prop
before changing modelOpen.value.

---

Outside diff comments:
In `@src/runtime/components/Sidebar.vue`:
- Around line 162-171: The watcher on isMobile restores modelOpen from
desktopOpen but doesn't reconcile the isCollapsed state from useResizable,
causing divergence; update the watcher so that after restoring modelOpen.value =
desktopOpen.value you also explicitly set isCollapsed.value (or call the
useResizable sync method) to the correct value (e.g., isCollapsed.value =
!modelOpen.value or the project’s equivalent inversion) so both modelOpen and
isCollapsed remain consistent when switching mobile↔desktop; ensure the
assignment happens after the modelOpen restore (use nextTick if needed) so any
dependent watchers run correctly.

---

Nitpick comments:
In `@src/runtime/components/Sidebar.vue`:
- Around line 219-224: The railCursor computed currently only handles the
maxSize boundary; add a mirrored branch that checks expandedWidth.value <=
props.minSize (and only when not collapsed) and returns the opposite directional
cursor to indicate only expansion is allowed — specifically, in the new branch
return props.side === 'left' ? 'e-resize' : 'w-resize'; update the railCursor
computation (which references isResizable, isCollapsed, expandedWidth,
props.maxSize and props.minSize) to include this minSize branch before falling
back to 'ew-resize'.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7001c7c1-7f78-430d-8d8b-7507c253201a

📥 Commits

Reviewing files that changed from the base of the PR and between a8c6967 and 3c81255.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Sidebar-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Sidebar.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • playgrounds/nuxt/app/pages/components/sidebar.vue
  • src/runtime/components/Sidebar.vue
  • test/components/Sidebar.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • playgrounds/nuxt/app/pages/components/sidebar.vue

// Resizable rail integration
const isResizable = computed(() => props.rail && props.resizable)
const canCollapse = computed(() => isResizable.value && props.collapsible !== 'none')
const sidebarId = `sidebar-${props.id || useId()}`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

sidebarId double-prefixes user-provided id and isn't reactive.

When a consumer passes id="my-sidebar", the resulting id becomes sidebar-my-sidebar, which is then used as the storageKey inside useResizable. This makes it hard for users to predict/align with the persisted cookie/localStorage key. Also, because the expression runs once in setup with plain ||, later changes to props.id aren't reflected.

Proposed fix
-const sidebarId = `sidebar-${props.id || useId()}`
+const fallbackId = useId()
+const sidebarId = computed(() => props.id ?? `sidebar-${fallbackId}`)

And pass sidebarId.value (or wrap the useResizable call accordingly) so the storage key matches what consumers expect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` at line 194, Make sidebarId reactive and
avoid double-prefixing by generating a stable fallback id once (const
generatedId = useId()), then create sidebarId as a computed: const sidebarId =
computed(() => props.id ?? `sidebar-${generatedId}`); this way a user-supplied
props.id is used verbatim and later changes to props.id are reflected. Also
update the useResizable call to receive the actual string (pass sidebarId.value
or unwrap it where storageKey is built) so the persisted storageKey matches the
consumer-provided id.

Comment on lines +209 to +212
// Sync initial persisted collapsed state to open model
if (canCollapse.value && isCollapsed.value) {
modelOpen.value = false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how the existing dashboard sidebar handles persisted-collapsed vs v-model open sync
fd -t f 'DashboardSidebar.vue' | xargs -I{} sed -n '1,260p' {}

Repository: nuxt/ui

Length of output: 22224


🏁 Script executed:

fd -t f 'Sidebar.vue' | grep -v DashboardSidebar

Repository: nuxt/ui

Length of output: 140


🏁 Script executed:

cat -n src/runtime/components/Sidebar.vue

Repository: nuxt/ui

Length of output: 17013


Establish parent intent before syncing persisted collapsed state to open model.

At setup time, lines 209–212 unconditionally set modelOpen.value = false whenever a persisted collapsed state exists, even if the parent explicitly passed v-model:open="true". This silently overrides the parent's intent without a guard. Consider only applying this sync when the parent did not explicitly provide open (treat the default true as "no external control"), or introduce an opt-in persistent prop to gate the behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Sidebar.vue` around lines 209 - 212, The code
unconditionally forces modelOpen.value = false when a persisted collapsed state
exists, which can override a parent-controlled v-model:open; update the setup
logic around canCollapse, isCollapsed and modelOpen so you only apply the
persisted collapse when the parent did not explicitly provide an open value
(i.e., detect absence of an external v-model:open and only then set
modelOpen.value = false), or add a boolean prop (e.g., persistent) and guard the
sync with that prop (apply the persisted state only when persistent is true);
adjust the check where modelOpen is set to consult either the absence of an
external open prop or the new persistent prop before changing modelOpen.value.

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

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants