@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,216 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { Icon } from '@iconify/vue';
4
+
5
+ /**
6
+ * List-item card with an expandable body — the shared chrome that
7
+ * BehaviorCard and PluginCard each re-implemented (and that PluginCard
8
+ * was the uglier of the two, with a hand-rolled menu and no source
9
+ * stripe).
10
+ *
11
+ * The component owns:
12
+ * - Outer card border + radius + background.
13
+ * - Optional left-edge accent stripe (`accentBar` prop).
14
+ * - The header row that holds:
15
+ * - `#leading` slot (drag handle / lock icon, no click semantics)
16
+ * - The expand button — chevron + `#title` slot, click toggles `expanded`
17
+ * - `#trailing` slot (badges / toggles / menus)
18
+ * - The body container that mounts the default slot when expanded.
19
+ * - Disabled-style strikethrough on the title when `disabled` is true.
20
+ *
21
+ * Everything domain-specific (which badge to show, whether to include a
22
+ * toggle, what's in the form) stays at the caller. This deliberately
23
+ * does NOT take an `enabled` prop or render an internal toggle — those
24
+ * are call-site decisions, and forcing them into the API would push
25
+ * BehaviorCard's protected-system rules and PluginCard's per-status
26
+ * delete-only menu logic up into ui.
27
+ */
28
+
29
+ export type AccentBarTone = 'neutral' | 'accent' | 'success' | 'warn' | 'danger';
30
+
31
+ const props = withDefaults(defineProps<{
32
+ /** Controlled expansion. Pair with `@update:expanded`. */
33
+ expanded?: boolean;
34
+ /** Strike-through + muted title — used by BehaviorCard when off. */
35
+ disabled?: boolean;
36
+ /** Left-edge 3px accent stripe. Use to indicate source / kind. */
37
+ accentBar?: AccentBarTone | null;
38
+ /** Custom CSS colour for `accentBar` — overrides the tone palette. */
39
+ accentBarColor?: string;
40
+ }>(), {
41
+ expanded: false,
42
+ disabled: false,
43
+ accentBar: null,
44
+ accentBarColor: ''
45
+ });
46
+
47
+ const emit = defineEmits<{
48
+ (e: 'update:expanded', value: boolean): void;
49
+ /** Fired regardless of who owns the expanded state. */
50
+ (e: 'toggle', next: boolean): void;
51
+ }>();
52
+
53
+ const rootClass = computed(() => [
54
+ 'app-item-card',
55
+ {
56
+ 'app-item-card--disabled': props.disabled,
57
+ 'app-item-card--bar': !!props.accentBar || !!props.accentBarColor
58
+ }
59
+ ]);
60
+
61
+ const barStyle = computed(() => {
62
+ if (props.accentBarColor) return { background: props.accentBarColor };
63
+ if (!props.accentBar) return undefined;
64
+ return { background: `var(--app-item-card-bar-${props.accentBar})` };
65
+ });
66
+
67
+ function toggle(): void {
68
+ const next = !props.expanded;
69
+ emit('update:expanded', next);
70
+ emit('toggle', next);
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <article :class="rootClass">
76
+ <div v-if="accentBar || accentBarColor" class="app-item-card__bar" :style="barStyle" aria-hidden="true"></div>
77
+ <div class="app-item-card__inner">
78
+ <header class="app-item-card__head" :class="{ 'app-item-card__head--has-body': expanded }">
79
+ <div v-if="$slots.leading" class="app-item-card__leading">
80
+ <slot name="leading" />
81
+ </div>
82
+ <button
83
+ type="button"
84
+ class="app-item-card__expander"
85
+ :aria-expanded="expanded"
86
+ @click="toggle"
87
+ >
88
+ <Icon
89
+ :icon="expanded ? 'material-symbols:expand-less-rounded' : 'material-symbols:expand-more-rounded'"
90
+ width="18"
91
+ height="18"
92
+ class="app-item-card__chevron"
93
+ />
94
+ <span class="app-item-card__title">
95
+ <slot name="title" />
96
+ </span>
97
+ </button>
98
+ <div v-if="$slots.trailing" class="app-item-card__trailing">
99
+ <slot name="trailing" />
100
+ </div>
101
+ </header>
102
+ <div v-if="expanded" class="app-item-card__body">
103
+ <slot />
104
+ <footer v-if="$slots.footer" class="app-item-card__footer">
105
+ <slot name="footer" />
106
+ </footer>
107
+ </div>
108
+ </div>
109
+ </article>
110
+ </template>
111
+
112
+ <style scoped>
113
+ .app-item-card {
114
+ /* Bar tone palette — sourced from semantic tokens so themes flow through. */
115
+ --app-item-card-bar-neutral: var(--text-muted);
116
+ --app-item-card-bar-accent: var(--accent);
117
+ --app-item-card-bar-success: var(--success-text);
118
+ --app-item-card-bar-warn: var(--warn-text);
119
+ --app-item-card-bar-danger: var(--danger);
120
+
121
+ display: flex;
122
+ flex-direction: row;
123
+ border: 1px solid var(--border);
124
+ border-radius: var(--radius-base);
125
+ background: var(--bg-surface);
126
+ overflow: hidden;
127
+ }
128
+ .app-item-card--disabled .app-item-card__title {
129
+ color: var(--text-muted);
130
+ text-decoration: line-through;
131
+ }
132
+
133
+ .app-item-card__bar {
134
+ width: 3px;
135
+ flex-shrink: 0;
136
+ border-radius: var(--radius-base) 0 0 var(--radius-base);
137
+ }
138
+
139
+ .app-item-card__inner {
140
+ flex: 1;
141
+ min-width: 0;
142
+ display: flex;
143
+ flex-direction: column;
144
+ }
145
+
146
+ .app-item-card__head {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 0.5rem;
150
+ padding: 0.45rem 0.75rem 0.45rem 0.4rem;
151
+ background: var(--bg-page);
152
+ border-bottom: 1px solid transparent;
153
+ }
154
+ .app-item-card__head--has-body {
155
+ border-bottom-color: var(--border);
156
+ }
157
+
158
+ .app-item-card__leading {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ flex-shrink: 0;
162
+ }
163
+
164
+ .app-item-card__expander {
165
+ flex: 1;
166
+ min-width: 0;
167
+ background: none;
168
+ border: none;
169
+ text-align: left;
170
+ cursor: pointer;
171
+ color: var(--text);
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 0.4rem;
175
+ padding: 0.25rem 0.1rem;
176
+ overflow: hidden;
177
+ font: inherit;
178
+ }
179
+ .app-item-card__expander:hover { color: var(--text-strong); }
180
+ .app-item-card__expander:focus-visible {
181
+ outline: 2px solid var(--accent);
182
+ outline-offset: -1px;
183
+ border-radius: var(--radius-sm);
184
+ }
185
+ .app-item-card__chevron {
186
+ color: var(--text-muted);
187
+ flex-shrink: 0;
188
+ }
189
+ .app-item-card__title {
190
+ flex: 1;
191
+ min-width: 0;
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 0.4rem;
195
+ overflow: hidden;
196
+ }
197
+
198
+ .app-item-card__trailing {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 0.4rem;
202
+ flex-shrink: 0;
203
+ }
204
+
205
+ .app-item-card__body {
206
+ padding: 0.75rem;
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 0.6rem;
210
+ }
211
+ .app-item-card__footer {
212
+ display: flex;
213
+ justify-content: flex-end;
214
+ gap: 0.5rem;
215
+ }
216
+ </style>
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { useBreakpoint } from '../composables/use-breakpoint';
4
+ import { usePopover, type Placement } from '../composables/use-popover';
5
+ import { useDrawer } from '../composables/use-drawer';
6
+
7
+ /**
8
+ * Viewport-aware menu:
9
+ * - Desktop → popover anchored to the trigger via usePopover.
10
+ * - Mobile → bottom drawer (mirrors AppPopover so menu surfaces
11
+ * feel consistent with picker / popover surfaces on touch).
12
+ *
13
+ * The trigger slot is wrapped in a click-bubbling `display: contents`
14
+ * span so the wrapper doesn't affect the caller's layout. Clicking
15
+ * any AppMenuItem inside the default slot closes the menu when
16
+ * `closeOnItemClick` is true (the default — same as the original
17
+ * desktop-only AppMenu it replaces).
18
+ */
19
+ const props = withDefaults(defineProps<{
20
+ placement?: Placement;
21
+ /** Distance [skidding, distance] between trigger and menu. Default: [0, 8]. */
22
+ offset?: [number, number];
23
+ /** Close after the user clicks any item inside the menu. Default: true. */
24
+ closeOnItemClick?: boolean;
25
+ /** Optional heading rendered at the top of the mobile drawer. */
26
+ drawerTitle?: string;
27
+ }>(), {
28
+ placement: 'bottom-end',
29
+ closeOnItemClick: true
30
+ });
31
+
32
+ const { isMobile } = useBreakpoint();
33
+
34
+ const isOpen = ref(false);
35
+ function toggle() { isOpen.value = !isOpen.value; }
36
+ function close() { isOpen.value = false; }
37
+
38
+ const triggerWrapRef = ref<HTMLElement | null>(null);
39
+ const contentEl = ref<HTMLElement | null>(null);
40
+
41
+ // Anchor the desktop popover against the slot's first real child —
42
+ // the wrapper itself uses display: contents and has no usable layout
43
+ // rectangle in some browsers. Same trick AppPopover plays.
44
+ const anchorRef = computed<HTMLElement | null>(() => {
45
+ const wrap = triggerWrapRef.value;
46
+ if (!wrap) return null;
47
+ return (wrap.firstElementChild as HTMLElement | null) ?? wrap;
48
+ });
49
+
50
+ const popoverVisible = computed(() => !isMobile.value && isOpen.value);
51
+ const drawerVisible = computed(() => isMobile.value && isOpen.value);
52
+
53
+ usePopover(anchorRef, contentEl, {
54
+ placement: props.placement,
55
+ trigger: 'manual',
56
+ offset: props.offset ?? [0, 8],
57
+ teleportTo: 'body',
58
+ visible: popoverVisible,
59
+ closeOnClickOutside: true,
60
+ closeOnEscape: true,
61
+ closeOnContentClick: props.closeOnItemClick,
62
+ onHide: () => {
63
+ // Mirror self-close (Escape / click-outside / item-click) back
64
+ // onto the local open ref. Guard against the false branch
65
+ // firing during a mobile/desktop swap, which would otherwise
66
+ // recurse the close.
67
+ if (popoverVisible.value) close();
68
+ }
69
+ });
70
+
71
+ const { backdropClass, panelClass, backdropTransition, panelTransition } = useDrawer({
72
+ visible: drawerVisible,
73
+ placement: 'bottom',
74
+ closeOnEscape: true,
75
+ onClose: close
76
+ });
77
+
78
+ function onContentClick() {
79
+ if (props.closeOnItemClick) close();
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <span
85
+ ref="triggerWrapRef"
86
+ class="app-menu-trigger"
87
+ @click="toggle"
88
+ >
89
+ <slot name="trigger" :is-open="isOpen" :toggle="toggle" :close="close" />
90
+ </span>
91
+
92
+ <!-- Desktop popover content. Always rendered so usePopover binds on
93
+ mount; visibility gated by popoverVisible. -->
94
+ <div
95
+ ref="contentEl"
96
+ class="app-menu app-menu--hidden"
97
+ role="menu"
98
+ @click="onContentClick"
99
+ >
100
+ <slot :close="close" :is-open="isOpen" />
101
+ </div>
102
+
103
+ <!-- Mobile drawer branch — slides up from the bottom edge,
104
+ matches AppPopover's drawer chrome. z-index sits above
105
+ AppModal (1500) so a menu opened from inside a modal still
106
+ floats on top. -->
107
+ <Teleport v-if="isMobile" to="body">
108
+ <Transition :name="backdropTransition">
109
+ <div
110
+ v-if="drawerVisible"
111
+ :class="[backdropClass, 'app-menu-drawer-backdrop']"
112
+ :style="{ zIndex: 2000 }"
113
+ @click="close"
114
+ />
115
+ </Transition>
116
+ <Transition :name="panelTransition">
117
+ <div
118
+ v-if="drawerVisible"
119
+ :class="[panelClass, 'app-menu-drawer-panel']"
120
+ data-placement="bottom"
121
+ :style="{ zIndex: 2001 }"
122
+ role="menu"
123
+ aria-modal="true"
124
+ @click="onContentClick"
125
+ >
126
+ <header v-if="drawerTitle" class="app-menu-drawer-title">{{ drawerTitle }}</header>
127
+ <div class="app-menu-drawer-body">
128
+ <slot :close="close" :is-open="isOpen" />
129
+ </div>
130
+ </div>
131
+ </Transition>
132
+ </Teleport>
133
+ </template>
134
+
135
+ <style scoped>
136
+ .app-menu-trigger {
137
+ display: contents;
138
+ cursor: pointer;
139
+ }
140
+ .app-menu {
141
+ min-width: 180px;
142
+ padding: 0.3rem 0;
143
+ background: var(--bg-surface);
144
+ border: 1px solid var(--border);
145
+ border-radius: var(--radius-base);
146
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
147
+ z-index: 1600;
148
+ }
149
+ /* .app-menu--hidden no longer sets display:none — visibility is managed
150
+ exclusively via inline style by use-popover to avoid specificity
151
+ conflicts (inline style wins over class; removing it would let the
152
+ class re-apply display:none and break the popover). */
153
+ .app-menu-drawer-panel {
154
+ max-height: 70vh;
155
+ background: var(--bg-surface);
156
+ border-top: 1px solid var(--border);
157
+ border-radius: 12px 12px 0 0;
158
+ display: flex;
159
+ flex-direction: column;
160
+ overflow: hidden;
161
+ padding-bottom: env(safe-area-inset-bottom, 0px);
162
+ }
163
+ .app-menu-drawer-panel::before {
164
+ content: '';
165
+ display: block;
166
+ width: 36px;
167
+ height: 4px;
168
+ border-radius: 2px;
169
+ background: var(--text-muted);
170
+ opacity: 0.35;
171
+ margin: 0.4rem auto 0;
172
+ flex-shrink: 0;
173
+ }
174
+ .app-menu-drawer-title {
175
+ padding: 0.6rem 1rem 0.5rem;
176
+ font-weight: 600;
177
+ font-size: 0.95rem;
178
+ color: var(--text-strong);
179
+ border-bottom: 1px solid var(--border);
180
+ flex-shrink: 0;
181
+ }
182
+ .app-menu-drawer-body {
183
+ flex: 1;
184
+ min-height: 0;
185
+ overflow-y: auto;
186
+ overflow-x: hidden;
187
+ padding: 0.3rem 0;
188
+ }
189
+ /* Bump menu items inside the mobile drawer to a comfortable touch
190
+ target — the desktop popover already has acceptable spacing for
191
+ pointer use, but cramped rows on a touchscreen are easy to misfire. */
192
+ .app-menu-drawer-body :deep(.app-menu-item) {
193
+ padding: 0.85rem 1rem;
194
+ font-size: 1rem;
195
+ min-height: 48px;
196
+ }
197
+ </style>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ disabled?: boolean;
4
+ /** If true, the item is rendered with danger styling (e.g., sign out). */
5
+ danger?: boolean;
6
+ }>();
7
+ </script>
8
+
9
+ <template>
10
+ <button
11
+ type="button"
12
+ role="menuitem"
13
+ :class="['app-menu-item', { 'is-danger': danger }]"
14
+ :disabled="disabled"
15
+ >
16
+ <slot />
17
+ </button>
18
+ </template>
19
+
20
+ <style scoped>
21
+ .app-menu-item {
22
+ width: 100%;
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 0.5rem;
26
+ padding: 0.5rem 0.85rem;
27
+ background: none;
28
+ border: none;
29
+ color: var(--text);
30
+ font: inherit;
31
+ font-size: 0.9rem;
32
+ text-align: left;
33
+ cursor: pointer;
34
+ }
35
+ .app-menu-item:hover:not(:disabled) {
36
+ background: var(--bg-surface-hover);
37
+ }
38
+ .app-menu-item:disabled {
39
+ opacity: 0.5;
40
+ cursor: default;
41
+ }
42
+ .app-menu-item.is-danger { color: var(--danger); }
43
+ </style>