mgv-backoffice 1.36.0 → 1.36.2

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 (60) hide show
  1. package/README.md +1794 -1719
  2. package/dist/components/BaseBadge.vue.d.ts +1 -11
  3. package/dist/components/BaseDropdown.vue.d.ts +1 -1
  4. package/dist/ui-lib.js +942 -925
  5. package/dist/ui-lib.umd.cjs +1 -1
  6. package/dist/utils/specForm.d.ts +1 -1
  7. package/package.json +54 -54
  8. package/src/components/BaseAlert.vue +104 -104
  9. package/src/components/BaseBadge.vue +12 -2
  10. package/src/components/BaseBarDistribution.test.ts +51 -51
  11. package/src/components/BaseBarDistribution.vue +73 -73
  12. package/src/components/BaseChipButton.vue +42 -42
  13. package/src/components/BaseCodeBlock.test.ts +70 -70
  14. package/src/components/BaseCodeBlock.vue +74 -74
  15. package/src/components/BaseCollapsibleSection.vue +113 -113
  16. package/src/components/BaseConfirmModal.vue +114 -114
  17. package/src/components/BaseCredentialsForm.test.ts +108 -108
  18. package/src/components/BaseCredentialsForm.vue +241 -241
  19. package/src/components/BaseDropdown.test.ts +87 -87
  20. package/src/components/BaseDropdown.vue +172 -172
  21. package/src/components/BaseEntityPickerModal.vue +330 -330
  22. package/src/components/BaseFileDropzone.vue +94 -94
  23. package/src/components/BaseFilterChip.test.ts +44 -44
  24. package/src/components/BaseFilterChip.vue +67 -67
  25. package/src/components/BaseModalShell.vue +209 -209
  26. package/src/components/BasePageHeader.vue +120 -120
  27. package/src/components/BasePillPickerModal.test.ts +95 -95
  28. package/src/components/BaseRemoveButton.vue +22 -22
  29. package/src/components/BaseSpecFields.test.ts +39 -0
  30. package/src/components/BaseSpecFields.vue +27 -3
  31. package/src/components/BaseStatBreakdown.test.ts +56 -56
  32. package/src/components/BaseStatBreakdown.vue +49 -49
  33. package/src/components/BaseStatusPill.vue +54 -54
  34. package/src/components/BaseTextInputModal.vue +140 -140
  35. package/src/components/BaseToolbarButton.test.ts +48 -48
  36. package/src/components/BaseToolbarButton.vue +94 -94
  37. package/src/composables/useFieldClasses.test.ts +55 -55
  38. package/src/composables/useFieldClasses.ts +56 -56
  39. package/src/composables/usePolling.test.ts +112 -112
  40. package/src/composables/usePolling.ts +102 -102
  41. package/src/composables/useQueryParamSync.test.ts +56 -56
  42. package/src/composables/useQueryParamSync.ts +42 -42
  43. package/src/composables/useThemeClasses.test.ts +34 -34
  44. package/src/composables/useThemeClasses.ts +137 -137
  45. package/src/index.ts +138 -138
  46. package/src/style.css +32 -32
  47. package/src/types/distribution.ts +7 -7
  48. package/src/types/pillPicker.ts +14 -14
  49. package/src/types/specField.ts +30 -30
  50. package/src/types/statBreakdown.ts +6 -6
  51. package/src/utils/format.test.ts +264 -264
  52. package/src/utils/format.ts +216 -216
  53. package/src/utils/httpColors.test.ts +100 -100
  54. package/src/utils/httpColors.ts +99 -99
  55. package/src/utils/kvRows.test.ts +47 -47
  56. package/src/utils/kvRows.ts +34 -34
  57. package/src/utils/specForm.test.ts +64 -53
  58. package/src/utils/specForm.ts +49 -48
  59. package/src/utils/validate.test.ts +82 -82
  60. package/src/utils/validate.ts +76 -76
