mgv-backoffice 1.47.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 (43) hide show
  1. package/README.md +2099 -2072
  2. package/dist/components/BaseBooleanIcon.vue.d.ts +14 -0
  3. package/dist/components/BaseDetailList.vue.d.ts +13 -1
  4. package/dist/components/BasePageHeader.vue.d.ts +1 -1
  5. package/dist/components/BasePhotoViewer.vue.d.ts +24 -0
  6. package/dist/components/BaseRating.vue.d.ts +9 -0
  7. package/dist/components/BaseRowButton.vue.d.ts +18 -0
  8. package/dist/components/BaseSelect.vue.d.ts +15 -3
  9. package/dist/components/BaseSidebarUser.vue.d.ts +3 -0
  10. package/dist/components/BaseStatCard.vue.d.ts +6 -4
  11. package/dist/components/BaseTextarea.vue.d.ts +12 -0
  12. package/dist/composables/useRefreshOnReturn.d.ts +7 -0
  13. package/dist/index.d.ts +8 -1
  14. package/dist/types/photoViewer.d.ts +6 -0
  15. package/dist/ui-lib.css +1 -1
  16. package/dist/ui-lib.js +1050 -851
  17. package/dist/ui-lib.umd.cjs +1 -1
  18. package/dist/utils/format.d.ts +9 -0
  19. package/package.json +1 -1
  20. package/src/components/BaseBadge.vue +7 -7
  21. package/src/components/BaseBooleanIcon.vue +22 -0
  22. package/src/components/BaseDetailList.vue +50 -47
  23. package/src/components/BaseField.label.test.ts +30 -0
  24. package/src/components/BaseField.vue +19 -3
  25. package/src/components/BasePageHeader.test.ts +29 -0
  26. package/src/components/BasePageHeader.vue +137 -131
  27. package/src/components/BasePhotoViewer.test.ts +22 -0
  28. package/src/components/BasePhotoViewer.vue +93 -0
  29. package/src/components/BaseRating.test.ts +19 -0
  30. package/src/components/BaseRating.vue +21 -0
  31. package/src/components/BaseRowButton.vue +24 -0
  32. package/src/components/BaseSelect.vue +4 -2
  33. package/src/components/BaseSidebarUser.vue +85 -75
  34. package/src/components/BaseStatCard.vue +11 -3
  35. package/src/components/BaseTextarea.test.ts +17 -0
  36. package/src/components/BaseTextarea.vue +24 -0
  37. package/src/composables/useRefreshOnReturn.test.ts +47 -0
  38. package/src/composables/useRefreshOnReturn.ts +24 -0
  39. package/src/index.ts +10 -0
  40. package/src/style.css +132 -36
  41. package/src/types/photoViewer.ts +6 -0
  42. package/src/utils/format.test.ts +342 -322
  43. package/src/utils/format.ts +23 -0
@@ -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>
@@ -11,7 +11,7 @@
11
11
  {{ title }}
12
12
  </p>
13
13
  <p class="mt-1 truncate text-3xl font-bold" :class="cls.value">
14
- {{ value }}
14
+ <slot name="value">{{ value }}</slot>
15
15
  </p>
16
16
  <p v-if="subtitle" class="mt-1 text-sm font-medium" :class="cls.subtitle">
17
17
  {{ subtitle }}
@@ -35,13 +35,14 @@
35
35
  * bold title, big value, accent subtitle, tinted icon chip) but for any
36
36
  * value — counts, labels, pre-formatted numbers. Pass `to` to make the whole
37
37
  * card a router link. The `icon` slot fills the chip (receives `iconClass`);
38
- * the `default` slot adds extra content under the subtitle.
38
+ * the `value` slot replaces the value text (e.g. <EuroAmount>); the `default`
39
+ * slot adds extra content under the subtitle.
39
40
  *
40
41
  */
41
42
  import { computed } from 'vue'
42
43
  import { RouterLink, type RouteLocationRaw } from 'vue-router'
43
44
 
44
- type Accent = 'emerald' | 'sky' | 'amber' | 'red' | 'slate'
45
+ type Accent = 'emerald' | 'sky' | 'amber' | 'violet' | 'red' | 'slate'
45
46
 
