@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,292 @@
1
+ <template>
2
+ <!-- display: contents, so wrapping the trigger never changes its layout. -->
3
+ <span
4
+ ref="wrapper"
5
+ class="contents"
6
+ @pointerover="onPointerover"
7
+ @pointerout="onPointerout"
8
+ @pointerdown="onPointerdown"
9
+ @focusin="onFocusin"
10
+ @focusout="onFocusout"
11
+ >
12
+ <slot />
13
+ <span
14
+ v-if="content || $slots.content"
15
+ :id="tooltipId"
16
+ ref="tooltip"
17
+ v-bind="tooltipAttrs"
18
+ popover="manual"
19
+ role="tooltip"
20
+ :class="tooltipClasses"
21
+ :style="{ maxWidth: toCss(maxWidth) }"
22
+ >
23
+ <slot name="content">{{ content }}</slot>
24
+ <span
25
+ v-if="arrow"
26
+ class="absolute size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-inherit"
27
+ :style="arrowStyle"
28
+ aria-hidden="true"
29
+ />
30
+ </span>
31
+ </span>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ import {
36
+ computed,
37
+ onBeforeUnmount,
38
+ onMounted,
39
+ ref,
40
+ useAttrs,
41
+ useId,
42
+ useTemplateRef,
43
+ watch,
44
+ } from "vue";
45
+ import { cn } from "../lib/cn";
46
+
47
+ type Side = "top" | "right" | "bottom" | "left";
48
+
49
+ export interface TooltipProps {
50
+ content?: string;
51
+ placement?: Side;
52
+ /** Milliseconds the pointer must rest on the trigger before it opens. */
53
+ delay?: 0 | 200 | 500;
54
+ arrow?: boolean;
55
+ maxWidth?: number | string;
56
+ }
57
+
58
+ const props = withDefaults(defineProps<TooltipProps>(), {
59
+ placement: "top",
60
+ delay: 0,
61
+ arrow: true,
62
+ maxWidth: 240,
63
+ });
64
+
65
+ defineSlots<{
66
+ /** The element the tooltip describes, e.g. a <UiButton>. */
67
+ default?: () => unknown;
68
+ /** Rich tooltip content, instead of the `content` text. */
69
+ content?: () => unknown;
70
+ }>();
71
+
72
+ // `class` and other attributes style the tooltip bubble itself.
73
+ defineOptions({ inheritAttrs: false });
74
+ const attrs = useAttrs();
75
+ const tooltipAttrs = computed(() => {
76
+ const { class: _class, ...rest } = attrs;
77
+ return rest;
78
+ });
79
+
80
+ const tooltipId = `ui-tooltip-${useId()}`;
81
+ const wrapperEl = useTemplateRef<HTMLSpanElement>("wrapper");
82
+ const tooltipEl = useTemplateRef<HTMLSpanElement>("tooltip");
83
+
84
+ const toCss = (value: number | string) =>
85
+ typeof value === "number" ? `${value}px` : value;
86
+
87
+ const triggerEl = () => {
88
+ const first = wrapperEl.value?.firstElementChild;
89
+ return first && first !== tooltipEl.value ? (first as HTMLElement) : null;
90
+ };
91
+
92
+ // Screen readers read the tooltip when the trigger gets focus. The trigger
93
+ // might wrap the focusable element, as <UiInput> does.
94
+ const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";
95
+ const describe = () => {
96
+ const trigger = triggerEl();
97
+ const target = trigger?.matches(FOCUSABLE)
98
+ ? trigger
99
+ : (trigger?.querySelector(FOCUSABLE) ?? trigger);
100
+ if (!target) return;
101
+ const ids = (target.getAttribute("aria-describedby") ?? "")
102
+ .split(" ")
103
+ .filter(Boolean);
104
+ if (!ids.includes(tooltipId)) {
105
+ target.setAttribute("aria-describedby", [...ids, tooltipId].join(" "));
106
+ }
107
+ };
108
+ onMounted(describe);
109
+
110
+ const open = ref(false);
111
+ let timer: ReturnType<typeof setTimeout> | undefined;
112
+ let pending: boolean | undefined;
113
+ let keyboardFocus = false;
114
+
115
+ // Moving across the trigger's children fires pointerover again, so an
116
+ // open or close that's already on its way isn't restarted.
117
+ const schedule = (value: boolean, delay: number) => {
118
+ if (pending === value) return;
119
+ clearTimeout(timer);
120
+ pending = undefined;
121
+ if (open.value === value) return;
122
+ if (!delay) {
123
+ open.value = value;
124
+ return;
125
+ }
126
+ pending = value;
127
+ timer = setTimeout(() => {
128
+ pending = undefined;
129
+ open.value = value;
130
+ }, delay);
131
+ };
132
+
133
+ // The tooltip is inside the wrapper, so hovering it keeps it open.
134
+ const isInside = (node: EventTarget | null) =>
135
+ node instanceof Node && !!wrapperEl.value?.contains(node);
136
+
137
+ const onPointerover = (event: PointerEvent) => {
138
+ if (event.pointerType !== "touch") schedule(true, props.delay);
139
+ };
140
+
141
+ // A short grace period lets the pointer cross the gap onto the tooltip.
142
+ const onPointerout = (event: PointerEvent) => {
143
+ if (!isInside(event.relatedTarget) && !keyboardFocus) schedule(false, 100);
144
+ };
145
+
146
+ const onPointerdown = (event: PointerEvent) => {
147
+ if (tooltipEl.value?.contains(event.target as Node)) return;
148
+ keyboardFocus = false;
149
+ schedule(false, 0);
150
+ };
151
+
152
+ // Only keyboard focus opens it, so a click or tap doesn't leave it showing.
153
+ const onFocusin = (event: FocusEvent) => {
154
+ if (!(event.target as Element).matches(":focus-visible")) return;
155
+ keyboardFocus = true;
156
+ schedule(true, 0);
157
+ };
158
+
159
+ const onFocusout = (event: FocusEvent) => {
160
+ if (isInside(event.relatedTarget)) return;
161
+ keyboardFocus = false;
162
+ schedule(false, 0);
163
+ };
164
+
165
+ const onKeydown = (event: KeyboardEvent) => {
166
+ if (event.key !== "Escape") return;
167
+ // Stops a <UiModal> around the trigger closing too.
168
+ event.preventDefault();
169
+ schedule(false, 0);
170
+ };
171
+
172
+ const EDGE = 8;
173
+ const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" } as const;
174
+ const side = ref<Side>(props.placement);
175
+ const arrowOffset = ref(0);
176
+
177
+ const clamp = (value: number, min: number, max: number) =>
178
+ Math.max(min, Math.min(value, max));
179
+
180
+ // Sits on the `placement` side, or the opposite one when there's more room
181
+ // there, and slides along the trigger to stay on screen. The arrow keeps
182
+ // pointing at the trigger's centre.
183
+ function position() {
184
+ const trigger = triggerEl();
185
+ const tooltip = tooltipEl.value;
186
+ if (!trigger || !tooltip) return;
187
+ // Measured at the top-left, where nothing squeezes its width.
188
+ tooltip.style.left = tooltip.style.top = "0px";
189
+ const rect = trigger.getBoundingClientRect();
190
+ const { offsetWidth: width, offsetHeight: height } = tooltip;
191
+ const { clientWidth: vw, clientHeight: vh } = document.documentElement;
192
+ const gap = props.arrow ? 10 : 6;
193
+ const room = {
194
+ top: rect.top,
195
+ bottom: vh - rect.bottom,
196
+ left: rect.left,
197
+ right: vw - rect.right,
198
+ };
199
+
200
+ let placed: Side = props.placement;
201
+ const vertical = placed === "top" || placed === "bottom";
202
+ if (
203
+ room[placed] < (vertical ? height : width) + gap + EDGE &&
204
+ room[opposite[placed]] > room[placed]
205
+ ) {
206
+ placed = opposite[placed];
207
+ }
208
+
209
+ const centerX = rect.left + rect.width / 2;
210
+ const centerY = rect.top + rect.height / 2;
211
+ const x = clamp(
212
+ vertical
213
+ ? centerX - width / 2
214
+ : placed === "left"
215
+ ? rect.left - gap - width
216
+ : rect.right + gap,
217
+ EDGE,
218
+ vw - width - EDGE,
219
+ );
220
+ const y = clamp(
221
+ vertical
222
+ ? placed === "top"
223
+ ? rect.top - gap - height
224
+ : rect.bottom + gap
225
+ : centerY - height / 2,
226
+ EDGE,
227
+ vh - height - EDGE,
228
+ );
229
+
230
+ tooltip.style.left = `${x}px`;
231
+ tooltip.style.top = `${y}px`;
232
+ side.value = placed;
233
+ // Kept clear of the rounded corners.
234
+ arrowOffset.value = vertical
235
+ ? clamp(centerX - x, 12, width - 12)
236
+ : clamp(centerY - y, 12, height - 12);
237
+ }
238
+
239
+ const arrowStyle = computed(() =>
240
+ side.value === "top" || side.value === "bottom"
241
+ ? {
242
+ left: `${arrowOffset.value}px`,
243
+ top: side.value === "top" ? "100%" : "0",
244
+ }
245
+ : {
246
+ top: `${arrowOffset.value}px`,
247
+ left: side.value === "left" ? "100%" : "0",
248
+ },
249
+ );
250
+
251
+ function stopTracking() {
252
+ window.removeEventListener("scroll", position, true);
253
+ window.removeEventListener("resize", position);
254
+ document.removeEventListener("keydown", onKeydown);
255
+ }
256
+
257
+ watch(
258
+ open,
259
+ (isOpen) => {
260
+ const tooltip = tooltipEl.value;
261
+ if (!tooltip) return;
262
+ if (isOpen) {
263
+ describe();
264
+ if (!tooltip.matches(":popover-open")) tooltip.showPopover();
265
+ position();
266
+ window.addEventListener("scroll", position, {
267
+ capture: true,
268
+ passive: true,
269
+ });
270
+ window.addEventListener("resize", position);
271
+ document.addEventListener("keydown", onKeydown);
272
+ } else {
273
+ if (tooltip.matches(":popover-open")) tooltip.hidePopover();
274
+ stopTracking();
275
+ }
276
+ },
277
+ { flush: "post" },
278
+ );
279
+
280
+ onBeforeUnmount(() => {
281
+ stopTracking();
282
+ clearTimeout(timer);
283
+ });
284
+
285
+ const tooltipClasses = computed(() =>
286
+ cn(
287
+ "inset-auto m-0 animate-fade-in overflow-visible rounded-md border-0 bg-foreground px-2.5 py-1.5 " +
288
+ "text-left text-xs leading-snug font-medium break-words text-background shadow-md motion-reduce:animate-none",
289
+ attrs.class as string,
290
+ ),
291
+ );
292
+ </script>
package/app/lib/cn.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { extendTailwindMerge } from 'tailwind-merge'
2
+
3
+ /*
4
+ * Joins class names and resolves Tailwind conflicts, so a class passed by the
5
+ * app wins over the component's default: cn('px-4', 'px-8') -> 'px-8'.
6
+ */
7
+ const twMerge = extendTailwindMerge({
8
+ extend: {
9
+ // Teach tailwind-merge about our custom radius tokens (rounded-control / rounded-panel).
10
+ theme: { radius: ['control', 'panel'] },
11
+ },
12
+ })
13
+
14
+ type ClassValue = string | false | null | undefined | ClassValue[] | Record<string, unknown>
15
+
16
+ function toClassString(value: ClassValue): string {
17
+ if (!value) return ''
18
+ if (typeof value === 'string') return value
19
+ if (Array.isArray(value)) return value.map(toClassString).filter(Boolean).join(' ')
20
+ return Object.keys(value).filter(key => value[key]).join(' ')
21
+ }
22
+
23
+ export function cn(...inputs: ClassValue[]): string {
24
+ return twMerge(toClassString(inputs))
25
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { dirname, join } from 'node:path'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ // Resolve paths relative to THIS layer, not to the app that extends it.
6
+ const currentDir = dirname(fileURLToPath(import.meta.url))
7
+
8
+ export default defineNuxtConfig({
9
+ // Every component in app/components is auto-imported with the "Ui" prefix:
10
+ // Button.vue -> <UiButton>, Input.vue -> <UiInput>, Modal.vue -> <UiModal>.
11
+ // To rename the prefix for your company, change it here.
12
+ components: [
13
+ { path: join(currentDir, 'app/components'), prefix: 'Ui' },
14
+ ],
15
+
16
+ // Tailwind + design tokens, loaded in every app that extends this layer.
17
+ css: [join(currentDir, 'app/assets/css/main.css')],
18
+
19
+ vite: {
20
+ plugins: [tailwindcss()],
21
+ },
22
+ })
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@tunji_xy/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.10.0",
4
+ "description": "Shared Nuxt 4 component library (layer) styled with Tailwind CSS: Button, Input, Textarea, Select, Checkbox, Radio, Switch, OtpInput, Tooltip, ProgressBar, Tabs, DropdownMenu, Modal",
5
+ "license": "MIT",
6
+ "author": "Olatunji Ekisanya",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/tunjixy/components-ui.git"
10
+ },
11
+ "homepage": "https://github.com/tunjixy/components-ui#readme",
12
+ "bugs": "https://github.com/tunjixy/components-ui/issues",
13
+ "keywords": [
14
+ "nuxt",
15
+ "nuxt-layer",
16
+ "vue",
17
+ "tailwindcss",
18
+ "components",
19
+ "ui"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "main": "./nuxt.config.ts",
26
+ "files": [
27
+ "app",
28
+ "nuxt.config.ts"
29
+ ],
30
+ "scripts": {
31
+ "dev": "nuxi dev .playground",
32
+ "build": "nuxi build .playground",
33
+ "generate": "nuxi generate .playground",
34
+ "preview": "nuxi preview .playground",
35
+ "typecheck": "nuxi typecheck .playground",
36
+ "prepare": "nuxi prepare .playground"
37
+ },
38
+ "dependencies": {
39
+ "@lucide/vue": "^1.70.0",
40
+ "@tailwindcss/vite": "^4.3.0",
41
+ "tailwind-merge": "^3.7.0",
42
+ "tailwindcss": "^4.3.0"
43
+ },
44
+ "peerDependencies": {
45
+ "nuxt": "^4.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^26.6.6",
49
+ "nuxt": "^4.2.0",
50
+ "shiki": "^4.5.0",
51
+ "typescript": "^5.9.0",
52
+ "vue": "^3.5.0",
53
+ "vue-tsc": "^3.1.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=20.19.0",
57
+ "npm": ">=11.0.0"
58
+ }
59
+ }