@tunji_xy/ui 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/README.md +68 -2
- package/app/assets/css/main.css +193 -0
- package/app/components/Button.vue +126 -0
- package/app/components/Checkbox.vue +130 -0
- package/app/components/DropdownMenu.vue +444 -0
- package/app/components/Input.vue +172 -0
- package/app/components/Modal.vue +210 -0
- package/app/components/OtpInput.vue +251 -0
- package/app/components/ProgressBar.vue +122 -0
- package/app/components/Radio.vue +131 -0
- package/app/components/Select.vue +680 -0
- package/app/components/Switch.vue +141 -0
- package/app/components/Tabs.vue +288 -0
- package/app/components/Textarea.vue +188 -0
- package/app/components/Tooltip.vue +292 -0
- package/app/lib/cn.ts +25 -0
- package/nuxt.config.ts +22 -0
- package/package.json +57 -4
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- display: contents, so wrapping the trigger never changes its layout. -->
|
|
3
|
+
<span
|
|
4
|
+
ref="wrapper"
|
|
5
|
+
class="contents"
|
|
6
|
+
@click="onClick"
|
|
7
|
+
@keydown="onTriggerKeydown"
|
|
8
|
+
@focusout="onFocusout"
|
|
9
|
+
>
|
|
10
|
+
<slot :open="open" />
|
|
11
|
+
<!-- mousedown.prevent: clicking in the menu keeps focus where it is, so focusout doesn't close it. -->
|
|
12
|
+
<span
|
|
13
|
+
:id="menuId"
|
|
14
|
+
ref="menu"
|
|
15
|
+
:aria-labelledby="triggerId"
|
|
16
|
+
v-bind="menuAttrs"
|
|
17
|
+
popover="manual"
|
|
18
|
+
role="menu"
|
|
19
|
+
tabindex="-1"
|
|
20
|
+
:class="menuClasses"
|
|
21
|
+
@keydown="onMenuKeydown"
|
|
22
|
+
@mousedown.prevent
|
|
23
|
+
>
|
|
24
|
+
<template
|
|
25
|
+
v-for="(item, index) in items"
|
|
26
|
+
:key="item.type === 'divider' ? `divider-${index}` : item.id"
|
|
27
|
+
>
|
|
28
|
+
<span
|
|
29
|
+
v-if="item.type === 'divider'"
|
|
30
|
+
role="separator"
|
|
31
|
+
class="-mx-1 my-1 block h-px bg-border"
|
|
32
|
+
/>
|
|
33
|
+
<button
|
|
34
|
+
v-else
|
|
35
|
+
type="button"
|
|
36
|
+
role="menuitem"
|
|
37
|
+
tabindex="-1"
|
|
38
|
+
:disabled="item.disabled"
|
|
39
|
+
:class="itemClasses(item)"
|
|
40
|
+
@click="select(item)"
|
|
41
|
+
@pointermove="onItemPointermove"
|
|
42
|
+
@pointerleave="onItemPointerleave"
|
|
43
|
+
>
|
|
44
|
+
<!-- Rendered empty on items without an icon, so every label lines up. -->
|
|
45
|
+
<span
|
|
46
|
+
v-if="hasIcons"
|
|
47
|
+
:class="
|
|
48
|
+
cn(
|
|
49
|
+
'inline-flex h-5 w-4 shrink-0 items-center justify-center [&>svg]:size-4',
|
|
50
|
+
!item.danger && 'text-muted-foreground',
|
|
51
|
+
)
|
|
52
|
+
"
|
|
53
|
+
>
|
|
54
|
+
<ItemIcon v-if="item.icon" :icon="item.icon" />
|
|
55
|
+
</span>
|
|
56
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
57
|
+
<span class="truncate">{{ item.label }}</span>
|
|
58
|
+
<span
|
|
59
|
+
v-if="item.description"
|
|
60
|
+
:class="
|
|
61
|
+
cn(
|
|
62
|
+
'text-xs',
|
|
63
|
+
item.danger ? 'text-danger/75' : 'text-muted-foreground',
|
|
64
|
+
)
|
|
65
|
+
"
|
|
66
|
+
>
|
|
67
|
+
{{ item.description }}
|
|
68
|
+
</span>
|
|
69
|
+
</span>
|
|
70
|
+
</button>
|
|
71
|
+
</template>
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<script setup lang="ts">
|
|
77
|
+
import {
|
|
78
|
+
computed,
|
|
79
|
+
h,
|
|
80
|
+
onBeforeUnmount,
|
|
81
|
+
onMounted,
|
|
82
|
+
ref,
|
|
83
|
+
useAttrs,
|
|
84
|
+
useId,
|
|
85
|
+
useTemplateRef,
|
|
86
|
+
watch,
|
|
87
|
+
type Component,
|
|
88
|
+
type FunctionalComponent,
|
|
89
|
+
} from "vue";
|
|
90
|
+
import { cn } from "../lib/cn";
|
|
91
|
+
|
|
92
|
+
export interface DropdownMenuAction {
|
|
93
|
+
type?: "item";
|
|
94
|
+
id: string | number;
|
|
95
|
+
label: string;
|
|
96
|
+
icon?: string | Component;
|
|
97
|
+
description?: string;
|
|
98
|
+
danger?: boolean;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DropdownMenuDivider {
|
|
103
|
+
type: "divider";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type DropdownMenuItem = DropdownMenuAction | DropdownMenuDivider;
|
|
107
|
+
|
|
108
|
+
export interface DropdownMenuProps {
|
|
109
|
+
items: DropdownMenuItem[];
|
|
110
|
+
placement?: "start" | "end" | "top-start" | "top-end";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const props = withDefaults(defineProps<DropdownMenuProps>(), {
|
|
114
|
+
placement: "start",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const open = defineModel<boolean>("open", { default: false });
|
|
118
|
+
|
|
119
|
+
const emit = defineEmits<{
|
|
120
|
+
select: [item: DropdownMenuAction];
|
|
121
|
+
}>();
|
|
122
|
+
|
|
123
|
+
defineSlots<{
|
|
124
|
+
/** The element that opens the menu, e.g. a <UiButton>. */
|
|
125
|
+
default?: (props: { open: boolean }) => unknown;
|
|
126
|
+
}>();
|
|
127
|
+
|
|
128
|
+
// `class` and other attributes go on the menu panel.
|
|
129
|
+
defineOptions({ inheritAttrs: false });
|
|
130
|
+
const attrs = useAttrs();
|
|
131
|
+
const menuAttrs = computed(() => {
|
|
132
|
+
const { class: _class, ...rest } = attrs;
|
|
133
|
+
return rest;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const menuId = `ui-dropdown-menu-${useId()}`;
|
|
137
|
+
const triggerId = ref<string>();
|
|
138
|
+
const wrapperEl = useTemplateRef<HTMLSpanElement>("wrapper");
|
|
139
|
+
const menuEl = useTemplateRef<HTMLSpanElement>("menu");
|
|
140
|
+
|
|
141
|
+
const actions = computed(() =>
|
|
142
|
+
props.items.filter(
|
|
143
|
+
(item): item is DropdownMenuAction => item.type !== "divider",
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
const hasIcons = computed(() => actions.value.some((item) => item.icon));
|
|
147
|
+
|
|
148
|
+
const ItemIcon: FunctionalComponent<{ icon: string | Component }> = ({
|
|
149
|
+
icon,
|
|
150
|
+
}) => (typeof icon === "string" ? icon : h(icon));
|
|
151
|
+
|
|
152
|
+
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";
|
|
153
|
+
|
|
154
|
+
// The slot's root, or the first focusable element in it when the root only
|
|
155
|
+
// wraps the control (a <UiTooltip> around a button, say).
|
|
156
|
+
function target() {
|
|
157
|
+
const root = wrapperEl.value?.firstElementChild;
|
|
158
|
+
if (!root || root === menuEl.value) return null;
|
|
159
|
+
return (
|
|
160
|
+
root.matches(FOCUSABLE) ? root : (root.querySelector(FOCUSABLE) ?? root)
|
|
161
|
+
) as HTMLElement;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The trigger is whatever the app put in the slot, so ARIA is set on it
|
|
165
|
+
// directly. One that can't take focus is made into a button for keyboards.
|
|
166
|
+
function wire() {
|
|
167
|
+
const el = target();
|
|
168
|
+
if (!el) return;
|
|
169
|
+
if (!el.matches(FOCUSABLE)) {
|
|
170
|
+
el.tabIndex = 0;
|
|
171
|
+
el.setAttribute("role", "button");
|
|
172
|
+
}
|
|
173
|
+
el.id ||= `${menuId}-trigger`;
|
|
174
|
+
el.setAttribute("aria-haspopup", "menu");
|
|
175
|
+
el.setAttribute("aria-controls", menuId);
|
|
176
|
+
el.setAttribute("aria-expanded", String(open.value));
|
|
177
|
+
triggerId.value = el.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const buttons = () => [
|
|
181
|
+
...(menuEl.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ??
|
|
182
|
+
[]),
|
|
183
|
+
];
|
|
184
|
+
const focusedIndex = () =>
|
|
185
|
+
buttons().indexOf(document.activeElement as HTMLButtonElement);
|
|
186
|
+
|
|
187
|
+
// The next enabled item after `from`, wrapping round, or -1.
|
|
188
|
+
function nextEnabled(from: number, step: 1 | -1) {
|
|
189
|
+
const count = actions.value.length;
|
|
190
|
+
for (let i = 1; i <= count; i++) {
|
|
191
|
+
const index = (((from + step * i) % count) + count) % count;
|
|
192
|
+
if (!actions.value[index]!.disabled) return index;
|
|
193
|
+
}
|
|
194
|
+
return -1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const first = () => nextEnabled(-1, 1);
|
|
198
|
+
const last = () => nextEnabled(0, -1);
|
|
199
|
+
|
|
200
|
+
const focusItem = (index: number) => (buttons()[index] ?? menuEl.value)?.focus();
|
|
201
|
+
|
|
202
|
+
type Focus = "menu" | "first" | "last";
|
|
203
|
+
let focusOnOpen: Focus = "menu";
|
|
204
|
+
|
|
205
|
+
function focusInto(focus: Focus) {
|
|
206
|
+
if (focus === "menu") menuEl.value?.focus({ preventScroll: true });
|
|
207
|
+
else focusItem(focus === "first" ? first() : last());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function show(focus: Focus = "menu") {
|
|
211
|
+
if (open.value) return focusInto(focus);
|
|
212
|
+
focusOnOpen = focus;
|
|
213
|
+
open.value = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function returnFocus() {
|
|
217
|
+
if (menuEl.value?.contains(document.activeElement)) target()?.focus();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function close() {
|
|
221
|
+
returnFocus();
|
|
222
|
+
open.value = false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function select(item: DropdownMenuAction) {
|
|
226
|
+
if (item.disabled) return;
|
|
227
|
+
// Closing first puts focus back on the trigger, so a handler that moves it
|
|
228
|
+
// (by opening a <UiModal>, say) isn't undone afterwards.
|
|
229
|
+
close();
|
|
230
|
+
emit("select", item);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// detail is 0 for a click made with Enter or Space, which opens on the first item.
|
|
234
|
+
function onClick(event: MouseEvent) {
|
|
235
|
+
if (menuEl.value?.contains(event.target as Node)) return;
|
|
236
|
+
if (open.value) close();
|
|
237
|
+
else show(event.detail === 0 ? "first" : "menu");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function onTriggerKeydown(event: KeyboardEvent) {
|
|
241
|
+
const el = event.target as HTMLElement;
|
|
242
|
+
if (menuEl.value?.contains(el)) return;
|
|
243
|
+
switch (event.key) {
|
|
244
|
+
case "ArrowDown":
|
|
245
|
+
show("first");
|
|
246
|
+
break;
|
|
247
|
+
case "ArrowUp":
|
|
248
|
+
show("last");
|
|
249
|
+
break;
|
|
250
|
+
case "Enter":
|
|
251
|
+
case " ":
|
|
252
|
+
// These already turn Enter and Space into a click.
|
|
253
|
+
if (el.matches("button, a[href], input, summary")) return;
|
|
254
|
+
if (open.value) close();
|
|
255
|
+
else show("first");
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let search = "";
|
|
264
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
265
|
+
|
|
266
|
+
// Jumps to the next item starting with what's been typed. Pressing the same
|
|
267
|
+
// letter again cycles through the items starting with it.
|
|
268
|
+
function typeahead(char: string) {
|
|
269
|
+
clearTimeout(searchTimer);
|
|
270
|
+
searchTimer = setTimeout(() => (search = ""), 500);
|
|
271
|
+
search += char.toLowerCase();
|
|
272
|
+
|
|
273
|
+
const repeated = [...search].every((c) => c === search[0]);
|
|
274
|
+
const prefix = repeated ? search[0]! : search;
|
|
275
|
+
const from = focusedIndex();
|
|
276
|
+
const start = repeated ? from + 1 : Math.max(from, 0);
|
|
277
|
+
const count = actions.value.length;
|
|
278
|
+
for (let i = 0; i < count; i++) {
|
|
279
|
+
const index = (start + i) % count;
|
|
280
|
+
const item = actions.value[index]!;
|
|
281
|
+
if (!item.disabled && item.label.toLowerCase().startsWith(prefix)) {
|
|
282
|
+
focusItem(index);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function onMenuKeydown(event: KeyboardEvent) {
|
|
289
|
+
const { key } = event;
|
|
290
|
+
const from = focusedIndex();
|
|
291
|
+
switch (key) {
|
|
292
|
+
case "ArrowDown":
|
|
293
|
+
focusItem(from === -1 ? first() : nextEnabled(from, 1));
|
|
294
|
+
break;
|
|
295
|
+
case "ArrowUp":
|
|
296
|
+
focusItem(from === -1 ? last() : nextEnabled(from, -1));
|
|
297
|
+
break;
|
|
298
|
+
case "Home":
|
|
299
|
+
focusItem(first());
|
|
300
|
+
break;
|
|
301
|
+
case "End":
|
|
302
|
+
focusItem(last());
|
|
303
|
+
break;
|
|
304
|
+
case "Escape":
|
|
305
|
+
// With preventDefault below, stops a <UiModal> around the menu closing too.
|
|
306
|
+
event.stopPropagation();
|
|
307
|
+
close();
|
|
308
|
+
break;
|
|
309
|
+
case "Tab":
|
|
310
|
+
// close() moves focus back to the trigger, and the browser tabs on from there.
|
|
311
|
+
close();
|
|
312
|
+
return;
|
|
313
|
+
default:
|
|
314
|
+
if (
|
|
315
|
+
key.length !== 1 ||
|
|
316
|
+
key === " " ||
|
|
317
|
+
event.ctrlKey ||
|
|
318
|
+
event.metaKey ||
|
|
319
|
+
event.altKey
|
|
320
|
+
)
|
|
321
|
+
return;
|
|
322
|
+
typeahead(key);
|
|
323
|
+
}
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Hovering focuses the item, so the pointer and keyboard share one highlight.
|
|
328
|
+
function onItemPointermove(event: PointerEvent) {
|
|
329
|
+
const item = event.currentTarget as HTMLButtonElement;
|
|
330
|
+
if (event.pointerType !== "touch" && document.activeElement !== item) {
|
|
331
|
+
item.focus({ preventScroll: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function onItemPointerleave(event: PointerEvent) {
|
|
336
|
+
if (document.activeElement === event.currentTarget) {
|
|
337
|
+
menuEl.value?.focus({ preventScroll: true });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Focus is always inside while it's open, so this also covers outside clicks.
|
|
342
|
+
function onFocusout(event: FocusEvent) {
|
|
343
|
+
if (
|
|
344
|
+
open.value &&
|
|
345
|
+
!wrapperEl.value?.contains(event.relatedTarget as Node | null)
|
|
346
|
+
) {
|
|
347
|
+
close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const GAP = 6;
|
|
352
|
+
const EDGE = 8;
|
|
353
|
+
|
|
354
|
+
// Below the trigger (above for `top-*`), flipping when the other side has
|
|
355
|
+
// more room. `start`/`end` line it up with the trigger's left/right edge,
|
|
356
|
+
// mirrored in right-to-left text, and it slides along to stay on screen.
|
|
357
|
+
function position() {
|
|
358
|
+
const anchor = target();
|
|
359
|
+
const menu = menuEl.value;
|
|
360
|
+
if (!anchor || !menu) return;
|
|
361
|
+
// Measured at the top-left, where nothing squeezes its width.
|
|
362
|
+
Object.assign(menu.style, {
|
|
363
|
+
left: "0px",
|
|
364
|
+
top: "0px",
|
|
365
|
+
bottom: "auto",
|
|
366
|
+
maxHeight: "",
|
|
367
|
+
});
|
|
368
|
+
const rect = anchor.getBoundingClientRect();
|
|
369
|
+
const { offsetWidth: width, offsetHeight: height } = menu;
|
|
370
|
+
const { clientWidth: vw, clientHeight: vh } = document.documentElement;
|
|
371
|
+
const below = vh - rect.bottom - GAP - EDGE;
|
|
372
|
+
const above = rect.top - GAP - EDGE;
|
|
373
|
+
|
|
374
|
+
const wantsTop = props.placement.startsWith("top");
|
|
375
|
+
const [wanted, other] = wantsTop ? [above, below] : [below, above];
|
|
376
|
+
const up = wantsTop !== (height > wanted && other > wanted);
|
|
377
|
+
const rtl = getComputedStyle(anchor).direction === "rtl";
|
|
378
|
+
const alignEnd = props.placement.endsWith("end") !== rtl;
|
|
379
|
+
const x = alignEnd ? rect.right - width : rect.left;
|
|
380
|
+
|
|
381
|
+
Object.assign(menu.style, {
|
|
382
|
+
left: `${Math.max(EDGE, Math.min(x, vw - width - EDGE))}px`,
|
|
383
|
+
top: up ? "auto" : `${rect.bottom + GAP}px`,
|
|
384
|
+
bottom: up ? `${vh - rect.top + GAP}px` : "auto",
|
|
385
|
+
maxHeight: `${up ? above : below}px`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const onScroll = (event: Event) => {
|
|
390
|
+
if (event.target !== menuEl.value) position();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
function stopTracking() {
|
|
394
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
395
|
+
window.removeEventListener("resize", position);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sync(isOpen: boolean) {
|
|
399
|
+
const menu = menuEl.value;
|
|
400
|
+
if (!menu) return;
|
|
401
|
+
wire();
|
|
402
|
+
if (isOpen) {
|
|
403
|
+
if (!menu.matches(":popover-open")) menu.showPopover();
|
|
404
|
+
position();
|
|
405
|
+
focusInto(focusOnOpen);
|
|
406
|
+
focusOnOpen = "menu";
|
|
407
|
+
window.addEventListener("scroll", onScroll, {
|
|
408
|
+
capture: true,
|
|
409
|
+
passive: true,
|
|
410
|
+
});
|
|
411
|
+
window.addEventListener("resize", position);
|
|
412
|
+
} else {
|
|
413
|
+
returnFocus();
|
|
414
|
+
if (menu.matches(":popover-open")) menu.hidePopover();
|
|
415
|
+
stopTracking();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
onMounted(() => sync(open.value));
|
|
420
|
+
watch(open, sync, { flush: "post" });
|
|
421
|
+
|
|
422
|
+
onBeforeUnmount(() => {
|
|
423
|
+
stopTracking();
|
|
424
|
+
clearTimeout(searchTimer);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const menuClasses = computed(() =>
|
|
428
|
+
cn(
|
|
429
|
+
"inset-auto m-0 w-max min-w-48 max-w-[min(20rem,calc(100vw_-_1rem))] animate-fade-in overflow-y-auto overscroll-contain " +
|
|
430
|
+
"rounded-control border border-border bg-surface p-1 text-foreground shadow-lg outline-none dark:scheme-dark motion-reduce:animate-none",
|
|
431
|
+
attrs.class as string,
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Inner radius = outer radius - padding, so the corners stay concentric.
|
|
436
|
+
// focus:outline-hidden still draws an outline in Windows high-contrast mode,
|
|
437
|
+
// which drops the background highlight.
|
|
438
|
+
const itemClasses = (item: DropdownMenuAction) =>
|
|
439
|
+
cn(
|
|
440
|
+
"flex w-full cursor-pointer items-start gap-2 rounded-[calc(var(--radius-control)_-_0.25rem)] px-2.5 py-1.5 text-left text-sm " +
|
|
441
|
+
"select-none focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
|
442
|
+
item.danger ? "text-danger focus:bg-danger/10" : "focus:bg-muted",
|
|
443
|
+
);
|
|
444
|
+
</script>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="cn('flex flex-col gap-1 text-foreground', attrs.class as string)"
|
|
4
|
+
>
|
|
5
|
+
<label v-if="label" :for="inputId" class="text-sm font-semibold">
|
|
6
|
+
{{ label }}
|
|
7
|
+
<span v-if="required" class="ml-0.5 text-danger" aria-hidden="true"
|
|
8
|
+
>*</span
|
|
9
|
+
>
|
|
10
|
+
</label>
|
|
11
|
+
|
|
12
|
+
<div :class="controlClasses">
|
|
13
|
+
<span
|
|
14
|
+
v-if="$slots.leading"
|
|
15
|
+
class="inline-flex items-center text-muted-foreground"
|
|
16
|
+
>
|
|
17
|
+
<slot name="leading" />
|
|
18
|
+
</span>
|
|
19
|
+
|
|
20
|
+
<input
|
|
21
|
+
:id="inputId"
|
|
22
|
+
ref="input"
|
|
23
|
+
v-bind="inputAttrs"
|
|
24
|
+
class="h-full min-w-0 flex-1 bg-transparent p-0 text-inherit outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed"
|
|
25
|
+
:type="type"
|
|
26
|
+
:name="name"
|
|
27
|
+
:value="model ?? ''"
|
|
28
|
+
:placeholder="placeholder"
|
|
29
|
+
:disabled="disabled"
|
|
30
|
+
:readonly="readonly"
|
|
31
|
+
:required="required"
|
|
32
|
+
:autocomplete="autocomplete"
|
|
33
|
+
:aria-invalid="error ? true : undefined"
|
|
34
|
+
:aria-describedby="message ? messageId : undefined"
|
|
35
|
+
@input="onInput"
|
|
36
|
+
@blur="emit('blur', $event)"
|
|
37
|
+
@focus="emit('focus', $event)"
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<span
|
|
41
|
+
v-if="$slots.trailing"
|
|
42
|
+
class="inline-flex items-center text-muted-foreground"
|
|
43
|
+
>
|
|
44
|
+
<slot name="trailing" />
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<p
|
|
49
|
+
v-if="message"
|
|
50
|
+
:id="messageId"
|
|
51
|
+
:class="cn('text-sm', error ? 'text-danger' : 'text-muted-foreground')"
|
|
52
|
+
:role="error ? 'alert' : undefined"
|
|
53
|
+
>
|
|
54
|
+
{{ message }}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { computed, useAttrs, useId, useTemplateRef } from "vue";
|
|
61
|
+
import { cn } from "../lib/cn";
|
|
62
|
+
|
|
63
|
+
export interface InputProps {
|
|
64
|
+
label?: string;
|
|
65
|
+
type?: "text" | "email" | "password" | "search" | "tel" | "url" | "number";
|
|
66
|
+
placeholder?: string;
|
|
67
|
+
hint?: string;
|
|
68
|
+
error?: string;
|
|
69
|
+
variant?: "outline" | "filled" | "ghost";
|
|
70
|
+
size?: "sm" | "md" | "lg";
|
|
71
|
+
radius?: "none" | "sm" | "md" | "lg" | "full";
|
|
72
|
+
disabled?: boolean;
|
|
73
|
+
readonly?: boolean;
|
|
74
|
+
required?: boolean;
|
|
75
|
+
id?: string;
|
|
76
|
+
name?: string;
|
|
77
|
+
autocomplete?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const props = withDefaults(defineProps<InputProps>(), {
|
|
81
|
+
type: "text",
|
|
82
|
+
variant: "outline",
|
|
83
|
+
size: "md",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const model = defineModel<string | number | null>({ default: "" });
|
|
87
|
+
|
|
88
|
+
const emit = defineEmits<{
|
|
89
|
+
blur: [event: FocusEvent];
|
|
90
|
+
focus: [event: FocusEvent];
|
|
91
|
+
}>();
|
|
92
|
+
|
|
93
|
+
defineSlots<{
|
|
94
|
+
leading?: () => unknown;
|
|
95
|
+
trailing?: () => unknown;
|
|
96
|
+
}>();
|
|
97
|
+
|
|
98
|
+
// `class` styles the outer wrapper (e.g. class="w-64"); every other attribute
|
|
99
|
+
// (maxlength, min, step, autofocus, data-*) goes to the <input> itself.
|
|
100
|
+
defineOptions({ inheritAttrs: false });
|
|
101
|
+
const attrs = useAttrs();
|
|
102
|
+
const inputAttrs = computed(() => {
|
|
103
|
+
const { class: _class, ...rest } = attrs;
|
|
104
|
+
return rest;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const autoId = useId();
|
|
108
|
+
const inputId = computed(() => props.id ?? `ui-input-${autoId}`);
|
|
109
|
+
const messageId = computed(() => `${inputId.value}-message`);
|
|
110
|
+
const message = computed(() => props.error || props.hint);
|
|
111
|
+
|
|
112
|
+
const variants = {
|
|
113
|
+
outline: "border border-border bg-surface focus-within:ring-3",
|
|
114
|
+
filled: "border border-transparent bg-foreground/5 focus-within:ring-3",
|
|
115
|
+
ghost:
|
|
116
|
+
"rounded-none border-b border-border px-0 focus-within:shadow-[0_1px_0_0]",
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
const hovers = {
|
|
120
|
+
outline: "hover:border-border-strong",
|
|
121
|
+
filled: "hover:bg-foreground/10",
|
|
122
|
+
// A clicked field is still under the pointer, so don't grey out its underline.
|
|
123
|
+
ghost: "hover:not-focus-within:border-border-strong",
|
|
124
|
+
} as const;
|
|
125
|
+
|
|
126
|
+
const sizes = {
|
|
127
|
+
sm: "h-8 px-2 text-sm",
|
|
128
|
+
md: "h-10 px-3 text-base sm:text-sm",
|
|
129
|
+
lg: "h-12 px-4 text-base",
|
|
130
|
+
} as const;
|
|
131
|
+
|
|
132
|
+
const radii = {
|
|
133
|
+
none: "rounded-none",
|
|
134
|
+
sm: "rounded-sm",
|
|
135
|
+
md: "rounded-md",
|
|
136
|
+
lg: "rounded-lg",
|
|
137
|
+
full: "rounded-full",
|
|
138
|
+
} as const;
|
|
139
|
+
|
|
140
|
+
const controlClasses = computed(() =>
|
|
141
|
+
cn(
|
|
142
|
+
"flex items-center gap-2 rounded-control transition-[background-color,border-color,box-shadow]",
|
|
143
|
+
sizes[props.size],
|
|
144
|
+
props.radius && radii[props.radius],
|
|
145
|
+
// After the radius, so ghost stays square whatever `radius` says.
|
|
146
|
+
variants[props.variant],
|
|
147
|
+
props.error
|
|
148
|
+
? "border-danger focus-within:ring-danger/30 focus-within:shadow-danger"
|
|
149
|
+
: "focus-within:border-primary focus-within:ring-primary/35 focus-within:shadow-primary",
|
|
150
|
+
!props.error && !props.disabled && hovers[props.variant],
|
|
151
|
+
props.disabled && "cursor-not-allowed opacity-60",
|
|
152
|
+
props.disabled && props.variant !== "ghost" && "bg-muted",
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const inputEl = useTemplateRef<HTMLInputElement>("input");
|
|
157
|
+
|
|
158
|
+
const onInput = (event: Event) => {
|
|
159
|
+
const target = event.target as HTMLInputElement;
|
|
160
|
+
if (props.type === "number") {
|
|
161
|
+
model.value = target.value === "" ? null : target.valueAsNumber;
|
|
162
|
+
} else {
|
|
163
|
+
model.value = target.value;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
defineExpose({
|
|
168
|
+
focus: () => inputEl.value?.focus(),
|
|
169
|
+
select: () => inputEl.value?.select(),
|
|
170
|
+
el: inputEl,
|
|
171
|
+
});
|
|
172
|
+
</script>
|