@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,409 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AppTabsRouted — AppTabs with URL query-param sync via `vue-router`.
|
|
4
|
+
*
|
|
5
|
+
* Tab + sub-tab keys two-way sync with URL query params, so a deep-link
|
|
6
|
+
* like `?tab=settings&sub=invites` lands on the right card and
|
|
7
|
+
* middle-clicking a tab opens it in a new browser tab.
|
|
8
|
+
*
|
|
9
|
+
* The first tab/sub key is treated as the default and omitted from the
|
|
10
|
+
* URL to keep shared links short. When two routed AppTabs share the
|
|
11
|
+
* same page, give each one a unique `name` prop so their query keys
|
|
12
|
+
* don't collide (`name="foo"` → `foo-tab` / `foo-sub`).
|
|
13
|
+
*
|
|
14
|
+
* For non-routed callers, use `AppTabs` (this file's plain sibling) —
|
|
15
|
+
* which does NOT import vue-router and so doesn't pull it into your
|
|
16
|
+
* plugin SPA's bundle.
|
|
17
|
+
*
|
|
18
|
+
* NB: separate component (vs an `routed` prop on AppTabs) so that
|
|
19
|
+
* plugin SPAs that never use routed tabs don't transitively depend on
|
|
20
|
+
* `vue-router`.
|
|
21
|
+
*/
|
|
22
|
+
import { computed, ref, watch } from 'vue';
|
|
23
|
+
import { useRoute, useRouter, RouterLink, type LocationQueryRaw } from 'vue-router';
|
|
24
|
+
import { Icon } from '@iconify/vue';
|
|
25
|
+
import { useBreakpoint } from '../composables/use-breakpoint';
|
|
26
|
+
import AppSelectField, { type SelectOption } from './AppSelectField.vue';
|
|
27
|
+
import type { TabDef } from './tab-types';
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<{
|
|
30
|
+
modelValue: string;
|
|
31
|
+
tabs: TabDef[];
|
|
32
|
+
subModelValue?: string;
|
|
33
|
+
subTabs?: TabDef[];
|
|
34
|
+
subLayout?: 'top' | 'sidebar';
|
|
35
|
+
/** Namespace prefix for the URL query keys; required when multiple
|
|
36
|
+
* routed AppTabs share a page. Empty (default) = plain `tab` /
|
|
37
|
+
* `sub`; non-empty = `${name}-tab` / `${name}-sub`. */
|
|
38
|
+
name?: string;
|
|
39
|
+
}>(), {
|
|
40
|
+
subModelValue: undefined,
|
|
41
|
+
subTabs: () => [],
|
|
42
|
+
subLayout: 'top',
|
|
43
|
+
name: ''
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{
|
|
47
|
+
(e: 'update:modelValue', value: string): void;
|
|
48
|
+
(e: 'update:subModelValue', value: string): void;
|
|
49
|
+
}>();
|
|
50
|
+
|
|
51
|
+
const { isMobile } = useBreakpoint();
|
|
52
|
+
const route = useRoute();
|
|
53
|
+
const router = useRouter();
|
|
54
|
+
|
|
55
|
+
const hasSub = computed(() => props.subTabs.length > 0);
|
|
56
|
+
|
|
57
|
+
const tabKey = computed(() => props.name ? `${props.name}-tab` : 'tab');
|
|
58
|
+
const subKey = computed(() => props.name ? `${props.name}-sub` : 'sub');
|
|
59
|
+
const defaultTab = computed(() => props.tabs[0]?.key ?? '');
|
|
60
|
+
const defaultSub = computed(() => props.subTabs[0]?.key ?? '');
|
|
61
|
+
|
|
62
|
+
function pickPrimary(key: string) { emit('update:modelValue', key); }
|
|
63
|
+
function pickSub(key: string) { emit('update:subModelValue', key); }
|
|
64
|
+
|
|
65
|
+
const primaryOptions = computed<SelectOption<string>[]>(() =>
|
|
66
|
+
props.tabs.map(t => ({ value: t.key, label: t.label }))
|
|
67
|
+
);
|
|
68
|
+
const subOptions = computed<SelectOption<string>[]>(() =>
|
|
69
|
+
props.subTabs.map(t => ({ value: t.key, label: t.label }))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
function readQuery(key: string): string {
|
|
73
|
+
const v = route.query[key];
|
|
74
|
+
return typeof v === 'string' ? v : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readFromRoute() {
|
|
78
|
+
const wantTab = readQuery(tabKey.value) || defaultTab.value;
|
|
79
|
+
if (wantTab && wantTab !== props.modelValue) emit('update:modelValue', wantTab);
|
|
80
|
+
|
|
81
|
+
if (props.subModelValue === undefined) return;
|
|
82
|
+
const wantSub = readQuery(subKey.value) || defaultSub.value;
|
|
83
|
+
if (wantSub && wantSub !== props.subModelValue) emit('update:subModelValue', wantSub);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function syncToRoute() {
|
|
87
|
+
const next: LocationQueryRaw = { ...route.query };
|
|
88
|
+
next[tabKey.value] = props.modelValue && props.modelValue !== defaultTab.value
|
|
89
|
+
? props.modelValue : undefined;
|
|
90
|
+
next[subKey.value] = props.subModelValue && props.subModelValue !== defaultSub.value
|
|
91
|
+
? props.subModelValue : undefined;
|
|
92
|
+
|
|
93
|
+
if ((route.query[tabKey.value] ?? null) === (next[tabKey.value] ?? null)
|
|
94
|
+
&& (route.query[subKey.value] ?? null) === (next[subKey.value] ?? null)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
router.replace({ query: next });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Seed from the URL on mount before any state→URL watcher fires —
|
|
101
|
+
// otherwise the initial sync would clobber the deep-link.
|
|
102
|
+
readFromRoute();
|
|
103
|
+
|
|
104
|
+
watch(() => [props.modelValue, props.subModelValue], () => syncToRoute());
|
|
105
|
+
watch(() => route.query, () => readFromRoute());
|
|
106
|
+
|
|
107
|
+
// Per-primary-tab memory of the last non-default sub the user picked.
|
|
108
|
+
const subMemory = ref<Record<string, string>>({});
|
|
109
|
+
watch(
|
|
110
|
+
() => [props.modelValue, props.subModelValue, defaultSub.value] as const,
|
|
111
|
+
([tab, sub, def]) => {
|
|
112
|
+
if (tab && typeof sub === 'string' && sub && sub !== def) {
|
|
113
|
+
subMemory.value[tab] = sub;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{ immediate: true }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
function tabLinkTo(key: string) {
|
|
120
|
+
const q: LocationQueryRaw = { ...route.query };
|
|
121
|
+
q[tabKey.value] = key === defaultTab.value ? undefined : key;
|
|
122
|
+
const remembered = subMemory.value[key];
|
|
123
|
+
q[subKey.value] = remembered ?? undefined;
|
|
124
|
+
return { path: route.path, query: q };
|
|
125
|
+
}
|
|
126
|
+
function subLinkTo(key: string) {
|
|
127
|
+
const q: LocationQueryRaw = { ...route.query };
|
|
128
|
+
q[subKey.value] = key === defaultSub.value ? undefined : key;
|
|
129
|
+
return { path: route.path, query: q };
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<!-- Mobile: two stacked dropdowns. -->
|
|
135
|
+
<div v-if="isMobile" class="tabs-root mobile">
|
|
136
|
+
<div class="dropdowns">
|
|
137
|
+
<AppSelectField
|
|
138
|
+
:model-value="modelValue"
|
|
139
|
+
:options="primaryOptions"
|
|
140
|
+
@update:model-value="pickPrimary"
|
|
141
|
+
/>
|
|
142
|
+
<AppSelectField
|
|
143
|
+
v-if="hasSub && subModelValue !== undefined"
|
|
144
|
+
:model-value="subModelValue"
|
|
145
|
+
:options="subOptions"
|
|
146
|
+
@update:model-value="pickSub"
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="panel">
|
|
150
|
+
<slot />
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Desktop, sub-tabs below primary. -->
|
|
155
|
+
<div
|
|
156
|
+
v-else-if="!hasSub || subLayout === 'top'"
|
|
157
|
+
class="tabs-root desktop top"
|
|
158
|
+
>
|
|
159
|
+
<nav class="primary-row" role="tablist">
|
|
160
|
+
<template v-for="t in tabs" :key="t.key">
|
|
161
|
+
<RouterLink
|
|
162
|
+
v-if="!t.disabled"
|
|
163
|
+
:to="tabLinkTo(t.key)"
|
|
164
|
+
role="tab"
|
|
165
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
166
|
+
:aria-selected="t.key === modelValue"
|
|
167
|
+
>
|
|
168
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
169
|
+
<span>{{ t.label }}</span>
|
|
170
|
+
</RouterLink>
|
|
171
|
+
<button
|
|
172
|
+
v-else
|
|
173
|
+
type="button"
|
|
174
|
+
role="tab"
|
|
175
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
176
|
+
:aria-selected="t.key === modelValue"
|
|
177
|
+
disabled
|
|
178
|
+
>
|
|
179
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
180
|
+
<span>{{ t.label }}</span>
|
|
181
|
+
</button>
|
|
182
|
+
</template>
|
|
183
|
+
</nav>
|
|
184
|
+
<nav v-if="hasSub" class="sub-row" role="tablist">
|
|
185
|
+
<template v-for="t in subTabs" :key="t.key">
|
|
186
|
+
<RouterLink
|
|
187
|
+
v-if="!t.disabled"
|
|
188
|
+
:to="subLinkTo(t.key)"
|
|
189
|
+
role="tab"
|
|
190
|
+
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
191
|
+
:aria-selected="t.key === subModelValue"
|
|
192
|
+
>
|
|
193
|
+
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
194
|
+
<span>{{ t.label }}</span>
|
|
195
|
+
</RouterLink>
|
|
196
|
+
<button
|
|
197
|
+
v-else
|
|
198
|
+
type="button"
|
|
199
|
+
role="tab"
|
|
200
|
+
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
201
|
+
:aria-selected="t.key === subModelValue"
|
|
202
|
+
disabled
|
|
203
|
+
>
|
|
204
|
+
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
205
|
+
<span>{{ t.label }}</span>
|
|
206
|
+
</button>
|
|
207
|
+
</template>
|
|
208
|
+
</nav>
|
|
209
|
+
<div class="panel">
|
|
210
|
+
<slot />
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<!-- Desktop, sub-tabs as a vertical sidebar. -->
|
|
215
|
+
<div v-else class="tabs-root desktop sidebar">
|
|
216
|
+
<nav class="primary-row" role="tablist">
|
|
217
|
+
<template v-for="t in tabs" :key="t.key">
|
|
218
|
+
<RouterLink
|
|
219
|
+
v-if="!t.disabled"
|
|
220
|
+
:to="tabLinkTo(t.key)"
|
|
221
|
+
role="tab"
|
|
222
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
223
|
+
:aria-selected="t.key === modelValue"
|
|
224
|
+
>
|
|
225
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
226
|
+
<span>{{ t.label }}</span>
|
|
227
|
+
</RouterLink>
|
|
228
|
+
<button
|
|
229
|
+
v-else
|
|
230
|
+
type="button"
|
|
231
|
+
role="tab"
|
|
232
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
233
|
+
:aria-selected="t.key === modelValue"
|
|
234
|
+
disabled
|
|
235
|
+
>
|
|
236
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
237
|
+
<span>{{ t.label }}</span>
|
|
238
|
+
</button>
|
|
239
|
+
</template>
|
|
240
|
+
</nav>
|
|
241
|
+
<div class="split">
|
|
242
|
+
<nav class="sub-side" role="tablist">
|
|
243
|
+
<template v-for="t in subTabs" :key="t.key">
|
|
244
|
+
<RouterLink
|
|
245
|
+
v-if="!t.disabled"
|
|
246
|
+
:to="subLinkTo(t.key)"
|
|
247
|
+
role="tab"
|
|
248
|
+
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
249
|
+
:aria-selected="t.key === subModelValue"
|
|
250
|
+
>
|
|
251
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
252
|
+
<span>{{ t.label }}</span>
|
|
253
|
+
</RouterLink>
|
|
254
|
+
<button
|
|
255
|
+
v-else
|
|
256
|
+
type="button"
|
|
257
|
+
role="tab"
|
|
258
|
+
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
259
|
+
:aria-selected="t.key === subModelValue"
|
|
260
|
+
disabled
|
|
261
|
+
>
|
|
262
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
263
|
+
<span>{{ t.label }}</span>
|
|
264
|
+
</button>
|
|
265
|
+
</template>
|
|
266
|
+
</nav>
|
|
267
|
+
<div class="panel side-panel">
|
|
268
|
+
<slot />
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<style scoped>
|
|
275
|
+
.tabs-root {
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-direction: column;
|
|
278
|
+
gap: 0.7rem;
|
|
279
|
+
min-height: 0;
|
|
280
|
+
flex: 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.primary-row {
|
|
284
|
+
display: flex;
|
|
285
|
+
gap: 0.4rem;
|
|
286
|
+
border-bottom: 1px solid var(--border);
|
|
287
|
+
padding-bottom: 0.5rem;
|
|
288
|
+
overflow-x: auto;
|
|
289
|
+
scrollbar-width: thin;
|
|
290
|
+
flex-shrink: 0;
|
|
291
|
+
}
|
|
292
|
+
.tab {
|
|
293
|
+
background: var(--bg-surface-2);
|
|
294
|
+
border: 1px solid transparent;
|
|
295
|
+
border-radius: var(--radius-base);
|
|
296
|
+
padding: 0.4rem 0.95rem;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
color: var(--text);
|
|
299
|
+
font: inherit;
|
|
300
|
+
font-size: 0.9rem;
|
|
301
|
+
font-weight: 500;
|
|
302
|
+
display: inline-flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
gap: 0.4rem;
|
|
305
|
+
white-space: nowrap;
|
|
306
|
+
text-decoration: none;
|
|
307
|
+
}
|
|
308
|
+
.tab:hover:not(:disabled) { background: var(--bg-surface-hover); }
|
|
309
|
+
.tab.active {
|
|
310
|
+
background: var(--accent-bg);
|
|
311
|
+
border-color: var(--accent);
|
|
312
|
+
color: var(--accent-text-strong);
|
|
313
|
+
}
|
|
314
|
+
.tab:disabled { opacity: 0.45; cursor: default; }
|
|
315
|
+
|
|
316
|
+
.sub-row {
|
|
317
|
+
display: flex;
|
|
318
|
+
gap: 0.6rem;
|
|
319
|
+
border-bottom: 1px solid var(--border);
|
|
320
|
+
overflow-x: auto;
|
|
321
|
+
scrollbar-width: thin;
|
|
322
|
+
margin-top: -0.3rem;
|
|
323
|
+
flex-shrink: 0;
|
|
324
|
+
}
|
|
325
|
+
.sub-tab {
|
|
326
|
+
background: none;
|
|
327
|
+
border: none;
|
|
328
|
+
color: var(--text-muted);
|
|
329
|
+
font: inherit;
|
|
330
|
+
font-size: 0.85rem;
|
|
331
|
+
font-weight: 500;
|
|
332
|
+
padding: 0.45rem 0.2rem;
|
|
333
|
+
cursor: pointer;
|
|
334
|
+
border-bottom: 2px solid transparent;
|
|
335
|
+
margin-bottom: -1px;
|
|
336
|
+
display: inline-flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
gap: 0.3rem;
|
|
339
|
+
white-space: nowrap;
|
|
340
|
+
text-decoration: none;
|
|
341
|
+
}
|
|
342
|
+
.sub-tab:hover:not(:disabled) { color: var(--text); }
|
|
343
|
+
.sub-tab.active { color: var(--accent-text-strong); border-color: var(--accent); }
|
|
344
|
+
.sub-tab:disabled { opacity: 0.45; cursor: default; }
|
|
345
|
+
|
|
346
|
+
.split {
|
|
347
|
+
display: flex;
|
|
348
|
+
gap: 0.9rem;
|
|
349
|
+
flex: 1;
|
|
350
|
+
min-height: 0;
|
|
351
|
+
}
|
|
352
|
+
.sub-side {
|
|
353
|
+
display: flex;
|
|
354
|
+
flex-direction: column;
|
|
355
|
+
gap: 0.15rem;
|
|
356
|
+
width: 200px;
|
|
357
|
+
flex-shrink: 0;
|
|
358
|
+
border-right: 1px solid var(--border);
|
|
359
|
+
padding-right: 0.6rem;
|
|
360
|
+
overflow-y: auto;
|
|
361
|
+
}
|
|
362
|
+
.sub-side-tab {
|
|
363
|
+
background: none;
|
|
364
|
+
border: 1px solid transparent;
|
|
365
|
+
border-radius: var(--radius-base);
|
|
366
|
+
padding: 0.5rem 0.7rem;
|
|
367
|
+
text-align: left;
|
|
368
|
+
color: var(--text-muted);
|
|
369
|
+
font: inherit;
|
|
370
|
+
font-size: 0.88rem;
|
|
371
|
+
font-weight: 500;
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
display: flex;
|
|
374
|
+
align-items: center;
|
|
375
|
+
gap: 0.5rem;
|
|
376
|
+
text-decoration: none;
|
|
377
|
+
}
|
|
378
|
+
.sub-side-tab:hover:not(:disabled) {
|
|
379
|
+
background: var(--bg-surface-hover);
|
|
380
|
+
color: var(--text);
|
|
381
|
+
}
|
|
382
|
+
.sub-side-tab.active {
|
|
383
|
+
background: var(--accent-bg);
|
|
384
|
+
color: var(--accent-text-strong);
|
|
385
|
+
border-color: var(--accent);
|
|
386
|
+
}
|
|
387
|
+
.sub-side-tab:disabled { opacity: 0.45; cursor: default; }
|
|
388
|
+
|
|
389
|
+
.panel {
|
|
390
|
+
flex: 1;
|
|
391
|
+
min-width: 0;
|
|
392
|
+
min-height: 0;
|
|
393
|
+
overflow-y: auto;
|
|
394
|
+
}
|
|
395
|
+
.side-panel {
|
|
396
|
+
padding-right: 0.2rem;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.tabs-root.mobile { gap: 0.5rem; }
|
|
400
|
+
.dropdowns {
|
|
401
|
+
display: flex;
|
|
402
|
+
flex-direction: column;
|
|
403
|
+
gap: 0.4rem;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@media (max-width: 640px) {
|
|
407
|
+
.sub-side { width: 160px; }
|
|
408
|
+
}
|
|
409
|
+
</style>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useId } from 'vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Multi-line counterpart to AppTextField — same label / hint / error
|
|
6
|
+
* chrome, but renders a vertically-resizable `<textarea>` and accepts
|
|
7
|
+
* a `rows` minimum.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
modelValue: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
readonly?: boolean;
|
|
18
|
+
rows?: number;
|
|
19
|
+
maxlength?: number;
|
|
20
|
+
fullWidth?: boolean;
|
|
21
|
+
name?: string;
|
|
22
|
+
}>(), {
|
|
23
|
+
label: '',
|
|
24
|
+
hint: '',
|
|
25
|
+
error: '',
|
|
26
|
+
placeholder: '',
|
|
27
|
+
disabled: false,
|
|
28
|
+
readonly: false,
|
|
29
|
+
rows: 3,
|
|
30
|
+
fullWidth: false,
|
|
31
|
+
name: ''
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: 'update:modelValue', value: string): void;
|
|
36
|
+
(e: 'blur', ev: FocusEvent): void;
|
|
37
|
+
(e: 'focus', ev: FocusEvent): void;
|
|
38
|
+
}>();
|
|
39
|
+
|
|
40
|
+
const fieldId = useId();
|
|
41
|
+
|
|
42
|
+
function onInput(e: Event): void {
|
|
43
|
+
const target = e.target as HTMLTextAreaElement;
|
|
44
|
+
emit('update:modelValue', target.value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rootClass = computed(() => ({
|
|
48
|
+
'app-field': true,
|
|
49
|
+
'app-field--full': props.fullWidth,
|
|
50
|
+
'app-field--error': !!props.error
|
|
51
|
+
}));
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<label :class="rootClass" :for="fieldId">
|
|
56
|
+
<span v-if="label" class="app-field__label">{{ label }}</span>
|
|
57
|
+
<textarea
|
|
58
|
+
:id="fieldId"
|
|
59
|
+
class="app-field__input app-field__input--textarea"
|
|
60
|
+
:value="modelValue"
|
|
61
|
+
:placeholder="placeholder"
|
|
62
|
+
:disabled="disabled"
|
|
63
|
+
:readonly="readonly"
|
|
64
|
+
:rows="rows"
|
|
65
|
+
:maxlength="maxlength"
|
|
66
|
+
:name="name || undefined"
|
|
67
|
+
@input="onInput"
|
|
68
|
+
@blur="emit('blur', $event)"
|
|
69
|
+
@focus="emit('focus', $event)"
|
|
70
|
+
></textarea>
|
|
71
|
+
<p v-if="error" class="app-field__error">{{ error }}</p>
|
|
72
|
+
<p v-else-if="hint" class="app-field__hint">{{ hint }}</p>
|
|
73
|
+
</label>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<style scoped>
|
|
77
|
+
.app-field {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 0.25rem;
|
|
81
|
+
min-width: 0;
|
|
82
|
+
}
|
|
83
|
+
.app-field--full {
|
|
84
|
+
grid-column: 1 / -1;
|
|
85
|
+
}
|
|
86
|
+
.app-field__label {
|
|
87
|
+
font-size: 0.75rem;
|
|
88
|
+
color: var(--text-muted);
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
}
|
|
91
|
+
.app-field__input {
|
|
92
|
+
padding: 0.45rem 0.6rem;
|
|
93
|
+
border: 1px solid var(--border);
|
|
94
|
+
border-radius: var(--radius-sm);
|
|
95
|
+
background: var(--bg-surface);
|
|
96
|
+
color: var(--text);
|
|
97
|
+
font: inherit;
|
|
98
|
+
width: 100%;
|
|
99
|
+
box-sizing: border-box;
|
|
100
|
+
}
|
|
101
|
+
.app-field__input--textarea {
|
|
102
|
+
resize: vertical;
|
|
103
|
+
min-height: 2.5rem;
|
|
104
|
+
font-family: inherit;
|
|
105
|
+
}
|
|
106
|
+
.app-field__input:focus {
|
|
107
|
+
outline: none;
|
|
108
|
+
border-color: var(--accent);
|
|
109
|
+
}
|
|
110
|
+
.app-field__input:disabled {
|
|
111
|
+
background: var(--bg-page);
|
|
112
|
+
cursor: not-allowed;
|
|
113
|
+
opacity: 0.7;
|
|
114
|
+
}
|
|
115
|
+
.app-field__hint {
|
|
116
|
+
margin: 0;
|
|
117
|
+
font-size: 0.72rem;
|
|
118
|
+
color: var(--text-faint, var(--text-muted));
|
|
119
|
+
}
|
|
120
|
+
.app-field__error {
|
|
121
|
+
margin: 0;
|
|
122
|
+
font-size: 0.75rem;
|
|
123
|
+
color: var(--danger);
|
|
124
|
+
}
|
|
125
|
+
.app-field--error .app-field__input {
|
|
126
|
+
border-color: var(--danger);
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup lang="ts" generic="V extends string | number | null">
|
|
2
|
+
import { computed, useId } from 'vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single-line text input with the label + hint + error chrome the
|
|
6
|
+
* admin pages were each re-writing. Use AppTextArea for multi-line.
|
|
7
|
+
*
|
|
8
|
+
* Pass `type="number"` (or other native types) to forward to the
|
|
9
|
+
* underlying `<input>` — the model value is whatever the input
|
|
10
|
+
* coerces to (typically string, since `type="number"` still hands
|
|
11
|
+
* back strings unless the consumer parses).
|
|
12
|
+
*
|
|
13
|
+
* No filter / formatter is built in. Domain transforms (trim, mask,
|
|
14
|
+
* parse number, etc.) stay at the caller; this component owns layout
|
|
15
|
+
* and visual state only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<{
|
|
19
|
+
modelValue: V;
|
|
20
|
+
label?: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
readonly?: boolean;
|
|
26
|
+
/** Native input type. Default: 'text'. */
|
|
27
|
+
type?: string;
|
|
28
|
+
/** Forward to the underlying input. */
|
|
29
|
+
maxlength?: number;
|
|
30
|
+
/** Visually de-emphasise (e.g. for display-only fields). */
|
|
31
|
+
muted?: boolean;
|
|
32
|
+
/** Grow to fill grid row in a parent `.grid` layout. */
|
|
33
|
+
fullWidth?: boolean;
|
|
34
|
+
/** Tag attribute for the underlying input — useful for forms. */
|
|
35
|
+
name?: string;
|
|
36
|
+
autocomplete?: string;
|
|
37
|
+
/** Focus on mount (typical for the first field in a modal). */
|
|
38
|
+
autofocus?: boolean;
|
|
39
|
+
/** Native `required` for form validation. */
|
|
40
|
+
required?: boolean;
|
|
41
|
+
/** Forwarded to the input. Useful for numeric IDs on mobile keyboards. */
|
|
42
|
+
inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
|
|
43
|
+
pattern?: string;
|
|
44
|
+
min?: number | string;
|
|
45
|
+
max?: number | string;
|
|
46
|
+
step?: number | string;
|
|
47
|
+
}>(), {
|
|
48
|
+
label: '',
|
|
49
|
+
hint: '',
|
|
50
|
+
error: '',
|
|
51
|
+
placeholder: '',
|
|
52
|
+
disabled: false,
|
|
53
|
+
readonly: false,
|
|
54
|
+
type: 'text',
|
|
55
|
+
muted: false,
|
|
56
|
+
fullWidth: false,
|
|
57
|
+
name: '',
|
|
58
|
+
autocomplete: 'off',
|
|
59
|
+
autofocus: false,
|
|
60
|
+
required: false
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const emit = defineEmits<{
|
|
64
|
+
(e: 'update:modelValue', value: V): void;
|
|
65
|
+
(e: 'blur', ev: FocusEvent): void;
|
|
66
|
+
(e: 'focus', ev: FocusEvent): void;
|
|
67
|
+
}>();
|
|
68
|
+
|
|
69
|
+
const fieldId = useId();
|
|
70
|
+
|
|
71
|
+
function onInput(e: Event): void {
|
|
72
|
+
const target = e.target as HTMLInputElement;
|
|
73
|
+
emit('update:modelValue', target.value as V);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rootClass = computed(() => ({
|
|
77
|
+
'app-field': true,
|
|
78
|
+
'app-field--full': props.fullWidth,
|
|
79
|
+
'app-field--error': !!props.error
|
|
80
|
+
}));
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<label :class="rootClass" :for="fieldId">
|
|
85
|
+
<span v-if="label" class="app-field__label">{{ label }}</span>
|
|
86
|
+
<input
|
|
87
|
+
:id="fieldId"
|
|
88
|
+
:class="['app-field__input', { 'app-field__input--muted': muted }]"
|
|
89
|
+
:type="type"
|
|
90
|
+
:value="modelValue"
|
|
91
|
+
:placeholder="placeholder"
|
|
92
|
+
:disabled="disabled"
|
|
93
|
+
:readonly="readonly"
|
|
94
|
+
:maxlength="maxlength"
|
|
95
|
+
:name="name || undefined"
|
|
96
|
+
:autocomplete="autocomplete"
|
|
97
|
+
:autofocus="autofocus"
|
|
98
|
+
:required="required"
|
|
99
|
+
:inputmode="inputmode"
|
|
100
|
+
:pattern="pattern || undefined"
|
|
101
|
+
:min="min"
|
|
102
|
+
:max="max"
|
|
103
|
+
:step="step"
|
|
104
|
+
@input="onInput"
|
|
105
|
+
@blur="emit('blur', $event)"
|
|
106
|
+
@focus="emit('focus', $event)"
|
|
107
|
+
/>
|
|
108
|
+
<p v-if="error" class="app-field__error">{{ error }}</p>
|
|
109
|
+
<p v-else-if="hint" class="app-field__hint">{{ hint }}</p>
|
|
110
|
+
</label>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<style scoped>
|
|
114
|
+
.app-field {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
gap: 0.25rem;
|
|
118
|
+
min-width: 0;
|
|
119
|
+
}
|
|
120
|
+
.app-field--full {
|
|
121
|
+
grid-column: 1 / -1;
|
|
122
|
+
}
|
|
123
|
+
.app-field__label {
|
|
124
|
+
font-size: 0.75rem;
|
|
125
|
+
color: var(--text-muted);
|
|
126
|
+
font-weight: 600;
|
|
127
|
+
}
|
|
128
|
+
.app-field__input {
|
|
129
|
+
padding: 0.45rem 0.6rem;
|
|
130
|
+
border: 1px solid var(--border);
|
|
131
|
+
border-radius: var(--radius-sm);
|
|
132
|
+
background: var(--bg-surface);
|
|
133
|
+
color: var(--text);
|
|
134
|
+
font: inherit;
|
|
135
|
+
width: 100%;
|
|
136
|
+
box-sizing: border-box;
|
|
137
|
+
}
|
|
138
|
+
.app-field__input:focus {
|
|
139
|
+
outline: none;
|
|
140
|
+
border-color: var(--accent);
|
|
141
|
+
}
|
|
142
|
+
.app-field__input--muted {
|
|
143
|
+
background: var(--bg-page) !important;
|
|
144
|
+
color: var(--text-muted) !important;
|
|
145
|
+
cursor: default;
|
|
146
|
+
}
|
|
147
|
+
.app-field__input:disabled {
|
|
148
|
+
background: var(--bg-page);
|
|
149
|
+
cursor: not-allowed;
|
|
150
|
+
opacity: 0.7;
|
|
151
|
+
}
|
|
152
|
+
.app-field__hint {
|
|
153
|
+
margin: 0;
|
|
154
|
+
font-size: 0.72rem;
|
|
155
|
+
color: var(--text-faint, var(--text-muted));
|
|
156
|
+
}
|
|
157
|
+
.app-field__error {
|
|
158
|
+
margin: 0;
|
|
159
|
+
font-size: 0.75rem;
|
|
160
|
+
color: var(--danger);
|
|
161
|
+
}
|
|
162
|
+
.app-field--error .app-field__input {
|
|
163
|
+
border-color: var(--danger);
|
|
164
|
+
}
|
|
165
|
+
</style>
|