mgv-backoffice 1.11.0 → 1.16.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 +54 -2
- package/dist/src/components/BaseButton.vue.d.ts +22 -0
- package/dist/src/components/BaseConfirmModal.vue.d.ts +16 -1
- package/dist/src/components/BaseCopyButton.vue.d.ts +26 -0
- package/dist/src/components/BaseModalShell.vue.d.ts +18 -0
- package/dist/src/components/EarningsCard.vue.d.ts +11 -0
- package/dist/src/enums/BaseButtonEnum.d.ts +5 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +487 -268
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +2 -1
- package/src/components/BaseButton.vue +172 -2
- package/src/components/BaseConfirmModal.vue +7 -0
- package/src/components/BaseCopyButton.vue +135 -0
- package/src/components/BaseEntityPickerModal.vue +1 -1
- package/src/components/BaseModalShell.vue +52 -0
- package/src/components/EarningsCard.test.ts +33 -0
- package/src/components/EarningsCard.vue +64 -10
- package/src/enums/BaseButtonEnum.ts +4 -0
- package/src/index.ts +1 -0
- package/src/utils/pnl.test.ts +6 -0
- package/src/utils/pnl.ts +6 -1
- package/tailwind.safelist.css +17 -1
- package/tailwind.safelist.js +17 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Copy-to-clipboard icon button with transient "copied" feedback.
|
|
4
|
+
Click → writes `text` to the clipboard → swaps the clipboard icon
|
|
5
|
+
for a checkmark for `resetMs` → reverts. Emits `copied` / `error`
|
|
6
|
+
so the parent can surface its own toast (the button itself stays
|
|
7
|
+
silent — the icon swap is the built-in affordance).
|
|
8
|
+
|
|
9
|
+
Variants:
|
|
10
|
+
• ghost — borderless `p-1` icon, emerald hover tint. The
|
|
11
|
+
affordance used next to inline IDs / code headers.
|
|
12
|
+
• bordered — `w-9 h-9` boxed button that turns emerald while
|
|
13
|
+
"copied". Pairs with a read-only input in a form row.
|
|
14
|
+
|
|
15
|
+
Copy strategy: the async Clipboard API where available, falling
|
|
16
|
+
back to a hidden-textarea `execCommand('copy')` so it still works
|
|
17
|
+
on insecure origins / older browsers.
|
|
18
|
+
-->
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
:title="title"
|
|
22
|
+
:aria-label="ariaLabel"
|
|
23
|
+
:class="buttonClass"
|
|
24
|
+
@click="handleCopy"
|
|
25
|
+
>
|
|
26
|
+
<ClipboardDocumentCheckIcon v-if="copied" :class="iconClass" aria-hidden="true" />
|
|
27
|
+
<ClipboardDocumentIcon v-else :class="iconClass" aria-hidden="true" />
|
|
28
|
+
</button>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
import { ref, computed, onUnmounted } from 'vue'
|
|
33
|
+
import { ClipboardDocumentIcon, ClipboardDocumentCheckIcon } from '@heroicons/vue/24/outline'
|
|
34
|
+
import { useTheme } from '../composables/useTheme'
|
|
35
|
+
|
|
36
|
+
type CopyVariant = 'ghost' | 'bordered'
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
/** The text written to the clipboard on click. */
|
|
41
|
+
text: string
|
|
42
|
+
/** Used in the tooltip / aria-label, e.g. label="Stub ID" → "Copy Stub ID". */
|
|
43
|
+
label?: string
|
|
44
|
+
/** Visual style. See the component comment. */
|
|
45
|
+
variant?: CopyVariant
|
|
46
|
+
/** How long the checkmark stays before reverting (ms). */
|
|
47
|
+
resetMs?: number
|
|
48
|
+
/** Icon size class. */
|
|
49
|
+
iconClass?: string
|
|
50
|
+
}>(),
|
|
51
|
+
{
|
|
52
|
+
label: '',
|
|
53
|
+
variant: 'ghost',
|
|
54
|
+
resetMs: 1500,
|
|
55
|
+
iconClass: 'w-4 h-4',
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const emit = defineEmits<{
|
|
60
|
+
copied: []
|
|
61
|
+
error: [error: unknown]
|
|
62
|
+
}>()
|
|
63
|
+
|
|
64
|
+
const { isDark } = useTheme()
|
|
65
|
+
|
|
66
|
+
const copied = ref(false)
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
68
|
+
|
|
69
|
+
const title = computed(() => {
|
|
70
|
+
if (copied.value) return 'Copied!'
|
|
71
|
+
return props.label ? `Copy ${props.label}` : 'Copy'
|
|
72
|
+
})
|
|
73
|
+
const ariaLabel = computed(() => (props.label ? `Copy ${props.label}` : 'Copy'))
|
|
74
|
+
|
|
75
|
+
const buttonClass = computed(() => {
|
|
76
|
+
if (props.variant === 'bordered') {
|
|
77
|
+
const base =
|
|
78
|
+
'inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer shrink-0'
|
|
79
|
+
const state = copied.value
|
|
80
|
+
? isDark.value
|
|
81
|
+
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'
|
|
82
|
+
: 'border-emerald-300 bg-emerald-50 text-emerald-600'
|
|
83
|
+
: isDark.value
|
|
84
|
+
? 'border-gray-600 text-gray-400 hover:text-gray-200 hover:bg-gray-800'
|
|
85
|
+
: 'border-gray-300 text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
|
86
|
+
return `${base} ${state}`
|
|
87
|
+
}
|
|
88
|
+
// ghost
|
|
89
|
+
const base = 'p-1 rounded transition-colors cursor-pointer'
|
|
90
|
+
const state = isDark.value
|
|
91
|
+
? 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-500/10'
|
|
92
|
+
: 'text-gray-500 hover:text-emerald-600 hover:bg-emerald-50'
|
|
93
|
+
return `${base} ${state}`
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
async function writeToClipboard(text: string): Promise<void> {
|
|
97
|
+
// Prefer the async Clipboard API, but fall back to execCommand if it
|
|
98
|
+
// is unavailable or throws (e.g. insecure origin / denied permission).
|
|
99
|
+
try {
|
|
100
|
+
if (navigator.clipboard?.writeText) {
|
|
101
|
+
await navigator.clipboard.writeText(text)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// fall through to the textarea fallback
|
|
106
|
+
}
|
|
107
|
+
const ta = document.createElement('textarea')
|
|
108
|
+
ta.value = text
|
|
109
|
+
ta.style.position = 'fixed'
|
|
110
|
+
ta.style.opacity = '0'
|
|
111
|
+
document.body.appendChild(ta)
|
|
112
|
+
ta.select()
|
|
113
|
+
const ok = document.execCommand('copy')
|
|
114
|
+
document.body.removeChild(ta)
|
|
115
|
+
if (!ok) throw new Error('Copy command was rejected')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleCopy() {
|
|
119
|
+
try {
|
|
120
|
+
await writeToClipboard(props.text)
|
|
121
|
+
copied.value = true
|
|
122
|
+
if (timer) clearTimeout(timer)
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
copied.value = false
|
|
125
|
+
}, props.resetMs)
|
|
126
|
+
emit('copied')
|
|
127
|
+
} catch (e) {
|
|
128
|
+
emit('error', e)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onUnmounted(() => {
|
|
133
|
+
if (timer) clearTimeout(timer)
|
|
134
|
+
})
|
|
135
|
+
</script>
|
|
@@ -23,7 +23,45 @@
|
|
|
23
23
|
:aria-labelledby="titleId"
|
|
24
24
|
@click="onBackdrop"
|
|
25
25
|
>
|
|
26
|
+
<!--
|
|
27
|
+
Scrollable layout — for large content modals. A flex column
|
|
28
|
+
capped at 90vh: header and footer stay fixed (`shrink-0`) while
|
|
29
|
+
the body scrolls (`overflow-y-auto flex-1`). Supports a subtitle
|
|
30
|
+
and a `header-actions` slot (e.g. a close button) on the right.
|
|
31
|
+
-->
|
|
26
32
|
<div
|
|
33
|
+
v-if="scrollable"
|
|
34
|
+
class="relative w-full mx-4 rounded-lg shadow-xl max-h-[90vh] flex flex-col"
|
|
35
|
+
:class="[maxWidthClass, shellClass]"
|
|
36
|
+
@click.stop
|
|
37
|
+
>
|
|
38
|
+
<div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
|
|
39
|
+
<div class="flex items-start gap-4 min-w-0">
|
|
40
|
+
<slot name="icon" />
|
|
41
|
+
<div class="flex-1 min-w-0">
|
|
42
|
+
<h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
|
|
43
|
+
{{ title }}
|
|
44
|
+
</h3>
|
|
45
|
+
<p v-if="subtitle" class="text-sm mt-1 break-all" :class="t.mutedText">
|
|
46
|
+
{{ subtitle }}
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<slot name="header-actions" />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="p-6 overflow-y-auto flex-1">
|
|
54
|
+
<slot />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div v-if="$slots.footer" class="flex gap-3 justify-end p-6 border-t shrink-0" :class="t.border">
|
|
58
|
+
<slot name="footer" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Compact layout (default) — small centered dialog. -->
|
|
63
|
+
<div
|
|
64
|
+
v-else
|
|
27
65
|
class="relative w-full mx-4 rounded-lg shadow-xl"
|
|
28
66
|
:class="[maxWidthClass, shellClass]"
|
|
29
67
|
@click.stop
|
|
@@ -78,11 +116,25 @@ interface Props {
|
|
|
78
116
|
* `backdrop` / handle their own Escape.
|
|
79
117
|
*/
|
|
80
118
|
manualClose?: boolean
|
|
119
|
+
/**
|
|
120
|
+
* Switches to the scrollable layout — a flex column capped at 90vh
|
|
121
|
+
* with a fixed header/footer and a scrolling body. Use for large
|
|
122
|
+
* content modals (request detail, import preview, …) rather than the
|
|
123
|
+
* compact confirm-style default.
|
|
124
|
+
*/
|
|
125
|
+
scrollable?: boolean
|
|
126
|
+
/**
|
|
127
|
+
* Optional muted line under the title. Only rendered in the
|
|
128
|
+
* scrollable layout.
|
|
129
|
+
*/
|
|
130
|
+
subtitle?: string
|
|
81
131
|
}
|
|
82
132
|
|
|
83
133
|
const props = withDefaults(defineProps<Props>(), {
|
|
84
134
|
maxWidthClass: 'max-w-md',
|
|
85
135
|
manualClose: false,
|
|
136
|
+
scrollable: false,
|
|
137
|
+
subtitle: '',
|
|
86
138
|
})
|
|
87
139
|
|
|
88
140
|
const emit = defineEmits<{
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import EarningsCard from './EarningsCard.vue'
|
|
4
|
+
|
|
5
|
+
describe('EarningsCard', () => {
|
|
6
|
+
it('formats a finite amount with currency and decimals', () => {
|
|
7
|
+
const w = mount(EarningsCard, { props: { amount: 1234.5, currency: '$', decimals: 2 } })
|
|
8
|
+
expect(w.text()).toContain('$1,234.50')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('falls back to a zeroed amount when given NaN (never renders "NaN")', () => {
|
|
12
|
+
const w = mount(EarningsCard, { props: { amount: Number.NaN } })
|
|
13
|
+
expect(w.text()).not.toContain('NaN')
|
|
14
|
+
expect(w.text()).toContain('$0.00')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to a zeroed amount when given Infinity', () => {
|
|
18
|
+
const w = mount(EarningsCard, { props: { amount: Number.POSITIVE_INFINITY } })
|
|
19
|
+
expect(w.text()).not.toContain('Infinity')
|
|
20
|
+
expect(w.text()).toContain('$0.00')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('defaults to the orange accent for backward compatibility', () => {
|
|
24
|
+
const w = mount(EarningsCard, { props: { amount: 1 } })
|
|
25
|
+
expect(w.html()).toContain('border-orange-300')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies the emerald accent when requested', () => {
|
|
29
|
+
const w = mount(EarningsCard, { props: { amount: 1, accent: 'emerald' } })
|
|
30
|
+
expect(w.html()).toContain('border-emerald-300')
|
|
31
|
+
expect(w.html()).not.toContain('border-orange-300')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Accent colour of the card. Defaults to `orange` so existing consumers are
|
|
6
|
+
* unaffected; `emerald` lets a host app tint the card to a green-themed brand.
|
|
7
|
+
* Each accent carries the full literal Tailwind class strings (rather than a
|
|
8
|
+
* single interpolated hue) so the classes are statically detectable and the
|
|
9
|
+
* two themes can differ in more than hue — e.g. orange renders the amount in
|
|
10
|
+
* neutral grey while emerald renders it in the accent itself.
|
|
11
|
+
*/
|
|
12
|
+
type Accent = 'orange' | 'emerald'
|
|
13
|
+
|
|
4
14
|
interface Props {
|
|
5
15
|
title?: string
|
|
6
16
|
amount?: number
|
|
@@ -8,6 +18,7 @@ interface Props {
|
|
|
8
18
|
badge?: string
|
|
9
19
|
currency?: string
|
|
10
20
|
decimals?: number
|
|
21
|
+
accent?: Accent
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -17,22 +28,61 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
17
28
|
badge: '',
|
|
18
29
|
currency: '$',
|
|
19
30
|
decimals: 2,
|
|
31
|
+
accent: 'orange',
|
|
20
32
|
})
|
|
21
33
|
|
|
34
|
+
const ACCENTS: Record<Accent, {
|
|
35
|
+
border: string
|
|
36
|
+
badge: string
|
|
37
|
+
amount: string
|
|
38
|
+
subtitle: string
|
|
39
|
+
iconBox: string
|
|
40
|
+
icon: string
|
|
41
|
+
}> = {
|
|
42
|
+
orange: {
|
|
43
|
+
border: 'border-orange-300 dark:border-orange-700',
|
|
44
|
+
badge: 'bg-orange-100 text-orange-500 dark:bg-orange-900/40 dark:text-orange-400',
|
|
45
|
+
amount: 'text-gray-400 dark:text-slate-300',
|
|
46
|
+
subtitle: 'text-orange-400 dark:text-orange-400',
|
|
47
|
+
iconBox: 'bg-orange-50 dark:bg-orange-900/30',
|
|
48
|
+
icon: 'text-orange-400',
|
|
49
|
+
},
|
|
50
|
+
emerald: {
|
|
51
|
+
border: 'border-emerald-300 dark:border-emerald-700',
|
|
52
|
+
badge: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400',
|
|
53
|
+
amount: 'text-emerald-500 dark:text-emerald-400',
|
|
54
|
+
subtitle: 'text-emerald-500 dark:text-emerald-400',
|
|
55
|
+
iconBox: 'bg-emerald-50 dark:bg-emerald-900/30',
|
|
56
|
+
icon: 'text-emerald-500',
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cls = computed(() => ACCENTS[props.accent])
|
|
61
|
+
|
|
22
62
|
const formattedAmount = computed(() => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
63
|
+
// Guard against NaN/Infinity reaching toLocaleString (which would render the
|
|
64
|
+
// literal "NaN"); fall back to a zeroed amount so the card stays readable.
|
|
65
|
+
const amount = Number.isFinite(props.amount) ? props.amount : 0
|
|
66
|
+
return (
|
|
67
|
+
props.currency +
|
|
68
|
+
amount.toLocaleString('en-US', {
|
|
69
|
+
minimumFractionDigits: props.decimals,
|
|
70
|
+
maximumFractionDigits: props.decimals,
|
|
71
|
+
})
|
|
72
|
+
)
|
|
27
73
|
})
|
|
28
74
|
</script>
|
|
29
75
|
|
|
30
76
|
<template>
|
|
31
|
-
<div
|
|
77
|
+
<div
|
|
78
|
+
class="relative rounded-xl border-2 border-dashed bg-white p-6 dark:bg-slate-800"
|
|
79
|
+
:class="cls.border"
|
|
80
|
+
>
|
|
32
81
|
<!-- Badge -->
|
|
33
82
|
<div
|
|
34
83
|
v-if="badge"
|
|
35
|
-
class="absolute right-4 top-4 rounded-md
|
|
84
|
+
class="absolute right-4 top-4 rounded-md px-3 py-1 text-xs font-bold tracking-wide"
|
|
85
|
+
:class="cls.badge"
|
|
36
86
|
>
|
|
37
87
|
{{ badge }}
|
|
38
88
|
</div>
|
|
@@ -46,20 +96,24 @@ const formattedAmount = computed(() => {
|
|
|
46
96
|
</p>
|
|
47
97
|
|
|
48
98
|
<!-- Amount -->
|
|
49
|
-
<p class="mt-1 text-4xl font-bold
|
|
99
|
+
<p class="mt-1 text-4xl font-bold" :class="cls.amount">
|
|
50
100
|
{{ formattedAmount }}
|
|
51
101
|
</p>
|
|
52
102
|
|
|
53
103
|
<!-- Subtitle -->
|
|
54
|
-
<p class="mt-1 text-sm font-medium
|
|
104
|
+
<p class="mt-1 text-sm font-medium" :class="cls.subtitle">
|
|
55
105
|
{{ subtitle }}
|
|
56
106
|
</p>
|
|
57
107
|
</div>
|
|
58
108
|
|
|
59
109
|
<!-- Chart icon -->
|
|
60
|
-
<div
|
|
110
|
+
<div
|
|
111
|
+
class="mt-6 flex h-12 w-12 items-center justify-center rounded-xl"
|
|
112
|
+
:class="cls.iconBox"
|
|
113
|
+
>
|
|
61
114
|
<svg
|
|
62
|
-
class="h-6 w-6
|
|
115
|
+
class="h-6 w-6"
|
|
116
|
+
:class="cls.icon"
|
|
63
117
|
viewBox="0 0 24 24"
|
|
64
118
|
fill="none"
|
|
65
119
|
stroke="currentColor"
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { default as BaseAppLayout } from './components/BaseAppLayout.vue'
|
|
|
29
29
|
export { default as BasePageHeader } from './components/BasePageHeader.vue'
|
|
30
30
|
export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
|
|
31
31
|
export { default as BaseActionButton } from './components/BaseActionButton.vue'
|
|
32
|
+
export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
|
|
32
33
|
|
|
33
34
|
// Composables
|
|
34
35
|
export { useTheme } from './composables/useTheme'
|
package/src/utils/pnl.test.ts
CHANGED
|
@@ -42,4 +42,10 @@ describe('computePnL', () => {
|
|
|
42
42
|
expect(computePnL({ buyPrice: 0, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
43
43
|
expect(computePnL({ buyPrice: -5, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
it('returns nulls when lastPrice is not positive (corrupt row / bad quote, not a real -100% loss)', () => {
|
|
47
|
+
const nulls = { pnlUsd: null, pnlPct: null }
|
|
48
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 0, filledQty: 2 })).toEqual(nulls)
|
|
49
|
+
expect(computePnL({ buyPrice: 100, lastPrice: -5, filledQty: 2 })).toEqual(nulls)
|
|
50
|
+
})
|
|
45
51
|
})
|
package/src/utils/pnl.ts
CHANGED
|
@@ -30,7 +30,12 @@ export function computePnL(row: PnLInputs): PnL {
|
|
|
30
30
|
const buy = Number(row.buyPrice)
|
|
31
31
|
const last = Number(row.lastPrice)
|
|
32
32
|
const qty = Number(row.filledQty)
|
|
33
|
-
|
|
33
|
+
// `last <= 0` mirrors the `buy <= 0` guard: a real traded price is always
|
|
34
|
+
// positive, so a zero/negative last price means a corrupt row or a bad quote
|
|
35
|
+
// parse — never an asset that genuinely went to zero. Without this, such a row
|
|
36
|
+
// yields pnlUsd = (0 - buy) * qty = -notional, a phantom ~100% loss that
|
|
37
|
+
// silently drags portfolio totals far negative. Treat it as unpriced (null).
|
|
38
|
+
if (!isFinite(buy) || !isFinite(last) || !isFinite(qty) || buy <= 0 || last <= 0) {
|
|
34
39
|
return { pnlUsd: null, pnlPct: null }
|
|
35
40
|
}
|
|
36
41
|
return {
|
package/tailwind.safelist.css
CHANGED
|
@@ -43,9 +43,25 @@
|
|
|
43
43
|
bg-red-700 hover:bg-red-800 focus:ring-red-300 bg-red-400
|
|
44
44
|
bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300
|
|
45
45
|
bg-purple-700 hover:bg-purple-800 focus:ring-purple-300 bg-purple-400
|
|
46
|
+
bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-300 bg-emerald-400
|
|
47
|
+
bg-sky-600 hover:bg-sky-700 focus:ring-sky-300 bg-sky-400
|
|
48
|
+
bg-amber-500 hover:bg-amber-600 focus:ring-amber-300 bg-amber-300
|
|
49
|
+
bg-gray-600 hover:bg-gray-700
|
|
46
50
|
px-5 py-2.5 mr-2 mb-2 py-2 text-base py-3 px-6 py-3.5
|
|
47
|
-
font-medium rounded-lg rounded-full cursor-not-allowed
|
|
51
|
+
font-medium rounded-lg rounded-full cursor-not-allowed cursor-pointer
|
|
48
52
|
inline w-4 h-4 me-3 animate-spin
|
|
53
|
+
border bg-transparent opacity-50 opacity-60
|
|
54
|
+
text-{blue,green,emerald,sky,red,amber,yellow,purple}-700
|
|
55
|
+
border-{blue,green,emerald,sky,red,amber,yellow,purple}-300
|
|
56
|
+
hover:bg-{blue,green,emerald,sky,red,amber,yellow,purple}-50
|
|
57
|
+
text-{blue,green,emerald,sky,red,amber,yellow,purple}-400
|
|
58
|
+
border-{blue,green,emerald,sky,red,amber,yellow,purple}-500/40
|
|
59
|
+
hover:bg-{blue,green,emerald,sky,red,amber,yellow,purple}-500/10
|
|
60
|
+
text-{blue,green,emerald,sky,yellow,purple}-600
|
|
61
|
+
text-red-500 text-amber-300
|
|
62
|
+
hover:bg-gray-100
|
|
63
|
+
text-gray-700 border-gray-300 hover:bg-gray-50
|
|
64
|
+
text-gray-500 text-gray-400 text-gray-300 border-gray-600 hover:bg-gray-800
|
|
49
65
|
");
|
|
50
66
|
|
|
51
67
|
/* ── BaseToast ────────────────────────────────────────────── */
|
package/tailwind.safelist.js
CHANGED
|
@@ -97,6 +97,23 @@ const safelist = [
|
|
|
97
97
|
'hover:bg-purple-800',
|
|
98
98
|
'focus:ring-purple-300',
|
|
99
99
|
'bg-purple-400',
|
|
100
|
+
// EMERALD/SKY/AMBER/GRAY (filled) + outline + ghost variants
|
|
101
|
+
'bg-emerald-600', 'hover:bg-emerald-700', 'focus:ring-emerald-300', 'bg-emerald-400',
|
|
102
|
+
'bg-sky-600', 'hover:bg-sky-700', 'focus:ring-sky-300', 'bg-sky-400',
|
|
103
|
+
'bg-amber-500', 'hover:bg-amber-600', 'focus:ring-amber-300', 'bg-amber-300',
|
|
104
|
+
'bg-gray-600', 'hover:bg-gray-700',
|
|
105
|
+
'border', 'bg-transparent', 'opacity-50', 'opacity-60', 'cursor-pointer',
|
|
106
|
+
// outline tones
|
|
107
|
+
...['blue', 'green', 'emerald', 'sky', 'red', 'amber', 'yellow', 'purple'].flatMap(c => [
|
|
108
|
+
`text-${c}-700`, `border-${c}-300`, `hover:bg-${c}-50`,
|
|
109
|
+
`text-${c}-400`, `border-${c}-500/40`, `hover:bg-${c}-500/10`,
|
|
110
|
+
]),
|
|
111
|
+
// ghost tones (light 500/600 text, dark 300/400 text)
|
|
112
|
+
...['blue', 'green', 'emerald', 'sky', 'yellow', 'purple'].flatMap(c => [`text-${c}-600`]),
|
|
113
|
+
'text-red-500', 'text-amber-300',
|
|
114
|
+
'hover:bg-gray-100',
|
|
115
|
+
'text-gray-700', 'border-gray-300', 'hover:bg-gray-50',
|
|
116
|
+
'text-gray-500', 'text-gray-400', 'text-gray-300', 'border-gray-600', 'hover:bg-gray-800',
|
|
100
117
|
// Dynamic: switch on size
|
|
101
118
|
'px-5',
|
|
102
119
|
'py-2.5',
|