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,55 +1,55 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { useFieldClasses } from './useFieldClasses'
3
- import { useTheme } from './useTheme'
4
-
5
- // useTheme is a module-level singleton — grab its ref once and flip it
6
- // per test. localStorage is cleared so a previous test's persisted theme
7
- // can't leak into the initial value.
8
- beforeEach(() => {
9
- localStorage.clear()
10
- })
11
-
12
- describe('useFieldClasses', () => {
13
- it('returns the light-mode strings when isDark is false', () => {
14
- const { isDark } = useTheme()
15
- isDark.value = false
16
- const f = useFieldClasses()
17
- expect(f.label.value).toBe('text-gray-600')
18
- expect(f.input.value).toContain('bg-white')
19
- expect(f.input.value).toContain('focus:ring-emerald-500')
20
- })
21
-
22
- it('returns the dark-mode strings when isDark is true', () => {
23
- const { isDark } = useTheme()
24
- isDark.value = true
25
- const f = useFieldClasses()
26
- expect(f.label.value).toBe('text-gray-300')
27
- expect(f.input.value).toContain('bg-gray-800')
28
- })
29
-
30
- it('requiredInput flags empty values with the red skin', () => {
31
- const { isDark } = useTheme()
32
- isDark.value = false
33
- const f = useFieldClasses()
34
- expect(f.requiredInput('')).toContain('border-red-400')
35
- expect(f.requiredInput(' ')).toContain('border-red-400')
36
- expect(f.requiredInput(undefined)).toContain('border-red-400')
37
- expect(f.requiredInput(null)).toContain('border-red-400')
38
- })
39
-
40
- it('requiredInput falls back to the standard skin for filled values', () => {
41
- const { isDark } = useTheme()
42
- isDark.value = false
43
- const f = useFieldClasses()
44
- expect(f.requiredInput('hello')).toBe(f.input.value)
45
- expect(f.requiredInput(0)).toBe(f.input.value) // 0 is a real value, not empty
46
- })
47
-
48
- it('requiredInput follows the active theme', () => {
49
- const { isDark } = useTheme()
50
- isDark.value = true
51
- const f = useFieldClasses()
52
- expect(f.requiredInput('')).toContain('bg-gray-800')
53
- expect(f.requiredInput('')).toContain('border-red-500')
54
- })
55
- })
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useFieldClasses } from './useFieldClasses'
3
+ import { useTheme } from './useTheme'
4
+
5
+ // useTheme is a module-level singleton — grab its ref once and flip it
6
+ // per test. localStorage is cleared so a previous test's persisted theme
7
+ // can't leak into the initial value.
8
+ beforeEach(() => {
9
+ localStorage.clear()
10
+ })
11
+
12
+ describe('useFieldClasses', () => {
13
+ it('returns the light-mode strings when isDark is false', () => {
14
+ const { isDark } = useTheme()
15
+ isDark.value = false
16
+ const f = useFieldClasses()
17
+ expect(f.label.value).toBe('text-gray-600')
18
+ expect(f.input.value).toContain('bg-white')
19
+ expect(f.input.value).toContain('focus:ring-emerald-500')
20
+ })
21
+
22
+ it('returns the dark-mode strings when isDark is true', () => {
23
+ const { isDark } = useTheme()
24
+ isDark.value = true
25
+ const f = useFieldClasses()
26
+ expect(f.label.value).toBe('text-gray-300')
27
+ expect(f.input.value).toContain('bg-gray-800')
28
+ })
29
+
30
+ it('requiredInput flags empty values with the red skin', () => {
31
+ const { isDark } = useTheme()
32
+ isDark.value = false
33
+ const f = useFieldClasses()
34
+ expect(f.requiredInput('')).toContain('border-red-400')
35
+ expect(f.requiredInput(' ')).toContain('border-red-400')
36
+ expect(f.requiredInput(undefined)).toContain('border-red-400')
37
+ expect(f.requiredInput(null)).toContain('border-red-400')
38
+ })
39
+
40
+ it('requiredInput falls back to the standard skin for filled values', () => {
41
+ const { isDark } = useTheme()
42
+ isDark.value = false
43
+ const f = useFieldClasses()
44
+ expect(f.requiredInput('hello')).toBe(f.input.value)
45
+ expect(f.requiredInput(0)).toBe(f.input.value) // 0 is a real value, not empty
46
+ })
47
+
48
+ it('requiredInput follows the active theme', () => {
49
+ const { isDark } = useTheme()
50
+ isDark.value = true
51
+ const f = useFieldClasses()
52
+ expect(f.requiredInput('')).toContain('bg-gray-800')
53
+ expect(f.requiredInput('')).toContain('border-red-500')
54
+ })
55
+ })
@@ -1,56 +1,56 @@
1
- import { computed, type ComputedRef } from 'vue'
2
- import { useTheme } from './useTheme'
3
-
4
- /**
5
- * Shared form-field class strings for the gray/emerald form skin
6
- * (the WireMate mock-form family). Extracted because the exact same
7
- * `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
8
- * copy-pasted across every form section component.
9
- *
10
- * Usage:
11
- *
12
- * const f = useFieldClasses()
13
- * <label :class="f.label">Name</label>
14
- * <input :class="f.input" />
15
- * <input :class="f.requiredInput(value)" /> // red skin while empty
16
- *
17
- * Vue auto-unwraps the computed refs in template bindings; `requiredInput`
18
- * is a plain function and is called with the current field value.
19
- */
20
- export interface FieldClasses {
21
- /** Form label: gray-300 / gray-600. */
22
- label: ComputedRef<string>
23
- /** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
24
- input: ComputedRef<string>
25
- /**
26
- * Input skin for required fields: red border + ring while the value is
27
- * empty (undefined / null / blank after trim), the standard skin otherwise.
28
- */
29
- requiredInput: (value: string | number | undefined | null) => string
30
- }
31
-
32
- export function useFieldClasses(): FieldClasses {
33
- const { isDark } = useTheme()
34
-
35
- const label = computed(() =>
36
- isDark.value ? 'text-gray-300' : 'text-gray-600',
37
- )
38
-
39
- const input = computed(() =>
40
- isDark.value
41
- ? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
42
- : 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
43
- )
44
-
45
- function requiredInput(value: string | number | undefined | null): string {
46
- const isEmpty = value === undefined || value === null || String(value).trim() === ''
47
- if (isEmpty) {
48
- return isDark.value
49
- ? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
50
- : 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
51
- }
52
- return input.value
53
- }
54
-
55
- return { label, input, requiredInput }
56
- }
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import { useTheme } from './useTheme'
3
+
4
+ /**
5
+ * Shared form-field class strings for the gray/emerald form skin
6
+ * (the WireMate mock-form family). Extracted because the exact same
7
+ * `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
8
+ * copy-pasted across every form section component.
9
+ *
10
+ * Usage:
11
+ *
12
+ * const f = useFieldClasses()
13
+ * <label :class="f.label">Name</label>
14
+ * <input :class="f.input" />
15
+ * <input :class="f.requiredInput(value)" /> // red skin while empty
16
+ *
17
+ * Vue auto-unwraps the computed refs in template bindings; `requiredInput`
18
+ * is a plain function and is called with the current field value.
19
+ */
20
+ export interface FieldClasses {
21
+ /** Form label: gray-300 / gray-600. */
22
+ label: ComputedRef<string>
23
+ /** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
24
+ input: ComputedRef<string>
25
+ /**
26
+ * Input skin for required fields: red border + ring while the value is
27
+ * empty (undefined / null / blank after trim), the standard skin otherwise.
28
+ */
29
+ requiredInput: (value: string | number | undefined | null) => string
30
+ }
31
+
32
+ export function useFieldClasses(): FieldClasses {
33
+ const { isDark } = useTheme()
34
+
35
+ const label = computed(() =>
36
+ isDark.value ? 'text-gray-300' : 'text-gray-600',
37
+ )
38
+
39
+ const input = computed(() =>
40
+ isDark.value
41
+ ? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
42
+ : 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
43
+ )
44
+
45
+ function requiredInput(value: string | number | undefined | null): string {
46
+ const isEmpty = value === undefined || value === null || String(value).trim() === ''
47
+ if (isEmpty) {
48
+ return isDark.value
49
+ ? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
50
+ : 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
51
+ }
52
+ return input.value
53
+ }
54
+
55
+ return { label, input, requiredInput }
56
+ }
@@ -1,112 +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
- })
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
+ })
@@ -1,102 +1,102 @@
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
- // True only when the visibility handler paused an ACTIVE loop. A loop the
39
- // caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
40
- // stopped across hide/show cycles instead of being silently restarted.
41
- let suspendedByVisibility = false
42
-
43
- function startTimer() {
44
- if (timer !== null || intervalMs === null) return
45
- timer = setInterval(() => void fn(), intervalMs)
46
- }
47
-
48
- function stopTimer() {
49
- if (timer !== null) {
50
- clearInterval(timer)
51
- timer = null
52
- }
53
- }
54
-
55
- function start() {
56
- suspendedByVisibility = false
57
- if (active.value) return
58
- active.value = true
59
- if (immediate) void fn()
60
- startTimer()
61
- }
62
-
63
- function stop() {
64
- suspendedByVisibility = false
65
- active.value = false
66
- stopTimer()
67
- }
68
-
69
- function onVisibilityChange() {
70
- if (document.visibilityState === 'visible') {
71
- // Only resume a loop THIS handler paused — a caller-stopped loop
72
- // stays stopped. Refresh right away so stale data doesn't linger
73
- // for a full interval after the user returns, then re-arm the timer.
74
- if (!suspendedByVisibility) return
75
- suspendedByVisibility = false
76
- active.value = true
77
- void fn()
78
- startTimer()
79
- } else {
80
- if (!active.value) return
81
- active.value = false
82
- stopTimer()
83
- suspendedByVisibility = true
84
- }
85
- }
86
-
87
- onMounted(() => {
88
- start()
89
- if (pauseWhenHidden) {
90
- document.addEventListener('visibilitychange', onVisibilityChange)
91
- }
92
- })
93
-
94
- onUnmounted(() => {
95
- stop()
96
- if (pauseWhenHidden) {
97
- document.removeEventListener('visibilitychange', onVisibilityChange)
98
- }
99
- })
100
-
101
- return { start, stop, active: readonly(active) }
102
- }
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
+ // True only when the visibility handler paused an ACTIVE loop. A loop the
39
+ // caller stopped explicitly (stop(), e.g. an auto-refresh toggle) must stay
40
+ // stopped across hide/show cycles instead of being silently restarted.
41
+ let suspendedByVisibility = false
42
+
43
+ function startTimer() {
44
+ if (timer !== null || intervalMs === null) return
45
+ timer = setInterval(() => void fn(), intervalMs)
46
+ }
47
+
48
+ function stopTimer() {
49
+ if (timer !== null) {
50
+ clearInterval(timer)
51
+ timer = null
52
+ }
53
+ }
54
+
55
+ function start() {
56
+ suspendedByVisibility = false
57
+ if (active.value) return
58
+ active.value = true
59
+ if (immediate) void fn()
60
+ startTimer()
61
+ }
62
+
63
+ function stop() {
64
+ suspendedByVisibility = false
65
+ active.value = false
66
+ stopTimer()
67
+ }
68
+
69
+ function onVisibilityChange() {
70
+ if (document.visibilityState === 'visible') {
71
+ // Only resume a loop THIS handler paused — a caller-stopped loop
72
+ // stays stopped. Refresh right away so stale data doesn't linger
73
+ // for a full interval after the user returns, then re-arm the timer.
74
+ if (!suspendedByVisibility) return
75
+ suspendedByVisibility = false
76
+ active.value = true
77
+ void fn()
78
+ startTimer()
79
+ } else {
80
+ if (!active.value) return
81
+ active.value = false
82
+ stopTimer()
83
+ suspendedByVisibility = true
84
+ }
85
+ }
86
+
87
+ onMounted(() => {
88
+ start()
89
+ if (pauseWhenHidden) {
90
+ document.addEventListener('visibilitychange', onVisibilityChange)
91
+ }
92
+ })
93
+
94
+ onUnmounted(() => {
95
+ stop()
96
+ if (pauseWhenHidden) {
97
+ document.removeEventListener('visibilitychange', onVisibilityChange)
98
+ }
99
+ })
100
+
101
+ return { start, stop, active: readonly(active) }
102
+ }