@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,260 @@
1
+ <script setup lang="ts" generic="V extends string | number | null">
2
+ import { computed, nextTick, ref, watch } from 'vue';
3
+ import AppSelect from './AppSelect.vue';
4
+ import type { Placement } from '../composables/use-popover';
5
+ import type { DrawerPlacement } from '../composables/use-drawer';
6
+
7
+ /**
8
+ * Drop-in replacement for native `<select>` built on AppSelect, so the
9
+ * picker honours the same viewport-aware popover/drawer split the rest
10
+ * of the admin surface uses. Owns:
11
+ * - A value-model (`v-model`).
12
+ * - A trigger button styled to match form inputs.
13
+ * - The flat / grouped option list rendering.
14
+ * - Optional in-dropdown filter (off by default) for long option lists.
15
+ *
16
+ * `options` accepts either `{ value, label }[]` (flat) or items with a
17
+ * `group` field (header pulled from the unique non-null group strings,
18
+ * preserving first-seen order).
19
+ *
20
+ * Labels are passed in as props with English defaults — this component
21
+ * does not depend on vue-i18n.
22
+ */
23
+ export interface SelectOption<V> {
24
+ value: V;
25
+ label: string;
26
+ group?: string | null;
27
+ }
28
+
29
+ const props = withDefaults(defineProps<{
30
+ modelValue: V;
31
+ options: SelectOption<V>[];
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ placement?: Placement;
35
+ drawerPlacement?: DrawerPlacement;
36
+ drawerTitle?: string;
37
+ /** Show a search box at the top of the dropdown. Default: false. */
38
+ filter?: boolean;
39
+ /** Placeholder for the filter input. */
40
+ filterPlaceholder?: string;
41
+ /** Text shown when filter yields no matches. */
42
+ noMatchesLabel?: string;
43
+ }>(), {
44
+ placeholder: '',
45
+ disabled: false,
46
+ placement: 'bottom-start',
47
+ drawerPlacement: 'bottom',
48
+ drawerTitle: '',
49
+ filter: false,
50
+ filterPlaceholder: 'Search',
51
+ noMatchesLabel: 'No matches'
52
+ });
53
+
54
+ const emit = defineEmits<{
55
+ (e: 'update:modelValue', value: V): void;
56
+ }>();
57
+
58
+ const isOpen = ref(false);
59
+ const filterText = ref('');
60
+ const filterInputRef = ref<HTMLInputElement | null>(null);
61
+
62
+ const selected = computed(() =>
63
+ props.options.find(o => o.value === props.modelValue) ?? null
64
+ );
65
+
66
+ // When the filter is on, narrow the option list by case-insensitive
67
+ // substring match against the label. Group headers fall away naturally
68
+ // because the grouping pass below only emits a header when at least
69
+ // one option in that group survives the filter.
70
+ const filteredOptions = computed(() => {
71
+ if (!props.filter) return props.options;
72
+ const needle = filterText.value.trim().toLowerCase();
73
+ if (!needle) return props.options;
74
+ return props.options.filter(o => o.label.toLowerCase().includes(needle));
75
+ });
76
+
77
+ interface Group {
78
+ label: string | null;
79
+ items: SelectOption<V>[];
80
+ }
81
+ const groups = computed<Group[]>(() => {
82
+ const out: Group[] = [];
83
+ const byKey = new Map<string, Group>();
84
+ for (const opt of filteredOptions.value) {
85
+ const key = opt.group ?? '';
86
+ let g = byKey.get(key);
87
+ if (!g) {
88
+ g = { label: opt.group ?? null, items: [] };
89
+ byKey.set(key, g);
90
+ out.push(g);
91
+ }
92
+ g.items.push(opt);
93
+ }
94
+ return out;
95
+ });
96
+
97
+ const hasGroups = computed(() => groups.value.some(g => g.label !== null));
98
+ const noMatches = computed(() => props.filter && filteredOptions.value.length === 0);
99
+
100
+ // Reset filter on close so the next open starts fresh; auto-focus the
101
+ // filter input on open so the user can type immediately.
102
+ watch(isOpen, (open) => {
103
+ if (!open) {
104
+ filterText.value = '';
105
+ } else if (props.filter) {
106
+ nextTick(() => filterInputRef.value?.focus());
107
+ }
108
+ });
109
+
110
+ function pick(value: V) {
111
+ emit('update:modelValue', value);
112
+ isOpen.value = false;
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <AppSelect
118
+ v-model:open="isOpen"
119
+ :placement="placement"
120
+ :drawer-placement="drawerPlacement"
121
+ :drawer-title="drawerTitle || placeholder"
122
+ :close-on-item-click="!filter"
123
+ >
124
+ <template #trigger>
125
+ <button type="button" class="select-trigger" :disabled="disabled">
126
+ <span class="label" :class="{ placeholder: !selected }">
127
+ {{ selected?.label ?? placeholder }}
128
+ </span>
129
+ <span class="chevron" :class="{ open: isOpen }">›</span>
130
+ </button>
131
+ </template>
132
+
133
+ <div v-if="filter" class="filter-bar" @click.stop>
134
+ <input
135
+ ref="filterInputRef"
136
+ v-model="filterText"
137
+ type="text"
138
+ class="filter-input"
139
+ :placeholder="filterPlaceholder"
140
+ />
141
+ </div>
142
+ <ul class="select-list">
143
+ <template v-if="noMatches">
144
+ <li class="empty">{{ noMatchesLabel }}</li>
145
+ </template>
146
+ <template v-else-if="hasGroups">
147
+ <template v-for="(g, gi) in groups" :key="gi">
148
+ <li v-if="g.label !== null" class="group-head">{{ g.label }}</li>
149
+ <li
150
+ v-for="opt in g.items"
151
+ :key="String(opt.value) + '|' + opt.label"
152
+ :class="['option', { active: opt.value === modelValue, indented: g.label !== null }]"
153
+ @click="pick(opt.value)"
154
+ >{{ opt.label }}</li>
155
+ </template>
156
+ </template>
157
+ <template v-else>
158
+ <li
159
+ v-for="opt in filteredOptions"
160
+ :key="String(opt.value) + '|' + opt.label"
161
+ :class="['option', { active: opt.value === modelValue }]"
162
+ @click="pick(opt.value)"
163
+ >{{ opt.label }}</li>
164
+ </template>
165
+ </ul>
166
+ </AppSelect>
167
+ </template>
168
+
169
+ <style scoped>
170
+ .select-trigger {
171
+ display: flex;
172
+ width: 100%;
173
+ align-items: center;
174
+ gap: 0.4rem;
175
+ padding: 0.4rem 0.55rem;
176
+ border: 1px solid var(--border);
177
+ border-radius: var(--radius-sm);
178
+ background: var(--bg-surface);
179
+ color: var(--text);
180
+ font: inherit;
181
+ font-size: 0.9rem;
182
+ cursor: pointer;
183
+ text-align: left;
184
+ min-width: 0;
185
+ }
186
+ .select-trigger:hover:not(:disabled) { background: var(--bg-surface-hover); }
187
+ .select-trigger:disabled { opacity: 0.55; cursor: not-allowed; }
188
+ .select-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
189
+ .label {
190
+ flex: 1;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
194
+ }
195
+ .label.placeholder { color: var(--text-muted); }
196
+ .chevron {
197
+ flex-shrink: 0;
198
+ color: var(--text-muted);
199
+ transition: transform var(--transition-base);
200
+ transform: rotate(90deg);
201
+ font-size: 0.95rem;
202
+ }
203
+ .chevron.open { transform: rotate(270deg); }
204
+
205
+ .filter-bar {
206
+ padding: 0.4rem 0.5rem;
207
+ border-bottom: 1px solid var(--border);
208
+ background: var(--bg-surface);
209
+ position: sticky;
210
+ top: 0;
211
+ z-index: 1;
212
+ }
213
+ .filter-input {
214
+ width: 100%;
215
+ padding: 0.35rem 0.5rem;
216
+ border: 1px solid var(--border);
217
+ border-radius: var(--radius-sm);
218
+ background: var(--bg-surface);
219
+ color: var(--text);
220
+ font: inherit;
221
+ font-size: 0.88rem;
222
+ box-sizing: border-box;
223
+ }
224
+ .filter-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
225
+
226
+ .select-list {
227
+ list-style: none;
228
+ margin: 0;
229
+ padding: 0.25rem 0;
230
+ overflow-y: auto;
231
+ max-height: 320px;
232
+ }
233
+ .option {
234
+ padding: 0.45rem 0.85rem;
235
+ cursor: pointer;
236
+ font-size: 0.88rem;
237
+ color: var(--text);
238
+ display: flex;
239
+ align-items: center;
240
+ }
241
+ .option:hover { background: var(--bg-surface-hover); }
242
+ .option.active { background: var(--bg-surface-active); font-weight: 500; }
243
+ .option.indented { padding-left: 1.5rem; }
244
+ .empty {
245
+ padding: 0.6rem 0.85rem;
246
+ color: var(--text-muted);
247
+ font-size: 0.85rem;
248
+ text-align: center;
249
+ }
250
+ .group-head {
251
+ padding: 0.4rem 0.85rem 0.2rem;
252
+ font-size: 0.72rem;
253
+ text-transform: uppercase;
254
+ letter-spacing: 0.04em;
255
+ color: var(--text-muted);
256
+ }
257
+ @media (max-width: 768px) {
258
+ .select-list { max-height: none; }
259
+ }
260
+ </style>
@@ -0,0 +1,310 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { Icon } from '@iconify/vue';
4
+ import { useBreakpoint } from '../composables/use-breakpoint';
5
+ import AppSelectField, { type SelectOption } from './AppSelectField.vue';
6
+ import type { TabDef } from './tab-types';
7
+
8
+ export type { TabDef };
9
+
10
+ /**
11
+ * AppTabs — two-level tab strip with viewport-aware fallback. NO
12
+ * vue-router dependency. Tab clicks emit `update:modelValue` /
13
+ * `update:subModelValue`; the parent owns selection state.
14
+ *
15
+ * Desktop:
16
+ * - Primary tabs always render as a horizontal pill row at the top.
17
+ * - Sub-tabs (when provided) render either underneath the primary
18
+ * row (`subLayout: 'top'`) or as a full-height vertical sidebar
19
+ * to the left of the panel (`subLayout: 'sidebar'`).
20
+ *
21
+ * Mobile (≤768px):
22
+ * - Both rows collapse to AppSelectField dropdowns stacked vertically.
23
+ *
24
+ * If you need URL-synced tabs with deep-linkable hrefs, use
25
+ * `AppTabsRouted` from this package (a thin wrapper that adds
26
+ * `vue-router` integration on top).
27
+ */
28
+ const props = withDefaults(defineProps<{
29
+ modelValue: string;
30
+ tabs: TabDef[];
31
+ subModelValue?: string;
32
+ subTabs?: TabDef[];
33
+ /** Where sub-tabs render on desktop. Has no effect on mobile. */
34
+ subLayout?: 'top' | 'sidebar';
35
+ }>(), {
36
+ subModelValue: undefined,
37
+ subTabs: () => [],
38
+ subLayout: 'top'
39
+ });
40
+
41
+ const emit = defineEmits<{
42
+ (e: 'update:modelValue', value: string): void;
43
+ (e: 'update:subModelValue', value: string): void;
44
+ }>();
45
+
46
+ const { isMobile } = useBreakpoint();
47
+
48
+ const hasSub = computed(() => props.subTabs.length > 0);
49
+
50
+ function pickPrimary(key: string) { emit('update:modelValue', key); }
51
+ function pickSub(key: string) { emit('update:subModelValue', key); }
52
+
53
+ const primaryOptions = computed<SelectOption<string>[]>(() =>
54
+ props.tabs.map(t => ({ value: t.key, label: t.label }))
55
+ );
56
+ const subOptions = computed<SelectOption<string>[]>(() =>
57
+ props.subTabs.map(t => ({ value: t.key, label: t.label }))
58
+ );
59
+ </script>
60
+
61
+ <template>
62
+ <!-- Mobile: two stacked dropdowns. -->
63
+ <div v-if="isMobile" class="tabs-root mobile">
64
+ <div class="dropdowns">
65
+ <AppSelectField
66
+ :model-value="modelValue"
67
+ :options="primaryOptions"
68
+ @update:model-value="pickPrimary"
69
+ />
70
+ <AppSelectField
71
+ v-if="hasSub && subModelValue !== undefined"
72
+ :model-value="subModelValue"
73
+ :options="subOptions"
74
+ @update:model-value="pickSub"
75
+ />
76
+ </div>
77
+ <div class="panel">
78
+ <slot />
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Desktop, sub-tabs below primary. -->
83
+ <div
84
+ v-else-if="!hasSub || subLayout === 'top'"
85
+ class="tabs-root desktop top"
86
+ >
87
+ <nav class="primary-row" role="tablist">
88
+ <button
89
+ v-for="t in tabs"
90
+ :key="t.key"
91
+ type="button"
92
+ role="tab"
93
+ :class="['tab', { active: t.key === modelValue }]"
94
+ :aria-selected="t.key === modelValue"
95
+ :disabled="t.disabled"
96
+ @click="!t.disabled && pickPrimary(t.key)"
97
+ >
98
+ <Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
99
+ <span>{{ t.label }}</span>
100
+ </button>
101
+ </nav>
102
+ <nav v-if="hasSub" class="sub-row" role="tablist">
103
+ <button
104
+ v-for="t in subTabs"
105
+ :key="t.key"
106
+ type="button"
107
+ role="tab"
108
+ :class="['sub-tab', { active: t.key === subModelValue }]"
109
+ :aria-selected="t.key === subModelValue"
110
+ :disabled="t.disabled"
111
+ @click="!t.disabled && pickSub(t.key)"
112
+ >
113
+ <Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
114
+ <span>{{ t.label }}</span>
115
+ </button>
116
+ </nav>
117
+ <div class="panel">
118
+ <slot />
119
+ </div>
120
+ </div>
121
+
122
+ <!-- Desktop, sub-tabs as a vertical sidebar. -->
123
+ <div v-else class="tabs-root desktop sidebar">
124
+ <nav class="primary-row" role="tablist">
125
+ <button
126
+ v-for="t in tabs"
127
+ :key="t.key"
128
+ type="button"
129
+ role="tab"
130
+ :class="['tab', { active: t.key === modelValue }]"
131
+ :aria-selected="t.key === modelValue"
132
+ :disabled="t.disabled"
133
+ @click="!t.disabled && pickPrimary(t.key)"
134
+ >
135
+ <Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
136
+ <span>{{ t.label }}</span>
137
+ </button>
138
+ </nav>
139
+ <div class="split">
140
+ <nav class="sub-side" role="tablist">
141
+ <button
142
+ v-for="t in subTabs"
143
+ :key="t.key"
144
+ type="button"
145
+ role="tab"
146
+ :class="['sub-side-tab', { active: t.key === subModelValue }]"
147
+ :aria-selected="t.key === subModelValue"
148
+ :disabled="t.disabled"
149
+ @click="!t.disabled && pickSub(t.key)"
150
+ >
151
+ <Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
152
+ <span>{{ t.label }}</span>
153
+ </button>
154
+ </nav>
155
+ <div class="panel side-panel">
156
+ <slot />
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </template>
161
+
162
+ <style scoped>
163
+ .tabs-root {
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: 0.7rem;
167
+ min-height: 0;
168
+ /* Span the parent's cross-axis. Without this, a parent that uses
169
+ `align-items: flex-start` (a common pattern when other siblings
170
+ are bare buttons that shouldn't stretch) collapses the tabs
171
+ strip to its own content width. */
172
+ width: 100%;
173
+ flex: 1;
174
+ }
175
+
176
+ .primary-row {
177
+ display: flex;
178
+ gap: 0.4rem;
179
+ border-bottom: 1px solid var(--border);
180
+ padding-bottom: 0.5rem;
181
+ /* Horizontal scroll when tabs don't fit; never vertical. The
182
+ browser implicitly upgrades the other axis to `auto` whenever
183
+ one axis is non-visible, so `overflow-y: hidden` is required —
184
+ otherwise an unwanted vertical scrollbar appears whenever the
185
+ border-bottom / focus ring nudges the box past its parent's
186
+ computed height (which it does for sub-row's active underline). */
187
+ overflow-x: auto;
188
+ overflow-y: hidden;
189
+ scrollbar-width: thin;
190
+ flex-shrink: 0;
191
+ }
192
+ .tab {
193
+ background: var(--bg-surface-2);
194
+ border: 1px solid transparent;
195
+ border-radius: var(--radius-base);
196
+ padding: 0.4rem 0.95rem;
197
+ cursor: pointer;
198
+ color: var(--text);
199
+ font: inherit;
200
+ font-size: 0.9rem;
201
+ font-weight: 500;
202
+ display: inline-flex;
203
+ align-items: center;
204
+ gap: 0.4rem;
205
+ white-space: nowrap;
206
+ text-decoration: none;
207
+ }
208
+ .tab:hover:not(:disabled) { background: var(--bg-surface-hover); }
209
+ .tab.active {
210
+ background: var(--accent-bg);
211
+ border-color: var(--accent);
212
+ color: var(--accent-text-strong);
213
+ }
214
+ .tab:disabled { opacity: 0.45; cursor: default; }
215
+
216
+ .sub-row {
217
+ display: flex;
218
+ gap: 0.6rem;
219
+ border-bottom: 1px solid var(--border);
220
+ overflow-x: auto;
221
+ overflow-y: hidden;
222
+ scrollbar-width: thin;
223
+ margin-top: -0.3rem;
224
+ flex-shrink: 0;
225
+ }
226
+ .sub-tab {
227
+ background: none;
228
+ border: none;
229
+ color: var(--text-muted);
230
+ font: inherit;
231
+ font-size: 0.85rem;
232
+ font-weight: 500;
233
+ padding: 0.45rem 0.2rem;
234
+ cursor: pointer;
235
+ border-bottom: 2px solid transparent;
236
+ margin-bottom: -1px;
237
+ display: inline-flex;
238
+ align-items: center;
239
+ gap: 0.3rem;
240
+ white-space: nowrap;
241
+ text-decoration: none;
242
+ }
243
+ .sub-tab:hover:not(:disabled) { color: var(--text); }
244
+ .sub-tab.active { color: var(--accent-text-strong); border-color: var(--accent); }
245
+ .sub-tab:disabled { opacity: 0.45; cursor: default; }
246
+
247
+ .split {
248
+ display: flex;
249
+ gap: 0.9rem;
250
+ flex: 1;
251
+ min-height: 0;
252
+ }
253
+ .sub-side {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 0.15rem;
257
+ width: 200px;
258
+ flex-shrink: 0;
259
+ border-right: 1px solid var(--border);
260
+ padding-right: 0.6rem;
261
+ overflow-y: auto;
262
+ }
263
+ .sub-side-tab {
264
+ background: none;
265
+ border: 1px solid transparent;
266
+ border-radius: var(--radius-base);
267
+ padding: 0.5rem 0.7rem;
268
+ text-align: left;
269
+ color: var(--text-muted);
270
+ font: inherit;
271
+ font-size: 0.88rem;
272
+ font-weight: 500;
273
+ cursor: pointer;
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 0.5rem;
277
+ text-decoration: none;
278
+ }
279
+ .sub-side-tab:hover:not(:disabled) {
280
+ background: var(--bg-surface-hover);
281
+ color: var(--text);
282
+ }
283
+ .sub-side-tab.active {
284
+ background: var(--accent-bg);
285
+ color: var(--accent-text-strong);
286
+ border-color: var(--accent);
287
+ }
288
+ .sub-side-tab:disabled { opacity: 0.45; cursor: default; }
289
+
290
+ .panel {
291
+ flex: 1;
292
+ min-width: 0;
293
+ min-height: 0;
294
+ overflow-y: auto;
295
+ }
296
+ .side-panel {
297
+ padding-right: 0.2rem;
298
+ }
299
+
300
+ .tabs-root.mobile { gap: 0.5rem; }
301
+ .dropdowns {
302
+ display: flex;
303
+ flex-direction: column;
304
+ gap: 0.4rem;
305
+ }
306
+
307
+ @media (max-width: 640px) {
308
+ .sub-side { width: 160px; }
309
+ }
310
+ </style>