diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index ce670e579..d457fe965 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -12,6 +12,7 @@ import DropdownMenu from './dropdown-menu'
import Feedback from './feedback'
import History from './history'
import IconButton from './icon-button'
+import { Layout, LayoutAsideToggle, LayoutProxyScrollbar } from './layout'
import { Prompt, Prompts } from './prompts'
import Sender from './sender'
import SenderCompat from './sender-compat'
@@ -43,6 +44,7 @@ export * from './dropdown-menu/index.type'
export * from './feedback/index.type'
export * from './history/index.type'
export * from './icon-button/index.type'
+export * from './layout/index.type'
export * from './prompts/index.type'
export * from './sender/index.type'
export * from './sender-actions/index.type'
@@ -81,6 +83,9 @@ const components = [
Feedback,
History,
IconButton,
+ Layout,
+ LayoutProxyScrollbar,
+ LayoutAsideToggle,
Prompt,
Prompts,
Sender,
@@ -135,6 +140,12 @@ export {
History as TrHistory,
IconButton,
IconButton as TrIconButton,
+ Layout,
+ Layout as TrLayout,
+ LayoutProxyScrollbar,
+ LayoutProxyScrollbar as TrLayoutProxyScrollbar,
+ LayoutAsideToggle,
+ LayoutAsideToggle as TrLayoutAsideToggle,
Prompt,
Prompt as TrPrompt,
Prompts,
diff --git a/packages/components/src/layout/Layout.vue b/packages/components/src/layout/Layout.vue
new file mode 100644
index 000000000..ee493f220
--- /dev/null
+++ b/packages/components/src/layout/Layout.vue
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/LayoutAsideToggle.vue b/packages/components/src/layout/LayoutAsideToggle.vue
new file mode 100644
index 000000000..31e22bee3
--- /dev/null
+++ b/packages/components/src/layout/LayoutAsideToggle.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/LayoutProxyScrollbar.vue b/packages/components/src/layout/LayoutProxyScrollbar.vue
new file mode 100644
index 000000000..b45dfa22b
--- /dev/null
+++ b/packages/components/src/layout/LayoutProxyScrollbar.vue
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/components/AsideContent.vue b/packages/components/src/layout/components/AsideContent.vue
new file mode 100644
index 000000000..15832065c
--- /dev/null
+++ b/packages/components/src/layout/components/AsideContent.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
diff --git a/packages/components/src/layout/components/AsideResizeTrigger.vue b/packages/components/src/layout/components/AsideResizeTrigger.vue
new file mode 100644
index 000000000..faab80d67
--- /dev/null
+++ b/packages/components/src/layout/components/AsideResizeTrigger.vue
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+
+
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/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/useLayoutContext.ts b/packages/components/src/layout/composables/useLayoutContext.ts
new file mode 100644
index 000000000..d6f7cf021
--- /dev/null
+++ b/packages/components/src/layout/composables/useLayoutContext.ts
@@ -0,0 +1,18 @@
+import { inject, provide, type InjectionKey } from 'vue'
+import type { LayoutContext } from '../internal.type'
+
+const layoutContextKey: InjectionKey = Symbol('LayoutContext')
+
+export function provideLayoutContext(context: LayoutContext): void {
+ provide(layoutContextKey, context)
+}
+
+export function useLayoutContext(): LayoutContext {
+ const context = inject(layoutContextKey, null)
+
+ if (!context) {
+ throw new Error('[Layout] useLayoutContext must be used within Layout.')
+ }
+
+ return context
+}
diff --git a/packages/components/src/layout/composables/useLayoutRootState.ts b/packages/components/src/layout/composables/useLayoutRootState.ts
new file mode 100644
index 000000000..fb20c0013
--- /dev/null
+++ b/packages/components/src/layout/composables/useLayoutRootState.ts
@@ -0,0 +1,158 @@
+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/asidePresets'
+import { emitAsideOpenChange, type LayoutEmitFn } from '../utils/asideEventEmitters'
+import { useControllableState } from '../../shared/composables/useControllableState'
+
+function isFloatingStateEqual(left: LayoutFloatingState | undefined, right: LayoutFloatingState | undefined): boolean {
+ return (
+ left?.placement === right?.placement &&
+ left?.offsetX === right?.offsetX &&
+ left?.offsetY === right?.offsetY &&
+ left?.width === right?.width &&
+ left?.height === right?.height
+ )
+}
+
+function resolveFiniteNumber(value: number | undefined, fallback: number): number {
+ return value === undefined || !Number.isFinite(value) ? fallback : value
+}
+
+function createAsidePanel(
+ side: LayoutSide,
+ aside: () => LayoutAsideProps | undefined,
+ emit: LayoutEmitFn,
+): 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(side)),
+ )
+ const maxWidth = computed(() => {
+ 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(side),
+ isControlled: () => asideValue.value?.open !== undefined,
+ onChange: (nextOpen) => emitAsideOpenChange(emit, { side, open: nextOpen }),
+ })
+
+ const widthState = useControllableState({
+ value: () => asideValue.value?.expandedWidth,
+ defaultValue: () => resolveFiniteNumber(asideValue.value?.defaultExpandedWidth, getDefaultAsideExpandedWidth(side)),
+ isControlled: () => asideValue.value?.expandedWidth !== undefined,
+ })
+
+ const resolvedOpen = computed(() => openState.resolvedState.value ?? getDefaultAsideOpen(side))
+ const resolvedWidth = computed(() => {
+ const nextWidth = resolveFiniteNumber(widthState.resolvedState.value, getDefaultAsideExpandedWidth(side))
+ return clamp(nextWidth, minWidth.value, maxWidth.value)
+ })
+
+ const isDock = computed(() => layoutMode.value === 'dock')
+ const isDrawer = computed(() => layoutMode.value === 'drawer')
+ const isRail = computed(() => isDock.value && !resolvedOpen.value && collapsedWidth.value > 0)
+ const isHidden = computed(() => !resolvedOpen.value && (isDrawer.value || !isRail.value))
+ const canResize = computed(() => isDock.value && resolvedOpen.value && resizable.value)
+
+ function setOpen(nextOpen: boolean): void {
+ if (resolvedOpen.value === nextOpen) {
+ return
+ }
+
+ openState.commit(nextOpen)
+ }
+
+ function setWidth(nextWidth: number): void {
+ const clampedWidth = clamp(nextWidth, minWidth.value, maxWidth.value)
+ if (resolvedWidth.value === clampedWidth) {
+ return
+ }
+
+ widthState.commit(clampedWidth)
+ }
+
+ return {
+ isOpen: resolvedOpen,
+ width: resolvedWidth,
+ collapsedWidth,
+ collapseEffect,
+ minWidth,
+ maxWidth,
+ isDock,
+ isDrawer,
+ isRail,
+ isHidden,
+ canResize,
+ setOpen,
+ setWidth,
+ }
+}
+
+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),
+ isControlled: () => props.mode === 'floating' && props.floatingState !== undefined,
+ onChange: (nextFloatingState) => nextFloatingState && emit('update:floatingState', nextFloatingState),
+ })
+
+ const resolvedMode = computed(() => (props.mode === 'floating' ? 'floating' : 'normal'))
+ const resolvedFloatingState = computed(() => floatingState.resolvedState.value)
+ const resolvedFloating = computed(() => {
+ const nextFloatingState = resolvedFloatingState.value
+ const nextFloatingOptions = props.mode === 'floating' ? props.floatingOptions : undefined
+
+ if (!nextFloatingState && !nextFloatingOptions) {
+ return undefined
+ }
+
+ return {
+ ...nextFloatingOptions,
+ ...nextFloatingState,
+ }
+ })
+
+ const floating: LayoutFloatingContext = {
+ state: {
+ mode: resolvedMode,
+ value: resolvedFloatingState,
+ resolved: resolvedFloating,
+ },
+ actions: {
+ initialize: (nextFloatingState) => {
+ if (isFloatingStateEqual(resolvedFloatingState.value, nextFloatingState)) {
+ return
+ }
+
+ floatingState.commit(nextFloatingState, { notify: false })
+ },
+ commit: (nextFloatingState) => {
+ if (isFloatingStateEqual(resolvedFloatingState.value, nextFloatingState)) {
+ return
+ }
+
+ floatingState.commit(nextFloatingState)
+ },
+ },
+ }
+
+ return {
+ 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.ts b/packages/components/src/layout/index.ts
new file mode 100644
index 000000000..a50af3b3d
--- /dev/null
+++ b/packages/components/src/layout/index.ts
@@ -0,0 +1,54 @@
+import type { App } from 'vue'
+import LayoutComp from './Layout.vue'
+import LayoutAsideToggleComp from './LayoutAsideToggle.vue'
+import LayoutProxyScrollbarComp from './LayoutProxyScrollbar.vue'
+
+export * from './index.type'
+
+LayoutProxyScrollbarComp.name = 'TrLayoutProxyScrollbar'
+
+const layoutProxyScrollbarInstall = function (app: App) {
+ app.component(LayoutProxyScrollbarComp.name!, LayoutProxyScrollbarComp)
+}
+
+const LayoutProxyScrollbar = LayoutProxyScrollbarComp as typeof LayoutProxyScrollbarComp & {
+ install: typeof layoutProxyScrollbarInstall
+}
+
+LayoutProxyScrollbar.install = layoutProxyScrollbarInstall
+
+LayoutAsideToggleComp.name = 'TrLayoutAsideToggle'
+
+const layoutAsideToggleInstall = function (app: App) {
+ app.component(LayoutAsideToggleComp.name!, LayoutAsideToggleComp)
+}
+
+const LayoutAsideToggle = LayoutAsideToggleComp as typeof LayoutAsideToggleComp & {
+ install: typeof layoutAsideToggleInstall
+}
+
+LayoutAsideToggle.install = layoutAsideToggleInstall
+
+LayoutComp.name = 'TrLayout'
+
+const layoutInstall = function (app: App) {
+ app.component(LayoutComp.name!, LayoutComp)
+ app.component(LayoutProxyScrollbar.name!, LayoutProxyScrollbar)
+ app.component(LayoutAsideToggle.name!, LayoutAsideToggle)
+}
+
+type LayoutCompound = typeof LayoutComp & {
+ install: typeof layoutInstall
+ ProxyScrollbar: typeof LayoutProxyScrollbar
+ AsideToggle: typeof LayoutAsideToggle
+}
+
+const Layout = LayoutComp as LayoutCompound
+
+Layout.install = layoutInstall
+Layout.ProxyScrollbar = LayoutProxyScrollbar
+Layout.AsideToggle = LayoutAsideToggle
+
+export { Layout, LayoutProxyScrollbar, LayoutAsideToggle }
+
+export default Layout
diff --git a/packages/components/src/layout/index.type.ts b/packages/components/src/layout/index.type.ts
new file mode 100644
index 000000000..c5b433718
--- /dev/null
+++ b/packages/components/src/layout/index.type.ts
@@ -0,0 +1,133 @@
+import type { ComponentPublicInstance, VNode } from 'vue'
+
+export type LayoutSide = 'left' | 'right'
+export type LayoutAsideMode = 'dock' | 'drawer'
+export type LayoutAsideCollapseEffect = 'overlay' | 'slide'
+export type LayoutMode = 'normal' | 'floating'
+export type LayoutFloatingPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
+
+export interface LayoutFloatingState {
+ placement?: LayoutFloatingPlacement
+ offsetX?: number
+ offsetY?: number
+ width?: number
+ height?: number
+}
+
+export interface LayoutFloatingOptions {
+ draggable?: boolean
+ resizable?: boolean
+ minWidth?: number
+ maxWidth?: number
+ minHeight?: number
+ maxHeight?: number
+}
+
+export type LayoutFloatingResizeHandle = 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
+
+export interface LayoutAsideOpenDetail {
+ side: LayoutSide
+ open: boolean
+}
+
+export interface LayoutAsideOpenValue {
+ open: boolean
+}
+
+export interface LayoutAsideResizeDetail {
+ side: LayoutSide
+ expandedWidth: number
+}
+
+export interface LayoutAsideResizeValue {
+ expandedWidth: number
+}
+
+export type LayoutFloatingDragDetail = LayoutFloatingState
+
+export type LayoutFloatingResizeDetail = LayoutFloatingState & {
+ handle: LayoutFloatingResizeHandle
+}
+
+export interface LayoutAsideProps {
+ mode?: LayoutAsideMode
+ open?: boolean
+ defaultOpen?: boolean
+ expandedWidth?: number
+ defaultExpandedWidth?: number
+ minExpandedWidth?: number
+ maxExpandedWidth?: number
+ collapsedWidth?: number
+ collapseEffect?: LayoutAsideCollapseEffect
+ resizable?: boolean
+}
+
+export interface LayoutAsidePanelsProps {
+ leftAside?: LayoutAsideProps
+ rightAside?: LayoutAsideProps
+}
+
+export interface LayoutNormalProps extends LayoutAsidePanelsProps {
+ mode?: 'normal'
+}
+
+type LayoutFloatingStateControlProps =
+ | {
+ floatingState?: LayoutFloatingState
+ defaultFloatingState?: never
+ }
+ | {
+ floatingState?: never
+ defaultFloatingState?: LayoutFloatingState
+ }
+
+export type LayoutFloatingProps = LayoutAsidePanelsProps &
+ LayoutFloatingStateControlProps & {
+ mode: 'floating'
+ floatingOptions?: LayoutFloatingOptions
+ }
+
+export type LayoutProps = LayoutNormalProps | LayoutFloatingProps
+
+export type LayoutScrollTargetComponent = Pick
+
+export type LayoutScrollTarget = HTMLElement | LayoutScrollTargetComponent | null | undefined
+
+export interface LayoutProxyScrollbarProps {
+ scrollTarget?: LayoutScrollTarget
+}
+
+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'?: () => VNode | VNode[]
+ header?: () => VNode | VNode[]
+ main?: () => VNode | VNode[]
+ footer?: () => VNode | VNode[]
+ 'right-aside'?: () => VNode | VNode[]
+}
diff --git a/packages/components/src/layout/internal.type.ts b/packages/components/src/layout/internal.type.ts
new file mode 100644
index 000000000..8c3218cc9
--- /dev/null
+++ b/packages/components/src/layout/internal.type.ts
@@ -0,0 +1,67 @@
+import type { ComputedRef } from 'vue'
+import type { LayoutAsideCollapseEffect, LayoutFloatingOptions, LayoutFloatingState, LayoutMode } from './index.type'
+
+export type LayoutResolvedFloating = LayoutFloatingState & LayoutFloatingOptions
+
+export type LayoutFloatingRect = Omit<
+ LayoutResolvedFloating,
+ 'placement' | 'offsetX' | 'offsetY' | 'width' | 'height'
+> & {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export interface LayoutFloatingDragPosition {
+ x: number
+ y: number
+}
+
+export interface LayoutPanel {
+ isOpen: ComputedRef
+ width: ComputedRef
+ collapsedWidth: ComputedRef
+ collapseEffect: ComputedRef
+ minWidth: ComputedRef
+ maxWidth: ComputedRef
+ isDock: ComputedRef
+ isDrawer: ComputedRef
+ isRail: ComputedRef
+ isHidden: ComputedRef
+ canResize: ComputedRef
+ setOpen: (nextOpen: boolean) => void
+ setWidth: (nextWidth: number) => void
+}
+
+export interface LayoutFloatingStateContext {
+ mode: ComputedRef
+ value: ComputedRef
+ resolved: ComputedRef
+}
+
+export interface LayoutFloatingActions {
+ initialize: (nextFloating: LayoutFloatingState) => void
+ commit: (nextFloating: LayoutFloatingState) => void
+}
+
+export interface LayoutFloatingContext {
+ state: LayoutFloatingStateContext
+ actions: LayoutFloatingActions
+}
+
+export interface LayoutAsideToggleContext {
+ isOpen: ComputedRef
+ toggle: () => void
+}
+
+export interface LayoutContext {
+ left: LayoutAsideToggleContext
+ right: LayoutAsideToggleContext
+}
+
+export interface LayoutState {
+ leftPanel: LayoutPanel
+ rightPanel: LayoutPanel
+ floating: LayoutFloatingContext
+}
diff --git a/packages/components/src/layout/utils/asideEventEmitters.ts b/packages/components/src/layout/utils/asideEventEmitters.ts
new file mode 100644
index 000000000..2ba6334d3
--- /dev/null
+++ b/packages/components/src/layout/utils/asideEventEmitters.ts
@@ -0,0 +1,66 @@
+import type { LayoutAsideOpenDetail, LayoutAsideResizeDetail, LayoutEmits } from '../index.type'
+
+export type LayoutEmitFn = (event: K, ...args: LayoutEmits[K]) => void
+
+function emitSideOpenChange(emit: LayoutEmitFn, detail: LayoutAsideOpenDetail): void {
+ if (detail.side === 'left') {
+ emit('left-aside-open-change', { open: detail.open })
+ return
+ }
+
+ emit('right-aside-open-change', { open: detail.open })
+}
+
+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: LayoutAsideResizeDetail,
+): void {
+ if (detail.side === 'left') {
+ if (phase === 'start') {
+ emit('left-aside-resize-start', { expandedWidth: detail.expandedWidth })
+ return
+ }
+
+ if (phase === 'end') {
+ emit('left-aside-resize-end', { expandedWidth: detail.expandedWidth })
+ return
+ }
+
+ emit('left-aside-resize', { expandedWidth: detail.expandedWidth })
+ return
+ }
+
+ if (phase === 'start') {
+ emit('right-aside-resize-start', { expandedWidth: detail.expandedWidth })
+ return
+ }
+
+ 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
new file mode 100644
index 000000000..b3e95cb3c
--- /dev/null
+++ b/packages/components/src/layout/utils/cssLength.ts
@@ -0,0 +1,30 @@
+export function toPx(value: number | undefined): string | undefined {
+ return value === undefined ? undefined : `${value}px`
+}
+
+const PX_LENGTH_RE = /^(-?(?:\d+|\d*\.\d+))px$/i
+const ZERO_LENGTH_RE = /^0(?:\.0+)?$/i
+
+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()) {
+ return fallback
+ }
+
+ const normalized = value.trim().toLowerCase()
+
+ if (ZERO_LENGTH_RE.test(normalized)) {
+ return 0
+ }
+
+ const pxMatch = normalized.match(PX_LENGTH_RE)
+ if (pxMatch) {
+ const parsed = Number.parseFloat(pxMatch[1])
+ return Number.isFinite(parsed) ? parsed : fallback
+ }
+
+ return fallback
+}
diff --git a/packages/components/src/layout/utils/domInteraction.ts b/packages/components/src/layout/utils/domInteraction.ts
new file mode 100644
index 000000000..c95cbbe25
--- /dev/null
+++ b/packages/components/src/layout/utils/domInteraction.ts
@@ -0,0 +1,21 @@
+export interface BodyInteractionState {
+ cursor: string
+ userSelect: string
+}
+
+export function lockBodyInteraction(body: HTMLElement, cursor: string): BodyInteractionState {
+ const state = {
+ cursor: body.style.cursor,
+ userSelect: body.style.userSelect,
+ }
+
+ body.style.cursor = cursor
+ body.style.userSelect = 'none'
+
+ return state
+}
+
+export function restoreBodyInteraction(body: HTMLElement, state: BodyInteractionState): void {
+ body.style.cursor = state.cursor
+ body.style.userSelect = state.userSelect
+}
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/number.ts b/packages/components/src/layout/utils/number.ts
new file mode 100644
index 000000000..299358185
--- /dev/null
+++ b/packages/components/src/layout/utils/number.ts
@@ -0,0 +1,3 @@
+export function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max)
+}
diff --git a/packages/components/src/layout/utils/slots.ts b/packages/components/src/layout/utils/slots.ts
new file mode 100644
index 000000000..9f38afdc5
--- /dev/null
+++ b/packages/components/src/layout/utils/slots.ts
@@ -0,0 +1,46 @@
+import { Comment, Fragment, Text, isVNode } from 'vue'
+
+type NonEmptyContentSlot = () => unknown
+
+function hasRenderableValue(value: unknown): boolean {
+ if (value == null) {
+ return false
+ }
+
+ if (typeof value === 'string') {
+ return value.trim().length > 0
+ }
+
+ return true
+}
+
+function hasRenderableChildren(children: unknown): boolean {
+ if (!Array.isArray(children)) {
+ return hasRenderableValue(children)
+ }
+
+ return children.some((child) => {
+ if (!isVNode(child)) {
+ return hasRenderableValue(child)
+ }
+
+ if (child.type === Comment) {
+ return false
+ }
+
+ if (child.type === Text) {
+ return hasRenderableValue(child.children)
+ }
+
+ if (child.type === Fragment) {
+ return hasRenderableChildren(child.children)
+ }
+
+ return true
+ })
+}
+
+// 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
new file mode 100644
index 000000000..4630b4e9d
--- /dev/null
+++ b/packages/components/src/layout/utils/surfaceGeometry.ts
@@ -0,0 +1,381 @@
+import type { LayoutFloatingPlacement, LayoutFloatingResizeHandle, LayoutFloatingState } from '../index.type'
+import type { LayoutFloatingRect, LayoutResolvedFloating } from '../internal.type'
+import { clamp } from './number'
+
+export interface FloatingBounds {
+ left: number
+ top: number
+ right: number
+ bottom: number
+}
+
+export interface FloatingConstraints {
+ minWidth: number
+ maxWidth: number
+ minHeight: number
+ maxHeight: number
+}
+
+export interface FloatingSnapshot {
+ placement: LayoutFloatingPlacement
+ rect: LayoutFloatingRect
+ bounds: FloatingBounds
+ constraints: FloatingConstraints
+ xMax: number
+ yMax: number
+}
+
+export const DEFAULT_FLOATING_WIDTH = 420
+export const DEFAULT_FLOATING_HEIGHT = 560
+export const DEFAULT_FLOATING_GAP = 0
+export const DEFAULT_FLOATING_TOP = DEFAULT_FLOATING_GAP
+export const DEFAULT_FLOATING_OFFSET = 24
+export const DEFAULT_MIN_FLOATING_WIDTH = 320
+export const DEFAULT_MIN_FLOATING_HEIGHT = 240
+
+type FloatingRectLike = Pick &
+ Partial>
+type FloatingConfig = LayoutFloatingState &
+ Partial>
+
+interface ViewportSize {
+ width: number
+ height: number
+}
+
+interface ResolvedFloatingOffset {
+ x: number
+ y: number
+}
+
+function resolveFloatingPlacement(config: Pick | undefined): LayoutFloatingPlacement {
+ return config?.placement ?? 'center'
+}
+
+function isFloatingRect(value: LayoutFloatingRect | FloatingConfig): value is LayoutFloatingRect {
+ return value !== undefined && 'x' in value && 'y' in value
+}
+
+function resolveViewportSize(): ViewportSize {
+ if (typeof window === 'undefined') {
+ return {
+ width: DEFAULT_FLOATING_WIDTH + DEFAULT_FLOATING_GAP * 2,
+ height: DEFAULT_FLOATING_HEIGHT + DEFAULT_FLOATING_GAP * 2,
+ }
+ }
+
+ const viewport = window.visualViewport
+
+ if (viewport) {
+ return {
+ width: viewport.width,
+ height: viewport.height,
+ }
+ }
+
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }
+}
+
+function getPlacementPosition(
+ placement: LayoutFloatingPlacement,
+ bounds: FloatingBounds,
+ width: number,
+ height: number,
+ offset: ResolvedFloatingOffset,
+) {
+ switch (placement) {
+ case 'top-left':
+ return {
+ x: bounds.left + offset.x,
+ y: bounds.top + offset.y,
+ }
+ case 'top-right':
+ return {
+ x: bounds.right - width - offset.x,
+ y: bounds.top + offset.y,
+ }
+ case 'bottom-left':
+ return {
+ x: bounds.left + offset.x,
+ y: bounds.bottom - height - offset.y,
+ }
+ case 'bottom-right':
+ return {
+ x: bounds.right - width - offset.x,
+ y: bounds.bottom - height - offset.y,
+ }
+ case 'center':
+ default:
+ return {
+ x: (bounds.left + bounds.right - width) / 2,
+ y: (bounds.top + bounds.bottom - height) / 2,
+ }
+ }
+}
+
+function resolveFloatingOffset(config: LayoutFloatingState | undefined): ResolvedFloatingOffset {
+ return {
+ x: config?.offsetX ?? DEFAULT_FLOATING_OFFSET,
+ y: config?.offsetY ?? DEFAULT_FLOATING_OFFSET,
+ }
+}
+
+function resolveFloatingOffsetFromRect(
+ rect: LayoutFloatingRect,
+ bounds: FloatingBounds,
+ placement: LayoutFloatingPlacement,
+): ResolvedFloatingOffset | null {
+ switch (placement) {
+ case 'top-left':
+ return {
+ x: rect.x - bounds.left,
+ y: rect.y - bounds.top,
+ }
+ case 'top-right':
+ return {
+ x: bounds.right - rect.width - rect.x,
+ y: rect.y - bounds.top,
+ }
+ case 'bottom-left':
+ return {
+ x: rect.x - bounds.left,
+ y: bounds.bottom - rect.height - rect.y,
+ }
+ case 'bottom-right':
+ return {
+ x: bounds.right - rect.width - rect.x,
+ y: bounds.bottom - rect.height - rect.y,
+ }
+ case 'center':
+ default:
+ return null
+ }
+}
+
+function resolveNearestCornerPlacement(rect: LayoutFloatingRect, bounds: FloatingBounds): LayoutFloatingPlacement {
+ const centerX = rect.x + rect.width / 2
+ const centerY = rect.y + rect.height / 2
+ const viewportCenterX = (bounds.left + bounds.right) / 2
+ const viewportCenterY = (bounds.top + bounds.bottom) / 2
+ const horizontal = centerX <= viewportCenterX ? 'left' : 'right'
+ const vertical = centerY <= viewportCenterY ? 'top' : 'bottom'
+
+ return `${vertical}-${horizontal}` as Exclude
+}
+
+export function resolveViewportBounds(gap = DEFAULT_FLOATING_GAP, topGap = DEFAULT_FLOATING_TOP): FloatingBounds {
+ const viewport = resolveViewportSize()
+
+ return {
+ left: gap,
+ top: topGap,
+ right: Math.max(gap, viewport.width - gap),
+ bottom: Math.max(topGap, viewport.height - gap),
+ }
+}
+
+export function resolveFloatingConstraints(source?: Partial): FloatingConstraints {
+ const bounds = resolveViewportBounds()
+ const maxWidth = Math.max(1, bounds.right - bounds.left)
+ const maxHeight = Math.max(1, bounds.bottom - bounds.top)
+ const minWidth = clamp(source?.minWidth ?? DEFAULT_MIN_FLOATING_WIDTH, 1, maxWidth)
+ const minHeight = clamp(source?.minHeight ?? DEFAULT_MIN_FLOATING_HEIGHT, 1, maxHeight)
+
+ return {
+ minWidth,
+ maxWidth: clamp(source?.maxWidth ?? maxWidth, minWidth, maxWidth),
+ minHeight,
+ maxHeight: clamp(source?.maxHeight ?? maxHeight, minHeight, maxHeight),
+ }
+}
+
+export function clampFloatingRect(
+ rect: FloatingRectLike,
+ constraints = resolveFloatingConstraints(rect),
+ bounds = resolveViewportBounds(),
+): LayoutFloatingRect {
+ const width = clamp(rect.width, constraints.minWidth, constraints.maxWidth)
+ const height = clamp(rect.height, constraints.minHeight, constraints.maxHeight)
+ const xMax = Math.max(bounds.left, bounds.right - width)
+ const yMax = Math.max(bounds.top, bounds.bottom - height)
+
+ return {
+ x: clamp(rect.x, bounds.left, xMax),
+ y: clamp(rect.y, bounds.top, yMax),
+ width,
+ height,
+ draggable: rect.draggable ?? true,
+ resizable: rect.resizable ?? false,
+ minWidth: constraints.minWidth,
+ maxWidth: constraints.maxWidth,
+ minHeight: constraints.minHeight,
+ maxHeight: constraints.maxHeight,
+ }
+}
+
+export function clampFloatingRectByHandle(
+ rect: FloatingRectLike,
+ handle: LayoutFloatingResizeHandle,
+ constraints = resolveFloatingConstraints(rect),
+ bounds = resolveViewportBounds(),
+): LayoutFloatingRect {
+ const right = rect.x + rect.width
+ const bottom = rect.y + rect.height
+ const availableWidthFromLeft = right - bounds.left
+ const availableHeightFromTop = bottom - bounds.top
+
+ let width = rect.width
+ let height = rect.height
+ let x = rect.x
+ let y = rect.y
+
+ if (handle.includes('w')) {
+ if (availableWidthFromLeft >= constraints.minWidth) {
+ width = clamp(rect.width, constraints.minWidth, Math.min(constraints.maxWidth, availableWidthFromLeft))
+ x = right - width
+ } else {
+ width = constraints.minWidth
+ x = bounds.left
+ }
+ } else if (handle.includes('e')) {
+ width = clamp(
+ rect.width,
+ constraints.minWidth,
+ Math.min(constraints.maxWidth, Math.max(constraints.minWidth, bounds.right - rect.x)),
+ )
+ x = rect.x
+ }
+
+ if (handle.includes('n')) {
+ if (availableHeightFromTop >= constraints.minHeight) {
+ height = clamp(rect.height, constraints.minHeight, Math.min(constraints.maxHeight, availableHeightFromTop))
+ y = bottom - height
+ } else {
+ height = constraints.minHeight
+ y = bounds.top
+ }
+ } else if (handle.includes('s')) {
+ height = clamp(
+ rect.height,
+ constraints.minHeight,
+ Math.min(constraints.maxHeight, Math.max(constraints.minHeight, bounds.bottom - rect.y)),
+ )
+ y = rect.y
+ }
+
+ return clampFloatingRect(
+ {
+ ...rect,
+ x,
+ y,
+ width,
+ height,
+ },
+ constraints,
+ bounds,
+ )
+}
+
+export function resolveDefaultFloatingRect(
+ config?: FloatingConfig,
+ bounds = resolveViewportBounds(),
+): LayoutFloatingRect {
+ const constraints = resolveFloatingConstraints(config)
+ const width = clamp(config?.width ?? DEFAULT_FLOATING_WIDTH, constraints.minWidth, constraints.maxWidth)
+ const height = clamp(config?.height ?? DEFAULT_FLOATING_HEIGHT, constraints.minHeight, constraints.maxHeight)
+ const placement = resolveFloatingPlacement(config)
+ const offset = resolveFloatingOffset(config)
+ const position = getPlacementPosition(placement, bounds, width, height, offset)
+
+ return clampFloatingRect(
+ {
+ x: position.x,
+ y: position.y,
+ width,
+ height,
+ draggable: config?.draggable ?? true,
+ resizable: config?.resizable ?? false,
+ minWidth: config?.minWidth,
+ maxWidth: config?.maxWidth,
+ minHeight: config?.minHeight,
+ maxHeight: config?.maxHeight,
+ },
+ constraints,
+ bounds,
+ )
+}
+
+export function normalizeFloatingRect(rectLike: LayoutFloatingRect | FloatingConfig | undefined): LayoutFloatingRect {
+ if (!rectLike) {
+ return resolveDefaultFloatingRect()
+ }
+
+ if (isFloatingRect(rectLike)) {
+ return clampFloatingRect(
+ {
+ x: rectLike.x,
+ y: rectLike.y,
+ width: rectLike.width,
+ height: rectLike.height,
+ draggable: rectLike.draggable,
+ resizable: rectLike.resizable,
+ minWidth: rectLike.minWidth,
+ maxWidth: rectLike.maxWidth,
+ minHeight: rectLike.minHeight,
+ maxHeight: rectLike.maxHeight,
+ },
+ resolveFloatingConstraints(rectLike),
+ )
+ }
+
+ return resolveDefaultFloatingRect(rectLike)
+}
+
+export function resolveFloatingSnapshot(
+ config: LayoutFloatingRect | FloatingConfig | undefined,
+ source?: Pick,
+): FloatingSnapshot {
+ const bounds = resolveViewportBounds()
+ const rect = normalizeFloatingRect(config)
+ const constraints = resolveFloatingConstraints(rect)
+ const normalizedRect = clampFloatingRect(rect, constraints, bounds)
+
+ return {
+ placement: config && isFloatingRect(config) ? resolveFloatingPlacement(source) : resolveFloatingPlacement(config),
+ rect: normalizedRect,
+ bounds,
+ constraints,
+ xMax: Math.max(bounds.left, bounds.right - normalizedRect.width),
+ yMax: Math.max(bounds.top, bounds.bottom - normalizedRect.height),
+ }
+}
+
+export function toCommittedFloatingState(
+ snapshot: FloatingSnapshot,
+ source?: Partial,
+ options?: { normalizeCenter?: boolean },
+): LayoutFloatingState {
+ const sourcePlacement = source?.placement ?? snapshot.placement
+ const placement =
+ options?.normalizeCenter && sourcePlacement === 'center'
+ ? resolveNearestCornerPlacement(snapshot.rect, snapshot.bounds)
+ : sourcePlacement
+ const offset = resolveFloatingOffsetFromRect(snapshot.rect, snapshot.bounds, placement)
+
+ return {
+ placement,
+ ...(offset ? { offsetX: offset.x, offsetY: offset.y } : {}),
+ width: snapshot.rect.width,
+ height: snapshot.rect.height,
+ }
+}
+
+export function areFloatingGeometryEqual(
+ left: Pick | undefined,
+ right: Pick | undefined,
+): boolean {
+ return left?.x === right?.x && left?.y === right?.y && left?.width === right?.width && left?.height === right?.height
+}
diff --git a/packages/components/src/layout/utils/surfaceResize.ts b/packages/components/src/layout/utils/surfaceResize.ts
new file mode 100644
index 000000000..bf4cfc5b2
--- /dev/null
+++ b/packages/components/src/layout/utils/surfaceResize.ts
@@ -0,0 +1,61 @@
+import type { LayoutFloatingResizeHandle } from '../index.type'
+import type { LayoutFloatingRect } from '../internal.type'
+
+interface ResolveFloatingResizeRectOptions {
+ handle: LayoutFloatingResizeHandle
+ deltaX: number
+ deltaY: number
+ startRect: LayoutFloatingRect
+}
+
+function applyNorth(rect: LayoutFloatingRect, deltaY: number): LayoutFloatingRect {
+ return {
+ ...rect,
+ y: rect.y + deltaY,
+ height: rect.height - deltaY,
+ }
+}
+
+function applySouth(rect: LayoutFloatingRect, deltaY: number): LayoutFloatingRect {
+ return {
+ ...rect,
+ height: rect.height + deltaY,
+ }
+}
+
+function applyEast(rect: LayoutFloatingRect, deltaX: number): LayoutFloatingRect {
+ return {
+ ...rect,
+ width: rect.width + deltaX,
+ }
+}
+
+function applyWest(rect: LayoutFloatingRect, deltaX: number): LayoutFloatingRect {
+ return {
+ ...rect,
+ x: rect.x + deltaX,
+ width: rect.width - deltaX,
+ }
+}
+
+export function resolveFloatingResizeRect(options: ResolveFloatingResizeRectOptions): LayoutFloatingRect {
+ let nextRect = { ...options.startRect }
+
+ if (options.handle.includes('n')) {
+ nextRect = applyNorth(nextRect, options.deltaY)
+ }
+
+ if (options.handle.includes('s')) {
+ nextRect = applySouth(nextRect, options.deltaY)
+ }
+
+ if (options.handle.includes('e')) {
+ nextRect = applyEast(nextRect, options.deltaX)
+ }
+
+ if (options.handle.includes('w')) {
+ nextRect = applyWest(nextRect, options.deltaX)
+ }
+
+ return nextRect
+}
diff --git a/packages/components/src/shared/composables/index.ts b/packages/components/src/shared/composables/index.ts
index fb703769a..3968ce1cf 100644
--- a/packages/components/src/shared/composables/index.ts
+++ b/packages/components/src/shared/composables/index.ts
@@ -1,5 +1,6 @@
export * from './createTeleport'
export * from './useAutoScroll'
+export * from './useControllableState'
export * from './useSlotRefs'
export * from './useTeleportTarget'
export * from './useTouchDevice'
diff --git a/packages/components/src/shared/composables/useControllableState.ts b/packages/components/src/shared/composables/useControllableState.ts
new file mode 100644
index 000000000..eb87c7371
--- /dev/null
+++ b/packages/components/src/shared/composables/useControllableState.ts
@@ -0,0 +1,45 @@
+import { computed, shallowRef, toValue, watchEffect, type ComputedRef, type MaybeRefOrGetter } from 'vue'
+
+interface UseControllableStateOptions {
+ value: MaybeRefOrGetter
+ defaultValue?: MaybeRefOrGetter
+ isControlled: MaybeRefOrGetter
+ onChange?: (nextValue: T) => void
+}
+
+interface UseControllableStateResult {
+ isControlled: ComputedRef
+ resolvedState: ComputedRef
+ commit: (nextValue: T, options?: { notify?: boolean }) => void
+}
+
+export function useControllableState(options: UseControllableStateOptions): UseControllableStateResult {
+ const internalState = shallowRef(toValue(options.defaultValue))
+ const isControlled = computed(() => toValue(options.isControlled))
+
+ watchEffect(() => {
+ if (!isControlled.value) {
+ return
+ }
+
+ internalState.value = toValue(options.value)
+ })
+
+ const resolvedState = computed(() => (isControlled.value ? toValue(options.value) : internalState.value))
+
+ function commit(nextValue: T, commitOptions?: { notify?: boolean }): void {
+ if (!isControlled.value) {
+ internalState.value = nextValue
+ }
+
+ if (commitOptions?.notify !== false) {
+ options.onChange?.(nextValue)
+ }
+ }
+
+ return {
+ isControlled,
+ resolvedState,
+ commit,
+ }
+}
diff --git a/packages/components/src/styles/components/index.css b/packages/components/src/styles/components/index.css
index f0807bd75..a64493ab4 100644
--- a/packages/components/src/styles/components/index.css
+++ b/packages/components/src/styles/components/index.css
@@ -7,6 +7,7 @@
@import './anchor.less';
@import './prompt.less';
@import './prompts.less';
+@import './layout.less';
[data-tr-color-mode='light'] {
color-scheme: light;
diff --git a/packages/components/src/styles/components/layout.less b/packages/components/src/styles/components/layout.less
new file mode 100644
index 000000000..b328be07c
--- /dev/null
+++ b/packages/components/src/styles/components/layout.less
@@ -0,0 +1,139 @@
+.tr-layout-vars() {
+ @layout-prefix: tr-layout;
+ @floating-prefix: tr-layout-floating;
+ @main-prefix: tr-layout-main;
+
+ @layout-vars: {
+ bg: var(--tr-container-bg-default);
+ divider-color: var(--tr-border-color-disabled);
+ main-min-width: 320px;
+ panel-shadow: 0 20px 48px rgba(15, 23, 42, 0.18);
+ overlay-bg: rgba(15, 23, 42, 0.42);
+ };
+
+ @floating-vars: {
+ z-index: calc(var(--tr-z-index-drawer, 1000) + 1);
+ radius: 24px;
+ shadow: 0 28px 72px rgba(15, 23, 42, 0.2);
+ };
+
+ @main-vars: {
+ scrollbar-width: 12px;
+ scrollbar-thumb-bg: color-mix(in srgb, var(--tr-text-primary, #111827) 18%, transparent);
+ scrollbar-thumb-bg-hover: color-mix(in srgb, var(--tr-text-primary, #111827) 28%, transparent);
+ scrollbar-thumb-bg-active: color-mix(in srgb, var(--tr-text-primary, #111827) 36%, transparent);
+ };
+
+ @layout-private-vars: {
+ overlay-z-index: var(--tr-z-index-drawer, 1000);
+ transition-duration: 220ms;
+ transition-easing: ease;
+ };
+
+ @floating-private-vars: {
+ border-color: color-mix(in srgb, var(--tr-text-primary, #111827) 6%, transparent);
+ outline-color: color-mix(in srgb, var(--tr-container-bg-default, #ffffff) 52%, transparent);
+ drag-bar-top: 8px;
+ drag-hit-width: 56px;
+ drag-hit-height: 18px;
+ drag-pill-width: 40px;
+ drag-pill-height: 5px;
+ drag-pill-bg: color-mix(in srgb, var(--tr-text-primary, #111827) 16%, transparent);
+ drag-pill-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66);
+ drag-hover-bg: color-mix(in srgb, var(--tr-container-bg-default, #ffffff) 88%, transparent);
+ drag-hover-border: color-mix(in srgb, var(--tr-text-primary, #111827) 8%, transparent);
+ drag-hover-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
+ };
+
+ @resize-indicator-private-vars: {
+ indicator-width: 6px;
+ indicator-height: 22px;
+ indicator-bg: var(--tr-container-bg-default, #ffffff);
+ indicator-border: color-mix(in srgb, var(--tr-text-primary, #111827) 10%, transparent);
+ indicator-active-bg: color-mix(
+ in srgb,
+ var(--tr-color-primary, #1476ff) 72%,
+ var(--tr-container-bg-default, #ffffff)
+ );
+ indicator-active-border: color-mix(
+ in srgb,
+ var(--tr-color-primary, #1476ff) 88%,
+ var(--tr-container-bg-default, #ffffff)
+ );
+ indicator-active-shadow: 0 0 0 4px color-mix(in srgb, var(--tr-color-primary-light, #deecff) 56%, transparent);
+ indicator-idle-opacity: 0;
+ };
+
+ @resize-trigger-private-vars: {
+ trigger-size: 6px;
+ line-color: transparent;
+ line-hover-color: color-mix(in srgb, var(--tr-color-primary, #1476ff) 32%, transparent);
+ line-active-color: color-mix(in srgb, var(--tr-color-primary, #1476ff) 66%, transparent);
+ indicator-idle-offset: 4px;
+ };
+
+ @floating-resize-private-vars: {
+ hit-area-size: 12px;
+ indicator-hover-opacity: 1;
+ indicator-idle-offset: 6px;
+ };
+
+ @main-private-vars: {
+ scrollbar-block-inset: 4px;
+ scrollbar-inline-end: 4px;
+ scrollbar-thumb-inset: 2px;
+ };
+
+ :root,
+ [data-tr-theme] {
+ each(@layout-vars, {
+ --@{layout-prefix}-@{key}: @value;
+ });
+
+ each(@floating-vars, {
+ --@{floating-prefix}-@{key}: @value;
+ });
+
+ each(@main-vars, {
+ --@{main-prefix}-@{key}: @value;
+ });
+ }
+
+ .tr-layout {
+ each(@layout-private-vars, {
+ --@{key}: @value;
+ });
+
+ each(@floating-private-vars, {
+ --@{key}: @value;
+ });
+ }
+
+ .tr-layout__resize-trigger {
+ each(@resize-indicator-private-vars, {
+ --@{key}: @value;
+ });
+
+ each(@resize-trigger-private-vars, {
+ --@{key}: @value;
+ });
+ }
+
+ .tr-layout__floating-resize-trigger {
+ each(@resize-indicator-private-vars, {
+ --@{key}: @value;
+ });
+
+ each(@floating-resize-private-vars, {
+ --@{key}: @value;
+ });
+ }
+
+ .tr-layout__main {
+ each(@main-private-vars, {
+ --@{key}: @value;
+ });
+ }
+}
+
+.tr-layout-vars();