From 9f98b969c86a8828ee53e4ab2f112d884112223d Mon Sep 17 00:00:00 2001 From: Mike Newbon Date: Sun, 15 Mar 2026 04:24:21 +0100 Subject: [PATCH 1/8] feat(Sidebar): add resizable functionality --- .../examples/sidebar/SidebarExample.vue | 1 + .../docs/2.components/dashboard-sidebar.md | 2 +- docs/content/docs/2.components/sidebar.md | 84 ++++++++++++++++- src/runtime/components/Sidebar.vue | 86 ++++++++++++++++- src/theme/sidebar.ts | 6 +- .../__snapshots__/Sidebar-vue.spec.ts.snap | 94 +++++++++---------- .../__snapshots__/Sidebar.spec.ts.snap | 94 +++++++++---------- 7 files changed, 262 insertions(+), 105 deletions(-) diff --git a/docs/app/components/content/examples/sidebar/SidebarExample.vue b/docs/app/components/content/examples/sidebar/SidebarExample.vue index 22cde75c6a..f5baa20aa8 100644 --- a/docs/app/components/content/examples/sidebar/SidebarExample.vue +++ b/docs/app/components/content/examples/sidebar/SidebarExample.vue @@ -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', diff --git a/docs/content/docs/2.components/dashboard-sidebar.md b/docs/content/docs/2.components/dashboard-sidebar.md index 3b61bd6094..7e1798e115 100644 --- a/docs/content/docs/2.components/dashboard-sidebar.md +++ b/docs/content/docs/2.components/dashboard-sidebar.md @@ -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. diff --git a/docs/content/docs/2.components/sidebar.md b/docs/content/docs/2.components/sidebar.md index 1388190c27..70cb8ca83e 100644 --- a/docs/content/docs/2.components/sidebar.md +++ b/docs/content/docs/2.components/sidebar.md @@ -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. @@ -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: | + + +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: | + + +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`. @@ -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 --- diff --git a/src/runtime/components/Sidebar.vue b/src/runtime/components/Sidebar.vue index 4f6641da07..fcf013a5d3 100644 --- a/src/runtime/components/Sidebar.vue +++ b/src/runtime/components/Sidebar.vue @@ -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' @@ -11,7 +12,7 @@ type SidebarState = 'expanded' | 'collapsed' type SidebarMode = 'modal' | 'slideover' | 'drawer' type SidebarMenu = T extends 'modal' ? ModalProps : T extends 'slideover' ? SlideoverProps : T extends 'drawer' ? DrawerProps : never -export interface SidebarProps { +export interface SidebarProps extends Pick { /** * The element or component this component should render as. * @defaultValue 'aside' @@ -58,10 +59,18 @@ export interface SidebarProps { 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 below `minSize` 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' @@ -89,13 +98,14 @@ export interface SidebarSlots { @@ -194,6 +194,8 @@ const canCollapse = computed(() => isResizable.value && props.collapsible !== 'n const sidebarId = `sidebar-${props.id || useId()}` const desktopCollapsed = ref(!modelOpen.value) +const effectiveCollapsedSize = computed(() => props.collapsedSize ?? 4) + const { el: containerEl, size: sidebarSize, isDragging, isCollapsed, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, onDoubleClick: handleDoubleClick, collapse } = useResizable(sidebarId, computed(() => ({ side: props.side, minSize: props.minSize, @@ -201,7 +203,7 @@ const { el: containerEl, size: sidebarSize, isDragging, isCollapsed, onMouseDown defaultSize: props.defaultSize, resizable: isResizable.value, collapsible: canCollapse.value, - collapsedSize: props.collapsedSize ?? Math.max(0, props.minSize - 8), + collapsedSize: effectiveCollapsedSize.value, unit: 'rem' as const, persistent: true, storage: 'cookie' as const @@ -212,27 +214,8 @@ if (!isMobile.value && canCollapse.value && isCollapsed.value) { modelOpen.value = false } -// Track whether mousedown resulted in a drag (to distinguish click vs drag on the rail) -let didDrag = false - -function onRailMouseDown(e: MouseEvent) { - didDrag = false - const startX = e.clientX - const onMove = (ev: MouseEvent) => { - if (Math.abs(ev.clientX - startX) > 3) didDrag = true - } - const onUp = () => { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) - } - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - handleMouseDown(e) -} - function onRailClick() { - if (!isResizable.value) return (open.value = !open.value) - if (!didDrag && canCollapse.value) collapse(!isCollapsed.value) + if (!isResizable.value) open.value = !open.value } // Dynamic cursor: ew-resize (bidirectional) by default, directional at bounds @@ -367,7 +350,7 @@ const menuProps = toRef(() => defu(props.menu, { :class="ui.root({ class: [uiProp?.root, props.class] })" :style="isResizable ? { '--sidebar-width': `${expandedWidth}rem`, - ...(props.collapsedSize && props.collapsible === 'icon' ? { '--sidebar-width-icon': `${props.collapsedSize}rem` } : {}) + ...(props.collapsible === 'icon' ? { '--sidebar-width-icon': `${effectiveCollapsedSize}rem` } : {}) } : undefined" > @@ -386,7 +369,15 @@ const menuProps = toRef(() => defu(props.menu, { > - +