@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olatunji Ekisanya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # @tunji_xy/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A component library for Nuxt 4, built as a **Nuxt layer** and styled with **Tailwind CSS v4**.
4
+ Extend it and every component is auto-imported with the `Ui` prefix (`<UiButton>`,
5
+ `<UiSelect>`, `<UiModal>`, …), along with Tailwind and the library's design tokens.
6
+
7
+ **Documentation** for theming and every component (live examples, props, keyboard support)
8
+ lives in the docs site in this repo: clone it, run `npm install` and `npm run dev`, and open
9
+ http://localhost:3000.
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ npm install @tunji_xy/ui
15
+ ```
16
+
17
+ Then extend the layer:
18
+
19
+ ```ts
20
+ // nuxt.config.ts
21
+ export default defineNuxtConfig({
22
+ extends: ["@tunji_xy/ui"],
23
+ });
24
+ ```
25
+
26
+ The layer brings Tailwind with it, so don't install or configure Tailwind again in the app.
27
+ For icons, add `@lucide/vue`, which is what the components use themselves.
28
+
29
+ ## Requirements
30
+
31
+ - Node.js 20.19+
32
+ - **npm 11+.** npm 10 (bundled with Node 22) crashes on install with
33
+ `Cannot read properties of null (reading 'edgesOut')`. Upgrade once with
34
+ `npm install -g npm@11`.
35
+
36
+ ## Developing
37
+
38
+ ```bash
39
+ npm install
40
+ npm run dev # docs site at http://localhost:3000, hot-reloads component edits
41
+ npm run typecheck # vue-tsc across the docs site + components
42
+ npm run build # production build of the docs site
43
+ npm run generate # static docs site in .playground/.output/public
44
+ ```
45
+
46
+ ```
47
+ component-ui/
48
+ ├─ app/
49
+ │ ├─ components/ one .vue file per component
50
+ │ ├─ assets/css/ main.css (Tailwind import, design tokens, dark theme)
51
+ │ └─ lib/cn.ts class merging helper (tailwind-merge)
52
+ ├─ .playground/ the docs site, which extends the layer like any app
53
+ └─ nuxt.config.ts registers components ("Ui" prefix) and the Tailwind Vite plugin
54
+ ```
55
+
56
+ ## Adding a component
57
+
58
+ 1. Create `app/components/Thing.vue`. It's available everywhere as `<UiThing>`.
59
+ 2. Import Vue APIs explicitly (`import { ref } from 'vue'`) and `cn` from `../lib/cn`.
60
+ 3. Style it with token utilities (`bg-primary`, `border-border`, …), never hard-coded colours
61
+ like `bg-indigo-600`, so re-branding and dark mode reach it.
62
+ 4. Accept a `class` and merge it with `cn(...)` (see `Button.vue`).
63
+ 5. Document it in the docs site:
64
+ - an entry in `.playground/app/docs/components.ts` (sidebar and component cards)
65
+ - a props playground in `.playground/app/demo/ThingDemo.vue`
66
+ - examples in `.playground/app/examples/thing/`
67
+ - a page at `.playground/app/pages/components/thing.vue` (copy an existing one)
68
+ 6. Bump the version (semver: breaking prop changes mean a major bump) and add an entry to
69
+ `CHANGELOG.md`.
@@ -0,0 +1,193 @@
1
+ @import "tailwindcss";
2
+
3
+ /*
4
+ * Tailwind skips node_modules when scanning for classes. When this layer is
5
+ * installed as a package, this line makes sure the classes used by its own
6
+ * components are still generated. (The app's own files are scanned automatically.)
7
+ */
8
+ @source "../../components";
9
+
10
+ /* Dark mode follows a "dark" class on <html> (works with @nuxtjs/color-mode). */
11
+ @custom-variant dark (&:where(.dark, .dark *));
12
+
13
+ /*
14
+ * Design tokens. Each one becomes a Tailwind utility, e.g. --color-primary gives
15
+ * bg-primary / text-primary / border-primary / ring-primary, and --radius-control
16
+ * gives rounded-control. Apps that extend this layer can use them too.
17
+ *
18
+ * To re-brand in an app, override the variables in the app's CSS:
19
+ * :root { --color-primary: #0f766e; --color-primary-hover: #115e59; }
20
+ */
21
+ @theme {
22
+ /* Brand */
23
+ --color-primary: #4f46e5;
24
+ --color-primary-hover: #4338ca;
25
+ --color-primary-foreground: #ffffff;
26
+ --color-danger: #dc2626;
27
+ --color-danger-hover: #b91c1c;
28
+ --color-danger-foreground: #ffffff;
29
+
30
+ /* Surfaces & text */
31
+ --color-background: #f4f4f5;
32
+ --color-surface: #ffffff;
33
+ --color-muted: #f4f4f5;
34
+ --color-foreground: #18181b;
35
+ --color-muted-foreground: #52525b;
36
+ --color-border: #d4d4d8;
37
+ --color-border-strong: #a1a1aa;
38
+ --color-overlay: rgb(9 9 11 / 0.55);
39
+
40
+ /* Shape */
41
+ --radius-control: 0.5rem; /* buttons, inputs */
42
+ --radius-panel: 0.875rem; /* modals, cards */
43
+
44
+ /* Motion */
45
+ --animate-modal-in: ui-modal-in 300ms cubic-bezier(0.32, 0.72, 0, 1);
46
+ --animate-modal-out: ui-modal-out 200ms ease-in forwards;
47
+ --animate-fade-in: ui-fade-in 180ms ease-out;
48
+ --animate-fade-out: ui-fade-out 200ms ease-in forwards;
49
+ --animate-caret-blink: ui-caret-blink 1s step-end infinite;
50
+ --animate-progress-indeterminate: ui-progress-indeterminate 1.4s ease-in-out
51
+ infinite;
52
+
53
+ /* UiModal sets where these slide from (see the UiModal section below). */
54
+ @keyframes ui-modal-in {
55
+ from {
56
+ opacity: var(--ui-modal-opacity-from, 0);
57
+ translate: var(--ui-modal-slide-from, 0 2rem);
58
+ }
59
+ }
60
+ @keyframes ui-modal-out {
61
+ to {
62
+ opacity: var(--ui-modal-opacity-from, 0);
63
+ translate: var(--ui-modal-slide-from, 0 2rem);
64
+ }
65
+ }
66
+ @keyframes ui-fade-in {
67
+ from {
68
+ opacity: 0;
69
+ }
70
+ }
71
+ @keyframes ui-fade-out {
72
+ to {
73
+ opacity: 0;
74
+ }
75
+ }
76
+ @keyframes ui-caret-blink {
77
+ 50% {
78
+ opacity: 0;
79
+ }
80
+ }
81
+ /* A 40%-wide segment, from just off the left edge to just off the right. */
82
+ @keyframes ui-progress-indeterminate {
83
+ from {
84
+ translate: -100%;
85
+ }
86
+ to {
87
+ translate: 250%;
88
+ }
89
+ }
90
+ }
91
+
92
+ .dark {
93
+ --color-primary: #818cf8;
94
+ --color-primary-hover: #a5b4fc;
95
+ --color-primary-foreground: #11111b;
96
+ --color-danger: #f87171;
97
+ --color-danger-hover: #fca5a5;
98
+ --color-danger-foreground: #1c0a0a;
99
+
100
+ --color-background: #09090b;
101
+ --color-surface: #18181b;
102
+ --color-muted: #27272a;
103
+ --color-foreground: #fafafa;
104
+ --color-muted-foreground: #a1a1aa;
105
+ --color-border: #3f3f46;
106
+ --color-border-strong: #52525b;
107
+ --color-overlay: rgb(0 0 0 / 0.7);
108
+ }
109
+
110
+ /*
111
+ * UiModal: a sheet along the bottom of the screen on phones, centred from the
112
+ * sm breakpoint (640px) up, and the whole screen with size="full".
113
+ *
114
+ * These rules are in the components layer, so a class passed to <UiModal> wins
115
+ * over them at every screen size: class="max-w-xl", or the docs site's
116
+ * class="m-0 h-dvh w-72" side drawer.
117
+ *
118
+ * It slides in from --ui-modal-slide-from (a translate value, relative to its
119
+ * own size), starting at --ui-modal-opacity-from. A side drawer can set both:
120
+ * class="[--ui-modal-slide-from:-100%_0] [--ui-modal-opacity-from:1]".
121
+ */
122
+ @layer components {
123
+ dialog[data-ui-modal] {
124
+ --ui-modal-slide-from: 0 100%;
125
+ --ui-modal-opacity-from: 1;
126
+ /* Tailwind's reset removes the browser's auto margins. Auto above pins it to the bottom. */
127
+ margin: auto auto 0;
128
+ width: 100%;
129
+ max-width: none;
130
+ max-height: calc(100dvh - 2rem);
131
+ border-radius: var(--radius-panel) var(--radius-panel) 0 0;
132
+
133
+ @variant sm {
134
+ --ui-modal-slide-from: 0 2rem;
135
+ --ui-modal-opacity-from: 0;
136
+ margin: auto;
137
+ width: calc(100% - 2rem);
138
+ max-height: calc(100dvh - 4rem);
139
+ border-radius: var(--radius-panel);
140
+
141
+ &[data-size="sm"] {
142
+ max-width: 24rem;
143
+ }
144
+ &[data-size="md"] {
145
+ max-width: 32rem;
146
+ }
147
+ &[data-size="lg"] {
148
+ max-width: 48rem;
149
+ }
150
+ }
151
+
152
+ &[data-size="full"] {
153
+ --ui-modal-slide-from: 0 100%;
154
+ --ui-modal-opacity-from: 1;
155
+ margin: 0;
156
+ width: 100%;
157
+ height: 100dvh;
158
+ max-width: none;
159
+ max-height: none;
160
+ border-radius: 0;
161
+ }
162
+
163
+ /* Modal.vue sets data-closing, then closes the dialog once the animation ends. */
164
+ &[open] {
165
+ animation: var(--animate-modal-in);
166
+ &::backdrop {
167
+ animation: var(--animate-fade-in);
168
+ }
169
+ }
170
+ &[data-closing] {
171
+ animation: var(--animate-modal-out);
172
+ pointer-events: none;
173
+ &::backdrop {
174
+ animation: var(--animate-fade-out);
175
+ }
176
+ }
177
+
178
+ /* With reduced motion, it fades in place instead of sliding. */
179
+ @variant motion-reduce {
180
+ &[open] {
181
+ animation: var(--animate-fade-in);
182
+ }
183
+ &[data-closing] {
184
+ animation: var(--animate-fade-out);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /* Stop the page behind an open UiModal from scrolling. */
191
+ html:has(dialog[data-ui-modal][open]) {
192
+ overflow: hidden;
193
+ }
@@ -0,0 +1,126 @@
1
+ <template>
2
+ <button
3
+ v-bind="passthroughAttrs"
4
+ :type="type"
5
+ :class="classes"
6
+ :disabled="disabled"
7
+ :aria-busy="loading || undefined"
8
+ @click="onClick"
9
+ >
10
+ <span
11
+ v-if="loading"
12
+ class="size-4 animate-spin rounded-full border-2 border-current border-r-transparent"
13
+ aria-hidden="true"
14
+ />
15
+ <span v-else-if="$slots.leading" class="inline-flex text-[1.1em]">
16
+ <slot name="leading" />
17
+ </span>
18
+ <span>
19
+ <slot />
20
+ </span>
21
+ <span v-if="$slots.trailing" class="inline-flex text-[1.1em]">
22
+ <slot name="trailing" />
23
+ </span>
24
+ </button>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed, useAttrs } from "vue";
29
+ import { cn } from "../lib/cn";
30
+
31
+ export interface ButtonProps {
32
+ variant?: "primary" | "outline" | "ghost" | "danger" | "text";
33
+ size?: "sm" | "md" | "lg";
34
+ type?: "button" | "submit" | "reset";
35
+ disabled?: boolean;
36
+ loading?: boolean;
37
+ block?: boolean;
38
+ raised?: boolean;
39
+ radius?: "none" | "sm" | "md" | "lg" | "full";
40
+ }
41
+
42
+ const props = withDefaults(defineProps<ButtonProps>(), {
43
+ variant: "primary",
44
+ size: "md",
45
+ type: "button",
46
+ disabled: false,
47
+ loading: false,
48
+ block: false,
49
+ raised: false,
50
+ radius: "none",
51
+ });
52
+
53
+ const emit = defineEmits<{
54
+ click: [event: MouseEvent];
55
+ }>();
56
+
57
+ defineSlots<{
58
+ /** Button label. */
59
+ default?: () => unknown;
60
+ /** Icon before the label (hidden while loading). */
61
+ leading?: () => unknown;
62
+ /** Icon after the label. */
63
+ trailing?: () => unknown;
64
+ }>();
65
+
66
+ // We merge the app's `class` ourselves so it can override defaults (e.g. class="px-8").
67
+ defineOptions({ inheritAttrs: false });
68
+ const attrs = useAttrs();
69
+
70
+ const base =
71
+ "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap " +
72
+ "border border-transparent font-semibold leading-none select-none outline-none transition-colors " +
73
+ "focus-visible:ring-3 focus-visible:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-50";
74
+
75
+ const variants = {
76
+ primary: "bg-primary text-primary-foreground hover:bg-primary-hover",
77
+ outline: "border-primary bg-transparent text-primary hover:bg-primary/10",
78
+ ghost: "bg-primary/10 text-primary border-transparent hover:bg-primary/20",
79
+ danger:
80
+ "bg-danger text-danger-foreground hover:bg-danger-hover focus-visible:ring-danger/35",
81
+ text: "text-foreground bg-transparent",
82
+ } as const;
83
+
84
+ const sizes = {
85
+ sm: "h-8 px-3 text-sm",
86
+ md: "h-10 px-4 text-sm",
87
+ lg: "h-12 px-6 text-base",
88
+ } as const;
89
+
90
+ const radii = {
91
+ none: "rounded-none",
92
+ sm: "rounded-sm",
93
+ md: "rounded-md",
94
+ lg: "rounded-lg",
95
+ full: "rounded-full",
96
+ } as const;
97
+
98
+ const raised =
99
+ "shadow-sm transition hover:shadow-md enabled:active:translate-y-px enabled:active:shadow-none";
100
+
101
+ const classes = computed(() =>
102
+ cn(
103
+ base,
104
+ variants[props.variant],
105
+ sizes[props.size],
106
+ props.block && "flex w-full",
107
+ props.raised && raised,
108
+ radii[props.radius],
109
+ props.loading && "cursor-progress",
110
+ attrs.class as string,
111
+ ),
112
+ );
113
+
114
+ const passthroughAttrs = computed(() => {
115
+ const { class: _class, ...rest } = attrs;
116
+ return rest;
117
+ });
118
+
119
+ const onClick = (event: MouseEvent) => {
120
+ if (props.disabled || props.loading) {
121
+ event.preventDefault();
122
+ return;
123
+ }
124
+ emit("click", event);
125
+ };
126
+ </script>
@@ -0,0 +1,130 @@
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
+ :class="boxClasses"
22
+ :name="name"
23
+ :value="value"
24
+ :disabled="disabled"
25
+ :required="required"
26
+ :aria-describedby="message ? messageId : undefined"
27
+ />
28
+ <CheckIcon
29
+ :class="
30
+ cn(
31
+ 'pointer-events-none absolute inset-0 m-auto text-white opacity-0 transition-opacity peer-checked:opacity-100',
32
+ sizes[size].check,
33
+ )
34
+ "
35
+ :stroke-width="3"
36
+ />
37
+ </span>
38
+
39
+ <label
40
+ v-if="label || $slots.default"
41
+ :for="inputId"
42
+ :class="
43
+ cn('select-none', disabled ? 'cursor-not-allowed' : 'cursor-pointer')
44
+ "
45
+ >
46
+ <slot>{{ label }}</slot>
47
+ </label>
48
+ </div>
49
+
50
+ <p
51
+ v-if="message"
52
+ :id="messageId"
53
+ :class="cn('text-sm', sizes[size].message, 'text-muted-foreground')"
54
+ >
55
+ {{ message }}
56
+ </p>
57
+ </div>
58
+ </template>
59
+
60
+ <script setup lang="ts">
61
+ import { computed, useAttrs, useId, useTemplateRef } from "vue";
62
+ import { CheckIcon } from "@lucide/vue";
63
+ import { cn } from "../lib/cn";
64
+
65
+ export interface CheckboxProps {
66
+ label?: string;
67
+ hint?: string;
68
+ size?: "sm" | "md" | "lg";
69
+ disabled?: boolean;
70
+ required?: boolean;
71
+ id?: string;
72
+ name?: string;
73
+ /** Added to / removed from the v-model when it's an array. */
74
+ value?: string | number;
75
+ }
76
+
77
+ const props = withDefaults(defineProps<CheckboxProps>(), {
78
+ size: "md",
79
+ });
80
+
81
+ // A boolean for a single checkbox, or an array of the checked `value`s for a group.
82
+ const model = defineModel<boolean | (string | number)[]>({ default: false });
83
+
84
+ defineSlots<{
85
+ /** Label content, when it needs more than plain text (e.g. a link). */
86
+ default?: () => unknown;
87
+ }>();
88
+
89
+ // `class` styles the outer wrapper; every other attribute and listener
90
+ // (@change, @focus, data-*) goes to the <input> itself.
91
+ defineOptions({ inheritAttrs: false });
92
+ const attrs = useAttrs();
93
+ const inputAttrs = computed(() => {
94
+ const { class: _class, ...rest } = attrs;
95
+ return rest;
96
+ });
97
+
98
+ const autoId = useId();
99
+ const inputId = computed(() => props.id ?? `ui-checkbox-${autoId}`);
100
+ const messageId = computed(() => `${inputId.value}-message`);
101
+ const message = computed(() => props.hint);
102
+
103
+ // Lucide's check spans two thirds of its grid, so it's drawn smaller than the
104
+ // box to leave a margin, with a heavier stroke to keep it legible at that size.
105
+ const sizes = {
106
+ sm: { row: "gap-2 text-sm", box: "size-4", check: "size-3", message: "pl-6" },
107
+ md: { row: "gap-2 text-sm", box: "size-5", check: "size-3.5", message: "pl-7" },
108
+ lg: { row: "gap-3 text-base", box: "size-6", check: "size-4", message: "pl-9" },
109
+ } as const;
110
+
111
+ // The native input is restyled into the box, so keyboard, forms and
112
+ // screen readers behave exactly like a plain checkbox.
113
+ const boxClasses = computed(() =>
114
+ cn(
115
+ "peer m-0 cursor-pointer appearance-none rounded-sm border border-border bg-transparent outline-none " +
116
+ "transition-[background-color,border-color,box-shadow] checked:border-primary checked:bg-primary " +
117
+ "focus-visible:ring-3 disabled:cursor-not-allowed",
118
+ sizes[props.size].box,
119
+ "focus-visible:ring-primary/35",
120
+ !props.disabled && "not-checked:hover:border-border-strong",
121
+ ),
122
+ );
123
+
124
+ const inputEl = useTemplateRef<HTMLInputElement>("input");
125
+
126
+ defineExpose({
127
+ focus: () => inputEl.value?.focus(),
128
+ el: inputEl,
129
+ });
130
+ </script>