mgv-backoffice 1.48.0 → 1.49.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.
Files changed (39) hide show
  1. package/README.md +17 -0
  2. package/dist/components/BaseBooleanIcon.vue.d.ts +14 -0
  3. package/dist/components/BaseDetailList.vue.d.ts +13 -1
  4. package/dist/components/BasePhotoViewer.vue.d.ts +24 -0
  5. package/dist/components/BaseRating.vue.d.ts +9 -0
  6. package/dist/components/BaseRowButton.vue.d.ts +18 -0
  7. package/dist/components/BaseSelect.vue.d.ts +15 -3
  8. package/dist/components/BaseSidebarUser.vue.d.ts +3 -0
  9. package/dist/components/BaseStatCard.vue.d.ts +6 -4
  10. package/dist/components/BaseTextarea.vue.d.ts +12 -0
  11. package/dist/composables/useRefreshOnReturn.d.ts +7 -0
  12. package/dist/index.d.ts +8 -1
  13. package/dist/types/photoViewer.d.ts +6 -0
  14. package/dist/ui-lib.css +1 -1
  15. package/dist/ui-lib.js +1018 -819
  16. package/dist/ui-lib.umd.cjs +1 -1
  17. package/dist/utils/format.d.ts +9 -0
  18. package/package.json +1 -1
  19. package/src/components/BaseBadge.vue +7 -7
  20. package/src/components/BaseBooleanIcon.vue +22 -0
  21. package/src/components/BaseDetailList.vue +50 -47
  22. package/src/components/BaseField.label.test.ts +30 -0
  23. package/src/components/BaseField.vue +19 -3
  24. package/src/components/BasePhotoViewer.test.ts +22 -0
  25. package/src/components/BasePhotoViewer.vue +93 -0
  26. package/src/components/BaseRating.test.ts +19 -0
  27. package/src/components/BaseRating.vue +21 -0
  28. package/src/components/BaseRowButton.vue +24 -0
  29. package/src/components/BaseSelect.vue +4 -2
  30. package/src/components/BaseSidebarUser.vue +85 -75
  31. package/src/components/BaseStatCard.vue +11 -3
  32. package/src/components/BaseTextarea.test.ts +17 -0
  33. package/src/components/BaseTextarea.vue +24 -0
  34. package/src/composables/useRefreshOnReturn.test.ts +47 -0
  35. package/src/composables/useRefreshOnReturn.ts +24 -0
  36. package/src/index.ts +10 -0
  37. package/src/types/photoViewer.ts +6 -0
  38. package/src/utils/format.test.ts +342 -322
  39. package/src/utils/format.ts +23 -0
@@ -110,3 +110,12 @@ export declare function stringifyValue(value: unknown): string;
110
110
  * Renders an em-dash for null/undefined/non-finite input.
111
111
  */
112
112
  export declare function fmtDuration(ms: number | null | undefined, maxUnits?: number): string;
113
+ /**
114
+ * Normalize a backend time string for display: "11:00:00" → "11:00".
115
+ * Leaves already-short ("11:00") or unexpected values untouched.
116
+ */
117
+ export declare function fmtTime(time?: string | null): string;
118
+ /** ISO instant → "YYYY-MM-DDTHH:mm" in the browser's time zone, for <input type="datetime-local">. */
119
+ export declare function toLocalInput(iso?: string | null): string;
120
+ /** Value of an <input type="datetime-local"> → ISO instant (null when empty). */
121
+ export declare function fromLocalInput(value: string): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -31,16 +31,16 @@ const props = defineProps({
31
31
  })
32
32
 
