-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(Sidebar): add resizable functionality #6340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4
Are you sure you want to change the base?
Changes from all commits
9f98b96
9c84986
85224f0
c40ab3a
b9e6872
25fdc5d
a8c6967
620a144
3c81255
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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> = 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' | ||
|
|
@@ -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' | ||
|
|
@@ -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' | ||
|
|
@@ -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>() | ||
|
|
@@ -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()}` | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© 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 DashboardSidebarRepository: nuxt/ui Length of output: 140 π Script executed: cat -n src/runtime/components/Sidebar.vueRepository: nuxt/ui Length of output: 17013 Establish parent intent before syncing persisted collapsed state to At setup time, lines 209β212 unconditionally set π€ Prompt for AI Agents |
||
|
|
||
| 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' | ||
|
|
@@ -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 | ||
|
|
@@ -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> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sidebarIddouble-prefixes user-providedidand isn't reactive.When a consumer passes
id="my-sidebar", the resulting id becomessidebar-my-sidebar, which is then used as thestorageKeyinsideuseResizable. 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 toprops.idaren't reflected.Proposed fix
And pass
sidebarId.value(or wrap theuseResizablecall accordingly) so the storage key matches what consumers expect.π€ Prompt for AI Agents