mgv-backoffice 1.47.0 → 1.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -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
- <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
51
- class="text-2xl font-bold"
52
- :class="[t.primaryTextSoft, accent ? 'relative pl-3.5 tracking-tight' : '']"
53
- >
54
- <span
55
- v-if="accent"
56
- class="absolute left-0 top-[0.14em] h-[1.1em] w-1 rounded-full bg-gradient-to-b from-emerald-400 to-emerald-600"
57
- aria-hidden="true"
58
- />
59
- {{ title }}
60
- </h1>
61
- <p v-if="subtitle || $slots.subtitle" class="max-w-[72ch] text-sm" :class="t.dimTextAlt">
62
- <slot name="subtitle">{{ subtitle }}</slot>
63
- </p>
64
- </div>
65
- </div>
66
- <div class="flex items-center gap-2">
67
- <slot name="actions" />
68
- </div>
69
- </div>
70
- </header>
71
- </template>
72
-
73
- <script setup lang="ts">
74
- import { computed } from 'vue'
75
- import { useTheme } from '../composables/useTheme'
76
- import { useThemeClasses } from '../composables/useThemeClasses'
77
-
78
- type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
79
-
80
- const props = withDefaults(
81
- defineProps<{
82
- title: string
83
- subtitle?: string
84
- iconColor?: IconColor
85
- maxWidthClass?: string
86
- align?: 'center' | 'end'
87
- marginClass?: string
88
- /** Brand-green gauge bar in front of the title (icon-less headers). */
89
- accent?: boolean
90
- }>(),
91
- {
92
- subtitle: '',
93
- iconColor: 'emerald',
94
- maxWidthClass: 'max-w-4xl',
95
- align: 'center',
96
- marginClass: 'mb-8',
97
- accent: false,
98
- },
99
- )
100
-
101
- const { isDark } = useTheme()
102
- const t = useThemeClasses()
103
-
104
- // Maps the named colour palette to the badge-background classes for
105
- // dark/light themes. Callers just pass `iconColor="sky"` and the
106
- // component resolves both modes.
107
- const badgeBgClass = computed(() => {
108
- const palette: Record<IconColor, { dark: string; light: string }> = {
109
- emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
110
- sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
111
- red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
112
- amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
113
- }
114
- const entry = palette[props.iconColor]
115
- return isDark.value ? entry.dark : entry.light
116
- })
117
-
118
- // Icon text colour, routed through the same name → palette mapping so
119
- // callers don't need to know the exact Tailwind shade per theme. Exposed
120
- // to the `icon` slot via the `iconClass` slot prop.
121
- const iconTextClass = computed(() => {
122
- const palette: Record<IconColor, { dark: string; light: string }> = {
123
- emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
124
- sky: { dark: 'text-sky-400', light: 'text-sky-600' },
125
- red: { dark: 'text-red-400', light: 'text-red-500' },
126
- amber: { dark: 'text-amber-400', light: 'text-amber-600' },
127
- }
128
- const entry = palette[props.iconColor]
129
- return isDark.value ? entry.dark : entry.light
130
- })
131
- </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
+ • 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>
package/src/style.css CHANGED
@@ -31,10 +31,106 @@
31
31
  over plain base utilities like bg-slate-50 on a cascade tie. */
32
32
  @custom-variant dark (&:is(.dark, .dark *));
33
33
 
