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