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();