Skip to content

Commit 632081e

Browse files
authored
feat: add icon-picker component (#4832)
* feat: add icon-picker component * fix: resolve conversations * refactor: resort @vben/hooks
1 parent 6b9acf0 commit 632081e

File tree

13 files changed

+1130
-3
lines changed

13 files changed

+1130
-3
lines changed

packages/@core/base/icons/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ export * from './create-icon';
44
export * from './lucide';
55

66
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue';
7-
export { addCollection, addIcon, Icon as IconifyIcon } from '@iconify/vue';
7+
export {
8+
addCollection,
9+
addIcon,
10+
Icon as IconifyIcon,
11+
listIcons,
12+
} from '@iconify/vue';

packages/@core/base/icons/src/lucide.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
FoldHorizontal,
2828
Fullscreen,
2929
Github,
30+
Grip,
3031
Info,
3132
InspectionPanel,
3233
Languages,
@@ -40,6 +41,7 @@ export {
4041
Minimize,
4142
Minimize2,
4243
MoonStar,
44+
Package2,
4345
Palette,
4446
PanelLeft,
4547
PanelRight,

packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ interface Props extends VbenButtonProps {
1414
disabled?: boolean;
1515
onClick?: () => void;
1616
tooltip?: string;
17+
tooltipDelayDuration?: number;
1718
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
1819
variant?: ButtonVariants;
1920
}
2021
2122
const props = withDefaults(defineProps<Props>(), {
2223
disabled: false,
2324
onClick: () => {},
25+
tooltipDelayDuration: 200,
2426
tooltipSide: 'bottom',
2527
variant: 'icon',
2628
});
@@ -42,7 +44,11 @@ const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
4244
<slot></slot>
4345
</VbenButton>
4446

45-
<VbenTooltip v-else :side="tooltipSide">
47+
<VbenTooltip
48+
v-else
49+
:delay-duration="tooltipDelayDuration"
50+
:side="tooltipSide"
51+
>
4652
<template #trigger>
4753
<VbenButton
4854
:class="cn('rounded-full', props.class)"

packages/effects/common-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@vben-core/shadcn-ui": "workspace:*",
2626
"@vben-core/shared": "workspace:*",
2727
"@vben/constants": "workspace:*",
28+
"@vben/hooks": "workspace:*",
2829
"@vben/icons": "workspace:*",
2930
"@vben/locales": "workspace:*",
3031
"@vben/types": "workspace:*",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script setup lang="ts">
2+
import { ref, watch, watchEffect } from 'vue';
3+
4+
import { usePagination } from '@vben/hooks';
5+
import { Grip, Package2 } from '@vben/icons';
6+
import {
7+
Button,
8+
Pagination,
9+
PaginationEllipsis,
10+
PaginationFirst,
11+
PaginationLast,
12+
PaginationList,
13+
PaginationListItem,
14+
PaginationNext,
15+
PaginationPrev,
16+
VbenIcon,
17+
VbenIconButton,
18+
VbenPopover,
19+
} from '@vben-core/shadcn-ui';
20+
21+
interface Props {
22+
value?: string;
23+
pageSize?: number;
24+
/**
25+
* 图标列表
26+
*/
27+
icons?: string[];
28+
}
29+
30+
const props = withDefaults(defineProps<Props>(), {
31+
value: '',
32+
pageSize: 36,
33+
icons: () => [],
34+
});
35+
36+
const emit = defineEmits<{
37+
change: [string];
38+
'update:value': [string];
39+
}>();
40+
41+
const currentSelect = ref('');
42+
const currentList = ref(props.icons);
43+
const refTrigger = ref<HTMLDivElement>();
44+
45+
watch(
46+
() => props.icons,
47+
(newIcons) => {
48+
currentList.value = newIcons;
49+
},
50+
{ immediate: true },
51+
);
52+
53+
const { getPaginationList, getTotal, setCurrentPage } = usePagination(
54+
currentList,
55+
props.pageSize,
56+
);
57+
58+
watchEffect(() => {
59+
currentSelect.value = props.value;
60+
});
61+
62+
watch(
63+
() => currentSelect.value,
64+
(v) => {
65+
emit('update:value', v);
66+
emit('change', v);
67+
},
68+
);
69+
70+
const handleClick = (icon: string) => {
71+
currentSelect.value = icon;
72+
};
73+
74+
const handlePageChange = (page: number) => {
75+
setCurrentPage(page);
76+
};
77+
78+
const changeOpenState = () => {
79+
if (refTrigger.value) {
80+
refTrigger.value.click();
81+
}
82+
};
83+
84+
defineExpose({ changeOpenState });
85+
</script>
86+
<template>
87+
<VbenPopover
88+
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
89+
content-class="p-0 py-4"
90+
>
91+
<template #trigger>
92+
<div ref="refTrigger">
93+
<VbenIcon :icon="currentSelect || Grip" class="size-6" />
94+
</div>
95+
</template>
96+
97+
<div v-if="getPaginationList.length > 0">
98+
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
99+
<VbenIconButton
100+
v-for="(item, index) in getPaginationList"
101+
:key="index"
102+
:tooltip="item"
103+
tooltip-side="top"
104+
@click="handleClick(item)"
105+
>
106+
<VbenIcon :icon="item" />
107+
</VbenIconButton>
108+
</div>
109+
<div v-if="getTotal >= pageSize" class="flex-center pt-1">
110+
<Pagination
111+
v-slot="{ page }"
112+
:items-per-page="36"
113+
:sibling-count="1"
114+
:total="getTotal"
115+
show-edges
116+
@update:page="handlePageChange"
117+
>
118+
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
119+
<PaginationFirst class="size-5" />
120+
<PaginationPrev class="size-5" />
121+
<template v-for="(item, index) in items">
122+
<PaginationListItem
123+
v-if="item.type === 'page'"
124+
:key="index"
125+
:value="item.value"
126+
as-child
127+
>
128+
<Button
129+
:variant="item.value === page ? 'default' : 'outline'"
130+
class="size-5 p-0 text-sm"
131+
>
132+
{{ item.value }}
133+
</Button>
134+
</PaginationListItem>
135+
<PaginationEllipsis
136+
v-else
137+
:key="item.type"
138+
:index="index"
139+
class="size-5"
140+
/>
141+
</template>
142+
<PaginationNext class="size-5" />
143+
<PaginationLast class="size-5" />
144+
</PaginationList>
145+
</Pagination>
146+
</div>
147+
</div>
148+
149+
<template v-else>
150+
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
151+
<Package2 />
152+
<div>{{ $t('common.noData') }}</div>
153+
</div>
154+
</template>
155+
</VbenPopover>
156+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as IconPicker } from './icon-picker.vue';

packages/effects/common-ui/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './captcha';
22
export * from './ellipsis-text';
3+
export * from './icon-picker';
34
export * from './page';
45
export * from '@vben-core/form-ui';
56
export * from '@vben-core/popup-ui';

packages/effects/hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './use-app-config';
22
export * from './use-content-maximize';
33
export * from './use-design-tokens';
4+
export * from './use-pagination';
45
export * from './use-refresh';
56
export * from './use-tabs';
67
export * from './use-watermark';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Ref } from 'vue';
2+
import { computed, ref, unref } from 'vue';
3+
4+
/**
5+
* Paginates an array of items
6+
* @param list The array to paginate
7+
* @param pageNo The current page number (1-based)
8+
* @param pageSize Number of items per page
9+
* @returns Paginated array slice
10+
* @throws {Error} If pageNo or pageSize are invalid
11+
*/
12+
function pagination<T = any>(list: T[], pageNo: number, pageSize: number): T[] {
13+
if (pageNo < 1) throw new Error('Page number must be positive');
14+
if (pageSize < 1) throw new Error('Page size must be positive');
15+
16+
const offset = (pageNo - 1) * Number(pageSize);
17+
const ret =
18+
offset + pageSize >= list.length
19+
? list.slice(offset)
20+
: list.slice(offset, offset + pageSize);
21+
return ret;
22+
}
23+
24+
export function usePagination<T = any>(list: Ref<T[]>, pageSize: number) {
25+
const currentPage = ref(1);
26+
const pageSizeRef = ref(pageSize);
27+
28+
const totalPages = computed(() =>
29+
Math.ceil(unref(list).length / unref(pageSizeRef)),
30+
);
31+
32+
const getPaginationList = computed(() => {
33+
return pagination(unref(list), unref(currentPage), unref(pageSizeRef));
34+
});
35+
36+
const getTotal = computed(() => {
37+
return unref(list).length;
38+
});
39+
40+
function setCurrentPage(page: number) {
41+
if (page < 1 || page > unref(totalPages)) {
42+
throw new Error('Invalid page number');
43+
}
44+
currentPage.value = page;
45+
}
46+
47+
function setPageSize(pageSize: number) {
48+
if (pageSize < 1) {
49+
throw new Error('Page size must be positive');
50+
}
51+
pageSizeRef.value = pageSize;
52+
// Reset to first page to prevent invalid state
53+
currentPage.value = 1;
54+
}
55+
56+
return { setCurrentPage, getTotal, setPageSize, getPaginationList };
57+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<script lang="ts" setup>
2+
import { computed, ref } from 'vue';
3+
4+
import { IconPicker } from '@vben/common-ui';
5+
import { listIcons } from '@vben/icons';
6+
7+
import { Input } from 'ant-design-vue';
8+
9+
import iconsData from './icons.data';
10+
11+
export interface Props {
12+
allowClear?: boolean;
13+
pageSize?: number;
14+
/**
15+
* 可以通过prefix获取系统中使用的图标集
16+
*/
17+
prefix?: string;
18+
readonly?: boolean;
19+
value?: string;
20+
width?: string;
21+
}
22+
23+
// Don't inherit FormItem disabled、placeholder...
24+
defineOptions({
25+
inheritAttrs: false,
26+
});
27+
28+
const props = withDefaults(defineProps<Props>(), {
29+
allowClear: true,
30+
pageSize: 36,
31+
prefix: '',
32+
readonly: false,
33+
value: '',
34+
width: '100%',
35+
});
36+
37+
const refIconPicker = ref();
38+
const currentSelect = ref('');
39+
40+
const currentList = computed(() => {
41+
try {
42+
if (props.prefix) {
43+
const icons = listIcons('', props.prefix);
44+
if (icons.length === 0) {
45+
console.warn(`No icons found for prefix: ${props.prefix}`);
46+
}
47+
return icons;
48+
} else {
49+
const prefix = iconsData.prefix;
50+
return iconsData.icons.map((icon) => `${prefix}:${icon}`);
51+
}
52+
} catch (error) {
53+
console.error('Failed to load icons:', error);
54+
return [];
55+
}
56+
});
57+
58+
const triggerPopover = () => {
59+
refIconPicker.value?.changeOpenState?.();
60+
};
61+
62+
const handleChange = (icon: string) => {
63+
currentSelect.value = icon;
64+
};
65+
</script>
66+
67+
<template>
68+
<Input
69+
v-model:value="currentSelect"
70+
:allow-clear="props.allowClear"
71+
:readonly="props.readonly"
72+
:style="{ width }"
73+
class="cursor-pointer"
74+
placeholder="点击选中图标"
75+
@click="triggerPopover"
76+
>
77+
<template #addonAfter>
78+
<IconPicker
79+
ref="refIconPicker"
80+
:icons="currentList"
81+
:page-size="pageSize"
82+
:value="currentSelect"
83+
@change="handleChange"
84+
/>
85+
</template>
86+
</Input>
87+
</template>

0 commit comments

Comments
 (0)