refactor(flex): use SFC (#8308)

* refactor(flex): use SFC

* test: add unit tests
feat/vapor
selicens 2025-08-23 09:21:35 +08:00 committed by GitHub
parent 5cc4a63480
commit a15bb9cfb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 535 additions and 0 deletions

View File

@ -0,0 +1,93 @@
<template>
<a-flex gap="middle" vertical>
<label>
Select axis:
<select v-model="axis">
<option v-for="item in axisOptions" :key="item">{{ item }}</option>
</select>
</label>
<a-flex :vertical="axis === 'vertical'">
<div
v-for="(item, index) in new Array(4)"
:key="item"
:style="{ ...baseStyle, background: `${index % 2 ? '#1677ff' : '#1677ffbf'}` }"
/>
</a-flex>
<hr/>
<label>
Select justify:
<select v-model="justify">
<option v-for="item in justifyOptions" :key="item">{{ item }}</option>
</select>
</label>
<label>
Select align:
<select v-model="align">
<option v-for="item in alignOptions" :key="item">{{ item }}</option>
</select>
</label>
<a-flex :style="{ ...boxStyle }" :justify="justify" :align="align">
<a-button variant="solid">Primary</a-button>
<a-button variant="solid">Primary</a-button>
<a-button variant="solid">Primary</a-button>
<a-button variant="solid">Primary</a-button>
</a-flex>
<hr/>
<a-flex gap="middle" vertical>
<label>
Select gap size:
<select v-model="gapSize">
<option v-for="item in gapSizeOptions" :key="item">{{ item }}</option>
</select>
</label>
<a-flex :gap="gapSize">
<a-button variant="solid">Primary</a-button>
<a-button>Default</a-button>
<a-button variant="dashed">Dashed</a-button>
<a-button variant="link">Link</a-button>
</a-flex>
</a-flex>
<hr/>
<label>
Auto wrap:
</label>
<a-flex wrap="wrap" gap="small">
<a-button v-for="item in new Array(24)" :key="item" variant="solid">Button</a-button>
</a-flex>
</a-flex>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { ref, reactive } from 'vue';
const baseStyle: CSSProperties = {
width: '25%',
height: '54px',
};
const boxStyle: CSSProperties = {
width: '100%',
height: '120px',
borderRadius: '6px',
border: '1px solid #40a9ff',
};
const axisOptions = reactive(['horizontal', 'vertical']);
const axis = ref(axisOptions[0]);
const justifyOptions = reactive([
'flex-start',
'center',
'flex-end',
'space-between',
'space-around',
'space-evenly',
]);
const justify = ref(justifyOptions[0]);
const alignOptions = reactive(['flex-start', 'center', 'flex-end']);
const align = ref(alignOptions[0]);
const gapSizeOptions = reactive(['small', 'middle', 'large']);
const gapSize = ref(gapSizeOptions[0]);
</script>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, withDefaults } from 'vue'
import { type FlexProps, flexDefaultProps } from './meta'
import { isPresetSize } from '@/utils/gapSize'
import createFlexClassNames from './utils'
defineOptions({ name: 'AFlex' })
const props = withDefaults(defineProps<FlexProps>(), flexDefaultProps)
const mergedCls = computed(() => [
createFlexClassNames(props.prefixCls, props),
{
'ant-flex': true,
'ant-flex-vertical': props.vertical,
'ant-flex-rtl': false,
[`ant-flex-gap-${props.gap}`]: isPresetSize(props.gap),
}
])
const mergedStyle = computed(() => {
const style: CSSProperties = {}
if (props.flex) {
style.flex = props.flex
}
if (props.gap && !isPresetSize(props.gap)) {
style.gap = `${props.gap}px`
}
return style
})
</script>
<template>
<component :is="componentTag" :class="[$attrs.class, mergedCls]" :style="[$attrs.style, mergedStyle]" v-bind="$attrs">
<slot />
</component>
</template>
<style scoped></style>

