mgv-backoffice 1.30.0 → 1.32.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 +63 -0
- package/dist/components/BaseConfirmModal.vue.d.ts +3 -3
- package/dist/components/BaseCopyButton.vue.d.ts +1 -1
- package/dist/components/BaseFileDropzone.vue.d.ts +32 -0
- package/dist/components/BaseModalShell.vue.d.ts +23 -7
- package/dist/composables/useFieldClasses.d.ts +29 -0
- package/dist/index.d.ts +7 -2
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +847 -699
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/format.d.ts +23 -0
- package/dist/utils/httpColors.d.ts +13 -0
- package/dist/utils/kvRows.d.ts +21 -0
- package/package.json +1 -1
- package/src/components/BaseConfirmModal.vue +5 -17
- package/src/components/BaseEntityPickerModal.vue +7 -9
- package/src/components/BaseFileDropzone.vue +94 -0
- package/src/components/BaseModalShell.vue +45 -3
- package/src/components/BaseTextInputModal.vue +5 -9
- package/src/composables/useFieldClasses.test.ts +55 -0
- package/src/composables/useFieldClasses.ts +56 -0
- package/src/index.ts +11 -0
- package/src/utils/format.test.ts +55 -0
- package/src/utils/format.ts +57 -0
- package/src/utils/httpColors.test.ts +38 -0
- package/src/utils/httpColors.ts +31 -0
- package/src/utils/kvRows.test.ts +47 -0
- package/src/utils/kvRows.ts +34 -0
package/dist/utils/format.d.ts
CHANGED
|
@@ -45,6 +45,29 @@ export declare function fmtPct(n: number, digits?: number): string;
|
|
|
45
45
|
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
46
46
|
*/
|
|
47
47
|
export declare function fmtUsd(v: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
50
|
+
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|
|
51
|
+
* failure and an em-dash on empty input.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fmtCalendarDate(s: string | number | null | undefined): string;
|
|
54
|
+
/**
|
|
55
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
|
|
56
|
+
* label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fmtCalendarDateTime(s: string | number | null | undefined): string;
|
|
59
|
+
/**
|
|
60
|
+
* Render a millisecond count as a "= 1.50 s" hint so users can
|
|
61
|
+
* sanity-check magnitudes without mental math. Sub-second values get 3
|
|
62
|
+
* decimals ("= 0.250 s"), everything else 2. Returns '' for
|
|
63
|
+
* non-positive / non-finite input so callers can `v-if` it away.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
|
|
66
|
+
/**
|
|
67
|
+
* Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
|
|
68
|
+
* "2.0 MB"). Returns '' for zero/falsy input.
|
|
69
|
+
*/
|
|
70
|
+
export declare function fmtBytes(bytes: number | null | undefined): string;
|
|
48
71
|
/**
|
|
49
72
|
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
50
73
|
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
@@ -15,5 +15,18 @@ export declare function methodBadgeSolid(method?: string): string;
|
|
|
15
15
|
export declare function methodBadgeBright(method?: string): string;
|
|
16
16
|
/** Solid 600-weight status badge with white text. */
|
|
17
17
|
export declare function statusBadgeSolid(status?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Dark/light-aware tinted method badge — per-method hue on a soft
|
|
20
|
+
* `*-500/15` (dark) / `*-100` (light) surface, for card-surface chips.
|
|
21
|
+
* (Extracted from WireMate's mock/stub cards; GET is blue here, unlike
|
|
22
|
+
* the green-leaning bright family above.)
|
|
23
|
+
*/
|
|
24
|
+
export declare function methodBadgeTinted(method: string | undefined, isDark: boolean): string;
|
|
25
|
+
/**
|
|
26
|
+
* Dark/light-aware soft status badge keyed by status class — emerald 2xx,
|
|
27
|
+
* sky 3xx, amber 4xx, red 5xx, gray otherwise. Softer companion to
|
|
28
|
+
* `statusBadgeTinted` (which uses the green/blue/yellow palette).
|
|
29
|
+
*/
|
|
30
|
+
export declare function statusBadgeSoft(status: number | undefined, isDark: boolean): string;
|
|
18
31
|
/** Dark/light-aware tinted status badge. */
|
|
19
32
|
export declare function statusBadgeTinted(status: number | undefined, isDark: boolean): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators for dynamic key/value row grids (header lists, query-param
|
|
3
|
+
* lists, metadata grids, …). A row is inconsistent when one side is
|
|
4
|
+
* filled in and the other is blank:
|
|
5
|
+
*
|
|
6
|
+
* • key present, value blank → rowValueMissing
|
|
7
|
+
* • value present, key blank → rowKeyMissing
|
|
8
|
+
*
|
|
9
|
+
* Rows whose `matcherType` is `'absent'` are exempt — an absent matcher
|
|
10
|
+
* intentionally carries no value (it asserts the key is NOT present).
|
|
11
|
+
* Rows without a matcherType (plain key/value pairs) validate normally.
|
|
12
|
+
*/
|
|
13
|
+
export interface KeyValueRowLike {
|
|
14
|
+
key?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
matcherType?: string;
|
|
17
|
+
}
|
|
18
|
+
/** True when the row has a (non-absent) key but no value. */
|
|
19
|
+
export declare function rowValueMissing(row: KeyValueRowLike): boolean;
|
|
20
|
+
/** True when the row has a value but no key to bind it to. */
|
|
21
|
+
export declare function rowKeyMissing(row: KeyValueRowLike): boolean;
|
package/package.json
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<BaseModalShell
|
|
3
3
|
:title="title"
|
|
4
|
+
:icon="ExclamationTriangleIcon"
|
|
5
|
+
:icon-bg-class="variant === 'danger'
|
|
6
|
+
? isDark ? 'bg-red-900/30' : 'bg-red-50'
|
|
7
|
+
: isDark ? 'bg-amber-900/30' : 'bg-amber-50'"
|
|
8
|
+
:icon-class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
|
|
4
9
|
:manual-close="submitting"
|
|
5
10
|
@cancel="handleCancel"
|
|
6
11
|
>
|
|
7
|
-
<template #icon>
|
|
8
|
-
<div
|
|
9
|
-
class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
|
|
10
|
-
:class="[
|
|
11
|
-
variant === 'danger'
|
|
12
|
-
? isDark ? 'bg-red-900/30' : 'bg-red-50'
|
|
13
|
-
: isDark ? 'bg-amber-900/30' : 'bg-amber-50'
|
|
14
|
-
]"
|
|
15
|
-
>
|
|
16
|
-
<ExclamationTriangleIcon
|
|
17
|
-
class="h-6 w-6"
|
|
18
|
-
:class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
|
|
19
|
-
aria-hidden="true"
|
|
20
|
-
/>
|
|
21
|
-
</div>
|
|
22
|
-
</template>
|
|
23
|
-
|
|
24
12
|
<!--
|
|
25
13
|
Default renders the plain `message` string. Callers that need rich
|
|
26
14
|
text (bold names, inline code, etc.) can override the `message` slot
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<BaseModalShell
|
|
3
3
|
:title="title"
|
|
4
|
+
:icon="FolderIcon"
|
|
5
|
+
:icon-bg-class="palette.iconBg"
|
|
6
|
+
:icon-class="palette.iconText"
|
|
4
7
|
:manual-close="submitting"
|
|
5
8
|
@cancel="handleCancel"
|
|
6
9
|
>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
:class="palette.iconBg"
|
|
12
|
-
>
|
|
13
|
-
<FolderIcon class="h-6 w-6" :class="palette.iconText" aria-hidden="true" />
|
|
14
|
-
</div>
|
|
15
|
-
</slot>
|
|
10
|
+
<!-- Forward a caller-supplied icon override; otherwise the shell
|
|
11
|
+
renders the default folder icon in the palette-tinted chip. -->
|
|
12
|
+
<template v-if="$slots.icon" #icon>
|
|
13
|
+
<slot name="icon" />
|
|
16
14
|
</template>
|
|
17
15
|
|
|
18
16
|
<p v-if="message" class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Dashed "click to select a file" upload zone — extracted from WireMate's
|
|
4
|
+
ImportPostmanModal file picker. Also accepts drag-and-drop.
|
|
5
|
+
|
|
6
|
+
The hidden input is reset after every selection so choosing the same
|
|
7
|
+
file twice still fires `files` — callers don't need their own reset.
|
|
8
|
+
-->
|
|
9
|
+
<label
|
|
10
|
+
class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-10 text-center transition-colors"
|
|
11
|
+
:class="[
|
|
12
|
+
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
13
|
+
dragging
|
|
14
|
+
? (isDark ? 'border-emerald-500 text-emerald-400' : 'border-emerald-500 text-emerald-600')
|
|
15
|
+
: (isDark
|
|
16
|
+
? 'border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400'
|
|
17
|
+
: 'border-gray-300 hover:border-emerald-500 text-gray-500 hover:text-emerald-600'),
|
|
18
|
+
]"
|
|
19
|
+
@dragover.prevent="onDragOver"
|
|
20
|
+
@dragleave.prevent="dragging = false"
|
|
21
|
+
@drop.prevent="onDrop"
|
|
22
|
+
>
|
|
23
|
+
<slot name="icon">
|
|
24
|
+
<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
25
|
+
<path
|
|
26
|
+
stroke-linecap="round"
|
|
27
|
+
stroke-linejoin="round"
|
|
28
|
+
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
|
29
|
+
/>
|
|
30
|
+
</svg>
|
|
31
|
+
</slot>
|
|
32
|
+
<p class="text-sm font-medium">{{ label }}</p>
|
|
33
|
+
<p v-if="hint" class="text-xs" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
|
|
34
|
+
{{ hint }}
|
|
35
|
+
</p>
|
|
36
|
+
<input
|
|
37
|
+
type="file"
|
|
38
|
+
class="hidden"
|
|
39
|
+
:accept="accept"
|
|
40
|
+
:multiple="multiple"
|
|
41
|
+
:disabled="disabled"
|
|
42
|
+
@change="onChange"
|
|
43
|
+
/>
|
|
44
|
+
</label>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script lang="ts" setup>
|
|
48
|
+
import { ref } from 'vue'
|
|
49
|
+
import { useTheme } from '../composables/useTheme'
|
|
50
|
+
|
|
51
|
+
interface Props {
|
|
52
|
+
/** Main line, e.g. "Click to select a Postman collection (.json)". */
|
|
53
|
+
label: string
|
|
54
|
+
/** Optional dimmed helper line below the label. */
|
|
55
|
+
hint?: string
|
|
56
|
+
/** Forwarded to the hidden input's `accept`. Dropped files are NOT filtered by it. */
|
|
57
|
+
accept?: string
|
|
58
|
+
multiple?: boolean
|
|
59
|
+
disabled?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
63
|
+
hint: '',
|
|
64
|
+
accept: '',
|
|
65
|
+
multiple: false,
|
|
66
|
+
disabled: false,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const emit = defineEmits<{ files: [files: File[]] }>()
|
|
70
|
+
|
|
71
|
+
const { isDark } = useTheme()
|
|
72
|
+
|
|
73
|
+
const dragging = ref(false)
|
|
74
|
+
|
|
75
|
+
function onChange(e: Event) {
|
|
76
|
+
const input = e.target as HTMLInputElement
|
|
77
|
+
const files = Array.from(input.files ?? [])
|
|
78
|
+
// Reset so re-selecting the same file re-triggers change.
|
|
79
|
+
input.value = ''
|
|
80
|
+
if (files.length) emit('files', files)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onDragOver() {
|
|
84
|
+
if (!props.disabled) dragging.value = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onDrop(e: DragEvent) {
|
|
88
|
+
dragging.value = false
|
|
89
|
+
if (props.disabled) return
|
|
90
|
+
const dropped = Array.from(e.dataTransfer?.files ?? [])
|
|
91
|
+
if (!dropped.length) return
|
|
92
|
+
emit('files', props.multiple ? dropped : dropped.slice(0, 1))
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
backdrop, dark-mode card, escape key, aria-modal). Concrete modals
|
|
5
5
|
should compose this rather than re-implementing the chrome.
|
|
6
6
|
|
|
7
|
+
Every modal shows an icon left of the title: the REQUIRED `icon`
|
|
8
|
+
prop (a component, e.g. a heroicon) renders inside a tinted
|
|
9
|
+
circular chip. Tint via `icon-bg-class` / `icon-class`; the `icon`
|
|
10
|
+
slot remains as a full-markup override for callers that need
|
|
11
|
+
something richer than the standard chip.
|
|
12
|
+
|
|
7
13
|
Slots:
|
|
8
|
-
• icon —
|
|
14
|
+
• icon — override the standard icon chip next to the title
|
|
9
15
|
• default — body content
|
|
10
16
|
• footer — action buttons (cancel / confirm / etc.)
|
|
11
17
|
|
|
@@ -37,7 +43,14 @@
|
|
|
37
43
|
>
|
|
38
44
|
<div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
|
|
39
45
|
<div class="flex items-start gap-4 min-w-0">
|
|
40
|
-
<slot name="icon"
|
|
46
|
+
<slot name="icon">
|
|
47
|
+
<div
|
|
48
|
+
class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
|
|
49
|
+
:class="iconChipBg"
|
|
50
|
+
>
|
|
51
|
+
<component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
|
|
52
|
+
</div>
|
|
53
|
+
</slot>
|
|
41
54
|
<div class="flex-1 min-w-0">
|
|
42
55
|
<h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
|
|
43
56
|
{{ title }}
|
|
@@ -69,7 +82,14 @@
|
|
|
69
82
|
<div class="p-6">
|
|
70
83
|
<!-- Title row -->
|
|
71
84
|
<div class="flex items-start gap-4">
|
|
72
|
-
<slot name="icon"
|
|
85
|
+
<slot name="icon">
|
|
86
|
+
<div
|
|
87
|
+
class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
|
|
88
|
+
:class="iconChipBg"
|
|
89
|
+
>
|
|
90
|
+
<component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
|
|
91
|
+
</div>
|
|
92
|
+
</slot>
|
|
73
93
|
<div class="flex-1">
|
|
74
94
|
<h3
|
|
75
95
|
:id="titleId"
|
|
@@ -98,12 +118,26 @@
|
|
|
98
118
|
|
|
99
119
|
<script setup lang="ts">
|
|
100
120
|
import { computed } from 'vue'
|
|
121
|
+
import type { Component } from 'vue'
|
|
101
122
|
import { useTheme } from '../composables/useTheme'
|
|
102
123
|
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
103
124
|
import { useEscapeKey } from '../composables/useEscapeKey'
|
|
104
125
|
|
|
105
126
|
interface Props {
|
|
106
127
|
title: string
|
|
128
|
+
/**
|
|
129
|
+
* Icon component (e.g. a heroicon) rendered in a tinted circular
|
|
130
|
+
* chip left of the title. REQUIRED — every modal must name itself
|
|
131
|
+
* with an icon. The `icon` slot can override the whole chip.
|
|
132
|
+
*/
|
|
133
|
+
icon: Component
|
|
134
|
+
/**
|
|
135
|
+
* Background classes of the icon chip. Defaults to the emerald
|
|
136
|
+
* tint (dark-mode aware).
|
|
137
|
+
*/
|
|
138
|
+
iconBgClass?: string
|
|
139
|
+
/** Classes applied to the icon itself. Defaults to emerald. */
|
|
140
|
+
iconClass?: string
|
|
107
141
|
/**
|
|
108
142
|
* Max-width of the dialog card. Defaults to `max-w-md`. Pass any
|
|
109
143
|
* Tailwind max-w utility, or empty string to opt out.
|
|
@@ -135,6 +169,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
135
169
|
manualClose: false,
|
|
136
170
|
scrollable: false,
|
|
137
171
|
subtitle: '',
|
|
172
|
+
iconBgClass: '',
|
|
173
|
+
iconClass: 'text-emerald-600',
|
|
138
174
|
})
|
|
139
175
|
|
|
140
176
|
const emit = defineEmits<{
|
|
@@ -156,6 +192,12 @@ const shellClass = computed(() =>
|
|
|
156
192
|
: 'bg-white border border-gray-200',
|
|
157
193
|
)
|
|
158
194
|
|
|
195
|
+
// The icon chip's background: an explicit `icon-bg-class` wins, else the
|
|
196
|
+
// standard emerald tint in the current theme.
|
|
197
|
+
const iconChipBg = computed(() =>
|
|
198
|
+
props.iconBgClass || (isDark.value ? 'bg-emerald-900/30' : 'bg-emerald-50'),
|
|
199
|
+
)
|
|
200
|
+
|
|
159
201
|
function onBackdrop() {
|
|
160
202
|
emit('backdrop')
|
|
161
203
|
if (!props.manualClose) emit('cancel')
|
|
@@ -12,18 +12,14 @@
|
|
|
12
12
|
-->
|
|
13
13
|
<BaseModalShell
|
|
14
14
|
:title="title"
|
|
15
|
+
:icon="DocumentDuplicateIcon"
|
|
15
16
|
manual-close
|
|
16
17
|
@backdrop="handleBackdropClick"
|
|
17
18
|
>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
:class="isDark ? 'bg-emerald-900/30' : 'bg-emerald-50'"
|
|
23
|
-
>
|
|
24
|
-
<DocumentDuplicateIcon class="h-6 w-6 text-emerald-600" aria-hidden="true" />
|
|
25
|
-
</div>
|
|
26
|
-
</slot>
|
|
19
|
+
<!-- Forward a caller-supplied icon override; otherwise the shell
|
|
20
|
+
renders the default document icon in the standard emerald chip. -->
|
|
21
|
+
<template v-if="$slots.icon" #icon>
|
|
22
|
+
<slot name="icon" />
|
|
27
23
|
</template>
|
|
28
24
|
|
|
29
25
|
<p class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
|
|
@@ -0,0 +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
|
+
})
|
|
@@ -0,0 +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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export { default as BaseNotificationPanel } from './components/BaseNotificationP
|
|
|
36
36
|
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
|
+
export { default as BaseFileDropzone } from './components/BaseFileDropzone.vue'
|
|
39
40
|
|
|
40
41
|
// Components — extracted from TradeAutomation
|
|
41
42
|
export { default as BaseInput } from './components/BaseInput.vue'
|
|
@@ -62,6 +63,8 @@ export { useSidebarCollapse } from './composables/useSidebarCollapse'
|
|
|
62
63
|
export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
|
|
63
64
|
export { useNotifications } from './composables/useNotifications'
|
|
64
65
|
export { useQueryParamSync } from './composables/useQueryParamSync'
|
|
66
|
+
export { useFieldClasses } from './composables/useFieldClasses'
|
|
67
|
+
export type { FieldClasses } from './composables/useFieldClasses'
|
|
65
68
|
|
|
66
69
|
// Enums
|
|
67
70
|
export { AlertEnum } from './enums/AlertEnum'
|
|
@@ -92,9 +95,13 @@ export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
|
92
95
|
export {
|
|
93
96
|
methodBadgeSolid,
|
|
94
97
|
methodBadgeBright,
|
|
98
|
+
methodBadgeTinted,
|
|
95
99
|
statusBadgeSolid,
|
|
96
100
|
statusBadgeTinted,
|
|
101
|
+
statusBadgeSoft,
|
|
97
102
|
} from './utils/httpColors'
|
|
103
|
+
export { rowKeyMissing, rowValueMissing } from './utils/kvRows'
|
|
104
|
+
export type { KeyValueRowLike } from './utils/kvRows'
|
|
98
105
|
export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
|
|
99
106
|
export {
|
|
100
107
|
fmtNumber,
|
|
@@ -102,6 +109,10 @@ export {
|
|
|
102
109
|
fmtDateTime,
|
|
103
110
|
fmtDateTimeMs,
|
|
104
111
|
fmtDateShort,
|
|
112
|
+
fmtCalendarDate,
|
|
113
|
+
fmtCalendarDateTime,
|
|
114
|
+
fmtMsAsSeconds,
|
|
115
|
+
fmtBytes,
|
|
105
116
|
fmtPrice,
|
|
106
117
|
fmtPct,
|
|
107
118
|
fmtUsd,
|
package/src/utils/format.test.ts
CHANGED
|
@@ -178,3 +178,58 @@ describe('fmtDuration', () => {
|
|
|
178
178
|
expect(fmtDuration(59_000)).toBe('0m')
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
|
+
|
|
182
|
+
import { fmtCalendarDate, fmtCalendarDateTime, fmtMsAsSeconds, fmtBytes } from './format'
|
|
183
|
+
|
|
184
|
+
describe('fmtCalendarDate', () => {
|
|
185
|
+
it('renders a "Mon D, YYYY" en-US label', () => {
|
|
186
|
+
expect(fmtCalendarDate('2026-01-05T10:30:00Z')).toMatch(/^Jan \d{1,2}, 2026$/)
|
|
187
|
+
})
|
|
188
|
+
it('renders an em-dash for empty input', () => {
|
|
189
|
+
expect(fmtCalendarDate(null)).toBe(EM_DASH)
|
|
190
|
+
expect(fmtCalendarDate('')).toBe(EM_DASH)
|
|
191
|
+
})
|
|
192
|
+
it('falls back to the raw value for unparseable input', () => {
|
|
193
|
+
expect(fmtCalendarDate('not-a-date')).toBe('not-a-date')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('fmtCalendarDateTime', () => {
|
|
198
|
+
it('includes the time of day', () => {
|
|
199
|
+
expect(fmtCalendarDateTime('2026-01-05T10:30:00Z')).toMatch(/2026.*\d{1,2}:\d{2}/)
|
|
200
|
+
})
|
|
201
|
+
it('renders an em-dash for empty input', () => {
|
|
202
|
+
expect(fmtCalendarDateTime(undefined)).toBe(EM_DASH)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('fmtMsAsSeconds', () => {
|
|
207
|
+
it('renders 2 decimals at >= 1s', () => {
|
|
208
|
+
expect(fmtMsAsSeconds(1500)).toBe('= 1.50 s')
|
|
209
|
+
})
|
|
210
|
+
it('renders 3 decimals below 1s', () => {
|
|
211
|
+
expect(fmtMsAsSeconds(250)).toBe('= 0.250 s')
|
|
212
|
+
})
|
|
213
|
+
it('returns empty for zero / negative / non-finite input', () => {
|
|
214
|
+
expect(fmtMsAsSeconds(0)).toBe('')
|
|
215
|
+
expect(fmtMsAsSeconds(-5)).toBe('')
|
|
216
|
+
expect(fmtMsAsSeconds(NaN)).toBe('')
|
|
217
|
+
expect(fmtMsAsSeconds(null)).toBe('')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('fmtBytes', () => {
|
|
222
|
+
it('renders bytes below 1 KB', () => {
|
|
223
|
+
expect(fmtBytes(512)).toBe('512 B')
|
|
224
|
+
})
|
|
225
|
+
it('renders KB with one decimal', () => {
|
|
226
|
+
expect(fmtBytes(1536)).toBe('1.5 KB')
|
|
227
|
+
})
|
|
228
|
+
it('renders MB with one decimal', () => {
|
|
229
|
+
expect(fmtBytes(2 * 1024 * 1024)).toBe('2.0 MB')
|
|
230
|
+
})
|
|
231
|
+
it('returns empty for zero / falsy input', () => {
|
|
232
|
+
expect(fmtBytes(0)).toBe('')
|
|
233
|
+
expect(fmtBytes(null)).toBe('')
|
|
234
|
+
})
|
|
235
|
+
})
|
package/src/utils/format.ts
CHANGED
|
@@ -111,6 +111,63 @@ export function fmtUsd(v: number): string {
|
|
|
111
111
|
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
|
|
116
|
+
* (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
|
|
117
|
+
* failure and an em-dash on empty input.
|
|
118
|
+
*/
|
|
119
|
+
export function fmtCalendarDate(s: string | number | null | undefined): string {
|
|
120
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
121
|
+
const d = new Date(s)
|
|
122
|
+
if (isNaN(d.getTime())) return String(s)
|
|
123
|
+
return d.toLocaleDateString('en-US', {
|
|
124
|
+
year: 'numeric',
|
|
125
|
+
month: 'short',
|
|
126
|
+
day: 'numeric',
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
|
|
132
|
+
* label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
|
|
133
|
+
*/
|
|
134
|
+
export function fmtCalendarDateTime(s: string | number | null | undefined): string {
|
|
135
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
136
|
+
const d = new Date(s)
|
|
137
|
+
if (isNaN(d.getTime())) return String(s)
|
|
138
|
+
return d.toLocaleDateString('en-US', {
|
|
139
|
+
year: 'numeric',
|
|
140
|
+
month: 'short',
|
|
141
|
+
day: 'numeric',
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render a millisecond count as a "= 1.50 s" hint so users can
|
|
149
|
+
* sanity-check magnitudes without mental math. Sub-second values get 3
|
|
150
|
+
* decimals ("= 0.250 s"), everything else 2. Returns '' for
|
|
151
|
+
* non-positive / non-finite input so callers can `v-if` it away.
|
|
152
|
+
*/
|
|
153
|
+
export function fmtMsAsSeconds(ms: number | null | undefined): string {
|
|
154
|
+
if (typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return ''
|
|
155
|
+
const seconds = ms / 1000
|
|
156
|
+
const decimals = seconds < 1 ? 3 : 2
|
|
157
|
+
return `= ${seconds.toFixed(decimals)} s`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
|
|
162
|
+
* "2.0 MB"). Returns '' for zero/falsy input.
|
|
163
|
+
*/
|
|
164
|
+
export function fmtBytes(bytes: number | null | undefined): string {
|
|
165
|
+
if (!bytes) return ''
|
|
166
|
+
if (bytes < 1024) return `${bytes} B`
|
|
167
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
168
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
169
|
+
}
|
|
170
|
+
|
|
114
171
|
/**
|
|
115
172
|
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
116
173
|
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|