34
+ /* Design system ("Night Terminal"): remapping the gray/slate/emerald ramps
35
+ restyles every lib component and every consumer utility, since they all
36
+ resolve colours via var() at runtime.
37
+ - gray/slate: one blue-tinted ramp (deep navy dark mode, cool paper light mode)
38
+ - emerald: terminal green, the brand accent
39
+ Deliberately a plain block, NOT `@theme`: `@theme` output lands in
40
+ `@layer theme`, where whichever stylesheet loads last wins, so a consumer
41
+ importing this file before its own `@import "tailwindcss"` would silently
42
+ get the default palette back. An unlayered rule beats every layer in any
43
+ import order. Consumers re-brand with their own plain `:root` block loaded
44
+ after this stylesheet. */
45
+ :root, :host {
46
+ --color-gray-50: #f6f8fc;
47
+ --color-gray-100: #eef1f8;
48
+ --color-gray-200: #dee4ef;
49
+ --color-gray-300: #c4cede;
50
+ --color-gray-400: #94a3bd;
51
+ --color-gray-500: #65748f;
52
+ --color-gray-600: #475572;
53
+ --color-gray-700: #33415c;
54
+ --color-gray-800: #1c2942;
55
+ --color-gray-900: #101a30;
56
+ --color-gray-950: #070d1d;
57
+
58
+ --color-slate-50: #f6f8fc;
59
+ --color-slate-100: #eef1f8;
60
+ --color-slate-200: #dee4ef;
61
+ --color-slate-300: #c4cede;
62
+ --color-slate-400: #94a3bd;
63
+ --color-slate-500: #65748f;
64
+ --color-slate-600: #475572;
65
+ --color-slate-700: #33415c;
66
+ --color-slate-800: #1c2942;
67
+ --color-slate-900: #101a30;
68
+ --color-slate-950: #070d1d;
69
+
70
+ --color-emerald-50: #eafbf4;
71
+ --color-emerald-100: #d0f7e6;
72
+ --color-emerald-200: #a4efd0;
73
+ --color-emerald-300: #6fe3b5;
74
+ --color-emerald-400: #3ad597;
75
+ --color-emerald-500: #16c784;
76
+ --color-emerald-600: #0e9f68;
77
+ --color-emerald-700: #0b7d52;
78
+ --color-emerald-800: #0c6343;
79
+ --color-emerald-900: #0a5138;
80
+ --color-emerald-950: #042e1f;
81
+
82
+ --radius-lg: 0.625rem;
83
+ --radius-xl: 0.875rem;
84
+ }
85
+
34
86
  /* Apply the brand font app-wide. Plain body rule (not preflight-dependent) so
35
- it also styles consumers whose own Tailwind preflight came earlier. */
87
+ it also styles consumers whose own Tailwind preflight came earlier.
88
+ Tabular numerals line up digits in KPI cards and tables. */
36
89
  body {
37
90
  font-family: var(--font-sans);
91
+ font-variant-numeric: tabular-nums;
92
+ text-rendering: optimizeLegibility;
93
+ }
94
+
95
+ /* Global polish. In `base` so component utilities (focus:outline-none,
96
+ cursor-*) still win. */
97
+ @layer base {
98
+ /* Keyboard-focus affordance in the accent colour (mouse clicks stay clean). */
99
+ :focus-visible {
100
+ outline: 2px solid var(--color-emerald-500);
101
+ outline-offset: 2px;
102
+ }
103
+
104
+ ::selection {
105
+ background: color-mix(in srgb, var(--color-emerald-500) 30%, transparent);
106
+ }
107
+
108
+ /* Slim, theme-aware scrollbars for data-dense tables and logs. */
109
+ * {
110
+ scrollbar-width: thin;
111
+ scrollbar-color: var(--color-gray-300) transparent;
112
+ }
113
+
114
+ .dark * {
115
+ scrollbar-color: var(--color-gray-700) transparent;
116
+ }
117
+
118
+ /* Tailwind v4 dropped the pointer cursor on buttons; restore it everywhere. */
119
+ button:not(:disabled),
120
+ [role="button"]:not([aria-disabled="true"]),
121
+ input[type="button"]:not(:disabled),
122
+ input[type="submit"]:not(:disabled),
123
+ input[type="reset"]:not(:disabled) {
124
+ cursor: pointer;
125
+ }
126
+
127
+ button:disabled,
128
+ [role="button"][aria-disabled="true"],
129
+ input[type="button"]:disabled,
130
+ input[type="submit"]:disabled,
131
+ input[type="reset"]:disabled {
132
+ cursor: not-allowed;
133
+ }
38
134
  }
39
135
 
40
136
  /* BaseDateTimePicker skin: re-point @vuepic/vue-datepicker's CSS variables at
@@ -53,46 +149,46 @@ html .dp--theme-dark {
53
149
  }
54
150
  html .dp--theme-light {
55
151
  --dp-background-color: #fff; /* white, like BaseInput */
