mgv-backoffice 1.36.3 → 1.38.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 +120 -3
- package/dist/components/BaseCard.vue.d.ts +33 -0
- package/dist/components/BaseDateTimePicker.vue.d.ts +49 -0
- package/dist/components/BaseField.vue.d.ts +38 -0
- package/dist/components/BaseSearchSelect.vue.d.ts +41 -0
- package/dist/components/BaseStatCard.vue.d.ts +33 -0
- package/dist/components/BaseTable.vue.d.ts +3 -0
- package/dist/index.d.ts +6 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1308 -911
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +4 -1
- package/src/components/BaseBreadcrumb.vue +68 -68
- package/src/components/BaseCard.test.ts +55 -0
- package/src/components/BaseCard.vue +48 -0
- package/src/components/BaseDateTimePicker.test.ts +49 -0
- package/src/components/BaseDateTimePicker.vue +91 -0
- package/src/components/BaseField.vue +47 -0
- package/src/components/BaseInput.test.ts +1 -1
- package/src/components/BaseInput.vue +46 -46
- package/src/components/BaseLogo.test.ts +26 -0
- package/src/components/BaseLogo.vue +7 -1
- package/src/components/BaseSearchSelect.test.ts +117 -0
- package/src/components/BaseSearchSelect.vue +318 -0
- package/src/components/BaseSelect.test.ts +3 -3
- package/src/components/BaseSelect.vue +61 -46
- package/src/components/BaseStatCard.test.ts +40 -0
- package/src/components/BaseStatCard.vue +103 -0
- package/src/components/BaseTable.test.ts +69 -55
- package/src/components/BaseTable.vue +62 -50
- package/src/index.ts +8 -0
- package/src/style.css +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mgv-backoffice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Shared Vue 3 UI component library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/ui-lib.umd.cjs",
|
|
@@ -51,5 +51,8 @@
|
|
|
51
51
|
"vitest": "^5.0.2",
|
|
52
52
|
"vue": "^3.5.43",
|
|
53
53
|
"vue-router": "^5.3.1"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@vuepic/vue-datepicker": "^14.0.0"
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<nav class="flex px-5 py-3 text-gray-700 border border-
|
|
3
|
-
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
|
4
|
-
|
|
5
|
-
<li class="inline-flex items-center" style="margin-left: 0;">
|
|
6
|
-
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
7
|
-
<HomeIcon class="h-4 w-4 text-gray-500 mr-2" />
|
|
8
|
-
Home
|
|
9
|
-
</router-link>
|
|
10
|
-
</li>
|
|
11
|
-
|
|
12
|
-
<li v-for="(crumb, index) in crumbs" :key="index" class="inline-flex items-center" style="margin-left: 0;">
|
|
13
|
-
<ChevronRightIcon class="h-4 w-4 text-gray-500" />
|
|
14
|
-
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
15
|
-
{{ crumb.name }}
|
|
16
|
-
</router-link>
|
|
17
|
-
</li>
|
|
18
|
-
|
|
19
|
-
</ol>
|
|
20
|
-
</nav>
|
|
21
|
-
</template>
|
|
22
|
-
|
|
23
|
-
<script lang="ts" setup>
|
|
24
|
-
import { computed } from 'vue'
|
|
25
|
-
import { HomeIcon } from '@heroicons/vue/24/solid'
|
|
26
|
-
import { ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
27
|
-
|
|
28
|
-
export interface BreadCrumb {
|
|
29
|
-
name: string
|
|
30
|
-
url: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const props = defineProps({
|
|
34
|
-
items: {
|
|
35
|
-
type: Array as () => BreadCrumb[],
|
|
36
|
-
required: false,
|
|
37
|
-
default: undefined
|
|
38
|
-
},
|
|
39
|
-
path: {
|
|
40
|
-
type: String,
|
|
41
|
-
required: false,
|
|
42
|
-
default: undefined
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
const crumbs = computed<BreadCrumb[]>(() => {
|
|
47
|
-
// If explicit items are provided, use them directly
|
|
48
|
-
if (props.items && props.items.length > 0) {
|
|
49
|
-
return props.items
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Otherwise, auto-generate from the path prop (or current URL)
|
|
53
|
-
const pathname = props.path ?? (typeof window !== 'undefined' ? window.location.pathname : '/')
|
|
54
|
-
const segments = pathname.split('/').filter(s => s !== '')
|
|
55
|
-
|
|
56
|
-
let accumulated = ''
|
|
57
|
-
return segments.map(segment => {
|
|
58
|
-
accumulated += '/' + segment
|
|
59
|
-
|
|
60
|
-
const isNumeric = !Number.isNaN(Number(segment))
|
|
61
|
-
const name = !isNumeric
|
|
62
|
-
? (segment.charAt(0).toUpperCase() + segment.slice(1)).replaceAll('-', ' ')
|
|
63
|
-
: segment
|
|
64
|
-
|
|
65
|
-
return { name, url: accumulated }
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="flex overflow-x-auto px-5 py-3 text-gray-700 border border-slate-200 rounded-xl bg-white shadow-sm dark:bg-slate-900 dark:border-slate-700" aria-label="Breadcrumb">
|
|
3
|
+
<ol class="inline-flex items-center whitespace-nowrap space-x-1 md:space-x-3">
|
|
4
|
+
|
|
5
|
+
<li class="inline-flex items-center" style="margin-left: 0;">
|
|
6
|
+
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
7
|
+
<HomeIcon class="h-4 w-4 text-gray-500 mr-2" />
|
|
8
|
+
Home
|
|
9
|
+
</router-link>
|
|
10
|
+
</li>
|
|
11
|
+
|
|
12
|
+
<li v-for="(crumb, index) in crumbs" :key="index" class="inline-flex items-center" style="margin-left: 0;">
|
|
13
|
+
<ChevronRightIcon class="h-4 w-4 text-gray-500" />
|
|
14
|
+
<router-link :to="crumb.url" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white transition-colors rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">
|
|
15
|
+
{{ crumb.name }}
|
|
16
|
+
</router-link>
|
|
17
|
+
</li>
|
|
18
|
+
|
|
19
|
+
</ol>
|
|
20
|
+
</nav>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script lang="ts" setup>
|
|
24
|
+
import { computed } from 'vue'
|
|
25
|
+
import { HomeIcon } from '@heroicons/vue/24/solid'
|
|
26
|
+
import { ChevronRightIcon } from '@heroicons/vue/24/outline'
|
|
27
|
+
|
|
28
|
+
export interface BreadCrumb {
|
|
29
|
+
name: string
|
|
30
|
+
url: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = defineProps({
|
|
34
|
+
items: {
|
|
35
|
+
type: Array as () => BreadCrumb[],
|
|
36
|
+
required: false,
|
|
37
|
+
default: undefined
|
|
38
|
+
},
|
|
39
|
+
path: {
|
|
40
|
+
type: String,
|
|
41
|
+
required: false,
|
|
42
|
+
default: undefined
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const crumbs = computed<BreadCrumb[]>(() => {
|
|
47
|
+
// If explicit items are provided, use them directly
|
|
48
|
+
if (props.items && props.items.length > 0) {
|
|
49
|
+
return props.items
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Otherwise, auto-generate from the path prop (or current URL)
|
|
53
|
+
const pathname = props.path ?? (typeof window !== 'undefined' ? window.location.pathname : '/')
|
|
54
|
+
const segments = pathname.split('/').filter(s => s !== '')
|
|
55
|
+
|
|
56
|
+
let accumulated = ''
|
|
57
|
+
return segments.map(segment => {
|
|
58
|
+
accumulated += '/' + segment
|
|
59
|
+
|
|
60
|
+
const isNumeric = !Number.isNaN(Number(segment))
|
|
61
|
+
const name = !isNumeric
|
|
62
|
+
? (segment.charAt(0).toUpperCase() + segment.slice(1)).replaceAll('-', ' ')
|
|
63
|
+
: segment
|
|
64
|
+
|
|
65
|
+
return { name, url: accumulated }
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
</script>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseCard from './BaseCard.vue'
|
|
4
|
+
import BaseField from './BaseField.vue'
|
|
5
|
+
|
|
6
|
+
describe('BaseCard', () => {
|
|
7
|
+
it('renders the bordered surface with the body slot', () => {
|
|
8
|
+
const wrapper = mount(BaseCard, { slots: { default: '<p>body</p>' } })
|
|
9
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(['rounded-xl', 'border', 'shadow-sm']))
|
|
10
|
+
expect(wrapper.find('p').text()).toBe('body')
|
|
11
|
+
expect(wrapper.find('header').exists()).toBe(false)
|
|
12
|
+
expect(wrapper.find('footer').exists()).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('renders header (title, subtitle, actions) and footer when given', () => {
|
|
16
|
+
const wrapper = mount(BaseCard, {
|
|
17
|
+
props: { title: 'Template', subtitle: 'Edit it' },
|
|
18
|
+
slots: { actions: '<button>New</button>', footer: '<button>Save</button>' },
|
|
19
|
+
})
|
|
20
|
+
expect(wrapper.find('header h2').text()).toBe('Template')
|
|
21
|
+
expect(wrapper.find('header').text()).toContain('Edit it')
|
|
22
|
+
expect(wrapper.find('header button').text()).toBe('New')
|
|
23
|
+
expect(wrapper.find('footer button').text()).toBe('Save')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('supports compact and no body padding', () => {
|
|
27
|
+
expect(mount(BaseCard, { props: { padding: 'sm' } }).find('section > div').classes()).toContain('p-4')
|
|
28
|
+
expect(mount(BaseCard, { props: { padding: 'none' } }).find('section > div').classes()).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('BaseField', () => {
|
|
33
|
+
it('renders label (wired to the control), control and hint', () => {
|
|
34
|
+
const wrapper = mount(BaseField, {
|
|
35
|
+
props: { label: 'Name', labelFor: 'name', hint: 'Your name', required: true },
|
|
36
|
+
slots: { default: '<input id="name" />' },
|
|
37
|
+
})
|
|
38
|
+
expect(wrapper.find('label').attributes('for')).toBe('name')
|
|
39
|
+
expect(wrapper.find('label').text()).toBe('Name*')
|
|
40
|
+
expect(wrapper.find('input').exists()).toBe(true)
|
|
41
|
+
expect(wrapper.text()).toContain('Your name')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('shows the error instead of the hint', () => {
|
|
45
|
+
const wrapper = mount(BaseField, { props: { label: 'Name', hint: 'h', error: 'Required' } })
|
|
46
|
+
expect(wrapper.find('[role=alert]').text()).toBe('Required')
|
|
47
|
+
expect(wrapper.text()).not.toContain('h\n')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('uses the small uppercase label and flex growth for filter bars', () => {
|
|
51
|
+
const wrapper = mount(BaseField, { props: { label: 'Search', compact: true, grow: true } })
|
|
52
|
+
expect(wrapper.find('label').classes()).toEqual(expect.arrayContaining(['uppercase', 'text-xs']))
|
|
53
|
+
expect(wrapper.classes()).toContain('flex-1')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
class="rounded-xl border border-slate-200 bg-white text-left shadow-sm dark:border-slate-700 dark:bg-slate-900"
|
|
4
|
+
>
|
|
5
|
+
<header
|
|
6
|
+
v-if="title || subtitle || $slots.actions"
|
|
7
|
+
class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700"
|
|
8
|
+
>
|
|
9
|
+
<div class="min-w-0">
|
|
10
|
+
<h2 v-if="title" class="text-base font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
|
|
11
|
+
<p v-if="subtitle" class="text-sm text-slate-500 dark:text-slate-400">{{ subtitle }}</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="$slots.actions" class="flex items-center gap-2">
|
|
14
|
+
<slot name="actions" />
|
|
15
|
+
</div>
|
|
16
|
+
</header>
|
|
17
|
+
<div :class="padding === 'sm' ? 'p-4' : padding === 'none' ? '' : 'p-5'">
|
|
18
|
+
<slot />
|
|
19
|
+
</div>
|
|
20
|
+
<footer
|
|
21
|
+
v-if="$slots.footer"
|
|
22
|
+
class="flex flex-wrap items-center justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700"
|
|
23
|
+
>
|
|
24
|
+
<slot name="footer" />
|
|
25
|
+
</footer>
|
|
26
|
+
</section>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
/**
|
|
31
|
+
* Bordered surface card — the shared chrome for forms, filter/search panels
|
|
32
|
+
* and any grouped content. Optional header (title / subtitle / `actions`
|
|
33
|
+
* slot), a padded body (default slot) and an optional `footer` slot for
|
|
34
|
+
* submit / cancel buttons.
|
|
35
|
+
*/
|
|
36
|
+
interface Props {
|
|
37
|
+
title?: string
|
|
38
|
+
subtitle?: string
|
|
39
|
+
/** Body padding: 'md' (p-5, default), 'sm' (p-4, e.g. filter bars) or 'none'. */
|
|
40
|
+
padding?: 'md' | 'sm' | 'none'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
withDefaults(defineProps<Props>(), {
|
|
44
|
+
title: '',
|
|
45
|
+
subtitle: '',
|
|
46
|
+
padding: 'md',
|
|
47
|
+
})
|
|
48
|
+
</script>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { VueDatePicker } from '@vuepic/vue-datepicker'
|
|
4
|
+
import BaseDateTimePicker from './BaseDateTimePicker.vue'
|
|
5
|
+
|
|
6
|
+
function picker(props: Record<string, unknown> = {}, attrs: Record<string, unknown> = {}) {
|
|
7
|
+
return mount(BaseDateTimePicker, { props, attrs }).findComponent(VueDatePicker)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('BaseDateTimePicker', () => {
|
|
11
|
+
it('defaults to date + 24h time, teleported, with the dd/MM/yyyy HH:mm format', () => {
|
|
12
|
+
const dp = picker()
|
|
13
|
+
expect(dp.props('timePicker')).toBe(false)
|
|
14
|
+
expect(dp.props('timeConfig')).toMatchObject({ enableTimePicker: true, is24: true })
|
|
15
|
+
expect(dp.props('formats')).toEqual({ input: 'dd/MM/yyyy HH:mm' })
|
|
16
|
+
expect(dp.props('teleport')).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("mode 'date' hides the time picker", () => {
|
|
20
|
+
const dp = picker({ mode: 'date' })
|
|
21
|
+
expect(dp.props('timeConfig')).toMatchObject({ enableTimePicker: false })
|
|
22
|
+
expect(dp.props('formats')).toEqual({ input: 'dd/MM/yyyy' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("mode 'time' switches to the time-only picker, 12h when is24=false", () => {
|
|
26
|
+
const dp = picker({ mode: 'time', is24: false })
|
|
27
|
+
expect(dp.props('timePicker')).toBe(true)
|
|
28
|
+
expect(dp.props('formats')).toEqual({ input: 'hh:mm a' })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('custom format and timeConfig override the defaults', () => {
|
|
32
|
+
const dp = picker({ format: 'yyyy-MM-dd', timeConfig: { enableSeconds: true } })
|
|
33
|
+
expect(dp.props('formats')).toEqual({ input: 'yyyy-MM-dd' })
|
|
34
|
+
expect(dp.props('timeConfig')).toMatchObject({ enableTimePicker: true, enableSeconds: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('passes unknown props through to VueDatePicker', () => {
|
|
38
|
+
const dp = picker({}, { range: true, placeholder: 'Pick a date' })
|
|
39
|
+
expect(dp.props('range')).toBe(true)
|
|
40
|
+
expect(dp.props('placeholder')).toBe('Pick a date')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('re-emits update:modelValue (v-model contract)', async () => {
|
|
44
|
+
const wrapper = mount(BaseDateTimePicker)
|
|
45
|
+
const date = new Date(2026, 8, 27, 10, 30)
|
|
46
|
+
wrapper.findComponent(VueDatePicker).vm.$emit('update:model-value', date)
|
|
47
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[date]])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Thin wrapper around @vuepic/vue-datepicker so every consumer gets the same
|
|
4
|
+
picker, the same defaults and the lib's emerald/slate skin (see the
|
|
5
|
+
`.dp--theme-*` overrides in style.css) without installing or wiring
|
|
6
|
+
anything themselves.
|
|
7
|
+
|
|
8
|
+
Only the common knobs are typed props here. Every other VueDatePicker
|
|
9
|
+
prop (range, min-date, max-date, disabled-dates, locale, …), event and
|
|
10
|
+
slot falls through untouched, so nothing the underlying picker can do is
|
|
11
|
+
lost.
|
|
12
|
+
|
|
13
|
+
Dark mode follows useTheme() automatically.
|
|
14
|
+
-->
|
|
15
|
+
<VueDatePicker
|
|
16
|
+
:model-value="modelValue"
|
|
17
|
+
:dark="isDark"
|
|
18
|
+
:time-picker="mode === 'time'"
|
|
19
|
+
:time-config="resolvedTimeConfig"
|
|
20
|
+
:formats="resolvedFormats"
|
|
21
|
+
:auto-apply="autoApply"
|
|
22
|
+
:teleport="teleport"
|
|
23
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
24
|
+
>
|
|
25
|
+
<template v-for="(_, name) in $slots" #[name]="slotProps">
|
|
26
|
+
<slot :name="name" v-bind="slotProps ?? {}" />
|
|
27
|
+
</template>
|
|
28
|
+
</VueDatePicker>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
import { computed } from 'vue'
|
|
33
|
+
import { VueDatePicker, type ModelValue, type TimeConfig } from '@vuepic/vue-datepicker'
|
|
34
|
+
import '@vuepic/vue-datepicker/dist/main.css'
|
|
35
|
+
import { useTheme } from '../composables/useTheme'
|
|
36
|
+
|
|
37
|
+
export type DateTimePickerMode = 'date' | 'datetime' | 'time'
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
/** v-model value — Date, Date[] (range), time object, or string with `model-type`. */
|
|
41
|
+
modelValue?: ModelValue
|
|
42
|
+
/** 'date' = calendar only; 'datetime' = calendar + time (default); 'time' = time only. */
|
|
43
|
+
mode?: DateTimePickerMode
|
|
44
|
+
/**
|
|
45
|
+
* date-fns pattern for the input text. Defaults per mode:
|
|
46
|
+
* 'dd/MM/yyyy', 'dd/MM/yyyy HH:mm' (12h: 'hh:mm a'), 'HH:mm'.
|
|
47
|
+
*/
|
|
48
|
+
format?: string
|
|
49
|
+
/** 24-hour clock. */
|
|
50
|
+
is24?: boolean
|
|
51
|
+
/** Select on click without the Cancel/Select action row. */
|
|
52
|
+
autoApply?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Where the menu renders. Defaults to `true` (body) so it isn't clipped by
|
|
55
|
+
* modals or overflow-hidden containers.
|
|
56
|
+
*/
|
|
57
|
+
teleport?: boolean | string | HTMLElement
|
|
58
|
+
/** Extra time options, merged over the mode-derived defaults. */
|
|
59
|
+
timeConfig?: Partial<TimeConfig>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
63
|
+
modelValue: null,
|
|
64
|
+
mode: 'datetime',
|
|
65
|
+
format: undefined,
|
|
66
|
+
is24: true,
|
|
67
|
+
autoApply: false,
|
|
68
|
+
teleport: true,
|
|
69
|
+
timeConfig: undefined,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const emit = defineEmits<{
|
|
73
|
+
(e: 'update:modelValue', value: ModelValue): void
|
|
74
|
+
}>()
|
|
75
|
+
|
|
76
|
+
const { isDark } = useTheme()
|
|
77
|
+
|
|
78
|
+
const resolvedTimeConfig = computed<Partial<TimeConfig>>(() => ({
|
|
79
|
+
enableTimePicker: props.mode !== 'date',
|
|
80
|
+
is24: props.is24,
|
|
81
|
+
...props.timeConfig,
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const resolvedFormats = computed(() => {
|
|
85
|
+
if (props.format) return { input: props.format }
|
|
86
|
+
const time = props.is24 ? 'HH:mm' : 'hh:mm a'
|
|
87
|
+
if (props.mode === 'date') return { input: 'dd/MM/yyyy' }
|
|
88
|
+
if (props.mode === 'time') return { input: time }
|
|
89
|
+
return { input: `dd/MM/yyyy ${time}` }
|
|
90
|
+
})
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="grow ? 'min-w-48 flex-1' : ''" class="text-left">
|
|
3
|
+
<label
|
|
4
|
+
v-if="label"
|
|
5
|
+
:for="labelFor || undefined"
|
|
6
|
+
class="mb-1 block"
|
|
7
|
+
:class="compact
|
|
8
|
+
? 'text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400'
|
|
9
|
+
: 'text-sm font-medium text-slate-700 dark:text-slate-200'"
|
|
10
|
+
>
|
|
11
|
+
{{ label }}<span v-if="required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
|
|
12
|
+
</label>
|
|
13
|
+
<slot />
|
|
14
|
+
<p v-if="error" class="mt-1 text-xs font-medium text-red-600 dark:text-red-400" role="alert">{{ error }}</p>
|
|
15
|
+
<p v-else-if="hint" class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ hint }}</p>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
/**
|
|
21
|
+
* Label + control + message wrapper for forms and filter bars. The control
|
|
22
|
+
* goes in the default slot (BaseInput, BaseSelect, BaseSearchSelect, …).
|
|
23
|
+
* `compact` switches to the small uppercase filter-bar label; `grow` makes
|
|
24
|
+
* the field take the free space in a flex row (filter bars).
|
|
25
|
+
*/
|
|
26
|
+
interface Props {
|
|
27
|
+
label?: string
|
|
28
|
+
/** id of the control, wired to the label's `for`. */
|
|
29
|
+
labelFor?: string
|
|
30
|
+
required?: boolean
|
|
31
|
+
/** Validation message (red); replaces the hint while set. */
|
|
32
|
+
error?: string
|
|
33
|
+
hint?: string
|
|
34
|
+
compact?: boolean
|
|
35
|
+
grow?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
withDefaults(defineProps<Props>(), {
|
|
39
|
+
label: '',
|
|
40
|
+
labelFor: '',
|
|
41
|
+
required: false,
|
|
42
|
+
error: '',
|
|
43
|
+
hint: '',
|
|
44
|
+
compact: false,
|
|
45
|
+
grow: false,
|
|
46
|
+
})
|
|
47
|
+
</script>
|
|
@@ -7,7 +7,7 @@ describe('BaseInput', () => {
|
|
|
7
7
|
const wrapper = mount(BaseInput)
|
|
8
8
|
const input = wrapper.find('input')
|
|
9
9
|
expect(input.classes()).toEqual(
|
|
10
|
-
expect.arrayContaining(['rounded', 'border', 'w-full', 'px-3', 'py-2', 'text-sm']),
|
|
10
|
+
expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'px-3', 'py-2', 'text-sm']),
|
|
11
11
|
)
|
|
12
12
|
expect(input.attributes('type')).toBe('text')
|
|
13
13
|
})
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<!--
|
|
3
|
-
Themed text/number input. Carries the shared field skin (slate border,
|
|
4
|
-
slate-50/slate-900 surface); everything else — placeholder, id, disabled,
|
|
5
|
-
step/min, extra classes like `font-mono` — falls through via attrs and
|
|
6
|
-
Vue's class merging, so callers keep their one-off styling.
|
|
7
|
-
|
|
8
|
-
Dark mode uses `dark:` variants: the consuming app must map the `dark`
|
|
9
|
-
variant to the `.dark` class useTheme() toggles (all current consumers do).
|
|
10
|
-
-->
|
|
11
|
-
<input
|
|
12
|
-
:value="modelValue"
|
|
13
|
-
:type="type"
|
|
14
|
-
class="rounded border border-slate-300 dark:border-slate-600 bg-
|
|
15
|
-
:class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
|
|
16
|
-
@input="onInput"
|
|
17
|
-
/>
|
|
18
|
-
</template>
|
|
19
|
-
|
|
20
|
-
<script setup lang="ts">
|
|
21
|
-
interface Props {
|
|
22
|
-
modelValue?: string | number | null
|
|
23
|
-
/** Native input type ('text', 'number', 'password', …). */
|
|
24
|
-
type?: string
|
|
25
|
-
/** 'md' = px-3 py-2 (default); 'sm' = px-2 py-1.5 compact. */
|
|
26
|
-
size?: 'sm' | 'md'
|
|
27
|
-
/** Full-width (w-full). Set false for inline fields. */
|
|
28
|
-
block?: boolean
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
withDefaults(defineProps<Props>(), {
|
|
32
|
-
modelValue: '',
|
|
33
|
-
type: 'text',
|
|
34
|
-
size: 'md',
|
|
35
|
-
block: true,
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
const emit = defineEmits<{
|
|
39
|
-
/** Raw string value on every input — callers parse numbers themselves. */
|
|
40
|
-
(e: 'update:modelValue', value: string): void
|
|
41
|
-
}>()
|
|
42
|
-
|
|
43
|
-
function onInput(e: Event) {
|
|
44
|
-
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
|
45
|
-
}
|
|
46
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Themed text/number input. Carries the shared field skin (slate border,
|
|
4
|
+
slate-50/slate-900 surface); everything else — placeholder, id, disabled,
|
|
5
|
+
step/min, extra classes like `font-mono` — falls through via attrs and
|
|
6
|
+
Vue's class merging, so callers keep their one-off styling.
|
|
7
|
+
|
|
8
|
+
Dark mode uses `dark:` variants: the consuming app must map the `dark`
|
|
9
|
+
variant to the `.dark` class useTheme() toggles (all current consumers do).
|
|
10
|
+
-->
|
|
11
|
+
<input
|
|
12
|
+
:value="modelValue"
|
|
13
|
+
:type="type"
|
|
14
|
+
class="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 placeholder:text-slate-400 dark:placeholder:text-slate-500 transition-colors hover:border-slate-400 dark:hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:border-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
15
|
+
:class="[block ? 'w-full' : '', size === 'sm' ? 'px-2 py-1.5' : 'px-3 py-2']"
|
|
16
|
+
@input="onInput"
|
|
17
|
+
/>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
interface Props {
|
|
22
|
+
modelValue?: string | number | null
|
|
23
|
+
/** Native input type ('text', 'number', 'password', …). */
|
|
24
|
+
type?: string
|
|
25
|
+
/** 'md' = px-3 py-2 (default); 'sm' = px-2 py-1.5 compact. */
|
|
26
|
+
size?: 'sm' | 'md'
|
|
27
|
+
/** Full-width (w-full). Set false for inline fields. */
|
|
28
|
+
block?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
withDefaults(defineProps<Props>(), {
|
|
32
|
+
modelValue: '',
|
|
33
|
+
type: 'text',
|
|
34
|
+
size: 'md',
|
|
35
|
+
block: true,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** Raw string value on every input — callers parse numbers themselves. */
|
|
40
|
+
(e: 'update:modelValue', value: string): void
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
function onInput(e: Event) {
|
|
44
|
+
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import { mount, RouterLinkStub } from '@vue/test-utils'
|
|
4
|
+
import BaseLogo from './BaseLogo.vue'
|
|
5
|
+
|
|
6
|
+
describe('BaseLogo', () => {
|
|
7
|
+
it('gives each instance its own gradient ids and references them', () => {
|
|
8
|
+
const Two = defineComponent({ render: () => [h(BaseLogo), h(BaseLogo)] })
|
|
9
|
+
const wrapper = mount(Two, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
|
10
|
+
|
|
11
|
+
const svgs = wrapper.findAll('svg')
|
|
12
|
+
expect(svgs).toHaveLength(2)
|
|
13
|
+
|
|
14
|
+
const ids = svgs.map((svg) => svg.findAll('linearGradient').map((g) => g.attributes('id')))
|
|
15
|
+
// No id is shared between the two logos.
|
|
16
|
+
expect(ids[0].filter((id) => ids[1].includes(id))).toEqual([])
|
|
17
|
+
|
|
18
|
+
// Every fill in a logo points at a gradient inside that same logo.
|
|
19
|
+
svgs.forEach((svg, i) => {
|
|
20
|
+
svg.findAll('path').forEach((path) => {
|
|
21
|
+
const ref = path.attributes('fill')?.match(/^url\(#(.+)\)$/)?.[1]
|
|
22
|
+
expect(ids[i]).toContain(ref)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<router-link to="/" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
|
|
3
|
-
<svg :height="computePx()" :width="computePx()" viewBox="33.6 130.6 499.7 242.9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset=".085" stop-color="#469e43"/><stop offset="1" stop-color="#132d13"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="64.863" x2="64.863"
|
|
3
|
+
<svg :height="computePx()" :width="computePx()" viewBox="33.6 130.6 499.7 242.9" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient :id="`${uid}-a`"><stop offset=".085" stop-color="#469e43"/><stop offset="1" stop-color="#132d13"/></linearGradient><linearGradient :id="`${uid}-b`" gradientUnits="userSpaceOnUse" x1="64.863" x2="64.863" :href="`#${uid}-a`" y1="288.291" y2="369.4"/><linearGradient :id="`${uid}-c`" gradientUnits="userSpaceOnUse" x1="138.367" x2="138.367" :href="`#${uid}-a`" y1="308.454" y2="370.601"/><linearGradient :id="`${uid}-d`" gradientUnits="userSpaceOnUse" x1="195.475" x2="195.475" :href="`#${uid}-a`" y1="288.291" y2="369.85"/><linearGradient :id="`${uid}-e`" gradientUnits="userSpaceOnUse" x1="249.634" x2="249.634" :href="`#${uid}-a`" y1="307.312" y2="369.511"/><linearGradient :id="`${uid}-f`" gradientUnits="userSpaceOnUse" x1="302.884" x2="302.884" :href="`#${uid}-a`" y1="288.291" y2="367.857"/><linearGradient :id="`${uid}-g`" gradientUnits="userSpaceOnUse" x1="354.118" x2="354.118" :href="`#${uid}-a`" y1="307.312" y2="369.511"/><linearGradient :id="`${uid}-h`" gradientUnits="userSpaceOnUse" x1="428.02" x2="428.02" :href="`#${uid}-a`" y1="306.156" y2="367.742"/><linearGradient :id="`${uid}-i`" gradientUnits="userSpaceOnUse" x1="502.066" x2="502.066" :href="`#${uid}-a`" y1="288.291" y2="369.4"/><linearGradient :id="`${uid}-j`" gradientUnits="userSpaceOnUse" x1="283.264" x2="283.264" :href="`#${uid}-a`" y1="131.629" y2="232.559"/><linearGradient :id="`${uid}-k`" gradientUnits="userSpaceOnUse" x1="283.46" x2="283.46" y1="202.275" y2="183.785"><stop offset="0" stop-color="#969799" stop-opacity="0"/><stop offset="1" stop-color="#808183"/></linearGradient><path d="m65 307.2c-6.8 0-13.1 2.2-18.2 5.9v-24.8h-13.2v81.1h31.4c17.2 0 31.1-13.9 31.1-31.1s-13.9-31.1-31.1-31.1zm0 49.3h-18.2v-18.2c0-10.1 8.2-18.2 18.2-18.2 10.1 0 18.2 8.2 18.2 18.2.1 10.1-8.1 18.2-18.2 18.2z" :fill="`url(#${uid}-b)`"/><path d="m168.8 343.9h-46.7c1.4 7.1 6.6 12 14.1 12.7 6.4.6 12.9.1 19.9.1v11.9c-15.2 4.9-33.3.5-42.1-10.4-3.8-4.7-17.3-34.7 11.5-47.4 39-11.4 45.3 22.4 43.3 33.1zm-14.2-12.7c-1.7-8.1-8.8-12.9-17.9-12.3-7.6.5-14 5.8-14.5 12.3z" :fill="`url(#${uid}-c)`"/><path d="m195.5 320.2c0 10.5-.2 20 .1 29.6.2 5.8 4.4 8.4 13.2 8.6v10.5c-13.8 3.9-26.2-4.5-26.4-18-.2-18.7-.1-43.9-.1-62.6h14.1l-.3 18.5 12.7-.1v13.4h-13.3z" :fill="`url(#${uid}-d)`"/><path d="m249.6 307.3c-17.2 0-31.1 13.9-31.1 31.1s13.9 31.1 31.1 31.1h31.1v-31.1c0-17.2-13.9-31.1-31.1-31.1zm18.6 49.7h-18.5c-10.2 0-18.5-8.3-18.5-18.5s8.3-18.5 18.5-18.5 18.5 8.3 18.5 18.5z" :fill="`url(#${uid}-e)`"/><path d="m296.4 288.3h13v79.6h-13z" :fill="`url(#${uid}-f)`"/><path d="m354.1 307.3c-17.2 0-31.1 13.9-31.1 31.1s13.9 31.1 31.1 31.1h31.1v-31.1c0-17.2-13.9-31.1-31.1-31.1zm18.6 49.7h-18.5c-10.2 0-18.5-8.3-18.5-18.5s8.3-18.5 18.5-18.5 18.5 8.3 18.5 18.5z" :fill="`url(#${uid}-g)`"/><path d="m442.2 367.7c0-12.3.2-23.6 0-34.9-.2-3.4-3.6-13.8-14.1-13.4-10.3-.3-14.4 10-14.3 12.7.1 10.2.5 35.1.5 35.1l-13.6.1s-.3-25.7.2-36.2c.6-14.1 12.7-25 27.5-25 14.7 0 26.2 10.4 26.7 24.9.4 10.4.5 36.3.5 36.3z" :fill="`url(#${uid}-h)`"/><path d="m501.9 307.2c6.8 0 13.1 2.2 18.2 5.9v-24.8h13.2v81.1h-31.4c-17.2 0-31.1-13.9-31.1-31.1s13.9-31.1 31.1-31.1zm0 49.3h18.2v-18.2c0-10.1-8.2-18.2-18.2-18.2-10.1 0-18.2 8.2-18.2 18.2 0 10.1 8.1 18.2 18.2 18.2z" :fill="`url(#${uid}-i)`"/><path d="m309.2 180.6h-24v-24c0-12.6 10.2-24.1 22.8-25 14.8-1 27.1 11.3 26.1 26.1-.8 12.7-12.3 22.9-24.9 22.9zm-50.7-48.9c-14.8-1-27.1 11.3-26.1 26.1.8 12.6 12.4 22.8 25 22.8h23.9v-24c0-12.6-10.3-24.1-22.8-24.9zm50.7 51.9h-24v23.9c0 12.6 10.2 24.1 22.8 25 14.8 1 27.1-11.3 26.1-26.1-.8-12.6-12.3-22.8-24.9-22.8zm-52.4 0c-14 0-25.3 11.8-24.4 26 .8 12.2 10.7 22.1 22.9 22.9 14.2.9 26-10.4 26-24.4v-24.5z" :fill="`url(#${uid}-j)`"/><path d="m281.7 183.8h3.6v18.5h-3.6z" :fill="`url(#${uid}-k)`"/></svg>
|
|
4
4
|
</router-link>
|
|
5
5
|
</template>
|
|
6
6
|
|
|
7
7
|
<script lang="ts" setup>
|
|
8
|
+
import { useId } from "vue";
|
|
8
9
|
import { BaseLogoEnum } from "../enums/BaseLogoEnum";
|
|
9
10
|
|
|
11
|
+
// Per-instance prefix for the SVG gradient ids. With fixed ids, every logo on
|
|
12
|
+
// the page pointed at the FIRST logo's gradients, so when that one was hidden
|
|
13
|
+
// (e.g. the sidebar's mobile top bar on desktop) the visible logos lost their fill.
|
|
14
|
+
const uid = `logo-${useId()}`;
|
|
15
|
+
|
|
10
16
|
const props = defineProps({
|
|
11
17
|
size: {
|
|
12
18
|
type: String,
|