mgv-backoffice 1.36.0 → 1.36.2

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 (60) hide show
  1. package/README.md +1794 -1719
  2. package/dist/components/BaseBadge.vue.d.ts +1 -11
  3. package/dist/components/BaseDropdown.vue.d.ts +1 -1
  4. package/dist/ui-lib.js +942 -925
  5. package/dist/ui-lib.umd.cjs +1 -1
  6. package/dist/utils/specForm.d.ts +1 -1
  7. package/package.json +54 -54
  8. package/src/components/BaseAlert.vue +104 -104
  9. package/src/components/BaseBadge.vue +12 -2
  10. package/src/components/BaseBarDistribution.test.ts +51 -51
  11. package/src/components/BaseBarDistribution.vue +73 -73
  12. package/src/components/BaseChipButton.vue +42 -42
  13. package/src/components/BaseCodeBlock.test.ts +70 -70
  14. package/src/components/BaseCodeBlock.vue +74 -74
  15. package/src/components/BaseCollapsibleSection.vue +113 -113
  16. package/src/components/BaseConfirmModal.vue +114 -114
  17. package/src/components/BaseCredentialsForm.test.ts +108 -108
  18. package/src/components/BaseCredentialsForm.vue +241 -241
  19. package/src/components/BaseDropdown.test.ts +87 -87
  20. package/src/components/BaseDropdown.vue +172 -172
  21. package/src/components/BaseEntityPickerModal.vue +330 -330
  22. package/src/components/BaseFileDropzone.vue +94 -94
  23. package/src/components/BaseFilterChip.test.ts +44 -44
  24. package/src/components/BaseFilterChip.vue +67 -67
  25. package/src/components/BaseModalShell.vue +209 -209
  26. package/src/components/BasePageHeader.vue +120 -120
  27. package/src/components/BasePillPickerModal.test.ts +95 -95
  28. package/src/components/BaseRemoveButton.vue +22 -22
  29. package/src/components/BaseSpecFields.test.ts +39 -0
  30. package/src/components/BaseSpecFields.vue +27 -3
  31. package/src/components/BaseStatBreakdown.test.ts +56 -56
  32. package/src/components/BaseStatBreakdown.vue +49 -49
  33. package/src/components/BaseStatusPill.vue +54 -54
  34. package/src/components/BaseTextInputModal.vue +140 -140
  35. package/src/components/BaseToolbarButton.test.ts +48 -48
  36. package/src/components/BaseToolbarButton.vue +94 -94
  37. package/src/composables/useFieldClasses.test.ts +55 -55
  38. package/src/composables/useFieldClasses.ts +56 -56
  39. package/src/composables/usePolling.test.ts +112 -112
  40. package/src/composables/usePolling.ts +102 -102
  41. package/src/composables/useQueryParamSync.test.ts +56 -56
  42. package/src/composables/useQueryParamSync.ts +42 -42
  43. package/src/composables/useThemeClasses.test.ts +34 -34
  44. package/src/composables/useThemeClasses.ts +137 -137
  45. package/src/index.ts +138 -138
  46. package/src/style.css +32 -32
  47. package/src/types/distribution.ts +7 -7
  48. package/src/types/pillPicker.ts +14 -14
  49. package/src/types/specField.ts +30 -30
  50. package/src/types/statBreakdown.ts +6 -6
  51. package/src/utils/format.test.ts +264 -264
  52. package/src/utils/format.ts +216 -216
  53. package/src/utils/httpColors.test.ts +100 -100
  54. package/src/utils/httpColors.ts +99 -99
  55. package/src/utils/kvRows.test.ts +47 -47
  56. package/src/utils/kvRows.ts +34 -34
  57. package/src/utils/specForm.test.ts +64 -53
  58. package/src/utils/specForm.ts +49 -48
  59. package/src/utils/validate.test.ts +82 -82
  60. package/src/utils/validate.ts +76 -76
