@karyl-chan/ui 0.1.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 +58 -0
- package/src/components/AppBadge.vue +155 -0
- package/src/components/AppButton.vue +171 -0
- package/src/components/AppConfirmDialog.vue +176 -0
- package/src/components/AppItemCard.vue +216 -0
- package/src/components/AppMenu.vue +197 -0
- package/src/components/AppMenuItem.vue +43 -0
- package/src/components/AppModal.vue +257 -0
- package/src/components/AppPopover.vue +275 -0
- package/src/components/AppSelect.vue +89 -0
- package/src/components/AppSelectField.vue +260 -0
- package/src/components/AppTabs.vue +310 -0
- package/src/components/AppTabsRouted.vue +409 -0
- package/src/components/AppTextArea.vue +128 -0
- package/src/components/AppTextField.vue +165 -0
- package/src/components/AppToast.vue +65 -0
- package/src/components/AppToggle.vue +105 -0
- package/src/components/Draggable.vue +178 -0
- package/src/components/GlobalConfirmDialog.vue +25 -0
- package/src/components/UnreadPill.vue +28 -0
- package/src/components/UserAvatar.vue +114 -0
- package/src/components/UserCard.vue +274 -0
- package/src/components/UserItem.vue +167 -0
- package/src/components/tab-types.ts +9 -0
- package/src/composables/use-app-shell.ts +65 -0
- package/src/composables/use-breakpoint.ts +14 -0
- package/src/composables/use-click-outside-stack.ts +67 -0
- package/src/composables/use-color-scheme.ts +75 -0
- package/src/composables/use-confirm.ts +36 -0
- package/src/composables/use-drawer.css +47 -0
- package/src/composables/use-drawer.ts +58 -0
- package/src/composables/use-escape-stack.ts +80 -0
- package/src/composables/use-file-drop.ts +47 -0
- package/src/composables/use-long-press.ts +62 -0
- package/src/composables/use-popover.css +32 -0
- package/src/composables/use-popover.ts +1324 -0
- package/src/composables/use-shift-key.ts +18 -0
- package/src/index.ts +75 -0
- package/src/lib/avatar.ts +35 -0
- package/src/reset.css +53 -0
- package/src/stores/toastStore.ts +26 -0
- package/src/tokens.css +159 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karyl-chan/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared Vue UI primitives, composables, and design tokens used by karyl-chan's bot frontend and plugin webuis.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/karyl-chan/karyl-chan.git",
|
|
10
|
+
"directory": "packages/ui"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"import": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"./tokens.css": "./src/tokens.css",
|
|
21
|
+
"./reset.css": "./src/reset.css",
|
|
22
|
+
"./use-drawer.css": "./src/composables/use-drawer.css",
|
|
23
|
+
"./use-popover.css": "./src/composables/use-popover.css"
|
|
24
|
+
},
|
|
25
|
+
"files": ["src"],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "vue-tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@iconify/vue": ">=5",
|
|
34
|
+
"@popperjs/core": ">=2",
|
|
35
|
+
"nanoid": ">=3",
|
|
36
|
+
"pinia": ">=3",
|
|
37
|
+
"vue": ">=3.5",
|
|
38
|
+
"vue-router": ">=4"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"vue-router": { "optional": true }
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@iconify/vue": "^5.0.0",
|
|
45
|
+
"@popperjs/core": "^2.11.8",
|
|
46
|
+
"@vue/test-utils": "^2.4.8",
|
|
47
|
+
"@vue/tsconfig": "^0.7.0",
|
|
48
|
+
"jsdom": "^29.0.2",
|
|
49
|
+
"nanoid": "^3.3.11",
|
|
50
|
+
"pinia": "^3.0.4",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vite": "^6.0.7",
|
|
53
|
+
"vitest": "^4.1.5",
|
|
54
|
+
"vue": "^3.5.13",
|
|
55
|
+
"vue-router": "^4.5.0",
|
|
56
|
+
"vue-tsc": "^2.2.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { Icon } from '@iconify/vue';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compact inline label. Replaces the per-page .tag / .status-pill /
|
|
7
|
+
* .health-badge / .scope-chip / .stat classes that each carried their
|
|
8
|
+
* own colour-by-state logic.
|
|
9
|
+
*
|
|
10
|
+
* Two axes:
|
|
11
|
+
* - `tone` is semantic colour: neutral (default), accent, success,
|
|
12
|
+
* warn, danger.
|
|
13
|
+
* - `variant` is style strength: soft (subtle background, default),
|
|
14
|
+
* outline (border-only, transparent background), solid (filled).
|
|
15
|
+
*
|
|
16
|
+
* `size` is the visual weight only — md by default, sm for the tighter
|
|
17
|
+
* tag-row use case the bot's BehaviorCard trigger badges relied on.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warn' | 'danger';
|
|
21
|
+
export type BadgeVariant = 'soft' | 'outline' | 'solid';
|
|
22
|
+
export type BadgeSize = 'sm' | 'md';
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
tone?: BadgeTone;
|
|
26
|
+
variant?: BadgeVariant;
|
|
27
|
+
size?: BadgeSize;
|
|
28
|
+
/** Iconify icon shown before the label. */
|
|
29
|
+
icon?: string;
|
|
30
|
+
/** Render in monospaced font (e.g. for IDs / keys). */
|
|
31
|
+
mono?: boolean;
|
|
32
|
+
}>(), {
|
|
33
|
+
tone: 'neutral',
|
|
34
|
+
variant: 'soft',
|
|
35
|
+
size: 'md',
|
|
36
|
+
icon: '',
|
|
37
|
+
mono: false
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const classes = computed(() => [
|
|
41
|
+
'app-badge',
|
|
42
|
+
`app-badge--${props.variant}`,
|
|
43
|
+
`app-badge--tone-${props.tone}`,
|
|
44
|
+
`app-badge--${props.size}`,
|
|
45
|
+
{ 'app-badge--mono': props.mono }
|
|
46
|
+
]);
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<span :class="classes">
|
|
51
|
+
<!-- Icon sizes itself off Iconify's 1em default, which inherits
|
|
52
|
+
the badge's font-size — this keeps the icon proportional to
|
|
53
|
+
the label and avoids a Ref<number> being silently rejected
|
|
54
|
+
by Iconify's width/height customisation typing. -->
|
|
55
|
+
<Icon v-if="icon" :icon="icon" class="app-badge__icon" aria-hidden="true" />
|
|
56
|
+
<slot />
|
|
57
|
+
</span>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<style scoped>
|
|
61
|
+
.app-badge {
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
gap: 0.2rem;
|
|
65
|
+
border-radius: var(--radius-pill);
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
line-height: 1;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
border: 1px solid transparent;
|
|
71
|
+
}
|
|
72
|
+
.app-badge__icon {
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
/* Iconify uses font-size for its default 1em sizing; bumping it
|
|
75
|
+
slightly so the icon balances visually against the label
|
|
76
|
+
(Material Symbols icons read best at ~1.2× the text height). */
|
|
77
|
+
font-size: 1.2em;
|
|
78
|
+
}
|
|
79
|
+
.app-badge--md { font-size: 0.74rem; padding: 0.15rem 0.55rem; min-height: 18px; }
|
|
80
|
+
.app-badge--sm { font-size: 0.68rem; padding: 0.1rem 0.45rem; min-height: 15px; }
|
|
81
|
+
|
|
82
|
+
.app-badge--mono {
|
|
83
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
84
|
+
letter-spacing: 0.01em;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ───────── soft (default) ──────────────────────────────────────── */
|
|
88
|
+
.app-badge--soft.app-badge--tone-neutral {
|
|
89
|
+
background: var(--pill-bg, var(--bg-surface-2));
|
|
90
|
+
color: var(--text);
|
|
91
|
+
}
|
|
92
|
+
.app-badge--soft.app-badge--tone-accent {
|
|
93
|
+
background: var(--accent-bg);
|
|
94
|
+
color: var(--accent-text-strong, var(--accent));
|
|
95
|
+
}
|
|
96
|
+
.app-badge--soft.app-badge--tone-success {
|
|
97
|
+
background: var(--success-bg);
|
|
98
|
+
color: var(--success-text);
|
|
99
|
+
}
|
|
100
|
+
.app-badge--soft.app-badge--tone-warn {
|
|
101
|
+
background: var(--warn-bg);
|
|
102
|
+
color: var(--warn-text);
|
|
103
|
+
}
|
|
104
|
+
.app-badge--soft.app-badge--tone-danger {
|
|
105
|
+
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
|
106
|
+
color: var(--danger);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ───────── outline ─────────────────────────────────────────────── */
|
|
110
|
+
.app-badge--outline {
|
|
111
|
+
background: transparent;
|
|
112
|
+
}
|
|
113
|
+
.app-badge--outline.app-badge--tone-neutral {
|
|
114
|
+
border-color: var(--border);
|
|
115
|
+
color: var(--text-muted);
|
|
116
|
+
}
|
|
117
|
+
.app-badge--outline.app-badge--tone-accent {
|
|
118
|
+
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
|
|
119
|
+
color: var(--accent-text, var(--accent));
|
|
120
|
+
}
|
|
121
|
+
.app-badge--outline.app-badge--tone-success {
|
|
122
|
+
border-color: color-mix(in srgb, var(--success-text) 50%, transparent);
|
|
123
|
+
color: var(--success-text);
|
|
124
|
+
}
|
|
125
|
+
.app-badge--outline.app-badge--tone-warn {
|
|
126
|
+
border-color: color-mix(in srgb, var(--warn-text) 50%, transparent);
|
|
127
|
+
color: var(--warn-text);
|
|
128
|
+
}
|
|
129
|
+
.app-badge--outline.app-badge--tone-danger {
|
|
130
|
+
border-color: color-mix(in srgb, var(--danger) 50%, transparent);
|
|
131
|
+
color: var(--danger);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ───────── solid ───────────────────────────────────────────────── */
|
|
135
|
+
.app-badge--solid.app-badge--tone-neutral {
|
|
136
|
+
background: var(--text-muted);
|
|
137
|
+
color: var(--bg-surface);
|
|
138
|
+
}
|
|
139
|
+
.app-badge--solid.app-badge--tone-accent {
|
|
140
|
+
background: var(--accent);
|
|
141
|
+
color: var(--text-on-accent);
|
|
142
|
+
}
|
|
143
|
+
.app-badge--solid.app-badge--tone-success {
|
|
144
|
+
background: var(--success-text);
|
|
145
|
+
color: var(--success-bg);
|
|
146
|
+
}
|
|
147
|
+
.app-badge--solid.app-badge--tone-warn {
|
|
148
|
+
background: var(--warn-text);
|
|
149
|
+
color: var(--warn-bg);
|
|
150
|
+
}
|
|
151
|
+
.app-badge--solid.app-badge--tone-danger {
|
|
152
|
+
background: var(--danger);
|
|
153
|
+
color: var(--bg-surface);
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Icon } from '@iconify/vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AppButton — 通用按鈕元件
|
|
6
|
+
*
|
|
7
|
+
* 整合 codebase 中各頁面各自實作的 scoped button CSS,
|
|
8
|
+
* 提供一致的 variant / size / loading / disabled / block 語義。
|
|
9
|
+
*
|
|
10
|
+
* Props:
|
|
11
|
+
* variant — 視覺風格,預設 'primary'
|
|
12
|
+
* size — 尺寸,預設 'md'
|
|
13
|
+
* loading — 顯示旋轉 spinner;按鈕同時被 disabled
|
|
14
|
+
* disabled — 禁用
|
|
15
|
+
* block — 展開為 100% 寬度
|
|
16
|
+
* type — HTML button type,預設 'button'
|
|
17
|
+
* icon — 左側 iconify icon 名稱(亦可用 #icon slot)
|
|
18
|
+
*
|
|
19
|
+
* Slots:
|
|
20
|
+
* default — 按鈕文字
|
|
21
|
+
* icon — 左側圖示(優先於 `icon` prop)
|
|
22
|
+
* trailing — 右側圖示 / badge
|
|
23
|
+
*/
|
|
24
|
+
withDefaults(defineProps<{
|
|
25
|
+
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
26
|
+
size?: 'sm' | 'md' | 'lg';
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
block?: boolean;
|
|
30
|
+
type?: 'button' | 'submit';
|
|
31
|
+
icon?: string;
|
|
32
|
+
}>(), {
|
|
33
|
+
variant: 'primary',
|
|
34
|
+
size: 'md',
|
|
35
|
+
loading: false,
|
|
36
|
+
disabled: false,
|
|
37
|
+
block: false,
|
|
38
|
+
type: 'button',
|
|
39
|
+
icon: undefined,
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<button
|
|
45
|
+
:type="type"
|
|
46
|
+
:class="[
|
|
47
|
+
'app-btn',
|
|
48
|
+
`app-btn--${variant}`,
|
|
49
|
+
`app-btn--${size}`,
|
|
50
|
+
{ 'app-btn--block': block, 'app-btn--loading': loading },
|
|
51
|
+
]"
|
|
52
|
+
:disabled="disabled || loading"
|
|
53
|
+
>
|
|
54
|
+
<!-- spinner(loading 狀態)-->
|
|
55
|
+
<Icon
|
|
56
|
+
v-if="loading"
|
|
57
|
+
icon="material-symbols:progress-activity"
|
|
58
|
+
class="app-btn-spinner"
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<!-- 左側圖示:slot 優先,其次 icon prop -->
|
|
63
|
+
<slot v-if="!loading" name="icon">
|
|
64
|
+
<Icon
|
|
65
|
+
v-if="icon"
|
|
66
|
+
:icon="icon"
|
|
67
|
+
width="16"
|
|
68
|
+
height="16"
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
/>
|
|
71
|
+
</slot>
|
|
72
|
+
|
|
73
|
+
<!-- 主要 label -->
|
|
74
|
+
<span class="app-btn-label"><slot /></span>
|
|
75
|
+
|
|
76
|
+
<!-- 右側 slot -->
|
|
77
|
+
<slot name="trailing" />
|
|
78
|
+
</button>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<style scoped>
|
|
82
|
+
.app-btn {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: center;
|
|
86
|
+
gap: 0.35rem;
|
|
87
|
+
border-radius: var(--radius-sm);
|
|
88
|
+
font: inherit;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
transition: filter 0.1s, background 0.1s, opacity 0.1s;
|
|
93
|
+
flex-shrink: 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── block ──────────────────────────────────────────────────────── */
|
|
97
|
+
.app-btn--block {
|
|
98
|
+
width: 100%;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ── disabled / loading ─────────────────────────────────────────── */
|
|
102
|
+
.app-btn:disabled {
|
|
103
|
+
opacity: 0.55;
|
|
104
|
+
cursor: not-allowed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ── sizes ──────────────────────────────────────────────────────── */
|
|
108
|
+
.app-btn--sm {
|
|
109
|
+
padding: 0.3rem 0.6rem;
|
|
110
|
+
font-size: 0.82rem;
|
|
111
|
+
}
|
|
112
|
+
.app-btn--md {
|
|
113
|
+
padding: 0.45rem 0.85rem;
|
|
114
|
+
font-size: 0.88rem;
|
|
115
|
+
}
|
|
116
|
+
.app-btn--lg {
|
|
117
|
+
padding: 0.6rem 1.1rem;
|
|
118
|
+
font-size: 0.95rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ── primary ────────────────────────────────────────────────────── */
|
|
122
|
+
.app-btn--primary {
|
|
123
|
+
background: var(--accent);
|
|
124
|
+
color: var(--text-on-accent);
|
|
125
|
+
border: 1px solid var(--accent);
|
|
126
|
+
}
|
|
127
|
+
.app-btn--primary:not(:disabled):hover {
|
|
128
|
+
filter: brightness(1.1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ── secondary ──────────────────────────────────────────────────── */
|
|
132
|
+
.app-btn--secondary {
|
|
133
|
+
background: var(--bg-surface-2);
|
|
134
|
+
color: var(--text);
|
|
135
|
+
border: 1px solid var(--border);
|
|
136
|
+
}
|
|
137
|
+
.app-btn--secondary:not(:disabled):hover {
|
|
138
|
+
background: var(--bg-surface-hover);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── ghost ──────────────────────────────────────────────────────── */
|
|
142
|
+
.app-btn--ghost {
|
|
143
|
+
background: transparent;
|
|
144
|
+
color: var(--text);
|
|
145
|
+
border: 1px solid var(--border);
|
|
146
|
+
}
|
|
147
|
+
.app-btn--ghost:not(:disabled):hover {
|
|
148
|
+
background: var(--bg-surface-hover);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ── danger ─────────────────────────────────────────────────────── */
|
|
152
|
+
.app-btn--danger {
|
|
153
|
+
background: transparent;
|
|
154
|
+
color: var(--danger, #dc2626);
|
|
155
|
+
border: 1px solid color-mix(in srgb, var(--danger, #dc2626) 40%, transparent);
|
|
156
|
+
}
|
|
157
|
+
.app-btn--danger:not(:disabled):hover {
|
|
158
|
+
background: color-mix(in srgb, var(--danger, #dc2626) 8%, transparent);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ── spinner ─────────────────────────────────────────────────────── */
|
|
162
|
+
@keyframes app-btn-spin { to { transform: rotate(360deg); } }
|
|
163
|
+
.app-btn-spinner {
|
|
164
|
+
width: 14px;
|
|
165
|
+
height: 14px;
|
|
166
|
+
animation: app-btn-spin 0.8s linear infinite;
|
|
167
|
+
flex-shrink: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.app-btn-label { line-height: 1.2; }
|
|
171
|
+
</style>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AppConfirmDialog — generic confirmation dialog wrapping AppModal.
|
|
4
|
+
*
|
|
5
|
+
* `closeOnBackdrop` / `closeOnEscape` are automatically disabled when
|
|
6
|
+
* `loading` is true to prevent accidental dismissal during async work.
|
|
7
|
+
*
|
|
8
|
+
* Labels (`confirmLabel`, `cancelLabel`, `loadingLabel`) are passed in
|
|
9
|
+
* as props with English defaults — this component does not depend on
|
|
10
|
+
* vue-i18n. Consumers that need translation pass localized strings.
|
|
11
|
+
*/
|
|
12
|
+
import { Icon } from '@iconify/vue';
|
|
13
|
+
import AppModal from './AppModal.vue';
|
|
14
|
+
|
|
15
|
+
withDefaults(defineProps<{
|
|
16
|
+
visible: boolean;
|
|
17
|
+
title: string;
|
|
18
|
+
message: string;
|
|
19
|
+
confirmLabel: string;
|
|
20
|
+
/** Button colour: 'danger' = red, 'primary' = accent (default). */
|
|
21
|
+
confirmVariant?: 'primary' | 'danger';
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
/** Error message shown in a red box below the message. */
|
|
24
|
+
error?: string;
|
|
25
|
+
/** Extra status line, e.g. "Progress: 3 / 10" for batch operations. */
|
|
26
|
+
progress?: string;
|
|
27
|
+
/** Cancel button label. */
|
|
28
|
+
cancelLabel?: string;
|
|
29
|
+
/** Label shown on the primary button while `loading`. */
|
|
30
|
+
loadingLabel?: string;
|
|
31
|
+
}>(), {
|
|
32
|
+
confirmVariant: 'primary',
|
|
33
|
+
loading: false,
|
|
34
|
+
error: undefined,
|
|
35
|
+
progress: undefined,
|
|
36
|
+
cancelLabel: 'Cancel',
|
|
37
|
+
loadingLabel: 'Loading…',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const emit = defineEmits<{
|
|
41
|
+
(e: 'close'): void;
|
|
42
|
+
(e: 'confirm'): void;
|
|
43
|
+
}>();
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<AppModal
|
|
48
|
+
:visible="visible"
|
|
49
|
+
:title="title"
|
|
50
|
+
:close-on-backdrop="!loading"
|
|
51
|
+
:close-on-escape="!loading"
|
|
52
|
+
@close="emit('close')"
|
|
53
|
+
>
|
|
54
|
+
<div class="acd-body">
|
|
55
|
+
<p class="acd-message">{{ message }}</p>
|
|
56
|
+
<p v-if="progress" class="acd-progress">{{ progress }}</p>
|
|
57
|
+
<pre v-if="error" class="acd-error" role="alert">{{ error }}</pre>
|
|
58
|
+
<div class="acd-actions">
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="acd-btn acd-btn--ghost"
|
|
62
|
+
:disabled="loading"
|
|
63
|
+
@click="emit('close')"
|
|
64
|
+
>
|
|
65
|
+
{{ cancelLabel }}
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
:class="['acd-btn', confirmVariant === 'danger' ? 'acd-btn--danger' : 'acd-btn--primary']"
|
|
70
|
+
:disabled="loading"
|
|
71
|
+
@click="emit('confirm')"
|
|
72
|
+
>
|
|
73
|
+
<Icon
|
|
74
|
+
v-if="loading"
|
|
75
|
+
icon="material-symbols:progress-activity"
|
|
76
|
+
width="14"
|
|
77
|
+
height="14"
|
|
78
|
+
class="acd-spin"
|
|
79
|
+
/>
|
|
80
|
+
{{ loading ? loadingLabel : confirmLabel }}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</AppModal>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style scoped>
|
|
88
|
+
/* Inner wrapper: provides padding + flex-column layout + gap.
|
|
89
|
+
AppModal's body slots are structural-only (no padding), so each leaf
|
|
90
|
+
component is responsible for its own spacing. */
|
|
91
|
+
.acd-body {
|
|
92
|
+
padding: 0.9rem 1rem 0.75rem;
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
gap: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.acd-message {
|
|
99
|
+
margin: 0;
|
|
100
|
+
color: var(--text);
|
|
101
|
+
font-size: 0.9rem;
|
|
102
|
+
line-height: 1.5;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.acd-progress {
|
|
106
|
+
margin: 0;
|
|
107
|
+
color: var(--text-muted);
|
|
108
|
+
font-size: 0.85rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.acd-error {
|
|
112
|
+
margin: 0;
|
|
113
|
+
white-space: pre-wrap;
|
|
114
|
+
background: color-mix(in srgb, var(--danger, #dc2626) 8%, transparent);
|
|
115
|
+
border: 1px solid color-mix(in srgb, var(--danger, #dc2626) 30%, transparent);
|
|
116
|
+
border-radius: var(--radius-base);
|
|
117
|
+
padding: 0.5rem;
|
|
118
|
+
font-size: 0.78rem;
|
|
119
|
+
color: var(--danger, #dc2626);
|
|
120
|
+
max-height: 12rem;
|
|
121
|
+
overflow: auto;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.acd-actions {
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: flex-end;
|
|
127
|
+
gap: 0.5rem;
|
|
128
|
+
padding-top: 0.25rem;
|
|
129
|
+
border-top: 1px solid var(--border);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.acd-btn {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 0.3rem;
|
|
136
|
+
padding: 0.4rem 0.85rem;
|
|
137
|
+
border-radius: var(--radius-sm);
|
|
138
|
+
font-size: 0.85rem;
|
|
139
|
+
font-weight: 500;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
}
|
|
142
|
+
.acd-btn:disabled {
|
|
143
|
+
opacity: 0.55;
|
|
144
|
+
cursor: not-allowed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.acd-btn--ghost {
|
|
148
|
+
background: none;
|
|
149
|
+
border: 1px solid var(--border);
|
|
150
|
+
color: var(--text);
|
|
151
|
+
}
|
|
152
|
+
.acd-btn--ghost:not(:disabled):hover {
|
|
153
|
+
background: var(--bg-surface-hover);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.acd-btn--primary {
|
|
157
|
+
background: var(--accent);
|
|
158
|
+
color: var(--text-on-accent);
|
|
159
|
+
border: none;
|
|
160
|
+
}
|
|
161
|
+
.acd-btn--primary:not(:disabled):hover {
|
|
162
|
+
filter: brightness(1.1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.acd-btn--danger {
|
|
166
|
+
background: var(--danger, #dc2626);
|
|
167
|
+
color: #fff;
|
|
168
|
+
border: none;
|
|
169
|
+
}
|
|
170
|
+
.acd-btn--danger:not(:disabled):hover {
|
|
171
|
+
filter: brightness(1.1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
175
|
+
.acd-spin { animation: spin 0.8s linear infinite; }
|
|
176
|
+
</style>
|