@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.
Files changed (42) hide show
  1. package/package.json +58 -0
  2. package/src/components/AppBadge.vue +155 -0
  3. package/src/components/AppButton.vue +171 -0
  4. package/src/components/AppConfirmDialog.vue +176 -0
  5. package/src/components/AppItemCard.vue +216 -0
  6. package/src/components/AppMenu.vue +197 -0
  7. package/src/components/AppMenuItem.vue +43 -0
  8. package/src/components/AppModal.vue +257 -0
  9. package/src/components/AppPopover.vue +275 -0
  10. package/src/components/AppSelect.vue +89 -0
  11. package/src/components/AppSelectField.vue +260 -0
  12. package/src/components/AppTabs.vue +310 -0
  13. package/src/components/AppTabsRouted.vue +409 -0
  14. package/src/components/AppTextArea.vue +128 -0
  15. package/src/components/AppTextField.vue +165 -0
  16. package/src/components/AppToast.vue +65 -0
  17. package/src/components/AppToggle.vue +105 -0
  18. package/src/components/Draggable.vue +178 -0
  19. package/src/components/GlobalConfirmDialog.vue +25 -0
  20. package/src/components/UnreadPill.vue +28 -0
  21. package/src/components/UserAvatar.vue +114 -0
  22. package/src/components/UserCard.vue +274 -0
  23. package/src/components/UserItem.vue +167 -0
  24. package/src/components/tab-types.ts +9 -0
  25. package/src/composables/use-app-shell.ts +65 -0
  26. package/src/composables/use-breakpoint.ts +14 -0
  27. package/src/composables/use-click-outside-stack.ts +67 -0
  28. package/src/composables/use-color-scheme.ts +75 -0
  29. package/src/composables/use-confirm.ts +36 -0
  30. package/src/composables/use-drawer.css +47 -0
  31. package/src/composables/use-drawer.ts +58 -0
  32. package/src/composables/use-escape-stack.ts +80 -0
  33. package/src/composables/use-file-drop.ts +47 -0
  34. package/src/composables/use-long-press.ts +62 -0
  35. package/src/composables/use-popover.css +32 -0
  36. package/src/composables/use-popover.ts +1324 -0
  37. package/src/composables/use-shift-key.ts +18 -0
  38. package/src/index.ts +75 -0
  39. package/src/lib/avatar.ts +35 -0
  40. package/src/reset.css +53 -0
  41. package/src/stores/toastStore.ts +26 -0
  42. 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>