mgv-backoffice 1.33.0 → 1.35.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.
@@ -68,6 +68,18 @@ export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
68
68
  * "2.0 MB"). Returns '' for zero/falsy input.
69
69
  */
70
70
  export declare function fmtBytes(bytes: number | null | undefined): string;
71
+ /**
72
+ * Pretty-print a string that may contain JSON: parseable input is
73
+ * re-serialized with 2-space indentation, anything else is returned
74
+ * verbatim. Handy for request/response bodies of unknown content type.
75
+ */
76
+ export declare function formatJson(content: string): string;
77
+ /**
78
+ * Render an unknown value as a display string: strings pass through,
79
+ * null/undefined become '', everything else is JSON-serialized (falling
80
+ * back to String() for values JSON can't handle, e.g. cycles or BigInt).
81
+ */
82
+ export declare function stringifyValue(value: unknown): string;
71
83
  /**
72
84
  * Format a millisecond duration as compact day/hour/minute units, e.g.
73
85
  * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Pure, dependency-free input validators shared across back-office forms —
3
+ * extracted from WireMate's mock form.
4
+ *
5
+ * Semantics note: the payload validators (`isValidJson`, `isValidXml`,
6
+ * `isValidBase64`) treat empty/whitespace-only input as VALID — callers
7
+ * gate on "is there something to check" separately (required-ness is a
8
+ * different rule from well-formedness). `isValidAbsoluteUrl` is the
9
+ * opposite: it validates a value that must exist, so empty is invalid.
10
+ */
11
+ /**
12
+ * `true` when `value` parses as an absolute http:// or https:// URL.
13
+ * Deliberately tightens the scheme list — backends' HTTP clients won't
14
+ * follow ftp:// or mailto: anyway, so a stray scheme is a typo.
15
+ */
16
+ export declare function isValidAbsoluteUrl(value: string): boolean;
17
+ /**
18
+ * `true` when `str` is empty or parses as JSON.
19
+ */
20
+ export declare function isValidJson(str: string): boolean;
21
+ /**
22
+ * `true` when `str` is empty or is well-formed XML. Runs the browser's
23
+ * DOMParser and checks for a `<parsererror>` node — the only reliable
24
+ * signal DOMParser provides, since it never throws on bad input.
25
+ */
26
+ export declare function isValidXml(str: string): boolean;
27
+ /**
28
+ * `true` when `str` is empty or is well-formed base64 (after stripping
29
+ * whitespace/newlines, which are harmless in both RFC 4648 and most
30
+ * real-world payloads). Rejects lengths that can't possibly decode
31
+ * (`length % 4 !== 0` after padding) — the common "off by a character"
32
+ * copy/paste issue — then defers to the native decoder as the final
33
+ * authority.
34
+ */
35
+ export declare function isValidBase64(str: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -0,0 +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
+ })
@@ -0,0 +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>
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BasePillPickerModal from './BasePillPickerModal.vue'
4
+ import type { PillPickerItem } from '../types/pillPicker'
5
+
6
+ const items: PillPickerItem[] = [
7
+ { id: 'STOCK:AAPL', label: 'AAPL', group: 'STOCK' },
8
+ { id: 'STOCK:MSFT', label: 'MSFT', group: 'STOCK', title: 'Microsoft' },
9
+ { id: 'CRYPTO:BTC/USD', label: 'BTC/USD', group: 'CRYPTO' },
10
+ ]
11
+
12
+ const groups = [
13
+ { value: 'STOCK', label: 'Stock' },
14
+ { value: 'CRYPTO', label: 'Crypto' },
15
+ ]
16
+
17
+ function mountPicker(props: Record<string, unknown> = {}) {
18
+ return mount(BasePillPickerModal, {
19
+ props: { title: 'Pick one', items, ...props },
20
+ global: { stubs: { teleport: true } },
21
+ })
22
+ }
23
+
24
+ // Pills are the buttons inside the flex-wrap grid — not the segmented
25
+ // control's buttons and not the footer Close.
26
+ function pillLabels(wrapper: ReturnType<typeof mountPicker>): string[] {
27
+ return wrapper
28
+ .findAll('.flex.flex-wrap.gap-2 > button')
29
+ .map((b) => b.text())
30
+ }
31
+
32
+ describe('BasePillPickerModal', () => {
33
+ it('renders one pill per item and emits pick with the clicked item', async () => {
34
+ const wrapper = mountPicker()
35
+ expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
36
+ await wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].trigger('click')
37
+ expect(wrapper.emitted('pick')).toEqual([[items[1]]])
38
+ })
39
+
40
+ it('narrows by free text, case-insensitively, and shows the no-match message', async () => {
41
+ const wrapper = mountPicker({ noMatchMessage: 'nothing here' })
42
+ await wrapper.find('input').setValue('btc')
43
+ expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
44
+ await wrapper.find('input').setValue('zzz')
45
+ expect(wrapper.text()).toContain('nothing here')
46
+ })
47
+
48
+ it('renders the group toggle with All prepended and narrows by group', async () => {
49
+ const wrapper = mountPicker({ groups })
50
+ // Re-query before each click — the grid re-render replaces the buttons,
51
+ // so a wrapper captured before a click goes stale.
52
+ const seg = () => wrapper.findAll('[role="group"] button')
53
+ expect(seg().map((b) => b.text())).toEqual(['All', 'Stock', 'Crypto'])
54
+ await seg()[2].trigger('click')
55
+ expect(pillLabels(wrapper)).toEqual(['BTC/USD'])
56
+ await seg()[0].trigger('click')
57
+ expect(pillLabels(wrapper)).toEqual(['AAPL', 'MSFT', 'BTC/USD'])
58
+ })
59
+
60
+ it('hides the group toggle when no groups are passed', () => {
61
+ const wrapper = mountPicker()
62
+ expect(wrapper.find('[role="group"]').exists()).toBe(false)
63
+ })
64
+
65
+ it('shows the empty message instead of the filter row when there are no items', () => {
66
+ const wrapper = mountPicker({ items: [], emptyMessage: 'nothing configured' })
67
+ expect(wrapper.text()).toContain('nothing configured')
68
+ expect(wrapper.find('input').exists()).toBe(false)
69
+ })
70
+
71
+ it('applies the mono font to the input and pills only when asked', () => {
72
+ const mono = mountPicker({ mono: true })
73
+ expect(mono.find('input').classes()).toContain('font-mono')
74
+ expect(mono.findAll('.flex.flex-wrap.gap-2 > button')[0].classes()).toContain('font-mono')
75
+
76
+ const plain = mountPicker()
77
+ expect(plain.find('input').classes()).not.toContain('font-mono')
78
+ })
79
+
80
+ it('forwards an item title to the pill tooltip', () => {
81
+ const wrapper = mountPicker()
82
+ expect(
83
+ wrapper.findAll('.flex.flex-wrap.gap-2 > button')[1].attributes('title'),
84
+ ).toBe('Microsoft')
85
+ })
86
+
87
+ it('emits close from the footer button', async () => {
88
+ const wrapper = mountPicker({ closeText: 'Dismiss' })
89
+ const buttons = wrapper.findAll('button')
90
+ const closeBtn = buttons[buttons.length - 1]
91
+ expect(closeBtn.text()).toBe('Dismiss')
92
+ await closeBtn.trigger('click')
93
+ expect(wrapper.emitted('close')).toHaveLength(1)
94
+ })
95
+ })
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseToolbarButton from './BaseToolbarButton.vue'
4
+
5
+ describe('BaseToolbarButton', () => {
6
+ it('renders the neutral pill shape by default', () => {
7
+ const wrapper = mount(BaseToolbarButton, { props: { label: 'Refresh' } })
8
+ const cls = wrapper.find('button').classes()
9
+ expect(cls).toContain('rounded-lg')
10
+ expect(cls).toContain('py-2')
11
+ expect(cls).toContain('font-medium')
12
+ expect(cls).toContain('disabled:opacity-50')
13
+ expect(wrapper.text()).toBe('Refresh')
14
+ })
15
+
16
+ it('keeps the danger variant on the pill shape with the red palette', () => {
17
+ const wrapper = mount(BaseToolbarButton, {
18
+ props: { label: 'Delete', variant: 'danger' },
19
+ })
20
+ const cls = wrapper.find('button').classes()
21
+ expect(cls).toContain('rounded-lg')
22
+ expect(cls).toContain('bg-red-600')
23
+ })
24
+
25
+ it('renders the ghost variant as the slate h-9 rounded-md outline button', () => {
26
+ const wrapper = mount(BaseToolbarButton, {
27
+ props: { label: 'Close', variant: 'ghost' },
28
+ })
29
+ const cls = wrapper.find('button').classes()
30
+ expect(cls).toContain('h-9')
31
+ expect(cls).toContain('rounded-md')
32
+ expect(cls).toContain('px-4')
33
+ expect(cls).toContain('font-semibold')
34
+ expect(cls).toContain('border-slate-300')
35
+ expect(cls).toContain('disabled:opacity-40')
36
+ expect(cls).not.toContain('rounded-lg')
37
+ expect(cls).not.toContain('py-2')
38
+ })
39
+
40
+ it('emits click and honours disabled', async () => {
41
+ const wrapper = mount(BaseToolbarButton, { props: { label: 'Go' } })
42
+ await wrapper.find('button').trigger('click')
43
+ expect(wrapper.emitted('click')).toHaveLength(1)
44
+
45
+ const disabled = mount(BaseToolbarButton, { props: { label: 'Go', disabled: true } })
46
+ expect(disabled.find('button').attributes('disabled')).toBeDefined()
47
+ })
48
+ })
@@ -11,7 +11,8 @@
11
11
  Props:
