Skip to content

Commit

Permalink
feat: add icon-picker component (#4832)
Browse files Browse the repository at this point in the history
* feat: add icon-picker component

* fix: resolve conversations

* refactor: resort @vben/hooks
  • Loading branch information
DesignHhuang authored Nov 9, 2024
1 parent 6b9acf0 commit 632081e
Show file tree
Hide file tree
Showing 13 changed files with 1,130 additions and 3 deletions.
7 changes: 6 additions & 1 deletion packages/@core/base/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ export * from './create-icon';
export * from './lucide';

export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue';
export { addCollection, addIcon, Icon as IconifyIcon } from '@iconify/vue';
export {
addCollection,
addIcon,
Icon as IconifyIcon,
listIcons,
} from '@iconify/vue';
2 changes: 2 additions & 0 deletions packages/@core/base/icons/src/lucide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
FoldHorizontal,
Fullscreen,
Github,
Grip,
Info,
InspectionPanel,
Languages,
Expand All @@ -40,6 +41,7 @@ export {
Minimize,
Minimize2,
MoonStar,
Package2,
Palette,
PanelLeft,
PanelRight,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ interface Props extends VbenButtonProps {
disabled?: boolean;
onClick?: () => void;
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
onClick: () => {},
tooltipDelayDuration: 200,
tooltipSide: 'bottom',
variant: 'icon',
});
Expand All @@ -42,7 +44,11 @@ const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
<slot></slot>
</VbenButton>

<VbenTooltip v-else :side="tooltipSide">
<VbenTooltip
v-else
:delay-duration="tooltipDelayDuration"
:side="tooltipSide"
>
<template #trigger>
<VbenButton
:class="cn('rounded-full', props.class)"
Expand Down
1 change: 1 addition & 0 deletions packages/effects/common-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/types": "workspace:*",
Expand Down
156 changes: 156 additions & 0 deletions packages/effects/common-ui/src/components/icon-picker/icon-picker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue';
import { usePagination } from '@vben/hooks';
import { Grip, Package2 } from '@vben/icons';
import {
Button,
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
VbenIcon,
VbenIconButton,
VbenPopover,
} from '@vben-core/shadcn-ui';
interface Props {
value?: string;
pageSize?: number;
/**
* 图标列表
*/
icons?: string[];
}
const props = withDefaults(defineProps<Props>(), {
value: '',
pageSize: 36,
icons: () => [],
});
const emit = defineEmits<{
change: [string];
'update:value': [string];
}>();
const currentSelect = ref('');
const currentList = ref(props.icons);
const refTrigger = ref<HTMLDivElement>();
watch(
() => props.icons,
(newIcons) => {
currentList.value = newIcons;
},
{ immediate: true },
);
const { getPaginationList, getTotal, setCurrentPage } = usePagination(
currentList,
props.pageSize,
);
watchEffect(() => {
currentSelect.value = props.value;
});
watch(
() => currentSelect.value,
(v) => {
emit('update:value', v);
emit('change', v);
},
);
const handleClick = (icon: string) => {
currentSelect.value = icon;
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const changeOpenState = () => {
if (refTrigger.value) {
refTrigger.value.click();
}
};
defineExpose({ changeOpenState });
</script>
<template>
<VbenPopover
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
content-class="p-0 py-4"
>
<template #trigger>
<div ref="refTrigger">
<VbenIcon :icon="currentSelect || Grip" class="size-6" />
</div>
</template>

<div v-if="getPaginationList.length > 0">
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
<VbenIconButton
v-for="(item, index) in getPaginationList"
:key="index"
:tooltip="item"
tooltip-side="top"
@click="handleClick(item)"
>
<VbenIcon :icon="item" />
</VbenIconButton>
</div>
<div v-if="getTotal >= pageSize" class="flex-center pt-1">
<Pagination
v-slot="{ page }"
:items-per-page="36"
:sibling-count="1"
:total="getTotal"
show-edges
@update:page="handlePageChange"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst class="size-5" />
<PaginationPrev class="size-5" />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'default' : 'outline'"
class="size-5 p-0 text-sm"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis
v-else
:key="item.type"
:index="index"
class="size-5"
/>
</template>
<PaginationNext class="size-5" />
<PaginationLast class="size-5" />
</PaginationList>
</Pagination>
</div>
</div>

<template v-else>
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
<Package2 />
<div>{{ $t('common.noData') }}</div>
</div>
</template>
</VbenPopover>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as IconPicker } from './icon-picker.vue';
1 change: 1 addition & 0 deletions packages/effects/common-ui/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './captcha';
export * from './ellipsis-text';
export * from './icon-picker';
export * from './page';
export * from '@vben-core/form-ui';
export * from '@vben-core/popup-ui';
Expand Down
1 change: 1 addition & 0 deletions packages/effects/hooks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './use-app-config';
export * from './use-content-maximize';
export * from './use-design-tokens';
export * from './use-pagination';
export * from './use-refresh';
export * from './use-tabs';
export * from './use-watermark';
Expand Down
57 changes: 57 additions & 0 deletions packages/effects/hooks/src/use-pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Ref } from 'vue';
import { computed, ref, unref } from 'vue';