33
33
  const colorMap: Record<string, string> = {
34
- [ColorsEnums.RED]: 'bg-red-100 text-red-700 border-red-200',
35
- [ColorsEnums.GREEN]: 'bg-green-100 text-green-700 border-green-200',
36
- [ColorsEnums.BLUE]: 'bg-blue-100 text-blue-700 border-blue-200',
37
- [ColorsEnums.YELLOW]: 'bg-yellow-100 text-yellow-700 border-yellow-200',
38
- [ColorsEnums.GRAY]: 'bg-gray-100 text-gray-700 border-gray-200',
34
+ [ColorsEnums.RED]: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30',
35
+ [ColorsEnums.GREEN]: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-500/15 dark:text-green-300 dark:border-green-500/30',
36
+ [ColorsEnums.BLUE]: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
37
+ [ColorsEnums.YELLOW]: 'bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-300 dark:border-yellow-500/30',
38
+ [ColorsEnums.GRAY]: 'bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-500/15 dark:text-gray-300 dark:border-gray-500/30',
39
39
  [ColorsEnums.BLACK]: 'bg-gray-800 text-gray-100 border-gray-700',
40
- [ColorsEnums.NONE]: 'bg-gray-50 text-gray-600 border-gray-200',
40
+ [ColorsEnums.NONE]: 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700',
41
41
  }
42
42
 
43
- const defaultColor = 'bg-red-100 text-red-700 border-red-200'
43
+ const defaultColor = colorMap[ColorsEnums.RED]
44
44
 
