@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,141 @@
1
+ <template>
2
+ <div
3
+ :class="cn('flex flex-col gap-1 text-foreground', attrs.class as string)"
4
+ >
5
+ <div
6
+ :class="
7
+ cn(
8
+ 'flex items-start',
9
+ sizes[size].row,
10
+ disabled && 'cursor-not-allowed opacity-60',
11
+ )
12
+ "
13
+ >
14
+ <span class="relative flex h-lh shrink-0 items-center">
15
+ <input
16
+ :id="inputId"
17
+ ref="input"
18
+ v-model="model"
19
+ v-bind="inputAttrs"
20
+ type="checkbox"
21
+ role="switch"
22
+ :class="trackClasses"
23
+ :name="name"
24
+ :disabled="disabled"
25
+ :required="required"
26
+ :aria-describedby="message ? messageId : undefined"
27
+ />
28
+ <span
29
+ :class="
30
+ cn(
31
+ 'pointer-events-none absolute inset-y-0 left-0.5 my-auto rounded-full bg-white shadow-sm ' +
32
+ 'transition-transform motion-reduce:transition-none ' +
33
+ 'forced-color-adjust-none forced-colors:bg-[CanvasText]',
34
+ sizes[size].thumb,
35
+ )
36
+ "
37
+ aria-hidden="true"
38
+ />
39
+ </span>
40
+
41
+ <label
42
+ v-if="label || $slots.default"
43
+ :for="inputId"
44
+ :class="
45
+ cn('select-none', disabled ? 'cursor-not-allowed' : 'cursor-pointer')
46
+ "
47
+ >
48
+ <slot>{{ label }}</slot>
49
+ </label>
50
+ </div>
51
+
52
+ <p
53
+ v-if="message"
54
+ :id="messageId"
55
+ :class="cn('text-sm', sizes[size].message, 'text-muted-foreground')"
56
+ >
57
+ {{ message }}
58
+ </p>
59
+ </div>
60
+ </template>
61
+
62
+ <script setup lang="ts">
63
+ import { computed, useAttrs, useId, useTemplateRef } from "vue";
64
+ import { cn } from "../lib/cn";
65
+
66
+ export interface SwitchProps {
67
+ label?: string;
68
+ hint?: string;
69
+ size?: "sm" | "md" | "lg";
70
+ disabled?: boolean;
71
+ required?: boolean;
72
+ id?: string;
73
+ name?: string;
74
+ }
75
+
76
+ const props = withDefaults(defineProps<SwitchProps>(), {
77
+ size: "md",
78
+ });
79
+
80
+ const model = defineModel<boolean>({ default: false });
81
+
82
+ defineSlots<{
83
+ /** Label content, when it needs more than plain text (e.g. a link). */
84
+ default?: () => unknown;
85
+ }>();
86
+
87
+ // `class` styles the outer wrapper; every other attribute and listener
88
+ // (@change, @focus, aria-label, data-*) goes to the <input> itself.
89
+ defineOptions({ inheritAttrs: false });
90
+ const attrs = useAttrs();
91
+ const inputAttrs = computed(() => {
92
+ const { class: _class, ...rest } = attrs;
93
+ return rest;
94
+ });
95
+
96
+ const autoId = useId();
97
+ const inputId = computed(() => props.id ?? `ui-switch-${autoId}`);
98
+ const messageId = computed(() => `${inputId.value}-message`);
99
+ const message = computed(() => props.hint);
100
+
101
+ // The thumb sits 2px inside the track and slides by (track - thumb - 4px) when on.
102
+ // `message` lines the hint up with the label (track width + gap).
103
+ const sizes = {
104
+ sm: {
105
+ row: "gap-2 text-sm",
106
+ track: "h-4 w-7",
107
+ thumb: "size-3 peer-checked:translate-x-3",
108
+ message: "pl-9",
109
+ },
110
+ md: {
111
+ row: "gap-2 text-sm",
112
+ track: "h-5 w-9",
113
+ thumb: "size-4 peer-checked:translate-x-4",
114
+ message: "pl-11",
115
+ },
116
+ lg: {
117
+ row: "gap-3 text-base",
118
+ track: "h-6 w-11",
119
+ thumb: "size-5 peer-checked:translate-x-5",
120
+ message: "pl-14",
121
+ },
122
+ } as const;
123
+
124
+ const trackClasses = computed(() =>
125
+ cn(
126
+ "peer m-0 cursor-pointer appearance-none rounded-full border-2 border-transparent bg-border-strong outline-none " +
127
+ "transition-[background-color,box-shadow] checked:bg-primary " +
128
+ "focus-visible:ring-3 disabled:cursor-not-allowed",
129
+ sizes[props.size].track,
130
+ "focus-visible:ring-primary/35",
131
+ !props.disabled && "checked:hover:bg-primary-hover",
132
+ ),
133
+ );
134
+
135
+ const inputEl = useTemplateRef<HTMLInputElement>("input");
136
+
137
+ defineExpose({
138
+ focus: () => inputEl.value?.focus(),
139
+ el: inputEl,
140
+ });
141
+ </script>
@@ -0,0 +1,288 @@
1
+ <template>
2
+ <div :class="cn('flex flex-col gap-4', attrs.class as string)">
3
+ <div
4
+ ref="list"
5
+ v-bind="listAttrs"
6
+ role="tablist"
7
+ :class="listClasses"
8
+ @keydown="onKeydown"
9
+ >
10
+ <span
11
+ v-if="rect"
12
+ :class="indicatorClasses"
13
+ :style="{
14
+ width: `${rect.width}px`,
15
+ transform: `translateX(${rect.left}px)`,
16
+ }"
17
+ aria-hidden="true"
18
+ />
19
+ <button
20
+ v-for="(item, index) in items"
21
+ :id="tabId(index)"
22
+ :key="item.value"
23
+ type="button"
24
+ role="tab"
25
+ :class="tabClasses(index)"
26
+ :disabled="item.disabled"
27
+ :tabindex="index === selectedIndex ? 0 : -1"
28
+ :aria-selected="index === selectedIndex"
29
+ :aria-controls="
30
+ index === selectedIndex && $slots[String(item.value)]
31
+ ? panelId
32
+ : undefined
33
+ "
34
+ @click="select(index)"
35
+ >
36
+ <span v-if="item.icon" class="inline-flex shrink-0 [&>svg]:size-4">
37
+ <TabIcon :icon="item.icon" />
38
+ </span>
39
+ <span>{{ item.label }}</span>
40
+ <span
41
+ v-if="item.badge != null"
42
+ :class="badgeClasses(index === selectedIndex)"
43
+ >
44
+ {{ item.badge }}
45
+ </span>
46
+ </button>
47
+ </div>
48
+
49
+ <div
50
+ v-if="selectedItem && $slots[String(selectedItem.value)]"
51
+ :id="panelId"
52
+ role="tabpanel"
53
+ tabindex="0"
54
+ :aria-labelledby="tabId(selectedIndex)"
55
+ class="rounded-control outline-none focus-visible:ring-3 focus-visible:ring-primary/35"
56
+ >
57
+ <slot :name="String(selectedItem.value)" :item="selectedItem" />
58
+ </div>
59
+ </div>
60
+ </template>
61
+
62
+ <script setup lang="ts">
63
+ import {
64
+ computed,
65
+ h,
66
+ onBeforeUnmount,
67
+ onMounted,
68
+ ref,
69
+ useAttrs,
70
+ useId,
71
+ useTemplateRef,
72
+ watch,
73
+ type Component,
74
+ type FunctionalComponent,
75
+ } from "vue";
76
+ import { cn } from "../lib/cn";
77
+
78
+ export interface TabItem {
79
+ value: string | number;
80
+ label: string;
81
+ icon?: string | Component;
82
+ badge?: string | number;
83
+ disabled?: boolean;
84
+ }
85
+
86
+ export interface TabsProps {
87
+ items: TabItem[];
88
+ variant?: "line" | "pill" | "segmented";
89
+ fullWidth?: boolean;
90
+ }
91
+
92
+ const props = withDefaults(defineProps<TabsProps>(), {
93
+ variant: "line",
94
+ fullWidth: false,
95
+ });
96
+
97
+ const model = defineModel<string | number>();
98
+
99
+ defineSlots<{
100
+ /** A tab's panel. Name the slot after the tab's `value`, e.g. <template #inbox>. */
101
+ [value: string]: ((props: { item: TabItem }) => unknown) | undefined;
102
+ }>();
103
+
104
+ // `class` styles the outer wrapper; every other attribute
105
+ // (aria-label, data-*) goes to the tablist.
106
+ defineOptions({ inheritAttrs: false });
107
+ const attrs = useAttrs();
108
+ const listAttrs = computed(() => {
109
+ const { class: _class, ...rest } = attrs;
110
+ return rest;
111
+ });
112
+
113
+ const baseId = `ui-tabs-${useId()}`;
114
+ const tabId = (index: number) => `${baseId}-tab-${index}`;
115
+ const panelId = `${baseId}-panel`;
116
+
117
+ const enabled = computed(() =>
118
+ props.items.flatMap((item, index) => (item.disabled ? [] : [index])),
119
+ );
120
+
121
+ // With no v-model, or one that matches no tab, the first enabled tab is selected.
122
+ const selectedIndex = computed(() => {
123
+ const index = props.items.findIndex((item) => item.value === model.value);
124
+ return index !== -1 ? index : (enabled.value[0] ?? -1);
125
+ });
126
+ const selectedItem = computed(() => props.items[selectedIndex.value]);
127
+
128
+ const TabIcon: FunctionalComponent<{ icon: string | Component }> = ({
129
+ icon,
130
+ }) => (typeof icon === "string" ? icon : h(icon));
131
+
132
+ const listEl = useTemplateRef<HTMLDivElement>("list");
133
+ const tabEls = () => [
134
+ ...(listEl.value?.querySelectorAll<HTMLButtonElement>(
135
+ ':scope > [role="tab"]',
136
+ ) ?? []),
137
+ ];
138
+
139
+ function select(index: number) {
140
+ const item = props.items[index];
141
+ if (item && !item.disabled) model.value = item.value;
142
+ }
143
+
144
+ // Arrow keys move focus and select in one step, skipping disabled tabs.
145
+ function onKeydown(event: KeyboardEvent) {
146
+ const tabs = tabEls();
147
+ const order = enabled.value;
148
+ const at = order.indexOf(tabs.indexOf(event.target as HTMLButtonElement));
149
+ const rtl =
150
+ getComputedStyle(event.currentTarget as Element).direction === "rtl";
151
+ let next: number | undefined;
152
+ switch (event.key) {
153
+ case rtl ? "ArrowLeft" : "ArrowRight":
154
+ next = order[(at + 1) % order.length];
155
+ break;
156
+ case rtl ? "ArrowRight" : "ArrowLeft":
157
+ next = order[(at - 1 + order.length) % order.length];
158
+ break;
159
+ case "Home":
160
+ next = order[0];
161
+ break;
162
+ case "End":
163
+ next = order.at(-1);
164
+ break;
165
+ default:
166
+ return;
167
+ }
168
+ event.preventDefault();
169
+ if (next === undefined) return;
170
+ select(next);
171
+ tabs[next]?.focus();
172
+ }
173
+
174
+ // Until the first measurement (and during SSR) there's no indicator, and the
175
+ // selected tab wears its look directly, so hydration doesn't flash or slide.
176
+ const rect = ref<{ left: number; width: number } | null>(null);
177
+ const sliding = ref(false);
178
+
179
+ function measure(slide: boolean) {
180
+ const tab = tabEls()[selectedIndex.value];
181
+ sliding.value = slide;
182
+ rect.value = tab ? { left: tab.offsetLeft, width: tab.offsetWidth } : null;
183
+ }
184
+
185
+ // Fonts loading, badge counts, a resized container or a hidden parent being
186
+ // shown all move the tabs without a selection change. Only selecting slides.
187
+ let observer: ResizeObserver | undefined;
188
+ function observe() {
189
+ observer?.disconnect();
190
+ observer = new ResizeObserver(() => measure(false));
191
+ if (listEl.value) observer.observe(listEl.value);
192
+ for (const tab of tabEls()) observer.observe(tab);
193
+ }
194
+
195
+ onMounted(observe);
196
+ onBeforeUnmount(() => observer?.disconnect());
197
+
198
+ watch(selectedIndex, () => measure(true), { flush: "post" });
199
+ watch(
200
+ () => [props.variant, props.fullWidth],
201
+ () => measure(false),
202
+ { flush: "post" },
203
+ );
204
+ // Tabs are keyed by value, so new values mean new elements to observe.
205
+ watch(() => props.items.map((item) => item.value).join("\n"), observe, {
206
+ flush: "post",
207
+ });
208
+
209
+ // Box variants pad the list by 4px so focus rings aren't clipped by its
210
+ // horizontal scrolling; `pill` has no background, so -m-1 cancels the inset.
211
+ // Their indicators' transparent border is what Windows high-contrast mode
212
+ // draws, since it drops backgrounds.
213
+ const variants = {
214
+ line: {
215
+ list: "shadow-[inset_0_-1px_0_var(--color-border)]",
216
+ tab: "h-10 rounded-t-md px-3 focus-visible:ring-inset",
217
+ idle: "text-muted-foreground enabled:hover:text-foreground",
218
+ active: "text-foreground",
219
+ indicator: "bottom-0 border-b-2 border-primary",
220
+ fallback: "shadow-[inset_0_-2px_0_var(--color-primary)]",
221
+ badge: "bg-primary/10 text-primary",
222
+ },
223
+ pill: {
224
+ list: "-m-1 gap-1 p-1",
225
+ tab: "h-9 rounded px-4",
226
+ idle: "text-muted-foreground enabled:hover:bg-foreground/5 enabled:hover:text-foreground",
227
+ active: "text-primary-foreground",
228
+ indicator: "inset-y-1 rounded border border-transparent bg-primary",
229
+ fallback: "bg-primary",
230
+ badge: "bg-primary-foreground/20 text-primary-foreground",
231
+ },
232
+ segmented: {
233
+ list: "rounded-control bg-foreground/5 p-1",
234
+ // Inner radius = outer radius - padding, so the corners stay concentric.
235
+ tab: "h-8 rounded-[calc(var(--radius-control)_-_0.25rem)] px-3",
236
+ idle: "text-muted-foreground enabled:hover:text-foreground",
237
+ active: "text-foreground",
238
+ indicator:
239
+ "inset-y-1 rounded-[calc(var(--radius-control)_-_0.25rem)] border border-transparent bg-surface shadow-sm dark:bg-foreground/15",
240
+ fallback: "bg-surface shadow-sm dark:bg-foreground/15",
241
+ badge: "bg-primary/10 text-primary",
242
+ },
243
+ } as const;
244
+
245
+ const listClasses = computed(() =>
246
+ cn(
247
+ "relative flex overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
248
+ variants[props.variant].list,
249
+ props.variant === "segmented" && !props.fullWidth && "w-fit max-w-full",
250
+ ),
251
+ );
252
+
253
+ const indicatorClasses = computed(() =>
254
+ cn(
255
+ "pointer-events-none absolute left-0",
256
+ variants[props.variant].indicator,
257
+ sliding.value &&
258
+ "transition-[transform,width] duration-300 ease-out motion-reduce:transition-none",
259
+ ),
260
+ );
261
+
262
+ const tabClasses = (index: number) => {
263
+ const variant = variants[props.variant];
264
+ const selected = index === selectedIndex.value;
265
+ return cn(
266
+ "relative inline-flex cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap select-none outline-none " +
267
+ "transition-colors duration-300 focus-visible:ring-3 focus-visible:ring-primary/35 " +
268
+ "disabled:cursor-not-allowed disabled:opacity-50",
269
+ variant.tab,
270
+ props.fullWidth ? "flex-1" : "shrink-0",
271
+ selected ? variant.active : variant.idle,
272
+ selected && !rect.value && variant.fallback,
273
+ );
274
+ };
275
+
276
+ const badgeClasses = (selected: boolean) =>
277
+ cn(
278
+ "inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs leading-none font-semibold transition-colors duration-300",
279
+ selected
280
+ ? variants[props.variant].badge
281
+ : "bg-foreground/10 text-muted-foreground",
282
+ );
283
+
284
+ defineExpose({
285
+ focus: () => tabEls()[selectedIndex.value]?.focus(),
286
+ el: listEl,
287
+ });
288
+ </script>
@@ -0,0 +1,188 @@
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="textareaId" 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
+ <!-- With autoresize, an invisible copy of the text sets the height of the
13
+ grid cell both share, and the textarea stretches to fill it. -->
14
+ <div class="grid grid-cols-1">
15
+ <textarea
16
+ :id="textareaId"
17
+ ref="textarea"
18
+ v-bind="textareaAttrs"
19
+ :class="textareaClasses"
20
+ :name="name"
21
+ :value="model ?? ''"
22
+ :rows="rows"
23
+ :placeholder="placeholder"
24
+ :disabled="disabled"
25
+ :readonly="readonly"
26
+ :required="required"
27
+ :autocomplete="autocomplete"
28
+ :aria-invalid="error ? true : undefined"
29
+ :aria-describedby="message ? messageId : undefined"
30
+ @input="onInput"
31
+ @blur="emit('blur', $event)"
32
+ @focus="emit('focus', $event)"
33
+ />
34
+ <span
35
+ v-if="autoresize"
36
+ :class="replicaClasses"
37
+ :style="{ maxHeight: maxRows ? `${maxRows}lh` : undefined }"
38
+ >{{ replica }}</span
39
+ >
40
+ </div>
41
+
42
+ <p
43
+ v-if="message"
44
+ :id="messageId"
45
+ :class="cn('text-sm', error ? 'text-danger' : 'text-muted-foreground')"
46
+ :role="error ? 'alert' : undefined"
47
+ >
48
+ {{ message }}
49
+ </p>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed, useAttrs, useId, useTemplateRef } from "vue";
55
+ import { cn } from "../lib/cn";
56
+
57
+ export interface TextareaProps {
58
+ label?: string;
59
+ placeholder?: string;
60
+ hint?: string;
61
+ error?: string;
62
+ variant?: "outline" | "filled" | "ghost";
63
+ size?: "sm" | "md" | "lg";
64
+ radius?: "none" | "sm" | "md" | "lg" | "full";
65
+ rows?: number;
66
+ autoresize?: boolean;
67
+ maxRows?: number;
68
+ disabled?: boolean;
69
+ readonly?: boolean;
70
+ required?: boolean;
71
+ id?: string;
72
+ name?: string;
73
+ autocomplete?: string;
74
+ }
75
+
76
+ const props = withDefaults(defineProps<TextareaProps>(), {
77
+ variant: "outline",
78
+ size: "md",
79
+ rows: 3,
80
+ autoresize: false,
81
+ });
82
+
83
+ const model = defineModel<string | null>({ default: "" });
84
+
85
+ const emit = defineEmits<{
86
+ blur: [event: FocusEvent];
87
+ focus: [event: FocusEvent];
88
+ }>();
89
+
90
+ // `class` styles the outer wrapper (e.g. class="w-96"); every other attribute
91
+ // (maxlength, autofocus, spellcheck, data-*) goes to the <textarea> itself.
92
+ defineOptions({ inheritAttrs: false });
93
+ const attrs = useAttrs();
94
+ const textareaAttrs = computed(() => {
95
+ const { class: _class, ...rest } = attrs;
96
+ return rest;
97
+ });
98
+
99
+ const autoId = useId();
100
+ const textareaId = computed(() => props.id ?? `ui-textarea-${autoId}`);
101
+ const messageId = computed(() => `${textareaId.value}-message`);
102
+ const message = computed(() => props.error || props.hint);
103
+
104
+ // The trailing space gives a final newline its own line, as the textarea does.
105
+ // The placeholder stands in when empty, so a long one isn't cut off.
106
+ const replica = computed(() => `${model.value || props.placeholder || ""} `);
107
+
108
+ const variants = {
109
+ outline: "border border-border bg-surface focus:ring-3",
110
+ filled: "border border-transparent bg-foreground/5 focus:ring-3",
111
+ ghost: "rounded-none border-b border-border px-0 focus:shadow-[0_1px_0_0]",
112
+ } as const;
113
+
114
+ const hovers = {
115
+ outline: "hover:border-border-strong",
116
+ filled: "hover:bg-foreground/10",
117
+ // A clicked field is still under the pointer, so don't grey out its underline.
118
+ ghost: "hover:not-focus:border-border-strong",
119
+ } as const;
120
+
121
+ // The padding makes one row exactly as tall as a <UiInput> of the same size.
122
+ const sizes = {
123
+ sm: "px-2 py-1.25 text-sm",
124
+ md: "px-3 py-2.25 text-base sm:text-sm",
125
+ lg: "px-4 py-2.75 text-base",
126
+ } as const;
127
+
128
+ // A pill shape would clip the corners of multi-line text, so `full` rounds
129
+ // generously instead (still a pill while it's a single row).
130
+ const radii = {
131
+ none: "rounded-none",
132
+ sm: "rounded-sm",
133
+ md: "rounded-md",
134
+ lg: "rounded-lg",
135
+ full: "rounded-3xl",
136
+ } as const;
137
+
138
+ // Shared by the textarea and its invisible copy, so the text wraps identically.
139
+ const controlClasses = computed(() =>
140
+ cn(
141
+ "col-start-1 row-start-1 block w-full min-w-0 rounded-control outline-none transition-[background-color,border-color,box-shadow]",
142
+ sizes[props.size],
143
+ props.radius && radii[props.radius],
144
+ // After the radius, so ghost stays square whatever `radius` says.
145
+ variants[props.variant],
146
+ props.error
147
+ ? "border-danger focus:ring-danger/30 focus:shadow-danger"
148
+ : "focus:border-primary focus:ring-primary/35 focus:shadow-primary",
149
+ !props.error && !props.disabled && hovers[props.variant],
150
+ props.disabled && "cursor-not-allowed opacity-60",
151
+ props.disabled && props.variant !== "ghost" && "bg-muted",
152
+ ),
153
+ );
154
+
155
+ const textareaClasses = computed(() =>
156
+ cn(
157
+ controlClasses.value,
158
+ "placeholder:text-muted-foreground/80",
159
+ !props.autoresize
160
+ ? "resize-y"
161
+ : props.maxRows
162
+ ? "resize-none overflow-y-auto"
163
+ : "resize-none overflow-hidden",
164
+ ),
165
+ );
166
+
167
+ // content-box, so a `maxRows` cap in `lh` counts text lines, not padding.
168
+ // w-auto: with content-box, w-full would add the padding on top and make the
169
+ // copy wider than the textarea, so it would wrap onto fewer lines.
170
+ const replicaClasses = computed(() =>
171
+ cn(
172
+ controlClasses.value,
173
+ "invisible box-content w-auto overflow-hidden break-words whitespace-pre-wrap",
174
+ ),
175
+ );
176
+
177
+ const textareaEl = useTemplateRef<HTMLTextAreaElement>("textarea");
178
+
179
+ const onInput = (event: Event) => {
180
+ model.value = (event.target as HTMLTextAreaElement).value;
181
+ };
182
+
183
+ defineExpose({
184
+ focus: () => textareaEl.value?.focus(),
185
+ select: () => textareaEl.value?.select(),
186
+ el: textareaEl,
187
+ });
188
+ </script>