Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions examples/sites/demos/mobile-first/app/space/space-order.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<template>
<tiny-space :order="order" style="border: 1px dashed #ccc">
<tiny-button>First Button</tiny-button>
<tiny-button>Second Button</tiny-button>
<tiny-button>Third Button</tiny-button>
<tiny-button key="1">First Button</tiny-button>
<tiny-button key="2">Second Button</tiny-button>
<tiny-button key="3">Third Button</tiny-button>
<tiny-button>Fourth Button</tiny-button>
</tiny-space>
</template>

<script setup>
import { TinyButton, TinySpace } from '@opentiny/vue'

const order = ['2', '3', '1'] // 自定义顺序:第二个、第三个、然后是第一个
const order = ['2', '3', '1'] // 自定义顺序:第二个、第三个、然后第一个
</script>
47 changes: 26 additions & 21 deletions examples/sites/demos/mobile-first/app/space/webdoc/space.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export default {
'en-US': 'Basic Usage'
},
desc: {
'zh-CN': '<p>默认横向排列,支持自动插槽分隔间距</p>',
'en-US': '<p>Horizontal layout by default, with automatic spacing between slots</p>'
'zh-CN': '<p>默认采用横向布局(row),自动为插槽内容添加间距。</p>',
'en-US': '<p>Uses horizontal layout (row) by default, automatically adding spacing between slot content.</p>'
},
codeFiles: ['basic-usage.vue']
},
Expand All @@ -21,71 +21,76 @@ export default {
'en-US': 'Spacing Size'
},
desc: {
'zh-CN': '<p>通过 `size` 属性设置间距,支持 small / medium / large 或自定义数值 / 数组。</p>',
'zh-CN':
'<p>通过 <code>size</code> 属性设置间距大小,支持 small、medium、large 预定义值或自定义数值/数组。</p>',
'en-US':
'<p>Use the `size` prop to define spacing. Supports small / medium / large or custom values / arrays.</p>'
'<p>Use the <code>size</code> prop to set spacing size. Supports predefined values (small, medium, large) or custom values/arrays.</p>'
},
codeFiles: ['space-size.vue']
},
{
demoId: 'space-direction',
name: {
'zh-CN': '排列方向',
'en-US': 'Direction'
'en-US': 'Layout Direction'
},
desc: {
'zh-CN': '<p>通过 `direction` 属性设置排列方向,支持 horizontal 或 vertical。</p>',
'en-US': '<p>Use the `direction` prop to control layout direction: horizontal or vertical.</p>'
'zh-CN': '<p>通过 <code>direction</code> 属性设置布局方向,支持 row(横向)或 column(纵向)。</p>',
'en-US':
'<p>Use the <code>direction</code> prop to set layout direction: row (horizontal) or column (vertical).</p>'
},
codeFiles: ['space-direction.vue']
},
{
demoId: 'space-wrap',
name: {
'zh-CN': '换行显示',
'en-US': 'Wrapping'
'en-US': 'Content Wrapping'
},
desc: {
'zh-CN': '<p>通过 `wrap` 属性控制是否换行显示内容。</p>',
'en-US': '<p>Use the `wrap` prop to enable wrapping of child items.</p>'
'zh-CN': '<p>通过 <code>wrap</code> 属性控制子项内容是否换行显示。</p>',
'en-US': '<p>Use the <code>wrap</code> prop to control whether child items wrap to multiple lines.</p>'
},
codeFiles: ['space-wrap.vue']
},
{
demoId: 'space-align',
name: {
'zh-CN': '对齐方式',
'en-US': 'Alignment'
'zh-CN': '交叉轴对齐',
'en-US': 'Cross Axis Alignment'
},
desc: {
'zh-CN': '<p>通过 `align` 设置交叉轴对齐方式,如 start、center、end、baseline 等。</p>',
'en-US': '<p>Use `align` to define alignment on the cross axis, such as start, center, end, or baseline.</p>'
'zh-CN': '<p>通过 <code>align</code> 属性设置交叉轴对齐方式,支持 start、center、end、baseline 等值。</p>',
'en-US':
'<p>Use the <code>align</code> prop to define alignment on the cross axis, supporting values like start, center, end, and baseline.</p>'
},
codeFiles: ['space-align.vue']
},
{
demoId: 'space-justify',
name: {
'zh-CN': '主轴对齐方式',
'en-US': 'Justify Content'
'zh-CN': '主轴对齐',
'en-US': 'Main Axis Justification'
},
desc: {
'zh-CN':
'<p>通过 `justify` 设置主轴对齐方式,如 start、center、end、space-between、space-around、space-evenly。</p>',
'<p>通过 <code>justify</code> 属性设置主轴对齐方式,支持 start、center、end、space-between、space-around、space-evenly。</p>',
'en-US':
'<p>Use `justify` to set main axis alignment like start, center, end, space-between, space-around, space-evenly.</p>'
'<p>Use the <code>justify</code> prop to set main axis alignment, supporting start, center, end, space-between, space-around, and space-evenly.</p>'
},
codeFiles: ['space-justify.vue']
},
{
demoId: 'space-order',
name: {
'zh-CN': '自定义排序',
'en-US': 'Custom Order'
'en-US': 'Custom Ordering'
},
desc: {
'zh-CN': '<p>通过 `order` 属性传入 key 数组,自定义子元素渲染顺序。</p>',
'en-US': '<p>Use the `order` prop with a key array to customize rendering order of children.</p>'
'zh-CN':
'<p>通过 <code>order</code> 属性传入 key 数组来自定义子元素的渲染顺序,未设置 key 的子元素将自动排列在最后。</p>',
'en-US':
'<p>Use the <code>order</code> prop with an array of keys to customize the rendering order of child elements. Children without defined keys are automatically arranged at the end.</p>'
},
codeFiles: ['space-order.vue']
}
Expand Down
9 changes: 5 additions & 4 deletions examples/sites/demos/pc/app/space/space-order.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<template>
<tiny-space :order="order" style="border: 1px dashed #ccc">
<tiny-button>First Button</tiny-button>
<tiny-button>Second Button</tiny-button>
<tiny-button>Third Button</tiny-button>
<tiny-button key="1">First Button</tiny-button>
<tiny-button key="2">Second Button</tiny-button>
<tiny-button key="3">Third Button</tiny-button>
<tiny-button>Fourth Button</tiny-button>
</tiny-space>
</template>

