@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
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
|
-
#
|
|
1
|
+
# @tunji_xy/ui
|
|
2
2
|
|
|
3
|
-
|
|
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>
|