45
45
  const badgeClasses = computed(() => {
46
46
  const colorClasses = (props.color && colorMap[props.color]) || defaultColor
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <span v-if="value == null">{{ emptyText }}</span>
3
+ <span v-else class="inline-flex align-middle" :title="value ? trueLabel : falseLabel">
4
+ <CheckCircleIcon v-if="value" class="h-5 w-5 shrink-0 text-emerald-500" :aria-label="trueLabel" />
5
+ <XCircleIcon v-else class="h-5 w-5 shrink-0 text-red-500" :aria-label="falseLabel" />
6
+ </span>
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ // Icon-only yes/no flag for dense tables: green tick or red cross with the label in the tooltip, emptyText when the value is unknown. Use BaseBooleanBadge when the label should be visible.
11
+ import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
12
+
13
+ withDefaults(
14
+ defineProps<{
15
+ value?: boolean | null
16
+ trueLabel?: string
17
+ falseLabel?: string
18
+ emptyText?: string
19
+ }>(),
20
+ { value: null, trueLabel: 'Yes', falseLabel: 'No', emptyText: '—' },
21
+ )
22
+ </script>
@@ -1,47 +1,50 @@
1
- <template>
2
- <dl class="grid grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))]' : columns === 3 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))] lg:grid-cols-[repeat(3,minmax(0,1fr))]' : ''">
3
- <div v-for="item in items" :key="item.label" class="min-w-0">
4
- <dt class="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{{ item.label }}</dt>
5
- <dd class="mt-1 break-words text-sm text-slate-900 dark:text-slate-100" :class="item.mono ? 'font-mono' : ''">
6
- <a
7
- v-if="item.href && !isEmpty(item.value)"
8
- :href="item.href"
9
- target="_blank"
10
- rel="noopener noreferrer"
11
- class="text-emerald-600 hover:underline dark:text-emerald-400"
12
- >{{ item.value }}</a>
13
- <span v-else-if="isEmpty(item.value)" class="text-slate-400 dark:text-slate-500">{{ emptyText }}</span>
14
- <template v-else>{{ item.value }}</template>
15
- </dd>
16
- </div>
17
- </dl>
18
- </template>
19
-
20
- <script setup lang="ts">
21
- // Column classes use the arbitrary-value form on purpose (see
22
- // noSharedResponsiveGrid.test.ts): plain responsive column utilities would ship
23
- // in the lib CSS, which consumers import AFTER their own utilities, and override
24
- // the consumers' larger-breakpoint grid columns.
25
- /**
26
- * Read-only label/value grid for detail pages — the "view" counterpart of a
27
- * form. Empty values render `emptyText` (—); `href` makes the value an
28
- * external link; `mono` sets it in the monospace face (ids, hashes).
29
- */
30
- import type { DetailItem } from '../types/detail'
31
-
32
- interface Props {
33
- items: readonly DetailItem[]
34
- /** Columns from `sm` up (1 column on phones). */
35
- columns?: 1 | 2 | 3
36
- emptyText?: string
37
- }
38
-
39
- withDefaults(defineProps<Props>(), {
40
- columns: 2,
41
- emptyText: '—',
42
- })
43
-
44
- function isEmpty(v: DetailItem['value']): boolean {
45
- return v === null || v === undefined || v === ''
46
- }
47
- </script>
1
+ <template>
2
+ <dl class="grid grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))]' : columns === 3 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))] lg:grid-cols-[repeat(3,minmax(0,1fr))]' : ''">
3
+ <div v-for="item in items" :key="item.label" class="min-w-0">
4
+ <dt class="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{{ item.label }}</dt>
5
+ <dd class="mt-1 break-words text-sm text-slate-900 dark:text-slate-100" :class="item.mono ? 'font-mono' : ''">
6
+ <slot name="value" :item="item">
7
+ <a
8
+ v-if="item.href && !isEmpty(item.value)"
9
+ :href="item.href"
10
+ target="_blank"
11
+ rel="noopener noreferrer"
12
+ class="text-emerald-600 hover:underline dark:text-emerald-400"
13
+ >{{ item.value }}</a>
14
+ <span v-else-if="isEmpty(item.value)" class="text-slate-400 dark:text-slate-500">{{ emptyText }}</span>
15
+ <template v-else>{{ item.value }}</template>
16
+ </slot>
17
+ </dd>
18
+ </div>
19
+ </dl>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ // Column classes use the arbitrary-value form on purpose (see
24
+ // noSharedResponsiveGrid.test.ts): plain responsive column utilities would ship
25
+ // in the lib CSS, which consumers import AFTER their own utilities, and override
26
+ // the consumers' larger-breakpoint grid columns.
27
+ /**
28
+ * Read-only label/value grid for detail pages — the "view" counterpart of a
29
+ * form. Empty values render `emptyText` (—); `href` makes the value an
30
+ * external link; `mono` sets it in the monospace face (ids, hashes). The scoped
31
+ * `value` slot (`{ item }`) overrides how a value renders, e.g. a badge.
32
+ */
33
+ import type { DetailItem } from '../types/detail'
34
+
35
+ interface Props {
36
+ items: readonly DetailItem[]
37
+ /** Columns from `sm` up (1 column on phones). */
38
+ columns?: 1 | 2 | 3
39
+ emptyText?: string
40
+ }
41
+
42
+ withDefaults(defineProps<Props>(), {
43
+ columns: 2,
44
+ emptyText: '—',
45
+ })
46
+
47
+ function isEmpty(v: DetailItem['value']): boolean {
48
+ return v === null || v === undefined || v === ''
49
+ }
50
+ </script>
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import BaseField from './BaseField.vue'
5
+
6
+ describe('BaseField label association', () => {
7
+ it('links the label to the slotted control when labelFor is not set', async () => {
8
+ const wrapper = mount(BaseField, { props: { label: 'Name' }, slots: { default: '<input />' } })
9
+ await nextTick()
10
+ const id = wrapper.find('input').attributes('id')
11
+ expect(id).toBeTruthy()
12
+ expect(wrapper.find('label').attributes('for')).toBe(id)
13
+ })
14
+
15
+ it('keeps an id the control already has', async () => {
16
+ const wrapper = mount(BaseField, { props: { label: 'Name' }, slots: { default: '<input id="own" />' } })
17
+ await nextTick()
18
+ expect(wrapper.find('label').attributes('for')).toBe('own')
19
+ })
20
+
21
+ it('prefers an explicit labelFor', async () => {
22
+ const wrapper = mount(BaseField, {
23
+ props: { label: 'Name', labelFor: 'x' },
24
+ slots: { default: '<input />' },
25
+ })
26
+ await nextTick()
27
+ expect(wrapper.find('label').attributes('for')).toBe('x')
28
+ expect(wrapper.find('input').attributes('id')).toBeUndefined()
29
+ })
30
+ })
@@ -1,8 +1,8 @@
1
1
  <template>
2
- <div :class="grow ? 'min-w-48 flex-1' : ''" class="text-left">
2
+ <div ref="root" :class="grow ? 'min-w-48 flex-1' : ''" class="text-left">
3
3
  <label
4
4
  v-if="label"
5
- :for="labelFor || undefined"
5
+ :for="labelFor || autoFor"
6
6
  class="mb-1 block"
7
7
  :class="compact
8
8
  ? 'text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400'
@@ -17,6 +17,8 @@
17
17
  </template>
18
18
 
19
19
  <script setup lang="ts">
20
+ import { onMounted, ref, useId } from 'vue'
21
+
20
22
  /**
21
23
  * Label + control + message wrapper for forms and filter bars. The control
22
24
  * goes in the default slot (BaseInput, BaseSelect, BaseSearchSelect, …).
@@ -35,7 +37,7 @@ interface Props {
35
37
  grow?: boolean
36
38
  }
37
39
 
38
- withDefaults(defineProps<Props>(), {
40
+ const props = withDefaults(defineProps<Props>(), {
39
41
  label: '',
40
42
  labelFor: '',
41
43
  required: false,
@@ -44,4 +46,18 @@ withDefaults(defineProps<Props>(), {
44
46
  compact: false,
45
47
  grow: false,
46
48
  })
49
+
50
+ // Without labelFor, link the label to the control in the slot so clicking it
51
+ // focuses the field and screen readers announce it.
52
+ const root = ref<HTMLElement>()
53
+ const autoFor = ref<string>()
54
+ const generatedId = useId()
55
+
56
+ onMounted(() => {
57
+ if (props.labelFor || !props.label) return
58
+ const control = root.value?.querySelector<HTMLElement>('input, select, textarea')
59
+ if (!control) return
60
+ control.id ||= generatedId
61
+ autoFor.value = control.id
62
+ })
47
63
  </script>
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BasePhotoViewer from './BasePhotoViewer.vue'
4
+
5
+ const photos = [
6
+ { src: 'a.png', label: 'A' },
7
+ { src: 'b.png', label: 'B' },
8
+ ]
9
+
10
+ describe('BasePhotoViewer', () => {
11
+ it('wraps around with the arrow keys and closes on Escape', async () => {
12
+ const wrapper = mount(BasePhotoViewer, {
13
+ props: { photos, modelValue: 0 },
14
+ global: { stubs: { Teleport: true } },
15
+ })
16
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))
17
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([1])
18
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
19
+ expect(wrapper.emitted('close')).toHaveLength(1)
20
+ wrapper.unmount()
21
+ })
22
+ })
@@ -0,0 +1,93 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="current"
5
+ class="fixed inset-0 z-[1000] flex flex-col bg-slate-950/90"
6
+ role="dialog"
7
+ aria-modal="true"
8
+ @click.self="emit('close')"
9
+ >
10
+ <div class="flex items-center justify-between px-4 py-3 text-sm text-white">
11
+ <span class="font-medium">{{ current.label }}</span>
12
+ <div class="flex items-center gap-3">
13
+ <span class="text-slate-300">{{ index + 1 }} / {{ photos.length }}</span>
14
+ <button type="button" class="rounded-full p-1.5 hover:bg-white/10" :aria-label="closeLabel" @click="emit('close')">
15
+ <XMarkIcon class="h-6 w-6" />
16
+ </button>
17
+ </div>
18
+ </div>
19
+ <div class="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4" @click.self="emit('close')">
20
+ <img :src="current.src" :alt="current.label" class="max-h-full max-w-full rounded-lg object-contain shadow-2xl" />
21
+ <template v-if="photos.length > 1">
22
+ <button
23
+ type="button"
24
+ class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
25
+ :aria-label="previousLabel"
26
+ @click="step(-1)"
27
+ >
28
+ <ChevronLeftIcon class="h-7 w-7" />
29
+ </button>
30
+ <button
31
+ type="button"
32
+ class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
33
+ :aria-label="nextLabel"
34
+ @click="step(1)"
35
+ >
36
+ <ChevronRightIcon class="h-7 w-7" />
37
+ </button>
38
+ </template>
39
+ </div>
40
+ <p v-if="current.caption" class="mx-auto max-w-2xl whitespace-pre-line px-4 pb-4 text-center text-sm text-white">
41
+ {{ current.caption }}
42
+ </p>
43
+ <div v-if="photos.length > 1" class="flex justify-center gap-2 overflow-x-auto px-4 pb-4">
44
+ <button
45
+ v-for="(photo, i) in photos"
46
+ :key="i"
47
+ type="button"
48
+ class="h-14 w-14 shrink-0 overflow-hidden rounded-md border-2"
49
+ :class="i === index ? 'border-emerald-400' : 'border-transparent opacity-60 hover:opacity-100'"
50
+ :aria-label="photo.label"
51
+ @click="index = i"
52
+ >
53
+ <img :src="photo.src" :alt="photo.label" class="h-full w-full object-cover" />
54
+ </button>
55
+ </div>
56
+ </div>
57
+ </Teleport>
58
+ </template>
59
+
60
+ <script setup lang="ts">
61
+ // Full-screen photo viewer. v-model is the current index; arrow keys or the buttons step through, Esc or the backdrop emits `close`. Labels are props so apps can translate them.
62
+ import { computed, onBeforeUnmount, onMounted } from 'vue'
63
+ import { ChevronLeftIcon, ChevronRightIcon, XMarkIcon } from '@heroicons/vue/24/outline'
64
+ import type { ViewerPhoto } from '../types/photoViewer'
65
+
66
+ const props = withDefaults(
67
+ defineProps<{
68
+ photos: ViewerPhoto[]
69
+ closeLabel?: string
70
+ previousLabel?: string
71
+ nextLabel?: string
72
+ }>(),
73
+ { closeLabel: 'Close', previousLabel: 'Previous', nextLabel: 'Next' },
74
+ )
75
+ const index = defineModel<number>({ required: true })
76
+ const emit = defineEmits<{ close: [] }>()
77
+
78
+ const current = computed(() => props.photos[index.value])
79
+
80
+ function step(delta: number) {
81
+ const n = props.photos.length
82
+ if (n > 0) index.value = (index.value + delta + n) % n
83
+ }
84
+
85
+ function onKey(e: KeyboardEvent) {
86
+ if (e.key === 'Escape') emit('close')
87
+ else if (e.key === 'ArrowLeft') step(-1)
88
+ else if (e.key === 'ArrowRight') step(1)
89
+ }
90
+
91
+ onMounted(() => window.addEventListener('keydown', onKey))
92
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
93
+ </script>
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseRating from './BaseRating.vue'
4
+ import BaseBooleanIcon from './BaseBooleanIcon.vue'
5
+
6
+ describe('BaseRating', () => {
7
+ it('shows the value and optional count', () => {
8
+ expect(mount(BaseRating, { props: { rating: 4.26, count: 3 } }).text()).toContain('4.3 (3)')
9
+ expect(mount(BaseRating, { props: { rating: 4 } }).text()).toBe('4.0')
10
+ })
11
+ })
12
+
13
+ describe('BaseBooleanIcon', () => {
14
+ it('renders a dash for unknown and a labelled icon otherwise', () => {
15
+ expect(mount(BaseBooleanIcon, { props: { value: null } }).text()).toBe('—')
16
+ const yes = mount(BaseBooleanIcon, { props: { value: true, trueLabel: 'Ναι' } })
17
+ expect(yes.attributes('title')).toBe('Ναι')
18
+ })
19
+ })
@@ -0,0 +1,21 @@
1
+ <template>
2
+ <span class="inline-flex items-center gap-0.5" :title="`${rating} / 5`">
3
+ <template v-for="i in 5" :key="i">
4
+ <StarSolid v-if="i <= Math.round(rating)" class="h-4 w-4 text-amber-400" aria-hidden="true" />
5
+ <StarOutline v-else class="h-4 w-4 text-slate-300 dark:text-slate-600" aria-hidden="true" />
6
+ </template>
7
+ <span class="ml-1 font-mono text-xs text-slate-500 dark:text-slate-400">
8
+ {{ rating.toFixed(1) }}<template v-if="count !== null"> ({{ count }})</template>
9
+ </span>
10
+ </span>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ // Read-only 0-5 star rating with its numeric value and optional review count.
15
+ import { StarIcon as StarSolid } from '@heroicons/vue/24/solid'
16
+ import { StarIcon as StarOutline } from '@heroicons/vue/24/outline'
17
+
18
+ withDefaults(defineProps<{ rating: number; count?: number | null }>(), {
19
+ count: null,
20
+ })
21
+ </script>
@@ -0,0 +1,24 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium shadow-sm transition-colors"
5
+ :class="VARIANT_CLASS[variant]"
6
+ @click.stop
7
+ >
8
+ <slot />
9
+ </button>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ // Compact button for a table's Actions column: outline (neutral), primary (green) or danger (red). Stops click propagation so it works inside clickable rows.
14
+ withDefaults(defineProps<{ variant?: 'outline' | 'primary' | 'danger' }>(), {
15
+ variant: 'outline',
16
+ })
17
+
18
+ const VARIANT_CLASS = {
19
+ outline:
20
+ 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800',
21
+ primary: 'border border-emerald-700 bg-emerald-700 text-white hover:bg-emerald-800',
22
+ danger: 'border border-red-400 bg-red-400 text-white hover:bg-red-500',
23
+ }
24
+ </script>
@@ -4,11 +4,13 @@
4
4
  v-bind="$attrs"
5
5
  :value="modelValue"
6
6
  class="base-select w-full appearance-none bg-none rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm cursor-pointer transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
7
- :class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
7
+ :class="[size === 'md' ? 'py-2 pr-9' : 'py-1.5 pr-8', $slots.icon ? 'pl-8' : size === 'md' ? 'pl-3' : 'pl-2.5']"
8
8
  @change="onChange"
9
9
  >
10
10
  <SlotContent />
11
11
  </select>
12
+ <!-- Optional leading icon: pass a Heroicon and bind `iconClass`. -->
13
+ <slot name="icon" icon-class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
12
14
  <ChevronDownIcon
13
15
  class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 dark:text-slate-400"
14
16
  :class="size === 'md' ? 'right-3' : 'right-2.5'"
@@ -40,7 +42,7 @@ defineOptions({ inheritAttrs: false })
40
42
  // declarations) so `v-model` on a union-typed ref ('7D' | '30D' | …)
41
43
  // type-checks; the actual payload is always the selected option's string.
42
44
  interface Props {
43
- modelValue?: string | null
45
+ modelValue?: string | number | null
44
46
  /** 'sm' = px-2 py-1.5 (default, the usual select height); 'md' = px-3 py-2. */
45
47
  size?: 'sm' | 'md'
46
48
  /** Full-width (w-full). Set false for inline selects. */
@@ -1,75 +1,85 @@
1
- <template>
2
- <div class="flex flex-col gap-2 text-left">
3
- <component
4
- :is="to ? RouterLink : 'div'"
5
- v-bind="to ? { to } : {}"
6
- class="flex items-center gap-3 rounded-lg p-2 transition-colors"
7
- :class="to ? 'hover:bg-slate-100 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : ''"
8
- :title="to ? profileLabel : undefined"
9
- >
10
- <span
11
- class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
12
- aria-hidden="true"
13
- >{{ initials }}</span>
14
- <span class="min-w-0">
15
- <span class="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">{{ name }}</span>
16
- <span v-if="email" class="block truncate text-xs text-slate-500 dark:text-slate-400">{{ email }}</span>
17
- </span>
18
- </component>
19
- <BaseButton
20
- v-if="showLogout"
21
- class="w-full justify-center"
22
- :description="logoutLabel"
23
- :color="BaseButtonEnum.RED"
24
- :size="BaseButtonSizeEnum.SMALL"
25
- outline
26
- icon-left
27
- @click="emit('logout')"
28
- >
29
- <ArrowRightStartOnRectangleIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
30
- </BaseButton>
31
- </div>
32
- </template>
33
-
34
- <script setup lang="ts">
35
- /**
36
- * Signed-in user block for the sidebar footer (BaseSidebar `footer` slot):
37
- * initials avatar + name + email, linking to the profile page when `to` is
38
- * set, and a Logout button that emits `logout` (the app clears its session).
39
- */
40
- import { computed } from 'vue'
41
- import { RouterLink, type RouteLocationRaw } from 'vue-router'
42
- import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/24/outline'
43
- import BaseButton from './BaseButton.vue'
44
- import { BaseButtonEnum } from '../enums/BaseButtonEnum'
45
- import { BaseButtonSizeEnum } from '../enums/BaseButtonSizeEnum'
46
-
47
- interface Props {
48
- name: string
49
- email?: string
50
- /** Profile route; makes the name/email block a link. */
51
- to?: RouteLocationRaw
52
- showLogout?: boolean
53
- logoutLabel?: string
54
- profileLabel?: string
55
- }
56
-
57
- const props = withDefaults(defineProps<Props>(), {
58
- email: '',
59
- to: undefined,
60
- showLogout: true,
61
- logoutLabel: 'Logout',
62
- profileLabel: 'View profile',
63
- })
64
-
65
- const emit = defineEmits<{ (e: 'logout'): void }>()
66
-
67
- const initials = computed(() =>
68
- (props.name || '?')
69
- .split(/[\s._-]+/)
70
- .filter(Boolean)
71
- .slice(0, 2)
72
- .map((p) => p[0]!.toUpperCase())
73
- .join(''),
74
- )
75
- </script>
1
+ <template>
2
+ <div class="flex flex-col gap-2 text-left">
3
+ <component
4
+ :is="to ? RouterLink : 'div'"
5
+ v-bind="to ? { to } : {}"
6
+ class="flex items-center gap-3 rounded-lg p-2 transition-colors"
7
+ :class="to ? 'hover:bg-slate-100 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : ''"
8
+ :title="to ? profileLabel : undefined"
9
+ >
10
+ <img
11
+ v-if="avatarUrl"
12
+ :src="avatarUrl"
13
+ alt=""
14
+ class="h-9 w-9 shrink-0 rounded-full object-cover"
15
+ />
16
+ <span
17
+ v-else
18
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
19
+ aria-hidden="true"
20
+ >{{ initials }}</span>
21
+ <span class="min-w-0">
22
+ <span class="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">{{ name }}</span>
23
+ <span v-if="email" class="block truncate text-xs text-slate-500 dark:text-slate-400">{{ email }}</span>
24
+ </span>
25
+ </component>
26
+ <BaseButton
27
+ v-if="showLogout"
28
+ class="w-full justify-center"
29
+ :description="logoutLabel"
30
+ :color="BaseButtonEnum.RED"
31
+ :size="BaseButtonSizeEnum.SMALL"
32
+ outline
33
+ icon-left
34
+ @click="emit('logout')"
35
+ >
36
+ <ArrowRightStartOnRectangleIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
37
+ </BaseButton>
38
+ </div>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ /**
43
+ * Signed-in user block for the sidebar footer (BaseSidebar `footer` slot):
44
+ * initials avatar + name + email, linking to the profile page when `to` is
45
+ * set, and a Logout button that emits `logout` (the app clears its session).
46
+ */
47
+ import { computed } from 'vue'
48
+ import { RouterLink, type RouteLocationRaw } from 'vue-router'
49
+ import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/24/outline'
50
+ import BaseButton from './BaseButton.vue'
51
+ import { BaseButtonEnum } from '../enums/BaseButtonEnum'
52
+ import { BaseButtonSizeEnum } from '../enums/BaseButtonSizeEnum'
53
+
54
+ interface Props {
55
+ name: string
56
+ email?: string
57
+ /** Photo shown instead of the initials. */
58
+ avatarUrl?: string | null
59
+ /** Profile route; makes the name/email block a link. */
60
+ to?: RouteLocationRaw
61
+ showLogout?: boolean
62
+ logoutLabel?: string
63
+ profileLabel?: string
64
+ }
65
+
66
+ const props = withDefaults(defineProps<Props>(), {
67
+ email: '',
68
+ avatarUrl: null,
69
+ to: undefined,
70
+ showLogout: true,
71
+ logoutLabel: 'Logout',
72
+ profileLabel: 'View profile',
73
+ })
74
+
75
+ const emit = defineEmits<{ (e: 'logout'): void }>()
76
+
77
+ const initials = computed(() =>
78
+ (props.name || '?')
79
+ .split(/[\s._-]+/)
80
+ .filter(Boolean)
81
+ .slice(0, 2)
82
+ .map((p) => p[0]!.toUpperCase())
83
+ .join(''),
84
+ )
85
+ </script>