@@ -1,209 +1,209 @@
1
- <template>
2
- <!--
3
- Shared modal shell — knows how a dialog is structured (teleport,
4
- backdrop, dark-mode card, escape key, aria-modal). Concrete modals
5
- should compose this rather than re-implementing the chrome.
6
-
7
- Every modal shows an icon left of the title: the REQUIRED `icon`
8
- prop (a component, e.g. a heroicon) renders inside a tinted
9
- circular chip. Tint via `icon-bg-class` / `icon-class`; the `icon`
10
- slot remains as a full-markup override for callers that need
11
- something richer than the standard chip.
12
-
13
- Slots:
14
- • icon — override the standard icon chip next to the title
15
- • default — body content
16
- • footer — action buttons (cancel / confirm / etc.)
17
-
18
- Backdrop click:
19
- • Emits `backdrop`. By default this also fires `cancel` (the
20
- common case). Parents that want to suppress this — e.g. a
21
- modal with unsaved input — should pass `manual-close` and
22
- listen on `backdrop` to decide what to do.
23
- -->
24
- <Teleport to="body">
25
- <div
26
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
27
- role="dialog"
28
- aria-modal="true"
29
- :aria-labelledby="titleId"
30
- @click="onBackdrop"
31
- >
32
- <!--
33
- Scrollable layout — for large content modals. A flex column
34
- capped at 90vh: header and footer stay fixed (`shrink-0`) while
35
- the body scrolls (`overflow-y-auto flex-1`). Supports a subtitle
36
- and a `header-actions` slot (e.g. a close button) on the right.
37
- -->
38
- <div
39
- v-if="scrollable"
40
- class="relative w-full mx-4 rounded-lg shadow-xl max-h-[90vh] flex flex-col"
41
- :class="[maxWidthClass, shellClass]"
42
- @click.stop
43
- >
44
- <div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
45
- <div class="flex items-start gap-4 min-w-0">
46
- <slot name="icon">
47
- <div
48
- class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
49
- :class="iconChipBg"
50
- >
51
- <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
52
- </div>
53
- </slot>
54
- <div class="flex-1 min-w-0">
55
- <h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
56
- {{ title }}
57
- </h3>
58
- <p v-if="subtitle" class="text-sm mt-1 break-all" :class="t.mutedText">
59
- {{ subtitle }}
60
- </p>
61
- </div>
62
- </div>
63
- <slot name="header-actions" />
64
- </div>
65
-
66
- <div class="p-6 overflow-y-auto flex-1">
67
- <slot />
68
- </div>
69
-
70
- <div v-if="$slots.footer" class="flex gap-3 justify-end p-6 border-t shrink-0" :class="t.border">
71
- <slot name="footer" />
72
- </div>
73
- </div>
74
-
75
- <!-- Compact layout (default) — small centered dialog. -->
76
- <div
77
- v-else
78
- class="relative w-full mx-4 rounded-lg shadow-xl"
79
- :class="[maxWidthClass, shellClass]"
80
- @click.stop
81
- >
82
- <div class="p-6">
83
- <!-- Title row -->
84
- <div class="flex items-start gap-4">
85
- <slot name="icon">
86
- <div
87
- class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
88
- :class="iconChipBg"
89
- >
90
- <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
91
- </div>
92
- </slot>
93
- <div class="flex-1">
94
- <h3
95
- :id="titleId"
96
- class="text-lg font-medium"
97
- :class="t.primaryText"
98
- >
99
- {{ title }}
100
- </h3>
101
- </div>
102
- </div>
103
-
104
- <!-- Body -->
105
- <div class="mt-4">
106
- <slot />
107
- </div>
108
-
109
- <!-- Footer -->
110
- <div class="mt-6 flex gap-3 justify-end">
111
- <slot name="footer" />
112
- </div>
113
- </div>
114
- </div>
115
- </div>
116
- </Teleport>
117
- </template>
118
-
119
- <script setup lang="ts">
120
- import { computed } from 'vue'
121
- import type { Component } from 'vue'
122
- import { useTheme } from '../composables/useTheme'
123
- import { useThemeClasses } from '../composables/useThemeClasses'
124
- import { useEscapeKey } from '../composables/useEscapeKey'
125
-
126
- interface Props {
127
- title: string
128
- /**
129
- * Icon component (e.g. a heroicon) rendered in a tinted circular
130
- * chip left of the title. REQUIRED — every modal must name itself
131
- * with an icon. The `icon` slot can override the whole chip.
132
- */
133
- icon: Component
134
- /**
135
- * Background classes of the icon chip. Defaults to the emerald
136
- * tint (dark-mode aware).
137
- */
138
- iconBgClass?: string
139
- /** Classes applied to the icon itself. Defaults to emerald. */
140
- iconClass?: string
141
- /**
142
- * Max-width of the dialog card. Defaults to `max-w-md`. Pass any
143
- * Tailwind max-w utility, or empty string to opt out.
144
- */
145
- maxWidthClass?: string
146
- /**
147
- * When true, the backdrop click and Escape key are NOT auto-bound
148
- * to `cancel`. Use when the parent wants to gate dismissal on
149
- * something (e.g. unsaved input). The parent should listen on
150
- * `backdrop` / handle their own Escape.
151
- */
152
- manualClose?: boolean
153
- /**
154
- * Switches to the scrollable layout — a flex column capped at 90vh
155
- * with a fixed header/footer and a scrolling body. Use for large
156
- * content modals (request detail, import preview, …) rather than the
157
- * compact confirm-style default.
158
- */
159
- scrollable?: boolean
160
- /**
161
- * Optional muted line under the title. Only rendered in the
162
- * scrollable layout.
163
- */
164
- subtitle?: string
165
- }
166
-
167
- const props = withDefaults(defineProps<Props>(), {
168
- maxWidthClass: 'max-w-md',
169
- manualClose: false,
170
- scrollable: false,
171
- subtitle: '',
172
- iconBgClass: '',
173
- iconClass: 'text-emerald-600',
174
- })
175
-
176
- const emit = defineEmits<{
177
- cancel: []
178
- backdrop: []
179
- }>()
180
-
181
- const { isDark } = useTheme()
182
-
183
- const t = useThemeClasses()
184
- // Stable id derived per-instance so aria-labelledby points at this
185
- // dialog's own heading. Suffix with a random short string to avoid
186
- // collisions when two modals are momentarily stacked.
187
- const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`
188
-
189
- const shellClass = computed(() =>
190
- isDark.value
191
- ? 'bg-gray-900 border border-gray-700'
192
- : 'bg-white border border-gray-200',
193
- )
194
-
195
- // The icon chip's background: an explicit `icon-bg-class` wins, else the
196
- // standard emerald tint in the current theme.
197
- const iconChipBg = computed(() =>
198
- props.iconBgClass || (isDark.value ? 'bg-emerald-900/30' : 'bg-emerald-50'),
199
- )
200
-
201
- function onBackdrop() {
202
- emit('backdrop')
203
- if (!props.manualClose) emit('cancel')
204
- }
205
-
206
- useEscapeKey(() => {
207
- if (!props.manualClose) emit('cancel')
208
- })
209
- </script>
1
+ <template>
2
+ <!--
3
+ Shared modal shell — knows how a dialog is structured (teleport,
4
+ backdrop, dark-mode card, escape key, aria-modal). Concrete modals
5
+ should compose this rather than re-implementing the chrome.
6
+
7
+ Every modal shows an icon left of the title: the REQUIRED `icon`
8
+ prop (a component, e.g. a heroicon) renders inside a tinted
9
+ circular chip. Tint via `icon-bg-class` / `icon-class`; the `icon`
10
+ slot remains as a full-markup override for callers that need
11
+ something richer than the standard chip.
12
+
13
+ Slots:
14
+ • icon — override the standard icon chip next to the title
15
+ • default — body content
16
+ • footer — action buttons (cancel / confirm / etc.)
17
+
18
+ Backdrop click:
19
+ • Emits `backdrop`. By default this also fires `cancel` (the
20
+ common case). Parents that want to suppress this — e.g. a
21
+ modal with unsaved input — should pass `manual-close` and
22
+ listen on `backdrop` to decide what to do.
23
+ -->
24
+ <Teleport to="body">
25
+ <div
26
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
27
+ role="dialog"
28
+ aria-modal="true"
29
+ :aria-labelledby="titleId"
30
+ @click="onBackdrop"
31
+ >
32
+ <!--
33
+ Scrollable layout — for large content modals. A flex column
34
+ capped at 90vh: header and footer stay fixed (`shrink-0`) while
35
+ the body scrolls (`overflow-y-auto flex-1`). Supports a subtitle
36
+ and a `header-actions` slot (e.g. a close button) on the right.
37
+ -->
38
+ <div
39
+ v-if="scrollable"
40
+ class="relative w-full mx-4 rounded-lg shadow-xl max-h-[90vh] flex flex-col"
41
+ :class="[maxWidthClass, shellClass]"
42
+ @click.stop
43
+ >
44
+ <div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
45
+ <div class="flex items-start gap-4 min-w-0">
46
+ <slot name="icon">
47
+ <div
48
+ class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
49
+ :class="iconChipBg"
50
+ >
51
+ <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
52
+ </div>
53
+ </slot>
54
+ <div class="flex-1 min-w-0">
55
+ <h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
56
+ {{ title }}
57
+ </h3>
58
+ <p v-if="subtitle" class="text-sm mt-1 break-all" :class="t.mutedText">
59
+ {{ subtitle }}
60
+ </p>
61
+ </div>
62
+ </div>
63
+ <slot name="header-actions" />
64
+ </div>
65
+
66
+ <div class="p-6 overflow-y-auto flex-1">
67
+ <slot />
68
+ </div>
69
+
70
+ <div v-if="$slots.footer" class="flex gap-3 justify-end p-6 border-t shrink-0" :class="t.border">
71
+ <slot name="footer" />
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Compact layout (default) — small centered dialog. -->
76
+ <div
77
+ v-else
78
+ class="relative w-full mx-4 rounded-lg shadow-xl"
79
+ :class="[maxWidthClass, shellClass]"
80
+ @click.stop
81
+ >
82
+ <div class="p-6">
83
+ <!-- Title row -->
84
+ <div class="flex items-start gap-4">
85
+ <slot name="icon">
86
+ <div
87
+ class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
88
+ :class="iconChipBg"
89
+ >
90
+ <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
91
+ </div>
92
+ </slot>
93
+ <div class="flex-1">
94
+ <h3
95
+ :id="titleId"
96
+ class="text-lg font-medium"
97
+ :class="t.primaryText"
98
+ >
99
+ {{ title }}
100
+ </h3>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- Body -->
105
+ <div class="mt-4">
106
+ <slot />
107
+ </div>
108
+
109
+ <!-- Footer -->
110
+ <div class="mt-6 flex gap-3 justify-end">
111
+ <slot name="footer" />
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </Teleport>
117
+ </template>
118
+
119
+ <script setup lang="ts">
120
+ import { computed } from 'vue'
121
+ import type { Component } from 'vue'
122
+ import { useTheme } from '../composables/useTheme'
123
+ import { useThemeClasses } from '../composables/useThemeClasses'
124
+ import { useEscapeKey } from '../composables/useEscapeKey'
125
+
126
+ interface Props {
127
+ title: string
128
+ /**
129
+ * Icon component (e.g. a heroicon) rendered in a tinted circular
130
+ * chip left of the title. REQUIRED — every modal must name itself
131
+ * with an icon. The `icon` slot can override the whole chip.
132
+ */
133
+ icon: Component
134
+ /**
135
+ * Background classes of the icon chip. Defaults to the emerald
136
+ * tint (dark-mode aware).
137
+ */
138
+ iconBgClass?: string
139
+ /** Classes applied to the icon itself. Defaults to emerald. */
140
+ iconClass?: string
141
+ /**
142
+ * Max-width of the dialog card. Defaults to `max-w-md`. Pass any
143
+ * Tailwind max-w utility, or empty string to opt out.
144
+ */
145
+ maxWidthClass?: string
146
+ /**
147
+ * When true, the backdrop click and Escape key are NOT auto-bound
148
+ * to `cancel`. Use when the parent wants to gate dismissal on
149
+ * something (e.g. unsaved input). The parent should listen on
150
+ * `backdrop` / handle their own Escape.
151
+ */
152
+ manualClose?: boolean
153
+ /**
154
+ * Switches to the scrollable layout — a flex column capped at 90vh
155
+ * with a fixed header/footer and a scrolling body. Use for large
156
+ * content modals (request detail, import preview, …) rather than the
157
+ * compact confirm-style default.
158
+ */
159
+ scrollable?: boolean
160
+ /**
161
+ * Optional muted line under the title. Only rendered in the
162
+ * scrollable layout.
163
+ */
164
+ subtitle?: string
165
+ }
166
+
167
+ const props = withDefaults(defineProps<Props>(), {
168
+ maxWidthClass: 'max-w-md',
169
+ manualClose: false,
170
+ scrollable: false,
171
+ subtitle: '',
172
+ iconBgClass: '',
173
+ iconClass: 'text-emerald-600',
174
+ })
175
+
176
+ const emit = defineEmits<{
177
+ cancel: []
178
+ backdrop: []
179
+ }>()
180
+
181
+ const { isDark } = useTheme()
182
+
183
+ const t = useThemeClasses()
184
+ // Stable id derived per-instance so aria-labelledby points at this
185
+ // dialog's own heading. Suffix with a random short string to avoid
186
+ // collisions when two modals are momentarily stacked.
187
+ const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`
188
+
189
+ const shellClass = computed(() =>
190
+ isDark.value
191
+ ? 'bg-gray-900 border border-gray-700'
192
+ : 'bg-white border border-gray-200',
193
+ )
194
+
195
+ // The icon chip's background: an explicit `icon-bg-class` wins, else the
196
+ // standard emerald tint in the current theme.
197
+ const iconChipBg = computed(() =>
198
+ props.iconBgClass || (isDark.value ? 'bg-emerald-900/30' : 'bg-emerald-50'),
199
+ )
200
+
201
+ function onBackdrop() {
202
+ emit('backdrop')
203
+ if (!props.manualClose) emit('cancel')
204
+ }
205
+
206
+ useEscapeKey(() => {
207
+ if (!props.manualClose) emit('cancel')
208
+ })
209
+ </script>
@@ -1,120 +1,120 @@
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
- <header :class="maxWidthClass ? ['mx-auto px-4 sm:px-6', maxWidthClass] : undefined">
31
- <!--
32
- flex-wrap + gap so a long title and the action buttons reflow onto
33
- separate rows on narrow (mobile) viewports instead of the actions
34
- overflowing off the right edge of the screen. min-w-0 on the title
35
- group lets a long title truncate rather than shoving the actions out.
36
- -->
37
- <div
38
- class="flex flex-wrap justify-between gap-3"
39
- :class="[align === 'end' ? 'items-end' : 'items-center', marginClass]"
40
- >
41
- <div class="flex items-center gap-3 min-w-0">
42
- <div
43
- v-if="$slots.icon"
44
- class="w-10 h-10 rounded-lg flex items-center justify-center"
45
- :class="badgeBgClass"
46
- >
47
- <slot name="icon" :icon-class="iconTextClass" />
48
- </div>
49
- <div>
50
- <h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
51
- {{ title }}
52
- </h1>
53
- <p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
54
- <slot name="subtitle">{{ subtitle }}</slot>
55
- </p>
56
- </div>
57
- </div>
58
- <div class="flex items-center gap-2">
59
- <slot name="actions" />
60
- </div>
61
- </div>
62
- </header>
63
- </template>
64
-
65
- <script setup lang="ts">
66
- import { computed } from 'vue'
67
- import { useTheme } from '../composables/useTheme'
68
- import { useThemeClasses } from '../composables/useThemeClasses'
69
-
70
- type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
71
-
72
- const props = withDefaults(
73
- defineProps<{
74
- title: string
75
- subtitle?: string
76
- iconColor?: IconColor
77
- maxWidthClass?: string
78
- align?: 'center' | 'end'
79
- marginClass?: string
80
- }>(),
81
- {
82
- subtitle: '',
83
- iconColor: 'emerald',
84
- maxWidthClass: 'max-w-4xl',
85
- align: 'center',
86
- marginClass: 'mb-8',
87
- },
88
- )
89
-
90
- const { isDark } = useTheme()
91
- const t = useThemeClasses()
92
-
93
- // Maps the named colour palette to the badge-background classes for
94
- // dark/light themes. Callers just pass `iconColor="sky"` and the
95
- // component resolves both modes.
96
- const badgeBgClass = computed(() => {
97
- const palette: Record<IconColor, { dark: string; light: string }> = {
98
- emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
99
- sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
100
- red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
101
- amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
102
- }
103
- const entry = palette[props.iconColor]
104
- return isDark.value ? entry.dark : entry.light
105
- })
106
-
107
- // Icon text colour, routed through the same name → palette mapping so
108
- // callers don't need to know the exact Tailwind shade per theme. Exposed
109
- // to the `icon` slot via the `iconClass` slot prop.
110
- const iconTextClass = computed(() => {
111
- const palette: Record<IconColor, { dark: string; light: string }> = {
112
- emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
113
- sky: { dark: 'text-sky-400', light: 'text-sky-600' },
114
- red: { dark: 'text-red-400', light: 'text-red-500' },
115
- amber: { dark: 'text-amber-400', light: 'text-amber-600' },
116
- }
117
- const entry = palette[props.iconColor]
118
- return isDark.value ? entry.dark : entry.light
119
- })
120
- </script>
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
+ <header :class="maxWidthClass ? ['mx-auto px-4 sm:px-6', maxWidthClass] : undefined">
31
+ <!--
32
+ flex-wrap + gap so a long title and the action buttons reflow onto
33
+ separate rows on narrow (mobile) viewports instead of the actions
34
+ overflowing off the right edge of the screen. min-w-0 on the title
35
+ group lets a long title truncate rather than shoving the actions out.
36
+ -->
37
+ <div
38
+ class="flex flex-wrap justify-between gap-3"
39
+ :class="[align === 'end' ? 'items-end' : 'items-center', marginClass]"
40
+ >
41
+ <div class="flex items-center gap-3 min-w-0">
42
+ <div
43
+ v-if="$slots.icon"
44
+ class="w-10 h-10 rounded-lg flex items-center justify-center"
45
+ :class="badgeBgClass"
46
+ >
47
+ <slot name="icon" :icon-class="iconTextClass" />
48
+ </div>
49
+ <div>
50
+ <h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
51
+ {{ title }}
52
+ </h1>
53
+ <p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
54
+ <slot name="subtitle">{{ subtitle }}</slot>
55
+ </p>
56
+ </div>
57
+ </div>
58
+ <div class="flex items-center gap-2">
59
+ <slot name="actions" />
60
+ </div>
61
+ </div>
62
+ </header>
63
+ </template>
64
+
65
+ <script setup lang="ts">
66
+ import { computed } from 'vue'
67
+ import { useTheme } from '../composables/useTheme'
68
+ import { useThemeClasses } from '../composables/useThemeClasses'
69
+
70
+ type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
71
+
72
+ const props = withDefaults(
73
+ defineProps<{
74
+ title: string
75
+ subtitle?: string
76
+ iconColor?: IconColor
77
+ maxWidthClass?: string
78
+ align?: 'center' | 'end'
79
+ marginClass?: string
80
+ }>(),
81
+ {
82
+ subtitle: '',
83
+ iconColor: 'emerald',
84
+ maxWidthClass: 'max-w-4xl',
85
+ align: 'center',
86
+ marginClass: 'mb-8',
87
+ },
88
+ )
89
+
90
+ const { isDark } = useTheme()
91
+ const t = useThemeClasses()
92
+
93
+ // Maps the named colour palette to the badge-background classes for
94
+ // dark/light themes. Callers just pass `iconColor="sky"` and the
95
+ // component resolves both modes.
96
+ const badgeBgClass = computed(() => {
97
+ const palette: Record<IconColor, { dark: string; light: string }> = {
98
+ emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
99
+ sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
100
+ red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
101
+ amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
102
+ }
103
+ const entry = palette[props.iconColor]
104
+ return isDark.value ? entry.dark : entry.light
105
+ })
106
+
107
+ // Icon text colour, routed through the same name → palette mapping so
108
+ // callers don't need to know the exact Tailwind shade per theme. Exposed
109
+ // to the `icon` slot via the `iconClass` slot prop.
110
+ const iconTextClass = computed(() => {
111
+ const palette: Record<IconColor, { dark: string; light: string }> = {
112
+ emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
113
+ sky: { dark: 'text-sky-400', light: 'text-sky-600' },
114
+ red: { dark: 'text-red-400', light: 'text-red-500' },
115
+ amber: { dark: 'text-amber-400', light: 'text-amber-600' },
116
+ }
117
+ const entry = palette[props.iconColor]
118
+ return isDark.value ? entry.dark : entry.light
119
+ })
120
+ </script>