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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount, RouterLinkStub } from '@vue/test-utils'
|
|
3
|
+
import BaseStatCard from './BaseStatCard.vue'
|
|
4
|
+
|
|
5
|
+
const global = { stubs: { RouterLink: RouterLinkStub } }
|
|
6
|
+
|
|
7
|
+
describe('BaseStatCard', () => {
|
|
8
|
+
it('renders title, value and subtitle with the dashed accent border', () => {
|
|
9
|
+
const wrapper = mount(BaseStatCard, {
|
|
10
|
+
props: { title: 'Categories', value: 71, subtitle: 'in total', accent: 'sky' },
|
|
11
|
+
global,
|
|
12
|
+
})
|
|
13
|
+
expect(wrapper.text()).toContain('Categories')
|
|
14
|
+
expect(wrapper.text()).toContain('71')
|
|
15
|
+
expect(wrapper.text()).toContain('in total')
|
|
16
|
+
expect(wrapper.classes()).toEqual(expect.arrayContaining(['border-dashed', 'border-sky-300']))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is a plain div without `to` and a router link with it', () => {
|
|
20
|
+
expect(mount(BaseStatCard, { props: { title: 'A' }, global }).element.tagName).toBe('DIV')
|
|
21
|
+
const link = mount(BaseStatCard, { props: { title: 'A', to: '/category' }, global })
|
|
22
|
+
expect(link.findComponent(RouterLinkStub).props('to')).toBe('/category')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders the icon chip only when the icon slot is filled', () => {
|
|
26
|
+
const plain = mount(BaseStatCard, { props: { title: 'A' }, global })
|
|
27
|
+
expect(plain.find('.rounded-xl .rounded-xl').exists()).toBe(false)
|
|
28
|
+
const withIcon = mount(BaseStatCard, {
|
|
29
|
+
props: { title: 'A', accent: 'red' },
|
|
30
|
+
slots: { icon: '<template #icon="{ iconClass }"><i :class="iconClass" /></template>' },
|
|
31
|
+
global,
|
|
32
|
+
})
|
|
33
|
+
expect(withIcon.find('i').classes()).toEqual(expect.arrayContaining(['h-6', 'w-6', 'text-red-500']))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('omits the subtitle line when empty', () => {
|
|
37
|
+
const wrapper = mount(BaseStatCard, { props: { title: 'A', value: 1 }, global })
|
|
38
|
+
expect(wrapper.findAll('p')).toHaveLength(2)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="to ? RouterLink : 'div'"
|
|
4
|
+
v-bind="to ? { to } : {}"
|
|
5
|
+
class="relative block rounded-xl border-2 border-dashed bg-white p-5 transition-colors dark:bg-slate-800"
|
|
6
|
+
:class="[cls.border, to ? 'hover:bg-slate-50 dark:hover:bg-slate-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500' : '']"
|
|
7
|
+
>
|
|
8
|
+
<div class="flex items-start justify-between gap-3">
|
|
9
|
+
<div class="min-w-0 text-left">
|
|
10
|
+
<p class="truncate text-sm font-bold tracking-wide text-gray-800 dark:text-slate-200">
|
|
11
|
+
{{ title }}
|
|
12
|
+
</p>
|
|
13
|
+
<p class="mt-1 truncate text-3xl font-bold" :class="cls.value">
|
|
14
|
+
{{ value }}
|
|
15
|
+
</p>
|
|
16
|
+
<p v-if="subtitle" class="mt-1 text-sm font-medium" :class="cls.subtitle">
|
|
17
|
+
{{ subtitle }}
|
|
18
|
+
</p>
|
|
19
|
+
<slot />
|
|
20
|
+
</div>
|
|
21
|
+
<div
|
|
22
|
+
v-if="$slots.icon"
|
|
23
|
+
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
|
|
24
|
+
:class="cls.iconBox"
|
|
25
|
+
>
|
|
26
|
+
<slot name="icon" :icon-class="`h-6 w-6 ${cls.icon}`" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</component>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
/**
|
|
34
|
+
* Dashboard stat tile in the EarningsCard style (dashed accent border,
|
|
35
|
+
* bold title, big value, accent subtitle, tinted icon chip) but for any
|
|
36
|
+
* value — counts, labels, pre-formatted numbers. Pass `to` to make the whole
|
|
37
|
+
* card a router link. The `icon` slot fills the chip (receives `iconClass`);
|
|
38
|
+
* the `default` slot adds extra content under the subtitle.
|
|
39
|
+
*
|
|
40
|
+
*/
|
|
41
|
+
import { computed } from 'vue'
|
|
42
|
+
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
|
43
|
+
|
|
44
|
+
type Accent = 'emerald' | 'sky' | 'amber' | 'red' | 'slate'
|
|
45
|
+
|
|
46
|
+
interface Props {
|
|
47
|
+
title: string
|
|
48
|
+
value?: string | number
|
|
49
|
+
subtitle?: string
|
|
50
|
+
/** Border / value / subtitle / icon tint. */
|
|
51
|
+
accent?: Accent
|
|
52
|
+
/** Makes the card a router link. */
|
|
53
|
+
to?: RouteLocationRaw
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
57
|
+
value: '',
|
|
58
|
+
subtitle: '',
|
|
59
|
+
accent: 'emerald',
|
|
60
|
+
to: undefined,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Full literal class strings per accent so Tailwind can see every class.
|
|
64
|
+
const ACCENTS: Record<Accent, { border: string; value: string; subtitle: string; iconBox: string; icon: string }> = {
|
|
65
|
+
emerald: {
|
|
66
|
+
border: 'border-emerald-300 dark:border-emerald-700',
|
|
67
|
+
value: 'text-emerald-500 dark:text-emerald-400',
|
|
68
|
+
subtitle: 'text-emerald-500 dark:text-emerald-400',
|
|
69
|
+
iconBox: 'bg-emerald-50 dark:bg-emerald-900/30',
|
|
70
|
+
icon: 'text-emerald-500',
|
|
71
|
+
},
|
|
72
|
+
sky: {
|
|
73
|
+
border: 'border-sky-300 dark:border-sky-700',
|
|
74
|
+
value: 'text-sky-500 dark:text-sky-400',
|
|
75
|
+
subtitle: 'text-sky-500 dark:text-sky-400',
|
|
76
|
+
iconBox: 'bg-sky-50 dark:bg-sky-900/30',
|
|
77
|
+
icon: 'text-sky-500',
|
|
78
|
+
},
|
|
79
|
+
amber: {
|
|
80
|
+
border: 'border-amber-300 dark:border-amber-700',
|
|
81
|
+
value: 'text-amber-500 dark:text-amber-400',
|
|
82
|
+
subtitle: 'text-amber-500 dark:text-amber-400',
|
|
83
|
+
iconBox: 'bg-amber-50 dark:bg-amber-900/30',
|
|
84
|
+
icon: 'text-amber-500',
|
|
85
|
+
},
|
|
86
|
+
red: {
|
|
87
|
+
border: 'border-red-300 dark:border-red-700',
|
|
88
|
+
value: 'text-red-500 dark:text-red-400',
|
|
89
|
+
subtitle: 'text-red-500 dark:text-red-400',
|
|
90
|
+
iconBox: 'bg-red-50 dark:bg-red-900/30',
|
|
91
|
+
icon: 'text-red-500',
|
|
92
|
+
},
|
|
93
|
+
slate: {
|
|
94
|
+
border: 'border-slate-300 dark:border-slate-600',
|
|
95
|
+
value: 'text-slate-700 dark:text-slate-200',
|
|
96
|
+
subtitle: 'text-slate-500 dark:text-slate-400',
|
|
97
|
+
iconBox: 'bg-slate-100 dark:bg-slate-700/50',
|
|
98
|
+
icon: 'text-slate-500 dark:text-slate-300',
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cls = computed(() => ACCENTS[props.accent])
|
|
103
|
+
</script>
|
|
@@ -1,55 +1,69 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { mount } from '@vue/test-utils'
|
|
3
|
-
import BaseTable from './BaseTable.vue'
|
|
4
|
-
import type { TableColumn } from '../types/table'
|
|
5
|
-
|
|
6
|
-
const columns: TableColumn[] = [
|
|
7
|
-
{ label: 'Symbol' },
|
|
8
|
-
{ label: 'Qty', align: 'right' },
|
|
9
|
-
{ label: 'Bought' },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
describe('BaseTable', () => {
|
|
13
|
-
it('renders header cells with right-alignment where requested', () => {
|
|
14
|
-
const wrapper = mount(BaseTable, { props: { columns } })
|
|
15
|
-
const ths = wrapper.findAll('th')
|
|
16
|
-
expect(ths.map((th) => th.text())).toEqual(['Symbol', 'Qty', 'Bought'])
|
|
17
|
-
expect(ths[1].classes()).toContain('text-right')
|
|
18
|
-
expect(ths[0].classes()).not.toContain('text-right')
|
|
19
|
-
expect(ths[0].classes()).toEqual(expect.arrayContaining(['px-4', 'py-3']))
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('renders caller rows through the default slot', () => {
|
|
23
|
-
const wrapper = mount(BaseTable, {
|
|
24
|
-
props: { columns },
|
|
25
|
-
slots: { default: '<tr><td>AMD</td><td>3</td><td>today</td></tr>' },
|
|
26
|
-
})
|
|
27
|
-
expect(wrapper.find('tbody td').text()).toBe('AMD')
|
|
28
|
-
expect(wrapper.find('table').classes()).toEqual(expect.arrayContaining(['w-full', 'text-sm']))
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('renders the empty-state row spanning every column when empty', () => {
|
|
32
|
-
const wrapper = mount(BaseTable, {
|
|
33
|
-
props: { columns, empty: true, emptyText: 'Nothing here.' },
|
|
34
|
-
})
|
|
35
|
-
const td = wrapper.find('tbody td')
|
|
36
|
-
expect(td.attributes('colspan')).toBe('3')
|
|
37
|
-
expect(td.text()).toBe('Nothing here.')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('prefers the empty slot over emptyText', () => {
|
|
41
|
-
const wrapper = mount(BaseTable, {
|
|
42
|
-
props: { columns, empty: true, emptyText: 'fallback' },
|
|
43
|
-
slots: { empty: 'No trades match your filters.' },
|
|
44
|
-
})
|
|
45
|
-
expect(wrapper.find('tbody td').text()).toBe('No trades match your filters.')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('renders no empty-state row when not empty', () => {
|
|
49
|
-
const wrapper = mount(BaseTable, {
|
|
50
|
-
props: { columns, empty: false },
|
|
51
|
-
slots: { default: '<tr><td>row</td></tr>' },
|
|
52
|
-
})
|
|
53
|
-
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
|
54
|
-
})
|
|
55
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseTable from './BaseTable.vue'
|
|
4
|
+
import type { TableColumn } from '../types/table'
|
|
5
|
+
|
|
6
|
+
const columns: TableColumn[] = [
|
|
7
|
+
{ label: 'Symbol' },
|
|
8
|
+
{ label: 'Qty', align: 'right' },
|
|
9
|
+
{ label: 'Bought' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('BaseTable', () => {
|
|
13
|
+
it('renders header cells with right-alignment where requested', () => {
|
|
14
|
+
const wrapper = mount(BaseTable, { props: { columns } })
|
|
15
|
+
const ths = wrapper.findAll('th')
|
|
16
|
+
expect(ths.map((th) => th.text())).toEqual(['Symbol', 'Qty', 'Bought'])
|
|
17
|
+
expect(ths[1].classes()).toContain('text-right')
|
|
18
|
+
expect(ths[0].classes()).not.toContain('text-right')
|
|
19
|
+
expect(ths[0].classes()).toEqual(expect.arrayContaining(['px-4', 'py-3']))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders caller rows through the default slot', () => {
|
|
23
|
+
const wrapper = mount(BaseTable, {
|
|
24
|
+
props: { columns },
|
|
25
|
+
slots: { default: '<tr><td>AMD</td><td>3</td><td>today</td></tr>' },
|
|
26
|
+
})
|
|
27
|
+
expect(wrapper.find('tbody td').text()).toBe('AMD')
|
|
28
|
+
expect(wrapper.find('table').classes()).toEqual(expect.arrayContaining(['w-full', 'text-sm']))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders the empty-state row spanning every column when empty', () => {
|
|
32
|
+
const wrapper = mount(BaseTable, {
|
|
33
|
+
props: { columns, empty: true, emptyText: 'Nothing here.' },
|
|
34
|
+
})
|
|
35
|
+
const td = wrapper.find('tbody td')
|
|
36
|
+
expect(td.attributes('colspan')).toBe('3')
|
|
37
|
+
expect(td.text()).toBe('Nothing here.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('prefers the empty slot over emptyText', () => {
|
|
41
|
+
const wrapper = mount(BaseTable, {
|
|
42
|
+
props: { columns, empty: true, emptyText: 'fallback' },
|
|
43
|
+
slots: { empty: 'No trades match your filters.' },
|
|
44
|
+
})
|
|
45
|
+
expect(wrapper.find('tbody td').text()).toBe('No trades match your filters.')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders no empty-state row when not empty', () => {
|
|
49
|
+
const wrapper = mount(BaseTable, {
|
|
50
|
+
props: { columns, empty: false },
|
|
51
|
+
slots: { default: '<tr><td>row</td></tr>' },
|
|
52
|
+
})
|
|
53
|
+
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('has no card chrome by default and adds it with `card`', () => {
|
|
57
|
+
const plain = mount(BaseTable, { props: { columns } })
|
|
58
|
+
expect(plain.classes()).not.toContain('border')
|
|
59
|
+
const card = mount(BaseTable, { props: { columns, card: true } })
|
|
60
|
+
expect(card.classes()).toEqual(
|
|
61
|
+
expect.arrayContaining(['overflow-x-auto', 'rounded-xl', 'border']),
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('draws row dividers on the body', () => {
|
|
66
|
+
const wrapper = mount(BaseTable, { props: { columns } })
|
|
67
|
+
expect(wrapper.find('tbody').classes()).toEqual(expect.arrayContaining(['divide-y']))
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -1,50 +1,62 @@
|
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="card ? CARD_CLASS : undefined">
|
|
3
|
+
<!--
|
|
4
|
+
Styling shell for data tables — NOT a data grid. Owns the table skin
|
|
5
|
+
(slate header band, px-4 py-3 header cells, row dividers + hover,
|
|
6
|
+
empty-state row); rows are the caller's own <tr> markup via the default
|
|
7
|
+
slot, so cell content and per-cell styling stay fully in the caller's hands.
|
|
8
|
+
|
|
9
|
+
`card` adds the shared card chrome (rounded border, surface, horizontal
|
|
10
|
+
scroll). Leave it off when the caller already wraps the table itself
|
|
11
|
+
(e.g. inside a BaseRow with overflow-x-auto). Same `dark:`-variant note
|
|
12
|
+
as BaseInput.
|
|
13
|
+
-->
|
|
14
|
+
<table class="w-full text-sm">
|
|
15
|
+
<thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
|
|
16
|
+
<tr class="text-left border-b border-slate-200 dark:border-slate-700">
|
|
17
|
+
<th
|
|
18
|
+
v-for="(col, i) in columns"
|
|
19
|
+
:key="i"
|
|
20
|
+
class="px-4 py-3"
|
|
21
|
+
:class="col.align === 'right' ? 'text-right' : ''"
|
|
22
|
+
>
|
|
23
|
+
{{ col.label }}
|
|
24
|
+
</th>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody
|
|
28
|
+
class="divide-y divide-slate-200 dark:divide-slate-700 [&>tr]:transition-colors [&>tr:hover]:bg-slate-50 dark:[&>tr:hover]:bg-slate-800/40"
|
|
29
|
+
>
|
|
30
|
+
<tr v-if="empty">
|
|
31
|
+
<td :colspan="columns.length" class="px-4 py-12 text-center text-slate-500">
|
|
32
|
+
<slot name="empty">{{ emptyText }}</slot>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<slot />
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
import type { TableColumn } from '../types/table'
|
|
43
|
+
|
|
44
|
+
const CARD_CLASS =
|
|
45
|
+
'overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900'
|
|
46
|
+
|
|
47
|
+
interface Props {
|
|
48
|
+
columns: TableColumn[]
|
|
49
|
+
/** True when there are no rows — renders the empty-state row. */
|
|
50
|
+
empty?: boolean
|
|
51
|
+
/** Fallback empty-state text; the `empty` slot overrides it. */
|
|
52
|
+
emptyText?: string
|
|
53
|
+
/** Wrap the table in the rounded, bordered, horizontally scrolling card. */
|
|
54
|
+
card?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
withDefaults(defineProps<Props>(), {
|
|
58
|
+
empty: false,
|
|
59
|
+
emptyText: 'No rows.',
|
|
60
|
+
card: false,
|
|
61
|
+
})
|
|
62
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseToggle from './BaseToggle.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseToggle', () => {
|
|
6
|
+
it('reflects the value in aria-checked and the track colour', () => {
|
|
7
|
+
const off = mount(BaseToggle, { props: { modelValue: false } })
|
|
8
|
+
expect(off.find('[role=switch]').attributes('aria-checked')).toBe('false')
|
|
9
|
+
expect(off.find('[role=switch]').classes()).toContain('bg-slate-200')
|
|
10
|
+
|
|
11
|
+
const on = mount(BaseToggle, { props: { modelValue: true, color: 'red' } })
|
|
12
|
+
expect(on.find('[role=switch]').attributes('aria-checked')).toBe('true')
|
|
13
|
+
expect(on.find('[role=switch]').classes()).toContain('bg-red-600')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('uses the red off track with offTone="red"', () => {
|
|
17
|
+
const w = mount(BaseToggle, { props: { modelValue: false, offTone: 'red' } })
|
|
18
|
+
expect(w.find('[role=switch]').classes()).toContain('bg-red-200')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('emits update:modelValue and change with the flipped value', async () => {
|
|
22
|
+
const w = mount(BaseToggle, { props: { modelValue: false } })
|
|
23
|
+
await w.find('[role=switch]').trigger('click')
|
|
24
|
+
expect(w.emitted('update:modelValue')).toEqual([[true]])
|
|
25
|
+
expect(w.emitted('change')).toEqual([[true]])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('does nothing while disabled', async () => {
|
|
29
|
+
const w = mount(BaseToggle, { props: { modelValue: true, disabled: true } })
|
|
30
|
+
await w.find('[role=switch]').trigger('click')
|
|
31
|
+
expect(w.emitted('update:modelValue')).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders the label, or uses ariaLabel when there is none', () => {
|
|
35
|
+
expect(mount(BaseToggle, { props: { label: 'Crawler' } }).text()).toBe('Crawler')
|
|
36
|
+
const w = mount(BaseToggle, { props: { ariaLabel: 'Alive' } })
|
|
37
|
+
expect(w.find('[role=switch]').attributes('aria-label')).toBe('Alive')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<label
|
|
3
|
+
class="inline-flex items-center gap-3 select-none"
|
|
4
|
+
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
|
5
|
+
>
|
|
6
|
+
<button
|
|
7
|
+
type="button"
|
|
8
|
+
role="switch"
|
|
9
|
+
:aria-checked="modelValue"
|
|
10
|
+
:aria-label="label ? undefined : ariaLabel || undefined"
|
|
11
|
+
:disabled="disabled"
|
|
12
|
+
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900 disabled:cursor-not-allowed"
|
|
13
|
+
:class="modelValue ? ON[color] : offTone === 'red' ? 'bg-red-200 dark:bg-red-900/50' : 'bg-slate-200 dark:bg-slate-700'"
|
|
14
|
+
@click="toggle"
|
|
15
|
+
>
|
|
16
|
+
<span
|
|
17
|
+
class="inline-block h-5 w-5 rounded-full bg-white shadow ring-1 ring-black/5 transition-transform"
|
|
18
|
+
:class="modelValue ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
/>
|
|
21
|
+
</button>
|
|
22
|
+
<span v-if="label" class="text-sm font-medium text-slate-700 dark:text-slate-200">{{ label }}</span>
|
|
23
|
+
</label>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
/**
|
|
28
|
+
* On/off switch (role="switch"). `v-model` is the boolean; `change` fires
|
|
29
|
+
* after every user toggle with the new value, so callers can persist it.
|
|
30
|
+
* `color` tints the on state; `offTone="red"` shows a red track while off
|
|
31
|
+
* (e.g. "account is down" states).
|
|
32
|
+
*/
|
|
33
|
+
type Color = 'emerald' | 'red' | 'sky' | 'amber'
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
modelValue?: boolean
|
|
37
|
+
/** Visible label beside the switch (also its accessible name). */
|
|
38
|
+
label?: string
|
|
39
|
+
/** Accessible name when there is no visible label. */
|
|
40
|
+
ariaLabel?: string
|
|
41
|
+
color?: Color
|
|
42
|
+
offTone?: 'slate' | 'red'
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
47
|
+
modelValue: false,
|
|
48
|
+
label: '',
|
|
49
|
+
ariaLabel: '',
|
|
50
|
+
color: 'emerald',
|
|
51
|
+
offTone: 'slate',
|
|
52
|
+
disabled: false,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
(e: 'update:modelValue', value: boolean): void
|
|
57
|
+
(e: 'change', value: boolean): void
|
|
58
|
+
}>()
|
|
59
|
+
|
|
60
|
+
const ON: Record<Color, string> = {
|
|
61
|
+
emerald: 'bg-emerald-500',
|
|
62
|
+
red: 'bg-red-600',
|
|
63
|
+
sky: 'bg-sky-500',
|
|
64
|
+
amber: 'bg-amber-500',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toggle() {
|
|
68
|
+
if (props.disabled) return
|
|
69
|
+
const next = !props.modelValue
|
|
70
|
+
emit('update:modelValue', next)
|
|
71
|
+
emit('change', next)
|
|
72
|
+
}
|
|
73
|
+
</script>
|