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
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,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BasePageHeader from './BasePageHeader.vue'
|
|
4
|
+
|
|
5
|
+
const hasGauge = (wrapper: ReturnType<typeof mount>) =>
|
|
6
|
+
wrapper.find('h1 span[aria-hidden="true"]').exists()
|
|
7
|
+
|
|
8
|
+
describe('BasePageHeader accent', () => {
|
|
9
|
+
it('shows the gauge bar by default on icon-less headers', () => {
|
|
10
|
+
expect(hasGauge(mount(BasePageHeader, { props: { title: 'Dashboard' } }))).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('hides the gauge bar when an icon badge is present', () => {
|
|
14
|
+
const wrapper = mount(BasePageHeader, {
|
|
15
|
+
props: { title: 'Dashboard' },
|
|
16
|
+
slots: { icon: '<svg />' },
|
|
17
|
+
})
|
|
18
|
+
expect(hasGauge(wrapper)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('honours an explicit opt-out and opt-in', () => {
|
|
22
|
+
expect(hasGauge(mount(BasePageHeader, { props: { title: 'A', accent: false } }))).toBe(false)
|
|
23
|
+
const withIcon = mount(BasePageHeader, {
|
|
24
|
+
props: { title: 'A', accent: true },
|
|
25
|
+
slots: { icon: '<svg />' },
|
|
26
|
+
})
|
|
27
|
+
expect(hasGauge(withIcon)).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -1,131 +1,137 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
Page-level header. Standardises the "icon badge + title/subtitle on
|
|
4
|
-
the left, action buttons on the right" pattern for top-level views.
|
|
5
|
-
|
|
6
|
-
Slots:
|
|
7
|
-
• icon — the page-specific Heroicon. Receives an `iconClass`
|
|
8
|
-
slot prop carrying the theme-aware text colour, so callers can
|
|
9
|
-
bind `:class="iconClass"` on their icon component. The badge
|
|
10
|
-
square only renders when this slot is filled, so icon-less apps
|
|
11
|
-
get a plain title/subtitle header.
|
|
12
|
-
• subtitle — rich subtitle content (links, emphasis); overrides
|
|
13
|
-
the `subtitle` prop.
|
|
14
|
-
• actions — refresh / destructive buttons rendered on the right.
|
|
15
|
-
|
|
16
|
-
Props:
|
|
17
|
-
• title — the H1 text.
|
|
18
|
-
• subtitle — optional muted line below the title.
|
|
19
|
-
• iconColor — badge background + icon text colour
|
|
20
|
-
('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
|
|
21
|
-
• maxWidthClass — Tailwind max-w utility constraining the header
|
|
22
|
-
width so sibling views can share a footprint. Defaults to
|
|
23
|
-
'max-w-4xl'; pass '' to skip the width wrapper entirely (the
|
|
24
|
-
header then spans whatever container it sits in).
|
|
25
|
-
• align — vertical alignment of the title block vs the actions:
|
|
26
|
-
'center' (default) or 'end' (actions sit on the title baseline).
|
|
27
|
-
• marginClass — space under the header. Defaults to 'mb-8'; pass
|
|
28
|
-
'' when the parent already manages vertical rhythm (space-y-*).
|
|
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
|
-
|
|
76
|
-
import {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Page-level header. Standardises the "icon badge + title/subtitle on
|
|
4
|
+
the left, action buttons on the right" pattern for top-level views.
|
|
5
|
+
|
|
6
|
+
Slots:
|
|
7
|
+
• icon — the page-specific Heroicon. Receives an `iconClass`
|
|
8
|
+
slot prop carrying the theme-aware text colour, so callers can
|
|
9
|
+
bind `:class="iconClass"` on their icon component. The badge
|
|
10
|
+
square only renders when this slot is filled, so icon-less apps
|
|
11
|
+
get a plain title/subtitle header.
|
|
12
|
+
• subtitle — rich subtitle content (links, emphasis); overrides
|
|
13
|
+
the `subtitle` prop.
|
|
14
|
+
• actions — refresh / destructive buttons rendered on the right.
|
|
15
|
+
|
|
16
|
+
Props:
|
|
17
|
+
• title — the H1 text.
|
|
18
|
+
• subtitle — optional muted line below the title.
|
|
19
|
+
• iconColor — badge background + icon text colour
|
|
20
|
+
('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
|
|
21
|
+
• maxWidthClass — Tailwind max-w utility constraining the header
|
|
22
|
+
width so sibling views can share a footprint. Defaults to
|
|
23
|
+
'max-w-4xl'; pass '' to skip the width wrapper entirely (the
|
|
24
|
+
header then spans whatever container it sits in).
|
|
25
|
+
• align — vertical alignment of the title block vs the actions:
|
|
26
|
+
'center' (default) or 'end' (actions sit on the title baseline).
|
|
27
|
+
• marginClass — space under the header. Defaults to 'mb-8'; pass
|
|
28
|
+
'' when the parent already manages vertical rhythm (space-y-*).
|
|
29
|
+
• accent — brand-green gauge bar in front of the title. Defaults to
|
|
30
|
+
on for icon-less headers; pass :accent="false" to opt out.
|
|
31
|
+
-->
|
|
32
|
+
<header :class="maxWidthClass ? ['mx-auto px-4 sm:px-6', maxWidthClass] : undefined">
|
|
33
|
+
<!--
|
|
34
|
+
flex-wrap + gap so a long title and the action buttons reflow onto
|
|
35
|
+
separate rows on narrow (mobile) viewports instead of the actions
|
|
36
|
+
overflowing off the right edge of the screen. min-w-0 on the title
|
|
37
|
+
group lets a long title truncate rather than shoving the actions out.
|
|
38
|
+
-->
|
|
39
|
+
<div
|
|
40
|
+
class="flex flex-wrap justify-between gap-3"
|
|
41
|
+
:class="[align === 'end' ? 'items-end' : 'items-center', marginClass]"
|
|
42
|
+
>
|
|
43
|
+
<div class="flex items-center gap-3 min-w-0">
|
|
44
|
+
<div
|
|
45
|
+
v-if="$slots.icon"
|
|
46
|
+
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
47
|
+
:class="badgeBgClass"
|
|
48
|
+
>
|
|
49
|
+
<slot name="icon" :icon-class="iconTextClass" />
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<h1
|
|
53
|
+
class="text-2xl font-bold"
|
|
54
|
+
:class="[t.primaryTextSoft, showAccent ? 'relative pl-3.5 tracking-tight' : '']"
|
|
55
|
+
>
|
|
56
|
+
<span
|
|
57
|
+
v-if="showAccent"
|
|
58
|
+
class="absolute left-0 top-[0.14em] h-[1.1em] w-1 rounded-full bg-gradient-to-b from-emerald-400 to-emerald-600"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
/>
|
|
61
|
+
{{ title }}
|
|
62
|
+
</h1>
|
|
63
|
+
<p v-if="subtitle || $slots.subtitle" class="max-w-[72ch] text-sm" :class="t.dimTextAlt">
|
|
64
|
+
<slot name="subtitle">{{ subtitle }}</slot>
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="flex items-center gap-2">
|
|
69
|
+
<slot name="actions" />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</header>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import { computed, useSlots } from 'vue'
|
|
77
|
+
import { useTheme } from '../composables/useTheme'
|
|
78
|
+
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
79
|
+
|
|
80
|
+
type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
|
|
81
|
+
|
|
82
|
+
const props = withDefaults(
|
|
83
|
+
defineProps<{
|
|
84
|
+
title: string
|
|
85
|
+
subtitle?: string
|
|
86
|
+
iconColor?: IconColor
|
|
87
|
+
maxWidthClass?: string
|
|
88
|
+
align?: 'center' | 'end'
|
|
89
|
+
marginClass?: string
|
|
90
|
+
/** Brand-green gauge bar in front of the title. Defaults to on when there is no icon. */
|
|
91
|
+
accent?: boolean
|
|
92
|
+
}>(),
|
|
93
|
+
{
|
|
94
|
+
subtitle: '',
|
|
95
|
+
iconColor: 'emerald',
|
|
96
|
+
maxWidthClass: 'max-w-4xl',
|
|
97
|
+
align: 'center',
|
|
98
|
+
marginClass: 'mb-8',
|
|
99
|
+
// Explicit undefined: an absent Boolean prop would otherwise cast to false.
|
|
100
|
+
accent: undefined,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const { isDark } = useTheme()
|
|
105
|
+
const t = useThemeClasses()
|
|
106
|
+
const slots = useSlots()
|
|
107
|
+
|
|
108
|
+
const showAccent = computed(() => props.accent ?? !slots.icon)
|
|
109
|
+
|
|
110
|
+
// Maps the named colour palette to the badge-background classes for
|
|
111
|
+
// dark/light themes. Callers just pass `iconColor="sky"` and the
|
|
112
|
+
// component resolves both modes.
|
|
113
|
+
const badgeBgClass = computed(() => {
|
|
114
|
+
const palette: Record<IconColor, { dark: string; light: string }> = {
|
|
115
|
+
emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
|
|
116
|
+
sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
|
|
117
|
+
red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
|
|
118
|
+
amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
|
|
119
|
+
}
|
|
120
|
+
const entry = palette[props.iconColor]
|
|
121
|
+
return isDark.value ? entry.dark : entry.light
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Icon text colour, routed through the same name → palette mapping so
|
|
125
|
+
// callers don't need to know the exact Tailwind shade per theme. Exposed
|
|
126
|
+
// to the `icon` slot via the `iconClass` slot prop.
|
|
127
|
+
const iconTextClass = computed(() => {
|
|
128
|
+
const palette: Record<IconColor, { dark: string; light: string }> = {
|
|
129
|
+
emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
|
|
130
|
+
sky: { dark: 'text-sky-400', light: 'text-sky-600' },
|
|
131
|
+
red: { dark: 'text-red-400', light: 'text-red-500' },
|
|
132
|
+
amber: { dark: 'text-amber-400', light: 'text-amber-600' },
|
|
133
|
+
}
|
|
134
|
+
const entry = palette[props.iconColor]
|
|
135
|
+
return isDark.value ? entry.dark : entry.light
|
|
136
|
+
})
|
|
137
|
+
</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
|
+
})
|