@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.
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <dialog
3
+ ref="dialog"
4
+ v-bind="passthroughAttrs"
5
+ data-ui-modal
6
+ :data-size="size"
7
+ :data-closing="closing || undefined"
8
+ :class="dialogClasses"
9
+ :aria-labelledby="title || $slots.header ? titleId : undefined"
10
+ :aria-describedby="description ? descriptionId : undefined"
11
+ @cancel="onCancel"
12
+ @close="onClose"
13
+ @pointerdown="onPointerDown"
14
+ @click="onClick"
15
+ >
16
+ <!-- Only the content scrolls, so the header, × and footer stay in view.
17
+ The bottom padding clears the iPhone home indicator under a sheet. -->
18
+ <div
19
+ class="relative flex min-h-0 grow flex-col gap-4 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
20
+ >
21
+ <header v-if="title || description || $slots.header" class="pr-8">
22
+ <div :id="titleId">
23
+ <slot name="header" :close="close">
24
+ <h2 v-if="title" class="text-lg font-semibold leading-snug">
25
+ {{ title }}
26
+ </h2>
27
+ </slot>
28
+ </div>
29
+ <p
30
+ v-if="description"
31
+ :id="descriptionId"
32
+ class="mt-1 text-sm/relaxed text-muted-foreground"
33
+ >
34
+ {{ description }}
35
+ </p>
36
+ </header>
37
+
38
+ <button
39
+ v-if="!hideClose"
40
+ type="button"
41
+ class="absolute right-4 top-4 inline-flex size-8 cursor-pointer items-center justify-center rounded-control text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-primary/35"
42
+ aria-label="Close"
43
+ @click="close"
44
+ >
45
+ <XIcon class="size-4.5" />
46
+ </button>
47
+
48
+ <div
49
+ v-if="$slots.default"
50
+ class="-mx-6 -my-1 min-h-0 grow overflow-y-auto overscroll-contain px-6 py-1 text-sm/relaxed outline-none focus-visible:ring-3 focus-visible:ring-primary/35 focus-visible:ring-inset"
51
+ >
52
+ <slot :close="close" />
53
+ </div>
54
+
55
+ <footer
56
+ v-if="$slots.footer"
57
+ class="flex flex-wrap justify-end gap-2 pt-2"
58
+ >
59
+ <slot name="footer" :close="close" />
60
+ </footer>
61
+ </div>
62
+ </dialog>
63
+ </template>
64
+
65
+ <script setup lang="ts">
66
+ import {
67
+ computed,
68
+ nextTick,
69
+ onBeforeUnmount,
70
+ onMounted,
71
+ ref,
72
+ useAttrs,
73
+ useId,
74
+ useTemplateRef,
75
+ watch,
76
+ } from "vue";
77
+ import { XIcon } from "@lucide/vue";
78
+ import { cn } from "../lib/cn";
79
+
80
+ /*
81
+ * Built on the native <dialog> element, so the browser provides the focus trap,
82
+ * Esc handling, the top layer (no z-index fights) and returns focus to the
83
+ * element that opened it.
84
+ *
85
+ * Its layout per screen size and its open / close animations are in main.css
86
+ * (the UiModal section), in the components layer so an app's class wins.
87
+ */
88
+
89
+ export interface ModalProps {
90
+ title?: string;
91
+ description?: string;
92
+ /** sm, md and lg are a bottom sheet on phones; full fills the screen everywhere. */
93
+ size?: "sm" | "md" | "lg" | "full";
94
+ overlay?: "blur" | "transparent" | "dim";
95
+ closeOnBackdrop?: boolean;
96
+ closeOnEsc?: boolean;
97
+ hideClose?: boolean;
98
+ }
99
+
100
+ const props = withDefaults(defineProps<ModalProps>(), {
101
+ size: "md",
102
+ overlay: "blur",
103
+ closeOnBackdrop: true,
104
+ closeOnEsc: true,
105
+ hideClose: false,
106
+ });
107
+
108
+ const open = defineModel<boolean>("open", { default: false });
109
+
110
+ const emit = defineEmits<{
111
+ close: [];
112
+ }>();
113
+
114
+ defineSlots<{
115
+ default?: (props: { close: () => void }) => unknown;
116
+ header?: (props: { close: () => void }) => unknown;
117
+ footer?: (props: { close: () => void }) => unknown;
118
+ }>();
119
+
120
+ // A `class` passed by the app is merged onto the <dialog> (e.g. class="max-w-xl").
121
+ defineOptions({ inheritAttrs: false });
122
+ const attrs = useAttrs();
123
+ const passthroughAttrs = computed(() => {
124
+ const { class: _class, ...rest } = attrs;
125
+ return rest;
126
+ });
127
+
128
+ const overlays = {
129
+ blur: "backdrop:bg-overlay/50 backdrop:backdrop-blur-sm",
130
+ dim: "backdrop:bg-overlay",
131
+ transparent: "backdrop:bg-transparent",
132
+ } as const;
133
+
134
+ const dialogClasses = computed(() =>
135
+ cn(
136
+ "overflow-auto border-0 bg-surface p-0 text-foreground shadow-2xl open:flex open:flex-col",
137
+ overlays[props.overlay],
138
+ attrs.class as string,
139
+ ),
140
+ );
141
+
142
+ const dialogEl = useTemplateRef<HTMLDialogElement>("dialog");
143
+ const baseId = useId();
144
+ const titleId = `ui-modal-${baseId}-title`;
145
+ const descriptionId = `ui-modal-${baseId}-description`;
146
+
147
+ function close() {
148
+ open.value = false;
149
+ }
150
+
151
+ const closing = ref(false);
152
+ let run = 0;
153
+
154
+ const sync = async (isOpen: boolean) => {
155
+ const dialog = dialogEl.value;
156
+ if (!dialog) return;
157
+ const thisRun = ++run;
158
+
159
+ if (isOpen) {
160
+ closing.value = false;
161
+ if (!dialog.open) dialog.showModal();
162
+ return;
163
+ }
164
+
165
+ if (!dialog.open) return;
166
+ closing.value = true;
167
+ await nextTick();
168
+ await Promise.allSettled(dialog.getAnimations().map((a) => a.finished));
169
+ if (thisRun !== run) return;
170
+ closing.value = false;
171
+ dialog.close();
172
+ };
173
+
174
+ // <dialog> only exists in the browser, so sync after mount (safe for SSR).
175
+ onMounted(() => sync(open.value));
176
+
177
+ watch(open, sync, { flush: "post" });
178
+
179
+ onBeforeUnmount(() => {
180
+ if (dialogEl.value?.open) dialogEl.value.close();
181
+ });
182
+
183
+ const onCancel = (event: Event) => {
184
+ event.preventDefault();
185
+ if (props.closeOnEsc) close();
186
+ };
187
+
188
+ const onClose = () => {
189
+ closing.value = false;
190
+ if (open.value) open.value = false;
191
+ emit("close");
192
+ };
193
+
194
+ let pressStartedOnBackdrop = false;
195
+
196
+ const onPointerDown = (event: PointerEvent) => {
197
+ pressStartedOnBackdrop = event.target === dialogEl.value;
198
+ };
199
+
200
+ const onClick = (event: MouseEvent) => {
201
+ if (
202
+ props.closeOnBackdrop &&
203
+ pressStartedOnBackdrop &&
204
+ event.target === dialogEl.value
205
+ ) {
206
+ close();
207
+ }
208
+ pressStartedOnBackdrop = false;
209
+ };
210
+ </script>
@@ -0,0 +1,251 @@
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
13
+ :class="
14
+ cn(
15
+ 'group relative grid w-fit items-center',
16
+ sizes[size].group,
17
+ disabled && 'cursor-not-allowed opacity-60',
18
+ )
19
+ "
20
+ :style="{ gridTemplateColumns: columns }"
21
+ >
22
+ <template v-for="box in boxes" :key="box.index">
23
+ <span
24
+ v-if="box.separated"
25
+ class="mx-1 h-0.5 w-3 shrink-0 rounded-full bg-muted-foreground"
26
+ aria-hidden="true"
27
+ />
28
+ <div :class="boxClasses(box.active)" aria-hidden="true">
29
+ <template v-if="box.char">{{ mask ? "*" : box.char }}</template>
30
+ <span
31
+ v-else-if="box.active"
32
+ class="h-[1em] w-px animate-caret-blink bg-foreground motion-reduce:animate-none"
33
+ />
34
+ </div>
35
+ </template>
36
+
37
+ <!-- iOS zooms into inputs under 16px, hence text-base on invisible text. -->
38
+ <input
39
+ :id="inputId"
40
+ ref="input"
41
+ autocomplete="one-time-code"
42
+ autocapitalize="off"
43
+ autocorrect="off"
44
+ :spellcheck="false"
45
+ v-bind="inputAttrs"
46
+ type="text"
47
+ class="absolute inset-0 size-full cursor-text bg-transparent text-base text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"
48
+ :value="model"
49
+ :inputmode="numeric ? 'numeric' : 'text'"
50
+ :pattern="pattern"
51
+ :maxlength="length"
52
+ :name="name"
53
+ :disabled="disabled"
54
+ :required="required"
55
+ :aria-invalid="error ? true : undefined"
56
+ :aria-describedby="message ? messageId : undefined"
57
+ @input="onInput"
58
+ @paste="onPaste"
59
+ @focus="onFocus"
60
+ @blur="onBlur"
61
+ @click="syncCaret"
62
+ @keyup="syncCaret"
63
+ @select="syncCaret"
64
+ />
65
+ </div>
66
+
67
+ <p
68
+ v-if="message"
69
+ :id="messageId"
70
+ :class="cn('text-sm', error ? 'text-danger' : 'text-muted-foreground')"
71
+ :role="error ? 'alert' : undefined"
72
+ >
73
+ {{ message }}
74
+ </p>
75
+ </div>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import { computed, ref, useAttrs, useId, useTemplateRef, watch } from "vue";
80
+ import { cn } from "../lib/cn";
81
+
82
+ export interface OtpInputProps {
83
+ length?: 4 | 6 | 8;
84
+ mask?: boolean;
85
+ /** Adds a dash after every `separator` boxes. */
86
+ separator?: number;
87
+ numeric?: boolean;
88
+ label?: string;
89
+ hint?: string;
90
+ error?: string;
91
+ size?: "sm" | "md" | "lg";
92
+ disabled?: boolean;
93
+ required?: boolean;
94
+ id?: string;
95
+ name?: string;
96
+ }
97
+
98
+ const props = withDefaults(defineProps<OtpInputProps>(), {
99
+ length: 6,
100
+ mask: false,
101
+ numeric: true,
102
+ size: "md",
103
+ });
104
+
105
+ const model = defineModel<string>({ default: "" });
106
+
107
+ const emit = defineEmits<{
108
+ complete: [code: string];
109
+ }>();
110
+
111
+ // `class` styles the outer wrapper; every other attribute and listener
112
+ // (aria-label, autofocus, @focus, data-*) goes to the <input> itself.
113
+ defineOptions({ inheritAttrs: false });
114
+ const attrs = useAttrs();
115
+ const inputAttrs = computed(() => {
116
+ const { class: _class, ...rest } = attrs;
117
+ return rest;
118
+ });
119
+
120
+ const autoId = useId();
121
+ const inputId = computed(() => props.id ?? `ui-otp-input-${autoId}`);
122
+ const messageId = computed(() => `${inputId.value}-message`);
123
+ const message = computed(() => props.error || props.hint);
124
+
125
+ // Makes a partly filled code fail form validation, not just an empty one.
126
+ const pattern = computed(
127
+ () => `${props.numeric ? "\\d" : "."}{${props.length}}`,
128
+ );
129
+
130
+ const focused = ref(false);
131
+ const allSelected = ref(false);
132
+
133
+ const boxes = computed(() => {
134
+ const code = model.value;
135
+ const current = Math.min(code.length, props.length - 1);
136
+ return Array.from({ length: props.length }, (_, index) => ({
137
+ index,
138
+ char: code[index] ?? "",
139
+ active:
140
+ focused.value && (allSelected.value ? index < code.length : index === current),
141
+ separated:
142
+ !!props.separator && index > 0 && index % props.separator === 0,
143
+ }));
144
+ });
145
+
146
+ const sizes = {
147
+ sm: { group: "gap-1.5", box: "text-sm", width: "2.25rem" },
148
+ md: { group: "gap-2", box: "text-base", width: "2.5rem" },
149
+ lg: { group: "gap-2.5", box: "text-lg", width: "3rem" },
150
+ } as const;
151
+
152
+ // Grid columns rather than fixed widths, so the boxes shrink to fit a narrow
153
+ // screen (8 boxes on a phone) but never grow past their size.
154
+ const columns = computed(() =>
155
+ boxes.value
156
+ .map(
157
+ (box) =>
158
+ `${box.separated ? "auto " : ""}minmax(0, ${sizes[props.size].width})`,
159
+ )
160
+ .join(" "),
161
+ );
162
+
163
+ const boxClasses = (active: boolean) =>
164
+ cn(
165
+ "flex aspect-square min-h-0 w-full items-center justify-center rounded-control border border-border bg-surface leading-none font-medium " +
166
+ "transition-[border-color,box-shadow]",
167
+ sizes[props.size].box,
168
+ props.error && "border-danger",
169
+ active &&
170
+ (props.error
171
+ ? "ring-3 ring-danger/30"
172
+ : "border-primary ring-3 ring-primary/35"),
173
+ !active &&
174
+ !props.error &&
175
+ !props.disabled &&
176
+ "group-hover:border-border-strong",
177
+ props.disabled && "bg-muted",
178
+ );
179
+
180
+ const inputEl = useTemplateRef<HTMLInputElement>("input");
181
+
182
+ const clean = (text: string) => {
183
+ const chars = text.replace(/\s/g, "");
184
+ return (props.numeric ? chars.replace(/\D/g, "") : chars).slice(
185
+ 0,
186
+ props.length,
187
+ );
188
+ };
189
+
190
+ // The caret stays after the last character, so typing fills the next box and
191
+ // Backspace clears the last one. Select-all is kept so the code can be replaced.
192
+ const syncCaret = () => {
193
+ const el = inputEl.value;
194
+ if (!el) return;
195
+ const end = el.value.length;
196
+ allSelected.value =
197
+ end > 0 && el.selectionStart === 0 && el.selectionEnd === end;
198
+ if (
199
+ !allSelected.value &&
200
+ (el.selectionStart !== end || el.selectionEnd !== end)
201
+ ) {
202
+ el.setSelectionRange(end, end);
203
+ }
204
+ };
205
+
206
+ const setCode = (el: HTMLInputElement, code: string) => {
207
+ // Written back directly: if a filtered character left the model unchanged,
208
+ // Vue wouldn't re-render and the character would stay in the field.
209
+ el.value = code;
210
+ model.value = code;
211
+ syncCaret();
212
+ if (code.length === props.length) emit("complete", code);
213
+ };
214
+
215
+ const onInput = (event: Event) => {
216
+ const el = event.target as HTMLInputElement;
217
+ setCode(el, clean(el.value));
218
+ };
219
+
220
+ // A paste replaces the whole code, minus any spaces or dashes ("123-456").
221
+ const onPaste = (event: ClipboardEvent) => {
222
+ event.preventDefault();
223
+ const code = clean(
224
+ (event.clipboardData?.getData("text") ?? "").replace(/-/g, ""),
225
+ );
226
+ if (code) setCode(event.target as HTMLInputElement, code);
227
+ };
228
+
229
+ const onFocus = () => {
230
+ focused.value = true;
231
+ syncCaret();
232
+ };
233
+
234
+ const onBlur = () => {
235
+ focused.value = false;
236
+ allSelected.value = false;
237
+ };
238
+
239
+ watch(
240
+ () => [props.length, props.numeric],
241
+ () => {
242
+ const code = clean(model.value);
243
+ if (code !== model.value) model.value = code;
244
+ },
245
+ );
246
+
247
+ defineExpose({
248
+ focus: () => inputEl.value?.focus(),
249
+ el: inputEl,
250
+ });
251
+ </script>
@@ -0,0 +1,122 @@
1
+ <template>
2
+ <div
3
+ :class="cn('flex flex-col gap-1.5 text-foreground', attrs.class as string)"
4
+ >
5
+ <div
6
+ v-if="label || valueShown"
7
+ class="flex items-baseline justify-between gap-4 text-sm"
8
+ >
9
+ <span v-if="label" :id="labelId" class="font-semibold">{{ label }}</span>
10
+ <!-- Hidden from screen readers, which announce aria-valuenow instead. -->
11
+ <span
12
+ v-if="valueShown"
13
+ class="ml-auto text-muted-foreground tabular-nums"
14
+ aria-hidden="true"
15
+ >
16
+ {{ Math.round(percent) }}%
17
+ </span>
18
+ </div>
19
+
20
+ <div
21
+ v-bind="barAttrs"
22
+ role="progressbar"
23
+ aria-valuemin="0"
24
+ :aria-valuemax="max"
25
+ :aria-valuenow="indeterminate ? undefined : clamped"
26
+ :aria-labelledby="label ? labelId : undefined"
27
+ :class="
28
+ cn(
29
+ 'relative w-full overflow-hidden bg-foreground/10',
30
+ sizes[size],
31
+ rounded ? 'rounded-full' : 'rounded-none',
32
+ )
33
+ "
34
+ >
35
+ <div :class="fillClasses" :style="fillStyle" />
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { computed, useAttrs, useId } from "vue";
42
+ import { cn } from "../lib/cn";
43
+
44
+ export interface ProgressBarProps {
45
+ value?: number;
46
+ max?: number;
47
+ indeterminate?: boolean;
48
+ showValue?: boolean;
49
+ striped?: boolean;
50
+ /** A theme colour, or any CSS colour such as "#f472b6". */
51
+ color?: "primary" | "danger" | (string & {});
52
+ rounded?: boolean;
53
+ size?: "sm" | "md" | "lg";
54
+ label?: string;
55
+ }
56
+
57
+ const props = withDefaults(defineProps<ProgressBarProps>(), {
58
+ value: 0,
59
+ max: 100,
60
+ indeterminate: false,
61
+ showValue: false,
62
+ striped: false,
63
+ color: "primary",
64
+ rounded: true,
65
+ size: "md",
66
+ });
67
+
68
+ // `class` styles the outer wrapper; every other attribute
69
+ // (aria-label, data-*) goes to the bar itself.
70
+ defineOptions({ inheritAttrs: false });
71
+ const attrs = useAttrs();
72
+ const barAttrs = computed(() => {
73
+ const { class: _class, ...rest } = attrs;
74
+ return rest;
75
+ });
76
+
77
+ const labelId = `ui-progress-${useId()}`;
78
+
79
+ const max = computed(() => (props.max > 0 ? props.max : 100));
80
+ const clamped = computed(() =>
81
+ Math.min(max.value, Math.max(0, props.value)),
82
+ );
83
+ const percent = computed(() => (clamped.value / max.value) * 100);
84
+ const valueShown = computed(() => props.showValue && !props.indeterminate);
85
+
86
+ const sizes = {
87
+ sm: "h-1.5",
88
+ md: "h-2.5",
89
+ lg: "h-4",
90
+ } as const;
91
+
92
+ const colors = {
93
+ primary: "bg-primary",
94
+ danger: "bg-danger",
95
+ } as const;
96
+
97
+ const themeColor = computed(() =>
98
+ props.color in colors ? colors[props.color as keyof typeof colors] : null,
99
+ );
100
+
101
+ const stripes =
102
+ "bg-[linear-gradient(45deg,rgb(255_255_255/0.2)_25%,transparent_25%,transparent_50%,rgb(255_255_255/0.2)_50%,rgb(255_255_255/0.2)_75%,transparent_75%,transparent)] " +
103
+ "bg-[length:1rem_1rem]";
104
+
105
+ // With reduced motion, an indeterminate bar pulses in place instead of sliding.
106
+ const fillClasses = computed(() =>
107
+ cn(
108
+ "h-full",
109
+ props.rounded ? "rounded-full" : "rounded-none",
110
+ props.indeterminate
111
+ ? "absolute inset-y-0 left-0 w-2/5 animate-progress-indeterminate motion-reduce:w-full motion-reduce:animate-pulse"
112
+ : "transition-[width] duration-300 ease-out motion-reduce:transition-none",
113
+ themeColor.value,
114
+ props.striped && stripes,
115
+ ),
116
+ );
117
+
118
+ const fillStyle = computed(() => ({
119
+ width: props.indeterminate ? undefined : `${percent.value}%`,
120
+ backgroundColor: themeColor.value ? undefined : props.color,
121
+ }));
122
+ </script>