mgv-backoffice 1.37.0 → 1.40.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 +147 -4
- package/dist/components/BaseAuthLayout.vue.d.ts +27 -0
- package/dist/components/BaseBooleanBadge.vue.d.ts +12 -0
- package/dist/components/BaseCard.vue.d.ts +33 -0
- package/dist/components/BaseDetailList.vue.d.ts +13 -0
- package/dist/components/BaseField.vue.d.ts +38 -0
- package/dist/components/BasePageHeader.vue.d.ts +3 -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/components/BaseToggle.vue.d.ts +33 -0
- package/dist/index.d.ts +9 -0
- package/dist/types/detail.d.ts +10 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1388 -881
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseAuthLayout.test.ts +48 -0
- package/src/components/BaseAuthLayout.vue +50 -0
- package/src/components/BaseBooleanBadge.vue +33 -0
- package/src/components/BaseBreadcrumb.vue +68 -68
- package/src/components/BaseButton.vue +1 -1
- package/src/components/BaseCard.test.ts +55 -0
- package/src/components/BaseCard.vue +48 -0
- package/src/components/BaseDetailList.test.ts +36 -0
- package/src/components/BaseDetailList.vue +43 -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/BasePageHeader.vue +13 -2
- 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 +62 -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/components/BaseToggle.test.ts +39 -0
- package/src/components/BaseToggle.vue +73 -0
- package/src/components/Pagination.test.ts +93 -85
- package/src/components/Pagination.vue +87 -82
- package/src/index.ts +9 -0
- package/src/style.css +58 -58
- package/src/types/detail.ts +10 -0
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount, RouterLinkStub } from '@vue/test-utils'
|
|
3
|
+
import BaseAuthLayout from './BaseAuthLayout.vue'
|
|
4
|
+
import BaseBooleanBadge from './BaseBooleanBadge.vue'
|
|
5
|
+
|
|
6
|
+
const global = { stubs: { RouterLink: RouterLinkStub } }
|
|
7
|
+
|
|
8
|
+
describe('BaseAuthLayout', () => {
|
|
9
|
+
it('renders the default logo, title, content and footer', () => {
|
|
10
|
+
const wrapper = mount(BaseAuthLayout, {
|
|
11
|
+
props: { title: 'Sign in' },
|
|
12
|
+
slots: { default: '<form>form</form>', footer: 'No account? <a>Sign up</a>' },
|
|
13
|
+
global,
|
|
14
|
+
})
|
|
15
|
+
expect(wrapper.find('svg').exists()).toBe(true)
|
|
16
|
+
expect(wrapper.find('h1').text()).toBe('Sign in')
|
|
17
|
+
expect(wrapper.find('form').text()).toBe('form')
|
|
18
|
+
expect(wrapper.text()).toContain('No account? Sign up')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('lets the logo slot replace the default logo', () => {
|
|
22
|
+
const wrapper = mount(BaseAuthLayout, { slots: { logo: '<img alt="brand" />' }, global })
|
|
23
|
+
expect(wrapper.find('img').exists()).toBe(true)
|
|
24
|
+
expect(wrapper.find('svg').exists()).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('widens the card with `wide`', () => {
|
|
28
|
+
const card = (w: ReturnType<typeof mount>) => w.find('.rounded-xl')
|
|
29
|
+
expect(card(mount(BaseAuthLayout, { global })).classes()).toContain('max-w-md')
|
|
30
|
+
expect(card(mount(BaseAuthLayout, { props: { wide: true }, global })).classes()).toContain('max-w-3xl')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('BaseBooleanBadge', () => {
|
|
35
|
+
it('renders a green "Yes" pill for true and a neutral "No" pill for false', () => {
|
|
36
|
+
const yes = mount(BaseBooleanBadge, { props: { value: true } })
|
|
37
|
+
expect(yes.text()).toBe('Yes')
|
|
38
|
+
expect(yes.classes()).toContain('bg-emerald-50')
|
|
39
|
+
const no = mount(BaseBooleanBadge, { props: { value: false } })
|
|
40
|
+
expect(no.text()).toBe('No')
|
|
41
|
+
expect(no.classes()).toContain('bg-slate-100')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('uses custom labels and treats null as false', () => {
|
|
45
|
+
const w = mount(BaseBooleanBadge, { props: { value: null, trueLabel: 'Verified', falseLabel: 'Not verified' } })
|
|
46
|
+
expect(w.text()).toBe('Not verified')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex min-h-screen w-full flex-col items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
|
|
3
|
+
<div class="mb-6 flex justify-center">
|
|
4
|
+
<slot name="logo">
|
|
5
|
+
<BaseLogo :size="BaseLogoEnum.MEDIUM" />
|
|
6
|
+
</slot>
|
|
7
|
+
</div>
|
|
8
|
+
<div
|
|
9
|
+
class="w-full rounded-xl border border-slate-200 bg-white p-6 text-left shadow-sm sm:p-8 dark:border-slate-700 dark:bg-slate-900"
|
|
10
|
+
:class="wide ? 'max-w-3xl' : 'max-w-md'"
|
|
11
|
+
>
|
|
12
|
+
<h1 v-if="title" class="mb-1 text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{{ title }}</h1>
|
|
13
|
+
<p v-if="subtitle" class="text-sm text-slate-500 dark:text-slate-400">{{ subtitle }}</p>
|
|
14
|
+
<div :class="title || subtitle ? 'mt-6' : ''">
|
|
15
|
+
<slot />
|
|
16
|
+
</div>
|
|
17
|
+
<div
|
|
18
|
+
v-if="$slots.footer"
|
|
19
|
+
class="mt-6 border-t border-slate-200 pt-4 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400"
|
|
20
|
+
>
|
|
21
|
+
<slot name="footer" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
/**
|
|
29
|
+
* Full-page, centred shell for sign-in / sign-up / password-recovery screens:
|
|
30
|
+
* logo on top (defaults to BaseLogo, override via the `logo` slot), then a
|
|
31
|
+
* bordered card with the title, the page content (default slot) and an
|
|
32
|
+
* optional `footer` ("Don't have an account? Sign up"). `wide` widens the card
|
|
33
|
+
* for multi-column forms such as registration.
|
|
34
|
+
*/
|
|
35
|
+
import BaseLogo from './BaseLogo.vue'
|
|
36
|
+
import { BaseLogoEnum } from '../enums/BaseLogoEnum'
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
title?: string
|
|
40
|
+
subtitle?: string
|
|
41
|
+
/** max-w-3xl card instead of max-w-md (multi-column forms). */
|
|
42
|
+
wide?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
withDefaults(defineProps<Props>(), {
|
|
46
|
+
title: '',
|
|
47
|
+
subtitle: '',
|
|
48
|
+
wide: false,
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span
|
|
3
|
+
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
|
|
4
|
+
:class="value
|
|
5
|
+
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/30'
|
|
6
|
+
: 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/50 dark:text-slate-300 dark:ring-slate-400/20'"
|
|
7
|
+
>
|
|
8
|
+
<CheckCircleIcon v-if="value" class="h-3.5 w-3.5" aria-hidden="true" />
|
|
9
|
+
<XCircleIcon v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
|
10
|
+
{{ value ? trueLabel : falseLabel }}
|
|
11
|
+
</span>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
/**
|
|
16
|
+
* Yes/no status pill for boolean columns ("Verified", "Has API", …):
|
|
17
|
+
* a green check pill when true, a neutral cross pill when false — so a
|
|
18
|
+
* `false` reads as a state, not as an error.
|
|
19
|
+
*/
|
|
20
|
+
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
value?: boolean | null
|
|
24
|
+
trueLabel?: string
|
|
25
|
+
falseLabel?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
withDefaults(defineProps<Props>(), {
|
|
29
|
+
value: false,
|
|
30
|
+
trueLabel: 'Yes',
|
|
31
|
+
falseLabel: 'No',
|
|
32
|
+
})
|
|
33
|
+
</script>
|
|
@@ -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,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseDetailList from './BaseDetailList.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseDetailList', () => {
|
|
6
|
+
it('renders each label with its value', () => {
|
|
7
|
+
const wrapper = mount(BaseDetailList, {
|
|
8
|
+
props: { items: [{ label: 'Status', value: 'PUBLISHED' }, { label: 'Count', value: 3 }] },
|
|
9
|
+
})
|
|
10
|
+
expect(wrapper.findAll('dt').map((d) => d.text())).toEqual(['Status', 'Count'])
|
|
11
|
+
expect(wrapper.findAll('dd').map((d) => d.text())).toEqual(['PUBLISHED', '3'])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('shows the empty text for missing values', () => {
|
|
15
|
+
const wrapper = mount(BaseDetailList, {
|
|
16
|
+
props: { items: [{ label: 'Link', value: null }, { label: 'Tags', value: '' }], emptyText: 'n/a' },
|
|
17
|
+
})
|
|
18
|
+
expect(wrapper.findAll('dd').map((d) => d.text())).toEqual(['n/a', 'n/a'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders href values as external links and applies mono', () => {
|
|
22
|
+
const wrapper = mount(BaseDetailList, {
|
|
23
|
+
props: { items: [{ label: 'Link', value: 'open', href: 'https://x.test' }, { label: 'Id', value: 'abc', mono: true }] },
|
|
24
|
+
})
|
|
25
|
+
const a = wrapper.find('a')
|
|
26
|
+
expect(a.attributes('href')).toBe('https://x.test')
|
|
27
|
+
expect(a.attributes('target')).toBe('_blank')
|
|
28
|
+
expect(wrapper.findAll('dd')[1].classes()).toContain('font-mono')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('switches the grid columns', () => {
|
|
32
|
+
expect(mount(BaseDetailList, { props: { items: [], columns: 1 } }).classes()).not.toContain('sm:grid-cols-2')
|
|
33
|
+
expect(mount(BaseDetailList, { props: { items: [] } }).classes()).toContain('sm:grid-cols-2')
|
|
34
|
+
expect(mount(BaseDetailList, { props: { items: [], columns: 3 } }).classes()).toContain('lg:grid-cols-3')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 lg:grid-cols-3' : ''">
|
|
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
|
+
<a
|
|
7
|
+
v-if="item.href && !isEmpty(item.value)"
|
|
8
|
+
:href="item.href"
|
|
9
|
+
target="_blank"
|
|
10
|
+
rel="noopener noreferrer"
|
|
11
|
+
class="text-emerald-600 hover:underline dark:text-emerald-400"
|
|
12
|
+
>{{ item.value }}</a>
|
|
13
|
+
<span v-else-if="isEmpty(item.value)" class="text-slate-400 dark:text-slate-500">{{ emptyText }}</span>
|
|
14
|
+
<template v-else>{{ item.value }}</template>
|
|
15
|
+
</dd>
|
|
16
|
+
</div>
|
|
17
|
+
</dl>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
/**
|
|
22
|
+
* Read-only label/value grid for detail pages — the "view" counterpart of a
|
|
23
|
+
* form. Empty values render `emptyText` (—); `href` makes the value an
|
|
24
|
+
* external link; `mono` sets it in the monospace face (ids, hashes).
|
|
25
|
+
*/
|
|
26
|
+
import type { DetailItem } from '../types/detail'
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
items: readonly DetailItem[]
|
|
30
|
+
/** Columns from `sm` up (1 column on phones). */
|
|
31
|
+
columns?: 1 | 2 | 3
|
|
32
|
+
emptyText?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
withDefaults(defineProps<Props>(), {
|
|
36
|
+
columns: 2,
|
|
37
|
+
emptyText: '—',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function isEmpty(v: DetailItem['value']): boolean {
|
|
41
|
+
return v === null || v === undefined || v === ''
|
|
42
|
+
}
|
|
43
|
+
</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
|
+
})
|