@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
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import { useToastStore } from '../stores/toastStore';
3
+
4
+ const toast = useToastStore();
5
+ </script>
6
+
7
+ <template>
8
+ <Teleport to="body">
9
+ <TransitionGroup name="toast" tag="div" class="toast-container">
10
+ <div
11
+ v-for="item in toast.items"
12
+ :key="item.id"
13
+ class="toast-item"
14
+ :class="`toast-item--${item.type}`"
15
+ @click="toast.dismiss(item.id)"
16
+ >
17
+ {{ item.message }}
18
+ </div>
19
+ </TransitionGroup>
20
+ </Teleport>
21
+ </template>
22
+
23
+ <style scoped>
24
+ .toast-container {
25
+ position: fixed;
26
+ bottom: 1rem;
27
+ right: 1rem;
28
+ z-index: 9999;
29
+ display: flex;
30
+ flex-direction: column-reverse;
31
+ gap: 0.5rem;
32
+ pointer-events: none;
33
+ }
34
+ .toast-item {
35
+ pointer-events: auto;
36
+ cursor: pointer;
37
+ max-width: 360px;
38
+ padding: 0.6rem 0.9rem;
39
+ border-radius: var(--radius-sm, 6px);
40
+ font-size: 0.82rem;
41
+ line-height: 1.4;
42
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
43
+ }
44
+ .toast-item--error {
45
+ background: var(--danger, #ef4444);
46
+ color: #fff;
47
+ }
48
+ .toast-item--info {
49
+ background: var(--bg-surface, #1e1e2e);
50
+ color: var(--text, #cdd6f4);
51
+ border: 1px solid var(--border, #45475a);
52
+ }
53
+ .toast-enter-active,
54
+ .toast-leave-active {
55
+ transition: all 0.25s ease;
56
+ }
57
+ .toast-enter-from {
58
+ opacity: 0;
59
+ transform: translateY(0.5rem);
60
+ }
61
+ .toast-leave-to {
62
+ opacity: 0;
63
+ transform: translateX(1rem);
64
+ }
65
+ </style>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ /**
5
+ * Standalone on/off switch. Replaces the per-page hand-rolled toggles
6
+ * that previously lived inside BehaviorCard / PluginCard / etc., all
7
+ * of which had byte-identical CSS for the 32×18 pill + 14px knob.
8
+ *
9
+ * Two sizes: `md` (32×18, the default — matches every existing call
10
+ * site) and `sm` (26×15) for denser rows.
11
+ *
12
+ * `ariaLabel` is required by convention: the toggle has no visible
13
+ * text, so a programmatic name is the only signal screen readers get.
14
+ * Pass an empty string if the toggle is paired with a `<label>` that
15
+ * already names it (rare — most call sites do not).
16
+ */
17
+
18
+ const props = withDefaults(defineProps<{
19
+ modelValue: boolean;
20
+ disabled?: boolean;
21
+ size?: 'sm' | 'md';
22
+ ariaLabel?: string;
23
+ /** Tooltip text. Caller typically derives this from the current state. */
24
+ title?: string;
25
+ }>(), {
26
+ disabled: false,
27
+ size: 'md',
28
+ ariaLabel: '',
29
+ title: ''
30
+ });
31
+
32
+ const emit = defineEmits<{
33
+ (e: 'update:modelValue', value: boolean): void;
34
+ }>();
35
+
36
+ const classes = computed(() => ({
37
+ 'app-toggle': true,
38
+ [`app-toggle--${props.size}`]: true,
39
+ 'app-toggle--on': props.modelValue
40
+ }));
41
+
42
+ function onClick(): void {
43
+ if (props.disabled) return;
44
+ emit('update:modelValue', !props.modelValue);
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <button
50
+ type="button"
51
+ role="switch"
52
+ :class="classes"
53
+ :aria-checked="modelValue ? 'true' : 'false'"
54
+ :aria-label="ariaLabel || undefined"
55
+ :title="title || undefined"
56
+ :disabled="disabled"
57
+ @click.stop="onClick"
58
+ >
59
+ <span class="app-toggle__slider" aria-hidden="true"></span>
60
+ </button>
61
+ </template>
62
+
63
+ <style scoped>
64
+ .app-toggle {
65
+ position: relative;
66
+ flex-shrink: 0;
67
+ cursor: pointer;
68
+ border: none;
69
+ padding: 0;
70
+ background: none;
71
+ }
72
+ .app-toggle:disabled { cursor: not-allowed; opacity: 0.6; }
73
+
74
+ .app-toggle--md { width: 32px; height: 18px; }
75
+ .app-toggle--sm { width: 26px; height: 15px; }
76
+
77
+ .app-toggle__slider {
78
+ position: absolute;
79
+ inset: 0;
80
+ background: var(--border-strong);
81
+ border-radius: 999px;
82
+ transition: background var(--transition-base, 0.15s);
83
+ }
84
+ .app-toggle__slider::before {
85
+ content: '';
86
+ position: absolute;
87
+ top: 2px;
88
+ left: 2px;
89
+ background: var(--bg-surface);
90
+ border-radius: 50%;
91
+ transition: transform var(--transition-base, 0.15s);
92
+ }
93
+ .app-toggle--md .app-toggle__slider::before { width: 14px; height: 14px; }
94
+ .app-toggle--sm .app-toggle__slider::before { width: 11px; height: 11px; }
95
+
96
+ .app-toggle--on .app-toggle__slider { background: var(--accent); }
97
+ .app-toggle--md.app-toggle--on .app-toggle__slider::before { transform: translateX(14px); }
98
+ .app-toggle--sm.app-toggle--on .app-toggle__slider::before { transform: translateX(11px); }
99
+
100
+ .app-toggle:focus-visible {
101
+ outline: 2px solid var(--accent);
102
+ outline-offset: 2px;
103
+ border-radius: 999px;
104
+ }
105
+ </style>
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, ref } from 'vue'
3
+
4
+ interface Props {
5
+ /** 邊界元素(預設為父元素) */
6
+ bounds?: HTMLElement | null
7
+ /** 距離邊界的最小 padding(px) */
8
+ boundaryPadding?: number
9
+ /** 判定拖曳起始的位移門檻(px),避免誤判點擊 */
10
+ dragThreshold?: number
11
+ /** 是否啟用拖曳 */
12
+ disabled?: boolean
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ bounds: null,
17
+ boundaryPadding: 0,
18
+ dragThreshold: 5,
19
+ disabled: false,
20
+ })
21
+
22
+ const emit = defineEmits<{
23
+ (e: 'dragStart'): void
24
+ /** 拖曳放開的當下觸發(尚未完成 snap 回邊界) */
25
+ (e: 'dragEnd'): void
26
+ /** 位置穩定(含 snap 動畫結束)後觸發 */
27
+ (e: 'positionSettled'): void
28
+ }>()
29
+
30
+ const rootRef = ref<HTMLElement | null>(null)
31
+ const dragX = ref(0)
32
+ const dragY = ref(0)
33
+ const isDragging = ref(false)
34
+ const isSnapping = ref(false)
35
+
36
+ let startPointerX = 0
37
+ let startPointerY = 0
38
+ let startDragX = 0
39
+ let startDragY = 0
40
+ let pointerId = -1
41
+ let hasStartedDrag = false
42
+
43
+ function resolveBoundsRect(): DOMRect | null {
44
+ const boundsEl = props.bounds ?? rootRef.value?.parentElement
45
+ return boundsEl ? boundsEl.getBoundingClientRect() : null
46
+ }
47
+
48
+ function onPointerDown(e: PointerEvent) {
49
+ if (props.disabled || e.button !== 0 || !rootRef.value)
50
+ return
51
+ startPointerX = e.clientX
52
+ startPointerY = e.clientY
53
+ startDragX = dragX.value
54
+ startDragY = dragY.value
55
+ hasStartedDrag = false
56
+ pointerId = e.pointerId
57
+ isSnapping.value = false
58
+ window.addEventListener('pointermove', onPointerMove)
59
+ window.addEventListener('pointerup', onPointerUp)
60
+ window.addEventListener('pointercancel', onPointerUp)
61
+ }
62
+
63
+ function onPointerMove(e: PointerEvent) {
64
+ if (e.pointerId !== pointerId)
65
+ return
66
+ const dx = e.clientX - startPointerX
67
+ const dy = e.clientY - startPointerY
68
+ if (!hasStartedDrag) {
69
+ if (Math.hypot(dx, dy) < props.dragThreshold)
70
+ return
71
+ hasStartedDrag = true
72
+ isDragging.value = true
73
+ emit('dragStart')
74
+ }
75
+ dragX.value = startDragX + dx
76
+ dragY.value = startDragY + dy
77
+ }
78
+
79
+ function onPointerUp(e: PointerEvent) {
80
+ if (e.pointerId !== pointerId)
81
+ return
82
+ window.removeEventListener('pointermove', onPointerMove)
83
+ window.removeEventListener('pointerup', onPointerUp)
84
+ window.removeEventListener('pointercancel', onPointerUp)
85
+ pointerId = -1
86
+
87
+ if (!hasStartedDrag) {
88
+ isDragging.value = false
89
+ return
90
+ }
91
+ isDragging.value = false
92
+ hasStartedDrag = false
93
+ emit('dragEnd')
94
+
95
+ // 攔截因拖曳結束觸發的 click,避免意外觸發按鈕動作
96
+ const suppressClick = (clickE: Event) => {
97
+ clickE.stopPropagation()
98
+ clickE.preventDefault()
99
+ window.removeEventListener('click', suppressClick, true)
100
+ }
101
+ window.addEventListener('click', suppressClick, true)
102
+ // 保險:若 click 未如預期觸發,下一輪 tick 移除
103
+ setTimeout(() => window.removeEventListener('click', suppressClick, true), 0)
104
+
105
+ // 超出邊界時以 transition 漸變移回
106
+ const el = rootRef.value
107
+ const boundsRect = resolveBoundsRect()
108
+ if (!el || !boundsRect)
109
+ return
110
+ const elRect = el.getBoundingClientRect()
111
+ const pad = props.boundaryPadding
112
+ let adjustX = 0
113
+ let adjustY = 0
114
+ if (elRect.left < boundsRect.left + pad)
115
+ adjustX = (boundsRect.left + pad) - elRect.left
116
+ else if (elRect.right > boundsRect.right - pad)
117
+ adjustX = (boundsRect.right - pad) - elRect.right
118
+ if (elRect.top < boundsRect.top + pad)
119
+ adjustY = (boundsRect.top + pad) - elRect.top
120
+ else if (elRect.bottom > boundsRect.bottom - pad)
121
+ adjustY = (boundsRect.bottom - pad) - elRect.bottom
122
+
123
+ if (adjustX || adjustY) {
124
+ isSnapping.value = true
125
+ dragX.value += adjustX
126
+ dragY.value += adjustY
127
+ }
128
+ else {
129
+ // 不需 snap:位置已穩定
130
+ emit('positionSettled')
131
+ }
132
+ }
133
+
134
+ function onTransitionEnd(e: TransitionEvent) {
135
+ // 只處理 root 自身的 transform 過渡;避免子元素(如 AiStyleButton 的 outline)
136
+ // 的 transform transition 冒泡後誤觸 positionSettled
137
+ if (e.target !== rootRef.value)
138
+ return
139
+ if (e.propertyName === 'transform') {
140
+ isSnapping.value = false
141
+ emit('positionSettled')
142
+ }
143
+ }
144
+
145
+ onBeforeUnmount(() => {
146
+ window.removeEventListener('pointermove', onPointerMove)
147
+ window.removeEventListener('pointerup', onPointerUp)
148
+ window.removeEventListener('pointercancel', onPointerUp)
149
+ })
150
+ </script>
151
+
152
+ <template>
153
+ <div
154
+ ref="rootRef"
155
+ class="draggable"
156
+ :class="{ 'is-dragging': isDragging, 'is-snapping': isSnapping }"
157
+ :style="{ transform: `translate(${dragX}px, ${dragY}px)` }"
158
+ @pointerdown="onPointerDown"
159
+ @transitionend="onTransitionEnd">
160
+ <slot />
161
+ </div>
162
+ </template>
163
+
164
+ <style scoped>
165
+ .draggable {
166
+ touch-action: none;
167
+ cursor: grab;
168
+ }
169
+
170
+ .draggable.is-dragging {
171
+ cursor: grabbing;
172
+ user-select: none;
173
+ }
174
+
175
+ .draggable.is-snapping {
176
+ transition: transform 0.35s cubic-bezier(0.22, 0.61, 0.36, 1);
177
+ }
178
+ </style>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useConfirm } from '../composables/use-confirm';
4
+ import AppConfirmDialog from './AppConfirmDialog.vue';
5
+
6
+ const { pending, handleConfirm, handleClose } = useConfirm();
7
+
8
+ const visible = computed(() => pending.value !== null);
9
+ const opts = computed(() => pending.value?.options ?? {
10
+ title: '', message: '', confirmLabel: ''
11
+ });
12
+ </script>
13
+
14
+ <template>
15
+ <AppConfirmDialog
16
+ :visible="visible"
17
+ :title="opts.title"
18
+ :message="opts.message"
19
+ :confirm-label="opts.confirmLabel ?? 'OK'"
20
+ :confirm-variant="opts.confirmVariant ?? 'primary'"
21
+ :cancel-label="opts.cancelLabel"
22
+ @confirm="handleConfirm"
23
+ @close="handleClose"
24
+ />
25
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps<{ count: number }>();
5
+
6
+ const display = computed(() => (props.count > 99 ? '99+' : String(props.count)));
7
+ </script>
8
+
9
+ <template>
10
+ <span v-if="count > 0" class="unread-pill">{{ display }}</span>
11
+ </template>
12
+
13
+ <style scoped>
14
+ .unread-pill {
15
+ flex-shrink: 0;
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ min-width: 18px;
20
+ height: 18px;
21
+ padding: 0 6px;
22
+ border-radius: 9px;
23
+ font-size: 12px;
24
+ font-weight: bold;
25
+ background: var(--unread-accent, #f23f43);
26
+ color: #fff;
27
+ }
28
+ </style>
@@ -0,0 +1,114 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * UserAvatar — circular avatar `<img>` with letter-initial fallback.
4
+ *
5
+ * Three animation modes:
6
+ * - `animate="never"` (default): always serve the still frame.
7
+ * - `animate="hover"`: still by default, animated while the cursor is
8
+ * over the avatar — matches Discord's DM-list behaviour. The hover
9
+ * state is owned internally; if the caller needs hover state for
10
+ * other reasons, drive it via `animated` prop instead.
11
+ * - `animate="always"`: always animated (e.g. inside an open profile
12
+ * card where the user is actively looking at the avatar).
13
+ *
14
+ * The "is this asset animated?" check is automatic — non-animated
15
+ * Discord URLs ignore the `&animated=true` param, so we don't need
16
+ * to special-case them in the consumer.
17
+ */
18
+ import { computed, ref } from 'vue';
19
+ import { animatedAvatarUrl, isAnimatedAvatar } from '../lib/avatar';
20
+
21
+ const props = withDefaults(defineProps<{
22
+ /** Avatar URL (Discord CDN, or anything else). Null/undefined → fallback. */
23
+ src?: string | null;
24
+ /** Used to derive the fallback letter when there's no `src`. */
25
+ name?: string | null;
26
+ /** Pixel size of the circle. Default 40. */
27
+ size?: number;
28
+ /** When and how to swap to the animated variant. */
29
+ animate?: 'never' | 'hover' | 'always';
30
+ /**
31
+ * Force-override for the "is hovered" half of `animate: hover`.
32
+ * Lets the parent share hover state across multiple avatars
33
+ * (e.g. one row hover lighting up all avatars in that row).
34
+ */
35
+ animated?: boolean;
36
+ /** Alt text. Default empty (avatars are decorative when paired with a label). */
37
+ alt?: string;
38
+ }>(), {
39
+ src: null,
40
+ name: null,
41
+ size: 40,
42
+ animate: 'never',
43
+ animated: undefined,
44
+ alt: '',
45
+ });
46
+
47
+ const localHover = ref(false);
48
+
49
+ const effectiveSrc = computed<string | null>(() => {
50
+ if (!props.src) return null;
51
+ if (!isAnimatedAvatar(props.src)) return props.src;
52
+ const shouldAnimate =
53
+ props.animate === 'always' ||
54
+ (props.animate === 'hover' && (props.animated ?? localHover.value));
55
+ return shouldAnimate ? animatedAvatarUrl(props.src) : props.src;
56
+ });
57
+
58
+ const initial = computed<string>(() => {
59
+ const source = (props.name ?? props.alt ?? '').trim();
60
+ if (!source) return '?';
61
+ // Take the first grapheme — covers CJK / emoji single-char display
62
+ // names where charAt(0) would split a surrogate pair.
63
+ return [...source][0]?.toUpperCase() ?? '?';
64
+ });
65
+
66
+ const sizeStyle = computed(() => ({
67
+ width: `${props.size}px`,
68
+ height: `${props.size}px`,
69
+ fontSize: `${Math.round(props.size * 0.42)}px`,
70
+ }));
71
+ </script>
72
+
73
+ <template>
74
+ <div
75
+ class="user-avatar"
76
+ :class="{ 'has-image': !!effectiveSrc }"
77
+ :style="sizeStyle"
78
+ @mouseenter="localHover = true"
79
+ @mouseleave="localHover = false"
80
+ >
81
+ <img
82
+ v-if="effectiveSrc"
83
+ :src="effectiveSrc"
84
+ :alt="alt"
85
+ draggable="false"
86
+ />
87
+ <span v-else aria-hidden="true">{{ initial }}</span>
88
+ </div>
89
+ </template>
90
+
91
+ <style scoped>
92
+ .user-avatar {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ border-radius: 50%;
97
+ overflow: hidden;
98
+ background: var(--accent);
99
+ color: var(--text-on-accent);
100
+ font-weight: 600;
101
+ line-height: 1;
102
+ flex-shrink: 0;
103
+ user-select: none;
104
+ }
105
+ .user-avatar.has-image {
106
+ background: var(--bg-surface-2);
107
+ }
108
+ .user-avatar img {
109
+ width: 100%;
110
+ height: 100%;
111
+ object-fit: cover;
112
+ display: block;
113
+ }
114
+ </style>