mgv-backoffice 1.38.0 → 1.41.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 +91 -3
- package/dist/components/BaseAuthLayout.vue.d.ts +27 -0
- package/dist/components/BaseBooleanBadge.vue.d.ts +12 -0
- package/dist/components/BaseCheckbox.vue.d.ts +28 -0
- package/dist/components/BaseDetailList.vue.d.ts +13 -0
- package/dist/components/BasePageHeader.vue.d.ts +3 -0
- package/dist/components/BaseSidebarUser.vue.d.ts +23 -0
- package/dist/components/BaseToggle.vue.d.ts +33 -0
- package/dist/index.d.ts +7 -0
- package/dist/types/detail.d.ts +10 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +847 -602
- 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 +32 -0
- package/src/components/BaseButton.vue +1 -1
- package/src/components/BaseCheckbox.test.ts +30 -0
- package/src/components/BaseCheckbox.vue +51 -0
- package/src/components/BaseDetailList.test.ts +36 -0
- package/src/components/BaseDetailList.vue +43 -0
- package/src/components/BasePageHeader.vue +13 -2
- package/src/components/BaseSelect.test.ts +1 -1
- package/src/components/BaseSelect.vue +3 -2
- package/src/components/BaseSidebarUser.test.ts +26 -0
- package/src/components/BaseSidebarUser.vue +75 -0
- package/src/components/BaseSpinner.vue +76 -76
- 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 +7 -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 red "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-red-50')
|
|
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,32 @@
|
|
|
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-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/30'"
|
|
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 red cross pill when false.
|
|
18
|
+
*/
|
|
19
|
+
import { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/20/solid'
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
value?: boolean | null
|
|
23
|
+
trueLabel?: string
|
|
24
|
+
falseLabel?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
withDefaults(defineProps<Props>(), {
|
|
28
|
+
value: false,
|
|
29
|
+
trueLabel: 'Yes',
|
|
30
|
+
falseLabel: 'No',
|
|
31
|
+
})
|
|
32
|
+
</script>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseCheckbox from './BaseCheckbox.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseCheckbox', () => {
|
|
6
|
+
it('is rounded and emerald when checked', () => {
|
|
7
|
+
const input = mount(BaseCheckbox).find('input')
|
|
8
|
+
expect(input.classes()).toEqual(expect.arrayContaining(['rounded-md', 'text-emerald-600', 'accent-emerald-600']))
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('reflects modelValue and emits the new value on change', async () => {
|
|
12
|
+
const wrapper = mount(BaseCheckbox, { props: { modelValue: false } })
|
|
13
|
+
const input = wrapper.find('input')
|
|
14
|
+
expect((input.element as HTMLInputElement).checked).toBe(false)
|
|
15
|
+
await input.setValue(true)
|
|
16
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[true]])
|
|
17
|
+
expect(wrapper.emitted('change')).toEqual([[true]])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders the label and forwards attrs to the input', () => {
|
|
21
|
+
const wrapper = mount(BaseCheckbox, { props: { label: 'Remember me' }, attrs: { id: 'remember' } })
|
|
22
|
+
expect(wrapper.text()).toBe('Remember me')
|
|
23
|
+
expect(wrapper.find('input').attributes('id')).toBe('remember')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('can be disabled', () => {
|
|
27
|
+
const wrapper = mount(BaseCheckbox, { props: { disabled: true } })
|
|
28
|
+
expect(wrapper.find('input').attributes('disabled')).toBeDefined()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<label
|
|
3
|
+
class="inline-flex items-center gap-2 select-none"
|
|
4
|
+
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
|
5
|
+
>
|
|
6
|
+
<input
|
|
7
|
+
v-bind="$attrs"
|
|
8
|
+
type="checkbox"
|
|
9
|
+
:checked="modelValue"
|
|
10
|
+
:disabled="disabled"
|
|
11
|
+
class="h-5 w-5 shrink-0 cursor-[inherit] rounded-md border-slate-300 bg-white text-emerald-600 accent-emerald-600 transition-colors focus:ring-2 focus:ring-emerald-500/40 focus:ring-offset-0 dark:border-slate-600 dark:bg-slate-900 dark:checked:bg-emerald-600"
|
|
12
|
+
@change="onChange"
|
|
13
|
+
/>
|
|
14
|
+
<span v-if="label || $slots.default" class="text-sm text-slate-700 dark:text-slate-200">
|
|
15
|
+
<slot>{{ label }}</slot>
|
|
16
|
+
</span>
|
|
17
|
+
</label>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
/**
|
|
22
|
+
* Rounded checkbox, emerald when checked (both a forms-plugin fill via
|
|
23
|
+
* `text-emerald-600` and native `accent-color`, so it looks the same with or
|
|
24
|
+
* without @tailwindcss/forms / Flowbite). `v-model` is the boolean; `change`
|
|
25
|
+
* fires with the new value. Attrs (id, name, …) go to the <input>.
|
|
26
|
+
*/
|
|
27
|
+
defineOptions({ inheritAttrs: false })
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
modelValue?: boolean
|
|
31
|
+
label?: string
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
withDefaults(defineProps<Props>(), {
|
|
36
|
+
modelValue: false,
|
|
37
|
+
label: '',
|
|
38
|
+
disabled: false,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
(e: 'update:modelValue', value: boolean): void
|
|
43
|
+
(e: 'change', value: boolean): void
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
function onChange(e: Event) {
|
|
47
|
+
const checked = (e.target as HTMLInputElement).checked
|
|
48
|
+
emit('update:modelValue', checked)
|
|
49
|
+
emit('change', checked)
|
|
50
|
+
}
|
|
51
|
+
</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>
|
|
@@ -47,10 +47,18 @@
|
|
|
47
47
|
<slot name="icon" :icon-class="iconTextClass" />
|
|
48
48
|
</div>
|
|
49
49
|
<div>
|
|
50
|
-
<h1
|
|
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
|
+
/>
|
|
51
59
|
{{ title }}
|
|
52
60
|
</h1>
|
|
53
|
-
<p v-if="subtitle || $slots.subtitle" class="text-sm" :class="t.dimTextAlt">
|
|
61
|
+
<p v-if="subtitle || $slots.subtitle" class="max-w-[72ch] text-sm" :class="t.dimTextAlt">
|
|
54
62
|
<slot name="subtitle">{{ subtitle }}</slot>
|
|
55
63
|
</p>
|
|
56
64
|
</div>
|
|
@@ -77,6 +85,8 @@ const props = withDefaults(
|
|
|
77
85
|
maxWidthClass?: string
|
|
78
86
|
align?: 'center' | 'end'
|
|
79
87
|
marginClass?: string
|
|
88
|
+
/** Brand-green gauge bar in front of the title (icon-less headers). */
|
|
89
|
+
accent?: boolean
|
|
80
90
|
}>(),
|
|
81
91
|
{
|
|
82
92
|
subtitle: '',
|
|
@@ -84,6 +94,7 @@ const props = withDefaults(
|
|
|
84
94
|
maxWidthClass: 'max-w-4xl',
|
|
85
95
|
align: 'center',
|
|
86
96
|
marginClass: 'mb-8',
|
|
97
|
+
accent: false,
|
|
87
98
|
},
|
|
88
99
|
)
|
|
89
100
|
|
|
@@ -11,7 +11,7 @@ describe('BaseSelect', () => {
|
|
|
11
11
|
const wrapper = mount(BaseSelect, { slots })
|
|
12
12
|
const select = wrapper.find('select')
|
|
13
13
|
expect(select.classes()).toEqual(
|
|
14
|
-
expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'pl-2.5', 'py-1.5', 'text-sm']),
|
|
14
|
+
expect.arrayContaining(['rounded-lg', 'border', 'w-full', 'pl-2.5', 'py-1.5', 'text-sm', 'appearance-none', 'bg-none']),
|
|
15
15
|
)
|
|
16
16
|
})
|
|
17
17
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<select
|
|
4
4
|
v-bind="$attrs"
|
|
5
5
|
:value="modelValue"
|
|
6
|
-
class="w-full appearance-none 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 cursor-pointer 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"
|
|
6
|
+
class="w-full appearance-none bg-none 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 cursor-pointer 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"
|
|
7
7
|
:class="size === 'md' ? 'py-2 pl-3 pr-9' : 'py-1.5 pl-2.5 pr-8'"
|
|
8
8
|
@change="onChange"
|
|
9
9
|
>
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
|
29
29
|
|
|
30
30
|
// Attrs (id, name, disabled, extra classes) go to the <select>, not the
|
|
31
|
-
// chevron wrapper.
|
|
31
|
+
// chevron wrapper. `bg-none` drops any arrow a forms reset (e.g. Flowbite /
|
|
32
|
+
// @tailwindcss/forms) paints as a background image, so only our chevron shows.
|
|
32
33
|
defineOptions({ inheritAttrs: false })
|
|
33
34
|
|
|
34
35
|
// String-valued only — a DOM select always yields strings; use v-model.number
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount, RouterLinkStub } from '@vue/test-utils'
|
|
3
|
+
import BaseSidebarUser from './BaseSidebarUser.vue'
|
|
4
|
+
|
|
5
|
+
const global = { stubs: { RouterLink: RouterLinkStub } }
|
|
6
|
+
|
|
7
|
+
describe('BaseSidebarUser', () => {
|
|
8
|
+
it('shows initials, name and email', () => {
|
|
9
|
+
const wrapper = mount(BaseSidebarUser, { props: { name: 'john.doe', email: 'j@d.test' }, global })
|
|
10
|
+
expect(wrapper.text()).toContain('JD')
|
|
11
|
+
expect(wrapper.text()).toContain('john.doe')
|
|
12
|
+
expect(wrapper.text()).toContain('j@d.test')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('links to the profile when `to` is set', () => {
|
|
16
|
+
const wrapper = mount(BaseSidebarUser, { props: { name: 'test', to: { name: 'Profile' } }, global })
|
|
17
|
+
expect(wrapper.findComponent(RouterLinkStub).props('to')).toEqual({ name: 'Profile' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('emits logout from the button, and can hide it', async () => {
|
|
21
|
+
const wrapper = mount(BaseSidebarUser, { props: { name: 'test' }, global })
|
|
22
|
+
await wrapper.find('button').trigger('click')
|
|
23
|
+
expect(wrapper.emitted('logout')).toHaveLength(1)
|
|
24
|
+
expect(mount(BaseSidebarUser, { props: { name: 'test', showLogout: false }, global }).find('button').exists()).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex flex-col gap-2 text-left">
|
|
3
|
+
<component
|
|
4
|
+
:is="to ? RouterLink : 'div'"
|
|
5
|
+
v-bind="to ? { to } : {}"
|
|
6
|
+
class="flex items-center gap-3 rounded-lg p-2 transition-colors"
|
|
7
|
+
:class="to ? 'hover:bg-slate-100 dark:hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : ''"
|
|
8
|
+
:title="to ? profileLabel : undefined"
|
|
9
|
+
>
|
|
10
|
+
<span
|
|
11
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
>{{ initials }}</span>
|
|
14
|
+
<span class="min-w-0">
|
|
15
|
+
<span class="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">{{ name }}</span>
|
|
16
|
+
<span v-if="email" class="block truncate text-xs text-slate-500 dark:text-slate-400">{{ email }}</span>
|
|
17
|
+
</span>
|
|
18
|
+
</component>
|
|
19
|
+
<BaseButton
|
|
20
|
+
v-if="showLogout"
|
|
21
|
+
class="w-full justify-center"
|
|
22
|
+
:description="logoutLabel"
|
|
23
|
+
:color="BaseButtonEnum.RED"
|
|
24
|
+
:size="BaseButtonSizeEnum.SMALL"
|
|
25
|
+
outline
|
|
26
|
+
icon-left
|
|
27
|
+
@click="emit('logout')"
|
|
28
|
+
>
|
|
29
|
+
<ArrowRightStartOnRectangleIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
|
|
30
|
+
</BaseButton>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script setup lang="ts">
|
|
35
|
+
/**
|
|
36
|
+
* Signed-in user block for the sidebar footer (BaseSidebar `footer` slot):
|
|
37
|
+
* initials avatar + name + email, linking to the profile page when `to` is
|
|
38
|
+
* set, and a Logout button that emits `logout` (the app clears its session).
|
|
39
|
+
*/
|
|
40
|
+
import { computed } from 'vue'
|
|
41
|
+
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
|
42
|
+
import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/24/outline'
|
|
43
|
+
import BaseButton from './BaseButton.vue'
|
|
44
|
+
import { BaseButtonEnum } from '../enums/BaseButtonEnum'
|
|
45
|
+
import { BaseButtonSizeEnum } from '../enums/BaseButtonSizeEnum'
|
|
46
|
+
|
|
47
|
+
interface Props {
|
|
48
|
+
name: string
|
|
49
|
+
email?: string
|
|
50
|
+
/** Profile route; makes the name/email block a link. */
|
|
51
|
+
to?: RouteLocationRaw
|
|
52
|
+
showLogout?: boolean
|
|
53
|
+
logoutLabel?: string
|
|
54
|
+
profileLabel?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
58
|
+
email: '',
|
|
59
|
+
to: undefined,
|
|
60
|
+
showLogout: true,
|
|
61
|
+
logoutLabel: 'Logout',
|
|
62
|
+
profileLabel: 'View profile',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{ (e: 'logout'): void }>()
|
|
66
|
+
|
|
67
|
+
const initials = computed(() =>
|
|
68
|
+
(props.name || '?')
|
|
69
|
+
.split(/[\s._-]+/)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.slice(0, 2)
|
|
72
|
+
.map((p) => p[0]!.toUpperCase())
|
|
73
|
+
.join(''),
|
|
74
|
+
)
|
|
75
|
+
</script>
|
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import { computed } from 'vue'
|
|
26
|
-
|
|
27
|
-
type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
28
|
-
type SpinnerColor =
|
|
29
|
-
| 'blue'
|
|
30
|
-
| 'emerald'
|
|
31
|
-
| 'sky'
|
|
32
|
-
| 'indigo'
|
|
33
|
-
| 'teal'
|
|
34
|
-
| 'purple'
|
|
35
|
-
| 'red'
|
|
36
|
-
| 'amber'
|
|
37
|
-
|
|
38
|
-
const props = withDefaults(
|
|
39
|
-
defineProps<{
|
|
40
|
-
size?: SpinnerSize
|
|
41
|
-
color?: SpinnerColor
|
|
42
|
-
}>(),
|
|
43
|
-
{
|
|
44
|
-
size: 'sm',
|
|
45
|
-
color: '
|
|
46
|
-
},
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
// Diameter pairs with a proportional ring thickness. Full literal strings
|
|
50
|
-
// so Tailwind detects them at build time.
|
|
51
|
-
const sizeClass = computed(() => {
|
|
52
|
-
const sizes: Record<SpinnerSize, string> = {
|
|
53
|
-
sm: 'w-4 h-4 border-2',
|
|
54
|
-
md: 'w-6 h-6 border-2',
|
|
55
|
-
lg: 'w-8 h-8 border-4',
|
|
56
|
-
xl: 'w-12 h-12 border-4',
|
|
57
|
-
}
|
|
58
|
-
return sizes[props.size]
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
// Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
|
|
62
|
-
// the neutral gray track from the template.
|
|
63
|
-
const colorClass = computed(() => {
|
|
64
|
-
const colors: Record<SpinnerColor, string> = {
|
|
65
|
-
blue: 'border-t-blue-500',
|
|
66
|
-
emerald: 'border-t-emerald-500',
|
|
67
|
-
sky: 'border-t-sky-500',
|
|
68
|
-
indigo: 'border-t-indigo-500',
|
|
69
|
-
teal: 'border-t-teal-500',
|
|
70
|
-
purple: 'border-t-purple-500',
|
|
71
|
-
red: 'border-t-red-500',
|
|
72
|
-
amber: 'border-t-amber-500',
|
|
73
|
-
}
|
|
74
|
-
return colors[props.color]
|
|
75
|
-
})
|
|
76
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="border-gray-200 dark:border-slate-700 rounded-full animate-spin"
|
|
4
|
+
:class="[sizeClass, colorClass]"
|
|
5
|
+
role="status"
|
|
6
|
+
aria-label="Loading"
|
|
7
|
+
></div>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
/**
|
|
12
|
+
* Animated loading spinner — a spinning ring with a neutral track and a
|
|
13
|
+
* coloured leading arc.
|
|
14
|
+
*
|
|
15
|
+
* Props:
|
|
16
|
+
* • size — diameter + ring thickness. 'sm' (16px) is the default and
|
|
17
|
+
* reproduces the original spinner; 'md' (24px), 'lg' (32px) and
|
|
18
|
+
* 'xl' (48px) step up for full-page / empty-state loaders.
|
|
19
|
+
* • color — the colour of the spinning arc (`border-t-*`). The track
|
|
20
|
+
* stays neutral gray. Defaults to 'emerald' (the brand green).
|
|
21
|
+
*
|
|
22
|
+
* With no props it renders the small (16px) emerald spinner.
|
|
23
|
+
*
|
|
24
|
+
*/
|
|
25
|
+
import { computed } from 'vue'
|
|
26
|
+
|
|
27
|
+
type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
28
|
+
type SpinnerColor =
|
|
29
|
+
| 'blue'
|
|
30
|
+
| 'emerald'
|
|
31
|
+
| 'sky'
|
|
32
|
+
| 'indigo'
|
|
33
|
+
| 'teal'
|
|
34
|
+
| 'purple'
|
|
35
|
+
| 'red'
|
|
36
|
+
| 'amber'
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
size?: SpinnerSize
|
|
41
|
+
color?: SpinnerColor
|
|
42
|
+
}>(),
|
|
43
|
+
{
|
|
44
|
+
size: 'sm',
|
|
45
|
+
color: 'emerald',
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Diameter pairs with a proportional ring thickness. Full literal strings
|
|
50
|
+
// so Tailwind detects them at build time.
|
|
51
|
+
const sizeClass = computed(() => {
|
|
52
|
+
const sizes: Record<SpinnerSize, string> = {
|
|
53
|
+
sm: 'w-4 h-4 border-2',
|
|
54
|
+
md: 'w-6 h-6 border-2',
|
|
55
|
+
lg: 'w-8 h-8 border-4',
|
|
56
|
+
xl: 'w-12 h-12 border-4',
|
|
57
|
+
}
|
|
58
|
+
return sizes[props.size]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Only the leading arc (`border-t-*`) is tinted; the rest of the ring is
|
|
62
|
+
// the neutral gray track from the template.
|
|
63
|
+
const colorClass = computed(() => {
|
|
64
|
+
const colors: Record<SpinnerColor, string> = {
|
|
65
|
+
blue: 'border-t-blue-500',
|
|
66
|
+
emerald: 'border-t-emerald-500',
|
|
67
|
+
sky: 'border-t-sky-500',
|
|
68
|
+
indigo: 'border-t-indigo-500',
|
|
69
|
+
teal: 'border-t-teal-500',
|
|
70
|
+
purple: 'border-t-purple-500',
|
|
71
|
+
red: 'border-t-red-500',
|
|
72
|
+
amber: 'border-t-amber-500',
|
|
73
|
+
}
|
|
74
|
+
return colors[props.color]
|
|
75
|
+
})
|
|
76
|
+
</script>
|