@neatui/vue 5.6.0 → 5.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neatui/vue",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "basic component ui",
5
5
  "main": "./src/index.ts",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  "@fekit/scrollto": "^3.0.3",
37
37
  "@fekit/toast": "^2.7.2",
38
38
  "@fekit/utils": "^6.0.0",
39
- "@neatui/css": "^5.6.0",
39
+ "@neatui/css": "^5.7.0",
40
40
  "@rollup/plugin-commonjs": "^26.0.1",
41
41
  "@rollup/plugin-node-resolve": "^15.2.3",
42
42
  "@vue/compiler-sfc": "^3.4.30",
@@ -0,0 +1,480 @@
1
+ <template>
2
+ <div ref="root" class="ui-sidemenu flex-fixed" :data-mode="mode" :data-open="isOpen ? 1 : 0" :data-rail="rail ? 1 : 0" :data-opening="opening ? 1 : 0">
3
+ <div class="ui-sidemenu-mask" @click="setOpen(false)"></div>
4
+ <aside class="ui-sidemenu-panel" ui-flex="col xy">
5
+ <div class="ui-sidemenu-header flex-fixed" ui-flex="row xm">
6
+ <div class="ui-sidemenu-title flex-block">
7
+ <slot name="header" :rail="rail" :mode="mode">
8
+ <h5 ui-omit="1"><b>{{ title }}</b></h5>
9
+ </slot>
10
+ </div>
11
+ <button v-if="toggle" :aria-expanded="isOpen" :aria-label="toggleLabel" :title="toggleLabel" ui-btn="@a s none :square" type="button" @click="setOpen(!isOpen)">
12
+ <i class="icon icon-menu"></i>
13
+ </button>
14
+ </div>
15
+ <div class="flex-block" ui-scroll="x:hidden :y">
16
+ <ul class="ui-sidemenu-list">
17
+ <li v-for="(item, idx) in list" :key="item.__key" :class="{ active: item.__active }" :data-open="item.__open ? 1 : 0" >
18
+ <IFollowView v-model="railMap[item.__key]" btn-class="block" event="hover" pos="rb" tip-class="mt-ss">
19
+ <div class="ui-sidemenu-item bg-weak:hover" @click.stop="onItem(item, idx)">
20
+ <component
21
+ :is="item.children ? 'button' : linkTag"
22
+ v-bind="item.children ? { type: 'button', 'aria-expanded': rail ? !!railMap[item.__key] : !!item.__open } : linkProps(item)"
23
+ :aria-label="rail ? item.name : undefined"
24
+ ui-flex="row lm"
25
+ >
26
+ <div ui-flex="row cm">
27
+ <div ui-btn="@a s none :square">
28
+ <slot name="icon" :item="item"><i :class="`fs++ icon ${item.icon || ''}`"></i></slot>
29
+ </div>
30
+ <span class="ui-sidemenu-name">{{ item.name }}</span>
31
+ </div>
32
+ <i v-if="item.children" class="ui-sidemenu-arrow icon icon-dropdown"></i>
33
+ <span v-else-if="item.badge" class="ui-sidemenu-badge bg-risk co-fore lh-xs nx-sm ny-ss r-full">{{ badgeText(item.badge) }}</span>
34
+ </component>
35
+ </div>
36
+ <div v-if="item.children" class="ui-sidemenu-sub-wrap" :inert="rail || !item.__open">
37
+ <ul class="ui-sidemenu-sub">
38
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }">
39
+ <component :is="linkTag" v-bind="linkProps(sub)" @click="onLeaf(sub)">{{ sub.name }}</component>
40
+ </li>
41
+ </ul>
42
+ </div>
43
+ <template v-if="item.children && rail" #tips>
44
+ <ul :id="`${uid}-${item.__key}`" class="n-sl" ui-wrap="no">
45
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }" class="ux-hover r-sm nx-sl ny-ss">
46
+ <component :is="linkTag" v-bind="linkProps(sub)" class="block" @click="onLeaf(sub)">{{ sub.name }}</component>
47
+ </li>
48
+ </ul>
49
+ </template>
50
+ </IFollowView>
51
+ </li>
52
+ </ul>
53
+ </div>
54
+ <div v-if="$slots.footer" class="ui-sidemenu-footer flex-fixed">
55
+ <slot name="footer" :rail="rail" :mode="mode"></slot>
56
+ </div>
57
+ </aside>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue';
63
+ import { RouterLink, useRoute } from 'vue-router';
64
+ import IFollowView from './IFollowView.vue';
65
+
66
+ export interface SideMenuItem {
67
+ id?: string | number;
68
+ name: string;
69
+ path?: string;
70
+ icon?: string;
71
+ badge?: number | string;
72
+ children?: SideMenuItem[];
73
+ [key: string]: any;
74
+ }
75
+ type Mode = 'mob' | 'pad' | 'dpc';
76
+
77
+ const props = withDefaults(
78
+ defineProps<{
79
+ menus?: SideMenuItem[];
80
+ /** v-model:open;不传时组件自管,默认电脑展开、平板/手机收起 */
81
+ open?: boolean | number;
82
+ /** 当前路径,不传取 vue-router 当前路由 */
83
+ active?: string;
84
+ /** 子路由(/a/b)也高亮 /a */
85
+ prefix?: boolean;
86
+ title?: string;
87
+ toggle?: boolean;
88
+ toggleLabel?: string;
89
+ /** 同时只展开一个分组 */
90
+ accordion?: boolean;
91
+ }>(),
92
+ { menus: () => [], open: undefined, active: undefined, prefix: true, title: '', toggle: true, toggleLabel: '切换侧边栏', accordion: true },
93
+ );
94
+ const emit = defineEmits<{
95
+ (e: 'update:open', v: boolean): void;
96
+ (e: 'select', item: SideMenuItem): void;
97
+ (e: 'mode', mode: Mode): void;
98
+ }>();
99
+
100
+ // ---- 三端 ----
101
+ // 断点同 @neatui/css query:mob < 800 ≤ pad < 1280 ≤ dpc
102
+ const mode = ref<Mode>('dpc');
103
+ let mqMob: MediaQueryList | undefined;
104
+ let mqPad: MediaQueryList | undefined;
105
+ const readMode = () => {
106
+ mode.value = mqMob?.matches ? 'mob' : mqPad?.matches ? 'pad' : 'dpc';
107
+ };
108
+
109
+ // ---- 展开 ----
110
+ const inner = ref(true);
111
+ const isOpen = computed(() => (props.open === undefined ? inner.value : !!props.open));
112
+ const rail = computed(() => mode.value !== 'mob' && !isOpen.value);
113
+ const opening = ref(false);
114
+ let openingTimer: ReturnType<typeof setTimeout> | undefined;
115
+ const setOpen = (v: boolean) => {
116
+ inner.value = v;
117
+ emit('update:open', v);
118
+ };
119
+ watch(isOpen, (v) => {
120
+ // 展开过程中延迟展开子菜单,避免宽度动画时文字换行跳动
121
+ if (openingTimer) clearTimeout(openingTimer);
122
+ opening.value = v;
123
+ if (v) openingTimer = setTimeout(() => (opening.value = false), 400);
124
+ });
125
+ watch(mode, (m, old) => {
126
+ emit('mode', m);
127
+ // 换端时回到该端的默认态:电脑展开,平板/手机收起
128
+ if (old) setOpen(m === 'dpc');
129
+ });
130
+
131
+ const onKey = (e: KeyboardEvent) => {
132
+ if (e.key === 'Escape' && isOpen.value && mode.value !== 'dpc') setOpen(false);
133
+ };
134
+
135
+ onMounted(() => {
136
+ mqMob = window.matchMedia('(max-width: 799px)');
137
+ mqPad = window.matchMedia('(max-width: 1279px)');
138
+ readMode();
139
+ mqMob.addEventListener('change', readMode);
140
+ mqPad.addEventListener('change', readMode);
141
+ emit('mode', mode.value);
142
+ if (props.open === undefined) inner.value = mode.value === 'dpc';
143
+ else if (mode.value !== 'dpc' && props.open) setOpen(false);
144
+ window.addEventListener('keydown', onKey);
145
+ });
146
+ onBeforeUnmount(() => {
147
+ mqMob?.removeEventListener('change', readMode);
148
+ mqPad?.removeEventListener('change', readMode);
149
+ window.removeEventListener('keydown', onKey);
150
+ if (openingTimer) clearTimeout(openingTimer);
151
+ });
152
+
153
+ // ---- 菜单 ----
154
+ const route = useRoute();
155
+ const current = computed(() => props.active ?? route?.path ?? '');
156
+ const hit = (path?: string) => {
157
+ if (!path) return false;
158
+ const p = current.value.replace(/\/+$/, '') || '/';
159
+ const b = path.replace(/\/+$/, '') || '/';
160
+ return p === b || (props.prefix && b !== '/' && p.startsWith(b + '/'));
161
+ };
162
+ const keyOf = (item: SideMenuItem, idx: number) => String(item.id ?? item.path ?? idx);
163
+ const openMap = reactive<Record<string, boolean>>({});
164
+ // 收起态悬浮子菜单的显示状态
165
+ const railMap = reactive<Record<string, number>>({});
166
+ const uid = useId() || 'sidemenu';
167
+
168
+ const list = computed(() =>
169
+ props.menus.map((item, idx) => {
170
+ const __key = keyOf(item, idx);
171
+ if (!item.children?.length) return { ...item, children: undefined, __key, __active: hit(item.path), __open: false };
172
+ let __active = false;
173
+ const children = item.children.map((sub, i) => {
174
+ const a = hit(sub.path);
175
+ if (a) __active = true;
176
+ return { ...sub, __key: keyOf(sub, i), __active: a };
177
+ });
178
+ return { ...item, children, __key, __active, __open: openMap[__key] ?? false };
179
+ }),
180
+ );
181
+
182
+ // 当前路由所在分组自动展开(菜单异步到达、直接输网址都生效)
183
+ const activeGroup = computed(() => list.value.find((m) => m.children && m.__active)?.__key || '');
184
+ watch(
185
+ activeGroup,
186
+ (k) => {
187
+ if (!k) return;
188
+ if (props.accordion) Object.keys(openMap).forEach((o) => (openMap[o] = false));
189
+ openMap[k] = true;
190
+ },
191
+ { immediate: true },
192
+ );
193
+
194
+ const onItem = (item: any, _idx: number) => {
195
+ if (!item.children) return onLeaf(item);
196
+ // 收起态用悬浮子菜单:鼠标 hover 打开,键盘/点击切换并把焦点移入第一项
197
+ if (rail.value) {
198
+ const next = !railMap[item.__key];
199
+ railMap[item.__key] = next ? 1 : 0;
200
+ if (next) nextTick(() => document.getElementById(`${uid}-${item.__key}`)?.querySelector<HTMLElement>('a')?.focus());
201
+ return;
202
+ }
203
+ const next = !item.__open;
204
+ if (props.accordion) Object.keys(openMap).forEach((o) => (openMap[o] = false));
205
+ openMap[item.__key] = next;
206
+ };
207
+ const onLeaf = (item: SideMenuItem) => {
208
+ emit('select', item);
209
+ // 平板/手机选完收起浮层
210
+ if (mode.value !== 'dpc' && isOpen.value) setOpen(false);
211
+ };
212
+
213
+ const linkTag = computed(() => (route ? RouterLink : 'a'));
214
+ const linkProps = (item: SideMenuItem) => (route ? { to: item.path || '' } : { href: item.path || '' });
215
+ const badgeText = (b: number | string) => (typeof b === 'number' && b > 99 ? '99+' : b);
216
+
217
+ defineExpose({ mode, open: isOpen, setOpen });
218
+ </script>
219
+
220
+ <style lang="scss">
221
+ $rail: 5rem;
222
+ $full: 18rem;
223
+ $ease: cubic-bezier(0.32, 0.72, 0, 1);
224
+
225
+ .ui-sidemenu {
226
+ position: relative;
227
+ height: 100%;
228
+ width: $full;
229
+ z-index: 20;
230
+ transition: width 0.2s;
231
+
232
+ &[data-rail='1'] {
233
+ width: $rail;
234
+ }
235
+ // 平板:始终占轨道宽,展开时浮在内容上
236
+ &[data-mode='pad'] {
237
+ width: $rail;
238
+ }
239
+ // 手机:不占位,抽屉
240
+ &[data-mode='mob'] {
241
+ width: 0;
242
+ }
243
+ }
244
+
245
+ .ui-sidemenu-panel {
246
+ position: relative;
247
+ width: 100%;
248
+ height: 100%;
249
+ background: var(--co-fore);
250
+ box-shadow: 0 0 0.5rem color-mix(in srgb, var(--co-deep) 95%, transparent);
251
+ transition: width 0.2s, transform 0.4s $ease, box-shadow 0.4s $ease;
252
+
253
+ [data-mode='pad'] > & {
254
+ position: absolute;
255
+ left: 0;
256
+ top: 0;
257
+ width: $rail;
258
+ }
259
+ [data-mode='pad'][data-open='1'] > & {
260
+ width: $full;
261
+ }
262
+ [data-mode='mob'] > & {
263
+ position: fixed;
264
+ left: 0;
265
+ top: 0;
266
+ bottom: 0;
267
+ width: min(#{$full}, 85vw);
268
+ z-index: 40;
269
+ transform: translate3d(-100%, 0, 0);
270
+ box-shadow: none;
271
+ }
272
+ [data-mode='mob'][data-open='1'] > & {
273
+ transform: translate3d(0, 0, 0);
274
+ box-shadow: 0 0 1rem rgba(0, 0, 0, 0.2);
275
+ }
276
+ }
277
+
278
+ .ui-sidemenu-mask {
279
+ position: fixed;
280
+ inset: 0;
281
+ background: rgba(0, 0, 0, 0.35);
282
+ opacity: 0;
283
+ pointer-events: none;
284
+ transition: opacity 0.4s $ease;
285
+ z-index: 39;
286
+
287
+ [data-mode='pad'] > & {
288
+ background: transparent;
289
+ }
290
+ [data-mode='mob'][data-open='1'] > &,
291
+ [data-mode='pad'][data-open='1'] > & {
292
+ opacity: 1;
293
+ pointer-events: auto;
294
+ }
295
+ }
296
+ [data-mode='pad'] > .ui-sidemenu-panel {
297
+ z-index: 40;
298
+ }
299
+
300
+ .ui-sidemenu-header {
301
+ height: 4rem;
302
+ line-height: 2rem;
303
+ padding: 1rem 0.75rem 1rem 1rem;
304
+ white-space: nowrap;
305
+ box-shadow: 0 1px 0 var(--co-back);
306
+ transition: padding 0.2s;
307
+ }
308
+ .ui-sidemenu-footer {
309
+ padding: 1rem;
310
+ transition: padding 0.2s;
311
+ }
312
+
313
+ .ui-sidemenu-title,
314
+ .ui-sidemenu-name {
315
+ opacity: 1;
316
+ transition: opacity 0.2s 0.2s;
317
+ vertical-align: middle;
318
+ overflow: hidden;
319
+ }
320
+
321
+ .ui-sidemenu-list {
322
+ & > li {
323
+ position: relative;
324
+ white-space: nowrap;
325
+ padding: 0.25rem;
326
+ user-select: none;
327
+ transition: padding 0.2s;
328
+
329
+ &.active {
330
+ color: var(--co-main);
331
+ &::before {
332
+ position: absolute;
333
+ content: '';
334
+ width: 2px;
335
+ height: 2.5rem;
336
+ left: 0;
337
+ top: 0.25rem;
338
+ background: var(--co-main);
339
+ }
340
+ }
341
+ }
342
+ a {
343
+ cursor: pointer;
344
+ color: inherit;
345
+ }
346
+ }
347
+ .ui-sidemenu-item {
348
+ position: relative;
349
+ display: block;
350
+ padding: 0.25em;
351
+ border-radius: 0.5rem;
352
+ cursor: pointer;
353
+ }
354
+
355
+ // 分组标题用原生按钮,去掉默认样式
356
+ .ui-sidemenu-item > button {
357
+ width: 100%;
358
+ padding: 0;
359
+ border: 0;
360
+ background: none;
361
+ color: inherit;
362
+ font: inherit;
363
+ text-align: left;
364
+ cursor: pointer;
365
+ }
366
+
367
+ .ui-sidemenu-arrow {
368
+ position: absolute;
369
+ width: 2rem;
370
+ height: 2rem;
371
+ line-height: 2rem;
372
+ right: 0;
373
+ top: 50%;
374
+ margin-top: -1rem;
375
+ text-align: center;
376
+ transform: rotate(-90deg);
377
+ transition: transform 0.2s, opacity 0.2s 0.1s;
378
+ }
379
+ [data-open='1'] > * > .ui-sidemenu-item .ui-sidemenu-arrow {
380
+ transform: rotate(-180deg);
381
+ }
382
+
383
+ .ui-sidemenu-badge {
384
+ position: absolute;
385
+ top: 50%;
386
+ right: 1rem;
387
+ transform: translate3d(50%, -50%, 0) scale(0.5);
388
+ pointer-events: none;
389
+ }
390
+
391
+ // 二级(展开态)
392
+ .ui-sidemenu-sub-wrap {
393
+ display: grid;
394
+ grid-template-rows: 0fr;
395
+ opacity: 0;
396
+ transition: grid-template-rows 0.2s, opacity 0.2s;
397
+ }
398
+ .ui-sidemenu-list > [data-open='1'] .ui-sidemenu-sub-wrap {
399
+ grid-template-rows: 1fr;
400
+ opacity: 1;
401
+ }
402
+ .ui-sidemenu[data-opening='1'] .ui-sidemenu-list > [data-open='1'] .ui-sidemenu-sub-wrap {
403
+ transition-delay: 0.2s;
404
+ }
405
+ .ui-sidemenu-sub {
406
+ position: relative;
407
+ min-height: 0;
408
+ overflow: hidden;
409
+ padding-left: 2.5rem;
410
+ line-height: 2.5rem;
411
+ color: var(--co-read);
412
+
413
+ & > li {
414
+ position: relative;
415
+ height: 2.5rem;
416
+ padding: 0 0.5em;
417
+ border-radius: 0.5rem;
418
+ &:hover {
419
+ background: var(--co-weak);
420
+ }
421
+ a {
422
+ display: block;
423
+ }
424
+ // 树线
425
+ &::before {
426
+ position: absolute;
427
+ content: '';
428
+ width: 2px;
429
+ height: 100%;
430
+ top: 0;
431
+ left: -0.5em;
432
+ border-right: 1px solid var(--co-line);
433
+ }
434
+ &:last-child::before {
435
+ height: 50%;
436
+ }
437
+ &::after {
438
+ position: absolute;
439
+ content: '';
440
+ width: 0.6em;
441
+ height: 1px;
442
+ top: 50%;
443
+ left: -0.5em;
444
+ background: var(--co-line);
445
+ }
446
+ }
447
+ }
448
+
449
+ // 收起轨道态(电脑收起 / 平板默认)
450
+ .ui-sidemenu[data-rail='1'] {
451
+ .ui-sidemenu-header,
452
+ .ui-sidemenu-footer {
453
+ padding: 1rem 1.5rem;
454
+ }
455
+ .ui-sidemenu-title {
456
+ position: absolute;
457
+ left: 0.5rem;
458
+ opacity: 0;
459
+ visibility: hidden;
460
+ transition: none;
461
+ }
462
+ .ui-sidemenu-name {
463
+ opacity: 0;
464
+ pointer-events: none;
465
+ transition: opacity 0.15s;
466
+ }
467
+ .ui-sidemenu-list > li {
468
+ padding: 0.25rem 1.25rem;
469
+ }
470
+ .ui-sidemenu-sub-wrap,
471
+ .ui-sidemenu-arrow {
472
+ display: none;
473
+ }
474
+ .ui-sidemenu-badge {
475
+ top: 0;
476
+ right: 0;
477
+ transform: translate3d(40%, -30%, 0) scale(0.5);
478
+ }
479
+ }
480
+ </style>