@@ -1,42 +1,42 @@
1
- <template>
2
- <!--
3
- Small tinted "chip" action button — the compact emerald "+ Add" pill
4
- used above repeatable form rows (extracted from WireMate's CreateMock,
5
- where the identical class string appeared seven times).
6
-
7
- Label comes from the default slot so callers can mix text and icons.
8
- -->
9
- <button
10
- type="button"
11
- :disabled="disabled"
12
- class="inline-flex items-center gap-1 font-medium rounded-md border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
13
- :class="[
14
- size === 'xs' ? 'text-xs px-2 py-0.5' : 'text-xs px-2.5 py-1',
15
- isDark
16
- ? 'text-emerald-300 border-emerald-700 bg-emerald-900/30 hover:bg-emerald-800/50'
17
- : 'text-emerald-700 border-emerald-200 bg-emerald-50 hover:bg-emerald-100',
18
- ]"
19
- @click="emit('click')"
20
- >
21
- <slot />
22
- </button>
23
- </template>
24
-
25
- <script lang="ts" setup>
26
- import { useTheme } from '../composables/useTheme'
27
-
28
- interface Props {
29
- /** 'sm' = px-2.5 py-1 (default); 'xs' = px-2 py-0.5 for tight corners. */
30
- size?: 'xs' | 'sm'
31
- disabled?: boolean
32
- }
33
-
34
- withDefaults(defineProps<Props>(), {
35
- size: 'sm',
36
- disabled: false,
37
- })
38
-
39
- const emit = defineEmits<{ click: [] }>()
40
-
41
- const { isDark } = useTheme()
42
- </script>
1
+ <template>
2
+ <!--
3
+ Small tinted "chip" action button — the compact emerald "+ Add" pill
4
+ used above repeatable form rows (extracted from WireMate's CreateMock,
5
+ where the identical class string appeared seven times).
6
+
7
+ Label comes from the default slot so callers can mix text and icons.
8
+ -->
9
+ <button
10
+ type="button"
11
+ :disabled="disabled"
12
+ class="inline-flex items-center gap-1 font-medium rounded-md border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
13
+ :class="[
14
+ size === 'xs' ? 'text-xs px-2 py-0.5' : 'text-xs px-2.5 py-1',
15
+ isDark
16
+ ? 'text-emerald-300 border-emerald-700 bg-emerald-900/30 hover:bg-emerald-800/50'
17
+ : 'text-emerald-700 border-emerald-200 bg-emerald-50 hover:bg-emerald-100',
18
+ ]"
19
+ @click="emit('click')"
20
+ >
21
+ <slot />
22
+ </button>
23
+ </template>
24
+
25
+ <script lang="ts" setup>
26
+ import { useTheme } from '../composables/useTheme'
27
+
28
+ interface Props {
29
+ /** 'sm' = px-2.5 py-1 (default); 'xs' = px-2 py-0.5 for tight corners. */
30
+ size?: 'xs' | 'sm'
31
+ disabled?: boolean
32
+ }
33
+
34
+ withDefaults(defineProps<Props>(), {
35
+ size: 'sm',
36
+ disabled: false,
37
+ })
38
+
39
+ const emit = defineEmits<{ click: [] }>()
40
+
41
+ const { isDark } = useTheme()
42
+ </script>
@@ -1,70 +1,70 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { mount } from '@vue/test-utils'
3
- import BaseCodeBlock from './BaseCodeBlock.vue'
4
- import { useTheme } from '../composables/useTheme'
5
-
6
- function ensureLight() {
7
- const { isDark, toggleTheme } = useTheme()
8
- if (isDark.value) toggleTheme()
9
- }
10
-
11
- describe('BaseCodeBlock', () => {
12
- beforeEach(() => {
13
- ensureLight()
14
- })
15
-
16
- it('renders the code verbatim inside a <pre>', () => {
17
- const code = '{\n "a": 1\n}'
18
- const wrapper = mount(BaseCodeBlock, { props: { code } })
19
- const pre = wrapper.find('pre')
20
- expect(pre.exists()).toBe(true)
21
- expect(pre.text()).toBe(code)
22
- expect(pre.classes()).toContain('font-mono')
23
- expect(pre.classes()).toContain('overflow-auto')
24
- })
25
-
26
- it('defaults to the soft variant at size sm', () => {
27
- const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
28
- const classes = wrapper.find('pre').classes()
29
- expect(classes).toContain('bg-gray-100')
30
- expect(classes).toContain('text-sm')
31
- expect(classes).toContain('px-5')
32
- expect(classes).not.toContain('border')
33
- })
34
-
35
- it('renders the bordered variant with a border and card fill', () => {
36
- const wrapper = mount(BaseCodeBlock, {
37
- props: { code: 'x', variant: 'bordered' },
38
- })
39
- const classes = wrapper.find('pre').classes()
40
- expect(classes).toContain('border')
41
- expect(classes).toContain('bg-white')
42
- expect(classes).toContain('border-gray-200')
43
- })
44
-
45
- it('renders dense padding at size xs', () => {
46
- const wrapper = mount(BaseCodeBlock, { props: { code: 'x', size: 'xs' } })
47
- const classes = wrapper.find('pre').classes()
48
- expect(classes).toContain('text-xs')
49
- expect(classes).toContain('p-3')
50
- })
51
-
52
- it('applies maxHeightClass and merges fallthrough classes', () => {
53
- const wrapper = mount(BaseCodeBlock, {
54
- props: { code: 'x', maxHeightClass: 'max-h-96' },
55
- attrs: { class: 'mx-4' },
56
- })
57
- const classes = wrapper.find('pre').classes()
58
- expect(classes).toContain('max-h-96')
59
- expect(classes).toContain('mx-4')
60
- })
61
-
62
- it('switches palette when the theme flips', async () => {
63
- const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
64
- const { toggleTheme } = useTheme()
65
- toggleTheme()
66
- await wrapper.vm.$nextTick()
67
- expect(wrapper.find('pre').classes()).toContain('bg-gray-800/70')
68
- toggleTheme()
69
- })
70
- })
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseCodeBlock from './BaseCodeBlock.vue'
4
+ import { useTheme } from '../composables/useTheme'
5
+
6
+ function ensureLight() {
7
+ const { isDark, toggleTheme } = useTheme()
8
+ if (isDark.value) toggleTheme()
9
+ }
10
+
11
+ describe('BaseCodeBlock', () => {
12
+ beforeEach(() => {
13
+ ensureLight()
14
+ })
15
+
16
+ it('renders the code verbatim inside a <pre>', () => {
17
+ const code = '{\n "a": 1\n}'
18
+ const wrapper = mount(BaseCodeBlock, { props: { code } })
19
+ const pre = wrapper.find('pre')
20
+ expect(pre.exists()).toBe(true)
21
+ expect(pre.text()).toBe(code)
22
+ expect(pre.classes()).toContain('font-mono')
23
+ expect(pre.classes()).toContain('overflow-auto')
24
+ })
25
+
26
+ it('defaults to the soft variant at size sm', () => {
27
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
28
+ const classes = wrapper.find('pre').classes()
29
+ expect(classes).toContain('bg-gray-100')
30
+ expect(classes).toContain('text-sm')
31
+ expect(classes).toContain('px-5')
32
+ expect(classes).not.toContain('border')
33
+ })
34
+
35
+ it('renders the bordered variant with a border and card fill', () => {
36
+ const wrapper = mount(BaseCodeBlock, {
37
+ props: { code: 'x', variant: 'bordered' },
38
+ })
39
+ const classes = wrapper.find('pre').classes()
40
+ expect(classes).toContain('border')
41
+ expect(classes).toContain('bg-white')
42
+ expect(classes).toContain('border-gray-200')
43
+ })
44
+
45
+ it('renders dense padding at size xs', () => {
46
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x', size: 'xs' } })
47
+ const classes = wrapper.find('pre').classes()
48
+ expect(classes).toContain('text-xs')
49
+ expect(classes).toContain('p-3')
50
+ })
51
+
52
+ it('applies maxHeightClass and merges fallthrough classes', () => {
53
+ const wrapper = mount(BaseCodeBlock, {
54
+ props: { code: 'x', maxHeightClass: 'max-h-96' },
55
+ attrs: { class: 'mx-4' },
56
+ })
57
+ const classes = wrapper.find('pre').classes()
58
+ expect(classes).toContain('max-h-96')
59
+ expect(classes).toContain('mx-4')
60
+ })
61
+
62
+ it('switches palette when the theme flips', async () => {
63
+ const wrapper = mount(BaseCodeBlock, { props: { code: 'x' } })
64
+ const { toggleTheme } = useTheme()
65
+ toggleTheme()
66
+ await wrapper.vm.$nextTick()
67
+ expect(wrapper.find('pre').classes()).toContain('bg-gray-800/70')
68
+ toggleTheme()
69
+ })
70
+ })
@@ -1,74 +1,74 @@
1
- <template>
2
- <!--
3
- Themed monospace <pre> for JSON payloads, request dumps and code
4
- snippets — extracted from WireMate, where the same class soup was
5
- hand-rolled on every stub/request detail view.
6
-
7
- Variants:
8
- • soft — tinted fill, no border (bg-gray-800/70 / bg-gray-100).
9
- The in-card look used under a section heading.
10
- • bordered — bordered card fill (bg-gray-900 / bg-white). The
11
- standalone look used when the block sits directly on the page.
12
-
13
- Sizing:
14
- • sm — text-sm with roomy padding, for primary content blocks.
15
- • xs — text-xs with tight padding, for dense/secondary dumps.
16
-
17
- `max-height-class` caps tall payloads (e.g. 'max-h-96'); the block
18
- scrolls both axes. Extra classes (margins etc.) fall through via the
19
- normal class attr merge.
20
- -->
21
- <pre
22
- class="font-mono overflow-auto"
23
- :class="[sizeClass, paletteClass, maxHeightClass]"
24
- >{{ code }}</pre>
25
- </template>
26
-
27
- <script setup lang="ts">
28
- import { computed } from 'vue'
29
- import { useTheme } from '../composables/useTheme'
30
-
31
- const props = withDefaults(
32
- defineProps<{
33
- /** The raw text to render. Whitespace is preserved verbatim. */
34
- code: string
35
- /** Visual style: tinted fill ('soft') or bordered card ('bordered'). */
36
- variant?: 'soft' | 'bordered'
37
- /** Text size + padding: 'sm' (roomy) or 'xs' (dense). */
38
- size?: 'sm' | 'xs'
39
- /** Optional Tailwind max-height utility, e.g. 'max-h-96'. */
40
- maxHeightClass?: string
41
- }>(),
42
- {
43
- variant: 'soft',
44
- size: 'sm',
45
- maxHeightClass: '',
46
- },
47
- )
48
-
49
- const { isDark } = useTheme()
50
-
51
- // Full literal class strings so Tailwind detects them at build time.
52
- const PALETTES: Record<'soft' | 'bordered', { dark: string; light: string }> = {
53
- soft: {
54
- dark: 'bg-gray-800/70 text-gray-300',
55
- light: 'bg-gray-100 text-gray-700',
56
- },
57
- bordered: {
58
- dark: 'border bg-gray-900 border-gray-700 text-gray-300',
59
- light: 'border bg-white border-gray-200 text-gray-700',
60
- },
61
- }
62
-
63
- const SIZES: Record<'sm' | 'xs', string> = {
64
- sm: 'text-sm px-5 py-4 rounded-lg',
65
- xs: 'text-xs p-3 rounded-lg',
66
- }
67
-
68
- const paletteClass = computed(() => {
69
- const entry = PALETTES[props.variant]
70
- return isDark.value ? entry.dark : entry.light
71
- })
72
-
73
- const sizeClass = computed(() => SIZES[props.size])
74
- </script>
1
+ <template>
2
+ <!--
3
+ Themed monospace <pre> for JSON payloads, request dumps and code
4
+ snippets — extracted from WireMate, where the same class soup was
5
+ hand-rolled on every stub/request detail view.
6
+
7
+ Variants:
8
+ • soft — tinted fill, no border (bg-gray-800/70 / bg-gray-100).
9
+ The in-card look used under a section heading.
10
+ • bordered — bordered card fill (bg-gray-900 / bg-white). The
11
+ standalone look used when the block sits directly on the page.
12
+
13
+ Sizing:
14
+ • sm — text-sm with roomy padding, for primary content blocks.
15
+ • xs — text-xs with tight padding, for dense/secondary dumps.
16
+
17
+ `max-height-class` caps tall payloads (e.g. 'max-h-96'); the block
18
+ scrolls both axes. Extra classes (margins etc.) fall through via the
19
+ normal class attr merge.
20
+ -->
21
+ <pre
22
+ class="font-mono overflow-auto"
23
+ :class="[sizeClass, paletteClass, maxHeightClass]"
24
+ >{{ code }}</pre>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed } from 'vue'
29
+ import { useTheme } from '../composables/useTheme'
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ /** The raw text to render. Whitespace is preserved verbatim. */
34
+ code: string
35
+ /** Visual style: tinted fill ('soft') or bordered card ('bordered'). */
36
+ variant?: 'soft' | 'bordered'
37
+ /** Text size + padding: 'sm' (roomy) or 'xs' (dense). */
38
+ size?: 'sm' | 'xs'
39
+ /** Optional Tailwind max-height utility, e.g. 'max-h-96'. */
40
+ maxHeightClass?: string
41
+ }>(),
42
+ {
43
+ variant: 'soft',
44
+ size: 'sm',
45
+ maxHeightClass: '',
46
+ },
47
+ )
48
+
49
+ const { isDark } = useTheme()
50
+
51
+ // Full literal class strings so Tailwind detects them at build time.
52
+ const PALETTES: Record<'soft' | 'bordered', { dark: string; light: string }> = {
53
+ soft: {
54
+ dark: 'bg-gray-800/70 text-gray-300',
55
+ light: 'bg-gray-100 text-gray-700',
56
+ },
57
+ bordered: {
58
+ dark: 'border bg-gray-900 border-gray-700 text-gray-300',
59
+ light: 'border bg-white border-gray-200 text-gray-700',
60
+ },
61
+ }
62
+
63
+ const SIZES: Record<'sm' | 'xs', string> = {
64
+ sm: 'text-sm px-5 py-4 rounded-lg',
65
+ xs: 'text-xs p-3 rounded-lg',
66
+ }
67
+
68
+ const paletteClass = computed(() => {
69
+ const entry = PALETTES[props.variant]
70
+ return isDark.value ? entry.dark : entry.light
71
+ })
72
+
73
+ const sizeClass = computed(() => SIZES[props.size])
74
+ </script>
@@ -1,113 +1,113 @@
1
- <template>
2
- <!--
3
- Collapsible section wrapper. Parent owns the `collapsed` state so
4
- callers can persist it across edits or batch-collapse many sections
5
- at once.
6
-
7
- Slots:
8
- • default — the section body (only rendered when expanded)
9
-
10
- Props:
11
- • title — heading text
12
- • collapsed — current collapsed state
13
- • badge — optional pill next to the heading (label or count)
14
- • bodyClass — override classes for the body container. Defaults
15
- to a theme-aware white/gray-800 surface.
16
-
17
- Emits:
18
- • toggle — fired when the user clicks the header. The parent
19
- flips its own collapsed state in response.
20
- -->
21
- <!--
22
- NOTE: no `overflow-hidden` on the section — it would clip floating
23
- menus (BaseDropdown) rendered near the bottom of the body. The child
24
- corners are rounded explicitly instead so the card silhouette is
25
- unchanged.
26
- -->
27
- <section class="rounded-xl border transition-colors" :class="t.cardAlt">
28
- <button
29
- type="button"
30
- class="w-full flex items-center justify-between p-4 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
31
- :class="[headerClasses, collapsed ? 'rounded-xl' : 'rounded-t-xl']"
32
- :aria-expanded="!collapsed"
33
- @click="emit('toggle')"
34
- >
35
- <div class="flex items-center gap-3">
36
- <h2 class="text-base font-semibold" :class="headingClasses">{{ title }}</h2>
37
- <span
38
- v-if="badge"
39
- class="text-xs px-2 py-0.5 rounded-full"
40
- :class="badgeClasses"
41
- >
42
- {{ badge }}
43
- </span>
44
- </div>
45
- <svg
46
- class="w-5 h-5 transition-transform"
47
- :class="[collapsed ? '' : 'rotate-180', t.dimTextAlt]"
48
- fill="none"
49
- viewBox="0 0 24 24"
50
- stroke="currentColor"
51
- stroke-width="2"
52
- aria-hidden="true"
53
- >
54
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
55
- </svg>
56
- </button>
57
- <div
58
- v-show="!collapsed"
59
- class="p-6 border-t rounded-b-xl"
60
- :class="[t.border, resolvedBodyClass]"
61
- >
62
- <slot />
63
- </div>
64
- </section>
65
- </template>
66
-
67
- <script setup lang="ts">
68
- import { computed } from 'vue'
69
- import { useTheme } from '../composables/useTheme'
70
- import { useThemeClasses } from '../composables/useThemeClasses'
71
-
72
- interface Props {
73
- title: string
74
- collapsed: boolean
75
- /**
76
- * Optional pill next to the title. String so callers can pass either
77
- * a label ('Active') or a count ('3'). Null/undefined hides the pill.
78
- */
79
- badge?: string | null
80
- /**
81
- * Override the body container's background classes. Defaults to a
82
- * theme-aware surface: bg-white in light, bg-gray-800 in dark.
83
- */
84
- bodyClass?: string
85
- }
86
-
87
- const props = defineProps<Props>()
88
- const emit = defineEmits<{ toggle: [] }>()
89
-
90
- const { isDark } = useTheme()
91
- const t = useThemeClasses()
92
-
93
- const headingClasses = computed(() =>
94
- isDark.value ? 'text-gray-100' : 'text-gray-700',
95
- )
96
-
97
- const headerClasses = computed(() =>
98
- isDark.value
99
- ? 'bg-gray-800/50 hover:bg-gray-800 border-gray-700'
100
- : 'bg-gray-50 hover:bg-gray-100 border-gray-200',
101
- )
102
-
103
- const badgeClasses = computed(() =>
104
- isDark.value
105
- ? 'bg-emerald-500/15 text-emerald-400'
106
- : 'bg-emerald-50 text-emerald-700',
107
- )
108
-
109
- const resolvedBodyClass = computed(() => {
110
- if (props.bodyClass !== undefined) return props.bodyClass
111
- return isDark.value ? 'bg-gray-800' : 'bg-white'
112
- })
113
- </script>
1
+ <template>
2
+ <!--
3
+ Collapsible section wrapper. Parent owns the `collapsed` state so
4
+ callers can persist it across edits or batch-collapse many sections
5
+ at once.
6
+
7
+ Slots:
8
+ • default — the section body (only rendered when expanded)
9
+
10
+ Props:
11
+ • title — heading text
12
+ • collapsed — current collapsed state
13
+ • badge — optional pill next to the heading (label or count)
14
+ • bodyClass — override classes for the body container. Defaults
15
+ to a theme-aware white/gray-800 surface.
16
+
17
+ Emits:
18
+ • toggle — fired when the user clicks the header. The parent
19
+ flips its own collapsed state in response.
20
+ -->
21
+ <!--
22
+ NOTE: no `overflow-hidden` on the section — it would clip floating
23
+ menus (BaseDropdown) rendered near the bottom of the body. The child
24
+ corners are rounded explicitly instead so the card silhouette is
25
+ unchanged.
26
+ -->
27
+ <section class="rounded-xl border transition-colors" :class="t.cardAlt">
28
+ <button
29
+ type="button"
30
+ class="w-full flex items-center justify-between p-4 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
31
+ :class="[headerClasses, collapsed ? 'rounded-xl' : 'rounded-t-xl']"
32
+ :aria-expanded="!collapsed"
33
+ @click="emit('toggle')"
34
+ >
35
+ <div class="flex items-center gap-3">
36
+ <h2 class="text-base font-semibold" :class="headingClasses">{{ title }}</h2>
37
+ <span
38
+ v-if="badge"
39
+ class="text-xs px-2 py-0.5 rounded-full"
40
+ :class="badgeClasses"
41
+ >
42
+ {{ badge }}
43
+ </span>
44
+ </div>
45
+ <svg
46
+ class="w-5 h-5 transition-transform"
47
+ :class="[collapsed ? '' : 'rotate-180', t.dimTextAlt]"
48
+ fill="none"
49
+ viewBox="0 0 24 24"
50
+ stroke="currentColor"
51
+ stroke-width="2"
52
+ aria-hidden="true"
53
+ >
54
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
55
+ </svg>
56
+ </button>
57
+ <div
58
+ v-show="!collapsed"
59
+ class="p-6 border-t rounded-b-xl"
60
+ :class="[t.border, resolvedBodyClass]"
61
+ >
62
+ <slot />
63
+ </div>
64
+ </section>
65
+ </template>
66
+
67
+ <script setup lang="ts">
68
+ import { computed } from 'vue'
69
+ import { useTheme } from '../composables/useTheme'
70
+ import { useThemeClasses } from '../composables/useThemeClasses'
71
+
72
+ interface Props {
73
+ title: string
74
+ collapsed: boolean
75
+ /**
76
+ * Optional pill next to the title. String so callers can pass either
77
+ * a label ('Active') or a count ('3'). Null/undefined hides the pill.
78
+ */
79
+ badge?: string | null
80
+ /**
81
+ * Override the body container's background classes. Defaults to a
82
+ * theme-aware surface: bg-white in light, bg-gray-800 in dark.
83
+ */
84
+ bodyClass?: string
85
+ }
86
+
87
+ const props = defineProps<Props>()
88
+ const emit = defineEmits<{ toggle: [] }>()
89
+
90
+ const { isDark } = useTheme()
91
+ const t = useThemeClasses()
92
+
93
+ const headingClasses = computed(() =>
94
+ isDark.value ? 'text-gray-100' : 'text-gray-700',
95
+ )
96
+
97
+ const headerClasses = computed(() =>
98
+ isDark.value
99
+ ? 'bg-gray-800/50 hover:bg-gray-800 border-gray-700'
100
+ : 'bg-gray-50 hover:bg-gray-100 border-gray-200',
101
+ )
102
+
103
+ const badgeClasses = computed(() =>
104
+ isDark.value
105
+ ? 'bg-emerald-500/15 text-emerald-400'
106
+ : 'bg-emerald-50 text-emerald-700',
107
+ )
108
+
109
+ const resolvedBodyClass = computed(() => {
110
+ if (props.bodyClass !== undefined) return props.bodyClass
111
+ return isDark.value ? 'bg-gray-800' : 'bg-white'
112
+ })
113
+ </script>