46
47
  interface Props {
47
48
  title: string
@@ -69,6 +70,13 @@ const ACCENTS: Record<Accent, { border: string; value: string; subtitle: string;
69
70
  iconBox: 'bg-emerald-50 dark:bg-emerald-900/30',
70
71
  icon: 'text-emerald-500',
71
72
  },
73
+ violet: {
74
+ border: 'border-violet-300 dark:border-violet-700',
75
+ value: 'text-violet-500 dark:text-violet-400',
76
+ subtitle: 'text-violet-500 dark:text-violet-400',
77
+ iconBox: 'bg-violet-50 dark:bg-violet-900/30',
78
+ icon: 'text-violet-500',
79
+ },
72
80
  sky: {
73
81
  border: 'border-sky-300 dark:border-sky-700',
74
82
  value: 'text-sky-500 dark:text-sky-400',
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseTextarea from './BaseTextarea.vue'
4
+
5
+ describe('BaseTextarea', () => {
6
+ it('emits the typed value and passes attrs through', async () => {
7
+ const wrapper = mount(BaseTextarea, { props: { modelValue: 'a' }, attrs: { rows: '3' } })
8
+ expect(wrapper.attributes('rows')).toBe('3')
9
+ await wrapper.setValue('ab')
10
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ab'])
11
+ })
12
+
13
+ it('renders null as empty', () => {
14
+ const wrapper = mount(BaseTextarea, { props: { modelValue: null } })
15
+ expect((wrapper.element as HTMLTextAreaElement).value).toBe('')
16
+ })
17
+ })
@@ -0,0 +1,24 @@
1
+ <template>
2
+ <textarea
3
+ :value="modelValue ?? ''"
4
+ class="w-full 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 px-3 py-2 placeholder:text-slate-400 dark:placeholder:text-slate-500 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"
5
+ @input="onInput"
6
+ />
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ // Themed multi-line input, sharing BaseInput's field skin. rows, maxlength, placeholder, required and extra classes fall through via attrs.
11
+ interface Props {
12
+ modelValue?: string | null
13
+ }
14
+
15
+ withDefaults(defineProps<Props>(), { modelValue: '' })
16
+
17
+ const emit = defineEmits<{
18
+ (e: 'update:modelValue', value: string): void
19
+ }>()
20
+
21
+ function onInput(e: Event) {
22
+ emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
23
+ }
24
+ </script>
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { createApp, defineComponent, h } from 'vue'
3
+ import { REFRESH_AFTER_AWAY_MS, useRefreshOnReturn } from './useRefreshOnReturn'
4
+
5
+ function setVisibility(state: 'hidden' | 'visible') {
6
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
7
+ document.dispatchEvent(new Event('visibilitychange'))
8
+ }
9
+
10
+ describe('useRefreshOnReturn', () => {
11
+ afterEach(() => vi.useRealTimers())
12
+
13
+ function mountWith(refresh: () => void) {
14
+ const app = createApp(
15
+ defineComponent({
16
+ setup() {
17
+ useRefreshOnReturn(refresh)
18
+ return () => h('div')
19
+ },
20
+ }),
21
+ )
22
+ app.mount(document.createElement('div'))
23
+ return app
24
+ }
25
+
26
+ it('refreshes after a long absence', () => {
27
+ vi.useFakeTimers()
28
+ const refresh = vi.fn()
29
+ const wrapper = mountWith(refresh)
30
+ setVisibility('hidden')
31
+ vi.advanceTimersByTime(REFRESH_AFTER_AWAY_MS)
32
+ setVisibility('visible')
33
+ expect(refresh).toHaveBeenCalledOnce()
34
+ wrapper.unmount()
35
+ })
36
+
37
+ it('keeps the page after a quick switch', () => {
38
+ vi.useFakeTimers()
39
+ const refresh = vi.fn()
40
+ const wrapper = mountWith(refresh)
41
+ setVisibility('hidden')
42
+ vi.advanceTimersByTime(REFRESH_AFTER_AWAY_MS - 1)
43
+ setVisibility('visible')
44
+ expect(refresh).not.toHaveBeenCalled()
45
+ wrapper.unmount()
46
+ })
47
+ })
@@ -0,0 +1,24 @@
1
+ import { onBeforeUnmount, onMounted } from 'vue'
2
+
3
+ /** How long the tab must be hidden before coming back reloads the page's data; quick switches keep it. */
4
+ export const REFRESH_AFTER_AWAY_MS = 30_000
5
+
6
+ /**
7
+ * Calls `refresh` when the browser tab becomes visible again after `afterMs` or more hidden, since server data
8
+ * changes while the user is elsewhere. The first load is left to the component.
9
+ */
10
+ export function useRefreshOnReturn(refresh: () => unknown, afterMs = REFRESH_AFTER_AWAY_MS): void {
11
+ let hiddenAt: number | null = null
12
+
13
+ function onVisibilityChange() {
14
+ if (document.visibilityState === 'hidden') {
15
+ hiddenAt ??= Date.now()
16
+ return
17
+ }
18
+ if (hiddenAt !== null && Date.now() - hiddenAt >= afterMs) void refresh()
19
+ hiddenAt = null
20
+ }
21
+
22
+ onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange))
23
+ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibilityChange))
24
+ }
package/src/index.ts CHANGED
@@ -68,6 +68,13 @@ export { default as BaseSidebarUser } from './components/BaseSidebarUser.vue'
68
68
  export { default as BaseAuthLayout } from './components/BaseAuthLayout.vue'
69
69
  export { default as BaseBooleanBadge } from './components/BaseBooleanBadge.vue'
70
70
  export { default as BaseDetailList } from './components/BaseDetailList.vue'
71
+ export { default as BaseTextarea } from './components/BaseTextarea.vue'
72
+ export { default as BaseRating } from './components/BaseRating.vue'
73
+ export { default as BaseRowButton } from './components/BaseRowButton.vue'
74
+ export { default as BaseBooleanIcon } from './components/BaseBooleanIcon.vue'
75
+ export { default as BasePhotoViewer } from './components/BasePhotoViewer.vue'
76
+ export type { ViewerPhoto } from './types/photoViewer'
77
+ export { useRefreshOnReturn, REFRESH_AFTER_AWAY_MS } from './composables/useRefreshOnReturn'
71
78
  export type { DetailItem } from './types/detail'
72
79
 
73
80
  // Composables
@@ -149,6 +156,9 @@ export {
149
156
  fmtCurrencyAmount,
150
157
  fmtDuration,
151
158
  formatJson,
159
+ fmtTime,
160
+ toLocalInput,
161
+ fromLocalInput,
152
162
  stringifyValue,
153
163
  } from './utils/format'
154
164
  export {