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.
Files changed (47) hide show
  1. package/README.md +147 -4
  2. package/dist/components/BaseAuthLayout.vue.d.ts +27 -0
  3. package/dist/components/BaseBooleanBadge.vue.d.ts +12 -0
  4. package/dist/components/BaseCard.vue.d.ts +33 -0
  5. package/dist/components/BaseDetailList.vue.d.ts +13 -0
  6. package/dist/components/BaseField.vue.d.ts +38 -0
  7. package/dist/components/BasePageHeader.vue.d.ts +3 -0
  8. package/dist/components/BaseSearchSelect.vue.d.ts +41 -0
  9. package/dist/components/BaseStatCard.vue.d.ts +33 -0
  10. package/dist/components/BaseTable.vue.d.ts +3 -0
  11. package/dist/components/BaseToggle.vue.d.ts +33 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/types/detail.d.ts +10 -0
  14. package/dist/ui-lib.css +1 -1
  15. package/dist/ui-lib.js +1388 -881
  16. package/dist/ui-lib.umd.cjs +1 -1
  17. package/package.json +1 -1
  18. package/src/components/BaseAuthLayout.test.ts +48 -0
  19. package/src/components/BaseAuthLayout.vue +50 -0
  20. package/src/components/BaseBooleanBadge.vue +33 -0
  21. package/src/components/BaseBreadcrumb.vue +68 -68
  22. package/src/components/BaseButton.vue +1 -1
  23. package/src/components/BaseCard.test.ts +55 -0
  24. package/src/components/BaseCard.vue +48 -0
  25. package/src/components/BaseDetailList.test.ts +36 -0
  26. package/src/components/BaseDetailList.vue +43 -0
  27. package/src/components/BaseField.vue +47 -0
  28. package/src/components/BaseInput.test.ts +1 -1
  29. package/src/components/BaseInput.vue +46 -46
  30. package/src/components/BaseLogo.test.ts +26 -0
  31. package/src/components/BaseLogo.vue +7 -1
  32. package/src/components/BasePageHeader.vue +13 -2
  33. package/src/components/BaseSearchSelect.test.ts +117 -0
  34. package/src/components/BaseSearchSelect.vue +318 -0
  35. package/src/components/BaseSelect.test.ts +3 -3
  36. package/src/components/BaseSelect.vue +62 -46
  37. package/src/components/BaseStatCard.test.ts +40 -0
  38. package/src/components/BaseStatCard.vue +103 -0
  39. package/src/components/BaseTable.test.ts +69 -55
  40. package/src/components/BaseTable.vue +62 -50
  41. package/src/components/BaseToggle.test.ts +39 -0
  42. package/src/components/BaseToggle.vue +73 -0
  43. package/src/components/Pagination.test.ts +93 -85
  44. package/src/components/Pagination.vue +87 -82
  45. package/src/index.ts +9 -0
  46. package/src/style.css +58 -58
  47. 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
- Styling shell for data tables — NOT a data grid. Owns the table skin
4
- (slate header band, px-4 py-3 header cells, empty-state row); rows are
5
- the caller's own <tr> markup via the default slot, so cell content and
6
- per-cell styling stay fully in the caller's hands.
7
-
8
- Wrap it yourself for scrolling/card chrome (e.g. inside a BaseRow with
9
- overflow-x-auto). Same `dark:`-variant note as BaseInput.
10
- -->
11
- <table class="w-full text-sm">
12
- <thead class="bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">
13
- <tr class="text-left">
14
- <th
15
- v-for="(col, i) in columns"
16
- :key="i"
17
- class="px-4 py-3"
18
- :class="col.align === 'right' ? 'text-right' : ''"
19
- >
20
- {{ col.label }}
21
- </th>
22
- </tr>
23
- </thead>
24
- <tbody>
25
- <tr v-if="empty">
26
- <td :colspan="columns.length" class="px-4 py-12 text-center text-slate-500">
27
- <slot name="empty">{{ emptyText }}</slot>
28
- </td>
29
- </tr>
30
- <slot />
31
- </tbody>
32
- </table>
33
- </template>
34
-
35
- <script setup lang="ts">
36
- import type { TableColumn } from '../types/table'
37
-
38
- interface Props {
39
- columns: TableColumn[]
40
- /** True when there are no rows — renders the empty-state row. */
41
- empty?: boolean
42
- /** Fallback empty-state text; the `empty` slot overrides it. */
43
- emptyText?: string
44
- }
45
-
46
- withDefaults(defineProps<Props>(), {
47
- empty: false,
48
- emptyText: 'No rows.',
49
- })
50
- </script>
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>