View File

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Flex > should render correctly 1`] = `
"<div class="ant-flex">
<div>test</div>
</div>"
`;

View File

@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest'
import { Flex } from '@ant-design-vue/ui'
import { mount } from '@vue/test-utils'
describe('Flex', () => {
it('should render correctly', () => {
const wrapper = mount(Flex, {
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('Flex', () => {
const wrapper = mount(Flex, {
props: {
justify: 'center'
},
slots: {
default: `<div>test</div>`
},
});
const wrapper3 = mount(Flex, {
props: {
flex: '0 1 auto',
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.classes('ant-flex')).toBeTruthy();
expect(wrapper.find('.ant-flex-justify-center')).toBeTruthy();
expect(wrapper3.classes('ant-flex')).toBeTruthy();
expect(wrapper3.element.style.flex).toBe('0 1 auto');
});
describe('Props: gap', () => {
it('support string', () => {
const wrapper = mount(Flex, {
props: {
gap: 'inherit',
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.classes('ant-flex')).toBeTruthy();
expect(wrapper.element.style.gap).toBe('inherit');
});
it('support number', () => {
const wrapper = mount(Flex, {
props: {
gap: '100',
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.classes('ant-flex')).toBeTruthy();
expect(wrapper.element.style.gap).toBe('100px');
});
it('support preset size', () => {
const wrapper = mount(Flex, {
props: {
gap: 'small',
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.classes('ant-flex')).toBeTruthy();
expect(wrapper.classes('ant-flex-gap-small')).toBeTruthy();
});
});
it('Component work', () => {
const wrapper = mount(Flex, {
slots: {
default: `<div>test</div>`
},
});
const wrapper2 = mount(Flex, {
props: {
componentTag: 'span'
},
slots: {
default: `<div>test</div>`
},
});
expect(wrapper.find('.ant-flex').element.tagName).toBe('DIV');
expect(wrapper2.find('.ant-flex').element.tagName).toBe('SPAN');
});
it('when vertical=true should stretch work', () => {
const wrapper = mount(Flex, {
props: {
vertical: true
},
slots: {
default: `<div>test</div>`
},
});
const wrapper2 = mount(Flex, {
props: {
vertical: true,
align: 'center',
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.find('.ant-flex-align-stretch')).toBeTruthy();
expect(wrapper2.find('.ant-flex-align-center')).toBeTruthy();
});
it('wrap prop shouled support boolean', () => {
const wrapper = mount(Flex, {
props: {
wrap: 'wrap',
},
slots: {
default: `<div>test</div>`,
},
});
const wrapper2 = mount(Flex, {
props: {
wrap: true,
},
slots: {
default: `<div>test</div>`,
},
});
expect(wrapper.classes('ant-flex-wrap-wrap')).toBeTruthy();
expect(wrapper2.classes('ant-flex-wrap-wrap')).toBeTruthy();
})
})

View File

@ -0,0 +1,14 @@
import { App, Plugin } from 'vue'
import Flex from './Flex.vue'
import './style/index.css'
export { default as Flex } from './Flex.vue'
export * from './meta'
Flex.install = function (app: App) {
app.component('AFlex', Flex)
return app
}
export default Flex as typeof Flex & Plugin

View File

@ -0,0 +1,20 @@
import { CSSProperties } from "vue"
type SizeType = 'small' | 'middle' | 'large' | undefined
export type FlexProps = {
prefixCls?: string
rootClassName?: string
vertical?: boolean
wrap?: CSSProperties['flexWrap'] | boolean
justify?: CSSProperties['justifyContent']
align?: CSSProperties['alignItems']
flex?: CSSProperties['flex']
gap?: CSSProperties['gap'] | SizeType
componentTag?: any
}
export const flexDefaultProps = {
prefixCls: 'ant-flex',
componentTag: 'div',
} as const

View File

@ -0,0 +1,95 @@
@reference '../../../style/tailwind.css';
.ant-flex {
@apply flex;
@apply m-0;
@apply p-0;
&:where(.ant-flex-vertical) {
@apply flex-col;
}
&:where(.ant-flex-rtl) {
@apply flex-row-reverse;
}
/* gap */
&:where(.ant-flex-gap-small) {
@apply gap-[8px];
}
&:where(.ant-flex-gap-middle) {
@apply gap-[16px];
}
&:where(.ant-flex-gap-large) {
@apply gap-[32px];
}
/* wrap */
&:where(.ant-flex-wrap-wrap) {
@apply flex-wrap;
}
&:where(.ant-flex-wrap-nowrap) {
@apply flex-nowrap;
}
&:where(.ant-flex-wrap-wrap-reverse) {
@apply flex-wrap-reverse;
}
/* align */
&:where(.ant-flex-align-center) {
@apply items-center;
}
&:where(.ant-flex-align-start) {
@apply items-start;
}
&:where(.ant-flex-align-end) {
@apply items-end;
}
&:where(.ant-flex-align-flex-start) {
@apply items-start;
}
&:where(.ant-flex-align-flex-end) {
@apply items-end;
}
&:where(.ant-flex-align-self-start) {
@apply self-start;
}
&:where(.ant-flex-align-self-end) {
@apply self-end;
}
&:where(.ant-flex-align-baseline) {
@apply items-baseline;
}
&:where(.ant-flex-align-normal) {
@apply content-normal;
}
&:where(.ant-flex-align-stretch) {
@apply items-stretch;
}
/* justify */
&:where(.ant-flex-justify-flex-start) {
@apply justify-start;
}
&:where(.ant-flex-justify-flex-end) {
@apply justify-end;
}
&:where(.ant-flex-justify-start) {
@apply justify-start;
}
&:where(.ant-flex-justify-end) {
@apply justify-end;
}
&:where(.ant-flex-justify-center) {
@apply justify-center;
}
&:where(.ant-flex-justify-space-between) {
@apply justify-between;
}
&:where(.ant-flex-justify-space-around) {
@apply justify-around;
}
&:where(.ant-flex-justify-space-evenly) {
@apply justify-evenly;
}
&:where(.ant-flex-justify-stretch) {
@apply justify-stretch;
}
&:where(.ant-flex-justify-normal) {
@apply justify-normal;
}
}

View File

@ -0,0 +1,70 @@
import classNames from '../../utils/classNames'
import type { FlexProps } from './meta'
export const flexWrapValues = ['wrap', 'nowrap', 'wrap-reverse'] as const
export const justifyContentValues = [
'flex-start',
'flex-end',
'start',
'end',
'center',
'space-between',
'space-around',
'space-evenly',
'stretch',
'normal',
'left',
'right',
] as const
export const alignItemsValues = [
'center',
'start',
'end',
'flex-start',
'flex-end',
'self-start',
'self-end',
'baseline',
'normal',
'stretch',
] as const
const genClsWrap = (prefixCls: string, props: FlexProps) => {
const wrapCls: Record<PropertyKey, boolean> = {}
flexWrapValues.forEach(cssKey => {
// Handle both boolean attribute (wrap="wrap") and string value (wrap="wrap")
const isMatch = props.wrap === true && cssKey === 'wrap' || props.wrap === cssKey
wrapCls[`${prefixCls}-wrap-${cssKey}`] = isMatch
})
return wrapCls
}
const genClsAlign = (prefixCls: string, props: FlexProps) => {
const alignCls: Record<PropertyKey, boolean> = {}
alignItemsValues.forEach(cssKey => {
alignCls[`${prefixCls}-align-${cssKey}`] = props.align === cssKey
})
alignCls[`${prefixCls}-align-stretch`] = !props.align && !!props.vertical
return alignCls
}
const genClsJustify = (prefixCls: string, props: FlexProps) => {
const justifyCls: Record<PropertyKey, boolean> = {}
justifyContentValues.forEach(cssKey => {
justifyCls[`${prefixCls}-justify-${cssKey}`] = props.justify === cssKey
})
return justifyCls
}
function createFlexClassNames(prefixCls: string, props: FlexProps) {
return classNames({
...genClsWrap(prefixCls, props),
...genClsAlign(prefixCls, props),
...genClsJustify(prefixCls, props),
})
}
export default createFlexClassNames

View File

@ -2,3 +2,4 @@ export { default as Button } from './button'
export { default as Input } from './input'
export { default as Theme } from './theme'
export { default as Affix } from './affix'
export { default as Flex } from './flex'

View File

@ -0,0 +1,27 @@
import { isArray, isString, isObject } from './util'
function classNames(...args: any[]) {
const classes = []
for (let i = 0; i < args.length; i++) {
const value = args[i]
if (!value) continue
if (isString(value)) {
classes.push(value)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const inner = classNames(value[i])
if (inner) {
classes.push(inner)
}
}
} else if (isObject(value)) {
for (const name in value) {
if (value[name]) {
classes.push(name)
}
}
}
}
return classes.join(' ')
}
export default classNames

View File

@ -0,0 +1,13 @@
export type SizeType = 'small' | 'middle' | 'large' | undefined
export function isPresetSize(size?: SizeType | string | number): size is SizeType {
return ['small', 'middle', 'large'].includes(size as string)
}
export function isValidGapNumber(size?: SizeType | string | number): size is number {
if (!size) {
// The case of size = 0 is deliberately excluded here, because the default value of the gap attribute in CSS is 0, so if the user passes 0 in, we can directly ignore it.
return false
}
return typeof size === 'number' && !Number.isNaN(size)
}

View File

@ -0,0 +1,4 @@
export const isArray = Array.isArray
export const isString = val => typeof val === 'string'
export const isSymbol = val => typeof val === 'symbol'
export const isObject = val => val !== null && typeof val === 'object'