@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,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}