cd-icon-picker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { App } from 'vue';
2
+ import Icon from './src/Icon.vue';
3
+ import SvgIcon from './src/SvgIcon.vue';
4
+ import IconPicker from './src/IconPicker.vue';
5
+
6
+ const install = (app: App) => {
7
+ app.component('CdIcon', Icon);
8
+ app.component('CdSvgIcon', SvgIcon);
9
+ app.component('CdIconPicker', IconPicker);
10
+ };
11
+
12
+ export { Icon, IconPicker, SvgIcon, install };
13
+
14
+ export default { install };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "cd-icon-picker",
3
+ "version": "0.1.0",
4
+ "description": "Vue 3 icon picker supporting TDesign & RemixIcon, with custom SVG via :diy-icon.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "data",
14
+ "index.ts"
15
+ ],
16
+ "sideEffects": false,
17
+ "peerDependencies": {
18
+ "vue": "^3.3.0",
19
+ "tdesign-vue-next": "^1.0.0",
20
+ "remixicon": "^4.7.0"
21
+ },
22
+ "keywords": [
23
+ "vue3",
24
+ "icon-picker",
25
+ "tdesign",
26
+ "remixicon",
27
+ "svg"
28
+ ]
29
+ }
package/src/Icon.vue ADDED
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <SvgIcon v-if="isSvgIcon" :size="size" :name="getSvgIcon" :class="[$attrs.class, 'cd-icon']" :spin="spin" />
3
+ <i v-else-if="isRemixIcon" :class="['cd-icon', $attrs.class, icon, spin && 'svg-icon-spin']" :style="getStyle"></i>
4
+ <t-icon v-else :name="icon" :size="size" :color="color" :spin="spin" :class="[$attrs.class, 'cd-icon']" />
5
+ </template>
6
+ <script lang="ts">
7
+ import type { PropType } from 'vue';
8
+ import { defineComponent, computed } from 'vue';
9
+ import SvgIcon from './SvgIcon.vue';
10
+
11
+ const SVG_END_WITH_FLAG = '|svg';
12
+ export default defineComponent({
13
+ name: 'Icon',
14
+ components: { SvgIcon },
15
+ props: {
16
+ icon: {
17
+ type: String,
18
+ default: '',
19
+ },
20
+ color: {
21
+ type: String,
22
+ default: '',
23
+ },
24
+ size: {
25
+ type: [String, Number] as PropType<string | number>,
26
+ default: 16,
27
+ },
28
+ spin: {
29
+ type: Boolean,
30
+ default: false,
31
+ },
32
+ prefix: {
33
+ type: String,
34
+ default: '',
35
+ },
36
+ },
37
+ setup(props) {
38
+ const isSvgIcon = computed(() => props.icon?.endsWith(SVG_END_WITH_FLAG));
39
+ const getSvgIcon = computed(() => props.icon?.replace(SVG_END_WITH_FLAG, ''));
40
+ const isRemixIcon = computed(() => props.icon?.startsWith('ri-'));
41
+ const getStyle = computed((): Record<string, any> => {
42
+ const s = `${String(props.size).replace('px', '')}px`;
43
+ return {
44
+ fontSize: s,
45
+ color: props.color || undefined,
46
+ };
47
+ });
48
+ return { isSvgIcon, getSvgIcon, isRemixIcon, getStyle };
49
+ },
50
+ });
51
+ </script>
52
+ <style lang="scss" scoped>
53
+ .svg-icon-spin { animation: loadingCircle 1s infinite linear; }
54
+ @keyframes loadingCircle { 100% { transform: rotate(360deg); } }
55
+ </style>
@@ -0,0 +1,273 @@
1
+ <template>
2
+ <div class="cd-icon-picker-root">
3
+ <div v-if="mode === 'icon'" class="cd-icon-only" :style="{ width }" @click="handleOpen">
4
+ <span class="flex items-center justify-center" v-if="isSvgMode && currentSelect">
5
+ <SvgIcon :name="currentSelect" :prefix="svgPrefix" />
6
+ </span>
7
+ <Icon :icon="currentSelect || defaultIcon" v-else />
8
+ </div>
9
+ <t-input v-else :readonly="readonly" :style="{ width }" :placeholder="mode === 'text' ? placeholder : ''" class="cd-icon-picker" v-model="displayValue" @click="handleOpen">
10
+ <template #prefixIcon>
11
+ <span class="cursor-pointer px-2 py-1 flex items-center" v-if="isSvgMode && currentSelect">
12
+ <SvgIcon :name="currentSelect" :prefix="svgPrefix" />
13
+ </span>
14
+ <Icon :icon="currentSelect || defaultIcon" class="cursor-pointer px-2 py-1" v-else />
15
+ </template>
16
+ </t-input>
17
+
18
+ <t-dialog v-model:visible="visible" header="选择图标" width="940px" :close-on-esc-keydown="true" :close-on-overlay-click="true" @confirm="handleConfirm" @cancel="handleCancel">
19
+ <div class="cd-picker-body">
20
+ <div class="cd-picker-sidebar">
21
+ <div class="cd-picker-section">
22
+ <div class="cd-picker-section-title">风格</div>
23
+ <ul class="cd-picker-cats">
24
+ <li :class="['cd-picker-cat', styleType === 'outlined' && 'active']" @click="styleType = 'outlined'">描边</li>
25
+ <li :class="['cd-picker-cat', styleType === 'filled' && 'active']" @click="styleType = 'filled'">填充</li>
26
+ </ul>
27
+ </div>
28
+ <div class="cd-picker-section">
29
+ <div class="cd-picker-section-title">来源</div>
30
+ <ul class="cd-picker-cats">
31
+ <li :class="['cd-picker-cat', sourceType === 'remix' && 'active']" @click="sourceType = 'remix'">Remix</li>
32
+ <li :class="['cd-picker-cat', sourceType === 'tdesign' && 'active']" @click="sourceType = 'tdesign'">TDesign</li>
33
+
34
+ <li :class="['cd-picker-cat', sourceType === 'custom' && 'active']" @click="sourceType = 'custom'">自定义</li>
35
+ </ul>
36
+ </div>
37
+ <div class="cd-picker-section">
38
+ <div class="cd-picker-section-title">类别</div>
39
+ <ul class="cd-picker-cats" style="flex-wrap: wrap;">
40
+ <li :class="['cd-picker-cat', categoryType === 'all' && 'active']" @click="categoryType = 'all'">全部</li>
41
+ <li v-for="c in categoryOptions" :key="c" :class="['cd-picker-cat', categoryType === c && 'active']" @click="categoryType = c">{{ c }}</li>
42
+ </ul>
43
+ </div>
44
+ </div>
45
+ <div class="cd-picker-main">
46
+ <div class="cd-picker-search">
47
+ <t-input :placeholder="searchPlaceholder" @input="debounceHandleSearchChange" clearable />
48
+ </div>
49
+ <div v-if="getPaginationList.length" class="cd-picker-content">
50
+ <ul class="cd-picker-grid">
51
+ <li
52
+ v-for="icon in getPaginationList"
53
+ :key="icon"
54
+ :class="['cd-picker-item', currentSelect === icon && 'active']"
55
+ @click="handleClick(icon)"
56
+ :title="icon">
57
+ <SvgIcon v-if="isSvgMode" :name="icon" :prefix="svgPrefix" />
58
+ <Icon :icon="icon" v-else />
59
+ </li>
60
+ </ul>
61
+ <div class="cd-picker-pagination" v-if="getTotal >= pageSize">
62
+ <t-pagination show-less-items size="medium" :page-size="pageSize" :total="getTotal" @change="handlePageChange" />
63
+ </div>
64
+ </div>
65
+ <div v-else class="cd-picker-empty">
66
+ <t-empty />
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </t-dialog>
71
+ </div>
72
+ </template>
73
+ <script lang="ts" setup>
74
+ import type { PropType } from 'vue';
75
+ import { ref, watchEffect, watch, computed } from 'vue';
76
+ import Icon from './Icon.vue';
77
+ import SvgIcon from './SvgIcon.vue';
78
+ import categories from '../data/categories';
79
+
80
+ const props = defineProps({
81
+ value: { type: String, default: '' },
82
+ tdesign: { type: String, default: '' },
83
+ remixicon: { type: String, default: '' },
84
+ svg: { type: String, default: '' },
85
+ width: { type: String, default: '100%' },
86
+ pageSize: { type: Number, default: 140 },
87
+ copy: { type: Boolean, default: false },
88
+ diyIcon: { type: [Array, Object] as PropType<string[] | { prefix: string; icons: string[]; categoryMap?: Record<string, string[]> }> },
89
+ placeholder: { type: String, default: '选择图标' },
90
+ searchPlaceholder: { type: String, default: '搜索图标' },
91
+ readonly: { type: Boolean, default: true },
92
+ categoryMap: { type: Object as PropType<Record<string, string[]>> },
93
+ mode: { type: String as PropType<'icon' | 'text'>, default: 'text' },
94
+ });
95
+
96
+ const emit = defineEmits(['change', 'update:value', 'update:tdesign', 'update:remixicon', 'update:svg']);
97
+
98
+ // dialog 不需要 attach/placement
99
+
100
+ const isSvgMode = computed(() => sourceType.value === 'custom');
101
+ const svgPrefix = computed(() => {
102
+ const d = props.diyIcon as any;
103
+ return Array.isArray(d) ? 'icon' : (d?.prefix ?? 'icon');
104
+ });
105
+
106
+ const rawIcons = computed<string[]>(() => {
107
+ if (sourceType.value === 'custom') {
108
+ const d = props.diyIcon as any;
109
+ return Array.isArray(d) ? (d as string[]) : ((d?.icons ?? []) as string[]);
110
+ }
111
+ return [] as string[];
112
+ });
113
+
114
+ const styleType = ref<'outlined' | 'filled'>('outlined');
115
+ const sourceType = ref< 'remix'| 'tdesign' | 'custom'>('remix');
116
+ const categoryType = ref<string>('all');
117
+
118
+ const customIcons = computed(() => {
119
+ const d = props.diyIcon as any;
120
+ if (Array.isArray(d)) return d as string[];
121
+ return (d?.icons ?? []) as string[];
122
+ });
123
+
124
+ const currentSelect = ref('');
125
+ const visible = ref(false);
126
+ const page = ref(1);
127
+ const pageSize = computed(() => props.pageSize);
128
+
129
+ const displayValue = computed({
130
+ get() {
131
+ return props.mode === 'text' ? currentSelect.value : '';
132
+ },
133
+ set(v: string) {
134
+ if (props.mode === 'text') currentSelect.value = v || '';
135
+ },
136
+ });
137
+
138
+ watchEffect(() => {
139
+ const bySource = sourceType.value === 'tdesign' ? props.tdesign : sourceType.value === 'remix' ? props.remixicon : props.svg;
140
+ currentSelect.value = props.value || bySource || '';
141
+ });
142
+
143
+ watch([rawIcons, styleType, sourceType], () => {
144
+ page.value = 1;
145
+ });
146
+
147
+ watch(
148
+ () => currentSelect.value,
149
+ v => {
150
+ emit('update:value', v);
151
+ emit('change', v);
152
+ },
153
+ );
154
+
155
+ const searchText = ref('');
156
+ const categoryMap = computed(() => {
157
+ if (props.categoryMap) return props.categoryMap as Record<string, string[]>;
158
+ const d = props.diyIcon as any;
159
+ if (!Array.isArray(d) && d?.categoryMap) return d.categoryMap as Record<string, string[]>;
160
+ const map = (categories as any)[sourceType.value] || (categories as any).tdesign;
161
+ return map as Record<string, string[]>;
162
+ });
163
+
164
+ const categoryOptions = computed(() => Object.keys(categoryMap.value).filter(k => !k.endsWith('fill')));
165
+
166
+
167
+ const filteredIcons = computed(() => {
168
+ if (sourceType.value === 'custom') {
169
+ const list = customIcons.value;
170
+ return searchText.value ? list.filter(i => i.includes(searchText.value)) : list;
171
+ }
172
+
173
+ const useFill = styleType.value === 'filled';
174
+ const map: any = categoryMap.value;
175
+ const cats = categoryOptions.value;
176
+
177
+ let list: string[] = [];
178
+ if (categoryType.value === 'all') {
179
+ for (const cat of cats) {
180
+ const arr = (useFill ? map[`${cat}fill`] : map[cat]) || map[cat] || [];
181
+ list = list.concat(arr);
182
+ }
183
+ } else {
184
+ list = ((useFill ? map[`${categoryType.value}fill`] : map[categoryType.value]) || map[categoryType.value] || []) as string[];
185
+ }
186
+
187
+ if (sourceType.value === 'remix') {
188
+ const suffix = useFill ? '-fill' : '-line';
189
+ list = list.map(n => (/-line$|-fill$/.test(n) ? n : `${n}${suffix}`));
190
+ }
191
+
192
+ return searchText.value ? list.filter(i => i.includes(searchText.value)) : list;
193
+ });
194
+
195
+ const getTotal = computed(() => filteredIcons.value.length);
196
+ const getPaginationList = computed(() => {
197
+ const start = (page.value - 1) * pageSize.value;
198
+ return filteredIcons.value.slice(start, start + pageSize.value);
199
+ });
200
+
201
+ function handlePageChange(p: number | { current: number }) {
202
+ const next = typeof p === 'number' ? p : (p as any).current;
203
+ page.value = next || 1;
204
+ }
205
+
206
+ const defaultIcon = 'ri-apps-line';
207
+
208
+ function handleClick(icon: string) {
209
+ currentSelect.value = icon;
210
+ if (props.copy && navigator?.clipboard) {
211
+ navigator.clipboard.writeText(icon).catch(() => {});
212
+ }
213
+ if (sourceType.value === 'tdesign') emit('update:tdesign', icon);
214
+ else if (sourceType.value === 'remix') emit('update:remixicon', icon);
215
+ else emit('update:svg', icon);
216
+ }
217
+
218
+ let timer: any = null;
219
+ function debounceHandleSearchChange(e: any) {
220
+ const value = typeof e === 'string' ? e : (e?.target?.value || e?.detail?.value || '');
221
+ if (timer) clearTimeout(timer);
222
+ timer = setTimeout(() => handleSearch(value), 100);
223
+ }
224
+
225
+ function handleSearch(value: string) {
226
+ searchText.value = value || '';
227
+ page.value = 1;
228
+ }
229
+
230
+ function handleOpen() {
231
+ visible.value = true;
232
+ }
233
+
234
+ function handleConfirm() {
235
+ visible.value = false;
236
+ }
237
+
238
+ function handleCancel() {
239
+ visible.value = false;
240
+ }
241
+
242
+ </script>
243
+ <style lang="scss">
244
+ .cd-icon-picker {
245
+ &-popover {
246
+ .scrollbar {
247
+ height: 220px;
248
+ overflow: auto;
249
+ }
250
+ }
251
+ }
252
+
253
+ .cd-picker-body { display: flex; gap: 12px; padding: 12px; }
254
+ .cd-picker-sidebar { width: 240px; border-right: 1px solid #eee; padding-right: 12px; }
255
+ .cd-picker-section { margin-bottom: 12px; }
256
+ .cd-picker-section-title { font-size: 12px; color: #999; margin-bottom: 6px; }
257
+ .cd-picker-cats { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: row; gap: 4px; }
258
+ .cd-picker-cat { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
259
+ .cd-picker-cat.active { background: #f2f3f5; color: #0052d9; }
260
+
261
+ .cd-icon-only { display: flex; align-items: center; justify-content: center; border: 1px solid #e5e5e5; border-radius: 6px; padding: 6px; cursor: pointer; }
262
+ .cd-icon-only:hover { border-color: var(--td-brand-color, #0052d9); }
263
+
264
+ .cd-picker-main { width: 620px; }
265
+ .cd-picker-search { margin-bottom: 8px; }
266
+ .cd-picker-content { border-top: 1px solid #eee; }
267
+ .cd-picker-grid { list-style: none; margin: 0; padding: 10px 0; display: grid; grid-template-columns: repeat(10, 1fr); gap: 10px; }
268
+ .cd-picker-item { display: flex; width: 30px; align-items: center; justify-content: center; border: 1px solid #e5e5e5; border-radius: 6px; padding: 8px; cursor: pointer; }
269
+ .cd-picker-item.active { border-color: var(--td-brand-color, #0052d9); }
270
+ .cd-picker-item:hover { border-color: var(--td-brand-color, #0052d9); }
271
+ .cd-picker-pagination { display: flex; justify-content: center; padding: 8px; }
272
+ .cd-picker-empty { padding: 24px; }
273
+ </style>
@@ -0,0 +1,57 @@
1
+ <template>
2
+ <svg :class="['cd-svg-icon', $attrs.class, spin && 'svg-icon-spin']" :style="getStyle" aria-hidden="true">
3
+ <use :xlink:href="symbolId" />
4
+ </svg>
5
+ </template>
6
+ <script lang="ts">
7
+ import { defineComponent, computed } from 'vue';
8
+
9
+
10
+ export default defineComponent({
11
+ name: 'SvgIcon',
12
+ props: {
13
+ prefix: {
14
+ type: String,
15
+ default: 'icon',
16
+ },
17
+ name: {
18
+ type: String,
19
+ required: true,
20
+ },
21
+ size: {
22
+ type: [Number, String],
23
+ default: 16,
24
+ },
25
+ spin: {
26
+ type: Boolean,
27
+ default: false,
28
+ },
29
+ },
30
+ setup(props) {
31
+ const symbolId = computed(() => `#${props.prefix}-${props.name}`);
32
+
33
+ const getStyle = computed((): Record<string, any> => {
34
+ const { size } = props;
35
+ let s = `${size}`;
36
+ s = `${s.replace('px', '')}px`;
37
+ return {
38
+ width: s,
39
+ height: s,
40
+ };
41
+ });
42
+ return { symbolId, getStyle };
43
+ },
44
+ });
45
+ </script>
46
+ <style lang="scss" scoped>
47
+ .cd-svg-icon {
48
+ display: inline-block;
49
+ overflow: hidden;
50
+ vertical-align: -0.15em;
51
+ fill: currentColor;
52
+ }
53
+
54
+ .svg-icon-spin {
55
+ animation: loadingCircle 1s infinite linear;
56
+ }
57
+ </style>