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.
- package/README.md +2099 -2072
- package/dist/components/BaseBooleanIcon.vue.d.ts +14 -0
- package/dist/components/BaseDetailList.vue.d.ts +13 -1
- package/dist/components/BasePageHeader.vue.d.ts +1 -1
- package/dist/components/BasePhotoViewer.vue.d.ts +24 -0
- package/dist/components/BaseRating.vue.d.ts +9 -0
- package/dist/components/BaseRowButton.vue.d.ts +18 -0
- package/dist/components/BaseSelect.vue.d.ts +15 -3
- package/dist/components/BaseSidebarUser.vue.d.ts +3 -0
- package/dist/components/BaseStatCard.vue.d.ts +6 -4
- package/dist/components/BaseTextarea.vue.d.ts +12 -0
- package/dist/composables/useRefreshOnReturn.d.ts +7 -0
- package/dist/index.d.ts +8 -1
- package/dist/types/photoViewer.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1050 -851
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +9 -0
- package/package.json +1 -1
- package/src/components/BaseBadge.vue +7 -7
- package/src/components/BaseBooleanIcon.vue +22 -0
- package/src/components/BaseDetailList.vue +50 -47
- package/src/components/BaseField.label.test.ts +30 -0
- package/src/components/BaseField.vue +19 -3
- package/src/components/BasePageHeader.test.ts +29 -0
- package/src/components/BasePageHeader.vue +137 -131
- package/src/components/BasePhotoViewer.test.ts +22 -0
- package/src/components/BasePhotoViewer.vue +93 -0
- package/src/components/BaseRating.test.ts +19 -0
- package/src/components/BaseRating.vue +21 -0
- package/src/components/BaseRowButton.vue +24 -0
- package/src/components/BaseSelect.vue +4 -2
- package/src/components/BaseSidebarUser.vue +85 -75
- package/src/components/BaseStatCard.vue +11 -3
- package/src/components/BaseTextarea.test.ts +17 -0
- package/src/components/BaseTextarea.vue +24 -0
- package/src/composables/useRefreshOnReturn.test.ts +47 -0
- package/src/composables/useRefreshOnReturn.ts +24 -0
- package/src/index.ts +10 -0
- package/src/style.css +132 -36
- package/src/types/photoViewer.ts +6 -0
- package/src/utils/format.test.ts +342 -322
- 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
|
|
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
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class="w-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 `
|
|
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 {
|