mgv-backoffice 1.40.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 +34 -4
- package/dist/components/BaseCheckbox.vue.d.ts +28 -0
- package/dist/components/BaseSidebarUser.vue.d.ts +23 -0
- package/dist/index.d.ts +2 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +701 -620
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- package/src/components/BaseAuthLayout.test.ts +48 -48
- package/src/components/BaseBooleanBadge.vue +32 -33
- package/src/components/BaseCheckbox.test.ts +30 -0
- package/src/components/BaseCheckbox.vue +51 -0
- package/src/components/BaseSidebarUser.test.ts +26 -0
- package/src/components/BaseSidebarUser.vue +75 -0
- package/src/components/BaseSpinner.vue +76 -76
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,48 +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
|
|
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-
|
|
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
|
-
})
|
|
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
|
+
})
|
|
@@ -1,33 +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-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</script>
|
|
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,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>
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export { default as BaseCard } from './components/BaseCard.vue'
|
|
|
61
61
|
export { default as BaseField } from './components/BaseField.vue'
|
|
62
62
|
export { default as BaseStatCard } from './components/BaseStatCard.vue'
|
|
63
63
|
export { default as BaseToggle } from './components/BaseToggle.vue'
|
|
64
|
+
export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
|
|
65
|
+
export { default as BaseSidebarUser } from './components/BaseSidebarUser.vue'
|
|
64
66
|
export { default as BaseAuthLayout } from './components/BaseAuthLayout.vue'
|
|
65
67
|
export { default as BaseBooleanBadge } from './components/BaseBooleanBadge.vue'
|
|
66
68
|
export { default as BaseDetailList } from './components/BaseDetailList.vue'
|