reborn-ui 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +182 -240
- package/dist/index.js.map +1 -1
- package/package.json +53 -53
- package/registry/components/reborn-affix.json +8 -3
- package/registry/components/reborn-back-top.json +9 -4
- package/registry/components/reborn-badge.json +11 -5
- package/registry/components/reborn-button.json +5 -5
- package/registry/components/reborn-card.json +18 -0
- package/registry/components/reborn-cascader.json +18 -0
- package/registry/components/reborn-checkbox.json +4 -4
- package/registry/components/reborn-chip.json +11 -5
- package/registry/components/reborn-collapse.json +11 -5
- package/registry/components/reborn-color-picker.json +50 -0
- package/registry/components/reborn-draggable.json +32 -0
- package/registry/components/reborn-drawer.json +17 -0
- package/registry/components/reborn-dropdown-select.json +18 -0
- package/registry/components/reborn-footer.json +40 -0
- package/registry/components/reborn-form.json +11 -6
- package/registry/components/reborn-image.json +10 -5
- package/registry/components/reborn-input-number.json +12 -6
- package/registry/components/reborn-input-otp.json +40 -0
- package/registry/components/reborn-input.json +4 -4
- package/registry/components/reborn-loading.json +23 -0
- package/registry/components/reborn-loadmore.json +23 -0
- package/registry/components/reborn-overlay.json +38 -0
- package/registry/components/reborn-page.json +18 -0
- package/registry/components/reborn-picker-view.json +26 -0
- package/registry/components/reborn-popover.json +58 -0
- package/registry/components/reborn-popup.json +23 -0
- package/registry/components/reborn-qrcode.json +45 -0
- package/registry/components/reborn-radio.json +45 -0
- package/registry/components/reborn-rate.json +40 -0
- package/registry/components/reborn-root-portal.json +26 -0
- package/registry/components/reborn-select-date.json +40 -0
- package/registry/components/reborn-select-trigger.json +25 -0
- package/registry/components/reborn-select.json +41 -0
- package/registry/components/reborn-slider.json +40 -0
- package/registry/components/reborn-sticky.json +12 -6
- package/registry/components/reborn-switch.json +13 -7
- package/registry/components/reborn-tabbar.json +38 -0
- package/registry/components/reborn-tabs copy.json +46 -0
- package/registry/components/reborn-tabs-test.json +46 -0
- package/registry/components/reborn-tabs.json +12 -6
- package/registry/components/reborn-text.json +34 -0
- package/registry/components/reborn-textarea.json +5 -5
- package/registry/components/reborn-toast.json +38 -0
- package/registry/components/reborn-transition.json +38 -0
- package/registry/components/reborn-waterfall.json +18 -0
- package/registry/components/scroll-island.json +2 -2
- package/registry/registry.json +1101 -97
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-cascader",
|
|
3
|
+
"dependencies": [],
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "reborn-cascader.config.ts",
|
|
7
|
+
"content": "\r\nexport const CascaderSizes = ['sm', 'md', 'lg'] as const\r\nexport const CascaderColors = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] as const\r\n\r\nexport type CascaderSize = (typeof CascaderSizes)[number]\r\nexport type CascaderColor = (typeof CascaderColors)[number]\r\n\r\nexport interface CascaderUI {\r\n root?: string\r\n popup?: string\r\n tabs?: string\r\n tab?: string\r\n tabActive?: string\r\n list?: string\r\n item?: string\r\n itemActive?: string\r\n itemText?: string\r\n itemTextActive?: string\r\n footer?: string\r\n footerText?: string\r\n loading?: string\r\n loadingText?: string\r\n}\r\n\r\n// 选项接口定义\r\nexport interface CascaderOption {\r\n label: string\r\n value: string | number\r\n children?: CascaderOption[]\r\n [key: string]: any\r\n}\r\n\r\nexport interface CascaderProps {\r\n /** 绑定值 */\r\n modelValue?: (string | number)[] | (string | number)[][]\r\n /** 选项数据 */\r\n options?: CascaderOption[]\r\n /** 标题 */\r\n title?: string\r\n /** 占位提示 */\r\n placeholder?: string\r\n /** 是否显示触发器 */\r\n showTrigger?: boolean\r\n /** 是否禁用 */\r\n disabled?: boolean\r\n /** 标签字段 */\r\n labelKey?: string\r\n /** 值字段 */\r\n valueKey?: string\r\n /** 分隔符 */\r\n textSeparator?: string\r\n /** 列表高度 (rpx) */\r\n height?: number | string\r\n /** 尺寸 */\r\n size?: CascaderSize\r\n /** 颜色 */\r\n color?: CascaderColor\r\n /** 自定义类名 */\r\n customClass?: string\r\n /** 自定义样式 */\r\n customStyle?: string\r\n /** UI 覆盖 */\r\n ui?: CascaderUI\r\n /** 是否动态加载子节点 */\r\n lazy?: boolean\r\n /** 加载动态数据的方法 */\r\n lazyLoad?: (node: CascaderOption, resolve: (children: CascaderOption[]) => void, reject: () => void) => void\r\n /** 指定选项的子选项字段 */\r\n childrenKey?: string\r\n /** 查询次级菜单层级;为0时候查询所有 */\r\n leafLevel?: number\r\n /** 是否多选 */\r\n multiple?: boolean\r\n /** 是否显示省略号 */\r\n ellipsis?: boolean\r\n /** 省略号行数 */\r\n lines?: number\r\n}\r\n\r\nconst config = {\r\n slots: {\r\n root: 'w-full',\r\n popup: 'bg-white dark:bg-slate-900 rounded-t-[32rpx] overflow-hidden',\r\n tabs: 'flex flex-row items-center border-b border-gray-100 dark:border-slate-800 p-2 gap-1.5 overflow-x-auto no-scrollbar',\r\n tab: 'shrink-0',\r\n tabActive: '',\r\n list: 'h-[600rpx]',\r\n item: 'flex flex-row items-center justify-between px-[32rpx] py-[24rpx] active:bg-gray-50 dark:active:bg-slate-800 transition-colors',\r\n itemActive: 'bg-primary/5 dark:bg-primary/10',\r\n itemText: 'text-[28rpx]',\r\n itemTextActive: 'text-primary font-medium',\r\n footer: 'flex flex-row items-center justify-between px-[32rpx] py-[24rpx] border-t border-gray-100 dark:border-slate-800',\r\n footerText: 'text-[24rpx] text-gray-500',\r\n loading: 'flex flex-col items-center justify-center h-full w-full',\r\n loadingText: 'mt-2 text-[24rpx] text-gray-400',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n item: 'px-[24rpx] py-[16rpx]',\r\n itemText: 'text-[24rpx]',\r\n },\r\n md: {\r\n item: 'px-[32rpx] py-[24rpx]',\r\n itemText: 'text-[28rpx]',\r\n },\r\n lg: {\r\n item: 'px-[40rpx] py-[32rpx]',\r\n itemText: 'text-[32rpx]',\r\n },\r\n },\r\n color: {\r\n primary: {\r\n itemTextActive: 'text-primary',\r\n itemActive: 'bg-primary/5',\r\n },\r\n secondary: {\r\n itemTextActive: 'text-secondary',\r\n itemActive: 'bg-secondary/5',\r\n },\r\n success: {\r\n itemTextActive: 'text-success',\r\n itemActive: 'bg-success/5',\r\n },\r\n info: {\r\n itemTextActive: 'text-info',\r\n itemActive: 'bg-info/5',\r\n },\r\n warning: {\r\n itemTextActive: 'text-warning',\r\n itemActive: 'bg-warning/5',\r\n },\r\n error: {\r\n itemTextActive: 'text-error',\r\n itemActive: 'bg-error/5',\r\n },\r\n neutral: {\r\n itemTextActive: 'text-slate-900 dark:text-white',\r\n itemActive: 'bg-slate-100 dark:bg-slate-800',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'primary',\r\n size: 'md',\r\n },\r\n} as const\r\n\r\nexport default config\r\n",
|
|
8
|
+
"target": "uniapp"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "RebornCascader.vue",
|
|
12
|
+
"content": "<script setup lang=\"ts\">\r\n/**\r\n * RebornCascader 级联选择器\r\n * 用于多层级的数据选择,如省市区、分类层级等。\r\n */\r\nimport { ref, computed, nextTick, watch } from 'vue'\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport theme from './reborn-cascader.config'\r\nimport type { CascaderUI, CascaderColor, CascaderSize, CascaderOption, CascaderProps } from './reborn-cascader.config'\r\nimport RebornSelectTrigger from '../reborn-select-trigger/RebornSelectTrigger.vue'\r\nimport RebornText from '../reborn-text/RebornText.vue'\r\nimport RebornPopup from '../reborn-popup/RebornPopup.vue'\r\nimport RebornBadge from '../reborn-badge/RebornBadge.vue'\r\nimport RebornButton from '../reborn-button/RebornButton.vue'\r\nimport RebornCheckbox from '../reborn-checkbox/RebornCheckbox.vue'\r\nimport RebornLoading from '../reborn-loading/RebornLoading.vue'\r\n\r\ndefineOptions({\r\n name: 'RebornCascader',\r\n})\r\n\r\n\r\n\r\nconst props = withDefaults(defineProps<CascaderProps>(), {\r\n modelValue: () => [],\r\n options: () => [],\r\n title: '请选择',\r\n placeholder: '请选择',\r\n showTrigger: true,\r\n disabled: false,\r\n labelKey: 'label',\r\n valueKey: 'value',\r\n textSeparator: ' / ',\r\n height: 600,\r\n size: 'md',\r\n color: 'primary',\r\n customClass: '',\r\n customStyle: '',\r\n ui: () => ({}),\r\n lazy: false,\r\n childrenKey: 'children',\r\n leafLevel: 0,\r\n multiple: false,\r\n ellipsis: true,\r\n lines: 1,\r\n})\r\n\r\nconst emit = defineEmits<{\r\n (e: 'update:modelValue', value: (string | number)[] | (string | number)[][]): void\r\n (e: 'change', value: (string | number)[] | (string | number)[][]): void\r\n}>()\r\n\r\n// 样式系统\r\nconst b = tv(theme)\r\n\r\nconst styles = computed(() => b({ color: props.color, size: props.size }))\r\n\r\nconst ui = computed(() => {\r\n const s = styles.value\r\n const u = props.ui || {}\r\n return {\r\n root: (opts?: { class?: any }) => s.root({ class: cn(opts?.class, props.customClass, u.root) }),\r\n popup: (opts?: { class?: any }) => s.popup({ class: cn(opts?.class, u.popup) }),\r\n tabs: (opts?: { class?: any }) => s.tabs({ class: cn(opts?.class, u.tabs) }),\r\n tab: (opts?: { class?: any }) => s.tab({ class: cn(opts?.class, u.tab) }),\r\n list: (opts?: { class?: any }) => s.list({ class: cn(opts?.class, u.list) }),\r\n item: (opts?: { class?: any }) => s.item({ class: cn(opts?.class, u.item) }),\r\n itemActive: (opts?: { class?: any }) => s.itemActive({ class: cn(opts?.class, u.itemActive) }),\r\n itemText: (opts?: { class?: any }) => s.itemText({ class: cn(opts?.class, u.itemText) }),\r\n itemTextActive: (opts?: { class?: any }) => s.itemTextActive({ class: cn(opts?.class, u.itemTextActive) }),\r\n footer: (opts?: { class?: any }) => s.footer({ class: cn(opts?.class, u.footer) }),\r\n footerText: (opts?: { class?: any }) => s.footerText({ class: cn(opts?.class, u.footerText) }),\r\n loading: (opts?: { class?: any }) => s.loading({ class: cn(opts?.class, u.loading) }),\r\n loadingText: (opts?: { class?: any }) => s.loadingText({ class: cn(opts?.class, u.loadingText) }),\r\n }\r\n})\r\n\r\n// 状态\r\nconst visible = ref(false)\r\nconst isRootLoading = ref(false)\r\nconst current = ref(0) // 当前操作的层级索引\r\nconst activePath = ref<(string | number)[]>([]) // 当前显示的路径\r\nconst selectedPaths = ref<(string | number)[][]>([]) // 多选选中的路径集合\r\nconst scrollIntoViewId = ref('') // 水平滚动的目标ID\r\nconst loadingNodes = ref<Record<string, boolean>>({}) // 记录加载状态\r\nconst internalOptions = ref<CascaderOption[]>([])\r\n\r\nwatch(() => props.options, (val) => {\r\n internalOptions.value = JSON.parse(JSON.stringify(val || []))\r\n}, { immediate: true, deep: true })\r\n\r\n// 监听外界 modelValue 变化\r\nwatch(() => props.modelValue, (val) => {\r\n if (props.multiple) {\r\n selectedPaths.value = JSON.parse(JSON.stringify(val || []))\r\n activePath.value = selectedPaths.value[0] ? [...selectedPaths.value[0]] : []\r\n } else {\r\n activePath.value = [...(val as (string | number)[] || [])]\r\n }\r\n}, { immediate: true })\r\n\r\n// 计算属性:各级选项列表\r\nconst lists = computed<CascaderOption[][]>(() => {\r\n let data = internalOptions.value\r\n const result: CascaderOption[][] = [data]\r\n\r\n for (let i = 0; i < activePath.value.length; i++) {\r\n // 如果达到限定层级,不再增加下一级列表\r\n if (props.leafLevel > 0 && i + 1 >= props.leafLevel) break\r\n\r\n const val = activePath.value[i]\r\n const found = data.find(item => item[props.valueKey] === val)\r\n const children = found?.[props.childrenKey]\r\n if (found && children && children.length > 0) {\r\n data = children\r\n result.push(data)\r\n } else {\r\n break\r\n }\r\n }\r\n return result\r\n})\r\n\r\nconst columnWidthClass = computed(() => {\r\n const len = lists.value.filter(l => l && l.length > 0).length\r\n if (len === 1) return 'w-full'\r\n if (len === 2) return 'w-1/2'\r\n return 'w-1/3'\r\n})\r\n\r\n// 计算属性:当前页面的标签\r\nconst labels = computed(() => {\r\n const result: string[] = []\r\n for (let i = 0; i < activePath.value.length; i++) {\r\n const val = activePath.value[i]\r\n const found = lists.value[i]?.find(item => item[props.valueKey] === val)\r\n if (found) {\r\n result.push(found[props.labelKey])\r\n } else {\r\n break\r\n }\r\n }\r\n\r\n // 如果最后一级选完后还有下一级,或者刚开始没选,增加一个“请选择”标签\r\n const lastIndex = result.length - 1\r\n const lastVal = activePath.value[lastIndex]\r\n const lastListData = lists.value[lastIndex]\r\n const lastFound = lastListData?.find(item => item[props.valueKey] === lastVal)\r\n const children = lastFound?.[props.childrenKey]\r\n\r\n // 层级限制判断\r\n const reachedLeafLevel = props.leafLevel > 0 && result.length >= props.leafLevel\r\n\r\n if (!reachedLeafLevel && (!lastVal || (lastFound && ((children && children.length > 0) || props.lazy)))) {\r\n if (result.length < lists.value.length || !lastVal) {\r\n result.push('请选择')\r\n }\r\n }\r\n return result\r\n})\r\n\r\n// 触发器显示的文本\r\nconst triggerText = computed(() => {\r\n if (props.multiple) {\r\n if (selectedPaths.value.length === 0) return ''\r\n return `已选 ${selectedPaths.value.length} 项`\r\n }\r\n\r\n const texts: string[] = []\r\n let data = internalOptions.value\r\n\r\n for (const val of (props.modelValue as (string | number)[])) {\r\n const found = data.find(item => item[props.valueKey] === val)\r\n if (found) {\r\n texts.push(found[props.labelKey])\r\n const children = found[props.childrenKey]\r\n if (children) {\r\n data = children\r\n } else {\r\n break\r\n }\r\n }\r\n }\r\n return texts.join(props.textSeparator)\r\n})\r\n\r\n// 方法\r\nconst open = async () => {\r\n if (props.disabled) return\r\n visible.value = true\r\n\r\n // 懒加载初始化\r\n if (props.lazy && internalOptions.value.length === 0) {\r\n isRootLoading.value = true\r\n try {\r\n const children = await new Promise<CascaderOption[]>((resolve, reject) => {\r\n const rootNode = {\r\n [props.labelKey]: 'root',\r\n [props.valueKey]: 'root',\r\n isRoot: true\r\n } as unknown as CascaderOption\r\n props.lazyLoad?.(rootNode, resolve, reject)\r\n })\r\n internalOptions.value = children\r\n } catch (e) {\r\n console.error('Initial lazy load failed', e)\r\n } finally {\r\n isRootLoading.value = false\r\n }\r\n }\r\n\r\n // 定位到最后一级\r\n if (activePath.value.length > 0) {\r\n current.value = Math.min(activePath.value.length, lists.value.length - 1)\r\n } else {\r\n current.value = 0\r\n }\r\n console.log(activePath.value)\r\n console.log(labels.value)\r\n}\r\n\r\nconst close = () => {\r\n visible.value = false\r\n}\r\n\r\nconst onLabelTap = (index: number) => {\r\n // 点击标签,回到该级并清除后续选择\r\n activePath.value = activePath.value.slice(0, index)\r\n current.value = index\r\n scrollIntoViewId.value = `column-${index}`\r\n}\r\n\r\nconst toggleMultiSelection = (node: CascaderOption, path: (string | number)[], isCheck: boolean) => {\r\n const leafPaths = getLeafPaths(node, path.slice(0, -1))\r\n leafPaths.forEach(p => {\r\n const pStr = JSON.stringify(p)\r\n const index = selectedPaths.value.findIndex(sp => JSON.stringify(sp) === pStr)\r\n if (isCheck && index === -1) {\r\n selectedPaths.value.push(p)\r\n } else if (!isCheck && index > -1) {\r\n selectedPaths.value.splice(index, 1)\r\n }\r\n })\r\n}\r\n\r\nconst getLeafPaths = (node: CascaderOption, parentPath: (string | number)[] = []): (string | number)[][] => {\r\n const currentPath = [...parentPath, node[props.valueKey]]\r\n const children = node[props.childrenKey]\r\n if (!children || children.length === 0) {\r\n return [currentPath]\r\n }\r\n return children.flatMap((child: CascaderOption) => getLeafPaths(child, currentPath))\r\n}\r\n\r\nconst isLeafNode = (item: CascaderOption, listIndex: number) => {\r\n const childrenKey = props.childrenKey\r\n return item.leaf === true ||\r\n (!props.lazy && (!item[childrenKey] || item[childrenKey].length === 0)) ||\r\n (props.leafLevel > 0 && listIndex + 1 >= props.leafLevel)\r\n}\r\n\r\nconst onItemTap = async (item: CascaderOption, listIndex: number, fromCheckbox = false) => {\r\n if (props.disabled) return\r\n const val = item[props.valueKey]\r\n const childrenKey = props.childrenKey\r\n const isLeaf = isLeafNode(item, listIndex)\r\n\r\n // 更新当前路径\r\n const newPath = activePath.value.slice(0, listIndex)\r\n newPath.push(val)\r\n activePath.value = newPath\r\n current.value = listIndex\r\n\r\n if (props.multiple && fromCheckbox) {\r\n // 多选模式下点击复选框:父子全选逻辑\r\n const currentlyChecked = isItemSelected(item, listIndex)\r\n toggleMultiSelection(item, newPath, !currentlyChecked)\r\n return\r\n }\r\n\r\n if (isLeaf) {\r\n if (props.multiple) {\r\n // 多选点击叶子:切换选中状态\r\n toggleMultiSelection(item, newPath, !isItemSelected(item, listIndex))\r\n } else {\r\n // 单选:直接提交\r\n emit('update:modelValue', activePath.value)\r\n emit('change', activePath.value)\r\n close()\r\n }\r\n } else {\r\n // 需要加载子节点或者显示下一级\r\n if (props.lazy && !item.leaf && (!item[childrenKey] || item[childrenKey].length === 0)) {\r\n const nodeId = String(val)\r\n loadingNodes.value[nodeId] = true\r\n try {\r\n const children = await new Promise<CascaderOption[]>((resolve, reject) => {\r\n props.lazyLoad?.(item, resolve, reject)\r\n })\r\n item[childrenKey] = children\r\n } catch (e) {\r\n console.error('Lazy load failed', e)\r\n } finally {\r\n loadingNodes.value[nodeId] = false\r\n }\r\n }\r\n // 自动滚动到新的一级\r\n nextTick(() => {\r\n scrollIntoViewId.value = `column-${listIndex + 1}`\r\n })\r\n }\r\n}\r\n\r\n\r\nconst onConfirm = () => {\r\n emit('update:modelValue', selectedPaths.value)\r\n emit('change', selectedPaths.value)\r\n close()\r\n}\r\n\r\nconst clear = () => {\r\n activePath.value = []\r\n selectedPaths.value = []\r\n current.value = 0\r\n emit('update:modelValue', props.multiple ? [] : [])\r\n emit('change', props.multiple ? [] : [])\r\n}\r\n\r\nconst isItemSelected = (item: CascaderOption, listIndex: number) => {\r\n const path = [...activePath.value.slice(0, listIndex), item[props.valueKey]]\r\n if (props.multiple) {\r\n const leaves = getLeafPaths(item, activePath.value.slice(0, listIndex))\r\n return leaves.every(p => {\r\n const pStr = JSON.stringify(p)\r\n return selectedPaths.value.some(sp => JSON.stringify(sp) === pStr)\r\n })\r\n }\r\n return activePath.value[listIndex] === item[props.valueKey]\r\n}\r\n\r\nconst isItemIndeterminate = (item: CascaderOption, listIndex: number) => {\r\n if (!props.multiple) return false\r\n const leaves = getLeafPaths(item, activePath.value.slice(0, listIndex))\r\n if (leaves.length <= 1) return false\r\n\r\n const selectedCount = leaves.filter(p => {\r\n const pStr = JSON.stringify(p)\r\n return selectedPaths.value.some(sp => JSON.stringify(sp) === pStr)\r\n }).length\r\n\r\n return selectedCount > 0 && selectedCount < leaves.length\r\n}\r\n\r\ndefineExpose({ open, close, clear })\r\n</script>\r\n\r\n<template>\r\n <view :class=\"ui.root()\" :style=\"customStyle\">\r\n <RebornSelectTrigger v-if=\"showTrigger\" :text=\"triggerText\" :placeholder=\"placeholder\" :disabled=\"disabled\"\r\n :focus=\"visible\" :color=\"color\" :size=\"size\" @open=\"open\" @clear=\"clear\" />\r\n\r\n <RebornPopup v-model=\"visible\" :title=\"title\" position=\"bottom\" round @close=\"close\" :color=\"color\" rootPortal>\r\n <view :class=\"ui.popup()\">\r\n <!-- 顶部标签页 -->\r\n <view v-if=\"!multiple\" :class=\"ui.tabs()\">\r\n <view v-for=\"(label, index) in labels\" :key=\"index\" :class=\"ui.tab()\" @tap=\"onLabelTap(index)\">\r\n <slot name=\"tabs\" :label=\"label\" :index=\"index\" :current=\"current\">\r\n <RebornBadge variant=\"subtle\" :color=\"index === current ? color : 'neutral'\" :size=\"size\">\r\n <template #default>\r\n {{ label }}\r\n </template>\r\n </RebornBadge>\r\n </slot>\r\n </view>\r\n </view>\r\n\r\n <!-- 列表内容 -->\r\n <view :class=\"ui.list()\" :style=\"{ height: typeof height === 'number' ? height + 'rpx' : height }\">\r\n <view v-if=\"isRootLoading\" :class=\"ui.loading()\">\r\n <RebornLoading type=\"spinner\" :color=\"color\" size=\"64rpx\" />\r\n <text :class=\"ui.loadingText()\">正在加载数据...</text>\r\n </view>\r\n <scroll-view v-else scroll-x class=\"w-full h-full no-scrollbar\" :scroll-into-view=\"scrollIntoViewId\"\r\n scroll-with-animation>\r\n <view class=\"flex flex-row h-full min-w-full\">\r\n <view v-for=\"(listData, listIndex) in lists\" :key=\"listIndex\" :id=\"`column-${listIndex}`\"\r\n :class=\"[\r\n columnWidthClass,\r\n 'shrink-0 h-full border-r border-gray-100 dark:border-slate-800 last:border-r-0'\r\n ]\">\r\n <scroll-view scroll-y class=\"h-full\">\r\n <view v-for=\"(item, itemIndex) in listData\" :key=\"`${listIndex}-${item[valueKey]}`\"\r\n :class=\"[\r\n ui.item(),\r\n activePath[listIndex] === item[valueKey] ? ui.itemActive() : ''\r\n ]\" @tap=\"onItemTap(item, listIndex)\">\r\n <view class=\"flex flex-row items-center\">\r\n <view v-if=\"props.multiple\" class=\"mr-2\"\r\n @tap.stop=\"onItemTap(item, listIndex, true)\">\r\n <RebornCheckbox :modelValue=\"isItemSelected(item, listIndex)\"\r\n :indeterminate=\"isItemIndeterminate(item, listIndex)\" />\r\n </view>\r\n <slot name=\"item\" :item=\"item\" :listIndex=\"listIndex\"\r\n :active=\"activePath[listIndex] === item[valueKey]\">\r\n <RebornText\r\n :custom-class=\"activePath[listIndex] === item[valueKey] ? ui.itemTextActive() : ui.itemText()\"\r\n :ellipsis=\"ellipsis\" :lines=\"lines\">\r\n {{ item[labelKey] }}\r\n </RebornText>\r\n </slot>\r\n </view>\r\n <view v-if=\"loadingNodes[String(item[valueKey])]\"\r\n class=\"ml-auto h-[32rpx] w-[32rpx]\">\r\n <RebornLoading type=\"outline\" :color=\"color\" size=\"32rpx\" />\r\n </view>\r\n <view v-else-if=\"!isLeafNode(item, listIndex)\"\r\n class=\"i-lucide-chevron-right text-[32rpx] text-gray-300 ml-auto\" />\r\n </view>\r\n </scroll-view>\r\n </view>\r\n </view>\r\n </scroll-view>\r\n </view>\r\n\r\n <!-- 底部操作栏 (多选模式) -->\r\n <view v-if=\"props.multiple\" :class=\"ui.footer()\">\r\n <text :class=\"ui.footerText()\">已选 {{ selectedPaths.length }} 项</text>\r\n <view class=\"flex flex-row gap-2\">\r\n <RebornButton size=\"sm\" variant=\"outline\" @tap=\"clear\">清空</RebornButton>\r\n <RebornButton size=\"sm\" @tap=\"onConfirm\">确认</RebornButton>\r\n </view>\r\n </view>\r\n </view>\r\n </RebornPopup>\r\n </view>\r\n</template>\r\n",
|
|
13
|
+
"target": "uniapp"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"fileCount": 2,
|
|
17
|
+
"contentHash": "db9aa5e03276048db0f0a837d0f33bd78cfcab0e"
|
|
18
|
+
}
|
|
@@ -21,20 +21,20 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "index.ts",
|
|
24
|
-
"content": "export { default as RebornCheckbox } from
|
|
24
|
+
"content": "export { default as RebornCheckbox } from './RebornCheckbox.vue'\r\n",
|
|
25
25
|
"target": "uniapp"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"path": "reborn-checkbox.config.ts",
|
|
29
|
-
"content": "const size = [
|
|
29
|
+
"content": "const size = ['sm', 'md', 'lg'] as const\r\nconst color = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] as const\r\n\r\nexport { color as checkboxColors, size as checkboxSizes }\r\n\r\nexport default {\r\n slots: {\r\n wrapper: 'group inline-flex items-center gap-3 cursor-pointer select-none',\r\n input: 'sr-only',\r\n control:\r\n 'flex items-center justify-center rounded-md border border-gray-4 bg-white text-white transition-colors ring-1 ring-transparent group-[.is-disabled]:cursor-not-allowed group-[.is-disabled]:bg-gray-2 group-[.is-disabled]:border-gray-3',\r\n icon: 'size-4 opacity-0 scale-75 transition-all group-[.is-checked]:opacity-100 group-[.is-checked]:scale-100',\r\n label: 'text-gray-8 dark:text-gray-2',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n control: 'size-4',\r\n label: 'text-[length:var(--text-size-24)]',\r\n },\r\n md: {\r\n control: 'size-5',\r\n label: 'text-[length:var(--text-size-26)]',\r\n },\r\n lg: {\r\n control: 'size-6',\r\n label: 'text-[length:var(--text-size-28)]',\r\n },\r\n },\r\n color: {\r\n primary: {\r\n control: 'group-[.is-checked]:bg-primary group-[.is-checked]:border-primary',\r\n icon: 'text-white',\r\n },\r\n secondary: {\r\n control: 'group-[.is-checked]:bg-secondary group-[.is-checked]:border-secondary',\r\n icon: 'text-white',\r\n },\r\n success: {\r\n control: 'group-[.is-checked]:bg-success group-[.is-checked]:border-success',\r\n icon: 'text-white',\r\n },\r\n info: {\r\n control: 'group-[.is-checked]:bg-info group-[.is-checked]:border-info',\r\n icon: 'text-white',\r\n },\r\n warning: {\r\n control: 'group-[.is-checked]:bg-warning group-[.is-checked]:border-warning',\r\n icon: 'text-white',\r\n },\r\n error: {\r\n control: 'group-[.is-checked]:bg-error group-[.is-checked]:border-error',\r\n icon: 'text-white',\r\n },\r\n neutral: {\r\n control: 'group-[.is-checked]:bg-neutral group-[.is-checked]:border-neutral',\r\n icon: 'text-white',\r\n },\r\n },\r\n error: {\r\n true: {\r\n control: 'border-error',\r\n label: 'text-error',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md' as (typeof size)[number],\r\n color: 'primary' as (typeof color)[number],\r\n },\r\n}\r\n",
|
|
30
30
|
"target": "uniapp"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"path": "RebornCheckbox.vue",
|
|
34
|
-
"content": "<script setup lang=\"ts\">\r\nimport { computed, ref, useAttrs, watch } from
|
|
34
|
+
"content": "<script setup lang=\"ts\">\r\nimport type { ClassValue } from 'clsx'\r\nimport type { checkboxColors, checkboxSizes } from './reborn-checkbox.config'\r\nimport { computed, ref, useAttrs, watch } from 'vue'\r\nimport { useFormInject } from '@/composables/useFieldGroup'\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport theme from './reborn-checkbox.config'\r\n\r\ndefineOptions({\r\n inheritAttrs: false,\r\n})\r\n\r\nconst props = withDefaults(defineProps<CheckboxProps>(), {\r\n disabled: false,\r\n size: 'md',\r\n color: 'primary',\r\n})\r\n\r\nconst emit = defineEmits<{\r\n (e: 'update:modelValue', value: boolean | CheckboxValue[]): void\r\n}>()\r\n\r\nconst b = tv(theme)\r\n\r\nexport type CheckboxValue = string | number\r\n\r\nexport interface CheckboxProps {\r\n modelValue?: boolean | CheckboxValue[]\r\n defaultValue?: boolean | CheckboxValue[]\r\n value?: CheckboxValue\r\n label?: string\r\n disabled?: boolean\r\n size?: typeof checkboxSizes[number]\r\n color?: typeof checkboxColors[number]\r\n customClass?: any\r\n ui?: Partial<{\r\n wrapper: ClassValue\r\n input: ClassValue\r\n control: ClassValue\r\n icon: ClassValue\r\n label: ClassValue\r\n }>\r\n}\r\n\r\nconst { size: fieldGroupSize, disabled: fieldGroupDisabled, isError, validate } = useFormInject(props)\r\n\r\nconst localValue = ref<boolean | CheckboxValue[]>(props.defaultValue ?? false)\r\nconst currentValue = computed(() => (props.modelValue !== undefined ? props.modelValue : localValue.value))\r\nconst optionValue = computed<CheckboxValue>(() => props.value ?? props.label ?? '')\r\n\r\nconst isChecked = computed(() => {\r\n if (Array.isArray(currentValue.value)) {\r\n return currentValue.value.includes(optionValue.value)\r\n }\r\n\r\n return Boolean(currentValue.value)\r\n})\r\n\r\nconst uiOverrides = computed(() => props.ui || {})\r\n\r\nconst ui = computed(() => {\r\n const styles = b({\r\n size: props.size || fieldGroupSize.value,\r\n color: props.color,\r\n error: isError.value,\r\n })\r\n\r\n return {\r\n wrapper: (opts?: { class?: any }) => styles.wrapper({ class: cn(uiOverrides.value.wrapper, opts?.class) }),\r\n input: (opts?: { class?: any }) => styles.input({ class: cn(opts?.class, uiOverrides.value.input) }),\r\n control: (opts?: { class?: any }) => styles.control({ class: cn(opts?.class, uiOverrides.value.control) }),\r\n icon: (opts?: { class?: any }) => styles.icon({ class: cn(opts?.class, uiOverrides.value.icon) }),\r\n label: (opts?: { class?: any }) => styles.label({ class: cn(opts?.class, uiOverrides.value.label) }),\r\n }\r\n})\r\n\r\nfunction updateValue(nextValue: boolean | CheckboxValue[]) {\r\n if (props.modelValue === undefined) {\r\n localValue.value = nextValue\r\n }\r\n emit('update:modelValue', nextValue)\r\n if (validate) { validate('change') }\r\n}\r\n\r\nfunction toggle() {\r\n if (props.disabled) { return }\r\n\r\n if (Array.isArray(currentValue.value)) {\r\n const next = new Set(currentValue.value)\r\n if (next.has(optionValue.value)) {\r\n next.delete(optionValue.value)\r\n }\r\n else {\r\n next.add(optionValue.value)\r\n }\r\n updateValue(Array.from(next))\r\n }\r\n else {\r\n updateValue(!isChecked.value)\r\n }\r\n}\r\n\r\nwatch(\r\n () => props.modelValue,\r\n (value) => {\r\n if (value !== undefined) {\r\n localValue.value = value\r\n }\r\n },\r\n)\r\n</script>\r\n\r\n<template>\r\n <view :class=\"cn(ui.wrapper({ class: customClass }), isChecked && 'is-checked', fieldGroupDisabled && `\r\n is-disabled\r\n `)\" :data-disabled=\"fieldGroupDisabled\" hover-class=\"none\" style=\"-webkit-tap-highlight-color: transparent;\"\r\n @tap=\"toggle\">\r\n <view :class=\"ui.control()\">\r\n <slot name=\"icon\" :checked=\"isChecked\">\r\n <view class=\"i-lucide-check\" :class=\"ui.icon()\" />\r\n </slot>\r\n </view>\r\n\r\n <view v-if=\"props.label || $slots.default\" :class=\"ui.label()\">\r\n <slot>{{ props.label }}</slot>\r\n </view>\r\n </view>\r\n</template>\r\n",
|
|
35
35
|
"target": "uniapp"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
38
|
"fileCount": 6,
|
|
39
|
-
"contentHash": "
|
|
39
|
+
"contentHash": "9c1a060abc550ef10000887f38165212bd0978a9"
|
|
40
40
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
"files": [
|
|
5
5
|
{
|
|
6
6
|
"path": "index.ts",
|
|
7
|
-
"content": "export { default as RebornChip } from './RebornChip.vue'\r\n"
|
|
7
|
+
"content": "export { default as RebornChip } from './RebornChip.vue'\r\n",
|
|
8
|
+
"target": "web"
|
|
8
9
|
},
|
|
9
10
|
{
|
|
10
11
|
"path": "reborn-chip.config.ts",
|
|
@@ -16,17 +17,22 @@
|
|
|
16
17
|
"content": "<script setup lang=\"ts\">\r\nimport { computed } from 'vue'\r\nimport theme from './reborn-chip.config'\r\nimport { tv } from '~/lib/tv'\r\n\r\nconst b = tv(theme)\r\n\r\ntype Theme = typeof theme\r\n\r\ntype ChipColor = keyof Theme['variants']['color']\r\ntype ChipSize = keyof Theme['variants']['size']\r\ntype ChipPosition = keyof Theme['variants']['position']\r\n\r\nexport interface ChipProps {\r\n color?: ChipColor | (string & {})\r\n size?: ChipSize | (string & {})\r\n text?: string | number\r\n position?: ChipPosition | (string & {})\r\n show?: boolean\r\n inset?: boolean\r\n standalone?: boolean\r\n class?: any\r\n ui?: any\r\n}\r\n\r\nconst props = withDefaults(defineProps<ChipProps>(), {\r\n color: 'primary',\r\n size: 'md',\r\n position: 'top-right',\r\n show: true,\r\n inset: false,\r\n standalone: false\r\n})\r\n\r\nconst emit = defineEmits<{\r\n 'update:show': [value: boolean]\r\n}>()\r\n\r\nconst show = computed({\r\n get: () => props.show,\r\n set: (value) => emit('update:show', value)\r\n})\r\n\r\nconst ui = computed(() => b({\r\n color: props.color as ChipColor,\r\n size: props.size as ChipSize,\r\n position: props.position as ChipPosition,\r\n inset: props.inset,\r\n standalone: props.standalone\r\n}))\r\n</script>\r\n\r\n<template>\r\n <span :class=\"ui.root({ class: props.class })\">\r\n <slot />\r\n <div v-if=\"show\" :class=\"ui.base()\">\r\n <div v-if=\"props.text\" :class=\"ui.label()\">\r\n {{ props.text }}\r\n </div>\r\n </div>\r\n </span>\r\n</template>\r\n",
|
|
17
18
|
"target": "web"
|
|
18
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"path": "index.ts",
|
|
22
|
+
"content": "export { default as RebornChip } from './RebornChip.vue'\r\n",
|
|
23
|
+
"target": "uniapp"
|
|
24
|
+
},
|
|
19
25
|
{
|
|
20
26
|
"path": "reborn-chip.config.ts",
|
|
21
|
-
"content": "const size = ['3xs', '2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const\r\nconst color = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] as const\r\nconst position = ['top-right', 'bottom-right', 'top-left', 'bottom-left'] as const\r\n\r\nexport {
|
|
27
|
+
"content": "const size = ['3xs', '2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const\r\nconst color = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] as const\r\nconst position = ['top-right', 'bottom-right', 'top-left', 'bottom-left'] as const\r\n\r\nexport { color as chipColors, position as chipPositions, size as chipSizes }\r\n\r\nexport default {\r\n slots: {\r\n root: 'relative inline-flex items-center justify-center shrink-0 h-fit leading-none',\r\n base: 'absolute rounded-full flex items-center justify-center font-medium whitespace-nowrap ring-1',\r\n label: 'text-white',\r\n },\r\n variants: {\r\n color: {\r\n primary: { base: 'bg-primary ring-primary' },\r\n secondary: { base: 'bg-secondary ring-secondary' },\r\n success: { base: 'bg-success ring-success' },\r\n info: { base: 'bg-info ring-info' },\r\n warning: { base: 'bg-warning ring-warning' },\r\n error: { base: 'bg-error ring-error' },\r\n neutral: { base: 'bg-neutral ring-neutral' },\r\n },\r\n size: {\r\n '3xs': { base: 'h-[8rpx] min-w-[8rpx]', label: 'text-[length:8rpx]' },\r\n '2xs': { base: 'h-[10rpx] min-w-[10rpx]', label: 'text-[length:10rpx]' },\r\n 'xs': { base: 'h-[12rpx] min-w-[12rpx]', label: 'text-[length:12rpx]' },\r\n 'sm': { base: 'h-[14rpx] min-w-[14rpx]', label: 'text-[length:14rpx]' },\r\n 'md': { base: 'h-[16rpx] min-w-[16rpx]', label: 'text-[length:16rpx]' },\r\n 'lg': { base: 'h-[18rpx] min-w-[18rpx]', label: 'text-[length:18rpx]' },\r\n 'xl': { base: 'h-[20rpx] min-w-[20rpx]', label: 'text-[length:20rpx]' },\r\n '2xl': { base: 'h-[22rpx] min-w-[22rpx]', label: 'text-[length:22rpx]' },\r\n '3xl': { base: 'h-[24rpx] min-w-[24rpx]', label: 'text-[length:24rpx]' },\r\n },\r\n position: {\r\n 'top-right': { base: 'top-0 right-0' },\r\n 'bottom-right': { base: 'bottom-0 right-0' },\r\n 'top-left': { base: 'top-0 left-0' },\r\n 'bottom-left': { base: 'bottom-0 left-0' },\r\n },\r\n inset: {\r\n true: { base: '' },\r\n false: { base: '' },\r\n },\r\n standalone: {\r\n true: { base: 'absolute' },\r\n false: { base: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n {\r\n inset: false,\r\n position: 'top-right' as const,\r\n class: { base: '-translate-y-1/2 translate-x-1/2 transform' },\r\n },\r\n {\r\n inset: false,\r\n position: 'bottom-right' as const,\r\n class: { base: 'translate-y-1/2 translate-x-1/2 transform' },\r\n },\r\n {\r\n inset: false,\r\n position: 'top-left' as const,\r\n class: { base: '-translate-y-1/2 -translate-x-1/2 transform' },\r\n },\r\n {\r\n inset: false,\r\n position: 'bottom-left' as const,\r\n class: { base: 'translate-y-1/2 -translate-x-1/2 transform' },\r\n },\r\n ],\r\n defaultVariants: {\r\n size: 'md' as const,\r\n color: 'primary' as const,\r\n position: 'top-right' as const,\r\n inset: false,\r\n standalone: false,\r\n },\r\n}\r\n",
|
|
22
28
|
"target": "uniapp"
|
|
23
29
|
},
|
|
24
30
|
{
|
|
25
31
|
"path": "RebornChip.vue",
|
|
26
|
-
"content": "<script setup lang=\"ts\">\r\nimport {
|
|
32
|
+
"content": "<script setup lang=\"ts\">\r\nimport type { chipColors, chipPositions, chipSizes } from './reborn-chip.config'\r\nimport { computed } from 'vue'\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport theme from './reborn-chip.config'\r\n\r\ndefineOptions({\r\n name: 'RebornChip',\r\n inheritAttrs: false,\r\n})\r\n\r\nconst props = withDefaults(defineProps<ChipProps>(), {\r\n color: 'primary',\r\n size: 'md',\r\n position: 'top-right',\r\n show: true,\r\n inset: false,\r\n standalone: false,\r\n})\r\n\r\nconst emit = defineEmits(['update:show'])\r\n\r\nconst b = tv(theme)\r\n\r\ntype Theme = typeof theme\r\ntype ChipColor = keyof Theme['variants']['color']\r\ntype ChipSize = keyof Theme['variants']['size']\r\ntype ChipPosition = keyof Theme['variants']['position']\r\n\r\nexport interface ChipProps {\r\n color?: typeof chipColors[number]\r\n size?: typeof chipSizes[number]\r\n text?: string | number\r\n position?: typeof chipPositions[number]\r\n show?: boolean\r\n inset?: boolean\r\n standalone?: boolean\r\n customClass?: any\r\n}\r\n\r\nconst show = computed({\r\n get: () => props.show,\r\n set: (value: boolean) => emit('update:show', value),\r\n})\r\n\r\nconst ui = computed(() => b({\r\n color: props.color as ChipColor,\r\n size: props.size as ChipSize,\r\n position: props.position as ChipPosition,\r\n inset: props.inset,\r\n standalone: props.standalone,\r\n}))\r\n</script>\r\n\r\n<template>\r\n <view :class=\"ui.root({ class: cn(props.customClass) })\">\r\n <slot />\r\n <view v-if=\"show\" :class=\"ui.base()\">\r\n <text v-if=\"props.text\" :class=\"ui.label()\">{{ props.text }}</text>\r\n </view>\r\n </view>\r\n</template>\r\n\r\n<style scoped></style>\r\n",
|
|
27
33
|
"target": "uniapp"
|
|
28
34
|
}
|
|
29
35
|
],
|
|
30
|
-
"fileCount":
|
|
31
|
-
"contentHash": "
|
|
36
|
+
"fileCount": 6,
|
|
37
|
+
"contentHash": "4859720f31e7bd743cd231c53edc42ed8743e5fa"
|
|
32
38
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
"files": [
|
|
5
5
|
{
|
|
6
6
|
"path": "index.ts",
|
|
7
|
-
"content": "export { default as RebornCollapse } from './RebornCollapse.vue'\r\nexport { default as rebornCollapseConfig } from './reborn-collapse.config'\r\n"
|
|
7
|
+
"content": "export { default as RebornCollapse } from './RebornCollapse.vue'\r\nexport { default as rebornCollapseConfig } from './reborn-collapse.config'\r\n",
|
|
8
|
+
"target": "web"
|
|
8
9
|
},
|
|
9
10
|
{
|
|
10
11
|
"path": "reborn-collapse.config.ts",
|
|
@@ -16,17 +17,22 @@
|
|
|
16
17
|
"content": "<script lang=\"ts\">\r\nexport interface RebornCollapseProps {\r\n customClass?: any\r\n ui?: {\r\n root?: any\r\n trigger?: any\r\n content?: any\r\n }\r\n}\r\n</script>\r\n<script setup lang=\"ts\">\r\nimport { computed, ref, watch, onMounted, nextTick } from 'vue'\r\nimport { cn } from '@/lib/utils'\r\nimport { tv } from '@/lib/tv'\r\nimport theme from './reborn-collapse.config'\r\n\r\nconst props = withDefaults(defineProps<RebornCollapseProps>(), {\r\n customClass: '',\r\n ui: () => ({})\r\n})\r\n\r\nconst collapse = defineModel(\"modelValue\", {\r\n type: Boolean,\r\n default: false\r\n})\r\n\r\nconst b = tv(theme)\r\nconst ui = computed(() => b())\r\nconst overrides = computed(() => props.ui || {})\r\n\r\nconst contentRef = ref<HTMLElement | null>(null)\r\nconst height = ref(0)\r\nconst isOpened = ref(false)\r\n\r\nfunction updateHeight() {\r\n if (contentRef.value) {\r\n height.value = contentRef.value.scrollHeight\r\n }\r\n}\r\n\r\nfunction show() {\r\n isOpened.value = true\r\n // Wait for display:block (if inherent) or just ensuring Ref is ready\r\n nextTick(() => {\r\n updateHeight()\r\n })\r\n}\r\n\r\nfunction hide() {\r\n isOpened.value = false\r\n height.value = 0\r\n}\r\n\r\nfunction toggle() {\r\n if (isOpened.value) {\r\n hide()\r\n } else {\r\n show()\r\n }\r\n}\r\n\r\nwatch(\r\n () => collapse.value,\r\n (val) => {\r\n if (val) show()\r\n else hide()\r\n },\r\n { immediate: true }\r\n)\r\n\r\n// Optional: ResizeObserver to update height if content changes while open\r\nonMounted(() => {\r\n if (contentRef.value) {\r\n const resizeObserver = new ResizeObserver(() => {\r\n if (isOpened.value) {\r\n updateHeight()\r\n }\r\n })\r\n resizeObserver.observe(contentRef.value)\r\n }\r\n})\r\n\r\ndefineExpose({\r\n show,\r\n hide,\r\n toggle,\r\n resize: updateHeight\r\n})\r\n</script>\r\n\r\n<template>\r\n <div :class=\"ui.root({ class: cn(props.customClass, overrides?.root) })\">\r\n <div @click=\"toggle\">\r\n <slot :open=\"isOpened\" />\r\n </div>\r\n <div :class=\"ui.trigger({ class: overrides?.trigger })\" :style=\"{ height: isOpened ? height + 'px' : '0px' }\">\r\n <div ref=\"contentRef\" class=\"reborn-collapse__content\" :class=\"ui.content({ class: overrides?.content })\">\r\n <slot name=\"content\"></slot>\r\n </div>\r\n </div>\r\n </div>\r\n</template>\r\n",
|
|
17
18
|
"target": "web"
|
|
18
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"path": "index.ts",
|
|
22
|
+
"content": "export { default as RebornCollapse } from './RebornCollapse.vue'\r\n",
|
|
23
|
+
"target": "uniapp"
|
|
24
|
+
},
|
|
19
25
|
{
|
|
20
26
|
"path": "reborn-collapse.config.ts",
|
|
21
|
-
"content": "export default {\r\n
|
|
27
|
+
"content": "export default {\r\n slots: {\r\n root: '',\r\n trigger: 'relative overflow-hidden transition-[height] duration-300 ease-in-out',\r\n content: 'absolute top-0 left-0 w-full',\r\n },\r\n}\r\n",
|
|
22
28
|
"target": "uniapp"
|
|
23
29
|
},
|
|
24
30
|
{
|
|
25
31
|
"path": "RebornCollapse.vue",
|
|
26
|
-
"content": "<script lang=\"ts\">\r\
|
|
32
|
+
"content": "<script lang=\"ts\">\r\n</script>\r\n\r\n<script setup lang=\"ts\">\r\nimport { computed, getCurrentInstance, ref, watch } from 'vue'\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport theme from './reborn-collapse.config'\r\n\r\nexport interface RebornCollapseProps {\r\n customClass?: any\r\n ui?: {\r\n root?: any\r\n trigger?: any\r\n content?: any\r\n }\r\n}\r\n\r\nconst props = withDefaults(defineProps<RebornCollapseProps>(), {\r\n customClass: '',\r\n ui: () => ({}),\r\n})\r\n\r\nconst collapse = defineModel('modelValue', {\r\n type: Boolean,\r\n default: false,\r\n})\r\n\r\n// 获取组件实例\r\nconst { proxy } = getCurrentInstance()!\r\n\r\nconst b = tv(theme)\r\nconst ui = computed(() => b())\r\nconst overrides = computed(() => props.ui || {})\r\n\r\n// 折叠展开状态\r\nconst isOpened = ref(false)\r\n// 内容高度\r\nconst height = ref(0)\r\n\r\n/**\r\n * 显示折叠内容\r\n */\r\nfunction show() {\r\n isOpened.value = true\r\n updateHeight()\r\n}\r\n\r\n/**\r\n * 隐藏折叠内容\r\n */\r\nfunction hide() {\r\n isOpened.value = false\r\n height.value = 0\r\n}\r\n\r\n/**\r\n * 更新高度\r\n */\r\nfunction updateHeight() {\r\n // 使用 nextTick 确保 DOM 更新后再获取\r\n // Use setTimeout as simple generic nextTick for uni-app sometimes nextTick is not enough for layout\r\n setTimeout(() => {\r\n const query = uni.createSelectorQuery().in(proxy)\r\n query.select('.reborn-collapse__content')\r\n .boundingClientRect((data) => {\r\n const node = data as UniApp.NodeInfo\r\n if (node && node.height !== undefined) {\r\n height.value = node.height\r\n }\r\n })\r\n .exec()\r\n }, 50)\r\n}\r\n\r\n/**\r\n * 切换折叠状态\r\n */\r\nfunction toggle() {\r\n if (isOpened.value) {\r\n hide()\r\n } else {\r\n show()\r\n }\r\n}\r\n\r\n// 监听折叠状态变化\r\nwatch(\r\n () => collapse.value,\r\n (val: boolean) => {\r\n if (val) {\r\n show()\r\n } else {\r\n hide()\r\n }\r\n },\r\n { immediate: true },\r\n)\r\n\r\ndefineExpose({\r\n show,\r\n hide,\r\n toggle,\r\n resize: updateHeight,\r\n})\r\n</script>\r\n\r\n<template>\r\n <view :class=\"ui.root({ class: cn(props.customClass, overrides?.root) })\">\r\n <view @click=\"toggle\">\r\n <slot :open=\"isOpened\" />\r\n </view>\r\n <view :class=\"ui.trigger({ class: overrides?.trigger })\" :style=\"{ height: isOpened ? `${height}px` : '0px' }\">\r\n <view class=\"reborn-collapse__content\" :class=\"ui.content({ class: overrides?.content })\">\r\n <slot name=\"content\" />\r\n </view>\r\n </view>\r\n </view>\r\n</template>\r\n",
|
|
27
33
|
"target": "uniapp"
|
|
28
34
|
}
|
|
29
35
|
],
|
|
30
|
-
"fileCount":
|
|
31
|
-
"contentHash": "
|
|
36
|
+
"fileCount": 6,
|
|
37
|
+
"contentHash": "9706cedaf1f59fb2790ab36aa7169bd16450b6ba"
|
|
32
38
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-color-picker",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"@uiw/color-convert"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "reborn-color-picker-panel.config.ts",
|
|
9
|
+
"content": "export default {\r\n slots: {\r\n root: \"w-64 space-y-4 p-3 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 shadow-sm\",\r\n saturation: \"relative w-full aspect-video rounded-lg cursor-crosshair overflow-hidden ring-1 ring-inset ring-black/5\",\r\n saturationCursor: \"absolute w-4 h-4 -ml-2 -mt-2 rounded-full border-2 border-white shadow-sm pointer-events-none\",\r\n controls: \"flex gap-3 items-center\",\r\n preview: \"size-10 rounded-lg shadow-inner ring-1 ring-inset ring-black/5 shrink-0\",\r\n sliders: \"flex-1 space-y-2\",\r\n hueSlider: \"relative h-3 w-full rounded-full cursor-pointer ring-1 ring-inset ring-black/5\",\r\n hueCursor: \"absolute h-4 w-4 -mt-0.5 -ml-2 bg-white rounded-full shadow-md border border-gray-100 pointer-events-none\",\r\n alphaSlider: \"relative h-3 w-full rounded-full cursor-pointer ring-1 ring-inset ring-black/5\",\r\n alphaCursor: \"absolute h-4 w-4 -mt-0.5 -ml-2 bg-white rounded-full shadow-md border border-gray-100 pointer-events-none\",\r\n inputs: \"space-y-2\",\r\n formatToggles: \"flex gap-1\",\r\n input: \"w-full text-xs font-mono\",\r\n presets: \"pt-3 border-t border-gray-100 dark:border-gray-800\",\r\n presetTitle: \"text-[10px] text-gray-400 font-bold mb-2 uppercase tracking-tight\",\r\n presetGrid: \"grid grid-cols-10 gap-1.5\",\r\n presetSwatch: \"aspect-square ring-1 ring-black/5 hover:scale-110 transition-transform p-0!\"\r\n }\r\n}\r\n",
|
|
10
|
+
"target": "web"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"path": "reborn-color-picker.config.ts",
|
|
14
|
+
"content": "const size = [\"sm\", \"md\", \"lg\"] as const;\r\n\r\nexport { size as colorPickerSizes }\r\nexport default {\r\n slots: {\r\n root: \"ring ring-1 ring-gray-5 p-1 rounded-md cursor-pointer select-none hover:scale-105 transition-all\",\r\n base: \"rounded-md ring ring-1 ring-gray-5 w-full h-full flex justify-center items-center\",\r\n icon: \"text-white transition-transform duration-200\",\r\n },\r\n variants: {\r\n disabled: {\r\n true: {\r\n root: \"cursor-not-allowed opacity-50\",\r\n },\r\n },\r\n open: {\r\n true: {\r\n icon: \"rotate-180\",\r\n },\r\n },\r\n size: {\r\n sm: {\r\n root: \"h-[var(--button-sm-height)] w-[var(--button-sm-height)]\",\r\n },\r\n md: {\r\n root: \"h-[var(--button-base-height)] w-[var(--button-base-height)]\",\r\n },\r\n lg: {\r\n root: \"h-[var(--button-lg-height)] w-[var(--button-lg-height)]\",\r\n },\r\n },\r\n },\r\n}\r\n",
|
|
15
|
+
"target": "web"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "RebornColorPicker.vue",
|
|
19
|
+
"content": "<script setup lang=\"ts\">\r\nimport { computed } from 'vue'\r\nimport theme from './reborn-color-picker.config'\r\nimport { tv } from '~/lib/tv'\r\nimport { cn } from '~/lib/utils'\r\nimport RebornButton from '../reborn-button/RebornButton.vue'\r\nimport RebornColorPickerPanel from './RebornColorPickerPanel.vue'\r\n\r\ninterface Props {\r\n modelValue?: string\r\n disabled?: boolean\r\n size?: 'xs' | 'sm' | 'md' | 'lg',\r\n ui?: {\r\n root?: string\r\n base?: string\r\n icon?: string\r\n }\r\n}\r\n\r\nconst open = ref(false)\r\n\r\nconst props = withDefaults(defineProps<Props>(), {\r\n modelValue: '#000000',\r\n disabled: false,\r\n size: 'md',\r\n})\r\n\r\nconst emit = defineEmits<{\r\n (e: 'update:modelValue', value: string): void\r\n}>()\r\n\r\n// --- 颜色值计算属性 ---\r\nconst colorValue = computed({\r\n get: () => props.modelValue!,\r\n set: (val: string) => emit('update:modelValue', val),\r\n})\r\n\r\nconst b = tv(theme)\r\nconst uiOverrides = computed(() => props.ui || {})\r\n\r\nconst ui = computed(() => {\r\n const styles = b({\r\n size: props.size,\r\n disabled: props.disabled,\r\n open: open.value,\r\n })\r\n\r\n return {\r\n root: (opts?: { class?: any }) => styles.root({ class: cn(opts?.class, uiOverrides.value.root) }),\r\n base: (opts?: { class?: any }) => styles.base({ class: cn(opts?.class, uiOverrides.value.base) }),\r\n icon: (opts?: { class?: any }) => styles.icon({ class: cn(opts?.class, uiOverrides.value.icon) }),\r\n }\r\n})\r\n</script>\r\n\r\n<template>\r\n <RebornPopover v-model:open=\"open\" :disabled=\"disabled\" arrow>\r\n <!-- 触发器插槽 -->\r\n <slot>\r\n <div :class=\"ui.root()\">\r\n <div :class=\"ui.base()\" :style=\"{ backgroundColor: colorValue }\">\r\n <Icon name=\"lucide:chevron-down\" :class=\"ui.icon()\" />\r\n </div>\r\n </div>\r\n </slot>\r\n\r\n <!-- 颜色选择面板 -->\r\n <template #content>\r\n <RebornColorPickerPanel v-model=\"colorValue\" />\r\n </template>\r\n </RebornPopover>\r\n</template>\r\n",
|
|
20
|
+
"target": "web"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "RebornColorPickerPanel.vue",
|
|
24
|
+
"content": "<script setup lang=\"ts\">\r\nimport { computed, ref, watch } from \"vue\";\r\nimport {\r\n hexToHsva,\r\n hsvaToHex,\r\n hsvaToRgba,\r\n rgbaToHsva,\r\n} from \"@uiw/color-convert\";\r\nimport type { HsvaColor } from \"@uiw/color-convert\";\r\nimport { tv } from \"~/lib/tv\";\r\nimport { cn } from \"~/lib/utils\";\r\nimport theme from \"./reborn-color-picker-panel.config\";\r\nimport RebornButton from \"../reborn-button/RebornButton.vue\";\r\nimport RebornInput from \"../reborn-input/RebornInput.vue\";\r\n\r\ninterface Props {\r\n modelValue?: string;\r\n class?: any;\r\n ui?: {\r\n root?: string\r\n saturation?: string\r\n saturationCursor?: string\r\n controls?: string\r\n preview?: string\r\n sliders?: string\r\n hueSlider?: string\r\n hueCursor?: string\r\n alphaSlider?: string\r\n alphaCursor?: string\r\n inputs?: string\r\n formatToggles?: string\r\n input?: string\r\n presets?: string\r\n presetTitle?: string\r\n presetGrid?: string\r\n presetSwatch?: string\r\n };\r\n}\r\n\r\nconst props = withDefaults(defineProps<Props>(), {\r\n modelValue: \"#000000\",\r\n});\r\n\r\nconst emit = defineEmits<{\r\n (e: \"update:modelValue\", value: string): void;\r\n}>();\r\n\r\n// --- 样式配置 ---\r\nconst b = tv(theme);\r\nconst uiOverrides = computed(() => props.ui || {});\r\n\r\nconst ui = computed(() => {\r\n const styles = b() as any;\r\n\r\n return {\r\n root: (opts?: { class?: any }) => styles.root({ class: cn(opts?.class, uiOverrides.value.root) }),\r\n saturation: (opts?: { class?: any }) => styles.saturation({ class: cn(opts?.class, uiOverrides.value.saturation) }),\r\n saturationCursor: (opts?: { class?: any }) => styles.saturationCursor({ class: cn(opts?.class, uiOverrides.value.saturationCursor) }),\r\n controls: (opts?: { class?: any }) => styles.controls({ class: cn(opts?.class, uiOverrides.value.controls) }),\r\n preview: (opts?: { class?: any }) => styles.preview({ class: cn(opts?.class, uiOverrides.value.preview) }),\r\n sliders: (opts?: { class?: any }) => styles.sliders({ class: cn(opts?.class, uiOverrides.value.sliders) }),\r\n hueSlider: (opts?: { class?: any }) => styles.hueSlider({ class: cn(opts?.class, uiOverrides.value.hueSlider) }),\r\n hueCursor: (opts?: { class?: any }) => styles.hueCursor({ class: cn(opts?.class, uiOverrides.value.hueCursor) }),\r\n alphaSlider: (opts?: { class?: any }) => styles.alphaSlider({ class: cn(opts?.class, uiOverrides.value.alphaSlider) }),\r\n alphaCursor: (opts?: { class?: any }) => styles.alphaCursor({ class: cn(opts?.class, uiOverrides.value.alphaCursor) }),\r\n inputs: (opts?: { class?: any }) => styles.inputs({ class: cn(opts?.class, uiOverrides.value.inputs) }),\r\n formatToggles: (opts?: { class?: any }) => styles.formatToggles({ class: cn(opts?.class, uiOverrides.value.formatToggles) }),\r\n input: (opts?: { class?: any }) => styles.input({ class: cn(opts?.class, uiOverrides.value.input) }),\r\n presets: (opts?: { class?: any }) => styles.presets({ class: cn(opts?.class, uiOverrides.value.presets) }),\r\n presetTitle: (opts?: { class?: any }) => styles.presetTitle({ class: cn(opts?.class, uiOverrides.value.presetTitle) }),\r\n presetGrid: (opts?: { class?: any }) => styles.presetGrid({ class: cn(opts?.class, uiOverrides.value.presetGrid) }),\r\n presetSwatch: (opts?: { class?: any }) => styles.presetSwatch({ class: cn(opts?.class, uiOverrides.value.presetSwatch) }),\r\n }\r\n});\r\n\r\n// --- 状态 ---\r\nconst colorHsv = ref<HsvaColor>(hexToHsva(props.modelValue || \"#000000\"));\r\nconst format = ref<\"hex\" | \"rgb\" | \"rgba\">(\"hex\");\r\n\r\n// --- 来自 base.css 的预设颜色 ---\r\nconst presets = [\r\n // 红色系\r\n \"#ffebee\", \"#ffe0e4\", \"#ffb1bc\", \"#ff8b9b\", \"#ff6675\", \"#ff3d58\", \"#d92946\", \"#b31938\", \"#8c0d2a\", \"#660821\",\r\n // 橙色系\r\n \"#fff7df\", \"#ffe9c9\", \"#ffd5a0\", \"#ffc370\", \"#ffb03b\", \"#ff9711\", \"#bf7c2a\", \"#995c1a\", \"#733d0e\", \"#522601\",\r\n // 绿色系\r\n \"#f1faf8\", \"#e7f6f3\", \"#a2dfcf\", \"#5fcfad\", \"#3ac29e\", \"#16ae88\", \"#0b876c\", \"#036150\", \"#003b32\", \"#001412\",\r\n // 蓝色系\r\n \"#ecf9ff\", \"#dff4ff\", \"#9ed6f5\", \"#61ccff\", \"#35b6f2\", \"#0d99e5\", \"#0277bf\", \"#005999\", \"#003f73\", \"#00284d\",\r\n // 灰色系\r\n \"#ffffff\", \"#f5f5f5\", \"#eeeeee\", \"#cccccc\", \"#aaaaaa\", \"#999999\", \"#666666\", \"#333333\",\r\n];\r\n\r\n// --- 计算属性 ---\r\nconst hexValue = computed(() => hsvaToHex(colorHsv.value));\r\nconst rgbaValue = computed(() => hsvaToRgba(colorHsv.value));\r\n\r\nconst displayValue = computed({\r\n get: () => {\r\n if (format.value === \"hex\") return hexValue.value.toUpperCase();\r\n if (format.value === \"rgb\") return `rgb(${rgbaValue.value.r}, ${rgbaValue.value.g}, ${rgbaValue.value.b})`;\r\n return `rgba(${rgbaValue.value.r}, ${rgbaValue.value.g}, ${rgbaValue.value.b}, ${rgbaValue.value.a.toFixed(2)})`;\r\n },\r\n set: (val: string) => {\r\n try {\r\n if (val.startsWith(\"#\")) {\r\n colorHsv.value = hexToHsva(val);\r\n } else if (val.startsWith(\"rgb\")) {\r\n const matches = val.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?\\)/);\r\n if (matches) {\r\n const [_, r, g, b, a] = matches;\r\n colorHsv.value = rgbaToHsva({\r\n r: parseInt(r || \"0\"),\r\n g: parseInt(g || \"0\"),\r\n b: parseInt(b || \"0\"),\r\n a: a ? parseFloat(a) : 1,\r\n });\r\n }\r\n }\r\n } catch (e) {\r\n // 忽略无效输入\r\n }\r\n },\r\n});\r\n\r\n// --- 监听器 ---\r\nwatch(() => props.modelValue, (val: string | undefined) => {\r\n if (val && val !== hexValue.value) {\r\n colorHsv.value = hexToHsva(val);\r\n }\r\n});\r\n\r\nwatch(hexValue, (val: string) => {\r\n emit(\"update:modelValue\", val);\r\n});\r\n\r\n// --- 交互逻辑 ---\r\nconst saturationRef = ref<HTMLElement | undefined>();\r\nconst isDraggingSaturation = ref(false);\r\n\r\nfunction handleSaturationDrag(event: MouseEvent | TouchEvent) {\r\n if (!saturationRef.value) return;\r\n const rect = saturationRef.value.getBoundingClientRect();\r\n const isTouch = \"touches\" in event;\r\n const clientX = isTouch ? (event as TouchEvent).touches[0]?.clientX : (event as MouseEvent).clientX;\r\n const clientY = isTouch ? (event as TouchEvent).touches[0]?.clientY : (event as MouseEvent).clientY;\r\n\r\n if (clientX === undefined || clientY === undefined) return;\r\n\r\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\r\n const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));\r\n\r\n colorHsv.value = {\r\n ...colorHsv.value,\r\n s: x * 100,\r\n v: (1 - y) * 100,\r\n };\r\n}\r\n\r\nfunction startSaturationDrag(event: MouseEvent | TouchEvent) {\r\n isDraggingSaturation.value = true;\r\n handleSaturationDrag(event);\r\n const up = () => {\r\n isDraggingSaturation.value = false;\r\n window.removeEventListener(\"mousemove\", move);\r\n window.removeEventListener(\"mouseup\", up);\r\n window.removeEventListener(\"touchmove\", move);\r\n window.removeEventListener(\"touchend\", up);\r\n };\r\n const move = (e: MouseEvent | TouchEvent) => handleSaturationDrag(e);\r\n window.addEventListener(\"mousemove\", move);\r\n window.addEventListener(\"mouseup\", up);\r\n window.addEventListener(\"touchmove\", move);\r\n window.addEventListener(\"touchend\", up);\r\n}\r\n\r\nconst hueRef = ref<HTMLElement | undefined>();\r\nfunction handleHueDrag(event: MouseEvent | TouchEvent) {\r\n if (!hueRef.value) return;\r\n const rect = hueRef.value.getBoundingClientRect();\r\n const isTouch = \"touches\" in event;\r\n const clientX = isTouch ? (event as TouchEvent).touches[0]?.clientX : (event as MouseEvent).clientX;\r\n\r\n if (clientX === undefined) return;\r\n\r\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\r\n colorHsv.value = { ...colorHsv.value, h: x * 360 };\r\n}\r\n\r\nfunction startHueDrag(event: MouseEvent | TouchEvent) {\r\n handleHueDrag(event);\r\n const up = () => {\r\n window.removeEventListener(\"mousemove\", move);\r\n window.removeEventListener(\"mouseup\", up);\r\n window.removeEventListener(\"touchmove\", move);\r\n window.removeEventListener(\"touchend\", up);\r\n };\r\n const move = (e: MouseEvent | TouchEvent) => handleHueDrag(e);\r\n window.addEventListener(\"mousemove\", move);\r\n window.addEventListener(\"mouseup\", up);\r\n window.addEventListener(\"touchmove\", move);\r\n window.addEventListener(\"touchend\", up);\r\n}\r\n\r\nconst alphaRef = ref<HTMLElement | undefined>();\r\nfunction handleAlphaDrag(event: MouseEvent | TouchEvent) {\r\n if (!alphaRef.value) return;\r\n const rect = alphaRef.value.getBoundingClientRect();\r\n const isTouch = \"touches\" in event;\r\n const clientX = isTouch ? (event as TouchEvent).touches[0]?.clientX : (event as MouseEvent).clientX;\r\n\r\n if (clientX === undefined) return;\r\n\r\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\r\n colorHsv.value = { ...colorHsv.value, a: x };\r\n}\r\n\r\nfunction startAlphaDrag(event: MouseEvent | TouchEvent) {\r\n handleAlphaDrag(event);\r\n const up = () => {\r\n window.removeEventListener(\"mousemove\", move);\r\n window.removeEventListener(\"mouseup\", up);\r\n window.removeEventListener(\"touchmove\", move);\r\n window.removeEventListener(\"touchend\", up);\r\n };\r\n const move = (e: MouseEvent | TouchEvent) => handleAlphaDrag(e);\r\n window.addEventListener(\"mousemove\", move);\r\n window.addEventListener(\"mouseup\", up);\r\n window.addEventListener(\"touchmove\", move);\r\n window.addEventListener(\"touchend\", up);\r\n}\r\n\r\nfunction selectPreset(color: string) {\r\n colorHsv.value = hexToHsva(color);\r\n}\r\n</script>\r\n\r\n<template>\r\n <div :class=\"ui.root({ class: props.class })\">\r\n <!-- 饱和度/明度 画布 -->\r\n <div ref=\"saturationRef\" :class=\"ui.saturation()\"\r\n :style=\"{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%))` }\"\r\n @mousedown=\"startSaturationDrag\" @touchstart=\"startSaturationDrag\">\r\n <div :class=\"ui.saturationCursor()\" :style=\"{ left: `${colorHsv.s}%`, top: `${100 - colorHsv.v}%` }\" />\r\n </div>\r\n\r\n <div :class=\"ui.controls()\">\r\n <!-- 预览 -->\r\n <div :class=\"ui.preview()\" :style=\"{ backgroundColor: hexValue }\" />\r\n\r\n <div :class=\"ui.sliders()\">\r\n <!-- 色相滑块 -->\r\n <div ref=\"hueRef\" :class=\"ui.hueSlider()\"\r\n style=\"background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)\"\r\n @mousedown=\"startHueDrag\" @touchstart=\"startHueDrag\">\r\n <div :class=\"ui.hueCursor()\" :style=\"{ left: `${(colorHsv.h / 360) * 100}%` }\" />\r\n </div>\r\n\r\n <!-- 透明度滑块 -->\r\n <div ref=\"alphaRef\" :class=\"ui.alphaSlider()\" :style=\"{\r\n background: `linear-gradient(to right, transparent, ${hexValue}), url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAACBIREFUGF5jYmBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGAEAAAgAAB9vNcCAAAAAElFTkSuQmCC')`\r\n }\" @mousedown=\"startAlphaDrag\" @touchstart=\"startAlphaDrag\">\r\n <div :class=\"ui.alphaCursor()\" :style=\"{ left: `${colorHsv.a * 100}%` }\" />\r\n </div>\r\n </div>\r\n </div>\r\n\r\n <!-- 输入框与格式 -->\r\n <div :class=\"ui.inputs()\">\r\n <div :class=\"ui.formatToggles()\">\r\n <RebornButton v-for=\"f in (['hex', 'rgb', 'rgba'] as const)\" :key=\"f\" size=\"xs\"\r\n :variant=\"format === f ? 'solid' : 'ghost'\" :color=\"format === f ? 'primary' : 'neutral'\"\r\n class=\"px-2 py-1 text-[10px] uppercase font-bold rounded transition-colors\" @click=\"format = f\">\r\n {{ f }}\r\n </RebornButton>\r\n </div>\r\n <RebornInput v-model=\"displayValue\" size=\"sm\" :class=\"ui.input()\" spellcheck=\"false\" />\r\n </div>\r\n\r\n <!-- 预设颜色 -->\r\n <div :class=\"ui.presets()\">\r\n <div :class=\"ui.presetTitle()\">主题预设</div>\r\n <div :class=\"ui.presetGrid()\">\r\n <RebornButton v-for=\"color in presets\" :key=\"color\" size=\"xs\" square :class=\"ui.presetSwatch()\"\r\n :style=\"{ backgroundColor: color }\" @click=\"selectPreset(color)\" />\r\n </div>\r\n </div>\r\n </div>\r\n</template>\r\n",
|
|
25
|
+
"target": "web"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "reborn-color-picker-panel.config.ts",
|
|
29
|
+
"content": "const theme = {\r\n slots: {\r\n root: 'flex flex-col w-[260px] bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-800 p-4 select-none',\r\n saturation: 'relative w-full h-40 rounded-lg overflow-hidden cursor-crosshair mb-4',\r\n saturationCursor: 'absolute w-4 h-4 rounded-full border-2 border-white shadow-md -translate-x-2 -translate-y-2 pointer-events-none transition-all duration-75',\r\n controls: 'flex items-center gap-4 mb-4',\r\n preview: 'w-10 h-10 rounded-full border border-gray-100 dark:border-gray-800 shadow-sm shrink-0',\r\n sliders: 'flex-1 flex flex-col gap-2',\r\n hueSlider: 'relative h-3 rounded-full cursor-pointer',\r\n hueCursor: 'absolute w-4 h-4 rounded-full bg-white border border-gray-200 shadow-sm top-1/2 -translate-y-1/2 -translate-x-2 pointer-events-none',\r\n alphaSlider: 'relative h-3 rounded-full cursor-pointer',\r\n alphaCursor: 'absolute w-4 h-4 rounded-full bg-white border border-gray-200 shadow-sm top-1/2 -translate-y-1/2 -translate-x-2 pointer-events-none',\r\n inputs: 'flex flex-col gap-3',\r\n formatToggles: 'flex gap-1',\r\n input: 'flex-1',\r\n presets: 'mt-4 pt-4 border-t border-gray-100 dark:border-gray-800',\r\n presetTitle: 'text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2',\r\n presetGrid: 'grid grid-cols-8 gap-1.5',\r\n presetSwatch: 'w-full h-5 rounded-sm hover:scale-110 active:scale-95 transition-transform'\r\n }\r\n} as const\r\nexport default theme\r\n",
|
|
30
|
+
"target": "uniapp"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "reborn-color-picker.config.ts",
|
|
34
|
+
"content": "const size = [\"sm\", \"md\", \"lg\"] as const;\r\n\r\nexport { size as colorPickerSizes }\r\nexport default {\r\n slots: {\r\n root: \"ring ring-1 ring-gray-5 p-1 rounded-md cursor-pointer select-none hover:scale-105 transition-all\",\r\n base: \"rounded-md ring ring-1 ring-gray-5 w-full h-full flex justify-center items-center relative overflow-hidden\",\r\n icon: \"text-white transition-transform duration-200\",\r\n },\r\n variants: {\r\n disabled: {\r\n true: {\r\n root: \"cursor-not-allowed opacity-50\",\r\n },\r\n },\r\n open: {\r\n true: {\r\n icon: \"rotate-180\",\r\n },\r\n },\r\n size: {\r\n sm: {\r\n root: \"h-[var(--button-sm-height)] w-[var(--button-sm-height)]\",\r\n },\r\n md: {\r\n root: \"h-[var(--button-base-height)] w-[var(--button-base-height)]\",\r\n },\r\n lg: {\r\n root: \"h-[var(--button-lg-height)] w-[var(--button-lg-height)]\",\r\n },\r\n },\r\n },\r\n}\r\n",
|
|
35
|
+
"target": "uniapp"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "RebornColorPicker.vue",
|
|
39
|
+
"content": "<script lang=\"ts\">\r\nexport default {\r\n name: 'reborn-color-picker',\r\n options: {\r\n virtualHost: true,\r\n addGlobalClass: true,\r\n styleIsolation: 'shared'\r\n }\r\n}\r\n</script>\r\n\r\n<script lang=\"ts\" setup>\r\nimport { computed, ref, type PropType, watch } from 'vue'\r\nimport { tv } from '@/lib/tv'\r\nimport theme from './reborn-color-picker.config'\r\nimport RebornPopover from '../reborn-popover/RebornPopover.vue'\r\nimport RebornColorPickerPanel from './RebornColorPickerPanel.vue'\r\nimport { colorStringToHsva, detectColorFormat, hsvaToColorString, type ColorFormat } from '@/lib/color-utils'\r\n\r\nconst props = defineProps({\r\n modelValue: { type: String, default: '#000000' },\r\n disabled: { type: Boolean, default: false },\r\n size: { type: String as PropType<'sm' | 'md' | 'lg'>, default: 'md' },\r\n defaultFormat: { type: String as PropType<ColorFormat | undefined>, default: undefined },\r\n format: { type: String as PropType<ColorFormat | undefined>, default: undefined },\r\n /** Popover content config */\r\n content: { type: Object as PropType<any>, default: () => ({ side: 'right', align: 'center', sideOffset: 8 }) },\r\n /** Whether to show arrow */\r\n arrow: { type: Boolean, default: true },\r\n ui: { type: Object as PropType<any>, default: () => ({}) }\r\n})\r\n\r\nconst emit = defineEmits(['update:modelValue', 'onChange'])\r\nconst b = tv(theme)\r\nconst showPicker = ref(false)\r\nconst resolvedDefaultFormat = computed<ColorFormat | undefined>(() => props.defaultFormat ?? props.format)\r\nconst selectedFormat = ref<ColorFormat>(resolvedDefaultFormat.value ?? detectColorFormat(props.modelValue))\r\n\r\n// Internal color state in HSVA to avoid precision loss\r\nconst internalHsva = ref(colorStringToHsva(props.modelValue))\r\n\r\nwatch(() => resolvedDefaultFormat.value, (val) => {\r\n if (val) {\r\n selectedFormat.value = val\r\n }\r\n}, { immediate: true })\r\n\r\nwatch(() => props.modelValue, (val) => {\r\n const nextHsva = colorStringToHsva(val)\r\n // 与当前内部状态一致时跳过,避免 Panel 选色后产生多余回写和重渲染导致卡顿\r\n const same =\r\n internalHsva.value.h === nextHsva.h &&\r\n internalHsva.value.s === nextHsva.s &&\r\n internalHsva.value.v === nextHsva.v &&\r\n internalHsva.value.a === nextHsva.a\r\n if (!same) {\r\n internalHsva.value = nextHsva\r\n }\r\n\r\n if (!resolvedDefaultFormat.value) {\r\n selectedFormat.value = detectColorFormat(val)\r\n }\r\n}, { immediate: true })\r\n\r\nfunction getFormattedColor(hsva: any) {\r\n return hsvaToColorString(hsva, selectedFormat.value)\r\n}\r\n\r\nconst colorValue = computed({\r\n get: () => getFormattedColor(internalHsva.value),\r\n set: (val: string) => emit('update:modelValue', val)\r\n})\r\n\r\nconst uiOverrides = computed(() => props.ui || {})\r\nconst uiClasses = computed(() => {\r\n const styles = b({\r\n size: props.size,\r\n disabled: props.disabled\r\n })\r\n return {\r\n root: styles.root({ class: uiOverrides.value.root }),\r\n base: styles.base({ class: uiOverrides.value.base }),\r\n icon: styles.icon({ class: uiOverrides.value.icon }),\r\n }\r\n})\r\n\r\nfunction onPanelChange(value: string) {\r\n const hsva = colorStringToHsva(value)\r\n internalHsva.value = hsva\r\n emit('update:modelValue', getFormattedColor(hsva))\r\n emit('onChange', value)\r\n}\r\n\r\nfunction togglePicker() {\r\n if (props.disabled) return\r\n showPicker.value = !showPicker.value\r\n}\r\n</script>\r\n\r\n<template>\r\n <RebornPopover v-model=\"showPicker\" :content=\"content\" :arrow=\"arrow\" :disabled=\"disabled\">\r\n <view :class=\"uiClasses.root\">\r\n <view :class=\"uiClasses.base\" :style=\"{ backgroundColor: colorValue }\" @tap=\"togglePicker\">\r\n <view class=\"i-lucide-chevron-down text-white transition-transform duration-200\"\r\n :class=\"[uiClasses.icon, showPicker ? 'rotate-180' : '']\">\r\n </view>\r\n </view>\r\n </view>\r\n <template #content>\r\n <RebornColorPickerPanel :model-value=\"colorValue\" v-model:format=\"selectedFormat\"\r\n @update:modelValue=\"onPanelChange\" />\r\n </template>\r\n </RebornPopover>\r\n</template>\r\n",
|
|
40
|
+
"target": "uniapp"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "RebornColorPickerPanel.vue",
|
|
44
|
+
"content": "<script setup lang=\"ts\">\r\nimport { computed, ref, watch, getCurrentInstance, onMounted } from \"vue\";\r\nimport {\r\n colorStringToHsva,\r\n detectColorFormat,\r\n hsvaToColorString,\r\n hsvaToHex,\r\n hsvaToRgba,\r\n rgbaToHsva,\r\n rgbaToHex,\r\n} from \"../../lib/color-utils\";\r\nimport type { ColorFormat, HsvaColor } from \"../../lib/color-utils\";\r\nimport { tv } from \"@/lib/tv\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport theme from \"./reborn-color-picker-panel.config\";\r\n\r\nimport RebornButton from \"../reborn-button/RebornButton.vue\";\r\nimport RebornInput from \"../reborn-input/RebornInput.vue\";\r\n\r\ninterface Props {\r\n modelValue?: string;\r\n format?: ColorFormat;\r\n class?: any;\r\n ui?: {\r\n root?: string\r\n saturation?: string\r\n saturationCursor?: string\r\n controls?: string\r\n preview?: string\r\n sliders?: string\r\n hueSlider?: string\r\n hueCursor?: string\r\n alphaSlider?: string\r\n alphaCursor?: string\r\n inputs?: string\r\n formatToggles?: string\r\n input?: string\r\n presets?: string\r\n presetTitle?: string\r\n presetGrid?: string\r\n presetSwatch?: string\r\n };\r\n}\r\n\r\nconst props = withDefaults(defineProps<Props>(), {\r\n modelValue: \"#000000\",\r\n});\r\n\r\nconst emit = defineEmits<{\r\n (e: \"update:modelValue\", value: string): void;\r\n (e: \"update:format\", value: ColorFormat): void;\r\n}>();\r\n\r\nconst { proxy } = getCurrentInstance()!;\r\n\r\n// --- 样式配置 ---\r\nconst b = tv(theme);\r\nconst uiOverrides = computed(() => props.ui || {});\r\n\r\nconst ui = computed(() => {\r\n const styles = b();\r\n\r\n return {\r\n root: (opts?: { class?: any }) => styles.root({ class: cn(opts?.class, uiOverrides.value.root) }),\r\n saturation: (opts?: { class?: any }) => styles.saturation({ class: cn(opts?.class, uiOverrides.value.saturation) }),\r\n saturationCursor: (opts?: { class?: any }) => styles.saturationCursor({ class: cn(opts?.class, uiOverrides.value.saturationCursor) }),\r\n controls: (opts?: { class?: any }) => styles.controls({ class: cn(opts?.class, uiOverrides.value.controls) }),\r\n preview: (opts?: { class?: any }) => styles.preview({ class: cn(opts?.class, uiOverrides.value.preview) }),\r\n sliders: (opts?: { class?: any }) => styles.sliders({ class: cn(opts?.class, uiOverrides.value.sliders) }),\r\n hueSlider: (opts?: { class?: any }) => styles.hueSlider({ class: cn(opts?.class, uiOverrides.value.hueSlider) }),\r\n hueCursor: (opts?: { class?: any }) => styles.hueCursor({ class: cn(opts?.class, uiOverrides.value.hueCursor) }),\r\n alphaSlider: (opts?: { class?: any }) => styles.alphaSlider({ class: cn(opts?.class, uiOverrides.value.alphaSlider) }),\r\n alphaCursor: (opts?: { class?: any }) => styles.alphaCursor({ class: cn(opts?.class, uiOverrides.value.alphaCursor) }),\r\n inputs: (opts?: { class?: any }) => styles.inputs({ class: cn(opts?.class, uiOverrides.value.inputs) }),\r\n formatToggles: (opts?: { class?: any }) => styles.formatToggles({ class: cn(opts?.class, uiOverrides.value.formatToggles) }),\r\n input: (opts?: { class?: any }) => styles.input({ class: cn(opts?.class, uiOverrides.value.input) }),\r\n presets: (opts?: { class?: any }) => styles.presets({ class: cn(opts?.class, uiOverrides.value.presets) }),\r\n presetTitle: (opts?: { class?: any }) => styles.presetTitle({ class: cn(opts?.class, uiOverrides.value.presetTitle) }),\r\n presetGrid: (opts?: { class?: any }) => styles.presetGrid({ class: cn(opts?.class, uiOverrides.value.presetGrid) }),\r\n presetSwatch: (opts?: { class?: any }) => styles.presetSwatch({ class: cn(opts?.class, uiOverrides.value.presetSwatch) }),\r\n }\r\n});\r\n\r\n// --- 状态 ---\r\nconst colorHsv = ref<HsvaColor>(colorStringToHsva(props.modelValue || \"#000000\"));\r\nconst format = ref<ColorFormat>(props.format || detectColorFormat(props.modelValue));\r\n\r\n// 画布/滑块区域 rect 缓存,touchmove 时用缓存同步计算位置,避免每次异步 query 导致不跟手\r\ntype Rect = { left: number; top: number; width: number; height: number };\r\nconst saturationRect = ref<Rect | null>(null);\r\nconst hueRect = ref<Rect | null>(null);\r\nconst alphaRect = ref<Rect | null>(null);\r\n\r\n// --- 来自 base.css 的预设颜色 ---\r\nconst presets = [\r\n \"#ffebee\", \"#ff3d58\", \"#ff9711\", \"#16ae88\", \"#0d99e5\", \"#ffffff\", \"#eeeeee\", \"#333333\",\r\n];\r\n\r\n// --- 计算属性 ---\r\nconst hexValue = computed(() => hsvaToHex(colorHsv.value));\r\nconst rgbaValue = computed(() => hsvaToRgba(colorHsv.value));\r\n\r\nconst displayValue = computed({\r\n get: () => {\r\n const value = hsvaToColorString(colorHsv.value, format.value);\r\n return format.value === \"hex\" ? value.toUpperCase() : value;\r\n },\r\n set: (val: string) => {\r\n try {\r\n colorHsv.value = colorStringToHsva(val);\r\n\r\n if (!props.format) {\r\n format.value = detectColorFormat(val);\r\n }\r\n } catch (e) {\r\n // 忽略无效输入\r\n }\r\n },\r\n});\r\n\r\n// --- 监听器 ---\r\nwatch(() => props.modelValue, (val: string | undefined) => {\r\n if (!val) return;\r\n\r\n if (props.format) {\r\n format.value = props.format;\r\n } else {\r\n format.value = detectColorFormat(val);\r\n }\r\n\r\n const hsv = colorStringToHsva(val);\r\n const cur = colorHsv.value;\r\n if (\r\n cur.h !== hsv.h || cur.s !== hsv.s || cur.v !== hsv.v || cur.a !== hsv.a\r\n ) {\r\n colorHsv.value = hsv;\r\n }\r\n});\r\n\r\nwatch(() => props.format, (val) => {\r\n if (val) {\r\n format.value = val;\r\n }\r\n}, { immediate: true });\r\n\r\nwatch(displayValue, (val: string) => {\r\n emit(\"update:modelValue\", val);\r\n});\r\n\r\nwatch(format, (val) => {\r\n emit(\"update:format\", val);\r\n});\r\n\r\n// --- 交互逻辑:touchstart 预热 rect 缓存,touchmove 用缓存同步计算,避免异步 query 导致不跟手 ---\r\nfunction updateSaturationFromRect(rect: Rect, clientX: number, clientY: number) {\r\n const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\r\n const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));\r\n colorHsv.value = {\r\n ...colorHsv.value,\r\n s: x * 100,\r\n v: (1 - y) * 100,\r\n };\r\n}\r\n\r\nfunction handleSaturationTouch(e: any) {\r\n const touch = e.touches[0];\r\n if (!touch) return;\r\n if (e.type === \"touchstart\") saturationRect.value = null;\r\n const rect = saturationRect.value;\r\n if (rect) {\r\n updateSaturationFromRect(rect, touch.clientX, touch.clientY);\r\n return;\r\n }\r\n uni.createSelectorQuery()\r\n .in(proxy)\r\n .select(\".reborn-saturation\")\r\n .boundingClientRect((r: any) => {\r\n if (!r) return;\r\n saturationRect.value = { left: r.left, top: r.top, width: r.width, height: r.height };\r\n updateSaturationFromRect(saturationRect.value, touch.clientX, touch.clientY);\r\n })\r\n .exec();\r\n}\r\n\r\nfunction handleHueTouch(e: any) {\r\n const touch = e.touches[0];\r\n if (!touch) return;\r\n if (e.type === \"touchstart\") hueRect.value = null;\r\n const rect = hueRect.value;\r\n if (rect) {\r\n const x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));\r\n colorHsv.value = { ...colorHsv.value, h: x * 360 };\r\n return;\r\n }\r\n uni.createSelectorQuery()\r\n .in(proxy)\r\n .select(\".reborn-hue-slider\")\r\n .boundingClientRect((r: any) => {\r\n if (!r) return;\r\n hueRect.value = { left: r.left, top: r.top, width: r.width, height: r.height };\r\n const x = Math.max(0, Math.min(1, (touch.clientX - r.left) / r.width));\r\n colorHsv.value = { ...colorHsv.value, h: x * 360 };\r\n })\r\n .exec();\r\n}\r\n\r\nfunction handleAlphaTouch(e: any) {\r\n const touch = e.touches[0];\r\n if (!touch) return;\r\n if (e.type === \"touchstart\") alphaRect.value = null;\r\n const rect = alphaRect.value;\r\n if (rect) {\r\n const x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));\r\n colorHsv.value = { ...colorHsv.value, a: x };\r\n return;\r\n }\r\n uni.createSelectorQuery()\r\n .in(proxy)\r\n .select(\".reborn-alpha-slider\")\r\n .boundingClientRect((r: any) => {\r\n if (!r) return;\r\n alphaRect.value = { left: r.left, top: r.top, width: r.width, height: r.height };\r\n const x = Math.max(0, Math.min(1, (touch.clientX - r.left) / r.width));\r\n colorHsv.value = { ...colorHsv.value, a: x };\r\n })\r\n .exec();\r\n}\r\n\r\nfunction selectPreset(color: string) {\r\n colorHsv.value = colorStringToHsva(color);\r\n}\r\n</script>\r\n\r\n<template>\r\n <view :class=\"ui.root({ class: props.class })\" @touchmove.stop.prevent=\"\">\r\n <!-- 饱和度/明度 画布 -->\r\n <view :class=\"[ui.saturation(), 'reborn-saturation']\"\r\n :style=\"{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%))` }\"\r\n @touchstart=\"handleSaturationTouch\" @touchmove=\"handleSaturationTouch\">\r\n <view :class=\"ui.saturationCursor()\" :style=\"{ left: `${colorHsv.s}%`, top: `${100 - colorHsv.v}%` }\" />\r\n </view>\r\n\r\n <view :class=\"ui.controls()\">\r\n <!-- 预览 -->\r\n <view :class=\"ui.preview()\" :style=\"{ backgroundColor: hexValue }\" />\r\n\r\n <view :class=\"ui.sliders()\">\r\n <!-- 色相滑块 -->\r\n <view :class=\"[ui.hueSlider(), 'reborn-hue-slider']\"\r\n style=\"background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)\"\r\n @touchstart=\"handleHueTouch\" @touchmove=\"handleHueTouch\">\r\n <view :class=\"ui.hueCursor()\" :style=\"{ left: `${(colorHsv.h / 360) * 100}%` }\" />\r\n </view>\r\n\r\n <!-- 透明度滑块 -->\r\n <view :class=\"[ui.alphaSlider(), 'reborn-alpha-slider']\" :style=\"{\r\n background: `linear-gradient(to right, transparent, ${hexValue})`\r\n }\" @touchstart=\"handleAlphaTouch\" @touchmove=\"handleAlphaTouch\">\r\n <view :class=\"ui.alphaCursor()\" :style=\"{ left: `${colorHsv.a * 100}%` }\" />\r\n </view>\r\n </view>\r\n </view>\r\n\r\n <!-- 输入框与格式 -->\r\n <view :class=\"ui.inputs()\">\r\n <view :class=\"ui.formatToggles()\">\r\n <RebornButton v-for=\"f in (['hex', 'rgb', 'rgba'] as const)\" :key=\"f\" size=\"xs\"\r\n :variant=\"format === f ? 'solid' : 'soft'\" :color=\"format === f ? 'primary' : 'neutral'\"\r\n class=\"px-2 py-1 text-[10px] uppercase font-bold rounded transition-colors\"\r\n @tap=\"format = f as ColorFormat\">\r\n {{ f }}\r\n </RebornButton>\r\n </view>\r\n <RebornInput v-model=\"displayValue\" size=\"sm\" :class=\"ui.input()\" spellcheck=\"false\" />\r\n </view>\r\n\r\n <!-- 预设颜色 -->\r\n <view :class=\"ui.presets()\">\r\n <view :class=\"ui.presetTitle()\">主题预设</view>\r\n <view :class=\"ui.presetGrid()\">\r\n <view v-for=\"color in presets\" :key=\"color\" :class=\"ui.presetSwatch()\"\r\n :style=\"{ backgroundColor: color }\" @tap=\"selectPreset(color)\" />\r\n </view>\r\n </view>\r\n </view>\r\n</template>\r\n",
|
|
45
|
+
"target": "uniapp"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"fileCount": 8,
|
|
49
|
+
"contentHash": "77e26c15f04d908b13089c8bea65889ebd700705"
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-draggable",
|
|
3
|
+
"dependencies": [],
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "index.ts",
|
|
7
|
+
"content": "export { default as RebornDraggable } from './RebornDraggable.vue';\r\n"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "reborn-draggable.config.ts",
|
|
11
|
+
"content": "export default { root: 'grid gap-3', item: 'relative' } as const;\r\n",
|
|
12
|
+
"target": "web"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "RebornDraggable.vue",
|
|
16
|
+
"content": "<script setup lang=\"ts\" generic=\"T extends Record<string, any> | string | number\">\r\nimport { ref, watch } from 'vue';\r\nimport theme from './reborn-draggable.config';\r\n\r\nconst props = withDefaults(defineProps<{ modelValue?: T[]; disabled?: boolean; className?: string; }>(), { modelValue: () => [], disabled: false, className: '' });\r\nconst emit = defineEmits(['update:modelValue', 'change', 'start', 'end']);\r\nconst list = ref<T[]>([...props.modelValue]);\r\nconst dragIndex = ref(-1);\r\nconst isDragging = ref(false);\r\n\r\nwatch(() => props.modelValue, (v) => {\r\n if (!isDragging.value) {\r\n list.value = [...v];\r\n }\r\n}, { deep: true });\r\n\r\nconst isItemDisabled = (item: any) => props.disabled || (item && typeof item === 'object' && item.disabled === true);\r\n\r\nconst onDragStart = (e: DragEvent, index: number, item: any) => {\r\n if (isItemDisabled(item)) return e.preventDefault();\r\n isDragging.value = true;\r\n dragIndex.value = index;\r\n if (e.dataTransfer) {\r\n e.dataTransfer.effectAllowed = 'move';\r\n }\r\n emit('start', { index, item });\r\n};\r\n\r\nconst onDragEnter = (e: DragEvent, index: number, item: any) => {\r\n e.preventDefault();\r\n if (dragIndex.value < 0 || dragIndex.value === index) return;\r\n\r\n const next = [...list.value];\r\n const [draggedItem] = next.splice(dragIndex.value, 1);\r\n if (draggedItem !== undefined) {\r\n next.splice(index, 0, draggedItem as any);\r\n }\r\n list.value = next;\r\n dragIndex.value = index;\r\n};\r\n\r\nconst onDrop = (index: number) => {\r\n emit('update:modelValue', list.value);\r\n emit('change', list.value);\r\n};\r\n\r\nconst onDragEnd = () => {\r\n dragIndex.value = -1;\r\n isDragging.value = false;\r\n emit('end');\r\n};\r\n\r\nconst getItemKey = (item: any, index: number) => {\r\n return (item && typeof item === 'object' && (item.id || item.key || item.name)) || item || index;\r\n};\r\n</script>\r\n\r\n<template>\r\n <TransitionGroup tag=\"div\" :class=\"`${theme.root} ${props.className}`\" name=\"reborn-drag\">\r\n <div v-for=\"(item, index) in list\" :key=\"getItemKey(item, index)\" :class=\"[\r\n theme.item,\r\n isItemDisabled(item) ? 'cursor-not-allowed opacity-50' : 'cursor-move',\r\n dragIndex === index ? 'opacity-90 scale-105 shadow-md relative z-10' : ''\r\n ]\" :draggable=\"!isItemDisabled(item)\" @dragstart=\"onDragStart($event, index, item)\"\r\n @dragenter=\"onDragEnter($event, index, item)\" @dragover.prevent @drop=\"onDrop(index)\" @dragend=\"onDragEnd\">\r\n <slot name=\"item\" :item=\"item\" :index=\"index\" :dragging=\"dragIndex === index\">{{ item }}</slot>\r\n </div>\r\n </TransitionGroup>\r\n</template>\r\n\r\n<style scoped>\r\n.reborn-drag-move {\r\n transition: transform 0.3s ease;\r\n}\r\n\r\n.reborn-drag-enter-active,\r\n.reborn-drag-leave-active {\r\n transition: all 0.3s ease;\r\n}\r\n\r\n.reborn-drag-enter-from,\r\n.reborn-drag-leave-to {\r\n opacity: 0;\r\n transform: scale(0.9);\r\n}\r\n</style>\r\n",
|
|
17
|
+
"target": "web"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "reborn-draggable.config.ts",
|
|
21
|
+
"content": "const theme = {\r\n slots: {\r\n root: 'flex flex-col relative overflow-visible',\r\n // #ifdef APP-IOS\r\n item: 'relative z-10 transition-none opacity-100',\r\n // #endif\r\n // #ifndef APP-IOS\r\n // @ts-ignore\r\n item: 'relative z-10',\r\n // #endif\r\n },\r\n variants: {\r\n columns: {\r\n true: { root: 'flex-row flex-wrap' },\r\n },\r\n disabled: {\r\n true: { item: 'opacity-60' },\r\n },\r\n },\r\n defaultVariants: {\r\n columns: false,\r\n disabled: false,\r\n },\r\n}\r\n\r\nexport default theme\r\n",
|
|
22
|
+
"target": "uniapp"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "RebornDraggable.vue",
|
|
26
|
+
"content": "<template>\r\n <view class=\"reborn-draggable\" :class=\"ui.root({ class: props.className })\" :data-dragging=\"dragging\"\r\n @touchmove=\"dragWxs.touchmove\">\r\n <!-- @vue-ignore -->\r\n <view v-for=\"(item, index) in list\" :key=\"getItemKey(item, index)\" class=\"reborn-draggable__item\" :class=\"[\r\n ui.item(),\r\n {\r\n 'reborn-draggable__item--disabled': disabled,\r\n 'reborn-draggable__item--dragging': dragging && dragIndex == index,\r\n 'reborn-draggable__item--animating': dragging && dragIndex != index\r\n }\r\n ]\" :style=\"getItemStyle(index)\" @touchstart=\"\r\n (event: UniTouchEvent) => {\r\n onTouchStart(event, index, 'touch');\r\n }\r\n \" @longpress=\"\r\n (event: UniTouchEvent) => {\r\n onTouchStart(event, index, 'longpress');\r\n }\r\n \" @touchmove=\"onTouchMove\" @touchend=\"onTouchEnd\">\r\n <slot name=\"item\" :item=\"item\" :index=\"index\" :dragging=\"dragging\" :dragIndex=\"dragIndex\"\r\n :insertIndex=\"insertIndex\">\r\n </slot>\r\n </view>\r\n </view>\r\n</template>\r\n\r\n<script lang=\"ts\" setup>\r\nimport { computed, ref, getCurrentInstance, type PropType, watch, onMounted, onUnmounted } from \"vue\";\r\nimport theme from \"./reborn-draggable.config\";\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport { uuid } from \"@/lib/file\";\r\n\r\ndefineOptions({\r\n name: \"reborn-draggable\"\r\n});\r\n\r\ndefineSlots<{\r\n item(props: {\r\n item: any;\r\n index: number;\r\n dragging: boolean;\r\n dragIndex: number;\r\n insertIndex: number;\r\n }): any;\r\n}>();\r\n\r\n// 项目位置信息类型定义\r\ntype ItemPosition = {\r\n top: number;\r\n left: number;\r\n width: number;\r\n height: number;\r\n};\r\n\r\n// 解决 TS 类型检查报错:为 WXS 模块提供声明\r\nconst dragWxs = { touchmove: (e: any, ins: any) => true };\r\n\r\n// 位移偏移量类型定义\r\ntype TranslateOffset = {\r\n x: number;\r\n y: number;\r\n};\r\n\r\nconst props = defineProps({\r\n /** PassThrough 样式配置 */\r\n className: {\r\n type: String,\r\n default: \"\"\r\n },\r\n /** UI 配置 */\r\n ui: {\r\n type: Object as PropType<any>,\r\n default: () => ({})\r\n },\r\n /** 数据数组,支持双向绑定 */\r\n modelValue: {\r\n type: Array as PropType<any[]>,\r\n default: () => []\r\n },\r\n /** 是否禁用拖拽功能 */\r\n disabled: {\r\n type: Boolean,\r\n default: false\r\n },\r\n /** 列数:1为单列纵向布局,>1为多列网格布局 */\r\n columns: {\r\n type: Number,\r\n default: 1\r\n },\r\n // 是否需要长按触发\r\n longPress: {\r\n type: Boolean,\r\n default: true\r\n }\r\n});\r\n\r\nconst emit = defineEmits([\"update:modelValue\", \"change\", \"start\", \"end\"]);\r\n\r\nconst { proxy } = getCurrentInstance()!;\r\n\r\n/** 数据列表 */\r\nconst list = ref<any[]>([]);\r\n\r\n/** 是否正在拖拽 */\r\nconst dragging = ref(false);\r\n/** 当前拖拽元素的原始索引 */\r\nconst dragIndex = ref(-1);\r\n/** 预期插入的目标索引 */\r\nconst insertIndex = ref(-1);\r\n/** 触摸开始时的Y坐标 */\r\nconst startY = ref(0);\r\n/** 触摸开始时的X坐标 */\r\nconst startX = ref(0);\r\n/** Y轴偏移量 */\r\nconst offsetY = ref(0);\r\n/** X轴偏移量 */\r\nconst offsetX = ref(0);\r\n/** 当前拖拽的数据项 */\r\nconst dragItem = ref<any>({});\r\n/** 所有项目的位置信息缓存 */\r\nconst itemPositions = ref<ItemPosition[]>([]);\r\n/** 是否处于放下动画状态 */\r\nconst dropping = ref(false);\r\n/** 动态计算的项目高度 */\r\nconst itemHeight = ref(0);\r\n/** 动态计算的项目宽度 */\r\nconst itemWidth = ref(0);\r\n/** 是否已开始排序模拟(防止误触) */\r\nconst sortingStarted = ref(false);\r\n\r\n\r\nconst b = tv(theme)\r\nconst uiOverrides = computed(() => props.ui || {})\r\nconst ui = computed(() => {\r\n const styles = b({\r\n disabled: props.disabled,\r\n columns: props.columns > 1,\r\n })\r\n\r\n return {\r\n root: (opts?: { class?: any }) => styles.root({ class: cn(opts?.class, uiOverrides.value.base) }),\r\n item: (opts?: { class?: any }) => styles.item({ class: cn(opts?.class, uiOverrides.value.base) }), // alias to base\r\n }\r\n})\r\n\r\nfunction isNull(value?: any | null): boolean {\r\n // #ifdef APP\r\n return value == null;\r\n // #endif\r\n\r\n // #ifndef APP\r\n return value == null || value == undefined;\r\n // #endif\r\n}\r\n/**\r\n * 重置所有拖拽相关的状态\r\n * 在拖拽结束后调用,确保组件回到初始状态\r\n */\r\nfunction reset() {\r\n dragging.value = false; // 拖拽状态\r\n dropping.value = false; // 放下动画状态\r\n dragIndex.value = -1; // 拖拽元素索引\r\n insertIndex.value = -1; // 插入位置索引\r\n offsetX.value = 0; // X轴偏移\r\n offsetY.value = 0; // Y轴偏移\r\n dragItem.value = {}; // 拖拽的数据项\r\n itemPositions.value = []; // 位置信息缓存\r\n itemHeight.value = 0; // 动态计算的高度\r\n itemWidth.value = 0; // 动态计算的宽度\r\n sortingStarted.value = false; // 排序模拟状态\r\n}\r\n\r\n/**\r\n * 计算网格布局中元素的位移偏移\r\n * @param index 当前元素索引\r\n * @param dragIdx 拖拽元素索引\r\n * @param insertIdx 插入位置索引\r\n * @returns 包含 x 和 y 坐标偏移的对象\r\n */\r\nfunction calculateGridOffset(index: number, dragIdx: number, insertIdx: number): TranslateOffset {\r\n const cols = props.columns;\r\n\r\n // 计算当前元素在网格中的行列位置\r\n const currentRow = Math.floor(index / cols);\r\n const currentCol = index % cols;\r\n\r\n // 计算元素在拖拽后的新位置索引\r\n let newIndex = index;\r\n\r\n if (dragIdx < insertIdx) {\r\n // 向后拖拽:dragIdx+1 到 insertIdx 之间的元素需要向前移动一位\r\n if (index > dragIdx && index <= insertIdx) {\r\n newIndex = index - 1;\r\n }\r\n } else if (dragIdx > insertIdx) {\r\n // 向前拖拽:insertIdx 到 dragIdx-1 之间的元素需要向后移动一位\r\n if (index >= insertIdx && index < dragIdx) {\r\n newIndex = index + 1;\r\n }\r\n }\r\n\r\n // 计算新位置的行列坐标\r\n const newRow = Math.floor(newIndex / cols);\r\n const newCol = newIndex % cols;\r\n\r\n // 使用动态计算的网格尺寸\r\n const cellWidth = itemWidth.value;\r\n const cellHeight = itemHeight.value;\r\n\r\n // 计算实际的像素位移\r\n const offsetX = (newCol - currentCol) * cellWidth;\r\n const offsetY = (newRow - currentRow) * cellHeight;\r\n\r\n return { x: offsetX, y: offsetY };\r\n}\r\n\r\n/**\r\n * 计算网格布局的插入位置\r\n * @param dragCenterX 拖拽元素中心点X坐标\r\n * @param dragCenterY 拖拽元素中心点Y坐标\r\n * @returns 最佳插入位置索引\r\n */\r\nfunction calculateGridInsertIndex(dragCenterX: number, dragCenterY: number): number {\r\n if (itemPositions.value.length == 0) {\r\n return dragIndex.value;\r\n }\r\n\r\n let closestIndex = dragIndex.value;\r\n let minDistance = Infinity;\r\n\r\n // 使用欧几里得距离找到最近的网格位置(包括原位置)\r\n for (let i = 0; i < itemPositions.value.length; i++) {\r\n const position = itemPositions.value[i];\r\n\r\n // 计算到元素中心点的距离\r\n const centerX = position.left + position.width / 2;\r\n const centerY = position.top + position.height / 2;\r\n\r\n // 使用欧几里得距离公式\r\n const distance = Math.sqrt(\r\n Math.pow(dragCenterX - centerX, 2) + Math.pow(dragCenterY - centerY, 2)\r\n );\r\n\r\n // 更新最近的位置\r\n if (distance < minDistance) {\r\n minDistance = distance;\r\n closestIndex = i;\r\n }\r\n }\r\n\r\n return closestIndex;\r\n}\r\n\r\n/**\r\n * 计算单列布局的插入位置\r\n * @param clientY Y坐标\r\n * @returns 最佳插入位置索引\r\n */\r\nfunction calculateSingleColumnInsertIndex(clientY: number): number {\r\n let closestIndex = dragIndex.value;\r\n let minDistance = Infinity;\r\n\r\n // 遍历所有元素,找到距离最近的元素中心\r\n for (let i = 0; i < itemPositions.value.length; i++) {\r\n const position = itemPositions.value[i];\r\n\r\n // 计算到元素中心点的距离\r\n const itemCenter = position.top + position.height / 2;\r\n const distance = Math.abs(clientY - itemCenter);\r\n\r\n if (distance < minDistance) {\r\n minDistance = distance;\r\n closestIndex = i;\r\n }\r\n }\r\n\r\n return closestIndex;\r\n}\r\n\r\n/**\r\n * 计算拖拽元素的最佳插入位置\r\n * @param clientPosition 在主轴上的坐标(仅用于单列布局的Y轴坐标)\r\n * @returns 最佳插入位置的索引\r\n */\r\nfunction calculateInsertIndex(clientPosition: number): number {\r\n // 如果没有位置信息,保持原位置\r\n if (itemPositions.value.length == 0) {\r\n return dragIndex.value;\r\n }\r\n\r\n // 根据布局类型选择计算方式\r\n if (props.columns > 1) {\r\n // 多列网格布局:计算拖拽元素的中心点坐标,使用2D坐标计算最近位置\r\n const dragPos = itemPositions.value[dragIndex.value];\r\n const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;\r\n const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;\r\n return calculateGridInsertIndex(dragCenterX, dragCenterY);\r\n } else {\r\n // 单列布局:基于Y轴距离计算最近的元素中心\r\n return calculateSingleColumnInsertIndex(clientPosition);\r\n }\r\n}\r\n\r\n/**\r\n * 计算单列布局的位移偏移\r\n * @param index 元素索引\r\n * @param dragIdx 拖拽元素索引\r\n * @param insertIdx 插入位置索引\r\n * @returns 位移偏移对象\r\n */\r\nfunction calculateSingleColumnOffset(\r\n index: number,\r\n dragIdx: number,\r\n insertIdx: number\r\n): TranslateOffset {\r\n if (dragIdx < insertIdx) {\r\n // 向下拖拽:dragIdx+1 到 insertIdx 之间的元素向上移动\r\n if (index > dragIdx && index <= insertIdx) {\r\n return { x: 0, y: -itemHeight.value };\r\n }\r\n } else if (dragIdx > insertIdx) {\r\n // 向上拖拽:insertIdx 到 dragIdx-1 之间的元素向下移动\r\n if (index >= insertIdx && index < dragIdx) {\r\n return { x: 0, y: itemHeight.value };\r\n }\r\n }\r\n\r\n return { x: 0, y: 0 };\r\n}\r\n\r\n/**\r\n * 计算非拖拽元素的位移偏移量\r\n * @param index 元素索引\r\n * @returns 包含 x 和 y 坐标偏移的对象\r\n */\r\nfunction getItemTranslateOffset(index: number): TranslateOffset {\r\n // 只在满足所有条件时才计算位移:拖拽中、非放下状态、已开始排序\r\n if (!dragging.value || dropping.value || !sortingStarted.value) {\r\n return { x: 0, y: 0 };\r\n }\r\n\r\n const dragIdx = dragIndex.value;\r\n const insertIdx = insertIndex.value;\r\n\r\n // 跳过正在拖拽的元素(拖拽元素由位置控制)\r\n if (index == dragIdx) {\r\n return { x: 0, y: 0 };\r\n }\r\n\r\n // 没有位置变化时不需要位移(拖回原位置)\r\n if (dragIdx == insertIdx) {\r\n return { x: 0, y: 0 };\r\n }\r\n\r\n // 根据布局类型计算位移\r\n if (props.columns > 1) {\r\n // 多列网格布局:使用2D位移计算\r\n return calculateGridOffset(index, dragIdx, insertIdx);\r\n } else {\r\n // 单列布局:使用简单的纵向位移\r\n return calculateSingleColumnOffset(index, dragIdx, insertIdx);\r\n }\r\n}\r\n\r\n/**\r\n * 计算项目的完整样式对象\r\n * @param index 项目索引\r\n * @returns 样式对象\r\n */\r\nfunction getItemStyle(index: number) {\r\n const style: Record<string, string> = {};\r\n const isCurrent = dragIndex.value == index;\r\n\r\n // 多列布局时设置等宽分布\r\n if (props.columns > 1) {\r\n const widthPercent = 100 / props.columns;\r\n style[\"flex-basis\"] = `${widthPercent}%`;\r\n style[\"width\"] = `${widthPercent}%`;\r\n style[\"box-sizing\"] = \"border-box\";\r\n }\r\n\r\n // 放下动画期间,只保留基础样式\r\n if (dropping.value) {\r\n return style;\r\n }\r\n\r\n // 拖拽状态下的样式处理\r\n if (dragging.value) {\r\n if (isCurrent) {\r\n // 拖拽元素:跟随移动\r\n style[\"transform\"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;\r\n style[\"z-index\"] = \"100\";\r\n } else {\r\n // 其他元素:显示排序预览位移\r\n const translateOffset = getItemTranslateOffset(index);\r\n style[\"transform\"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;\r\n }\r\n }\r\n\r\n return style;\r\n}\r\n\r\n/**\r\n * 获取所有项目的位置信息\r\n */\r\nasync function getItemPosition(): Promise<void> {\r\n return new Promise((resolve) => {\r\n uni.createSelectorQuery()\r\n .in(proxy)\r\n .select(\".reborn-draggable\")\r\n .boundingClientRect()\r\n .exec((res) => {\r\n const box = res[0] as any;\r\n\r\n itemWidth.value = (box.width ?? 0) / props.columns;\r\n\r\n uni.createSelectorQuery()\r\n .in(proxy)\r\n .selectAll(\".reborn-draggable__item\")\r\n .boundingClientRect()\r\n .exec((res) => {\r\n const rects = res[0] as any[];\r\n const positions: ItemPosition[] = [];\r\n\r\n for (let i = 0; i < rects.length; i++) {\r\n const rect = rects[i];\r\n\r\n if (i == 0) {\r\n itemHeight.value = rect.height ?? 0;\r\n }\r\n\r\n positions.push({\r\n top: rect.top ?? 0,\r\n left: rect.left ?? 0,\r\n width: itemWidth.value,\r\n height: itemHeight.value\r\n });\r\n }\r\n\r\n itemPositions.value = positions;\r\n\r\n resolve();\r\n });\r\n });\r\n });\r\n}\r\n\r\n/**\r\n * 获取项目是否禁用\r\n * @param index 项目索引\r\n * @returns 是否禁用\r\n */\r\nfunction getItemDisabled(index: number): boolean {\r\n return !isNull(list.value[index][\"disabled\"]) && (list.value[index][\"disabled\"] as boolean);\r\n}\r\n\r\n/**\r\n * 检查拖拽元素的中心点是否移动到其他元素区域\r\n */\r\nfunction checkMovedToOtherElement(): boolean {\r\n // 如果没有位置信息,默认未移出\r\n if (itemPositions.value.length == 0) return false;\r\n\r\n const dragIdx = dragIndex.value;\r\n const dragPosition = itemPositions.value[dragIdx];\r\n\r\n // 计算拖拽元素当前的中心点位置(考虑拖拽偏移)\r\n const dragCenterX = dragPosition.left + dragPosition.width / 2 + offsetX.value;\r\n const dragCenterY = dragPosition.top + dragPosition.height / 2 + offsetY.value;\r\n\r\n // 根据布局类型采用不同的判断策略\r\n if (props.columns > 1) {\r\n // 多列网格布局:检查中心点是否与其他元素区域重叠\r\n for (let i = 0; i < itemPositions.value.length; i++) {\r\n if (i == dragIdx) continue;\r\n\r\n const otherPosition = itemPositions.value[i];\r\n const isOverlapping =\r\n dragCenterX >= otherPosition.left &&\r\n dragCenterX <= otherPosition.left + otherPosition.width &&\r\n dragCenterY >= otherPosition.top &&\r\n dragCenterY <= otherPosition.top + otherPosition.height;\r\n\r\n if (isOverlapping) {\r\n return true;\r\n }\r\n }\r\n } else {\r\n // 检查是否向上移动超过上一个元素的中线\r\n if (dragIdx > 0) {\r\n const prevPosition = itemPositions.value[dragIdx - 1];\r\n const prevCenterY = prevPosition.top + prevPosition.height / 2;\r\n if (dragCenterY <= prevCenterY) {\r\n return true;\r\n }\r\n }\r\n\r\n // 检查是否向下移动超过下一个元素的中线\r\n if (dragIdx < itemPositions.value.length - 1) {\r\n const nextPosition = itemPositions.value[dragIdx + 1];\r\n const nextCenterY = nextPosition.top + nextPosition.height / 2;\r\n if (dragCenterY >= nextCenterY) {\r\n return true;\r\n }\r\n }\r\n }\r\n\r\n return false;\r\n}\r\n\r\n/**\r\n * 触摸开始事件处理\r\n * @param event 触摸事件对象\r\n * @param index 触摸的项目索引\r\n */\r\nasync function onTouchStart(event: any, index: number, type: string) {\r\n // 如果是长按触发,但未开启长按功能,则直接返回\r\n if (type == \"longpress\" && !props.longPress) return;\r\n // 如果是普通触摸触发,但已开启长按功能,则直接返回\r\n if (type == \"touch\" && props.longPress) return;\r\n\r\n // 检查是否禁用或索引无效\r\n if (props.disabled) return;\r\n if (getItemDisabled(index)) return;\r\n if (index < 0 || index >= list.value.length) return;\r\n\r\n // 获取触摸点\r\n const touch = event.touches[0];\r\n\r\n // 初始化拖拽状态\r\n dragging.value = true;\r\n\r\n // 初始化拖拽索引\r\n dragIndex.value = index;\r\n insertIndex.value = index; // 初始插入位置为原位置\r\n startX.value = touch.clientX;\r\n startY.value = touch.clientY;\r\n offsetX.value = 0;\r\n offsetY.value = 0;\r\n // 初始化拖拽数据项\r\n dragItem.value = list.value[index];\r\n\r\n // 先获取所有项目的位置信息,为后续计算做准备\r\n await getItemPosition();\r\n\r\n // 触发开始事件\r\n emit(\"start\", index);\r\n\r\n // 震动\r\n uni.$emit(\"cool.vibrate\");\r\n\r\n // 阻止事件冒泡\r\n if (event.stopPropagation) {\r\n event.stopPropagation();\r\n }\r\n // 阻止默认行为\r\n if (event.cancelable !== false && event.preventDefault) {\r\n event.preventDefault();\r\n }\r\n}\r\n\r\n/**\r\n * 触摸移动事件处理\r\n * @param event 触摸事件对象\r\n */\r\nfunction onTouchMove(event: TouchEvent): void {\r\n if (!dragging.value) return;\r\n\r\n const touch = event.touches[0];\r\n\r\n // 更新拖拽偏移量\r\n offsetX.value = touch.clientX - startX.value;\r\n offsetY.value = touch.clientY - startY.value;\r\n\r\n // 智能启动排序模拟:只有移出原元素区域才开始\r\n if (!sortingStarted.value) {\r\n if (checkMovedToOtherElement()) {\r\n sortingStarted.value = true;\r\n }\r\n }\r\n\r\n // 只有开始排序模拟后才计算插入位置\r\n if (sortingStarted.value) {\r\n // 计算拖拽元素当前的中心点坐标\r\n const dragPos = itemPositions.value[dragIndex.value];\r\n const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;\r\n const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;\r\n\r\n // 根据布局类型选择坐标轴:网格布局使用X坐标,单列布局使用Y坐标\r\n const dragCenter = props.columns > 1 ? dragCenterX : dragCenterY;\r\n\r\n // 计算最佳插入位置\r\n const newIndex = calculateInsertIndex(dragCenter);\r\n if (newIndex != insertIndex.value) {\r\n insertIndex.value = newIndex;\r\n }\r\n }\r\n\r\n // 阻止默认行为\r\n if (event.cancelable !== false && event.preventDefault) {\r\n event.preventDefault();\r\n }\r\n}\r\n\r\n/**\r\n * 触摸结束事件处理\r\n */\r\nfunction onTouchEnd(): void {\r\n if (!dragging.value) return;\r\n\r\n // 旧索引\r\n const oldIndex = dragIndex.value;\r\n\r\n // 新索引\r\n const newIndex = insertIndex.value;\r\n\r\n // 如果位置发生变化,立即更新数组\r\n if (oldIndex != newIndex && newIndex >= 0) {\r\n const newList = [...list.value];\r\n const item = newList.splice(oldIndex, 1)[0];\r\n newList.splice(newIndex, 0, item);\r\n list.value = newList;\r\n\r\n // 触发变化事件\r\n emit(\"update:modelValue\", list.value);\r\n emit(\"change\", list.value);\r\n }\r\n\r\n // 开始放下动画\r\n dropping.value = true;\r\n dragging.value = false;\r\n\r\n // 重置所有状态\r\n reset();\r\n\r\n // 等待放下动画完成后重置所有状态\r\n emit(\"end\", newIndex >= 0 ? newIndex : oldIndex);\r\n}\r\n\r\n/**\r\n * 根据平台选择合适的key\r\n * @param item 数据项\r\n * @param index 索引\r\n * @returns 合适的key\r\n */\r\nfunction getItemKey(item: any, index: number): string {\r\n // #ifdef MP\r\n // 小程序环境使用 index 作为 key,避免数据错乱\r\n return `${index}`;\r\n // #endif\r\n\r\n // #ifndef MP\r\n // 其他平台使用 uid,提供更好的性能\r\n return item[\"uid\"] as string;\r\n // #endif\r\n}\r\n\r\nwatch(\r\n computed(() => props.modelValue),\r\n (val: any[]) => {\r\n list.value = val.map((e) => {\r\n return {\r\n uid: e[\"uid\"] ?? uuid(),\r\n ...e\r\n };\r\n });\r\n },\r\n {\r\n immediate: true\r\n }\r\n);\r\n</script>\r\n\r\n<script module=\"dragWxs\" lang=\"wxs\">\r\nmodule.exports = {\r\n touchmove: function (e, ins) {\r\n if (e.currentTarget && e.currentTarget.dataset) {\r\n var dragging = e.currentTarget.dataset.dragging;\r\n // 判断是否在拖拽中(兼容各端 boolean 和 string)\r\n if (dragging === true || dragging === 'true') {\r\n return false; // 阻止默认的页面滚动行为\r\n }\r\n }\r\n return true;\r\n }\r\n}\r\n</script>\r\n\r\n<style lang=\"scss\" scoped>\r\n.reborn-draggable {\r\n &__item {\r\n &--dragging {\r\n @apply opacity-95 z-20 scale-105 shadow-md;\r\n }\r\n\r\n &--disabled {\r\n @apply opacity-60;\r\n }\r\n\r\n &--animating {\r\n @apply duration-200;\r\n transition-property: transform;\r\n }\r\n }\r\n}\r\n</style>\r\n",
|
|
27
|
+
"target": "uniapp"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"fileCount": 5,
|
|
31
|
+
"contentHash": "efb144f47af02ccecacbdb1e1a44c210eff02d19"
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-drawer",
|
|
3
|
+
"dependencies": [],
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "index.ts",
|
|
7
|
+
"content": "export { default as RebornDrawer } from './RebornDrawer.vue';\r\n"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "RebornDrawer.vue",
|
|
11
|
+
"content": "<script setup lang=\"ts\">\r\nimport { computed } from 'vue';\r\nimport RebornOverlay from '../reborn-overlay/RebornOverlay.vue';\r\nimport RebornTransition from '../reborn-transition/RebornTransition.vue';\r\n\r\nconst props = withDefaults(defineProps<{ modelValue?: boolean; placement?: 'left'|'right'|'top'|'bottom'; size?: string; zIndex?: number; closeOnClickOverlay?: boolean; lockScroll?: boolean; }>(), { modelValue: false, placement: 'right', size: '320px', zIndex: 20, closeOnClickOverlay: true, lockScroll: true });\r\nconst emit = defineEmits(['update:modelValue', 'close', 'open']);\r\n\r\nconst transitionName = computed(() => ({ left:'slide-right', right:'slide-right', top:'fade-down', bottom:'slide-up' }[props.placement] as any));\r\nconst panelStyle = computed(() => {\r\n if (props.placement === 'left' || props.placement === 'right') return `${props.placement}:0;top:0;height:100%;width:${props.size};`;\r\n return `${props.placement}:0;left:0;width:100%;height:${props.size};`;\r\n});\r\n</script>\r\n<template>\r\n <RebornOverlay :model-value=\"props.modelValue\" :z-index=\"props.zIndex\" :close-on-click-overlay=\"props.closeOnClickOverlay\" :lock-scroll=\"props.lockScroll\" @update:model-value=\"emit('update:modelValue', $event)\">\r\n <RebornTransition :show=\"props.modelValue\" :name=\"transitionName\" custom-class=\"pointer-events-auto fixed bg-white dark:bg-gray-900 shadow-xl\" :custom-style=\"`z-index:${props.zIndex + 1};${panelStyle}`\" @after-enter=\"emit('open')\" @after-leave=\"emit('close')\">\r\n <slot />\r\n </RebornTransition>\r\n </RebornOverlay>\r\n</template>\r\n",
|
|
12
|
+
"target": "web"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"fileCount": 2,
|
|
16
|
+
"contentHash": "d30b599d46ea70078fe9aef4abaf86abce7e71ca"
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-dropdown-select",
|
|
3
|
+
"dependencies": [],
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"path": "reborn-dropdown-select.config.ts",
|
|
7
|
+
"content": "const colors = ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'neutral'] as const\r\nconst sizes = ['sm', 'md', 'lg'] as const\r\n\r\nconst config = {\r\n slots: {\r\n wrapper: 'relative w-full',\r\n trigger: 'w-full',\r\n content: 'absolute top-full left-0 right-0 bg-white dark:bg-gray-8 border border-gray-2 dark:border-gray-7 rounded-md shadow-lg z-[999] max-h-[400rpx] overflow-y-auto mt-1',\r\n item: 'py-2 px-3 text-sm text-gray-8 dark:text-gray-2 border-b border-gray-100 dark:border-gray-7 last:border-b-0 active:bg-gray-50 dark:active:bg-gray-7 flex items-center justify-between',\r\n itemText: 'flex-1 truncate',\r\n itemIcon: 'w-4 h-4 text-blue-6 dark:text-blue-400',\r\n empty: 'py-3 text-center text-gray-400 text-sm',\r\n mask: 'fixed inset-0 z-[998]',\r\n },\r\n variants: {\r\n color: {\r\n primary: {\r\n item: 'active:bg-primary/10',\r\n itemIcon: 'text-primary',\r\n },\r\n secondary: {\r\n item: 'active:bg-secondary/10',\r\n itemIcon: 'text-secondary',\r\n },\r\n success: {\r\n item: 'active:bg-success/10',\r\n itemIcon: 'text-success',\r\n },\r\n info: {\r\n item: 'active:bg-info/10',\r\n itemIcon: 'text-info',\r\n },\r\n warning: {\r\n item: 'active:bg-warning/10',\r\n itemIcon: 'text-warning',\r\n },\r\n error: {\r\n item: 'active:bg-error/10',\r\n itemIcon: 'text-error',\r\n },\r\n neutral: {\r\n item: 'active:bg-neutral/10',\r\n itemIcon: 'text-neutral',\r\n },\r\n },\r\n size: {\r\n sm: {\r\n item: 'py-1.5 px-2 text-xs',\r\n },\r\n md: {\r\n item: 'py-2 px-3 text-sm',\r\n },\r\n lg: {\r\n item: 'py-3 px-4 text-base',\r\n },\r\n },\r\n disabled: {\r\n true: {\r\n wrapper: 'opacity-50 pointer-events-none',\r\n },\r\n },\r\n selected: {\r\n true: {\r\n itemText: 'font-medium',\r\n },\r\n },\r\n },\r\n compoundVariants: [\r\n { color: 'primary' as (typeof colors)[number], selected: true as const, class: { item: 'bg-primary/10 text-primary' } },\r\n { color: 'secondary' as (typeof colors)[number], selected: true as const, class: { item: 'bg-secondary/10 text-secondary' } },\r\n { color: 'success' as (typeof colors)[number], selected: true as const, class: { item: 'bg-success/10 text-success' } },\r\n { color: 'info' as (typeof colors)[number], selected: true as const, class: { item: 'bg-info/10 text-info' } },\r\n { color: 'warning' as (typeof colors)[number], selected: true as const, class: { item: 'bg-warning/10 text-warning' } },\r\n { color: 'error' as (typeof colors)[number], selected: true as const, class: { item: 'bg-error/10 text-error' } },\r\n { color: 'neutral' as (typeof colors)[number], selected: true as const, class: { item: 'bg-neutral/10 text-neutral' } },\r\n ],\r\n defaultVariants: {\r\n color: 'primary' as (typeof colors)[number],\r\n size: 'md' as (typeof sizes)[number],\r\n disabled: false,\r\n selected: false,\r\n },\r\n}\r\n\r\nexport { colors as dropdownSelectColors, sizes as dropdownSelectSizes }\r\nexport default config\r\n",
|
|
8
|
+
"target": "uniapp"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "RebornDropdownSelect.vue",
|
|
12
|
+
"content": "<script setup lang=\"ts\">\r\nimport { ref, computed } from 'vue'\r\nimport RebornSelectTrigger from '@/components/reborn-select-trigger/RebornSelectTrigger.vue'\r\nimport { tv } from '@/lib/tv'\r\nimport { cn } from '@/lib/utils'\r\nimport theme, { type dropdownSelectColors, type dropdownSelectSizes } from './reborn-dropdown-select.config'\r\n\r\ndefineOptions({\r\n name: 'RebornDropdownSelect',\r\n})\r\n\r\ninterface Option {\r\n label: string\r\n value: any\r\n}\r\n\r\ninterface Props {\r\n modelValue?: any\r\n options?: Option[]\r\n placeholder?: string\r\n disabled?: boolean\r\n size?: typeof dropdownSelectSizes[number]\r\n color?: typeof dropdownSelectColors[number]\r\n clearable?: boolean\r\n ui?: Partial<{\r\n wrapper?: string\r\n trigger?: string\r\n content?: string\r\n item?: string\r\n itemText?: string\r\n itemIcon?: string\r\n empty?: string\r\n mask?: string\r\n }>\r\n customClass?: any\r\n}\r\n\r\nconst props = withDefaults(defineProps<Props>(), {\r\n options: () => [],\r\n placeholder: '请选择',\r\n disabled: false,\r\n size: 'md',\r\n color: 'primary',\r\n clearable: false,\r\n ui: () => ({}),\r\n})\r\n\r\nconst emit = defineEmits(['update:modelValue', 'change'])\r\n\r\nconst isOpen = ref(false)\r\n\r\nconst selectedLabel = computed(() => {\r\n const option = props.options.find(opt => opt.value === props.modelValue)\r\n return option ? option.label : ''\r\n})\r\n\r\n// ui 样式系统\r\nconst b = tv(theme)\r\nconst ui = computed(() => {\r\n const styles = b({\r\n color: props.color,\r\n size: props.size,\r\n disabled: props.disabled,\r\n })\r\n\r\n return {\r\n wrapper: (opts?: { class?: any }) => styles.wrapper({ class: cn(opts?.class, props.ui?.wrapper) }),\r\n trigger: (opts?: { class?: any }) => styles.trigger({ class: cn(opts?.class, props.ui?.trigger) }),\r\n content: (opts?: { class?: any }) => styles.content({ class: cn(opts?.class, props.ui?.content) }),\r\n item: (opts?: { class?: any; selected?: boolean }) => styles.item({ selected: opts?.selected, class: cn(opts?.class, props.ui?.item) }),\r\n itemText: (opts?: { class?: any; selected?: boolean }) => styles.itemText({ selected: opts?.selected, class: cn(opts?.class, props.ui?.itemText) }),\r\n itemIcon: (opts?: { class?: any }) => styles.itemIcon({ class: cn(opts?.class, props.ui?.itemIcon) }),\r\n empty: (opts?: { class?: any }) => styles.empty({ class: cn(opts?.class, props.ui?.empty) }),\r\n mask: (opts?: { class?: any }) => styles.mask({ class: cn(opts?.class, props.ui?.mask) }),\r\n }\r\n})\r\n\r\nfunction toggleDropdown() {\r\n if (props.disabled) return\r\n isOpen.value = !isOpen.value\r\n}\r\n\r\nfunction closeDropdown() {\r\n isOpen.value = false\r\n}\r\n\r\nfunction selectOption(option: Option) {\r\n emit('update:modelValue', option.value)\r\n emit('change', option.value)\r\n closeDropdown()\r\n}\r\n\r\nfunction onClear() {\r\n emit('update:modelValue', null)\r\n emit('change', null)\r\n}\r\n</script>\r\n\r\n<template>\r\n <view :class=\"ui.wrapper({ class: props.customClass })\">\r\n <RebornSelectTrigger :customClass=\"ui.trigger()\" :text=\"selectedLabel\" :placeholder=\"placeholder\"\r\n :disabled=\"disabled\" :size=\"size\" :color=\"color\" :focus=\"isOpen\" :clearable=\"clearable\" @open=\"toggleDropdown\"\r\n @clear=\"onClear\" />\r\n\r\n <!-- Mask to close dropdown -->\r\n <view v-if=\"isOpen\" :class=\"ui.mask()\" @tap=\"closeDropdown\" />\r\n\r\n <view v-if=\"isOpen\" :class=\"ui.content()\">\r\n <view v-for=\"(item, index) in options\" :key=\"index\" :class=\"ui.item({ selected: item.value === modelValue })\"\r\n @tap.stop=\"selectOption(item)\">\r\n <text :class=\"ui.itemText({ selected: item.value === modelValue })\">{{ item.label }}</text>\r\n <text v-if=\"item.value === modelValue\" class=\"i-lucide-check\" :class=\"ui.itemIcon()\" />\r\n </view>\r\n <view v-if=\"options.length === 0\" :class=\"ui.empty()\">\r\n 无数据\r\n </view>\r\n </view>\r\n </view>\r\n</template>\r\n",
|
|
13
|
+
"target": "uniapp"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"fileCount": 2,
|
|
17
|
+
"contentHash": "b42818ced837140c4a9c533b86c37864f104de79"
|
|
18
|
+
}
|