@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export interface ConfirmOptions {
|
|
4
|
+
title: string;
|
|
5
|
+
message: string;
|
|
6
|
+
confirmLabel?: string;
|
|
7
|
+
confirmVariant?: "primary" | "danger";
|
|
8
|
+
cancelLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PendingConfirm {
|
|
12
|
+
options: ConfirmOptions;
|
|
13
|
+
resolve: (confirmed: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pending = ref<PendingConfirm | null>(null);
|
|
17
|
+
|
|
18
|
+
export function useConfirm() {
|
|
19
|
+
function confirm(opts: ConfirmOptions): Promise<boolean> {
|
|
20
|
+
return new Promise<boolean>((resolve) => {
|
|
21
|
+
pending.value = { options: opts, resolve };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handleConfirm() {
|
|
26
|
+
pending.value?.resolve(true);
|
|
27
|
+
pending.value = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleClose() {
|
|
31
|
+
pending.value?.resolve(false);
|
|
32
|
+
pending.value = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { pending, confirm, handleConfirm, handleClose };
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Drawer layout, backdrop, and slide-transition classes.
|
|
3
|
+
* Imported by: main.ts (global, once at app init)
|
|
4
|
+
* Discipline: per-instance chrome (padding, colour, border) stays in the
|
|
5
|
+
* caller's scoped style; this file is only for shared structure and transitions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.drawer-backdrop {
|
|
9
|
+
position: fixed;
|
|
10
|
+
inset: 0;
|
|
11
|
+
background: rgba(0, 0, 0, 0.45);
|
|
12
|
+
z-index: 1000;
|
|
13
|
+
}
|
|
14
|
+
.drawer-panel {
|
|
15
|
+
position: fixed;
|
|
16
|
+
z-index: 1001;
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
background: var(--bg-surface);
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
}
|
|
22
|
+
.drawer-panel[data-placement="bottom"] { bottom: 0; left: 0; right: 0; }
|
|
23
|
+
.drawer-panel[data-placement="top"] { top: 0; left: 0; right: 0; }
|
|
24
|
+
.drawer-panel[data-placement="left"] { top: 0; bottom: 0; left: 0; }
|
|
25
|
+
.drawer-panel[data-placement="right"] { top: 0; bottom: 0; right: 0; }
|
|
26
|
+
|
|
27
|
+
.drawer-fade-enter-active,
|
|
28
|
+
.drawer-fade-leave-active { transition: opacity 0.2s; }
|
|
29
|
+
.drawer-fade-enter-from,
|
|
30
|
+
.drawer-fade-leave-to { opacity: 0; }
|
|
31
|
+
|
|
32
|
+
.drawer-slide-bottom-enter-active,
|
|
33
|
+
.drawer-slide-bottom-leave-active,
|
|
34
|
+
.drawer-slide-top-enter-active,
|
|
35
|
+
.drawer-slide-top-leave-active,
|
|
36
|
+
.drawer-slide-left-enter-active,
|
|
37
|
+
.drawer-slide-left-leave-active,
|
|
38
|
+
.drawer-slide-right-enter-active,
|
|
39
|
+
.drawer-slide-right-leave-active { transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
40
|
+
.drawer-slide-bottom-enter-from,
|
|
41
|
+
.drawer-slide-bottom-leave-to { transform: translateY(100%); }
|
|
42
|
+
.drawer-slide-top-enter-from,
|
|
43
|
+
.drawer-slide-top-leave-to { transform: translateY(-100%); }
|
|
44
|
+
.drawer-slide-left-enter-from,
|
|
45
|
+
.drawer-slide-left-leave-to { transform: translateX(-100%); }
|
|
46
|
+
.drawer-slide-right-enter-from,
|
|
47
|
+
.drawer-slide-right-leave-to { transform: translateX(100%); }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { onBeforeUnmount, watch, type Ref } from "vue";
|
|
2
|
+
import { useEscapeStack } from "./use-escape-stack";
|
|
3
|
+
|
|
4
|
+
export type DrawerPlacement = "bottom" | "top" | "left" | "right";
|
|
5
|
+
|
|
6
|
+
export interface UseDrawerOptions {
|
|
7
|
+
/** Reactive flag controlling the drawer's mounted/open state. */
|
|
8
|
+
visible: Ref<boolean>;
|
|
9
|
+
/** Edge the panel is anchored to and slides in from. Default: 'bottom'. */
|
|
10
|
+
placement?: DrawerPlacement;
|
|
11
|
+
/** Called when the user dismisses the drawer (backdrop click / Escape). */
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
/** Escape closes the drawer. Default: true. */
|
|
14
|
+
closeOnEscape?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseDrawerReturn {
|
|
18
|
+
placement: DrawerPlacement;
|
|
19
|
+
backdropClass: string;
|
|
20
|
+
panelClass: string;
|
|
21
|
+
backdropTransition: string;
|
|
22
|
+
panelTransition: string;
|
|
23
|
+
close: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generic drawer behavior: escape-stack registration, placement-driven
|
|
28
|
+
* transition class names, and shared backdrop/panel styles. The caller
|
|
29
|
+
* owns the Teleport + <Transition> + DOM, so drawers can live inline
|
|
30
|
+
* without a one-size-fits-all wrapper component.
|
|
31
|
+
*/
|
|
32
|
+
export function useDrawer(options: UseDrawerOptions): UseDrawerReturn {
|
|
33
|
+
const placement: DrawerPlacement = options.placement ?? "bottom";
|
|
34
|
+
const closeOnEscape = options.closeOnEscape !== false;
|
|
35
|
+
const { register, unregister } = useEscapeStack();
|
|
36
|
+
|
|
37
|
+
const close = () => options.onClose?.();
|
|
38
|
+
|
|
39
|
+
watch(
|
|
40
|
+
options.visible,
|
|
41
|
+
(v) => {
|
|
42
|
+
if (v) register(closeOnEscape ? close : null);
|
|
43
|
+
else unregister();
|
|
44
|
+
},
|
|
45
|
+
{ immediate: true },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
onBeforeUnmount(unregister);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
placement,
|
|
52
|
+
backdropClass: "drawer-backdrop",
|
|
53
|
+
panelClass: "drawer-panel",
|
|
54
|
+
backdropTransition: "drawer-fade",
|
|
55
|
+
panelTransition: `drawer-slide-${placement}`,
|
|
56
|
+
close,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
|
|
3
|
+
type EscapeCallback = () => void
|
|
4
|
+
|
|
5
|
+
interface StackEntry {
|
|
6
|
+
id: string
|
|
7
|
+
callback: EscapeCallback | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const stacks = new Map<string, StackEntry[]>()
|
|
11
|
+
let listenerInstalled = false
|
|
12
|
+
|
|
13
|
+
function ensureListener() {
|
|
14
|
+
if (listenerInstalled) return
|
|
15
|
+
listenerInstalled = true
|
|
16
|
+
|
|
17
|
+
document.addEventListener('keydown', (e) => {
|
|
18
|
+
if (e.key !== 'Escape') return
|
|
19
|
+
|
|
20
|
+
for (const [, entries] of stacks) {
|
|
21
|
+
// 從頂端往下找第一個有 callback 的
|
|
22
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
23
|
+
const entry = entries[i]
|
|
24
|
+
if (entry.callback) {
|
|
25
|
+
e.stopPropagation()
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
entry.callback()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 統一的 overlay escape 管理
|
|
37
|
+
*
|
|
38
|
+
* 所有 overlay(usePopover、NuModal)共用同一個 escape stack,
|
|
39
|
+
* 按 Escape 時只有最頂層的 overlay 會被關閉。
|
|
40
|
+
*
|
|
41
|
+
* @param target - stack 分組 key(通常為 'overlay')
|
|
42
|
+
*/
|
|
43
|
+
export const useEscapeStack = (target = 'overlay') => {
|
|
44
|
+
if (!stacks.has(target)) {
|
|
45
|
+
stacks.set(target, [])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const id = nanoid()
|
|
49
|
+
const stack = stacks.get(target)!
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 註冊到 stack,提供 escape callback。
|
|
53
|
+
* 傳入 null 表示佔位但不響應 Escape(如 closeOnEscape: false 的 overlay)。
|
|
54
|
+
*/
|
|
55
|
+
const register = (callback: EscapeCallback | null) => {
|
|
56
|
+
// 避免重複註冊
|
|
57
|
+
if (stack.some(e => e.id === id)) return
|
|
58
|
+
stack.push({ id, callback })
|
|
59
|
+
ensureListener()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 從 stack 移除
|
|
64
|
+
*/
|
|
65
|
+
const unregister = () => {
|
|
66
|
+
const idx = stack.findIndex(e => e.id === id)
|
|
67
|
+
if (idx !== -1) stack.splice(idx, 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 取得目前 stack 中的註冊數量(用於判斷是否需要解鎖 body scroll 等)
|
|
72
|
+
*/
|
|
73
|
+
const getStackSize = () => stack.length
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
register,
|
|
77
|
+
unregister,
|
|
78
|
+
getStackSize,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface FileDropHandlers {
|
|
4
|
+
isDragging: Ref<boolean>
|
|
5
|
+
onDragEnter: (event: DragEvent) => void
|
|
6
|
+
onDragOver: (event: DragEvent) => void
|
|
7
|
+
onDragLeave: () => void
|
|
8
|
+
onDrop: (event: DragEvent) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useFileDrop(onFiles: (files: File[]) => void): FileDropHandlers {
|
|
12
|
+
const isDragging = ref(false)
|
|
13
|
+
let counter = 0
|
|
14
|
+
|
|
15
|
+
function isFileDrag(event: DragEvent): boolean {
|
|
16
|
+
const types = event.dataTransfer?.types
|
|
17
|
+
if (!types) return false
|
|
18
|
+
for (let i = 0; i < types.length; i++) if (types[i] === 'Files') return true
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
isDragging,
|
|
24
|
+
onDragEnter(event) {
|
|
25
|
+
if (!isFileDrag(event)) return
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
counter++
|
|
28
|
+
isDragging.value = true
|
|
29
|
+
},
|
|
30
|
+
onDragOver(event) {
|
|
31
|
+
if (!isFileDrag(event)) return
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
},
|
|
34
|
+
onDragLeave() {
|
|
35
|
+
counter = Math.max(0, counter - 1)
|
|
36
|
+
if (counter === 0) isDragging.value = false
|
|
37
|
+
},
|
|
38
|
+
onDrop(event) {
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
counter = 0
|
|
41
|
+
isDragging.value = false
|
|
42
|
+
const files = event.dataTransfer?.files
|
|
43
|
+
if (!files || files.length === 0) return
|
|
44
|
+
onFiles(Array.from(files))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { onBeforeUnmount } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface UseLongPressOptions {
|
|
4
|
+
/** Hold duration before the action fires. Default 450 ms — matches
|
|
5
|
+
* the timer used by the chat conversation's message long-press. */
|
|
6
|
+
ms?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LongPressInfo {
|
|
10
|
+
/** Viewport-relative coordinates of the touch. Suitable for
|
|
11
|
+
* positioning a context menu via clientX/clientY semantics. */
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
/** The element the touch started on. Captured at touchstart because
|
|
15
|
+
* `event.currentTarget` is null by the time the timer fires. */
|
|
16
|
+
target: HTMLElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LongPressBinding {
|
|
20
|
+
start: (event: TouchEvent, action: (info: LongPressInfo) => void) => void;
|
|
21
|
+
cancel: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Touch long-press helper. Returns `start` (call from `@touchstart`) and
|
|
26
|
+
* `cancel` (call from `@touchend` / `@touchmove` / `@touchcancel`).
|
|
27
|
+
* `start` snapshots the touch coordinates and `currentTarget` synchronously
|
|
28
|
+
* so the action closure can use them after the setTimeout — `event.currentTarget`
|
|
29
|
+
* is reset to null once event handling completes.
|
|
30
|
+
*
|
|
31
|
+
* `contextmenu` events fire reliably on right-click but not on touch
|
|
32
|
+
* (especially Safari on iOS). This helper supplies the missing path
|
|
33
|
+
* for any sidebar row / chip / button that wants to surface a context
|
|
34
|
+
* menu under both pointer and touch input.
|
|
35
|
+
*/
|
|
36
|
+
export function useLongPress(options: UseLongPressOptions = {}): LongPressBinding {
|
|
37
|
+
const ms = options.ms ?? 450;
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
|
|
40
|
+
function start(event: TouchEvent, action: (info: LongPressInfo) => void) {
|
|
41
|
+
if (event.touches.length !== 1) return;
|
|
42
|
+
const touch = event.touches[0];
|
|
43
|
+
const target = event.currentTarget as HTMLElement | null;
|
|
44
|
+
if (!target) return;
|
|
45
|
+
const info: LongPressInfo = { x: touch.clientX, y: touch.clientY, target };
|
|
46
|
+
if (timer) clearTimeout(timer);
|
|
47
|
+
timer = setTimeout(() => {
|
|
48
|
+
timer = null;
|
|
49
|
+
action(info);
|
|
50
|
+
}, ms);
|
|
51
|
+
}
|
|
52
|
+
function cancel() {
|
|
53
|
+
if (timer) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
timer = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(cancel);
|
|
60
|
+
|
|
61
|
+
return { start, cancel };
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Popover placement-entry animation keyframes and utility classes.
|
|
3
|
+
* Imported by: main.ts (global, once at app init)
|
|
4
|
+
* Discipline: per-instance chrome (padding, colour, border) stays in the
|
|
5
|
+
* caller's scoped style; this file is only for shared animation primitives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
[data-popover-entering] *{transition:none!important}
|
|
9
|
+
@keyframes popoverSlideDownAndFade{
|
|
10
|
+
from{opacity:0;transform:translateY(-10px)}
|
|
11
|
+
to{opacity:1;transform:translateY(0)}
|
|
12
|
+
}
|
|
13
|
+
@keyframes popoverSlideUpAndFade{
|
|
14
|
+
from{opacity:0;transform:translateY(10px)}
|
|
15
|
+
to{opacity:1;transform:translateY(0)}
|
|
16
|
+
}
|
|
17
|
+
@keyframes popoverSlideLeftAndFade{
|
|
18
|
+
from{opacity:0;transform:translateX(10px)}
|
|
19
|
+
to{opacity:1;transform:translateY(0)}
|
|
20
|
+
}
|
|
21
|
+
@keyframes popoverSlideRightAndFade{
|
|
22
|
+
from{opacity:0;transform:translateX(-10px)}
|
|
23
|
+
to{opacity:1;transform:translateY(0)}
|
|
24
|
+
}
|
|
25
|
+
.popover-anim-down-normal{animation:popoverSlideDownAndFade .2s ease 0s 1 normal both}
|
|
26
|
+
.popover-anim-down-reverse{animation:popoverSlideDownAndFade .2s ease 0s 1 reverse both}
|
|
27
|
+
.popover-anim-up-normal{animation:popoverSlideUpAndFade .2s ease 0s 1 normal both}
|
|
28
|
+
.popover-anim-up-reverse{animation:popoverSlideUpAndFade .2s ease 0s 1 reverse both}
|
|
29
|
+
.popover-anim-left-normal{animation:popoverSlideLeftAndFade .2s ease 0s 1 normal both}
|
|
30
|
+
.popover-anim-left-reverse{animation:popoverSlideLeftAndFade .2s ease 0s 1 reverse both}
|
|
31
|
+
.popover-anim-right-normal{animation:popoverSlideRightAndFade .2s ease 0s 1 normal both}
|
|
32
|
+
.popover-anim-right-reverse{animation:popoverSlideRightAndFade .2s ease 0s 1 reverse both}
|