mgv-backoffice 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -1
- package/dist/components/BaseCodeBlock.vue.d.ts +17 -0
- package/dist/composables/usePolling.d.ts +31 -0
- package/dist/composables/useThemeClasses.d.ts +27 -24
- package/dist/index.d.ts +5 -1
- package/dist/ui-lib.css +1 -2
- package/dist/ui-lib.js +722 -607
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +12 -0
- package/dist/utils/validate.d.ts +35 -0
- package/package.json +1 -1
- package/src/components/BaseCodeBlock.test.ts +70 -0
- package/src/components/BaseCodeBlock.vue +74 -0
- package/src/composables/usePolling.test.ts +112 -0
- package/src/composables/usePolling.ts +90 -0
- package/src/composables/useThemeClasses.test.ts +34 -0
- package/src/composables/useThemeClasses.ts +30 -26
- package/src/index.ts +11 -0
- package/src/style.css +22 -0
- package/src/utils/format.test.ts +29 -0
- package/src/utils/format.ts +28 -0
- package/src/utils/validate.test.ts +82 -0
- package/src/utils/validate.ts +76 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -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
|
@@ -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,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
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed,
|
|
1
|
+
import { computed, reactive } from 'vue'
|
|
2
2
|
import { useTheme } from './useTheme'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -14,69 +14,73 @@ import { useTheme } from './useTheme'
|
|
|
14
14
|
* <p :class="t.mutedText">...</p>
|
|
15
15
|
* </div>
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* The returned object is `reactive()`, so property access yields the
|
|
18
|
+
* plain class string while staying reactive to theme flips. (It used to
|
|
19
|
+
* be a plain object of ComputedRefs — Vue does NOT unwrap refs nested in
|
|
20
|
+
* plain objects inside template bindings, so `:class="t.card"` rendered
|
|
21
|
+
* the ref's own keys — `fn dep __v_isRef …` — instead of the classes.)
|
|
22
|
+
* Property access is string-valued now: do not append `.value`.
|
|
19
23
|
*/
|
|
20
24
|
export interface ThemeClasses {
|
|
21
25
|
// -- Surfaces --
|
|
22
26
|
/** Card surface: bg-gray-800 / bg-white, with border. */
|
|
23
|
-
card:
|
|
27
|
+
card: string
|
|
24
28
|
/** Slightly darker card surface: bg-gray-900 / bg-white, with border. */
|
|
25
|
-
cardAlt:
|
|
29
|
+
cardAlt: string
|
|
26
30
|
/** Page background. */
|
|
27
|
-
pageBg:
|
|
31
|
+
pageBg: string
|
|
28
32
|
|
|
29
33
|
// -- Borders --
|
|
30
34
|
/** Standard border between cards/sections. */
|
|
31
|
-
border:
|
|
35
|
+
border: string
|
|
32
36
|
/** Subtle divider (lighter than border). */
|
|
33
|
-
divider:
|
|
37
|
+
divider: string
|
|
34
38
|
|
|
35
39
|
// -- Text hierarchy --
|
|
36
40
|
/** Page headings: white / gray-900. */
|
|
37
|
-
primaryText:
|
|
41
|
+
primaryText: string
|
|
38
42
|
/** Softer headings: gray-100 / gray-800. */
|
|
39
|
-
primaryTextSoft:
|
|
43
|
+
primaryTextSoft: string
|
|
40
44
|
/** Body copy on cards: gray-200 / gray-700. */
|
|
41
|
-
bodyText:
|
|
45
|
+
bodyText: string
|
|
42
46
|
/** Form labels: gray-300 / gray-700. */
|
|
43
|
-
label:
|
|
47
|
+
label: string
|
|
44
48
|
/** Secondary text: gray-400 / gray-600. */
|
|
45
|
-
mutedText:
|
|
49
|
+
mutedText: string
|
|
46
50
|
/** Tertiary text: gray-500 / gray-600. */
|
|
47
|
-
subtleText:
|
|
51
|
+
subtleText: string
|
|
48
52
|
/** Dimmest text: gray-500 in dark / gray-400 in light. */
|
|
49
|
-
dimText:
|
|
53
|
+
dimText: string
|
|
50
54
|
/** Slightly more contrast than dimText: gray-400 / gray-500. */
|
|
51
|
-
dimTextAlt:
|
|
55
|
+
dimTextAlt: string
|
|
52
56
|
/** Big illustration / empty-state icon: gray-600 / gray-300. */
|
|
53
|
-
illustration:
|
|
57
|
+
illustration: string
|
|
54
58
|
|
|
55
59
|
// -- Inputs --
|
|
56
60
|
/** Standard text input. */
|
|
57
|
-
input:
|
|
61
|
+
input: string
|
|
58
62
|
/** Input + placeholder colour. */
|
|
59
|
-
inputWithPlaceholder:
|
|
63
|
+
inputWithPlaceholder: string
|
|
60
64
|
|
|
61
65
|
// -- Buttons --
|
|
62
66
|
/** Ghost / Cancel button. */
|
|
63
|
-
ghostButton:
|
|
67
|
+
ghostButton: string
|
|
64
68
|
|
|
65
69
|
// -- Accent text colours --
|
|
66
70
|
/** Emerald accent text: emerald-400 / emerald-600. */
|
|
67
|
-
emeraldText:
|
|
71
|
+
emeraldText: string
|
|
68
72
|
/** Amber accent text: amber-400 / amber-600. */
|
|
69
|
-
amberText:
|
|
73
|
+
amberText: string
|
|
70
74
|
/** Amber heading-weight: amber-300 / amber-700. */
|
|
71
|
-
amberTextStrong:
|
|
75
|
+
amberTextStrong: string
|
|
72
76
|
/** Red accent text: red-400 / red-500. */
|
|
73
|
-
redText:
|
|
77
|
+
redText: string
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
export function useThemeClasses(): ThemeClasses {
|
|
77
81
|
const { isDark } = useTheme()
|
|
78
82
|
|
|
79
|
-
return {
|
|
83
|
+
return reactive({
|
|
80
84
|
card: computed(() =>
|
|
81
85
|
isDark.value
|
|
82
86
|
? 'bg-gray-800 border-gray-700'
|
|
@@ -129,5 +133,5 @@ export function useThemeClasses(): ThemeClasses {
|
|
|
129
133
|
isDark.value ? 'text-amber-300' : 'text-amber-700',
|
|
130
134
|
),
|
|
131
135
|
redText: computed(() => (isDark.value ? 'text-red-400' : 'text-red-500')),
|
|
132
|
-
}
|
|
136
|
+
})
|
|
133
137
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export { default as BaseChipButton } from './components/BaseChipButton.vue'
|
|
|
37
37
|
export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
|
|
38
38
|
export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
|
|
39
39
|
export { default as BaseFileDropzone } from './components/BaseFileDropzone.vue'
|
|
40
|
+
export { default as BaseCodeBlock } from './components/BaseCodeBlock.vue'
|
|
40
41
|
|
|
41
42
|
// Components — extracted from TradeAutomation
|
|
42
43
|
export { default as BaseInput } from './components/BaseInput.vue'
|
|
@@ -65,6 +66,8 @@ export { useNotifications } from './composables/useNotifications'
|
|
|
65
66
|
export { useQueryParamSync } from './composables/useQueryParamSync'
|
|
66
67
|
export { useFieldClasses } from './composables/useFieldClasses'
|
|
67
68
|
export type { FieldClasses } from './composables/useFieldClasses'
|
|
69
|
+
export { usePolling } from './composables/usePolling'
|
|
70
|
+
export type { UsePollingOptions } from './composables/usePolling'
|
|
68
71
|
|
|
69
72
|
// Enums
|
|
70
73
|
export { AlertEnum } from './enums/AlertEnum'
|
|
@@ -117,7 +120,15 @@ export {
|
|
|
117
120
|
fmtPct,
|
|
118
121
|
fmtUsd,
|
|
119
122
|
fmtDuration,
|
|
123
|
+
formatJson,
|
|
124
|
+
stringifyValue,
|
|
120
125
|
} from './utils/format'
|
|
126
|
+
export {
|
|
127
|
+
isValidAbsoluteUrl,
|
|
128
|
+
isValidJson,
|
|
129
|
+
isValidXml,
|
|
130
|
+
isValidBase64,
|
|
131
|
+
} from './utils/validate'
|
|
121
132
|
export { buildSpecParams, firstInvalidNumericSpec } from './utils/specForm'
|
|
122
133
|
export { computePnL } from './utils/pnl'
|
|
123
134
|
export type { PnL, PnLInputs } from './utils/pnl'
|
package/src/style.css
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
|
+
/* Brand typography, shipped by the library so every consumer renders the same:
|
|
2
|
+
Fira Sans for UI text, Fira Code for numerals/data. External @import (kept
|
|
3
|
+
first — CSS ignores @import after any other rule); consumers should add
|
|
4
|
+
<link rel="preconnect"> hints for fonts.googleapis.com / fonts.gstatic.com
|
|
5
|
+
in their index.html for faster first paint. */
|
|
6
|
+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
|
|
7
|
+
|
|
1
8
|
@import "tailwindcss";
|
|
2
9
|
|
|
10
|
+
/* Register the fonts as the Tailwind theme defaults. The compiled stylesheet
|
|
11
|
+
re-declares theme variables in its :root, so in consumers that import this
|
|
12
|
+
file after their own `@import "tailwindcss"` these values win the cascade
|
|
13
|
+
for font-sans/font-mono utilities AND the body rule below. */
|
|
14
|
+
@theme {
|
|
15
|
+
--font-sans: 'Fira Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
+
--font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
/* Compile `dark:` variants against the `.dark` class that useTheme()/initTheme()
|
|
4
20
|
toggle on <html> — NOT the OS-level prefers-color-scheme media query
|
|
5
21
|
(Tailwind v4's default). Both consumers (TradeAutomation, WireMate) switch
|
|
@@ -8,3 +24,9 @@
|
|
|
8
24
|
Use a specificity-bearing selector (:is, not :where) so dark: utilities win
|
|
9
25
|
over plain base utilities like bg-slate-50 on a cascade tie. */
|
|
10
26
|
@custom-variant dark (&:is(.dark, .dark *));
|
|
27
|
+
|
|
28
|
+
/* Apply the brand font app-wide. Plain body rule (not preflight-dependent) so
|
|
29
|
+
it also styles consumers whose own Tailwind preflight came earlier. */
|
|
30
|
+
body {
|
|
31
|
+
font-family: var(--font-sans);
|
|
32
|
+
}
|
package/src/utils/format.test.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
fmtPct,
|
|
10
10
|
fmtUsd,
|
|
11
11
|
fmtDuration,
|
|
12
|
+
formatJson,
|
|
13
|
+
stringifyValue,
|
|
12
14
|
} from './format'
|
|
13
15
|
|
|
14
16
|
describe('fmtDateTimeMs', () => {
|
|
@@ -233,3 +235,30 @@ describe('fmtBytes', () => {
|
|
|
233
235
|
expect(fmtBytes(null)).toBe('')
|
|
234
236
|
})
|
|
235
237
|
})
|
|
238
|
+
|
|
239
|
+
describe('formatJson', () => {
|
|
240
|
+
it('pretty-prints parseable JSON with 2-space indentation', () => {
|
|
241
|
+
expect(formatJson('{"a":1,"b":[2,3]}')).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}')
|
|
242
|
+
})
|
|
243
|
+
it('returns non-JSON input verbatim', () => {
|
|
244
|
+
expect(formatJson('plain text')).toBe('plain text')
|
|
245
|
+
expect(formatJson('<xml/>')).toBe('<xml/>')
|
|
246
|
+
expect(formatJson('')).toBe('')
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('stringifyValue', () => {
|
|
251
|
+
it('passes strings through and blanks null/undefined', () => {
|
|
252
|
+
expect(stringifyValue('abc')).toBe('abc')
|
|
253
|
+
expect(stringifyValue(null)).toBe('')
|
|
254
|
+
expect(stringifyValue(undefined)).toBe('')
|
|
255
|
+
})
|
|
256
|
+
it('JSON-serializes objects, arrays and numbers', () => {
|
|
257
|
+
expect(stringifyValue({ a: 1 })).toBe('{"a":1}')
|
|
258
|
+
expect(stringifyValue([1, 2])).toBe('[1,2]')
|
|
259
|
+
expect(stringifyValue(42)).toBe('42')
|
|
260
|
+
})
|
|
261
|
+
it('falls back to String() when JSON.stringify throws', () => {
|
|
262
|
+
expect(stringifyValue(1n)).toBe('1')
|
|
263
|
+
})
|
|
264
|
+
})
|