diff --git a/packages/cdk/utils/src/props.ts b/packages/cdk/utils/src/props.ts index ce2954bbe..148b51192 100644 --- a/packages/cdk/utils/src/props.ts +++ b/packages/cdk/utils/src/props.ts @@ -217,4 +217,4 @@ export function callEmit any>( export type VKey = string | number | symbol -export const vKeyPropDef = IxPropTypes.oneOf([String, Number, Symbol]) +export const vKeyPropDef = IxPropTypes.oneOfType([String, Number, Symbol]) diff --git a/packages/components/_private/overflow/__tests__/__snapshots__/overflow.spec.ts.snap b/packages/components/_private/overflow/__tests__/__snapshots__/overflow.spec.ts.snap new file mode 100644 index 000000000..d89cca49e --- /dev/null +++ b/packages/components/_private/overflow/__tests__/__snapshots__/overflow.spec.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Overflow maxLabel responsive work 1`] = ` +"
+
0
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+ + +
" +`; + +exports[`Overflow maxLabel responsive work 2`] = ` +"
+
0
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
+ 19 ...
+ +
" +`; + +exports[`Overflow render work 1`] = ` +"
+ + +
" +`; + +exports[`Overflow render work 2`] = ` +"
+
+ 0 ...
+ +
" +`; diff --git a/packages/components/_private/overflow/__tests__/overflow.spec.ts b/packages/components/_private/overflow/__tests__/overflow.spec.ts new file mode 100644 index 000000000..81567b66e --- /dev/null +++ b/packages/components/_private/overflow/__tests__/overflow.spec.ts @@ -0,0 +1,95 @@ +import { MountingOptions, mount } from '@vue/test-utils' + +import { merge } from 'lodash-es' + +import { renderWork } from '@tests' + +import Overflow from '../src/Overflow' +import { OverflowProps } from '../src/types' + +interface OverflowData { + value: number + key: string +} + +describe('Overflow', () => { + const overfolwData: OverflowData[] = Array.from(Array(20)).map((_, idx) => ({ value: idx, key: `${idx}-key` })) + const totalLen = overfolwData.length + + const OverflowMount = (options?: MountingOptions>) => { + return mount(Overflow, { + ...(merge( + { + props: { + getKey: (item: OverflowData) => item.key, + prefixCls: 'ix-test', + dataSource: overfolwData, + }, + slots: { item: `` }, + }, + options, + ) as MountingOptions), + }) + } + + renderWork(Overflow, { + props: { maxLabel: 4 }, + }) + + renderWork(Overflow, { + props: { maxLabel: 'responsive' }, + }) + + test('maxLabel work', async () => { + const wrapper = OverflowMount() + + let items = wrapper.findAll('.ix-overflow-item') + + expect(items.length).toBe(totalLen) + + await wrapper.setProps({ maxLabel: 3 }) + + // [0, 1, 2, + 17 ...] + items = wrapper.findAll('.ix-overflow-item') + + expect(items.length).toBe(4) + expect(items[3].text()).toBe('+ 17 ...') + expect(items[3].attributes('style')).toEqual(expect.not.stringContaining('display: none')) + }) + test('maxLabel responsive work', async () => { + const wrapper = OverflowMount() + + expect(wrapper.html()).toMatchSnapshot() + + await wrapper.setProps({ maxLabel: 'responsive' }) + + expect(wrapper.html()).toMatchSnapshot() + }) + test('item slot work', async () => { + const wrapper = OverflowMount({ props: { maxLabel: 2 } }) + + const items = wrapper.findAll('.ix-overflow-item') + + expect(items[0].text()).toBe('0') + expect(items[1].text()).toBe('1') + }) + test('rest slot work', async () => { + const wrapper = OverflowMount({ + props: { maxLabel: 2 }, + slots: { rest: `` }, + }) + + const rest = wrapper.find('.ix-overflow-rest') + + expect(rest.text()).toBe('+ 18 more') + }) + test('suffix slot work', async () => { + const wrapper = OverflowMount({ + props: { maxLabel: 2 }, + slots: { suffix: `x` }, + }) + + const suffix = wrapper.find('.ix-overflow-suffix') + expect(suffix.text()).toBe('x') + }) +}) diff --git a/packages/components/_private/overflow/index.ts b/packages/components/_private/overflow/index.ts new file mode 100644 index 000000000..06de7a5bd --- /dev/null +++ b/packages/components/_private/overflow/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { OverflowComponent } from './src/types' + +import Overflow from './src/Overflow' + +const ɵOverflow = Overflow as unknown as OverflowComponent + +export { ɵOverflow } + +export type { + OverflowInstance as ɵOverflowInstance, + OverflowComponent as ɵOverflowComponent, + OverflowPublicProps as ɵOverflowProps, +} from './src/types' diff --git a/packages/components/_private/overflow/src/Item.tsx b/packages/components/_private/overflow/src/Item.tsx new file mode 100644 index 000000000..0daa5f4ac --- /dev/null +++ b/packages/components/_private/overflow/src/Item.tsx @@ -0,0 +1,34 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { defineComponent, onBeforeUnmount, onMounted, ref } from 'vue' + +import { callEmit, offResize, onResize } from '@idux/cdk/utils' + +import { overflowItemProps } from './types' + +export default defineComponent({ + name: 'IxOverflowItem', + props: overflowItemProps, + setup(props, { slots }) { + const itemElRef = ref() + const handleResize = (entry: ResizeObserverEntry) => callEmit(props.onSizeChange, entry.target, props.itemKey ?? '') + + onMounted(() => onResize(itemElRef.value, handleResize)) + onBeforeUnmount(() => { + offResize(itemElRef.value, handleResize) + }) + + return () => { + return ( +
+ {slots.default?.()} +
+ ) + } + }, +}) diff --git a/packages/components/_private/overflow/src/Overflow.tsx b/packages/components/_private/overflow/src/Overflow.tsx new file mode 100644 index 000000000..036d07706 --- /dev/null +++ b/packages/components/_private/overflow/src/Overflow.tsx @@ -0,0 +1,201 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { VKey } from '@idux/cdk/utils' + +import { Ref, computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' + +import { isNumber } from 'lodash-es' + +import { offResize, onResize, throwError } from '@idux/cdk/utils' +import { useGlobalConfig } from '@idux/components/config' + +import Item from './Item' +import { overflowProps } from './types' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SafeAny = any + +const restNodeKey = '__IDUX_OVERFLOW_REST' +const suffixNodeKey = '__IDUX_OVERFLOW_SUFFIX' as VKey +const responsive = 'responsive' + +export default defineComponent({ + name: 'IxOverflow', + props: overflowProps, + setup(props, { slots }) { + const common = useGlobalConfig('common') + const mergedPrefixCls = computed(() => `${common.prefixCls}-overflow`) + const containerElRef = ref() + + const { containerWidth, setContainerWidth } = useContainerSize(containerElRef) + const { itemsWidthMap, setItemWidth } = useItemSize() + const restWidth = ref(0) + const suffixWidth = ref(0) + + const displayCount = ref(props.dataSource.length) + const isResposive = computed(() => props.maxLabel === responsive) + const restReady = ref(false) + const showRest = computed( + () => isResposive.value || (isNumber(props.maxLabel) && props.dataSource.length > props.maxLabel), + ) + + const mergedData = computed(() => { + if (!isResposive.value) { + return props.dataSource.slice(0, props.maxLabel as number) + } + return props.dataSource + }) + const restData = computed(() => props.dataSource.slice(displayCount.value)) + const displayRest = computed(() => restReady.value && !!restData.value.length) + + watch( + [itemsWidthMap, containerWidth, restWidth, suffixWidth, mergedData], + () => { + const len = props.dataSource.length + const lastIndex = len - 1 + + const data: SafeAny = props.dataSource ?? [] + let totalWidth = 0 + if (!len) { + displayCount.value = 0 + return + } + + if (!isResposive.value) { + displayCount.value = Math.min(props.maxLabel as number, len) + restReady.value = true + return + } + + for (let i = 0; i < len; i++) { + const getItemWidth = (index: number) => itemsWidthMap.value.get(props.getKey(data[index])) ?? 0 + const internalContainerWidth = containerWidth.value - suffixWidth.value + const curItemWidth = getItemWidth(i) + + // break when item is not ready + if (!curItemWidth) { + displayCount.value = i + 1 + break + } + restReady.value = true + + totalWidth += curItemWidth + + // container width is enough + if (i === lastIndex && totalWidth <= internalContainerWidth) { + displayCount.value = i + 1 + break + } else if (totalWidth + restWidth.value > internalContainerWidth) { + // container width is not enough, rest node appeared + displayCount.value = i + break + } + + displayCount.value = i + 1 + } + }, + { + deep: true, + immediate: true, + flush: 'post', + }, + ) + + onMounted(() => { + onResize(containerElRef.value, setContainerWidth) + }) + onBeforeUnmount(() => offResize(containerElRef.value, setContainerWidth)) + + const itemSharedProps = { + prefixCls: mergedPrefixCls.value, + } + + const internalRenderItem = (item: SafeAny, index: number) => { + if (!slots.item) { + throwError('components/_private/overflow', 'item slot must be provided') + } + const nodeContent = slots.item?.(item) ?? '' + return ( + setItemWidth(key!, itemEl)} + > + {nodeContent} + + ) + } + const internalRenderRest = (rest: unknown[]) => { + const nodeContent = slots.rest?.(rest) ?? `+ ${rest.length} ...` + + return ( + (restWidth.value = itemEl.clientWidth ?? 0)} + > + {nodeContent} + + ) + } + const internalRenderSuffix = () => { + const nodeContent = slots.suffix?.() ?? null + + return nodeContent ? ( + (suffixWidth.value = itemEl.clientWidth ?? 0)} + > + {nodeContent} + + ) : null + } + return () => { + return ( +
+ {mergedData.value.map(internalRenderItem)} + {showRest.value && internalRenderRest(restData.value)} + {internalRenderSuffix()} +
+ ) + } + }, +}) + +const useContainerSize = (containerElRef: Ref) => { + const containerWidth = ref(0) + const setContainerWidth = () => { + containerWidth.value = containerElRef.value?.clientWidth ?? 0 + } + + return { + containerWidth, + setContainerWidth, + } +} + +const useItemSize = () => { + const itemsWidthMap = ref>(new Map()) + const setItemWidth = (key: VKey, itemEl?: Element) => { + if (!itemEl && itemsWidthMap.value.get(key)) { + itemsWidthMap.value.delete(key) + } else { + itemEl?.clientWidth && itemsWidthMap.value.set(key, itemEl?.clientWidth ?? 0) + } + } + + return { + itemsWidthMap, + setItemWidth, + } +} diff --git a/packages/components/_private/overflow/src/types.ts b/packages/components/_private/overflow/src/types.ts new file mode 100644 index 000000000..3fce42352 --- /dev/null +++ b/packages/components/_private/overflow/src/types.ts @@ -0,0 +1,40 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ExtractInnerPropTypes, ExtractPublicPropTypes, VKey } from '@idux/cdk/utils' +import type { DefineComponent, HTMLAttributes } from 'vue' + +import { IxPropTypes, vKeyPropDef } from '@idux/cdk/utils' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SafeAny = any + +export interface ItemData { + key: VKey + [propName: string]: SafeAny +} + +export const overflowItemProps = { + prefixCls: IxPropTypes.string.isRequired, + display: IxPropTypes.bool.def(true), + itemKey: vKeyPropDef.isRequired, + data: IxPropTypes.object(), + onSizeChange: IxPropTypes.func<(itemEl: Element, key?: VKey) => void>(), +} + +export const overflowProps = { + maxLabel: IxPropTypes.oneOfType([IxPropTypes.number, IxPropTypes.oneOf(['responsive'])]).def(Number.MAX_SAFE_INTEGER), + getKey: IxPropTypes.func<(item: SafeAny) => VKey>().isRequired, + prefixCls: IxPropTypes.string.isRequired, + dataSource: IxPropTypes.array().def(() => []), +} + +export type OverflowProps = ExtractInnerPropTypes +export type OverflowItemProps = ExtractInnerPropTypes +export type OverflowPublicProps = ExtractPublicPropTypes +export type OverflowComponent = DefineComponent & OverflowPublicProps> +export type OverflowInstance = InstanceType> diff --git a/packages/components/_private/overflow/style/index.less b/packages/components/_private/overflow/style/index.less new file mode 100644 index 000000000..8dc49c4cf --- /dev/null +++ b/packages/components/_private/overflow/style/index.less @@ -0,0 +1,15 @@ +@import '../../../style/mixins/reset.less'; + +.@{overflow-prefix} { + .reset-component(); + + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + width: 100%; + + &-item { + height: 100%; + } +} diff --git a/packages/components/_private/overflow/style/themes/default.less b/packages/components/_private/overflow/style/themes/default.less new file mode 100644 index 000000000..c1c89d158 --- /dev/null +++ b/packages/components/_private/overflow/style/themes/default.less @@ -0,0 +1,2 @@ +@import '../index.less'; +@import './default.variable.less'; diff --git a/packages/components/_private/overflow/style/themes/default.ts b/packages/components/_private/overflow/style/themes/default.ts new file mode 100644 index 000000000..027ca3f89 --- /dev/null +++ b/packages/components/_private/overflow/style/themes/default.ts @@ -0,0 +1,4 @@ +// style dependencies +import '@idux/components/style/core/default' + +import './default.less' diff --git a/packages/components/_private/overflow/style/themes/default.variable.less b/packages/components/_private/overflow/style/themes/default.variable.less new file mode 100644 index 000000000..2c886e535 --- /dev/null +++ b/packages/components/_private/overflow/style/themes/default.variable.less @@ -0,0 +1 @@ +@import '../../../../style/themes/default.less'; \ No newline at end of file diff --git a/packages/components/default.less b/packages/components/default.less index 688b57f86..202a5a363 100644 --- a/packages/components/default.less +++ b/packages/components/default.less @@ -5,6 +5,7 @@ @import './_private/overlay/style/themes/default.less'; @import './_private/time-panel/style/themes/default.less'; @import './_private/loading/style/themes/default.less'; +@import './_private/overflow/style/themes/default.less'; @import './affix/style/themes/default.less'; @import './alert/style/themes/default.less'; diff --git a/packages/components/pagination/__tests__/__snapshots__/pagination.spec.ts.snap b/packages/components/pagination/__tests__/__snapshots__/pagination.spec.ts.snap index 42ba165e9..435d44488 100644 --- a/packages/components/pagination/__tests__/__snapshots__/pagination.spec.ts.snap +++ b/packages/components/pagination/__tests__/__snapshots__/pagination.spec.ts.snap @@ -38,11 +38,18 @@ exports[`Pagination render work 3`] = `
-
10 条/页 - -
-
+
+
+
10 条/页 + +
+
+
+
+ +
+
diff --git a/packages/components/select/__tests__/__snapshots__/select.spec.ts.snap b/packages/components/select/__tests__/__snapshots__/select.spec.ts.snap index e72f084ac..5e68127fd 100644 --- a/packages/components/select/__tests__/__snapshots__/select.spec.ts.snap +++ b/packages/components/select/__tests__/__snapshots__/select.spec.ts.snap @@ -1,13 +1,93 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Select multiple work maxLabel responsive work 1`] = ` +"
+ +
+
+
+
A0 + +
+
+
+
A1
+
+
+
A2
+
+
+
A4
+
+
+
A5
+
+
+
+ 4 ... + +
+
+
+
+
+
+ +
+ +
+
+" +`; + +exports[`Select multiple work maxLabel responsive work 2`] = ` +"
+ +
+
+
+
A0 + +
+
+
+
A1
+
+
+
+ 3 ... + +
+
+
+
+
+
+ +
+ +
+
+" +`; + exports[`Select multiple work render work 1`] = ` "
-
0
-
1
-
2
-
+
+
+
0
+
+
+
1
+
+
+
2
+
+ +
+
+
+
@@ -20,11 +100,18 @@ exports[`Select single work render work 1`] = ` "
-
Tom - -
-
+
+
+
Tom + +
+
+
+
+ +
+
@@ -38,11 +125,18 @@ exports[`Select template work render work 1`] = ` "
-
Tom - -
-
+
+
+
Tom + +
+
+
+
+ +
+
diff --git a/packages/components/select/__tests__/select.spec.ts b/packages/components/select/__tests__/select.spec.ts index cd980ae40..2e905ca61 100644 --- a/packages/components/select/__tests__/select.spec.ts +++ b/packages/components/select/__tests__/select.spec.ts @@ -503,8 +503,8 @@ describe('Select', () => { expect(options[4].attributes('title')).toBe('') }) - test('maxLabelCount work', async () => { - const wrapper = SelectMount({ props: { maxLabelCount: 3, value: [0, 1, 2, 4, 5] } }) + test('maxLabel work', async () => { + const wrapper = SelectMount({ props: { maxLabel: 3, value: [0, 1, 2, 4, 5] } }) let items = wrapper.findAll('.ix-select-selector-item') @@ -513,7 +513,7 @@ describe('Select', () => { expect(items[2].text()).toBe('A2') expect(items[3].text()).toBe('+ 2 ...') - await wrapper.setProps({ maxLabelCount: 2 }) + await wrapper.setProps({ maxLabel: 2 }) items = wrapper.findAll('.ix-select-selector-item') @@ -521,6 +521,15 @@ describe('Select', () => { expect(items[1].text()).toBe('A1') expect(items[2].text()).toBe('+ 3 ...') }) + + test('maxLabel responsive work', async () => { + const wrapper = SelectMount({ props: { maxLabel: 'responsive', value: [0, 1, 2, 4, 5] } }) + + expect(wrapper.html()).toMatchSnapshot() + + await wrapper.setProps({ maxLabel: 2 }) + expect(wrapper.html()).toMatchSnapshot() + }) }) describe('template work', () => { diff --git a/packages/components/select/demo/CustomLabel.vue b/packages/components/select/demo/CustomLabel.vue index 10c41ec8e..be5bac998 100644 --- a/packages/components/select/demo/CustomLabel.vue +++ b/packages/components/select/demo/CustomLabel.vue @@ -1,12 +1,5 @@