Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ defineShortcuts(extractShortcuts(teamsItems.value))
v-model:open="open"
collapsible="icon"
rail
resizable
:ui="{
container: 'h-full',
inner: 'bg-elevated/25 divide-transparent',
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/2.components/dashboard-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ links:
The DashboardSidebar component is used to display a sidebar in a dashboard layout. It supports drag-to-resize, state persistence and integrates with [DashboardGroup](/docs/components/dashboard-group), [DashboardPanel](/docs/components/dashboard-panel) and [DashboardNavbar](/docs/components/dashboard-navbar).

::tip{to="/docs/components/sidebar"}
**DashboardSidebar vs Sidebar**: This component is designed for dashboard layouts with drag-to-resize, state persistence and `DashboardGroup` integration. For a simple, standalone sidebar (chat panel, settings, navigation), use [Sidebar](/docs/components/sidebar) instead.
**DashboardSidebar vs Sidebar**: This component is designed for dashboard layouts with `DashboardGroup` integration. For a standalone sidebar (chat panel, settings, navigation), use [Sidebar](/docs/components/sidebar) instead β€” it also supports drag-to-resize and state persistence.
::

Its state (size, collapsed, etc.) will be saved based on the `storage` and `storage-key` props you provide to the [DashboardGroup](/docs/components/dashboard-group#props) component.
Expand Down
84 changes: 81 additions & 3 deletions docs/content/docs/2.components/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ navigation.badge: New
The Sidebar component is a standalone, fixed sidebar that pushes the page content. On desktop, it renders inline and can be collapsed; on mobile, it opens a [Modal](/docs/components/modal), [Slideover](/docs/components/slideover) or [Drawer](/docs/components/drawer) component.

::tip{to="/docs/components/dashboard-sidebar"}
**Sidebar vs DashboardSidebar**: This component is a simple, standalone sidebar you can drop anywhere (chat panel, settings, navigation). If you need drag-to-resize, state persistence and integration with [DashboardGroup](/docs/components/dashboard-group), use [DashboardSidebar](/docs/components/dashboard-sidebar) instead.
**Sidebar vs DashboardSidebar**: This component is a standalone sidebar you can drop anywhere (chat panel, settings, navigation) with optional drag-to-resize and state persistence. If you need integration with [DashboardGroup](/docs/components/dashboard-group) use [DashboardSidebar](/docs/components/dashboard-sidebar) instead.
::

Use the `header`, `default` and `footer` slots to customize the sidebar content. The `v-model:open` directive is viewport-aware: on desktop it controls the expanded/collapsed state, on mobile it controls the menu.
Expand Down Expand Up @@ -191,6 +191,84 @@ class: '!p-0 !justify-start h-[500px] contain-[paint]'
:placeholder{class="h-full"}
::

### Resizable

Use the `resizable` prop to make the sidebar resizable by dragging the rail. Requires `rail` to be enabled.

::component-code
---
prettier: true
ignore:
- rail
- collapsible
- title
- ui.container
hide:
- minSize
- defaultSize
- maxSize
- ui
- class
props:
rail: true
resizable: true
collapsible: icon
minSize: 12
defaultSize: 16
maxSize: 24
title: Navigation
ui.container: h-full
items:
resizable:
- true
- false
slots:
default: |

<Placeholder class="h-full" />
class: '!p-0 !justify-start h-[500px] contain-[paint]'
---

:placeholder{class="h-full"}
::

When `collapsible` is not `none`, dragging below `min-size` snaps the sidebar to its collapsed state. Click the rail to toggle collapsed, or double-click to reset to `default-size`.

### Size

Use the `min-size`, `max-size`, `default-size` and `collapsed-size` props to customize the size of the sidebar in `rem`.

::component-code
---
prettier: true
ignore:
- rail
- resizable
- title
- ui.container
hide:
- ui
- class
props:
rail: true
resizable: true
collapsible: icon
minSize: 14
defaultSize: 18
maxSize: 28
collapsedSize: 10
title: Navigation
ui.container: h-full
slots:
default: |

<Placeholder class="h-full" />
class: '!p-0 !justify-start h-[500px] contain-[paint]'
---

:placeholder{class="h-full"}
::

### Close

Use the `close` prop to display a close button in the sidebar header. The close button is only rendered when `collapsible` is not `none`.
Expand Down Expand Up @@ -348,9 +426,9 @@ The only difference with the previous example is replacing `ref(true)` with `use

### With custom width

The sidebar width is controlled by the `--sidebar-width` CSS variable (defaults to `16rem`). The collapsed icon width is controlled by `--sidebar-width-icon` (defaults to `4rem`).
When using the `resizable` prop, the sidebar width is controlled by the `default-size`, `min-size` and `max-size` props. Without `resizable`, the width is controlled by the `--sidebar-width` CSS variable (defaults to `16rem`). The collapsed icon width is controlled by `--sidebar-width-icon` (defaults to `4rem`).

Override them globally in your CSS or per-instance with the `style` attribute.
Override the CSS variables globally in your CSS or per-instance with the `style` attribute.

::component-example
---
Expand Down
1 change: 1 addition & 0 deletions playgrounds/nuxt/app/pages/components/sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function onSubmit() {
collapsible="icon"
close
rail
resizable
:ui="{ container: 'relative', body: 'py-2' }"
>
<template #title="{ state }">
Expand Down
102 changes: 94 additions & 8 deletions src/runtime/components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { VNode } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/sidebar'
import type { UseResizableProps } from '../composables/useResizable'
import type { ButtonProps, DrawerProps, IconProps, ModalProps, SlideoverProps, LinkPropsKeys } from '../types'
import type { ComponentConfig } from '../types/tv'

Expand All @@ -11,7 +12,7 @@ type SidebarState = 'expanded' | 'collapsed'
type SidebarMode = 'modal' | 'slideover' | 'drawer'
type SidebarMenu<T> = T extends 'modal' ? ModalProps : T extends 'slideover' ? SlideoverProps : T extends 'drawer' ? DrawerProps : never

export interface SidebarProps<T extends SidebarMode = SidebarMode> {
export interface SidebarProps<T extends SidebarMode = SidebarMode> extends Pick<UseResizableProps, 'id' | 'minSize' | 'maxSize' | 'defaultSize' | 'collapsedSize'> {
/**
* The element or component this component should render as.
* @defaultValue 'aside'
Expand Down Expand Up @@ -58,10 +59,18 @@ export interface SidebarProps<T extends SidebarMode = SidebarMode> {
closeIcon?: IconProps['name']
/**
* Display a rail on the sidebar edge to toggle collapse.
* Only renders when `collapsible` is not `none`.
* When `resizable` is also enabled, the rail acts as a drag-to-resize handle.
* @defaultValue false
*/
rail?: boolean
/**
* Whether to allow the user to resize the sidebar by dragging the rail.
* Requires `rail` to be enabled. Drag to resize between `minSize` and `maxSize`.
* When `collapsible` is not `none`, dragging near `collapsedSize` snaps to collapsed.
* Double-click the rail to reset to `defaultSize`.
* @defaultValue false
*/
resizable?: boolean
/**
* The mode of the sidebar menu on mobile.
* @defaultValue 'slideover'
Expand All @@ -83,19 +92,20 @@ export interface SidebarSlots {
close?(props: { ui: Sidebar['ui'], state: SidebarState }): VNode[]
default?(props: { state: SidebarState, open: boolean, close: () => void }): VNode[]
footer?(props: { state: SidebarState, open: boolean, close: () => void }): VNode[]
rail?(props: { ui: Sidebar['ui'], state: SidebarState }): VNode[]
rail?(props: { ui: Sidebar['ui'], state: SidebarState, onMouseDown: (e: MouseEvent) => void, onTouchStart: (e: TouchEvent) => void, onDoubleClick: (e: MouseEvent) => void }): VNode[]
content?(props: { close: () => void }): VNode[]
}
</script>

<script setup lang="ts" generic="T extends SidebarMode">
import { computed, onMounted, ref, toRef, watch } from 'vue'
import { computed, onMounted, ref, toRef, useId, watch } from 'vue'
import { Primitive } from 'reka-ui'
import { defu } from 'defu'
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentUI } from '../composables/useComponentUI'
import { useLocale } from '../composables/useLocale'
import { useResizable } from '../composables/useResizable'
import { tv } from '../utils/tv'
import UButton from './Button.vue'
import USlideover from './Slideover.vue'
Expand All @@ -111,6 +121,11 @@ const props = withDefaults(defineProps<SidebarProps<T>>(), {
side: 'left',
close: false,
rail: false,
resizable: false,
minSize: 12,
maxSize: 24,
defaultSize: 16,
collapsedSize: 4,
mode: 'slideover' as never
})
const slots = defineSlots<SidebarSlots>()
Expand Down Expand Up @@ -173,6 +188,56 @@ const { t } = useLocale()
const appConfig = useAppConfig() as Sidebar['AppConfig']
const uiProp = useComponentUI('sidebar', props)

// 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.


const { el: containerEl, size: sidebarSize, isDragging, isCollapsed, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, onDoubleClick: handleDoubleClick, collapse } = useResizable(sidebarId, computed(() => ({
side: props.side as 'left' | 'right',
minSize: props.minSize,
maxSize: props.maxSize,
defaultSize: props.defaultSize,
resizable: isResizable.value,
collapsible: canCollapse.value,
collapsedSize: props.collapsedSize,
unit: 'rem' as const,
persistent: true,
storage: 'cookie' as const
})))

// Sync initial persisted collapsed state to open model
if (canCollapse.value && isCollapsed.value) {
modelOpen.value = false
}
Comment on lines +209 to +212
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.


function onRailClick() {
if (!isResizable.value) open.value = !open.value
}

// Dynamic cursor: ew-resize (bidirectional) by default, directional at bounds
const railCursor = computed(() => {
if (!isResizable.value) return undefined
if (isCollapsed.value) return props.side === 'left' ? 'e-resize' : 'w-resize'
if (expandedWidth.value >= props.maxSize) return props.side === 'left' ? 'w-resize' : 'e-resize'
return 'ew-resize'
})

// Track expanded width for --sidebar-width (so offcanvas slide-out uses the correct value when collapsed)
const expandedWidth = ref(props.defaultSize)
watch(sidebarSize, (v) => {
if (!isCollapsed.value) expandedWidth.value = v
}, { immediate: true })

// Sync useResizable collapse ↔ open model
watch(isCollapsed, (collapsed) => {
if (!isMobile.value && canCollapse.value) modelOpen.value = !collapsed
})
// modelOpen and isCollapsed have inverted semantics β€” equal values means they're out of sync
watch(modelOpen, (v) => {
if (!isMobile.value && canCollapse.value && isCollapsed.value === v) collapse(!v)
})

const state = computed<SidebarState>(() => open.value ? 'expanded' : 'collapsed')

// Close button only works when collapsible is not 'none'
Expand Down Expand Up @@ -279,7 +344,12 @@ const menuProps = toRef(() => defu(props.menu, {
:data-collapsible="state === 'collapsed' ? collapsible : undefined"
:data-variant="variant"
:data-side="side"
:data-dragging="isDragging || undefined"
:class="ui.root({ class: [uiProp?.root, props.class] })"
:style="isResizable ? {
'--sidebar-width': `${expandedWidth}rem`,
...(props.collapsible === 'icon' ? { '--sidebar-width-icon': `${collapsedSize}rem` } : {})
} : undefined"
>
<!-- Gap spacer: reserves layout space for the fixed sidebar -->
<div
Expand All @@ -290,20 +360,36 @@ const menuProps = toRef(() => defu(props.menu, {

<!-- Fixed container: the actual visible sidebar -->
<div
ref="containerEl"
data-slot="container"
:data-state="state"
:class="ui.container({ class: uiProp?.container })"
>
<ReuseInnerTemplate />

<slot v-if="rail" name="rail" :state="state" :ui="ui">
<button
<slot
v-if="rail"
name="rail"
:state="state"
:ui="ui"
:on-mouse-down="handleMouseDown"
:on-touch-start="handleTouchStart"
:on-double-click="handleDoubleClick"
>
<component
:is="isResizable ? 'div' : 'button'"
data-slot="rail"
:data-state="state"
:aria-label="t('sidebar.toggle')"
:role="isResizable ? 'separator' : undefined"
:aria-orientation="isResizable ? 'vertical' : undefined"
:aria-label="isResizable ? undefined : t('sidebar.toggle')"
:tabindex="-1"
:class="ui.rail({ class: uiProp?.rail })"
@click="open = !open"
:style="railCursor ? { cursor: railCursor } : undefined"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart"
@dblclick="handleDoubleClick"
@click="onRailClick"
/>
</slot>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/theme/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { ModuleOptions } from '../module'

export default (options: Required<ModuleOptions>) => ({
slots: {
root: 'peer [--sidebar-width:16rem] [--sidebar-width-icon:4rem]',
gap: 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
container: 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear lg:flex',
root: 'peer [--sidebar-width:16rem] [--sidebar-width-icon:4rem] data-[dragging=true]:select-none',
gap: 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-out group-data-[dragging=true]/sidebar:!duration-0',
container: 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-out lg:flex group-data-[dragging=true]/sidebar:!duration-0',
inner: 'flex size-full flex-col overflow-hidden divide-y divide-default',
header: 'flex items-center gap-1.5 overflow-hidden px-4 min-h-(--ui-header-height)',
wrapper: 'min-w-0 flex-1',
Expand All @@ -14,7 +14,7 @@ export default (options: Required<ModuleOptions>) => ({
close: '',
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4',
footer: 'flex items-center gap-1.5 overflow-hidden p-4',
rail: ['absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-px lg:flex hover:after:bg-(--ui-border-accented)', options.theme.transitions && 'after:transition-colors']
rail: ['absolute inset-y-0 z-20 hidden w-4 transition-all ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-px lg:flex hover:after:bg-(--ui-border-accented)', options.theme.transitions && 'after:transition-colors']
},
variants: {
side: {
Expand Down
1 change: 1 addition & 0 deletions test/components/Sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('Sidebar', () => {
['with mode drawer', { props: { mode: 'drawer' as const, menu: { portal: false } } }],
...collapsibles.filter((c: string) => c !== 'none').map((collapsible: string) => [`with collapsed ${collapsible}`, { props: { open: false, collapsible } }]),
['with rail', { props: { rail: true, collapsible: 'icon' as const } }],
['with resizable', { props: { rail: true, resizable: true, collapsible: 'icon' as const } }],
['with class', { props: { class: 'bg-elevated/50' } }],
['with ui', { props: { ui: { body: 'py-0' } } }],
// Slots
Expand Down
Loading
Loading