56
- --dp-text-color: #0f172a; /* slate-900 */
57
- --dp-hover-color: #f1f5f9; /* slate-100 */
58
- --dp-hover-text-color: #0f172a;
59
- --dp-hover-icon-color: #64748b; /* slate-500 */
60
- --dp-icon-color: #94a3b8; /* slate-400 */
61
- --dp-primary-color: #059669; /* emerald-600 */
62
- --dp-primary-disabled-color: #6ee7b7; /* emerald-300 */
63
- --dp-secondary-color: #94a3b8;
64
- --dp-border-color: #cbd5e1; /* slate-300 */
65
- --dp-menu-border-color: #cbd5e1;
66
- --dp-border-color-hover: #94a3b8;
67
- --dp-border-color-focus: #10b981; /* emerald-500 */
68
- --dp-highlight-color: rgb(16 185 129 / 0.12);
69
- --dp-success-color: #10b981;
152
+ --dp-text-color: var(--color-slate-900);
153
+ --dp-hover-color: var(--color-slate-100);
154
+ --dp-hover-text-color: var(--color-slate-900);
155
+ --dp-hover-icon-color: var(--color-slate-500);
156
+ --dp-icon-color: var(--color-slate-400);
157
+ --dp-primary-color: var(--color-emerald-600);
158
+ --dp-primary-disabled-color: var(--color-emerald-300);
159
+ --dp-secondary-color: var(--color-slate-400);
160
+ --dp-border-color: var(--color-slate-300);
161
+ --dp-menu-border-color: var(--color-slate-300);
162
+ --dp-border-color-hover: var(--color-slate-400);
163
+ --dp-border-color-focus: var(--color-emerald-500);
164
+ --dp-highlight-color: color-mix(in srgb, var(--color-emerald-500) 12%, transparent);
165
+ --dp-success-color: var(--color-emerald-500);
70
166
  --dp-danger-color: #ef4444; /* red-500 */
71
167
  --dp-marker-color: #ef4444;
72
168
  }
73
169
  html .dp--theme-dark {
74
- --dp-background-color: #0f172a; /* slate-900 */
75
- --dp-text-color: #f1f5f9; /* slate-100 */
76
- --dp-hover-color: #1e293b; /* slate-800 */
77
- --dp-hover-text-color: #f1f5f9;
78
- --dp-hover-icon-color: #cbd5e1; /* slate-300 */
79
- --dp-icon-color: #64748b; /* slate-500 */
80
- --dp-primary-color: #059669; /* emerald-600 */
81
- --dp-primary-disabled-color: #065f46; /* emerald-800 */
82
- --dp-secondary-color: #64748b;
83
- --dp-border-color: #475569; /* slate-600 */
84
- --dp-menu-border-color: #334155; /* slate-700 */
85
- --dp-border-color-hover: #64748b;
86
- --dp-border-color-focus: #10b981; /* emerald-500 */
87
- --dp-highlight-color: rgb(16 185 129 / 0.2);
88
- --dp-disabled-color: #1e293b;
89
- --dp-disabled-color-text: #64748b;
90
- --dp-scroll-bar-background: #0f172a;
91
- --dp-scroll-bar-color: #334155;
92
- --dp-success-color: #10b981;
170
+ --dp-background-color: var(--color-slate-900);
171
+ --dp-text-color: var(--color-slate-100);
172
+ --dp-hover-color: var(--color-slate-800);
173
+ --dp-hover-text-color: var(--color-slate-100);
174
+ --dp-hover-icon-color: var(--color-slate-300);
175
+ --dp-icon-color: var(--color-slate-500);
176
+ --dp-primary-color: var(--color-emerald-600);
177
+ --dp-primary-disabled-color: var(--color-emerald-800);
178
+ --dp-secondary-color: var(--color-slate-500);
179
+ --dp-border-color: var(--color-slate-600);
180
+ --dp-menu-border-color: var(--color-slate-700);
181
+ --dp-border-color-hover: var(--color-slate-500);
182
+ --dp-border-color-focus: var(--color-emerald-500);
183
+ --dp-highlight-color: color-mix(in srgb, var(--color-emerald-500) 20%, transparent);
184
+ --dp-disabled-color: var(--color-slate-800);
185
+ --dp-disabled-color-text: var(--color-slate-500);
186
+ --dp-scroll-bar-background: var(--color-slate-900);
187
+ --dp-scroll-bar-color: var(--color-slate-700);
188
+ --dp-success-color: var(--color-emerald-500);
93
189
  --dp-danger-color: #ef4444;
94
190
  --dp-marker-color: #ef4444;
95
- --dp-tooltip-color: #1e293b;
191
+ --dp-tooltip-color: var(--color-slate-800);
96
192
  }
97
193
 
98
194
  /* BaseSelect: rounded, themed dropdown panel. Opt-in customizable <select>
@@ -161,7 +257,7 @@ html .dp--theme-dark {
161
257
  }
162
258
 
163
259
  .dark .base-select option:checked {
164
- background-color: rgb(16 185 129 / 0.15);
260
+ background-color: color-mix(in srgb, var(--color-emerald-500) 15%, transparent);
165
261
  color: var(--color-emerald-300);
166
262
  }
167
263
  }