minm-ui 1.0.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/package.json +15 -0
- package/src/components/UiAreaChart.vue +117 -0
- package/src/components/UiBadge.vue +34 -0
- package/src/components/UiBarChart.vue +50 -0
- package/src/components/UiButton.vue +54 -0
- package/src/components/UiCard.vue +28 -0
- package/src/components/UiDonutChart.vue +73 -0
- package/src/components/UiDrawer.vue +65 -0
- package/src/components/UiEmptyState.vue +17 -0
- package/src/components/UiIcon.vue +76 -0
- package/src/components/UiInput.vue +50 -0
- package/src/components/UiModal.vue +63 -0
- package/src/components/UiPagination.vue +70 -0
- package/src/components/UiSelect.vue +53 -0
- package/src/components/UiSkeleton.vue +17 -0
- package/src/components/UiSpinner.vue +11 -0
- package/src/components/UiStatTile.vue +49 -0
- package/src/components/UiTable.vue +139 -0
- package/src/components/UiTextarea.vue +35 -0
- package/src/components/UiToaster.vue +47 -0
- package/src/composables/useToast.ts +43 -0
- package/src/index.ts +24 -0
- package/src/styles/tokens.css +199 -0
- package/src/types.ts +9 -0
- package/tailwind-preset.cjs +94 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import UiIcon from './UiIcon.vue';
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
currentPage: number;
|
|
7
|
+
lastPage: number;
|
|
8
|
+
total?: number;
|
|
9
|
+
from?: number | null;
|
|
10
|
+
to?: number | null;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{ (e: 'change', page: number): void }>();
|
|
14
|
+
|
|
15
|
+
// Compact page window around the current page.
|
|
16
|
+
const pages = computed(() => {
|
|
17
|
+
const { currentPage: c, lastPage: l } = props;
|
|
18
|
+
const out: (number | '…')[] = [];
|
|
19
|
+
const push = (n: number | '…') => out.push(n);
|
|
20
|
+
const window = 1;
|
|
21
|
+
for (let p = 1; p <= l; p++) {
|
|
22
|
+
if (p === 1 || p === l || (p >= c - window && p <= c + window)) push(p);
|
|
23
|
+
else if (out[out.length - 1] !== '…') push('…');
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function go(p: number) {
|
|
29
|
+
if (p >= 1 && p <= props.lastPage && p !== props.currentPage) emit('change', p);
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3 text-sm">
|
|
35
|
+
<p class="text-muted">
|
|
36
|
+
<span v-if="total != null" class="tabular">
|
|
37
|
+
Showing <span class="font-medium text-content">{{ from ?? 0 }}</span>–<span class="font-medium text-content">{{ to ?? 0 }}</span>
|
|
38
|
+
of <span class="font-medium text-content">{{ total }}</span>
|
|
39
|
+
</span>
|
|
40
|
+
</p>
|
|
41
|
+
<div v-if="lastPage > 1" class="flex items-center gap-1">
|
|
42
|
+
<button
|
|
43
|
+
class="flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted transition-colors hover:bg-surface-hover disabled:opacity-40"
|
|
44
|
+
:disabled="currentPage <= 1"
|
|
45
|
+
@click="go(currentPage - 1)"
|
|
46
|
+
>
|
|
47
|
+
<UiIcon name="chevron-left" :size="16" />
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
v-for="(p, i) in pages"
|
|
51
|
+
:key="i"
|
|
52
|
+
:disabled="p === '…'"
|
|
53
|
+
:class="[
|
|
54
|
+
'tabular flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-[13px] font-medium transition-colors',
|
|
55
|
+
p === currentPage ? 'bg-accent text-accent-fg' : p === '…' ? 'text-subtle' : 'border border-border text-muted hover:bg-surface-hover',
|
|
56
|
+
]"
|
|
57
|
+
@click="typeof p === 'number' && go(p)"
|
|
58
|
+
>
|
|
59
|
+
{{ p }}
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
class="flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted transition-colors hover:bg-surface-hover disabled:opacity-40"
|
|
63
|
+
:disabled="currentPage >= lastPage"
|
|
64
|
+
@click="go(currentPage + 1)"
|
|
65
|
+
>
|
|
66
|
+
<UiIcon name="chevron-right" :size="16" />
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import UiIcon from './UiIcon.vue';
|
|
3
|
+
|
|
4
|
+
interface Option { label: string; value: string | number | boolean | null }
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
modelValue?: string | number | boolean | null;
|
|
8
|
+
label?: string;
|
|
9
|
+
options: Option[];
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
hint?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
defineEmits<{ (e: 'update:modelValue', value: string): void }>();
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<label class="block">
|
|
22
|
+
<span v-if="label" class="mb-1.5 block text-[13px] font-medium text-content">
|
|
23
|
+
{{ label }}
|
|
24
|
+
<span v-if="required" class="text-danger">*</span>
|
|
25
|
+
</span>
|
|
26
|
+
<div class="relative">
|
|
27
|
+
<select
|
|
28
|
+
:value="modelValue as any"
|
|
29
|
+
:disabled="disabled"
|
|
30
|
+
:class="[
|
|
31
|
+
'h-10 w-full appearance-none rounded-md border bg-surface pl-3 pr-9 text-sm text-content transition-colors',
|
|
32
|
+
'focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-60',
|
|
33
|
+
error ? 'border-danger' : 'border-border focus:border-accent',
|
|
34
|
+
]"
|
|
35
|
+
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
|
36
|
+
>
|
|
37
|
+
<option v-if="placeholder" value="" disabled :selected="modelValue === '' || modelValue == null">
|
|
38
|
+
{{ placeholder }}
|
|
39
|
+
</option>
|
|
40
|
+
<option v-for="opt in options" :key="String(opt.value)" :value="opt.value as any">
|
|
41
|
+
{{ opt.label }}
|
|
42
|
+
</option>
|
|
43
|
+
</select>
|
|
44
|
+
<UiIcon
|
|
45
|
+
name="chevron-down"
|
|
46
|
+
:size="17"
|
|
47
|
+
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-subtle"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
<p v-if="error" class="mt-1 text-xs text-danger">{{ error }}</p>
|
|
51
|
+
<p v-else-if="hint" class="mt-1 text-xs text-muted">{{ hint }}</p>
|
|
52
|
+
</label>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(defineProps<{ width?: string; height?: string; rounded?: string }>(), {
|
|
3
|
+
height: '1rem',
|
|
4
|
+
});
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<span
|
|
9
|
+
class="relative block overflow-hidden bg-surface-2"
|
|
10
|
+
:style="{ width: width || '100%', height, borderRadius: rounded || 'var(--radius-sm)' }"
|
|
11
|
+
>
|
|
12
|
+
<span
|
|
13
|
+
class="absolute inset-0 -translate-x-full"
|
|
14
|
+
style="background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--border-strong) 40%, transparent), transparent); animation: shimmer 1.5s infinite;"
|
|
15
|
+
/>
|
|
16
|
+
</span>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import UiIcon from './UiIcon.vue';
|
|
3
|
+
withDefaults(defineProps<{ size?: number; label?: string }>(), { size: 22 });
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div class="flex items-center justify-center gap-2 text-muted">
|
|
8
|
+
<UiIcon name="refresh" :size="size" class="animate-spin" />
|
|
9
|
+
<span v-if="label" class="text-sm">{{ label }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import UiIcon from './UiIcon.vue';
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
label: string;
|
|
7
|
+
value: string | number;
|
|
8
|
+
icon?: string;
|
|
9
|
+
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
|
10
|
+
delta?: number | null; // percentage change; sign drives colour/arrow
|
|
11
|
+
deltaLabel?: string;
|
|
12
|
+
hint?: string;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const toneBg: Record<string, string> = {
|
|
16
|
+
accent: 'bg-accent-soft text-accent-soft-fg',
|
|
17
|
+
success: 'bg-success-bg text-success',
|
|
18
|
+
warning: 'bg-warning-bg text-warning',
|
|
19
|
+
danger: 'bg-danger-bg text-danger',
|
|
20
|
+
info: 'bg-info-bg text-info',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const deltaUp = computed(() => (props.delta ?? 0) >= 0);
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="rounded-lg border border-border bg-surface p-5 shadow-sm transition-shadow hover:shadow-md">
|
|
28
|
+
<div class="flex items-start justify-between gap-3">
|
|
29
|
+
<p class="text-[13px] font-medium text-muted">{{ label }}</p>
|
|
30
|
+
<span
|
|
31
|
+
v-if="icon"
|
|
32
|
+
:class="['flex h-9 w-9 shrink-0 items-center justify-center rounded-lg', toneBg[tone || 'accent']]"
|
|
33
|
+
>
|
|
34
|
+
<UiIcon :name="icon" :size="18" />
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
<p class="tabular mt-3 text-[28px] font-semibold leading-none text-content">{{ value }}</p>
|
|
38
|
+
<div class="mt-2 flex items-center gap-2 text-xs">
|
|
39
|
+
<span
|
|
40
|
+
v-if="delta !== null && delta !== undefined"
|
|
41
|
+
:class="['inline-flex items-center gap-0.5 font-medium', deltaUp ? 'text-success' : 'text-danger']"
|
|
42
|
+
>
|
|
43
|
+
<UiIcon :name="deltaUp ? 'trend-up' : 'trend-down'" :size="14" />
|
|
44
|
+
{{ Math.abs(delta) }}%
|
|
45
|
+
</span>
|
|
46
|
+
<span v-if="deltaLabel || hint" class="text-subtle">{{ deltaLabel || hint }}</span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import UiIcon from './UiIcon.vue';
|
|
3
|
+
import UiSkeleton from './UiSkeleton.vue';
|
|
4
|
+
import type { Column } from '../types';
|
|
5
|
+
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
columns: Column[];
|
|
10
|
+
rows: Record<string, any>[];
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
sort?: string;
|
|
13
|
+
order?: 'asc' | 'desc';
|
|
14
|
+
rowKey?: string;
|
|
15
|
+
clickable?: boolean;
|
|
16
|
+
selectable?: boolean;
|
|
17
|
+
selected?: (string | number)[];
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
(e: 'sort', key: string): void;
|
|
22
|
+
(e: 'row-click', row: Record<string, any>): void;
|
|
23
|
+
(e: 'update:selected', ids: (string | number)[]): void;
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
function alignClass(a?: string) {
|
|
27
|
+
return a === 'right' ? 'text-right' : a === 'center' ? 'text-center' : 'text-left';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function onSort(col: Column) {
|
|
31
|
+
if (col.sortable) emit('sort', col.key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const keyOf = (row: Record<string, any>) => row[props.rowKey || 'id'];
|
|
35
|
+
const selectedSet = computed(() => new Set(props.selected ?? []));
|
|
36
|
+
const allSelected = computed(() =>
|
|
37
|
+
props.rows.length > 0 && props.rows.every((r) => selectedSet.value.has(keyOf(r))));
|
|
38
|
+
|
|
39
|
+
function toggleRow(row: Record<string, any>) {
|
|
40
|
+
const id = keyOf(row);
|
|
41
|
+
const next = new Set(props.selected ?? []);
|
|
42
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
43
|
+
emit('update:selected', [...next]);
|
|
44
|
+
}
|
|
45
|
+
function toggleAll() {
|
|
46
|
+
emit('update:selected', allSelected.value ? [] : props.rows.map(keyOf));
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div class="overflow-x-auto">
|
|
52
|
+
<table class="w-full border-collapse text-sm">
|
|
53
|
+
<thead>
|
|
54
|
+
<tr class="border-b border-border">
|
|
55
|
+
<th v-if="selectable" class="w-10 px-4 py-3">
|
|
56
|
+
<input type="checkbox" class="ui-checkbox" :checked="allSelected" @change="toggleAll" />
|
|
57
|
+
</th>
|
|
58
|
+
<th
|
|
59
|
+
v-for="col in columns"
|
|
60
|
+
:key="col.key"
|
|
61
|
+
:style="col.width ? { width: col.width } : undefined"
|
|
62
|
+
:class="[
|
|
63
|
+
'whitespace-nowrap px-4 py-3 text-[12px] font-semibold uppercase tracking-wide text-subtle',
|
|
64
|
+
alignClass(col.align),
|
|
65
|
+
col.sortable ? 'cursor-pointer select-none hover:text-content' : '',
|
|
66
|
+
]"
|
|
67
|
+
@click="onSort(col)"
|
|
68
|
+
>
|
|
69
|
+
<span class="inline-flex items-center gap-1" :class="col.align === 'right' ? 'flex-row-reverse' : ''">
|
|
70
|
+
{{ col.label }}
|
|
71
|
+
<UiIcon
|
|
72
|
+
v-if="col.sortable && sort === col.key"
|
|
73
|
+
:name="order === 'asc' ? 'chevron-up' : 'chevron-down'"
|
|
74
|
+
:size="14"
|
|
75
|
+
class="text-accent"
|
|
76
|
+
/>
|
|
77
|
+
</span>
|
|
78
|
+
</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
|
|
82
|
+
<tbody>
|
|
83
|
+
<!-- Loading skeleton -->
|
|
84
|
+
<template v-if="loading">
|
|
85
|
+
<tr v-for="n in 6" :key="`sk-${n}`" class="border-b border-border">
|
|
86
|
+
<td v-if="selectable" class="px-4 py-3.5"><UiSkeleton width="1rem" height="1rem" /></td>
|
|
87
|
+
<td v-for="col in columns" :key="col.key" class="px-4 py-3.5">
|
|
88
|
+
<UiSkeleton :width="col.key === columns[0].key ? '60%' : '80%'" height="0.9rem" />
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<!-- Rows -->
|
|
94
|
+
<template v-else>
|
|
95
|
+
<tr
|
|
96
|
+
v-for="(row, i) in rows"
|
|
97
|
+
:key="rowKey ? row[rowKey] : i"
|
|
98
|
+
:class="[
|
|
99
|
+
'border-b border-border transition-colors',
|
|
100
|
+
selectedSet.has(keyOf(row)) ? 'bg-accent-soft/40' : '',
|
|
101
|
+
clickable ? 'cursor-pointer hover:bg-surface-hover' : 'hover:bg-surface-2',
|
|
102
|
+
]"
|
|
103
|
+
@click="clickable && emit('row-click', row)"
|
|
104
|
+
>
|
|
105
|
+
<td v-if="selectable" class="px-4 py-3" @click.stop>
|
|
106
|
+
<input type="checkbox" class="ui-checkbox" :checked="selectedSet.has(keyOf(row))" @change="toggleRow(row)" />
|
|
107
|
+
</td>
|
|
108
|
+
<td
|
|
109
|
+
v-for="col in columns"
|
|
110
|
+
:key="col.key"
|
|
111
|
+
:class="['px-4 py-3 align-middle text-content', alignClass(col.align), col.class]"
|
|
112
|
+
>
|
|
113
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
|
|
114
|
+
{{ row[col.key] ?? '—' }}
|
|
115
|
+
</slot>
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
</template>
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
|
|
122
|
+
<!-- Empty -->
|
|
123
|
+
<div v-if="!loading && rows.length === 0">
|
|
124
|
+
<slot name="empty">
|
|
125
|
+
<div class="px-4 py-12 text-center text-sm text-muted">No records found.</div>
|
|
126
|
+
</slot>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
|
|
131
|
+
<style scoped>
|
|
132
|
+
.ui-checkbox {
|
|
133
|
+
width: 1rem;
|
|
134
|
+
height: 1rem;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
accent-color: var(--accent);
|
|
137
|
+
border-radius: 4px;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
modelValue?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
rows?: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
defineEmits<{ (e: 'update:modelValue', value: string): void }>();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<label class="block">
|
|
17
|
+
<span v-if="label" class="mb-1.5 block text-[13px] font-medium text-content">
|
|
18
|
+
{{ label }}
|
|
19
|
+
<span v-if="required" class="text-danger">*</span>
|
|
20
|
+
</span>
|
|
21
|
+
<textarea
|
|
22
|
+
:value="modelValue"
|
|
23
|
+
:rows="rows || 3"
|
|
24
|
+
:placeholder="placeholder"
|
|
25
|
+
:class="[
|
|
26
|
+
'w-full rounded-md border bg-surface px-3 py-2 text-sm text-content transition-colors resize-y',
|
|
27
|
+
'placeholder:text-subtle focus:outline-none focus:ring-2 focus:ring-[var(--ring)]',
|
|
28
|
+
error ? 'border-danger' : 'border-border focus:border-accent',
|
|
29
|
+
]"
|
|
30
|
+
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
|
31
|
+
/>
|
|
32
|
+
<p v-if="error" class="mt-1 text-xs text-danger">{{ error }}</p>
|
|
33
|
+
<p v-else-if="hint" class="mt-1 text-xs text-muted">{{ hint }}</p>
|
|
34
|
+
</label>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useToast } from '../composables/useToast';
|
|
3
|
+
import UiIcon from './UiIcon.vue';
|
|
4
|
+
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
|
|
7
|
+
const config = {
|
|
8
|
+
success: { icon: 'check_circle', class: 'text-success', bar: 'bg-success' },
|
|
9
|
+
error: { icon: 'alert', class: 'text-danger', bar: 'bg-danger' },
|
|
10
|
+
warning: { icon: 'alert', class: 'text-warning', bar: 'bg-warning' },
|
|
11
|
+
info: { icon: 'bell', class: 'text-info', bar: 'bg-info' },
|
|
12
|
+
} as const;
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<Teleport to="body">
|
|
17
|
+
<div class="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-full max-w-sm flex-col gap-2.5">
|
|
18
|
+
<TransitionGroup name="toast">
|
|
19
|
+
<div
|
|
20
|
+
v-for="t in toast.items"
|
|
21
|
+
:key="t.id"
|
|
22
|
+
class="pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-lg border border-border bg-surface p-4 pr-10 shadow-lg"
|
|
23
|
+
>
|
|
24
|
+
<span :class="['absolute inset-y-0 left-0 w-1', config[t.type].bar]" />
|
|
25
|
+
<UiIcon :name="config[t.type].icon" :size="19" :class="['mt-0.5 shrink-0', config[t.type].class]" />
|
|
26
|
+
<div class="min-w-0">
|
|
27
|
+
<p class="text-sm font-semibold text-content">{{ t.title }}</p>
|
|
28
|
+
<p v-if="t.message" class="mt-0.5 text-[13px] text-muted">{{ t.message }}</p>
|
|
29
|
+
</div>
|
|
30
|
+
<button
|
|
31
|
+
class="absolute right-2 top-2 rounded-md p-1 text-subtle transition-colors hover:bg-surface-hover hover:text-content"
|
|
32
|
+
@click="toast.dismiss(t.id)"
|
|
33
|
+
>
|
|
34
|
+
<UiIcon name="x" :size="15" />
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</TransitionGroup>
|
|
38
|
+
</div>
|
|
39
|
+
</Teleport>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<style scoped>
|
|
43
|
+
.toast-enter-active, .toast-leave-active { transition: all 0.3s cubic-bezier(0.16,1,0.3,1); }
|
|
44
|
+
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
|
45
|
+
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
|
46
|
+
.toast-move { transition: transform 0.3s ease; }
|
|
47
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { reactive } from 'vue';
|
|
2
|
+
|
|
3
|
+
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
|
4
|
+
|
|
5
|
+
export interface Toast {
|
|
6
|
+
id: number;
|
|
7
|
+
type: ToastType;
|
|
8
|
+
title: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const state = reactive<{ items: Toast[] }>({ items: [] });
|
|
13
|
+
let seq = 0;
|
|
14
|
+
|
|
15
|
+
function push(type: ToastType, title: string, message?: string, duration = 4000) {
|
|
16
|
+
const id = ++seq;
|
|
17
|
+
state.items.push({ id, type, title, message });
|
|
18
|
+
if (duration > 0) {
|
|
19
|
+
setTimeout(() => dismiss(id), duration);
|
|
20
|
+
}
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dismiss(id: number) {
|
|
25
|
+
const i = state.items.findIndex((t) => t.id === id);
|
|
26
|
+
if (i !== -1) state.items.splice(i, 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Global toast API. Import anywhere:
|
|
31
|
+
* const toast = useToast();
|
|
32
|
+
* toast.success('Saved', 'Location updated.');
|
|
33
|
+
*/
|
|
34
|
+
export function useToast() {
|
|
35
|
+
return {
|
|
36
|
+
items: state.items,
|
|
37
|
+
dismiss,
|
|
38
|
+
success: (title: string, message?: string) => push('success', title, message),
|
|
39
|
+
error: (title: string, message?: string) => push('error', title, message, 6000),
|
|
40
|
+
info: (title: string, message?: string) => push('info', title, message),
|
|
41
|
+
warning: (title: string, message?: string) => push('warning', title, message),
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @confast/ui — shared design system for all Confast web apps.
|
|
2
|
+
export { default as UiIcon } from './components/UiIcon.vue';
|
|
3
|
+
export { default as UiButton } from './components/UiButton.vue';
|
|
4
|
+
export { default as UiInput } from './components/UiInput.vue';
|
|
5
|
+
export { default as UiSelect } from './components/UiSelect.vue';
|
|
6
|
+
export { default as UiTextarea } from './components/UiTextarea.vue';
|
|
7
|
+
export { default as UiBadge } from './components/UiBadge.vue';
|
|
8
|
+
export { default as UiCard } from './components/UiCard.vue';
|
|
9
|
+
export { default as UiStatTile } from './components/UiStatTile.vue';
|
|
10
|
+
export { default as UiSpinner } from './components/UiSpinner.vue';
|
|
11
|
+
export { default as UiSkeleton } from './components/UiSkeleton.vue';
|
|
12
|
+
export { default as UiEmptyState } from './components/UiEmptyState.vue';
|
|
13
|
+
export { default as UiModal } from './components/UiModal.vue';
|
|
14
|
+
export { default as UiDrawer } from './components/UiDrawer.vue';
|
|
15
|
+
export { default as UiTable } from './components/UiTable.vue';
|
|
16
|
+
export { default as UiPagination } from './components/UiPagination.vue';
|
|
17
|
+
export { default as UiToaster } from './components/UiToaster.vue';
|
|
18
|
+
export { default as UiAreaChart } from './components/UiAreaChart.vue';
|
|
19
|
+
export { default as UiBarChart } from './components/UiBarChart.vue';
|
|
20
|
+
export { default as UiDonutChart } from './components/UiDonutChart.vue';
|
|
21
|
+
|
|
22
|
+
export { useToast } from './composables/useToast';
|
|
23
|
+
export type { Toast, ToastType } from './composables/useToast';
|
|
24
|
+
export type { Column } from './types';
|