This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1,45 @@
import type { App } from 'vue';
import { createApp, h } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useLayoutContentStyle } from '../use-layout-style';
let activeApp: App | undefined;
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
describe('useLayoutContentStyle', () => {
it('positions overlays without observing content size', () => {
const resizeObserver = vi.fn();
vi.stubGlobal('ResizeObserver', resizeObserver);
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp({
setup() {
const { contentElement, overlayStyle } = useLayoutContentStyle();
return () =>
h('main', { ref: contentElement }, [
h('div', { style: overlayStyle.value }),
]);
},
});
activeApp.mount(host);
const overlay = host.querySelector<HTMLElement>('main > div');
expect(overlay).not.toBeNull();
if (!overlay) throw new Error('overlay element was not rendered');
expect(resizeObserver).not.toHaveBeenCalled();
expect(overlay.style.position).toBe('absolute');
expect(`${overlay.style.inset}`).toBe('0');
});
});
@@ -0,0 +1,139 @@
import type { App } from 'vue';
import { createApp } from 'vue';
import { CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT } from '@vben-core/shared/constants';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useLayoutViewportHeight } from '../use-layout-viewport-height';
let activeApp: App | undefined;
function stubDvhSupport(supported: boolean) {
vi.stubGlobal('CSS', {
supports: vi.fn((property: string, value: string) => {
return supported && property === 'height' && value === '1dvh';
}),
});
}
function mountViewportHeight() {
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp({
setup() {
useLayoutViewportHeight();
return () => null;
},
});
activeApp.mount(host);
}
function getViewportHeightVar() {
return document.documentElement.style.getPropertyValue(
CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT,
);
}
async function flushAnimationFrame() {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
document.documentElement.style.removeProperty(
CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT,
);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useLayoutViewportHeight', () => {
it('does not write the CSS variable when dvh is supported', () => {
const resizeObserver = vi.fn();
const addEventListener = vi.spyOn(window, 'addEventListener');
vi.stubGlobal('ResizeObserver', resizeObserver);
stubDvhSupport(true);
mountViewportHeight();
expect(getViewportHeightVar()).toBe('');
expect(resizeObserver).not.toHaveBeenCalled();
expect(addEventListener).not.toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
});
it('writes innerHeight when dvh is unsupported', () => {
const resizeObserver = vi.fn();
vi.stubGlobal('ResizeObserver', resizeObserver);
stubDvhSupport(false);
vi.stubGlobal('visualViewport', null);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
mountViewportHeight();
expect(getViewportHeightVar()).toBe('800px');
expect(resizeObserver).not.toHaveBeenCalled();
});
it('prefers visualViewport.height over innerHeight', () => {
stubDvhSupport(false);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
const visualViewport = new EventTarget() as VisualViewport;
Object.defineProperty(visualViewport, 'height', {
configurable: true,
value: 640,
writable: true,
});
vi.stubGlobal('visualViewport', visualViewport);
mountViewportHeight();
expect(getViewportHeightVar()).toBe('640px');
});
it('updates the CSS variable on visualViewport resize and stops after unmount', async () => {
stubDvhSupport(false);
const visualViewport = new EventTarget() as VisualViewport;
Object.defineProperty(visualViewport, 'height', {
configurable: true,
value: 640,
writable: true,
});
vi.stubGlobal('visualViewport', visualViewport);
mountViewportHeight();
expect(getViewportHeightVar()).toBe('640px');
Object.defineProperty(visualViewport, 'height', {
configurable: true,
value: 520,
writable: true,
});
visualViewport.dispatchEvent(new Event('resize'));
await flushAnimationFrame();
expect(getViewportHeightVar()).toBe('520px');
activeApp?.unmount();
activeApp = undefined;
Object.defineProperty(visualViewport, 'height', {
configurable: true,
value: 400,
writable: true,
});
visualViewport.dispatchEvent(new Event('resize'));
await flushAnimationFrame();
expect(getViewportHeightVar()).toBe('520px');
});
});
@@ -0,0 +1,129 @@
import type { App, WritableComputedRef } from 'vue';
import { createApp, nextTick } from 'vue';
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SCROLL_FIXED_CLASS, useScrollLock } from '../use-scroll-lock';
let activeApp: App | undefined;
function createScrollableElement(id?: string) {
const element = document.createElement('div');
if (id) {
element.id = id;
}
element.style.overflow = 'auto';
Object.defineProperties(element, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 120 },
});
document.body.append(element);
return element;
}
function mountScrollLock(options?: { immediate?: boolean }) {
let scrollLock: undefined | WritableComputedRef<boolean>;
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp({
setup() {
scrollLock = useScrollLock(options);
return () => null;
},
});
activeApp.mount(host);
if (!scrollLock) {
throw new Error('useScrollLock was not initialized');
}
return scrollLock;
}
async function flushMountedLock() {
await nextTick();
await nextTick();
}
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
document.body.style.cssText = '';
vi.restoreAllMocks();
});
describe('useScrollLock', () => {
it('should lock the layout scroll element first', async () => {
const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL);
element.style.setProperty('scrollbar-gutter', 'auto');
const scrollLock = mountScrollLock();
await flushMountedLock();
expect(scrollLock.value).toBe(true);
expect(element.style.overflow).toBe('hidden');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable');
expect(document.body.style.overflow).not.toBe('hidden');
activeApp?.unmount();
activeApp = undefined;
expect(element.style.overflow).toBe('auto');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('auto');
});
it('should support manual locking', async () => {
const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL);
const scrollLock = mountScrollLock({ immediate: false });
await flushMountedLock();
expect(scrollLock.value).toBe(false);
scrollLock.value = true;
expect(element.style.overflow).toBe('hidden');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable');
scrollLock.value = false;
expect(element.style.overflow).toBe('auto');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('');
});
it('should fall back to body and compensate fixed nodes', async () => {
document.body.style.overflow = 'auto';
Object.defineProperties(document.body, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 120 },
});
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(
120,
);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY: 'auto',
} as CSSStyleDeclaration);
const fixedNode = document.createElement('div');
fixedNode.className = SCROLL_FIXED_CLASS;
fixedNode.style.transition = 'opacity 200ms';
document.body.append(fixedNode);
const scrollLock = mountScrollLock();
await flushMountedLock();
expect(scrollLock.value).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
expect(document.body.style.paddingRight).toBe('0px');
expect(fixedNode.style.paddingRight).toBe('0px');
activeApp?.unmount();
activeApp = undefined;
expect(document.body.style.overflow).toBe('auto');
expect(document.body.style.paddingRight).toBe('');
expect(fixedNode.style.paddingRight).toBe('');
});
});
@@ -0,0 +1,48 @@
import type { SortableOptions } from 'sortablejs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSortable } from '../use-sortable';
describe('useSortable', () => {
beforeEach(() => {
vi.mock('sortablejs/modular/sortable.complete.esm.js', () => ({
default: {
create: vi.fn(),
},
}));
});
it('should call Sortable.create with the correct options', async () => {
// Create a mock element
const mockElement = document.createElement('div') as HTMLDivElement;
// Define custom options
const customOptions: SortableOptions = {
group: 'test-group',
sort: false,
};
// Use the useSortable function
const { initializeSortable } = useSortable(mockElement, customOptions);
// Initialize sortable
await initializeSortable();
// Import sortablejs to access the mocked create function
const Sortable =
// @ts-expect-error - This is a dynamic import
await import('sortablejs/modular/sortable.complete.esm.js');
// Verify that Sortable.create was called with the correct parameters
expect(Sortable.default.create).toHaveBeenCalledTimes(1);
expect(Sortable.default.create).toHaveBeenCalledWith(
mockElement,
expect.objectContaining({
animation: 300,
delay: 400,
delayOnTouchOnly: true,
...customOptions,
}),
);
});
});
@@ -0,0 +1,14 @@
export * from './use-is-mobile';
export * from './use-layout-style';
export * from './use-layout-viewport-height';
export * from './use-namespace';
export * from './use-priority-value';
export * from './use-scroll-lock';
export * from './use-simple-locale';
export * from './use-sortable';
export {
useEmitAsProps,
useForwardExpose,
useForwardProps,
useForwardPropsEmits,
} from 'reka-ui';
@@ -0,0 +1,7 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
export function useIsMobile() {
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
return { isMobile };
}
@@ -0,0 +1,49 @@
import type { CSSProperties } from 'vue';
import { computed, ref } from 'vue';
import {
CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT,
CSS_VARIABLE_LAYOUT_HEADER_HEIGHT,
} from '@vben-core/shared/constants';
import { useCssVar } from '@vueuse/core';
/**
* @zh_CN content style
*/
export function useLayoutContentStyle() {
const contentElement = ref<HTMLDivElement | null>(null);
const overlayStyle = computed(
(): CSSProperties => ({ inset: 0, position: 'absolute', zIndex: 150 }),
);
return { contentElement, overlayStyle };
}
export function useLayoutHeaderStyle() {
const headerHeight = useCssVar(CSS_VARIABLE_LAYOUT_HEADER_HEIGHT);
return {
getLayoutHeaderHeight: () => {
return Number.parseInt(`${headerHeight.value}`, 10);
},
setLayoutHeaderHeight: (height: number) => {
headerHeight.value = `${height}px`;
},
};
}
export function useLayoutFooterStyle() {
const footerHeight = useCssVar(CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT);
return {
getLayoutFooterHeight: () => {
return Number.parseInt(`${footerHeight.value}`, 10);
},
setLayoutFooterHeight: (height: number) => {
footerHeight.value = `${height}px`;
},
};
}
@@ -0,0 +1,71 @@
import { onMounted, onUnmounted } from 'vue';
import { CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT } from '@vben-core/shared/constants';
import { useCssVar, useEventListener } from '@vueuse/core';
function supportsDynamicViewportHeight() {
return (
globalThis.CSS !== undefined &&
typeof globalThis.CSS.supports === 'function' &&
globalThis.CSS.supports('height', '1dvh')
);
}
function readViewportHeight() {
return Math.round(window.visualViewport?.height ?? window.innerHeight);
}
/**
* 仅在不支持 dvh 时,把 --vben-viewport-height 写成像素值。
* 支持 dvh 时保持 CSS 的 100vh → 100dvh 级联,避免 useCssVar 把单位冻成 px。
*/
export function useLayoutViewportHeight() {
if (typeof window === 'undefined' || supportsDynamicViewportHeight()) {
return;
}
const viewportHeight = useCssVar(
CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT,
document.documentElement,
{ observe: false },
);
let frameId = 0;
function applyViewportHeight() {
viewportHeight.value = `${readViewportHeight()}px`;
}
function scheduleApplyViewportHeight() {
if (frameId) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = 0;
applyViewportHeight();
});
}
applyViewportHeight();
onMounted(applyViewportHeight);
useEventListener(window, 'resize', scheduleApplyViewportHeight);
if (window.visualViewport) {
useEventListener(
window.visualViewport,
'resize',
scheduleApplyViewportHeight,
);
}
onUnmounted(() => {
if (!frameId) {
return;
}
window.cancelAnimationFrame(frameId);
frameId = 0;
});
}
@@ -0,0 +1,106 @@
import { DEFAULT_NAMESPACE } from '@vben-core/shared/constants';
/**
* @see copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-namespace/index.ts
*/
const statePrefix = 'is-';
const _bem = (
namespace: string,
block: string,
blockSuffix: string,
element: string,
modifier: string,
) => {
let cls = `${namespace}-${block}`;
if (blockSuffix) {
cls += `-${blockSuffix}`;
}
if (element) {
cls += `__${element}`;
}
if (modifier) {
cls += `--${modifier}`;
}
return cls;
};
const is: {
(name: string): string;
// oxlint-disable-next-line typescript/unified-signatures
(name: string, state: boolean | undefined): string;
} = (name: string, ...args: [] | [boolean | undefined]) => {
const state = args.length > 0 ? args[0] : true;
return name && state ? `${statePrefix}${name}` : '';
};
const useNamespace = (block: string) => {
const namespace = DEFAULT_NAMESPACE;
const b = (blockSuffix = '') => _bem(namespace, block, blockSuffix, '', '');
const e = (element?: string) =>
element ? _bem(namespace, block, '', element, '') : '';
const m = (modifier?: string) =>
modifier ? _bem(namespace, block, '', '', modifier) : '';
const be = (blockSuffix?: string, element?: string) =>
blockSuffix && element
? _bem(namespace, block, blockSuffix, element, '')
: '';
const em = (element?: string, modifier?: string) =>
element && modifier ? _bem(namespace, block, '', element, modifier) : '';
const bm = (blockSuffix?: string, modifier?: string) =>
blockSuffix && modifier
? _bem(namespace, block, blockSuffix, '', modifier)
: '';
const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
blockSuffix && element && modifier
? _bem(namespace, block, blockSuffix, element, modifier)
: '';
// for css var
// --el-xxx: value;
const cssVar = (object: Record<string, string>) => {
const styles: Record<string, string> = {};
for (const key in object) {
if (object[key]) {
styles[`--${namespace}-${key}`] = object[key];
}
}
return styles;
};
// with block
const cssVarBlock = (object: Record<string, string>) => {
const styles: Record<string, string> = {};
for (const key in object) {
if (object[key]) {
styles[`--${namespace}-${block}-${key}`] = object[key];
}
}
return styles;
};
const cssVarName = (name: string) => `--${namespace}-${name}`;
const cssVarBlockName = (name: string) => `--${namespace}-${block}-${name}`;
return {
b,
be,
bem,
bm,
// css
cssVar,
cssVarBlock,
cssVarBlockName,
cssVarName,
e,
em,
is,
m,
namespace,
};
};
type UseNamespaceReturn = ReturnType<typeof useNamespace>;
export type { UseNamespaceReturn };
export { useNamespace };
@@ -0,0 +1,94 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, getCurrentInstance, unref, useAttrs, useSlots } from 'vue';
import {
getFirstNonNullOrUndefined,
kebabToCamelCase,
} from '@vben-core/shared/utils';
/**
* 依次从插槽、attrs、props、state 中获取值
* @param key
* @param props
* @param state
*/
export function usePriorityValue<
T extends Record<string, any>,
S extends Record<string, any>,
K extends keyof T = keyof T,
>(key: K, props: T, state: Readonly<Ref<NoInfer<S>>> | undefined) {
const instance = getCurrentInstance();
const slots = useSlots();
const attrs = useAttrs() as T;
const value = computed((): T[K] => {
// props不管有没有传,都会有默认值,会影响这里的顺序,
// 通过判断原始props是否有值来判断是否传入
const rawProps = (instance?.vnode?.props || {}) as T;
const standardRawProps = {} as T;
for (const [key, value] of Object.entries(rawProps)) {
standardRawProps[kebabToCamelCase(key) as K] = value;
}
const propsKey =
standardRawProps?.[key] === undefined ? undefined : props[key];
// slot可以关闭
return getFirstNonNullOrUndefined(
slots[key as string],
attrs[key],
propsKey,
state?.value?.[key as keyof S],
) as T[K];
});
return value;
}
/**
* 批量获取state中的值(每个值都是ref)
* @param props
* @param state
*/
export function usePriorityValues<
T extends Record<string, any>,
S extends Ref<Record<string, any>> = Readonly<Ref<NoInfer<T>, NoInfer<T>>>,
>(props: T, state: S | undefined) {
const result: { [K in keyof T]: ComputedRef<T[K]> } = {} as never;
(Object.keys(props) as (keyof T)[]).forEach((key) => {
result[key] = usePriorityValue(key as keyof typeof props, props, state);
});
return result;
}
/**
* 批量获取state中的值(集中在一个computed,用于透传)
* @param props
* @param state
*/
export function useForwardPriorityValues<
T extends Record<string, any>,
S extends Ref<Record<string, any>> = Readonly<Ref<NoInfer<T>, NoInfer<T>>>,
>(props: T, state: S | undefined) {
const computedResult: { [K in keyof T]: ComputedRef<T[K]> } = {} as never;
(Object.keys(props) as (keyof T)[]).forEach((key) => {
computedResult[key] = usePriorityValue(
key as keyof typeof props,
props,
state,
);
});
return computed(() => {
const unwrapResult: Record<string, any> = {};
Object.keys(props).forEach((key) => {
unwrapResult[key] = unref(computedResult[key]);
});
return unwrapResult as { [K in keyof T]: T[K] };
});
}
@@ -0,0 +1,140 @@
import { computed, nextTick, shallowRef } from 'vue';
import {
getLayoutScrollElement,
getScrollbarWidth,
needsScrollbar,
} from '@vben-core/shared/utils';
import {
useScrollLock as _useScrollLock,
tryOnBeforeUnmount,
tryOnMounted,
} from '@vueuse/core';
export const SCROLL_FIXED_CLASS = `_scroll__fixed_`;
interface ScrollLockOptions {
immediate?: boolean;
}
function getScrollLockTarget() {
return getLayoutScrollElement() ?? document.body;
}
function getLayoutFixedNodes() {
return [...document.querySelectorAll<HTMLElement>(`.${SCROLL_FIXED_CLASS}`)];
}
export function useScrollLock(options: ScrollLockOptions = {}) {
const { immediate = true } = options;
const lockTarget = shallowRef<HTMLElement | null>(null);
const isTargetLocked = _useScrollLock(lockTarget);
const scrollbarWidth = getScrollbarWidth();
let hasScrollbarCompensation = false;
let hasScrollbarGutter = false;
function applyScrollbarGutter(target: HTMLElement) {
if (target === document.body) {
return;
}
target.dataset.scrollbarGutter =
target.style.getPropertyValue('scrollbar-gutter');
target.style.setProperty('scrollbar-gutter', 'stable');
hasScrollbarGutter = true;
}
function resetScrollbarGutter(target: HTMLElement) {
if (!hasScrollbarGutter) {
return;
}
const scrollbarGutter = target.dataset.scrollbarGutter;
if (scrollbarGutter) {
target.style.setProperty('scrollbar-gutter', scrollbarGutter);
} else {
target.style.removeProperty('scrollbar-gutter');
}
delete target.dataset.scrollbarGutter;
hasScrollbarGutter = false;
}
function applyScrollbarCompensation(target: HTMLElement) {
if (target !== document.body || !needsScrollbar()) {
return;
}
target.style.paddingRight = `${scrollbarWidth}px`;
const nodes = getLayoutFixedNodes();
if (nodes.length > 0) {
nodes.forEach((node) => {
node.dataset.transition = node.style.transition;
node.style.transition = 'none';
node.style.paddingRight = `${scrollbarWidth}px`;
});
}
hasScrollbarCompensation = true;
}
function resetScrollbarCompensation(target: HTMLElement) {
if (!hasScrollbarCompensation) {
return;
}
const nodes = getLayoutFixedNodes();
if (nodes.length > 0) {
nodes.forEach((node) => {
node.style.paddingRight = '';
requestAnimationFrame(() => {
node.style.transition = node.dataset.transition || '';
});
});
}
target.style.paddingRight = '';
hasScrollbarCompensation = false;
}
const isLocked = computed({
get() {
return isTargetLocked.value;
},
set(value: boolean) {
const target = lockTarget.value ?? getScrollLockTarget();
lockTarget.value = target;
if (value) {
if (isTargetLocked.value) {
return;
}
if (needsScrollbar(target)) {
applyScrollbarGutter(target);
applyScrollbarCompensation(target);
}
isTargetLocked.value = true;
return;
}
isTargetLocked.value = false;
resetScrollbarCompensation(target);
resetScrollbarGutter(target);
},
});
tryOnMounted(async () => {
const target = getScrollLockTarget();
lockTarget.value = target;
await nextTick();
if (immediate && needsScrollbar(target)) {
isLocked.value = true;
}
});
tryOnBeforeUnmount(() => {
isLocked.value = false;
});
return isLocked;
}
@@ -0,0 +1,3 @@
# Simple i18n
Simple i18 implementation
@@ -0,0 +1,27 @@
import type { Locale } from './messages';
import { computed, ref } from 'vue';
import { createSharedComposable } from '@vueuse/core';
import { getMessages } from './messages';
export const useSimpleLocale = createSharedComposable(() => {
const currentLocale = ref<Locale>('zh-CN');
const setSimpleLocale = (locale: Locale) => {
currentLocale.value = locale;
};
const $t = computed(() => {
const localeMessages = getMessages(currentLocale.value);
return (key: string) => {
return localeMessages[key] || key;
};
});
return {
$t,
currentLocale,
setSimpleLocale,
};
});
@@ -0,0 +1,28 @@
export type Locale = 'en-US' | 'zh-CN';
export const messages: Record<Locale, Record<string, string>> = {
'en-US': {
cancel: 'Cancel',
collapse: 'Collapse',
confirm: 'Confirm',
expand: 'Expand',
prompt: 'Prompt',
reset: 'Reset',
submit: 'Submit',
toggleSidebar: 'Toggle sidebar',
confirmTitle: 'Please Confirm',
},
'zh-CN': {
cancel: '取消',
collapse: '收起',
confirm: '确认',
expand: '展开',
prompt: '提示',
reset: '重置',
submit: '提交',
toggleSidebar: '切换侧边栏',
confirmTitle: '请确认',
},
};
export const getMessages = (locale: Locale) => messages[locale];
@@ -0,0 +1,29 @@
import type { SortableOptions } from 'sortablejs';
import type Sortable from 'sortablejs';
function useSortable<T extends HTMLElement>(
sortableContainer: T,
options: SortableOptions = {},
) {
const initializeSortable = async () => {
const Sortable = await import(
// @ts-expect-error - This is a dynamic import
'sortablejs/modular/sortable.complete.esm.js'
);
const sortable = Sortable?.default?.create?.(sortableContainer, {
animation: 300,
delay: 400,
delayOnTouchOnly: true,
...options,
});
return sortable as Sortable;
};
return {
initializeSortable,
};
}
export { useSortable };
export type { Sortable };