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.
- package/README.md +17 -0
- package/dist/components/BaseBooleanIcon.vue.d.ts +14 -0
- package/dist/components/BaseDetailList.vue.d.ts +13 -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 +1018 -819
- 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/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/types/photoViewer.ts +6 -0
- package/src/utils/format.test.ts +342 -322
- package/src/utils/format.ts +23 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -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
|
@@ -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 =
|
|
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
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
</
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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>
|