+
diff --git a/packages/components/src/layout/components/FloatingDragBar.vue b/packages/components/src/layout/components/FloatingDragBar.vue
new file mode 100644
index 000000000..57f3291f5
--- /dev/null
+++ b/packages/components/src/layout/components/FloatingDragBar.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/components/FloatingResizeTrigger.vue b/packages/components/src/layout/components/FloatingResizeTrigger.vue
deleted file mode 100644
index 7cdf4f42e..000000000
--- a/packages/components/src/layout/components/FloatingResizeTrigger.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/packages/components/src/layout/components/FloatingResizeTriggers.vue b/packages/components/src/layout/components/FloatingResizeTriggers.vue
new file mode 100644
index 000000000..9f09cd8ad
--- /dev/null
+++ b/packages/components/src/layout/components/FloatingResizeTriggers.vue
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/components/LayoutSurface.vue b/packages/components/src/layout/components/LayoutSurface.vue
new file mode 100644
index 000000000..33d1d10c7
--- /dev/null
+++ b/packages/components/src/layout/components/LayoutSurface.vue
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/composables/useLayoutAsideResize.ts b/packages/components/src/layout/composables/useLayoutAsideResize.ts
deleted file mode 100644
index 56cf02357..000000000
--- a/packages/components/src/layout/composables/useLayoutAsideResize.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { useEventListener } from '@vueuse/core'
-import { computed, onBeforeUnmount, shallowRef } from 'vue'
-import type { LayoutAsideResizeEventDetail } from '../index.type'
-import type { LayoutContext, LayoutPanelContext } from '../internal.type'
-import type { LayoutPlacement } from '../index.type'
-import { resolveCssLengthToPx } from '../utils/cssLength'
-import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction'
-import { clamp } from '../utils/math'
-
-interface UseLayoutAsideResizeOptions {
- context: LayoutContext
- panel: LayoutPanelContext
- onResizeStart?: (detail: LayoutAsideResizeEventDetail) => void
- onResize?: (detail: LayoutAsideResizeEventDetail) => void
- onResizeEnd?: (detail: LayoutAsideResizeEventDetail) => void
-}
-
-interface ResizeState {
- pointerId: number
- handleEl: HTMLElement
- panel: LayoutPanelContext
- placement: LayoutPlacement
- startX: number
- startWidth: number
- currentWidth: number
- minWidth: number
- effectiveMax: number
- pendingWidth: number | null
- frameId: number | null
- bodyState: BodyInteractionState
-}
-
-function getDockedAsideWidth(panel: LayoutPanelContext, asideEl: HTMLElement | null | undefined): number {
- if (!panel.state.isDock.value || panel.state.isHidden.value || !asideEl) {
- return 0
- }
-
- return asideEl.getBoundingClientRect().width
-}
-
-export function useLayoutAsideResize(options: UseLayoutAsideResizeOptions) {
- const activeResize = shallowRef
(null)
- const isResizing = computed(() => activeResize.value !== null)
- const draggingPlacement = computed(() => activeResize.value?.placement ?? null)
- const pointerTarget = typeof window === 'undefined' ? undefined : window
-
- function scheduleWidth(nextWidth: number): void {
- const state = activeResize.value
- if (!state || nextWidth === state.currentWidth) {
- return
- }
-
- state.pendingWidth = nextWidth
- state.currentWidth = nextWidth
-
- if (state.frameId !== null || typeof window === 'undefined') {
- return
- }
-
- state.frameId = window.requestAnimationFrame(() => {
- const current = activeResize.value
- if (!current) {
- return
- }
-
- current.frameId = null
-
- if (current.pendingWidth === null) {
- return
- }
-
- current.panel.actions.setWidth(current.pendingWidth)
- options.onResize?.({
- placement: current.placement,
- expandedWidth: current.pendingWidth,
- })
- current.pendingWidth = null
- })
- }
-
- function stopResize(pointerId?: number): void {
- const state = activeResize.value
- if (!state || (pointerId !== undefined && state.pointerId !== pointerId)) {
- return
- }
-
- if (state.frameId !== null && typeof window !== 'undefined') {
- window.cancelAnimationFrame(state.frameId)
- state.frameId = null
- }
-
- if (state.pendingWidth !== null) {
- state.panel.actions.setWidth(state.pendingWidth)
- options.onResize?.({
- placement: state.placement,
- expandedWidth: state.pendingWidth,
- })
- state.pendingWidth = null
- }
-
- if (state.handleEl.hasPointerCapture(state.pointerId)) {
- state.handleEl.releasePointerCapture(state.pointerId)
- }
-
- restoreBodyInteraction(state.handleEl.ownerDocument.body, state.bodyState)
-
- options.onResizeEnd?.({
- placement: state.placement,
- expandedWidth: state.currentWidth,
- })
-
- activeResize.value = null
- }
-
- function startResize(event: PointerEvent): void {
- const panel = options.panel
-
- if (activeResize.value || !event.isPrimary || event.button !== 0 || !panel.state.canResize.value) {
- return
- }
-
- const rootEl = options.context.rootEl.value
- const handleEl = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
- const asideEl = panel.el.value
-
- if (!rootEl || !handleEl || !asideEl) {
- return
- }
-
- const isLeft = panel.state.placement === 'left'
- const oppositePanel = isLeft ? options.context.right : options.context.left
- const oppositeAsideEl = oppositePanel.el.value
- const rootRect = rootEl.getBoundingClientRect()
- const startWidth = asideEl.getBoundingClientRect().width
- const maxWidth = panel.state.maxWidth.value
- const minWidth = panel.state.minWidth.value
- const mainMinWidth = resolveCssLengthToPx(
- getComputedStyle(rootEl).getPropertyValue('--tr-layout-main-min-width').trim() || '320px',
- rootEl,
- 320,
- )
- const oppositeDockWidth = getDockedAsideWidth(oppositePanel, oppositeAsideEl)
- const effectiveMax = Math.max(minWidth, Math.min(maxWidth, rootRect.width - mainMinWidth - oppositeDockWidth))
-
- event.preventDefault()
- handleEl.setPointerCapture(event.pointerId)
-
- activeResize.value = {
- pointerId: event.pointerId,
- handleEl,
- panel,
- placement: panel.state.placement,
- startX: event.clientX,
- startWidth,
- currentWidth: startWidth,
- minWidth,
- effectiveMax,
- pendingWidth: null,
- frameId: null,
- bodyState: lockBodyInteraction(rootEl.ownerDocument.body, 'col-resize'),
- }
-
- options.onResizeStart?.({
- placement: panel.state.placement,
- expandedWidth: startWidth,
- })
- }
-
- useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => {
- const state = activeResize.value
- if (!state || event.pointerId !== state.pointerId) {
- return
- }
-
- const deltaX = event.clientX - state.startX
- const rawWidth = state.placement === 'left' ? state.startWidth + deltaX : state.startWidth - deltaX
- scheduleWidth(clamp(rawWidth, state.minWidth, state.effectiveMax))
- })
-
- useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => {
- stopResize(event.pointerId)
- })
-
- useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => {
- stopResize(event.pointerId)
- })
-
- onBeforeUnmount(() => {
- stopResize()
- })
-
- return {
- isResizing,
- draggingPlacement,
- startResize,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutContext.ts b/packages/components/src/layout/composables/useLayoutContext.ts
index e4e88cf1b..d6f7cf021 100644
--- a/packages/components/src/layout/composables/useLayoutContext.ts
+++ b/packages/components/src/layout/composables/useLayoutContext.ts
@@ -1,38 +1,8 @@
import { inject, provide, type InjectionKey } from 'vue'
-import type { LayoutContext, LayoutFloatingContext, LayoutPanelContext } from '../internal.type'
-import { createLayoutDrawerActions } from './useLayoutDrawerActions'
+import type { LayoutContext } from '../internal.type'
const layoutContextKey: InjectionKey = Symbol('LayoutContext')
-interface CreateLayoutContextOptions {
- rootEl: LayoutContext['rootEl']
- dragHandleEl: LayoutContext['dragHandleEl']
- left: LayoutPanelContext
- right: LayoutPanelContext
- floating: LayoutFloatingContext
-}
-
-export function createLayoutContext(options: CreateLayoutContextOptions): LayoutContext {
- const drawer = createLayoutDrawerActions({
- left: options.left,
- right: options.right,
- })
-
- return {
- rootEl: options.rootEl,
- dragHandleEl: options.dragHandleEl,
- left: drawer.left,
- right: drawer.right,
- floating: options.floating,
- ui: {
- isDrawerVisible: drawer.isDrawerVisible,
- },
- actions: {
- closeDrawers: drawer.closeDrawers,
- },
- }
-}
-
export function provideLayoutContext(context: LayoutContext): void {
provide(layoutContextKey, context)
}
diff --git a/packages/components/src/layout/composables/useLayoutDrawerActions.ts b/packages/components/src/layout/composables/useLayoutDrawerActions.ts
deleted file mode 100644
index 368f7a7bf..000000000
--- a/packages/components/src/layout/composables/useLayoutDrawerActions.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { computed, type ComputedRef } from 'vue'
-import type { LayoutPanelActions, LayoutPanelContext } from '../internal.type'
-
-interface CreateLayoutDrawerActionsOptions {
- left: LayoutPanelContext
- right: LayoutPanelContext
-}
-
-interface CreateLayoutDrawerActionsResult {
- left: LayoutPanelContext
- right: LayoutPanelContext
- isDrawerVisible: ComputedRef
- closeDrawers: () => void
-}
-
-function createPanelActions(panel: LayoutPanelContext, getSibling: () => LayoutPanelContext): LayoutPanelActions {
- function open(): void {
- if (panel.state.isDrawer.value) {
- const sibling = getSibling()
- if (sibling.state.isDrawer.value && sibling.state.isOpen.value) {
- sibling.actions.close()
- }
- }
-
- panel.actions.setOpen(true)
- }
-
- function close(): void {
- panel.actions.setOpen(false)
- }
-
- function toggle(): void {
- if (panel.state.isOpen.value) {
- close()
- return
- }
-
- open()
- }
-
- return {
- open,
- close,
- toggle,
- setOpen: (nextOpen) => {
- if (nextOpen) {
- open()
- return
- }
-
- close()
- },
- setWidth: panel.actions.setWidth,
- }
-}
-
-export function createLayoutDrawerActions(options: CreateLayoutDrawerActionsOptions): CreateLayoutDrawerActionsResult {
- let left = options.left
- let right = options.right
-
- left = {
- ...options.left,
- actions: createPanelActions(options.left, () => right),
- }
-
- right = {
- ...options.right,
- actions: createPanelActions(options.right, () => left),
- }
-
- const isDrawerVisible = computed(
- () =>
- (left.state.isDrawer.value && left.state.isOpen.value) ||
- (right.state.isDrawer.value && right.state.isOpen.value),
- )
-
- function closeDrawers(): void {
- if (left.state.isDrawer.value && left.state.isOpen.value) {
- left.actions.close()
- }
-
- if (right.state.isDrawer.value && right.state.isOpen.value) {
- right.actions.close()
- }
- }
-
- return {
- left,
- right,
- isDrawerVisible,
- closeDrawers,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutFloating.ts b/packages/components/src/layout/composables/useLayoutFloating.ts
deleted file mode 100644
index 8da4afad0..000000000
--- a/packages/components/src/layout/composables/useLayoutFloating.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { useWindowSize } from '@vueuse/core'
-import { computed, shallowRef, watch, type CSSProperties } from 'vue'
-import type {
- LayoutFloatingDragEventDetail,
- LayoutFloatingResizeEventDetail,
- LayoutFloatingResizeHandle,
- LayoutFloatingState,
-} from '../index.type'
-import type { LayoutContext, LayoutFloatingRect } from '../internal.type'
-import { useLayoutFloatingDrag } from './useLayoutFloatingDrag'
-import { useLayoutFloatingResize } from './useLayoutFloatingResize'
-import {
- areFloatingGeometryEqual,
- clampFloatingRect,
- DEFAULT_FLOATING_GAP,
- DEFAULT_FLOATING_HEIGHT,
- DEFAULT_FLOATING_WIDTH,
- normalizeFloatingRect,
- resolveFloatingSnapshot,
- toCommittedFloatingState,
-} from '../utils/surfaceGeometry'
-
-type FloatingInteraction = 'drag' | 'resize'
-
-interface UseLayoutFloatingOptions {
- context: LayoutContext
- onFloatingDragStart?: (detail: LayoutFloatingDragEventDetail) => void
- onFloatingDrag?: (detail: LayoutFloatingDragEventDetail) => void
- onFloatingDragEnd?: (detail: LayoutFloatingDragEventDetail) => void
- onFloatingResizeStart?: (detail: LayoutFloatingResizeEventDetail) => void
- onFloatingResize?: (detail: LayoutFloatingResizeEventDetail) => void
- onFloatingResizeEnd?: (detail: LayoutFloatingResizeEventDetail) => void
-}
-
-export function useLayoutFloating(options: UseLayoutFloatingOptions) {
- const { width: viewportWidth, height: viewportHeight } = useWindowSize({
- type: 'visual',
- initialWidth: DEFAULT_FLOATING_WIDTH + DEFAULT_FLOATING_GAP * 2,
- initialHeight: DEFAULT_FLOATING_HEIGHT + DEFAULT_FLOATING_GAP * 2,
- })
-
- const mode = options.context.floating.state.mode
- const isFloating = computed(() => mode.value === 'floating')
- const isNormal = computed(() => mode.value === 'normal')
- const floatingStateValue = options.context.floating.state.value
- const floatingValue = options.context.floating.state.resolved
- const floatingRect = computed(() => normalizeFloatingRect(floatingValue.value))
- const isFloatingDraggable = computed(() => floatingRect.value.draggable ?? true)
- const isFloatingResizable = computed(() => floatingRect.value.resizable === true)
- const activeInteraction = shallowRef(null)
- const canStartDrag = computed(() => isFloating.value && isFloatingDraggable.value && activeInteraction.value === null)
- const canStartResize = computed(
- () => isFloating.value && isFloatingResizable.value && activeInteraction.value === null,
- )
- const isDragEnabled = computed(
- () => isFloating.value && isFloatingDraggable.value && activeInteraction.value !== 'resize',
- )
- const isResizeVisible = computed(() => isFloating.value && isFloatingResizable.value)
-
- function toFloatingState(rect: LayoutFloatingRect, normalizeCenter = false): LayoutFloatingState {
- return toCommittedFloatingState(resolveFloatingSnapshot(rect, floatingValue.value), floatingStateValue.value, {
- normalizeCenter,
- })
- }
-
- function toResizeDetail(
- handle: LayoutFloatingResizeHandle,
- rect: LayoutFloatingRect,
- ): LayoutFloatingResizeEventDetail {
- return {
- ...toFloatingState(rect, true),
- handle,
- }
- }
-
- function commitRect(nextRect: LayoutFloatingRect): LayoutFloatingRect {
- const normalizedRect = clampFloatingRect(nextRect)
-
- if (areFloatingGeometryEqual(floatingRect.value, normalizedRect)) {
- return normalizedRect
- }
-
- options.context.floating.actions.commit(toFloatingState(normalizedRect, true))
-
- return normalizedRect
- }
-
- function applyDraggedPosition(nextX: number, nextY: number) {
- const nextRect = commitRect({
- ...floatingRect.value,
- x: nextX,
- y: nextY,
- })
-
- return nextRect
- }
-
- function startInteraction(type: FloatingInteraction): void {
- activeInteraction.value = type
- }
-
- function endInteraction(type: FloatingInteraction): void {
- if (activeInteraction.value === type) {
- activeInteraction.value = null
- }
- }
-
- const drag = useLayoutFloatingDrag({
- context: options.context,
- floatingRect,
- canStart: canStartDrag,
- isEnabled: isDragEnabled,
- toFloatingState,
- applyPosition: applyDraggedPosition,
- onInteractionStart: () => startInteraction('drag'),
- onInteractionEnd: () => endInteraction('drag'),
- onFloatingDragStart: options.onFloatingDragStart,
- onFloatingDrag: options.onFloatingDrag,
- onFloatingDragEnd: options.onFloatingDragEnd,
- })
-
- const resize = useLayoutFloatingResize({
- context: options.context,
- floatingRect,
- canStart: canStartResize,
- isVisible: isResizeVisible,
- commitRect,
- toResizeDetail,
- onInteractionStart: () => startInteraction('resize'),
- onInteractionEnd: () => endInteraction('resize'),
- onFloatingResizeStart: options.onFloatingResizeStart,
- onFloatingResize: options.onFloatingResize,
- onFloatingResizeEnd: options.onFloatingResizeEnd,
- })
-
- function syncFloatingRect(): void {
- if (!isFloating.value || activeInteraction.value !== null) {
- return
- }
-
- if (!floatingStateValue.value) {
- options.context.floating.actions.initialize(toFloatingState(floatingRect.value))
- }
-
- const nextRect = commitRect(floatingRect.value)
- drag.setPosition(nextRect.x, nextRect.y)
- }
-
- watch(
- [mode, floatingValue, viewportWidth, viewportHeight],
- () => {
- syncFloatingRect()
- },
- { immediate: true },
- )
-
- const floatingClass = computed(() => ({
- 'tr-layout--floating': isFloating.value,
- 'tr-layout--floating-dragging': drag.isDragging.value,
- 'tr-layout--floating-resizing': resize.isResizing.value,
- }))
-
- const floatingStyle = computed(() => {
- if (isNormal.value) {
- return {}
- }
-
- return {
- left: `${floatingRect.value.x}px`,
- top: `${floatingRect.value.y}px`,
- width: `${floatingRect.value.width}px`,
- height: `${floatingRect.value.height}px`,
- }
- })
-
- return {
- isFloating,
- showDragBar: computed(() => isFloating.value),
- floatingClass,
- floatingStyle,
- dragBarClass: drag.dragBarClass,
- resizeHandles: resize.resizeHandles,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutFloatingDrag.ts b/packages/components/src/layout/composables/useLayoutFloatingDrag.ts
deleted file mode 100644
index c8bfadbee..000000000
--- a/packages/components/src/layout/composables/useLayoutFloatingDrag.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useDraggable } from '@vueuse/core'
-import { computed, type ComputedRef } from 'vue'
-import type { LayoutFloatingDragEventDetail, LayoutFloatingState } from '../index.type'
-import type { LayoutContext, LayoutFloatingRect } from '../internal.type'
-import { DEFAULT_FLOATING_GAP, DEFAULT_FLOATING_TOP } from '../utils/surfaceGeometry'
-
-interface UseLayoutFloatingDragOptions {
- context: LayoutContext
- floatingRect: ComputedRef
- canStart: ComputedRef
- isEnabled: ComputedRef
- toFloatingState: (rect: LayoutFloatingRect, normalizeCenter?: boolean) => LayoutFloatingState
- applyPosition: (nextX: number, nextY: number) => LayoutFloatingRect
- onInteractionStart?: () => void
- onInteractionEnd?: () => void
- onFloatingDragStart?: (detail: LayoutFloatingDragEventDetail) => void
- onFloatingDrag?: (detail: LayoutFloatingDragEventDetail) => void
- onFloatingDragEnd?: (detail: LayoutFloatingDragEventDetail) => void
-}
-
-export function useLayoutFloatingDrag(options: UseLayoutFloatingDragOptions) {
- const { x, y, isDragging } = useDraggable(options.context.rootEl, {
- handle: options.context.dragHandleEl,
- initialValue: { x: DEFAULT_FLOATING_GAP, y: DEFAULT_FLOATING_TOP },
- preventDefault: true,
- buttons: [0],
- disabled: computed(() => !options.isEnabled.value),
- onStart: () => {
- if (!options.canStart.value) {
- return false
- }
-
- const rect = options.floatingRect.value
- setPosition(rect.x, rect.y)
- options.onInteractionStart?.()
- options.onFloatingDragStart?.(options.toFloatingState(rect, true))
- },
- onMove: (position) => {
- const nextRect = options.applyPosition(position.x, position.y)
- options.onFloatingDrag?.(options.toFloatingState(nextRect, true))
- },
- onEnd: (position) => {
- const nextRect = options.applyPosition(position.x, position.y)
- options.onFloatingDragEnd?.(options.toFloatingState(nextRect, true))
- options.onInteractionEnd?.()
- },
- })
-
- function setPosition(nextX: number, nextY: number): void {
- x.value = nextX
- y.value = nextY
- }
-
- const dragBarClass = computed(() => ({
- 'tr-layout__drag-bar--draggable': options.isEnabled.value,
- }))
-
- return {
- isDragging,
- dragBarClass,
- setPosition,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutFloatingResize.ts b/packages/components/src/layout/composables/useLayoutFloatingResize.ts
deleted file mode 100644
index 4e3b4af7b..000000000
--- a/packages/components/src/layout/composables/useLayoutFloatingResize.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { useEventListener } from '@vueuse/core'
-import { computed, onBeforeUnmount, shallowRef, type ComputedRef } from 'vue'
-import type { LayoutFloatingResizeEventDetail, LayoutFloatingResizeHandle } from '../index.type'
-import type { LayoutContext, LayoutFloatingRect } from '../internal.type'
-import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction'
-import { clampFloatingRectByHandle } from '../utils/surfaceGeometry'
-import { resolveFloatingResizeRect } from '../utils/surfaceResize'
-
-interface UseLayoutFloatingResizeOptions {
- context: LayoutContext
- floatingRect: ComputedRef
- canStart: ComputedRef
- isVisible: ComputedRef
- commitRect: (nextRect: LayoutFloatingRect) => LayoutFloatingRect
- toResizeDetail: (handle: LayoutFloatingResizeHandle, rect: LayoutFloatingRect) => LayoutFloatingResizeEventDetail
- onInteractionStart?: () => void
- onInteractionEnd?: () => void
- onFloatingResizeStart?: (detail: LayoutFloatingResizeEventDetail) => void
- onFloatingResize?: (detail: LayoutFloatingResizeEventDetail) => void
- onFloatingResizeEnd?: (detail: LayoutFloatingResizeEventDetail) => void
-}
-
-interface FloatingResizeState {
- pointerId: number
- handleEl: HTMLElement
- handle: LayoutFloatingResizeHandle
- currentRect: LayoutFloatingRect
- lastPointerX: number
- lastPointerY: number
- bodyState: BodyInteractionState
-}
-
-const RESIZE_HANDLES: LayoutFloatingResizeHandle[] = ['s', 'e', 'w', 'ne', 'nw', 'se', 'sw']
-
-function resolveResizeCursor(handle: LayoutFloatingResizeHandle): string {
- if (handle === 's') {
- return 'ns-resize'
- }
-
- if (handle === 'e' || handle === 'w') {
- return 'ew-resize'
- }
-
- if (handle === 'ne' || handle === 'sw') {
- return 'nesw-resize'
- }
-
- return 'nwse-resize'
-}
-
-export function useLayoutFloatingResize(options: UseLayoutFloatingResizeOptions) {
- const activeResize = shallowRef(null)
- const pointerTarget = typeof window === 'undefined' ? undefined : window
- const isResizing = computed(() => activeResize.value !== null)
- const activeResizeHandle = computed(() => activeResize.value?.handle ?? null)
-
- function stopResize(pointerId?: number): void {
- const state = activeResize.value
-
- if (!state || (pointerId !== undefined && state.pointerId !== pointerId)) {
- return
- }
-
- if (state.handleEl.hasPointerCapture(state.pointerId)) {
- state.handleEl.releasePointerCapture(state.pointerId)
- }
-
- restoreBodyInteraction(state.handleEl.ownerDocument.body, state.bodyState)
- options.onFloatingResizeEnd?.(options.toResizeDetail(state.handle, state.currentRect))
- activeResize.value = null
- options.onInteractionEnd?.()
- }
-
- function startResize(handle: LayoutFloatingResizeHandle, event: PointerEvent): void {
- if (activeResize.value || !event.isPrimary || event.button !== 0 || !options.canStart.value) {
- return
- }
-
- const handleEl = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
-
- if (!handleEl) {
- return
- }
-
- const startRect = options.floatingRect.value
-
- event.preventDefault()
- handleEl.setPointerCapture(event.pointerId)
-
- activeResize.value = {
- pointerId: event.pointerId,
- handleEl,
- handle,
- currentRect: startRect,
- lastPointerX: event.clientX,
- lastPointerY: event.clientY,
- bodyState: lockBodyInteraction(handleEl.ownerDocument.body, resolveResizeCursor(handle)),
- }
-
- options.onInteractionStart?.()
- options.onFloatingResizeStart?.(options.toResizeDetail(handle, startRect))
- }
-
- function applyResize(state: FloatingResizeState, pointerX: number, pointerY: number): void {
- const nextRect = options.commitRect(
- clampFloatingRectByHandle(
- resolveFloatingResizeRect({
- handle: state.handle,
- deltaX: pointerX - state.lastPointerX,
- deltaY: pointerY - state.lastPointerY,
- startRect: state.currentRect,
- }),
- state.handle,
- ),
- )
-
- state.currentRect = nextRect
- state.lastPointerX = pointerX
- state.lastPointerY = pointerY
-
- options.onFloatingResize?.(options.toResizeDetail(state.handle, nextRect))
- }
-
- useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => {
- const state = activeResize.value
-
- if (!state || event.pointerId !== state.pointerId) {
- return
- }
-
- if (event.clientX === state.lastPointerX && event.clientY === state.lastPointerY) {
- return
- }
-
- applyResize(state, event.clientX, event.clientY)
- })
-
- useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => {
- stopResize(event.pointerId)
- })
-
- useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => {
- stopResize(event.pointerId)
- })
-
- onBeforeUnmount(() => {
- stopResize()
- })
-
- const resizeHandles = computed(() => {
- if (!options.isVisible.value) {
- return []
- }
-
- return RESIZE_HANDLES.map((handle) => ({
- handle,
- active: activeResizeHandle.value === handle,
- onPointerdown: (event: PointerEvent) => startResize(handle, event),
- }))
- })
-
- return {
- isResizing,
- resizeHandles,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutProxyScrollbar.ts b/packages/components/src/layout/composables/useLayoutProxyScrollbar.ts
deleted file mode 100644
index 27ca8416c..000000000
--- a/packages/components/src/layout/composables/useLayoutProxyScrollbar.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import { useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core'
-import { computed, onBeforeUnmount, shallowRef, watch, type CSSProperties, type Ref } from 'vue'
-import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction'
-import { clamp } from '../utils/math'
-
-interface UseLayoutProxyScrollbarOptions {
- scrollTargetRef: Ref
- containerRef: Ref
-}
-
-interface ScrollMetrics {
- clientHeight: number
- scrollHeight: number
- scrollTop: number
- trackHeight: number
- thumbHeight: number
- thumbOffset: number
- isScrollable: boolean
-}
-
-interface ThumbDragState {
- pointerId: number
- startY: number
- startScrollTop: number
- bodyEl: HTMLBodyElement
- bodyState: BodyInteractionState
-}
-
-const MIN_THUMB_HEIGHT = 36
-
-function createEmptyMetrics(): ScrollMetrics {
- return {
- clientHeight: 0,
- scrollHeight: 0,
- scrollTop: 0,
- trackHeight: 0,
- thumbHeight: 0,
- thumbOffset: 0,
- isScrollable: false,
- }
-}
-
-function resolveTrackHeight(containerEl: HTMLElement | null, fallbackHeight: number): number {
- if (!(containerEl instanceof HTMLElement)) {
- return fallbackHeight
- }
-
- return Math.max(containerEl.clientHeight, 0)
-}
-
-export function useLayoutProxyScrollbar(options: UseLayoutProxyScrollbarOptions) {
- const metrics = shallowRef(createEmptyMetrics())
- const thumbDragState = shallowRef(null)
- const isTargetHovering = shallowRef(false)
- const isTrackHovering = shallowRef(false)
- const pointerTarget = typeof window === 'undefined' ? undefined : window
- let frameId: number | null = null
-
- const isScrollable = computed(() => metrics.value.isScrollable)
- const isDraggingThumb = computed(() => thumbDragState.value !== null)
- const scrollbarVisible = computed(
- () => isScrollable.value && (isTargetHovering.value || isTrackHovering.value || isDraggingThumb.value),
- )
-
- function syncMetrics(): void {
- frameId = null
-
- const scrollTarget = options.scrollTargetRef.value
- if (!scrollTarget) {
- metrics.value = createEmptyMetrics()
- return
- }
-
- const clientHeight = scrollTarget.clientHeight
- const scrollHeight = scrollTarget.scrollHeight
- const scrollTop = scrollTarget.scrollTop
- const trackHeight = resolveTrackHeight(options.containerRef.value, clientHeight)
- const isScrollable = scrollHeight - clientHeight > 1
-
- if (!isScrollable) {
- metrics.value = {
- clientHeight,
- scrollHeight,
- scrollTop,
- trackHeight,
- thumbHeight: trackHeight,
- thumbOffset: 0,
- isScrollable: false,
- }
- return
- }
-
- const scrollRange = scrollHeight - clientHeight
- const thumbHeight = clamp((clientHeight / scrollHeight) * trackHeight, MIN_THUMB_HEIGHT, trackHeight)
- const thumbTravel = Math.max(0, trackHeight - thumbHeight)
- const thumbOffset = scrollRange > 0 ? (scrollTop / scrollRange) * thumbTravel : 0
-
- metrics.value = {
- clientHeight,
- scrollHeight,
- scrollTop,
- trackHeight,
- thumbHeight,
- thumbOffset,
- isScrollable: true,
- }
- }
-
- function scheduleSync(): void {
- if (typeof window === 'undefined') {
- syncMetrics()
- return
- }
-
- if (frameId !== null) {
- return
- }
-
- frameId = window.requestAnimationFrame(syncMetrics)
- }
-
- function stopThumbDrag(pointerId?: number): void {
- const dragState = thumbDragState.value
- if (!dragState || (pointerId !== undefined && dragState.pointerId !== pointerId)) {
- return
- }
-
- restoreBodyInteraction(dragState.bodyEl, dragState.bodyState)
- thumbDragState.value = null
- }
-
- function startThumbDrag(event: PointerEvent): void {
- const scrollTarget = options.scrollTargetRef.value
- if (!scrollTarget || !metrics.value.isScrollable || event.button !== 0 || !event.isPrimary) {
- return
- }
-
- const bodyEl = scrollTarget.ownerDocument.body
- if (!(bodyEl instanceof HTMLBodyElement)) {
- return
- }
-
- event.preventDefault()
- thumbDragState.value = {
- pointerId: event.pointerId,
- startY: event.clientY,
- startScrollTop: scrollTarget.scrollTop,
- bodyEl,
- bodyState: lockBodyInteraction(bodyEl, 'grabbing'),
- }
- }
-
- useEventListener(options.scrollTargetRef, 'scroll', () => {
- scheduleSync()
- })
-
- useEventListener(options.scrollTargetRef, 'wheel', () => {
- scheduleSync()
- })
-
- useEventListener(options.scrollTargetRef, 'mouseenter', () => {
- isTargetHovering.value = true
- })
-
- useEventListener(options.scrollTargetRef, 'mouseleave', () => {
- isTargetHovering.value = false
- })
-
- useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => {
- const dragState = thumbDragState.value
- const scrollTarget = options.scrollTargetRef.value
- const currentMetrics = metrics.value
- if (!dragState || !scrollTarget || event.pointerId !== dragState.pointerId || !currentMetrics.isScrollable) {
- return
- }
-
- const deltaY = event.clientY - dragState.startY
- const scrollRange = currentMetrics.scrollHeight - currentMetrics.clientHeight
- const thumbTravel = currentMetrics.trackHeight - currentMetrics.thumbHeight
- const ratio = thumbTravel > 0 ? scrollRange / thumbTravel : 0
- scrollTarget.scrollTop = dragState.startScrollTop + deltaY * ratio
- scheduleSync()
- })
-
- useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => {
- stopThumbDrag(event.pointerId)
- })
-
- useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => {
- stopThumbDrag(event.pointerId)
- })
-
- useResizeObserver(options.scrollTargetRef, () => {
- scheduleSync()
- })
-
- useResizeObserver(options.containerRef, () => {
- scheduleSync()
- })
-
- useMutationObserver(
- options.scrollTargetRef,
- () => {
- scheduleSync()
- },
- { childList: true, subtree: true },
- )
-
- watch(
- options.scrollTargetRef,
- (nextTarget, prevTarget) => {
- stopThumbDrag()
- isTargetHovering.value = false
- isTrackHovering.value = false
- prevTarget?.removeAttribute('data-tr-layout-scroll-target')
- nextTarget?.setAttribute('data-tr-layout-scroll-target', '')
- scheduleSync()
- },
- { immediate: true },
- )
-
- watch(options.containerRef, () => {
- scheduleSync()
- })
-
- onBeforeUnmount(() => {
- if (frameId !== null && typeof window !== 'undefined') {
- window.cancelAnimationFrame(frameId)
- }
-
- stopThumbDrag()
- options.scrollTargetRef.value?.removeAttribute('data-tr-layout-scroll-target')
- })
-
- const thumbStyle = computed(() => ({
- height: `${metrics.value.thumbHeight}px`,
- transform: `translateY(${metrics.value.thumbOffset}px)`,
- }))
-
- const rootClass = computed(() => ({
- 'tr-layout-proxy-scrollbar--visible': scrollbarVisible.value,
- 'tr-layout-proxy-scrollbar--dragging-thumb': isDraggingThumb.value,
- }))
-
- return {
- isScrollable,
- rootClass,
- thumbStyle,
- setTrackHovering: (value: boolean) => {
- isTrackHovering.value = value
- },
- startThumbDrag,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutRenderState.ts b/packages/components/src/layout/composables/useLayoutRenderState.ts
deleted file mode 100644
index 21fb0673c..000000000
--- a/packages/components/src/layout/composables/useLayoutRenderState.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { computed, toValue, useSlots, type MaybeRefOrGetter } from 'vue'
-import type { LayoutAsideSlotProps } from '../index.type'
-import type { LayoutContext, LayoutPanelContext } from '../internal.type'
-import { toPx } from '../utils/cssLength'
-import { hasRenderableSlot } from '../utils/slots'
-
-interface UseLayoutRenderStateOptions {
- context: LayoutContext
- isResizing: MaybeRefOrGetter
-}
-
-function createAsideSlotProps(panel: LayoutPanelContext, placement: 'left' | 'right'): LayoutAsideSlotProps {
- return {
- placement,
- mode: panel.state.layoutMode.value,
- open: panel.state.isOpen.value,
- expandedWidth: panel.state.width.value,
- collapsedWidth: panel.state.collapsedWidth.value,
- resizable: panel.state.resizable.value,
- isRail: panel.state.isRail.value,
- isHidden: panel.state.isHidden.value,
- canResize: panel.state.canResize.value,
- toggle: panel.actions.toggle,
- setOpen: panel.actions.setOpen,
- setExpandedWidth: panel.actions.setWidth,
- }
-}
-
-export function useLayoutRenderState({ context, isResizing }: UseLayoutRenderStateOptions) {
- const slots = useSlots()
- const { left, right } = context
-
- const leftAsideSlotProps = computed(() => createAsideSlotProps(left, 'left'))
- const rightAsideSlotProps = computed(() => createAsideSlotProps(right, 'right'))
-
- const hasLeftAside = computed(() => hasRenderableSlot(slots['left-aside'], leftAsideSlotProps.value))
- const hasHeader = computed(() => hasRenderableSlot(slots.header))
- const hasFooter = computed(() => hasRenderableSlot(slots.footer))
- const hasRightAside = computed(() => hasRenderableSlot(slots['right-aside'], rightAsideSlotProps.value))
-
- const layoutStyle = computed>(() => {
- const style: Record = {}
- const leftDockWidth = toPx(left.state.width.value)
- const leftCollapsedWidth = toPx(left.state.collapsedWidth.value)
- const rightDockWidth = toPx(right.state.width.value)
- const rightCollapsedWidth = toPx(right.state.collapsedWidth.value)
-
- if (leftDockWidth) {
- style['--left-dock-width'] = leftDockWidth
- }
-
- if (leftCollapsedWidth) {
- style['--left-collapsed-width'] = leftCollapsedWidth
- }
-
- if (rightDockWidth) {
- style['--right-dock-width'] = rightDockWidth
- }
-
- if (rightCollapsedWidth) {
- style['--right-collapsed-width'] = rightCollapsedWidth
- }
-
- return style
- })
-
- const layoutClass = computed(() => ({
- 'tr-layout--left-dock': hasLeftAside.value && left.state.isDock.value,
- 'tr-layout--left-drawer': hasLeftAside.value && left.state.isDrawer.value,
- 'tr-layout--left-expanded': hasLeftAside.value && left.state.isOpen.value,
- 'tr-layout--left-rail': hasLeftAside.value && left.state.isRail.value,
- 'tr-layout--right-dock': hasRightAside.value && right.state.isDock.value,
- 'tr-layout--right-drawer': hasRightAside.value && right.state.isDrawer.value,
- 'tr-layout--right-expanded': hasRightAside.value && right.state.isOpen.value,
- 'tr-layout--right-rail': hasRightAside.value && right.state.isRail.value,
- 'tr-layout--resizing': toValue(isResizing),
- }))
-
- return {
- hasHeader,
- hasFooter,
- hasLeftAside,
- hasRightAside,
- leftAsideSlotProps,
- rightAsideSlotProps,
- layoutStyle,
- layoutClass,
- }
-}
diff --git a/packages/components/src/layout/composables/useLayoutRootState.ts b/packages/components/src/layout/composables/useLayoutRootState.ts
index 9c6d0dc67..fb20c0013 100644
--- a/packages/components/src/layout/composables/useLayoutRootState.ts
+++ b/packages/components/src/layout/composables/useLayoutRootState.ts
@@ -1,19 +1,14 @@
-import { computed, shallowRef } from 'vue'
-import type { LayoutAsideProps, LayoutFloatingState, LayoutPlacement, LayoutProps } from '../index.type'
-import type {
- LayoutFloatingContext,
- LayoutPanelContext,
- LayoutResolvedFloating,
- UseLayoutRootStateResult,
-} from '../internal.type'
-import { clamp } from '../utils/math'
+import { computed } from 'vue'
+import type { LayoutAsideProps, LayoutFloatingState, LayoutSide, LayoutProps } from '../index.type'
+import type { LayoutFloatingContext, LayoutPanel, LayoutResolvedFloating, LayoutState } from '../internal.type'
+import { clamp } from '../utils/number'
import {
getDefaultAsideExpandedWidth,
getDefaultAsideMaxWidth,
getDefaultAsideMinWidth,
getDefaultAsideOpen,
-} from '../utils/asideDefaults'
-import { emitAsideOpenChange, type LayoutEmitFn } from '../utils/emitAsideEvents'
+} from '../utils/asidePresets'
+import { emitAsideOpenChange, type LayoutEmitFn } from '../utils/asideEventEmitters'
import { useControllableState } from '../../shared/composables/useControllableState'
function isFloatingStateEqual(left: LayoutFloatingState | undefined, right: LayoutFloatingState | undefined): boolean {
@@ -30,41 +25,40 @@ function resolveFiniteNumber(value: number | undefined, fallback: number): numbe
return value === undefined || !Number.isFinite(value) ? fallback : value
}
-function createPanelContext(
- placement: LayoutPlacement,
+function createAsidePanel(
+ side: LayoutSide,
aside: () => LayoutAsideProps | undefined,
emit: LayoutEmitFn,
-): LayoutPanelContext {
+): LayoutPanel {
const asideValue = computed(() => aside())
const layoutMode = computed(() => asideValue.value?.mode ?? 'dock')
const collapsedWidth = computed(() => resolveFiniteNumber(asideValue.value?.collapsedWidth, 0))
const collapseEffect = computed(() => asideValue.value?.collapseEffect ?? 'overlay')
const resizable = computed(() => asideValue.value?.resizable ?? false)
const minWidth = computed(() =>
- resolveFiniteNumber(asideValue.value?.minExpandedWidth, getDefaultAsideMinWidth(placement)),
+ resolveFiniteNumber(asideValue.value?.minExpandedWidth, getDefaultAsideMinWidth(side)),
)
const maxWidth = computed(() => {
- const nextMaxWidth = resolveFiniteNumber(asideValue.value?.maxExpandedWidth, getDefaultAsideMaxWidth(placement))
+ const nextMaxWidth = resolveFiniteNumber(asideValue.value?.maxExpandedWidth, getDefaultAsideMaxWidth(side))
return Math.max(minWidth.value, nextMaxWidth)
})
const openState = useControllableState({
value: () => asideValue.value?.open,
- defaultValue: () => asideValue.value?.defaultOpen ?? getDefaultAsideOpen(placement),
+ defaultValue: () => asideValue.value?.defaultOpen ?? getDefaultAsideOpen(side),
isControlled: () => asideValue.value?.open !== undefined,
- onChange: (nextOpen) => emitAsideOpenChange(emit, { placement, open: nextOpen }),
+ onChange: (nextOpen) => emitAsideOpenChange(emit, { side, open: nextOpen }),
})
const widthState = useControllableState({
value: () => asideValue.value?.expandedWidth,
- defaultValue: () =>
- resolveFiniteNumber(asideValue.value?.defaultExpandedWidth, getDefaultAsideExpandedWidth(placement)),
+ defaultValue: () => resolveFiniteNumber(asideValue.value?.defaultExpandedWidth, getDefaultAsideExpandedWidth(side)),
isControlled: () => asideValue.value?.expandedWidth !== undefined,
})
- const resolvedOpen = computed(() => openState.resolvedState.value ?? getDefaultAsideOpen(placement))
+ const resolvedOpen = computed(() => openState.resolvedState.value ?? getDefaultAsideOpen(side))
const resolvedWidth = computed(() => {
- const nextWidth = resolveFiniteNumber(widthState.resolvedState.value, getDefaultAsideExpandedWidth(placement))
+ const nextWidth = resolveFiniteNumber(widthState.resolvedState.value, getDefaultAsideExpandedWidth(side))
return clamp(nextWidth, minWidth.value, maxWidth.value)
})
@@ -92,34 +86,23 @@ function createPanelContext(
}
return {
- el: shallowRef(null),
- state: {
- placement,
- layoutMode,
- isOpen: resolvedOpen,
- width: resolvedWidth,
- collapsedWidth,
- collapseEffect,
- minWidth,
- maxWidth,
- resizable,
- isDock,
- isDrawer,
- isRail,
- isHidden,
- canResize,
- },
- actions: {
- open: () => setOpen(true),
- close: () => setOpen(false),
- toggle: () => setOpen(!resolvedOpen.value),
- setOpen,
- setWidth,
- },
+ isOpen: resolvedOpen,
+ width: resolvedWidth,
+ collapsedWidth,
+ collapseEffect,
+ minWidth,
+ maxWidth,
+ isDock,
+ isDrawer,
+ isRail,
+ isHidden,
+ canResize,
+ setOpen,
+ setWidth,
}
}
-export function useLayoutRootState(props: LayoutProps, emit: LayoutEmitFn): UseLayoutRootStateResult {
+export function createLayoutState(props: LayoutProps, emit: LayoutEmitFn): LayoutState {
const floatingState = useControllableState({
value: () => (props.mode === 'floating' ? props.floatingState : undefined),
defaultValue: () => (props.mode === 'floating' ? props.defaultFloatingState : undefined),
@@ -168,8 +151,8 @@ export function useLayoutRootState(props: LayoutProps, emit: LayoutEmitFn): UseL
}
return {
- leftPanel: createPanelContext('left', () => props.leftAside, emit),
- rightPanel: createPanelContext('right', () => props.rightAside, emit),
+ leftPanel: createAsidePanel('left', () => props.leftAside, emit),
+ rightPanel: createAsidePanel('right', () => props.rightAside, emit),
floating,
}
}
diff --git a/packages/components/src/layout/composables/usePointerDragSession.ts b/packages/components/src/layout/composables/usePointerDragSession.ts
new file mode 100644
index 000000000..376c27aab
--- /dev/null
+++ b/packages/components/src/layout/composables/usePointerDragSession.ts
@@ -0,0 +1,87 @@
+import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
+import { shallowRef } from 'vue'
+import type { Ref } from 'vue'
+
+export interface PointerDragSession {
+ pointerId: number
+}
+
+export type PointerDragSessionFactory = (
+ event: PointerEvent,
+) => T | false | null | undefined
+
+export interface UsePointerDragSessionOptions {
+ onMove?: (session: T, event: PointerEvent) => void
+ onStop?: (session: T, event?: PointerEvent) => void
+}
+
+export interface UsePointerDragSessionReturn {
+ activeSession: Ref
+ startSession: (event: PointerEvent, createSession: PointerDragSessionFactory) => T | null
+ stopSession: (pointerId?: number, event?: PointerEvent) => void
+}
+
+export function usePointerDragSession(
+ options: UsePointerDragSessionOptions,
+): UsePointerDragSessionReturn {
+ const activeSession = shallowRef(null) as Ref
+ const pointerTarget = typeof window === 'undefined' ? undefined : window
+
+ function startSession(event: PointerEvent, createSession: PointerDragSessionFactory): T | null {
+ if (activeSession.value || !event.isPrimary || event.button !== 0) {
+ return null
+ }
+
+ const session = createSession(event)
+
+ if (!session) {
+ return null
+ }
+
+ activeSession.value = session
+
+ return session
+ }
+
+ function stopSession(pointerId?: number, event?: PointerEvent): void {
+ const session = activeSession.value
+
+ if (!session || (pointerId !== undefined && session.pointerId !== pointerId)) {
+ return
+ }
+
+ try {
+ options.onStop?.(session, event)
+ } finally {
+ activeSession.value = null
+ }
+ }
+
+ useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => {
+ const session = activeSession.value
+
+ if (!session || event.pointerId !== session.pointerId) {
+ return
+ }
+
+ options.onMove?.(session, event)
+ })
+
+ useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => {
+ stopSession(event.pointerId, event)
+ })
+
+ useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => {
+ stopSession(event.pointerId, event)
+ })
+
+ tryOnScopeDispose(() => {
+ stopSession()
+ })
+
+ return {
+ activeSession,
+ startSession,
+ stopSession,
+ }
+}
diff --git a/packages/components/src/layout/index.type.ts b/packages/components/src/layout/index.type.ts
index 4d8070063..c5b433718 100644
--- a/packages/components/src/layout/index.type.ts
+++ b/packages/components/src/layout/index.type.ts
@@ -1,6 +1,6 @@
-import type { VNode } from 'vue'
+import type { ComponentPublicInstance, VNode } from 'vue'
-export type LayoutPlacement = 'left' | 'right'
+export type LayoutSide = 'left' | 'right'
export type LayoutAsideMode = 'dock' | 'drawer'
export type LayoutAsideCollapseEffect = 'overlay' | 'slide'
export type LayoutMode = 'normal' | 'floating'
@@ -25,27 +25,27 @@ export interface LayoutFloatingOptions {
export type LayoutFloatingResizeHandle = 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
-export interface LayoutAsideOpenEventDetail {
- placement: LayoutPlacement
+export interface LayoutAsideOpenDetail {
+ side: LayoutSide
open: boolean
}
-export interface LayoutAsideSideOpenEventDetail {
+export interface LayoutAsideOpenValue {
open: boolean
}
-export interface LayoutAsideResizeEventDetail {
- placement: LayoutPlacement
+export interface LayoutAsideResizeDetail {
+ side: LayoutSide
expandedWidth: number
}
-export interface LayoutAsideSideResizeEventDetail {
+export interface LayoutAsideResizeValue {
expandedWidth: number
}
-export type LayoutFloatingDragEventDetail = LayoutFloatingState
+export type LayoutFloatingDragDetail = LayoutFloatingState
-export type LayoutFloatingResizeEventDetail = LayoutFloatingState & {
+export type LayoutFloatingResizeDetail = LayoutFloatingState & {
handle: LayoutFloatingResizeHandle
}
@@ -74,10 +74,10 @@ export interface LayoutNormalProps extends LayoutAsidePanelsProps {
type LayoutFloatingStateControlProps =
| {
floatingState?: LayoutFloatingState
- defaultFloatingState: never
+ defaultFloatingState?: never
}
| {
- floatingState: never
+ floatingState?: never
defaultFloatingState?: LayoutFloatingState
}
@@ -89,48 +89,45 @@ export type LayoutFloatingProps = LayoutAsidePanelsProps &
export type LayoutProps = LayoutNormalProps | LayoutFloatingProps
-export interface LayoutEmits {
- 'update:floatingState': [value: LayoutFloatingState]
- 'floating-drag-start': [detail: LayoutFloatingDragEventDetail]
- 'floating-drag': [detail: LayoutFloatingDragEventDetail]
- 'floating-drag-end': [detail: LayoutFloatingDragEventDetail]
- 'floating-resize-start': [detail: LayoutFloatingResizeEventDetail]
- 'floating-resize': [detail: LayoutFloatingResizeEventDetail]
- 'floating-resize-end': [detail: LayoutFloatingResizeEventDetail]
-
- 'aside-open-change': [detail: LayoutAsideOpenEventDetail]
- 'aside-resize-start': [detail: LayoutAsideResizeEventDetail]
- 'aside-resize': [detail: LayoutAsideResizeEventDetail]
- 'aside-resize-end': [detail: LayoutAsideResizeEventDetail]
- 'left-aside-open-change': [detail: LayoutAsideSideOpenEventDetail]
- 'left-aside-resize-start': [detail: LayoutAsideSideResizeEventDetail]
- 'left-aside-resize': [detail: LayoutAsideSideResizeEventDetail]
- 'left-aside-resize-end': [detail: LayoutAsideSideResizeEventDetail]
- 'right-aside-open-change': [detail: LayoutAsideSideOpenEventDetail]
- 'right-aside-resize-start': [detail: LayoutAsideSideResizeEventDetail]
- 'right-aside-resize': [detail: LayoutAsideSideResizeEventDetail]
- 'right-aside-resize-end': [detail: LayoutAsideSideResizeEventDetail]
+export type LayoutScrollTargetComponent = Pick
+
+export type LayoutScrollTarget = HTMLElement | LayoutScrollTargetComponent | null | undefined
+
+export interface LayoutProxyScrollbarProps {
+ scrollTarget?: LayoutScrollTarget
}
-export interface LayoutAsideSlotProps {
- placement: LayoutPlacement
- mode: LayoutAsideMode
- open: boolean
- expandedWidth: number
- collapsedWidth: number | undefined
- resizable: boolean
- isRail: boolean
- isHidden: boolean
- canResize: boolean
- toggle: () => void
- setOpen: (next: boolean) => void
- setExpandedWidth: (next: number) => void
+export interface LayoutAsideToggleProps {
+ side: LayoutSide
+}
+
+export interface LayoutEmits {
+ 'update:floatingState': [value: LayoutFloatingState]
+ 'floating-drag-start': [detail: LayoutFloatingDragDetail]
+ 'floating-drag': [detail: LayoutFloatingDragDetail]
+ 'floating-drag-end': [detail: LayoutFloatingDragDetail]
+ 'floating-resize-start': [detail: LayoutFloatingResizeDetail]
+ 'floating-resize': [detail: LayoutFloatingResizeDetail]
+ 'floating-resize-end': [detail: LayoutFloatingResizeDetail]
+
+ 'aside-open-change': [detail: LayoutAsideOpenDetail]
+ 'aside-resize-start': [detail: LayoutAsideResizeDetail]
+ 'aside-resize': [detail: LayoutAsideResizeDetail]
+ 'aside-resize-end': [detail: LayoutAsideResizeDetail]
+ 'left-aside-open-change': [detail: LayoutAsideOpenValue]
+ 'left-aside-resize-start': [detail: LayoutAsideResizeValue]
+ 'left-aside-resize': [detail: LayoutAsideResizeValue]
+ 'left-aside-resize-end': [detail: LayoutAsideResizeValue]
+ 'right-aside-open-change': [detail: LayoutAsideOpenValue]
+ 'right-aside-resize-start': [detail: LayoutAsideResizeValue]
+ 'right-aside-resize': [detail: LayoutAsideResizeValue]
+ 'right-aside-resize-end': [detail: LayoutAsideResizeValue]
}
export interface LayoutSlots {
- 'left-aside'?: (slotProps: LayoutAsideSlotProps) => VNode | VNode[]
+ 'left-aside'?: () => VNode | VNode[]
header?: () => VNode | VNode[]
main?: () => VNode | VNode[]
footer?: () => VNode | VNode[]
- 'right-aside'?: (slotProps: LayoutAsideSlotProps) => VNode | VNode[]
+ 'right-aside'?: () => VNode | VNode[]
}
diff --git a/packages/components/src/layout/internal.type.ts b/packages/components/src/layout/internal.type.ts
index ae00e432a..8c3218cc9 100644
--- a/packages/components/src/layout/internal.type.ts
+++ b/packages/components/src/layout/internal.type.ts
@@ -1,13 +1,5 @@
-import type { ComponentPublicInstance, ComputedRef, ShallowRef } from 'vue'
-import type {
- LayoutAsideCollapseEffect,
- LayoutAsideMode,
- LayoutFloatingOptions,
- LayoutFloatingState,
- LayoutMode,
- LayoutPlacement,
- LayoutProps,
-} from './index.type'
+import type { ComputedRef } from 'vue'
+import type { LayoutAsideCollapseEffect, LayoutFloatingOptions, LayoutFloatingState, LayoutMode } from './index.type'
export type LayoutResolvedFloating = LayoutFloatingState & LayoutFloatingOptions
@@ -21,51 +13,27 @@ export type LayoutFloatingRect = Omit<
height: number
}
-export type LayoutRuntimeProps = LayoutProps
-
-export type LayoutScrollTargetComponent = Pick
-
-export type LayoutScrollTarget = HTMLElement | LayoutScrollTargetComponent | null | undefined
-
-export interface LayoutAsideToggleProps {
- placement: LayoutPlacement
-}
-
-export interface LayoutProxyScrollbarProps {
- scrollTarget?: LayoutScrollTarget
+export interface LayoutFloatingDragPosition {
+ x: number
+ y: number
}
-export interface LayoutPanelState {
- placement: LayoutPlacement
- layoutMode: ComputedRef
+export interface LayoutPanel {
isOpen: ComputedRef
width: ComputedRef
collapsedWidth: ComputedRef
collapseEffect: ComputedRef
minWidth: ComputedRef
maxWidth: ComputedRef
- resizable: ComputedRef
isDock: ComputedRef
isDrawer: ComputedRef
isRail: ComputedRef
isHidden: ComputedRef
canResize: ComputedRef
-}
-
-export interface LayoutPanelActions {
- open: () => void
- close: () => void
- toggle: () => void
setOpen: (nextOpen: boolean) => void
setWidth: (nextWidth: number) => void
}
-export interface LayoutPanelContext {
- el: ShallowRef
- state: LayoutPanelState
- actions: LayoutPanelActions
-}
-
export interface LayoutFloatingStateContext {
mode: ComputedRef
value: ComputedRef
@@ -82,22 +50,18 @@ export interface LayoutFloatingContext {
actions: LayoutFloatingActions
}
+export interface LayoutAsideToggleContext {
+ isOpen: ComputedRef
+ toggle: () => void
+}
+
export interface LayoutContext {
- rootEl: ShallowRef
- dragHandleEl: ShallowRef
- left: LayoutPanelContext
- right: LayoutPanelContext
- floating: LayoutFloatingContext
- ui: {
- isDrawerVisible: ComputedRef
- }
- actions: {
- closeDrawers: () => void
- }
+ left: LayoutAsideToggleContext
+ right: LayoutAsideToggleContext
}
-export interface UseLayoutRootStateResult {
- leftPanel: LayoutPanelContext
- rightPanel: LayoutPanelContext
+export interface LayoutState {
+ leftPanel: LayoutPanel
+ rightPanel: LayoutPanel
floating: LayoutFloatingContext
}
diff --git a/packages/components/src/layout/utils/asideDefaults.ts b/packages/components/src/layout/utils/asideDefaults.ts
deleted file mode 100644
index a42da6084..000000000
--- a/packages/components/src/layout/utils/asideDefaults.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { LayoutPlacement } from '../index.type'
-
-const DEFAULT_ASIDE_OPEN = {
- left: true,
- right: false,
-} as const
-
-const DEFAULT_ASIDE_MIN_WIDTH = {
- left: 200,
- right: 240,
-} as const
-
-const DEFAULT_ASIDE_EXPANDED_WIDTH = {
- left: 300,
- right: 320,
-} as const
-
-const DEFAULT_ASIDE_MAX_WIDTH = {
- left: 560,
- right: 640,
-} as const
-
-export function getDefaultAsideOpen(placement: LayoutPlacement): boolean {
- return DEFAULT_ASIDE_OPEN[placement]
-}
-
-export function getDefaultAsideMinWidth(placement: LayoutPlacement): number {
- return DEFAULT_ASIDE_MIN_WIDTH[placement]
-}
-
-export function getDefaultAsideExpandedWidth(placement: LayoutPlacement): number {
- return DEFAULT_ASIDE_EXPANDED_WIDTH[placement]
-}
-
-export function getDefaultAsideMaxWidth(placement: LayoutPlacement): number {
- return DEFAULT_ASIDE_MAX_WIDTH[placement]
-}
diff --git a/packages/components/src/layout/utils/emitAsideEvents.ts b/packages/components/src/layout/utils/asideEventEmitters.ts
similarity index 61%
rename from packages/components/src/layout/utils/emitAsideEvents.ts
rename to packages/components/src/layout/utils/asideEventEmitters.ts
index 85cf9036c..2ba6334d3 100644
--- a/packages/components/src/layout/utils/emitAsideEvents.ts
+++ b/packages/components/src/layout/utils/asideEventEmitters.ts
@@ -1,11 +1,9 @@
-import type { LayoutAsideOpenEventDetail, LayoutAsideResizeEventDetail, LayoutEmits } from '../index.type'
+import type { LayoutAsideOpenDetail, LayoutAsideResizeDetail, LayoutEmits } from '../index.type'
export type LayoutEmitFn = (event: K, ...args: LayoutEmits[K]) => void
-export function emitAsideOpenChange(emit: LayoutEmitFn, detail: LayoutAsideOpenEventDetail): void {
- emit('aside-open-change', detail)
-
- if (detail.placement === 'left') {
+function emitSideOpenChange(emit: LayoutEmitFn, detail: LayoutAsideOpenDetail): void {
+ if (detail.side === 'left') {
emit('left-aside-open-change', { open: detail.open })
return
}
@@ -13,41 +11,56 @@ export function emitAsideOpenChange(emit: LayoutEmitFn, detail: LayoutAsideOpenE
emit('right-aside-open-change', { open: detail.open })
}
-export function emitAsideResizeEvent(
+export function emitAsideOpenChange(emit: LayoutEmitFn, detail: LayoutAsideOpenDetail): void {
+ emit('aside-open-change', detail)
+ emitSideOpenChange(emit, detail)
+}
+
+function emitSideResizeEvent(
emit: LayoutEmitFn,
phase: 'start' | 'progress' | 'end',
- detail: LayoutAsideResizeEventDetail,
+ detail: LayoutAsideResizeDetail,
): void {
- if (phase === 'start') {
- emit('aside-resize-start', detail)
-
- if (detail.placement === 'left') {
+ if (detail.side === 'left') {
+ if (phase === 'start') {
emit('left-aside-resize-start', { expandedWidth: detail.expandedWidth })
return
}
- emit('right-aside-resize-start', { expandedWidth: detail.expandedWidth })
- return
- }
-
- if (phase === 'end') {
- emit('aside-resize-end', detail)
-
- if (detail.placement === 'left') {
+ if (phase === 'end') {
emit('left-aside-resize-end', { expandedWidth: detail.expandedWidth })
return
}
- emit('right-aside-resize-end', { expandedWidth: detail.expandedWidth })
+ emit('left-aside-resize', { expandedWidth: detail.expandedWidth })
return
}
- emit('aside-resize', detail)
+ if (phase === 'start') {
+ emit('right-aside-resize-start', { expandedWidth: detail.expandedWidth })
+ return
+ }
- if (detail.placement === 'left') {
- emit('left-aside-resize', { expandedWidth: detail.expandedWidth })
+ if (phase === 'end') {
+ emit('right-aside-resize-end', { expandedWidth: detail.expandedWidth })
return
}
emit('right-aside-resize', { expandedWidth: detail.expandedWidth })
}
+
+export function emitAsideResizeEvent(
+ emit: LayoutEmitFn,
+ phase: 'start' | 'progress' | 'end',
+ detail: LayoutAsideResizeDetail,
+): void {
+ if (phase === 'start') {
+ emit('aside-resize-start', detail)
+ } else if (phase === 'end') {
+ emit('aside-resize-end', detail)
+ } else {
+ emit('aside-resize', detail)
+ }
+
+ emitSideResizeEvent(emit, phase, detail)
+}
diff --git a/packages/components/src/layout/utils/asidePresets.ts b/packages/components/src/layout/utils/asidePresets.ts
new file mode 100644
index 000000000..0bffd5b06
--- /dev/null
+++ b/packages/components/src/layout/utils/asidePresets.ts
@@ -0,0 +1,32 @@
+import type { LayoutSide } from '../index.type'
+
+const ASIDE_PRESETS = {
+ left: {
+ open: true,
+ minWidth: 200,
+ expandedWidth: 300,
+ maxWidth: 560,
+ },
+ right: {
+ open: false,
+ minWidth: 240,
+ expandedWidth: 320,
+ maxWidth: 640,
+ },
+} as const
+
+export function getDefaultAsideOpen(side: LayoutSide): boolean {
+ return ASIDE_PRESETS[side].open
+}
+
+export function getDefaultAsideMinWidth(side: LayoutSide): number {
+ return ASIDE_PRESETS[side].minWidth
+}
+
+export function getDefaultAsideExpandedWidth(side: LayoutSide): number {
+ return ASIDE_PRESETS[side].expandedWidth
+}
+
+export function getDefaultAsideMaxWidth(side: LayoutSide): number {
+ return ASIDE_PRESETS[side].maxWidth
+}
diff --git a/packages/components/src/layout/utils/cssLength.ts b/packages/components/src/layout/utils/cssLength.ts
index 58999d1c4..b3e95cb3c 100644
--- a/packages/components/src/layout/utils/cssLength.ts
+++ b/packages/components/src/layout/utils/cssLength.ts
@@ -3,42 +3,14 @@ export function toPx(value: number | undefined): string | undefined {
}
const PX_LENGTH_RE = /^(-?(?:\d+|\d*\.\d+))px$/i
-const ZERO_LENGTH_RE = /^0(?:\.0+)?(?:[a-z%]+)?$/i
+const ZERO_LENGTH_RE = /^0(?:\.0+)?$/i
-const measurementMap = new WeakMap()
-
-function getMeasurementElement(rootEl: HTMLElement): HTMLDivElement {
- const cached = measurementMap.get(rootEl)
- if (cached) {
- return cached
- }
-
- const el = rootEl.ownerDocument.createElement('div')
- el.style.position = 'absolute'
- el.style.visibility = 'hidden'
- el.style.pointerEvents = 'none'
- el.style.inset = '0 auto auto 0'
- el.style.width = '0px'
- el.style.height = '0px'
- el.style.padding = '0'
- el.style.border = '0'
- el.style.overflow = 'hidden'
- rootEl.appendChild(el)
- measurementMap.set(rootEl, el)
- return el
-}
-
-export function resolveCssLengthToPx(
- value: number | string | undefined,
- rootEl: HTMLElement | null | undefined,
- fallback: number,
- property: 'width' | 'height' = 'width',
-): number {
+export function resolveCssLengthToPx(value: number | string | undefined, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
- if (typeof value !== 'string' || !value.trim() || !rootEl) {
+ if (typeof value !== 'string' || !value.trim()) {
return fallback
}
@@ -54,21 +26,5 @@ export function resolveCssLengthToPx(
return Number.isFinite(parsed) ? parsed : fallback
}
- const measure = getMeasurementElement(rootEl)
- measure.style.width = property === 'width' ? normalized : '0px'
- measure.style.height = property === 'height' ? normalized : '0px'
-
- if (property === 'width' && !measure.style.width) {
- return fallback
- }
-
- if (property === 'height' && !measure.style.height) {
- return fallback
- }
-
- const size = measure.getBoundingClientRect()[property]
- measure.style.width = '0px'
- measure.style.height = '0px'
-
- return Number.isFinite(size) ? size : fallback
+ return fallback
}
diff --git a/packages/components/src/layout/utils/layoutElements.ts b/packages/components/src/layout/utils/layoutElements.ts
new file mode 100644
index 000000000..ff4b59f72
--- /dev/null
+++ b/packages/components/src/layout/utils/layoutElements.ts
@@ -0,0 +1,23 @@
+const LAYOUT_ROOT_SELECTOR = '.tr-layout'
+const LAYOUT_ASIDE_SELECTOR = '.tr-layout__aside'
+
+export function isHTMLElement(element: unknown): element is HTMLElement {
+ const ownerDocument = (element as { ownerDocument?: Document } | null)?.ownerDocument
+ const view = ownerDocument?.defaultView
+
+ return !!view && element instanceof view.HTMLElement
+}
+
+function closestHTMLElement(element: Element | null | undefined, selector: string): HTMLElement | null {
+ const candidate = element?.closest(selector)
+
+ return isHTMLElement(candidate) ? candidate : null
+}
+
+export function getLayoutRootElement(element: Element | null | undefined): HTMLElement | null {
+ return closestHTMLElement(element, LAYOUT_ROOT_SELECTOR)
+}
+
+export function getLayoutAsideElement(element: Element | null | undefined): HTMLElement | null {
+ return closestHTMLElement(element, LAYOUT_ASIDE_SELECTOR)
+}
diff --git a/packages/components/src/layout/utils/math.ts b/packages/components/src/layout/utils/number.ts
similarity index 100%
rename from packages/components/src/layout/utils/math.ts
rename to packages/components/src/layout/utils/number.ts
diff --git a/packages/components/src/layout/utils/slots.ts b/packages/components/src/layout/utils/slots.ts
index 37855f3a2..9f38afdc5 100644
--- a/packages/components/src/layout/utils/slots.ts
+++ b/packages/components/src/layout/utils/slots.ts
@@ -1,4 +1,6 @@
-import { Comment, Fragment, Text, isVNode, type Slot } from 'vue'
+import { Comment, Fragment, Text, isVNode } from 'vue'
+
+type NonEmptyContentSlot = () => unknown
function hasRenderableValue(value: unknown): boolean {
if (value == null) {
@@ -38,6 +40,7 @@ function hasRenderableChildren(children: unknown): boolean {
})
}
-export function hasRenderableSlot(slot?: Slot, slotProps?: object): boolean {
- return hasRenderableChildren(slot?.(slotProps))
+// Layout uses this to avoid keeping empty header / aside shells when conditional slot content renders nothing.
+export function hasNonEmptySlotContent(slot?: NonEmptyContentSlot): boolean {
+ return hasRenderableChildren(slot?.())
}
diff --git a/packages/components/src/layout/utils/surfaceGeometry.ts b/packages/components/src/layout/utils/surfaceGeometry.ts
index e1217cd6b..4630b4e9d 100644
--- a/packages/components/src/layout/utils/surfaceGeometry.ts
+++ b/packages/components/src/layout/utils/surfaceGeometry.ts
@@ -1,6 +1,6 @@
import type { LayoutFloatingPlacement, LayoutFloatingResizeHandle, LayoutFloatingState } from '../index.type'
import type { LayoutFloatingRect, LayoutResolvedFloating } from '../internal.type'
-import { clamp } from './math'
+import { clamp } from './number'
export interface FloatingBounds {
left: number
From 46e968dd936f3047385c846d484981fdf21bd70a Mon Sep 17 00:00:00 2001
From: SonyLeo <746591437@qq.com>
Date: Mon, 22 Jun 2026 01:36:13 -0700
Subject: [PATCH 6/6] refactor(layout): enhance drawer visibility logic with
slot content checks
---
packages/components/src/layout/Layout.vue | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/packages/components/src/layout/Layout.vue b/packages/components/src/layout/Layout.vue
index d0e550ee7..ee493f220 100644
--- a/packages/components/src/layout/Layout.vue
+++ b/packages/components/src/layout/Layout.vue
@@ -43,8 +43,15 @@ function toggleRightDrawer(): void {
toggleDrawer(rightPanel, leftPanel)
}
+const hasLeftAside = computed(() => hasNonEmptySlotContent(slots['left-aside']))
+const hasHeader = computed(() => hasNonEmptySlotContent(slots.header))
+const hasFooter = computed(() => hasNonEmptySlotContent(slots.footer))
+const hasRightAside = computed(() => hasNonEmptySlotContent(slots['right-aside']))
+
const isDrawerVisible = computed(
- () => (leftPanel.isDrawer.value && leftPanel.isOpen.value) || (rightPanel.isDrawer.value && rightPanel.isOpen.value),
+ () =>
+ (hasLeftAside.value && leftPanel.isDrawer.value && leftPanel.isOpen.value) ||
+ (hasRightAside.value && rightPanel.isDrawer.value && rightPanel.isOpen.value),
)
function closeDrawers(): void {
@@ -107,13 +114,8 @@ function getDockedAsideWidth(panel: LayoutPanel): number {
return panel.isRail.value ? panel.collapsedWidth.value : panel.width.value
}
-const leftDockWidth = computed(() => getDockedAsideWidth(drawer.left))
-const rightDockWidth = computed(() => getDockedAsideWidth(drawer.right))
-
-const hasLeftAside = computed(() => hasNonEmptySlotContent(slots['left-aside']))
-const hasHeader = computed(() => hasNonEmptySlotContent(slots.header))
-const hasFooter = computed(() => hasNonEmptySlotContent(slots.footer))
-const hasRightAside = computed(() => hasNonEmptySlotContent(slots['right-aside']))
+const leftDockWidth = computed(() => (hasLeftAside.value ? getDockedAsideWidth(drawer.left) : 0))
+const rightDockWidth = computed(() => (hasRightAside.value ? getDockedAsideWidth(drawer.right) : 0))
const layoutStyle = computed>(() => {
const style: Record = {}