/**
* Paginates an array of items
* @param list The array to paginate
* @param pageNo The current page number (1-based)
* @param pageSize Number of items per page
* @returns Paginated array slice
* @throws {Error} If pageNo or pageSize are invalid
*/
function pagination<T = any>(list: T[], pageNo: number, pageSize: number): T[] {
if (pageNo < 1) throw new Error('Page number must be positive');
if (pageSize < 1) throw new Error('Page size must be positive');

const offset = (pageNo - 1) * Number(pageSize);
const ret =
offset + pageSize >= list.length
? list.slice(offset)
: list.slice(offset, offset + pageSize);
return ret;
}

export function usePagination<T = any>(list: Ref<T[]>, pageSize: number) {
const currentPage = ref(1);
const pageSizeRef = ref(pageSize);

const totalPages = computed(() =>
Math.ceil(unref(list).length / unref(pageSizeRef)),
);

const getPaginationList = computed(() => {
return pagination(unref(list), unref(currentPage), unref(pageSizeRef));
});

const getTotal = computed(() => {
return unref(list).length;
});

function setCurrentPage(page: number) {
if (page < 1 || page > unref(totalPages)) {
throw new Error('Invalid page number');
}
currentPage.value = page;
}

function setPageSize(pageSize: number) {
if (pageSize < 1) {
throw new Error('Page size must be positive');
}
pageSizeRef.value = pageSize;
// Reset to first page to prevent invalid state
currentPage.value = 1;
}

return { setCurrentPage, getTotal, setPageSize, getPaginationList };
}
87 changes: 87 additions & 0 deletions playground/src/views/demos/features/icons/icon-picker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { IconPicker } from '@vben/common-ui';
import { listIcons } from '@vben/icons';
import { Input } from 'ant-design-vue';
import iconsData from './icons.data';
export interface Props {
allowClear?: boolean;
pageSize?: number;
/**
* 可以通过prefix获取系统中使用的图标集
*/
prefix?: string;
readonly?: boolean;
value?: string;
width?: string;
}
// Don't inherit FormItem disabled、placeholder...
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
allowClear: true,
pageSize: 36,
prefix: '',
readonly: false,
value: '',
width: '100%',
});
const refIconPicker = ref();
const currentSelect = ref('');
const currentList = computed(() => {
try {
if (props.prefix) {
const icons = listIcons('', props.prefix);
if (icons.length === 0) {
console.warn(`No icons found for prefix: ${props.prefix}`);
}
return icons;
} else {
const prefix = iconsData.prefix;
return iconsData.icons.map((icon) => `${prefix}:${icon}`);
}
} catch (error) {
console.error('Failed to load icons:', error);
return [];
}
});
const triggerPopover = () => {
refIconPicker.value?.changeOpenState?.();
};
const handleChange = (icon: string) => {
currentSelect.value = icon;
};
</script>

<template>
<Input
v-model:value="currentSelect"
:allow-clear="props.allowClear"
:readonly="props.readonly"
:style="{ width }"
class="cursor-pointer"
placeholder="点击选中图标"
@click="triggerPopover"
>
<template #addonAfter>
<IconPicker
ref="refIconPicker"
:icons="currentList"
:page-size="pageSize"
:value="currentSelect"
@change="handleChange"
/>
</template>
</Input>
</template>
Loading

0 comments on commit 632081e

Please sign in to comment.