12
12
  • label — button text (rendered in a <span>). Optional so an
13
13
  icon-only toolbar button is possible.
14
- • variant — 'neutral' (default grey) or 'danger' (solid red).
14
+ • variant — 'neutral' (default grey), 'danger' (solid red) or
15
+ 'ghost' (slate outline — TradeAutomation's toolbar/footer idiom).
15
16
  • disabled — greys out + blocks the click.
16
17
  • title — native tooltip / accessibility text.
17
18
  • type — native button type. Defaults to 'button' so the
@@ -21,7 +22,7 @@
21
22
  :type="type"
22
23
  :disabled="disabled"
23
24
  :title="title"
24
- class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
25
+ class="inline-flex shrink-0 items-center gap-2 text-sm border transition-colors cursor-pointer disabled:cursor-not-allowed whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
25
26
  :class="variantClass"
26
27
  @click="$emit('click', $event)"
27
28
  >
@@ -34,7 +35,7 @@
34
35
  import { computed } from 'vue'
35
36
  import { useTheme } from '../composables/useTheme'
36
37
 
37
- type ToolbarVariant = 'neutral' | 'danger'
38
+ type ToolbarVariant = 'neutral' | 'danger' | 'ghost'
38
39
 
39
40
  const props = withDefaults(
40
41
  defineProps<{
@@ -59,9 +60,19 @@ defineEmits<{
59
60
 
60
61
  const { isDark } = useTheme()
61
62
 
62
- // Resolves the variant + theme into the colour classes. The danger
63
+ // Resolves the variant + theme into shape and colour classes. The danger
63
64
  // style is normalised here so every destructive toolbar button reads
64
- // identically (red-500 hover in dark, red-700 hover in light).
65
+ // identically (red-500 hover in dark, red-700 hover in light). Shape lives
66
+ // per-variant because 'ghost' — the slate outline button extracted from
67
+ // TradeAutomation's toolbars and modal footers — is a fixed-height
68
+ // rounded-md button, not the neutral/danger pill; it themes via dark:
69
+ // utilities so it renders the same markup in both themes.
70
+ const SHAPE: Record<ToolbarVariant, string> = {
71
+ neutral: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
72
+ danger: 'px-3 py-2 rounded-lg font-medium disabled:opacity-50',
73
+ ghost: 'h-9 px-4 rounded-md font-semibold disabled:opacity-40 disabled:hover:bg-slate-50 dark:disabled:hover:bg-slate-900',
74
+ }
75
+
65
76
  const variantClass = computed(() => {
66
77
  const palette: Record<ToolbarVariant, { dark: string; light: string }> = {
67
78
  neutral: {
@@ -72,8 +83,12 @@ const variantClass = computed(() => {
72
83
  dark: 'bg-red-600 text-white border-red-600 hover:bg-red-500',
73
84
  light: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
74
85
  },
86
+ ghost: {
87
+ dark: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
88
+ light: 'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
89
+ },
75
90
  }
76
91
  const entry = palette[props.variant]
77
- return isDark.value ? entry.dark : entry.light
92
+ return [SHAPE[props.variant], isDark.value ? entry.dark : entry.light]
78
93
  })
79
94
  </script>
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { defineComponent, h, nextTick } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+ import { usePolling, type UsePollingOptions } from './usePolling'
5
+
6
+ function mountWithPolling(
7
+ fn: () => void,
8
+ intervalMs: number | null,
9
+ options?: UsePollingOptions,
10
+ ) {
11
+ let api!: ReturnType<typeof usePolling>
12
+ const wrapper = mount(
13
+ defineComponent({
14
+ setup() {
15
+ api = usePolling(fn, intervalMs, options)
16
+ return () => h('div')
17
+ },
18
+ }),
19
+ )
20
+ return { wrapper, api }
21
+ }
22
+
23
+ function setVisibility(state: 'visible' | 'hidden') {
24
+ Object.defineProperty(document, 'visibilityState', {
25
+ configurable: true,
26
+ get: () => state,
27
+ })
28
+ document.dispatchEvent(new Event('visibilitychange'))
29
+ }
30
+
31
+ describe('usePolling', () => {
32
+ beforeEach(() => {
33
+ vi.useFakeTimers()
34
+ setVisibility('visible')
35
+ })
36
+
37
+ afterEach(() => {
38
+ vi.useRealTimers()
39
+ })
40
+
41
+ it('runs immediately on mount and then on each interval', () => {
42
+ const fn = vi.fn()
43
+ mountWithPolling(fn, 1000)
44
+ expect(fn).toHaveBeenCalledTimes(1)
45
+ vi.advanceTimersByTime(3000)
46
+ expect(fn).toHaveBeenCalledTimes(4)
47
+ })
48
+
49
+ it('skips the initial call when immediate is false', () => {
50
+ const fn = vi.fn()
51
+ mountWithPolling(fn, 1000, { immediate: false })
52
+ expect(fn).not.toHaveBeenCalled()
53
+ vi.advanceTimersByTime(1000)
54
+ expect(fn).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('pauses while hidden and refreshes + resumes on return to visible', async () => {
58
+ const fn = vi.fn()
59
+ mountWithPolling(fn, 1000)
60
+ expect(fn).toHaveBeenCalledTimes(1)
61
+
62
+ setVisibility('hidden')
63
+ await nextTick()
64
+ vi.advanceTimersByTime(5000)
65
+ expect(fn).toHaveBeenCalledTimes(1) // no ticks while hidden
66
+
67
+ setVisibility('visible')
68
+ expect(fn).toHaveBeenCalledTimes(2) // immediate refresh on resume
69
+ vi.advanceTimersByTime(1000)
70
+ expect(fn).toHaveBeenCalledTimes(3) // interval re-armed
71
+ })
72
+
73
+ it('with intervalMs null, runs on mount and on visibility resume only', () => {
74
+ const fn = vi.fn()
75
+ mountWithPolling(fn, null)
76
+ expect(fn).toHaveBeenCalledTimes(1)
77
+ vi.advanceTimersByTime(60000)
78
+ expect(fn).toHaveBeenCalledTimes(1)
79
+
80
+ setVisibility('hidden')
81
+ setVisibility('visible')
82
+ expect(fn).toHaveBeenCalledTimes(2)
83
+ vi.advanceTimersByTime(60000)
84
+ expect(fn).toHaveBeenCalledTimes(2)
85
+ })
86
+
87
+ it('stops on unmount', () => {
88
+ const fn = vi.fn()
89
+ const { wrapper } = mountWithPolling(fn, 1000)
90
+ wrapper.unmount()
91
+ vi.advanceTimersByTime(5000)
92
+ expect(fn).toHaveBeenCalledTimes(1)
93
+ // and the visibility listener is gone too
94
+ setVisibility('hidden')
95
+ setVisibility('visible')
96
+ expect(fn).toHaveBeenCalledTimes(1)
97
+ })
98
+
99
+ it('exposes manual start/stop and an active flag', () => {
100
+ const fn = vi.fn()
101
+ const { api } = mountWithPolling(fn, 1000)
102
+ expect(api.active.value).toBe(true)
103
+ api.stop()
104
+ expect(api.active.value).toBe(false)
105
+ vi.advanceTimersByTime(3000)
106
+ expect(fn).toHaveBeenCalledTimes(1)
107
+ api.start()
108
+ expect(fn).toHaveBeenCalledTimes(2)
109
+ vi.advanceTimersByTime(1000)
110
+ expect(fn).toHaveBeenCalledTimes(3)
111
+ })
112
+ })
@@ -0,0 +1,90 @@
1
+ import { onMounted, onUnmounted, readonly, ref, type Ref } from 'vue'
2
+
3
+ export interface UsePollingOptions {
4
+ /** Run `fn` immediately when polling starts (mount / resume). Default true. */
5
+ immediate?: boolean
6
+ /**
7
+ * Pause the interval while the tab is hidden and refresh + resume when it
8
+ * becomes visible again — a hidden tab has no reason to keep waking the
9
+ * backend. Default true.
10
+ */
11
+ pauseWhenHidden?: boolean
12
+ }
13
+
14
+ /**
15
+ * Visibility-gated polling loop bound to the component lifecycle.
16
+ *
17
+ * Starts on mount, stops on unmount. With `pauseWhenHidden` (the default)
18
+ * the interval is torn down on `document.visibilitychange` → hidden and
19
+ * re-armed — with an immediate refresh — when the tab is shown again.
20
+ *
21
+ * Pass `intervalMs: null` for a refresh-only mode: `fn` runs on mount and
22
+ * on every return-to-visible, but no interval is scheduled. Useful for
23
+ * "reload this badge when the user comes back" data that doesn't warrant
24
+ * a timer.
25
+ *
26
+ * Rejections from an async `fn` are the caller's to handle — catch inside
27
+ * `fn`; the loop itself never swallows or reports them.
28
+ */
29
+ export function usePolling(
30
+ fn: () => void | Promise<void>,
31
+ intervalMs: number | null,
32
+ options: UsePollingOptions = {},
33
+ ): { start: () => void; stop: () => void; active: Readonly<Ref<boolean>> } {
34
+ const { immediate = true, pauseWhenHidden = true } = options
35
+
36
+ let timer: ReturnType<typeof setInterval> | null = null
37
+ const active = ref(false)
38
+
39
+ function startTimer() {
40
+ if (timer !== null || intervalMs === null) return
41
+ timer = setInterval(() => void fn(), intervalMs)
42
+ }
43
+
44
+ function stopTimer() {
45
+ if (timer !== null) {
46
+ clearInterval(timer)
47
+ timer = null
48
+ }
49
+ }
50
+
51
+ function start() {
52
+ if (active.value) return
53
+ active.value = true
54
+ if (immediate) void fn()
55
+ startTimer()
56
+ }
57
+
58
+ function stop() {
59
+ active.value = false
60
+ stopTimer()
61
+ }
62
+
63
+ function onVisibilityChange() {
64
+ if (document.visibilityState === 'visible') {
65
+ // Refresh right away so stale data doesn't linger for a full
66
+ // interval after the user returns, then re-arm the timer.
67
+ active.value = true
68
+ void fn()
69
+ startTimer()
70
+ } else {
71
+ stop()
72
+ }
73
+ }
74
+
75
+ onMounted(() => {
76
+ start()
77
+ if (pauseWhenHidden) {
78
+ document.addEventListener('visibilitychange', onVisibilityChange)
79
+ }
80
+ })
81
+
82
+ onUnmounted(() => {
83
+ stop()
84
+ if (pauseWhenHidden) {
85
+ document.removeEventListener('visibilitychange', onVisibilityChange)
86
+ }
87
+ })
88
+
89
+ return { start, stop, active: readonly(active) }
90
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isReactive } from 'vue'
3
+ import { normalizeClass } from '@vue/shared'
4
+ import { useThemeClasses } from './useThemeClasses'
5
+ import { useTheme } from './useTheme'
6
+
7
+ describe('useThemeClasses', () => {
8
+ it('returns a reactive object whose properties are plain class strings', () => {
9
+ const t = useThemeClasses()
10
+ expect(isReactive(t)).toBe(true)
11
+ expect(typeof t.primaryText).toBe('string')
12
+ expect(t.primaryText).toMatch(/^text-/)
13
+ })
14
+
15
+ it('survives normalizeClass — the regression that leaked ref internals into class attrs', () => {
16
+ // Before the reactive() fix, `:class="t.primaryText"` handed a raw
17
+ // ComputedRef to normalizeClass, which rendered the ref's own keys
18
+ // ("fn dep __v_isRef …") instead of the theme classes.
19
+ const t = useThemeClasses()
20
+ const normalized = normalizeClass(t.primaryText)
21
+ expect(normalized).not.toContain('__v_isRef')
22
+ expect(normalized).toBe(t.primaryText)
23
+ })
24
+
25
+ it('tracks theme flips', () => {
26
+ const { isDark, toggleTheme } = useTheme()
27
+ const t = useThemeClasses()
28
+ const before = t.primaryText
29
+ toggleTheme()
30
+ expect(t.primaryText).not.toBe(before)
31
+ expect(t.primaryText).toBe(isDark.value ? 'text-white' : 'text-gray-900')
32
+ toggleTheme() // restore for other tests sharing the singleton theme
33
+ })
34
+ })