<script setup>
import { TinyButton, TinySpace } from '@opentiny/vue'

const order = ['2', '3', '1'] // 自定义顺序:第二个、第三个、然后是第一个
const order = ['2', '3', '1'] // 自定义顺序:第二个、第三个、然后第一个
</script>
49 changes: 27 additions & 22 deletions examples/sites/demos/pc/app/space/webdoc/space.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ export default {
owner: '',
demos: [
{
demoId: 'basic-usage',
demoId: 'basic-space',
name: {
'zh-CN': '基本用法',
'en-US': 'Basic Usage'
},
desc: {
'zh-CN': '<p>默认横向排列,支持自动插槽分隔间距</p>',
'en-US': '<p>Horizontal layout by default, with automatic spacing between slots</p>'
'zh-CN': '<p>默认采用横向布局(row),自动为插槽内容添加间距。</p>',
'en-US': '<p>Uses horizontal layout (row) by default, automatically adding spacing between slot content.</p>'
},
codeFiles: ['basic-usage.vue']
},
Expand All @@ -21,71 +21,76 @@ export default {
'en-US': 'Spacing Size'
},
desc: {
'zh-CN': '<p>通过 `size` 属性设置间距,支持 small / medium / large 或自定义数值 / 数组。</p>',
'zh-CN':
'<p>通过 <code>size</code> 属性设置间距大小,支持 small、medium、large 预定义值或自定义数值/数组。</p>',
'en-US':
'<p>Use the `size` prop to define spacing. Supports small / medium / large or custom values / arrays.</p>'
'<p>Use the <code>size</code> prop to set spacing size. Supports predefined values (small, medium, large) or custom values/arrays.</p>'
},
codeFiles: ['space-size.vue']
},
{
demoId: 'space-direction',
name: {
'zh-CN': '排列方向',
'en-US': 'Direction'
'en-US': 'Layout Direction'
},
desc: {
'zh-CN': '<p>通过 `direction` 属性设置排列方向,支持 row 或column。</p>',
'en-US': '<p>Use the `direction` prop to control layout direction: row or column.</p>'
'zh-CN': '<p>通过 <code>direction</code> 属性设置布局方向,支持 row(横向)或 column(纵向)。</p>',
'en-US':
'<p>Use the <code>direction</code> prop to set layout direction: row (horizontal) or column (vertical).</p>'
},
codeFiles: ['space-direction.vue']
},
{
demoId: 'space-wrap',
name: {
'zh-CN': '换行显示',
'en-US': 'Wrapping'
'en-US': 'Content Wrapping'
},
desc: {
'zh-CN': '<p>通过 `wrap` 属性控制是否换行显示内容。</p>',
'en-US': '<p>Use the `wrap` prop to enable wrapping of child items.</p>'
'zh-CN': '<p>通过 <code>wrap</code> 属性控制子项内容是否换行显示。</p>',
'en-US': '<p>Use the <code>wrap</code> prop to control whether child items wrap to multiple lines.</p>'
},
codeFiles: ['space-wrap.vue']
},
{
demoId: 'space-align',
name: {
'zh-CN': '对齐方式',
'en-US': 'Alignment'
'zh-CN': '交叉轴对齐',
'en-US': 'Cross Axis Alignment'
},
desc: {
'zh-CN': '<p>通过 `align` 设置交叉轴对齐方式,如 start、center、end、baseline 等。</p>',
'en-US': '<p>Use `align` to define alignment on the cross axis, such as start, center, end, or baseline.</p>'
'zh-CN': '<p>通过 <code>align</code> 属性设置交叉轴对齐方式,支持 start、center、end、baseline 等值。</p>',
'en-US':
'<p>Use the <code>align</code> prop to define alignment on the cross axis, supporting values like start, center, end, and baseline.</p>'
},
codeFiles: ['space-align.vue']
},
{
demoId: 'space-justify',
name: {
'zh-CN': '主轴对齐方式',
'en-US': 'Justify Content'
'zh-CN': '主轴对齐',
'en-US': 'Main Axis Justification'
},
desc: {
'zh-CN':
'<p>通过 `justify` 设置主轴对齐方式,如 start、center、end、space-between、space-around、space-evenly。</p>',
'<p>通过 <code>justify</code> 属性设置主轴对齐方式,支持 start、center、end、space-between、space-around、space-evenly。</p>',
'en-US':
'<p>Use `justify` to set main axis alignment like start, center, end, space-between, space-around, space-evenly.</p>'
'<p>Use the <code>justify</code> prop to set main axis alignment, supporting start, center, end, space-between, space-around, and space-evenly.</p>'
},
codeFiles: ['space-justify.vue']
},
{
demoId: 'space-order',
name: {
'zh-CN': '自定义排序',
'en-US': 'Custom Order'
'en-US': 'Custom Ordering'
},
desc: {
'zh-CN': '<p>通过 `order` 属性传入 key 数组,自定义子元素渲染顺序。</p>',
'en-US': '<p>Use the `order` prop with a key array to customize rendering order of children.</p>'
'zh-CN':
'<p>通过 <code>order</code> 属性传入 key 数组来自定义子元素的渲染顺序,未设置 key 的子元素将自动排列在最后。</p>',
'en-US':
'<p>Use the <code>order</code> prop with an array of keys to customize the rendering order of child elements. Children without defined keys are automatically arranged at the end.</p>'
},
codeFiles: ['space-order.vue']
}
Expand Down
39 changes: 36 additions & 3 deletions packages/renderless/src/space/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,47 @@
import type { ISpaceProps } from '@/types'
import { getGapStyle } from './index'

export const api = ['state']
export const api = ['state', 'orderedChildren']

export const renderless = (props: ISpaceProps, hooks, { constants }) => {
function isVNodeFn(node: any): boolean {
return !!(node && (node.__v_isVNode || node.componentOptions))
}

export const renderless = (props: ISpaceProps, hooks, { slots }) => {
const { reactive, computed } = hooks

const state = reactive({
gapStyle: computed(() => getGapStyle(props))
})

return { state }
// 排序逻辑
const orderedChildren = computed(() => {
const children = slots.default?.() || []

// 过滤掉非 VNode 或注释节点
const validChildren = children.filter((v) => {
if (!isVNodeFn(v)) return false
const type = (v as any).type
return type !== 'Comment' && type !== Symbol.for('v-comment')
})

if (!props.order?.length) return validChildren

// 根据 key 或 class 建立索引
const map: Record<string, any> = {}
validChildren.forEach((child) => {
const key = child.key ?? (Array.isArray(child.props?.class) ? child.props.class.join(' ') : child.props?.class)
if (key) map[String(key)] = child
})

// 按 order 排序
const sorted = props.order.map((k) => map[k]).filter(Boolean)

// 剩余没有在 order 里的保持原顺序
const rest = validChildren.filter((v) => !props.order.includes(String(v.key)))

return [...sorted, ...rest]
})
Comment on lines +31 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ordering bug: duplicates and missed matches when children lack key or when order uses non-string keys.

Issues:

  • sorted lookup uses map[k] while map keys are String(key) → misses numeric/symbol keys.
  • rest filter only checks v.key, so children matched by class fallback can appear twice (in both sorted and rest).

Apply this refactor to use a single child-id strategy and consistent Set-based filtering:

 function isVNodeFn(node: any): boolean {
   return !!(node && (node.__v_isVNode || node.componentOptions))
 }
 
 export const renderless = (props: ISpaceProps, hooks, { slots }) => {
   const { reactive, computed } = hooks
@@
   // 排序逻辑
   const orderedChildren = computed(() => {
     const children = slots.default?.() || []
 
     // 过滤掉非 VNode 或注释节点
     const validChildren = children.filter((v) => {
       if (!isVNodeFn(v)) return false
       const type = (v as any).type
       return type !== 'Comment' && type !== Symbol.for('v-comment')
     })
 
     if (!props.order?.length) return validChildren
 
-    // 根据 key 或 class 建立索引
-    const map: Record<string, any> = {}
-    validChildren.forEach((child) => {
-      const key = child.key ?? (Array.isArray(child.props?.class) ? child.props.class.join(' ') : child.props?.class)
-      if (key) map[String(key)] = child
-    })
-
-    // 按 order 排序
-    const sorted = props.order.map((k) => map[k]).filter(Boolean)
-
-    // 剩余没有在 order 里的保持原顺序
-    const rest = validChildren.filter((v) => !props.order.includes(String(v.key)))
-
-    return [...sorted, ...rest]
+    // 统一子元素标识:优先使用 key,否则使用 class(字符串或数组),对象 class 取为启用的键名
+    const getChildId = (child: any): string | undefined => {
+      const cls = child.props?.class
+      const clsStr = Array.isArray(cls)
+        ? cls.join(' ')
+        : typeof cls === 'object' && cls
+        ? Object.keys(cls).filter((k) => !!cls[k]).join(' ')
+        : cls
+      const id = child.key ?? clsStr
+      return id != null ? String(id) : undefined
+    }
+
+    // 根据标识建立索引
+    const map = new Map<string, any>()
+    validChildren.forEach((child) => {
+      const id = getChildId(child)
+      if (id) map.set(id, child)
+    })
+
+    // 目标顺序集合(字符串化)
+    const orderSet = new Set((props.order as any[]).map((k) => String(k)))
+
+    // 按 order 排序
+    const sorted = Array.from(orderSet, (k) => map.get(k)).filter(Boolean)
+
+    // 剩余没有在 order 里的保持原顺序(根据统一标识排除)
+    const rest = validChildren.filter((child) => {
+      const id = getChildId(child)
+      return !id || !orderSet.has(id)
+    })
+
+    return [...sorted, ...rest]
   })
 
   return { state, orderedChildren }
 }

Also applies to: 7-9

🤖 Prompt for AI Agents
In packages/renderless/src/space/vue.ts around lines 31 to 45, the current
ordering logic inconsistently keys children (String(key) vs numeric/symbol order
entries) and filters rest by v.key causing duplicates when class is used as
fallback; replace with a single stable child-id strategy: compute an id for each
child once (e.g. String(child.key ?? (Array.isArray(child.props?.class) ?
child.props.class.join(' ') : child.props?.class ?? ''))), build a Map from that
id to the child, convert all entries in props.order to String when looking up
the Map to produce the sorted array, track matched ids in a Set while building
sorted, and filter the rest by checking that child id is not in the matched Set
so children matched by class fallback are not duplicated; ensure consistent
string keys and handle missing ids gracefully.


return { state, orderedChildren }
}
37 changes: 16 additions & 21 deletions packages/vue/src/space/__tests__/space.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,6 @@ import Space from '@opentiny/vue-space'
describe('PC Mode', () => {
const mount = mountPcMode

test('base 基本用法', async () => {
const wrapper = mount(() => (
<Space>
<span>Item 1</span>
<span>Item 2</span>
</Space>
))

// 1. 验证容器元素
expect(wrapper.find('[data-tag="tiny-space"]').exists()).toBe(true)

// 2. 验证子元素
expect(wrapper.findAll('[data-tag="tiny-space"] > *').length).toBe(2)

// 3. 验证文本内容
expect(wrapper.text()).toContain('Item 1')
expect(wrapper.text()).toContain('Item 2')

wrapper.unmount()
})

test('props direction', async () => {
const wrapper = mount(() => (
<Space direction="column">
Expand Down Expand Up @@ -73,4 +52,20 @@ describe('PC Mode', () => {
expect(wrapper.text()).toContain('Slot 2')
wrapper.unmount()
})

test('child element order', async () => {
const wrapper = mount(() => (
<Space>
<span class="item">A</span>
<span class="item">B</span>
<span class="item">C</span>
</Space>
))
const items = wrapper.findAll('.item')
expect(items.length).toBe(3)
expect(items[0].text()).toBe('A')
expect(items[1].text()).toBe('B')
expect(items[2].text()).toBe('C')
wrapper.unmount()
})
})
4 changes: 4 additions & 0 deletions packages/vue/src/space/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export const spaceProps = {
type: String,
default: ''
},
order: {
type: Array as PropType<string[]>,
default: () => []
},
/** 自定义样式 */
customStyle: {
type: Object as PropType<Record<string, any>>,
Expand Down
Loading
Loading