Use ShadCN

This commit is contained in:
Matthew McPeak 2026-07-15 19:27:50 -04:00
commit e2f140af78
130 changed files with 7256 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
**/node_modules
**/dist
**/storybook-static
**/.storybook-static
.git
docs
.tmp
.bun-cache
.pw-browsers
.pw-libs
.shots
*.log

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

112
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,112 @@
name: ci
on:
push:
tags:
- 'v*'
pull_request:
jobs:
build:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
outputs:
artifact_name: ${{ steps.meta.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set artifact name
id: meta
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${GITHUB_REF_NAME#v}"
else
tag="$(date -u +%Y%m%d)"
fi
echo "name=nychthemeron_${tag}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Audit dependencies
run: bun audit || true
- name: Run unit tests
run: bun run test:unit
- name: Type check
run: bun run type-check -- --force
- name: Build
run: bun run build
- name: Upload dist
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.name }}.zip
path: packages/library/dist
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download Artifacts
uses: https://code.forgejo.org/forgejo/download-artifact@v4
with:
name: ${{ needs.build.outputs.artifact_name }}.zip
path: packages/library/dist
- name: Set package version
run: bun pm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
working-directory: packages/library
- name: Configure registry auth
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${FORGEJO_TOKEN}" > /root/.npmrc
working-directory: packages/library
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- name: Publish package
run: npm publish --registry https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/
working-directory: packages/library
publish-docs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push docs image
uses: docker/build-push-action@v6
with:
context: .
file: packages/playground/Dockerfile
push: true
tags: |
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:latest

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
packages/*/dist
packages/*/node_modules
packages/*/.storybook-static
packages/*/storybook-static
# Storybook
.storybook/node_modules
node_modules/.cache
# Local browser-verification tooling (Playwright + extracted libs + screenshots)
.tmp/
.bun-cache/
.pw-browsers/
.pw-libs/
.shots/

10
.oxlintrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

6
.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

10
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

133
README.md Normal file
View file

@ -0,0 +1,133 @@
# nychthemeron
A CSS-reactive Vue component library built on PrimeVue (Unstyled), shipped with the nychthemeron theme as a default.
## About
nychthemeron provides a curated set of Vue 3 components powered by PrimeVue's unstyled components and the nychthemeron theme system. This approach gives you complete control over component styling while maintaining a consistent, polished design language out of the box.
### Features
- **CSS-Reactive**: Components respond to CSS changes without requiring JavaScript updates
- **PrimeVue Powered**: Built on the robust, accessible PrimeVue unstyled component library
- **Theme Included**: Ships with the nychthemeron theme, ready to use immediately
- **TypeScript**: Full type support for Vue 3 and TypeScript projects
- **Customizable**: Override or extend the default theme to match your design system
## Quick Start
### Installation
```bash
npm install @nychthemeron/library primevue vue@^3.5.0
```
### Basic Usage
Import the library components and styles:
```vue
<script setup lang="ts">
import { NychButton } from '@nychthemeron/library'
</script>
<template>
<NychButton label="Click me" />
</template>
<style>
@import '@nychthemeron/library/style';
@import '@nychthemeron/library/theme';
</style>
```
## Components
- **NychButton** - Customizable button component
More components coming soon.
## Customization
### Using the Default Theme
The nychthemeron theme is included by default:
```css
@import '@nychthemeron/library/theme';
```
### Custom Styling
Override or extend component styles by importing only the base styles and providing your own CSS:
```css
@import '@nychthemeron/library/style';
/* Your custom theme */
:root {
--primary-color: #your-color;
/* More CSS variables */
}
```
## Project Structure
This is a monorepo containing:
- **`packages/library`** - The component library package
- **`packages/playground`** - Development environment and examples
## Development
### Setup
```bash
bun install
```
### Development Server
Start the playground to develop and test components:
```bash
bun dev
```
### Build
Build the library for production:
```bash
bun build
```
### Testing
Run unit tests:
```bash
bun test
```
### Linting and Formatting
```bash
bun lint # Check and fix linting issues
bun format # Format code with Prettier
bun type-check # Run TypeScript type checking
```
## Requirements
- **Node.js**: >=20.19.0 or >=22.12.0
- **Vue**: ^3.5.0
- **PrimeVue**: ^4.5.0
## Browser Support
Supports all modern browsers with CSS Grid and CSS Variables support.
## License
MIT

2283
bun.lock Normal file

File diff suppressed because it is too large Load diff

3
env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module '*.css' {}

43
eslint.config.ts Normal file
View file

@ -0,0 +1,43 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
// shadcn-vue's generated ui/ components use single-word names by convention
// (Button.vue, Input.vue, ...) so their file names match the CLI's own output.
name: 'app/shadcn-vue-component-names',
files: ['**/components/ui/**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

50
package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "nychthemeron",
"version": "0.0.0",
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/eslint-plugin": "^1.6.20",
"@vue/eslint-config-typescript": "^14.8.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.69.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^9.0.1",
"oxlint": "~1.69.0",
"playwright": "^1.60.0",
"prettier": "3.8.4",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",
"vitest": "^4.1.8",
"vue": "^3.5.38",
"vue-tsc": "^3.3.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"private": true,
"scripts": {
"dev": "cd packages/playground && bun run dev",
"build:dev": "cd packages/library && bun run build ",
"build": "cd packages/library && bun run build && cd ../playground && bun run build",
"preview": "cd packages/playground && bun run preview",
"test:unit": "cd packages/library && bun run test:unit",
"type-check": "vue-tsc --build",
"lint": "eslint packages/*/src --fix",
"format": "prettier --write --experimental-cli packages/*/src",
"test": "vitest --config packages/library/vitest.config.ts"
},
"type": "module",
"workspaces": [
"packages/*"
]
}

View file

@ -0,0 +1,26 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-nova",
"font": "geist-sans",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/css/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"pointer": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,56 @@
{
"name": "@nychthemeron/library",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.js",
"types": "./dist/src/index.d.ts"
},
"./components": {
"import": "./src/components/index.ts",
"types": "./src/index.d.ts"
},
"./style": {
"import": "./src/assets/css/tailwind.css",
"types": "./src/index.d.ts"
},
"./theme": {
"import": "./src/assets/css/tailwind.css",
"types": "./src/index.d.ts"
}
},
"files": [
"dist",
"src/assets",
"src/components"
],
"scripts": {
"build": "vite build",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"peerDependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"@vue/test-utils": "^2.4.6",
"vite-plugin-dts": "^5.0.2"
},
"dependencies": {
"@fontsource/cinzel": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@lucide/vue": "^1.24.0",
"@tailwindcss/vite": "^4.3.2",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"shadcn-vue": "^2.7.4",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0"
}
}

View file

@ -0,0 +1,207 @@
/* ============================================================
tailwind.css
Toggle themes by setting data-theme on <html>:
<html data-theme="hades">
<html data-theme="apollo">
============================================================ */
/* Self-hosted fonts (not a Google Fonts CDN @import) so the library never
depends on outbound network access to render its own type. */
@import "@fontsource/cinzel/400.css";
@import "@fontsource/cinzel/600.css";
@import "@fontsource/ibm-plex-sans/400.css";
@import "@fontsource/ibm-plex-sans/500.css";
@import "@fontsource/ibm-plex-mono/400.css";
@import "@fontsource/ibm-plex-mono/500.css";
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-vue/tailwind.css";
/* Explicit source: this file is consumed by sibling workspace packages (e.g.
packages/playground) whose own Vite root Tailwind's automatic content
detection never walks into, so class usage in src/components/** would
otherwise be invisible to the scanner. */
@source "../../";
@theme inline {
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--color-background: var(--surface-0);
--color-foreground: var(--text-body);
--color-card: var(--surface-1);
--color-card-foreground: var(--text-body);
--color-popover: var(--surface-2);
--color-popover-foreground: var(--text-body);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-fg);
--color-secondary: var(--neutral);
--color-secondary-foreground: var(--neutral-fg);
--color-muted: var(--surface-2);
--color-muted-foreground: var(--text-muted);
--color-accent: var(--surface-3);
--color-accent-foreground: var(--text-high);
--color-destructive: var(--danger);
--color-destructive-foreground: var(--danger-fg);
--color-info: var(--info);
--color-info-foreground: var(--info-fg);
--color-success: var(--success);
--color-success-foreground: var(--success-fg);
--color-warning: var(--warn);
--color-warning-foreground: var(--warn-fg);
--color-border: var(--border);
--color-input: var(--border);
--color-ring: var(--primary);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.3125rem;
}
/* ── HADES ───────────────────────────────────────────────── */
[data-theme='hades'] {
--surface-0: #18160f;
--surface-1: #28241a;
--surface-2: #2e2a1e;
--surface-3: #322e22;
--track: #32302a;
--border: #4a4538;
--border-hi: #5a5040;
--border-lo: #38342a;
--primary: #d4b278;
--primary-fg: #0e0c08;
--warn: #e08840;
--warn-subtle: #281808;
--warn-border: #4a2c10;
--warn-fg: #fff8e8;
--danger: #7a1a1a;
--danger-subtle: #200808;
--danger-border: #3a1010;
--danger-fg: #fff8e8;
--info: #88aec8;
--info-subtle: #101e2c;
--info-border: #1e3850;
--info-fg: #0a1828;
--neutral: #7a6e60;
--neutral-fg: #fff8e8;
--success: #6aaa7a;
--success-subtle: #0e2214;
--success-border: #1e4228;
--success-fg: #fff8e8;
--text-high: #f4f0e8;
--text-body: #d0c8b8;
--text-muted: #908878;
--text-dim: #807868;
--text-label: #c0b8a8;
--focus-ring: 0 0 0 3px rgba(212, 178, 120, 0.65);
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* ── APOLLO ──────────────────────────────────────────────── */
[data-theme='apollo'] {
--surface-0: #faf7f2;
--surface-1: #f4f0e8;
--surface-2: #eee9df;
--surface-3: #e4ddd0;
--track: #e4ddd0;
--border: #bab2a0;
--border-hi: #c0b8a8;
--border-lo: #ccc4b4;
--primary: #b8860b;
--primary-fg: #fff8e8;
--warn: #9a6020;
--warn-subtle: #fdf3e0;
--warn-border: #e8d0a0;
--warn-fg: #fff8e8;
--danger: #7a1a1a;
--danger-subtle: #faeaea;
--danger-border: #c8a0a0;
--danger-fg: #fff8e8;
--info: #3a5a8a;
--info-subtle: #e8eef8;
--info-border: #b0c4e0;
--info-fg: #fff8e8;
--neutral: #8a8070;
--neutral-fg: #fff8e8;
--success: #5a7a40;
--success-subtle: #eef4e8;
--success-border: #c0d4a8;
--success-fg: #fff8e8;
--text-high: #1a1610;
--text-body: #3a3020;
--text-muted: #5a5040;
--text-dim: #8a8070;
--text-label: #5a5040;
--focus-ring: 0 0 0 3px rgba(184, 134, 11, 0.65);
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
@layer base {
* {
@apply border-border;
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground;
min-height: 100vh;
}
:focus-visible {
outline: 2px solid transparent;
box-shadow: var(--focus-ring);
}
}
@keyframes nych-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.nych-loading-icon .wreath {
transform-origin: 340px 180px;
animation: nych-spin 2s linear infinite;
}
.nych-loading-icon .wreath path {
fill: var(--primary-fg);
}
.nych-loading-icon .wreath line {
stroke: var(--text-high);
}

View file

@ -0,0 +1,70 @@
<template>
<svg class="nych-loading-icon" preserveAspectRatio="xMidYMid meet" viewBox="226 66 228 228" aria-hidden="true">
<g class="wreath">
<g transform="rotate(0,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(22.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(45,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(67.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(90,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(112.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(135,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(157.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(180,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(202.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(225,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(247.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(270,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(292.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(315,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(337.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
</g>
</svg>
</template>

View file

@ -0,0 +1,36 @@
// Re-export all components here as they're added
export { default as NychLoadingIcon } from './NychLoadingIcon.vue'
export { Button, buttonVariants } from './ui/button'
export type { ButtonVariants } from './ui/button'
export { Input } from './ui/input'
export { Textarea } from './ui/textarea'
export { Switch } from './ui/switch'
export { Badge, badgeVariants } from './ui/badge'
export { Checkbox } from './ui/checkbox'
export { CheckboxGroup } from './ui/checkbox-group'
export { RadioGroup, RadioGroupItem } from './ui/radio-group'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction, cardVariants } from './ui/card'
export type { CardVariants } from './ui/card'
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } from './ui/alert'
export {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
} from './ui/select'
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from './ui/dialog'

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { XIcon } from '@lucide/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { alertVariants } from '.'
import AlertAction from './AlertAction.vue'
import type { AlertVariants } from '.'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
closable?: boolean
}>(), {
variant: 'info',
closable: false,
})
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div data-slot="alert" role="alert" :class="cn(alertVariants({ variant }), props.class)">
<slot />
<AlertAction v-if="closable">
<Button variant="secondary" size="icon" class="size-6 bg-transparent hover:bg-black/10" @click="emit('close')">
<XIcon class="size-3.5" />
<span class="sr-only">Dismiss</span>
</Button>
</AlertAction>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-action"
:class="cn('absolute top-2 right-2', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('font-medium font-serif group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,26 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva(
'relative grid w-full gap-1 rounded-lg border border-l-4 px-3 py-2.5 text-sm text-foreground has-data-[slot=alert-action]:pr-10',
{
variants: {
variant: {
info: 'bg-info/15 border-info/30 border-l-info',
success: 'bg-success/15 border-success/30 border-l-success',
warning: 'bg-warning/15 border-warning/30 border-l-warning',
danger: 'bg-destructive/15 border-destructive/30 border-l-destructive',
secondary: 'bg-secondary/40 border-border',
},
},
defaultVariants: {
variant: 'info',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = withDefaults(defineProps<{
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>(), {
variant: 'primary',
})
</script>
<template>
<span data-slot="badge" :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>

View file

@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 text-xs font-semibold [&>svg]:size-3',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
info: 'bg-info text-info-foreground',
success: 'bg-success text-success-foreground',
warning: 'bg-warning text-warning-foreground',
danger: 'bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'primary',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { computed } from 'vue'
import { Primitive } from '@/lib/primitive'
import { cn } from '@/lib/utils'
import NychLoadingIcon from '@/components/NychLoadingIcon.vue'
import { buttonVariants } from '.'
interface Props {
as?: string
asChild?: boolean
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
variant: 'primary',
})
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:disabled="isDisabled"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<NychLoadingIcon v-if="loading" class="size-4" />
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,31 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-transparent font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
info: 'bg-info text-info-foreground hover:bg-info/90',
success: 'bg-success text-success-foreground hover:bg-success/90',
warning: 'bg-warning text-warning-foreground hover:bg-warning/90',
danger: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
default: 'h-8 px-3 text-sm',
sm: 'h-7 px-2.5 text-xs',
lg: 'h-9 px-4 text-base',
icon: 'size-8',
},
},
defaultVariants: {
variant: 'primary',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { CardVariants } from "."
import { cn } from "@/lib/utils"
import { cardVariants } from "."
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: CardVariants["size"]
}>(), {
size: "default",
})
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn(cardVariants({ size }), props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center justify-end gap-2', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-title"
:class="cn('text-base leading-snug font-medium font-serif group-data-[size=sm]/card:text-sm', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,26 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
export const cardVariants = cva(
'group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card py-4 text-sm text-card-foreground shadow-md has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
{
variants: {
size: {
default: '',
sm: 'gap-3 py-3',
},
},
defaultVariants: {
size: 'default',
},
},
)
export type CardVariants = VariantProps<typeof cardVariants>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
name?: string
class?: HTMLAttributes['class']
}>()
provide('nych-checkbox-group-name', props.name)
</script>
<template>
<div data-slot="checkbox-group" role="group" :class="cn('grid gap-2 w-full', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,2 @@
export { default as CheckboxGroup } from './CheckboxGroup.vue'
export const CHECKBOX_GROUP_NAME_KEY = 'nych-checkbox-group-name'

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue?: boolean
defaultValue?: boolean
disabled?: boolean
required?: boolean
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const checked = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue ?? false,
})
function toggle() {
if (props.disabled) return
checked.value = !checked.value
}
</script>
<template>
<button
type="button"
role="checkbox"
data-slot="checkbox"
:aria-checked="checked"
:aria-required="required"
:data-state="checked ? 'checked' : 'unchecked'"
:disabled="disabled"
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring', props.class)"
@click="toggle"
>
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
>
<span
v-if="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current"
>
<slot>
<CheckIcon />
</slot>
</span>
</Transition>
</button>
</template>

View file

@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { provide, useId } from 'vue'
import { useVModel } from '@vueuse/core'
import { DialogContextKey } from './context'
const props = defineProps<{ open?: boolean, defaultOpen?: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
provide(DialogContextKey, {
open,
titleId: `dialog-title-${useId()}`,
descriptionId: `dialog-description-${useId()}`,
})
</script>
<template>
<slot />
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-close"
@click="context.open.value = false"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogOverlay from './DialogOverlay.vue'
import DialogClose from './DialogClose.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], showCloseButton?: boolean }>(),
{ showCloseButton: true },
)
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<DialogOverlay />
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
>
<div
v-if="context.open.value"
ref="contentRef"
data-slot="dialog-content"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn('bg-popover text-popover-foreground border border-border grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm shadow-lg sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary" class="absolute top-2 right-2 size-7 bg-transparent text-foreground hover:bg-black/10" size="icon">
<XIcon class="size-3.5" />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</div>
</Transition>
</Teleport>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<p
:id="context.descriptionId"
data-slot="dialog-description"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import DialogClose from "./DialogClose.vue"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="secondary">
Close
</Button>
</DialogClose>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('gap-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Transition
enter-active-class="animate-in fade-in-0 animation-duration-100"
leave-active-class="animate-out fade-out-0 animation-duration-100"
>
<div
v-if="context.open.value"
data-slot="dialog-overlay"
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
/>
</Transition>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { XIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { DialogContextKey } from './context'
import DialogClose from './DialogClose.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
const contentRef = ref<HTMLElement | null>(null)
useFocusTrap(contentRef, context.open)
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
onPointerDownOutside: (event) => {
const target = event.target as HTMLElement
if (event.offsetX > target.clientWidth || event.offsetY > target.clientHeight) {
event.preventDefault()
}
},
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="animate-in fade-in-0 animation-duration-100"
leave-active-class="animate-out fade-out-0 animation-duration-100"
>
<div
v-if="context.open.value"
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80"
>
<div
ref="contentRef"
data-slot="dialog-content"
role="dialog"
aria-modal="true"
:aria-labelledby="context.titleId"
:aria-describedby="context.descriptionId"
tabindex="-1"
v-bind="$attrs"
:class="cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
props.class,
)"
>
<slot />
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</div>
</div>
</Transition>
</Teleport>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { DialogContextKey } from './context'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(DialogContextKey)!
</script>
<template>
<h2
:id="context.titleId"
data-slot="dialog-title"
:class="cn('text-base leading-none font-medium font-serif', props.class)"
>
<slot />
</h2>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import { inject } from 'vue'
import { Primitive } from '@/lib/primitive'
import { DialogContextKey } from './context'
const props = defineProps<{ asChild?: boolean }>()
const context = inject(DialogContextKey)!
</script>
<template>
<Primitive
as="button"
:as-child="props.asChild"
type="button"
data-slot="dialog-trigger"
aria-haspopup="dialog"
:aria-expanded="context.open.value"
@click="context.open.value = true"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
export interface DialogContext {
open: Ref<boolean>
titleId: string
descriptionId: string
}
export const DialogContextKey: InjectionKey<DialogContext> = Symbol('DialogContext')

View file

@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
</template>

View file

@ -0,0 +1 @@
export { default as Input } from "./Input.vue"

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, provide, ref } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useRovingFocus } from '@/lib/use-roving-focus'
import { RadioGroupContextKey } from './context'
const props = defineProps<{
modelValue?: string
defaultValue?: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue,
})
const disabled = computed(() => props.disabled ?? false)
const items = ref<HTMLElement[]>([])
function register(el: HTMLElement) {
items.value.push(el)
}
function unregister(el: HTMLElement) {
items.value = items.value.filter((item) => item !== el)
}
provide(RadioGroupContextKey, { modelValue, disabled, items, register, unregister })
const { focusIndex } = useRovingFocus(items, { orientation: 'vertical', loop: true })
function onKeydown(event: KeyboardEvent) {
const currentIndex = items.value.indexOf(document.activeElement as HTMLElement)
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
focusIndex(currentIndex + 1)
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
focusIndex(currentIndex - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusIndex(0)
} else if (event.key === 'End') {
event.preventDefault()
focusIndex(items.value.length - 1)
} else {
return
}
const value = (document.activeElement as HTMLElement | null)?.dataset.value
if (value !== undefined) modelValue.value = value
}
</script>
<template>
<div
data-slot="radio-group"
role="radiogroup"
:class="cn('grid gap-2 w-full', props.class)"
@keydown="onKeydown"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { CircleIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { RadioGroupContextKey } from './context'
const props = defineProps<{
value: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const context = inject(RadioGroupContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const isChecked = computed(() => context.modelValue.value === props.value)
const isTabbable = computed(() => {
const current = context.modelValue.value ?? context.items.value[0]?.dataset.value
return props.value === current
})
function onClick() {
if (props.disabled || context.disabled.value) return
context.modelValue.value = props.value
}
onMounted(() => {
if (itemRef.value) context.register(itemRef.value)
})
onBeforeUnmount(() => {
if (itemRef.value) context.unregister(itemRef.value)
})
</script>
<template>
<button
ref="itemRef"
type="button"
role="radio"
data-slot="radio-group-item"
:data-value="value"
:aria-checked="isChecked"
:data-state="isChecked ? 'checked' : 'unchecked'"
:tabindex="isTabbable ? 0 : -1"
:disabled="disabled || context.disabled.value"
:class="
cn(
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors hover:border-ring',
props.class,
)
"
@click="onClick"
>
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
>
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
<slot>
<CircleIcon class="fill-primary-foreground text-primary-foreground absolute top-1/2 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2" />
</slot>
</span>
</Transition>
</button>
</template>

View file

@ -0,0 +1,11 @@
import type { InjectionKey, Ref } from 'vue'
export interface RadioGroupContext {
modelValue: Ref<string | undefined>
disabled: Ref<boolean>
items: Ref<HTMLElement[]>
register: (el: HTMLElement) => void
unregister: (el: HTMLElement) => void
}
export const RadioGroupContextKey: InjectionKey<RadioGroupContext> = Symbol('RadioGroupContext')

View file

@ -0,0 +1,2 @@
export { default as RadioGroup } from "./RadioGroup.vue"
export { default as RadioGroupItem } from "./RadioGroupItem.vue"

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, provide, reactive, ref, useId } from 'vue'
import { useVModel } from '@vueuse/core'
import { SelectContextKey } from './context'
const props = defineProps<{
modelValue?: string
defaultValue?: string
disabled?: boolean
open?: boolean
defaultOpen?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue,
})
const open = useVModel(props, 'open', emit, {
passive: true,
defaultValue: props.defaultOpen ?? false,
})
const disabled = computed(() => props.disabled ?? false)
const triggerRef = ref<HTMLElement | null>(null)
const itemLabels = reactive(new Map<string, string>())
provide(SelectContextKey, {
open,
modelValue,
disabled,
triggerRef,
contentId: `select-content-${useId()}`,
itemLabels,
registerLabel: (value: string, label: string) => { itemLabels.set(value, label) },
})
</script>
<template>
<slot />
</template>

View file

@ -0,0 +1,121 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject, nextTick, ref, watch } from 'vue'
import { useScrollLock } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { useDismissableLayer } from '@/lib/use-dismissable-layer'
import { useRovingFocus } from '@/lib/use-roving-focus'
import { usePopoverPosition } from '@/lib/use-popover-position'
import { SelectContextKey } from './context'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const context = inject(SelectContextKey)!
const contentRef = ref<HTMLElement | null>(null)
const viewportRef = ref<HTMLElement | null>(null)
const items = ref<HTMLElement[]>([])
const position = usePopoverPosition(context.triggerRef, contentRef, context.open)
function collectItems() {
const viewport = viewportRef.value
items.value = viewport
? Array.from(viewport.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
: []
}
function initialFocusTarget() {
collectItems()
return items.value.find((item) => item.dataset.value === context.modelValue.value) ?? items.value[0] ?? null
}
useFocusTrap(contentRef, context.open, { initialFocus: initialFocusTarget })
useDismissableLayer(contentRef, context.open, {
onDismiss: () => { context.open.value = false },
})
const { handleKeydown: handleRovingKeydown } = useRovingFocus(items, { orientation: 'vertical', loop: false })
let typeaheadBuffer = ''
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined
function handleTypeahead(char: string) {
typeaheadBuffer += char.toLowerCase()
clearTimeout(typeaheadTimeout)
typeaheadTimeout = setTimeout(() => { typeaheadBuffer = '' }, 500)
const match = items.value.find((item) => (item.textContent ?? '').trim().toLowerCase().startsWith(typeaheadBuffer))
match?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
handleRovingKeydown(event)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
const value = (document.activeElement as HTMLElement | null)?.dataset.value
if (value !== undefined) {
context.modelValue.value = value
context.open.value = false
}
return
}
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
handleTypeahead(event.key)
}
}
watch(context.open, async (isOpen) => {
if (!isOpen) return
await nextTick()
collectItems()
})
const isLocked = useScrollLock(document.body)
watch(context.open, (value) => { isLocked.value = value }, { immediate: true })
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-95 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-95 animation-duration-100"
>
<div
v-if="context.open.value"
:id="context.contentId"
ref="contentRef"
data-slot="select-content"
role="listbox"
:data-side="position.side"
:style="{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
minWidth: `${position.minWidth}px`,
maxHeight: `${position.maxHeight}px`,
}"
tabindex="-1"
v-bind="$attrs"
:class="cn(
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 border border-border min-w-36 rounded-lg shadow-md overflow-x-hidden overflow-y-auto',
props.class,
)"
@keydown="onKeydown"
>
<SelectScrollUpButton :viewport="viewportRef" />
<div ref="viewportRef" class="overflow-y-auto">
<slot />
</div>
<SelectScrollDownButton :viewport="viewportRef" />
</div>
</Transition>
</Teleport>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-group" role="group" :class="cn('scroll-my-1 p-1', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { CheckIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
import SelectItemText from './SelectItemText.vue'
const props = defineProps<{
value: string
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const context = inject(SelectContextKey)!
const itemRef = ref<HTMLElement | null>(null)
const isSelected = computed(() => context.modelValue.value === props.value)
function select() {
if (props.disabled) return
context.modelValue.value = props.value
context.open.value = false
}
function onPointerMove() {
if (props.disabled) return
itemRef.value?.focus()
}
onMounted(() => {
context.registerLabel(props.value, itemRef.value?.textContent?.trim() ?? '')
})
</script>
<template>
<div
ref="itemRef"
data-slot="select-item"
role="option"
:data-value="value"
:aria-selected="isSelected"
:data-disabled="disabled ? '' : undefined"
tabindex="-1"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 transition-colors',
props.class,
)
"
@click="select"
@pointermove="onPointerMove"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<slot v-if="isSelected" name="indicator-icon">
<CheckIcon class="pointer-events-none" />
</slot>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</div>
</template>

View file

@ -0,0 +1,3 @@
<template>
<span data-slot="select-item-text"><slot /></span>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-label" :class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
function updateVisibility() {
const viewport = props.viewport
visible.value = !!viewport && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight
}
watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop += 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script>
<template>
<div
v-if="visible"
data-slot="select-scroll-down-button"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
>
<slot>
<ChevronDownIcon />
</slot>
</div>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { ChevronUpIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ viewport: HTMLElement | null, class?: HTMLAttributes['class'] }>()
const visible = ref(false)
let scrollInterval: ReturnType<typeof setInterval> | undefined
function updateVisibility() {
visible.value = !!props.viewport && props.viewport.scrollTop > 0
}
watchEffect((onCleanup) => {
const viewport = props.viewport
if (!viewport) return
updateVisibility()
viewport.addEventListener('scroll', updateVisibility)
onCleanup(() => viewport.removeEventListener('scroll', updateVisibility))
})
function startScroll() {
stopScroll()
scrollInterval = setInterval(() => {
if (props.viewport) props.viewport.scrollTop -= 8
}, 16)
}
function stopScroll() {
if (scrollInterval) clearInterval(scrollInterval)
scrollInterval = undefined
}
onBeforeUnmount(stopScroll)
</script>
<template>
<div
v-if="visible"
data-slot="select-scroll-up-button"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
@pointerdown="startScroll"
@pointerup="stopScroll"
@pointerleave="stopScroll"
>
<slot>
<ChevronUpIcon />
</slot>
</div>
</template>

View file

@ -0,0 +1,10 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="select-separator" role="separator" :class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)" />
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ChevronDownIcon } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { SelectContextKey } from './context'
const props = withDefaults(
defineProps<{ class?: HTMLAttributes['class'], size?: 'sm' | 'default', disabled?: boolean }>(),
{ size: 'default' },
)
const context = inject(SelectContextKey)!
function onClick() {
if (props.disabled || context.disabled.value) return
context.open.value = !context.open.value
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
context.open.value = true
}
}
</script>
<template>
<button
:ref="(el) => { context.triggerRef.value = el as HTMLElement | null }"
type="button"
data-slot="select-trigger"
:data-size="size"
role="combobox"
:aria-controls="context.contentId"
:aria-expanded="context.open.value"
aria-autocomplete="none"
:disabled="disabled || context.disabled.value"
:class="cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
@click="onClick"
@keydown="onKeydown"
>
<slot />
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</button>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { inject } from 'vue'
import { SelectContextKey } from './context'
const props = defineProps<{ placeholder?: string }>()
const context = inject(SelectContextKey)!
</script>
<template>
<span
data-slot="select-value"
:data-placeholder="context.modelValue.value ? undefined : placeholder"
>
<slot>{{ context.modelValue.value !== undefined ? context.itemLabels.get(context.modelValue.value) : placeholder }}</slot>
</span>
</template>

View file

@ -0,0 +1,13 @@
import type { InjectionKey, Ref } from 'vue'
export interface SelectContext {
open: Ref<boolean>
modelValue: Ref<string | undefined>
disabled: Ref<boolean>
triggerRef: Ref<HTMLElement | null>
contentId: string
itemLabels: Map<string, string>
registerLabel: (value: string, label: string) => void
}
export const SelectContextKey: InjectionKey<SelectContext> = Symbol('SelectContext')

View file

@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
modelValue?: boolean
defaultValue?: boolean
disabled?: boolean
required?: boolean
class?: HTMLAttributes['class']
size?: 'sm' | 'default'
}>(),
{ size: 'default' },
)
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const checked = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: props.defaultValue ?? false,
})
function toggle() {
if (props.disabled) return
checked.value = !checked.value
}
</script>
<template>
<button
type="button"
role="switch"
data-slot="switch"
:data-size="size"
:aria-checked="checked"
:aria-required="required"
:data-state="checked ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled"
:class="cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
props.class,
)"
@click="toggle"
>
<span
data-slot="switch-thumb"
:data-state="checked ? 'checked' : 'unchecked'"
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
>
<slot name="thumb" />
</span>
</button>
</template>

View file

@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="cn('border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue"

View file

@ -0,0 +1,2 @@
// Re-export all composables here as they're added
// export { useDate } from './useDate'

3
packages/library/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module '@nychthemeron/library/style'
declare module '@nychthemeron/library/theme'
declare module '@nychthemeron/library'

View file

@ -0,0 +1,42 @@
export {
createNychthemeron,
Button,
LoadingIcon,
Input,
Textarea,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Switch,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge,
} from './lib'

View file

@ -0,0 +1,217 @@
import type { App } from 'vue'
import {
NychLoadingIcon,
Button,
buttonVariants,
Input,
Textarea,
Switch,
Badge,
badgeVariants,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from '../components'
export {
Button,
buttonVariants,
Input,
Textarea,
Switch,
Badge,
badgeVariants,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
}
export const LoadingIcon = NychLoadingIcon
export const createNychthemeron = (): {
Button: typeof Button
LoadingIcon: typeof NychLoadingIcon
Input: typeof Input
Textarea: typeof Textarea
Checkbox: typeof Checkbox
CheckboxGroup: typeof CheckboxGroup
RadioGroup: typeof RadioGroup
RadioGroupItem: typeof RadioGroupItem
Switch: typeof Switch
Select: typeof Select
SelectTrigger: typeof SelectTrigger
SelectValue: typeof SelectValue
SelectContent: typeof SelectContent
SelectItem: typeof SelectItem
SelectGroup: typeof SelectGroup
SelectLabel: typeof SelectLabel
SelectSeparator: typeof SelectSeparator
Card: typeof Card
CardHeader: typeof CardHeader
CardTitle: typeof CardTitle
CardDescription: typeof CardDescription
CardContent: typeof CardContent
CardFooter: typeof CardFooter
CardAction: typeof CardAction
Dialog: typeof Dialog
DialogTrigger: typeof DialogTrigger
DialogContent: typeof DialogContent
DialogHeader: typeof DialogHeader
DialogTitle: typeof DialogTitle
DialogDescription: typeof DialogDescription
DialogFooter: typeof DialogFooter
DialogClose: typeof DialogClose
DialogOverlay: typeof DialogOverlay
DialogScrollContent: typeof DialogScrollContent
Alert: typeof Alert
AlertTitle: typeof AlertTitle
AlertDescription: typeof AlertDescription
AlertAction: typeof AlertAction
Badge: typeof Badge
install: (app: App) => void
} => ({
Button,
LoadingIcon,
Input,
Textarea,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Switch,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge,
install(app: App) {
app.component('NychButton', Button)
app.component('NychLoadingIcon', LoadingIcon)
app.component('NychInput', Input)
app.component('NychTextarea', Textarea)
app.component('NychCheckbox', Checkbox)
app.component('NychCheckboxGroup', CheckboxGroup)
app.component('NychRadioGroup', RadioGroup)
app.component('NychRadioGroupItem', RadioGroupItem)
app.component('NychSwitch', Switch)
app.component('NychSelect', Select)
app.component('NychSelectTrigger', SelectTrigger)
app.component('NychSelectValue', SelectValue)
app.component('NychSelectContent', SelectContent)
app.component('NychSelectItem', SelectItem)
app.component('NychSelectGroup', SelectGroup)
app.component('NychSelectLabel', SelectLabel)
app.component('NychSelectSeparator', SelectSeparator)
app.component('NychCard', Card)
app.component('NychCardHeader', CardHeader)
app.component('NychCardTitle', CardTitle)
app.component('NychCardDescription', CardDescription)
app.component('NychCardContent', CardContent)
app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog)
app.component('NychDialogTrigger', DialogTrigger)
app.component('NychDialogContent', DialogContent)
app.component('NychDialogHeader', DialogHeader)
app.component('NychDialogTitle', DialogTitle)
app.component('NychDialogDescription', DialogDescription)
app.component('NychDialogFooter', DialogFooter)
app.component('NychDialogClose', DialogClose)
app.component('NychDialogOverlay', DialogOverlay)
app.component('NychDialogScrollContent', DialogScrollContent)
app.component('NychAlert', Alert)
app.component('NychAlertTitle', AlertTitle)
app.component('NychAlertDescription', AlertDescription)
app.component('NychAlertAction', AlertAction)
app.component('NychBadge', Badge)
},
})

View file

@ -0,0 +1,21 @@
import type { Component } from 'vue'
import { defineComponent, h } from 'vue'
import Slot from './slot'
export const Primitive = defineComponent({
name: 'Primitive',
inheritAttrs: false,
props: {
as: {
type: [String, Object, Function] as unknown as () => string | Component,
default: 'div',
},
asChild: { type: Boolean, default: false },
},
setup(props, { slots, attrs }) {
return () => {
const Tag = props.asChild ? Slot : props.as
return h(Tag, attrs, slots)
}
},
})

View file

@ -0,0 +1,28 @@
import type { VNode } from 'vue'
import { Fragment, cloneVNode, defineComponent, mergeProps } from 'vue'
// `<slot />` used as a passthrough outlet resolves through Vue's `renderSlot()`
// helper, which always wraps the forwarded content in a Fragment vnode (for
// diffing), even when there's exactly one real child inside. Unwrap it so we
// clone the actual element/component vnode instead of the inert wrapper.
function unwrapFragment(vnode: VNode): VNode {
if (vnode.type === Fragment && Array.isArray(vnode.children) && vnode.children.length === 1) {
return unwrapFragment(vnode.children[0] as VNode)
}
return vnode
}
export default defineComponent({
name: 'Slot',
inheritAttrs: false,
setup(_, { slots, attrs }) {
return () => {
const children = slots.default?.() ?? []
if (children.length !== 1) {
throw new Error('Slot requires exactly one child element')
}
const child = unwrapFragment(children[0] as VNode)
return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record<string, unknown>))
}
},
})

View file

@ -0,0 +1,67 @@
import type { Ref } from 'vue'
import { onUnmounted, watch } from 'vue'
export interface DismissableLayerOptions {
onDismiss: () => void
onPointerDownOutside?: (event: PointerEvent) => void
}
// Stack of currently-active layers (dialogs, popovers, etc.), bottom to top.
// Only the topmost layer reacts to Escape/outside-pointerdown, so opening a
// nested layer (e.g. a dialog on top of another dialog) can't dismiss layers
// beneath it.
const layerStack: symbol[] = []
export function useDismissableLayer(
containerRef: Ref<HTMLElement | null>,
active: Ref<boolean>,
options: DismissableLayerOptions,
) {
const layerId = Symbol('dismissable-layer')
function isTopmost() {
return layerStack[layerStack.length - 1] === layerId
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (!isTopmost()) return
event.preventDefault()
options.onDismiss()
}
function handlePointerDown(event: PointerEvent) {
if (!isTopmost()) return
const container = containerRef.value
if (!container) return
if (container.contains(event.target as Node)) return
options.onPointerDownOutside?.(event)
if (event.defaultPrevented) return
options.onDismiss()
}
function attach() {
layerStack.push(layerId)
document.addEventListener('keydown', handleKeydown)
document.addEventListener('pointerdown', handlePointerDown)
}
function detach() {
const index = layerStack.indexOf(layerId)
if (index !== -1) layerStack.splice(index, 1)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('pointerdown', handlePointerDown)
}
watch(
active,
(isActive) => {
if (isActive) attach()
else detach()
},
{ immediate: true },
)
onUnmounted(detach)
}

View file

@ -0,0 +1,81 @@
import type { Ref } from 'vue'
import { nextTick, onUnmounted, watch } from 'vue'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}
export interface FocusTrapOptions {
initialFocus?: () => HTMLElement | null
}
export function useFocusTrap(
containerRef: Ref<HTMLElement | null>,
active: Ref<boolean>,
options: FocusTrapOptions = {},
) {
let previouslyFocused: HTMLElement | null = null
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const container = containerRef.value
if (!container) return
const focusable = getFocusable(container)
if (focusable.length === 0) {
event.preventDefault()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
const current = document.activeElement
if (event.shiftKey) {
if (current === first || !container.contains(current)) {
event.preventDefault()
last.focus()
}
} else {
if (current === last || !container.contains(current)) {
event.preventDefault()
first.focus()
}
}
}
watch(
active,
(isActive) => {
if (isActive) {
previouslyFocused = document.activeElement as HTMLElement | null
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
const container = containerRef.value
if (!container) return
if (container.contains(document.activeElement)) return
const target = options.initialFocus?.() ?? getFocusable(container)[0] ?? container
target?.focus()
})
} else {
document.removeEventListener('keydown', handleKeydown)
previouslyFocused?.focus?.()
previouslyFocused = null
}
},
{ immediate: true },
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
}

View file

@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import { nextTick, onUnmounted, reactive, watch } from 'vue'
export interface PopoverPosition {
top: number
left: number
minWidth: number
maxHeight: number
side: 'top' | 'bottom'
}
const GAP = 4
const VIEWPORT_MARGIN = 8
export function usePopoverPosition(
triggerRef: Ref<HTMLElement | null>,
contentRef: Ref<HTMLElement | null>,
open: Ref<boolean>,
) {
const position = reactive<PopoverPosition>({ top: 0, left: 0, minWidth: 0, maxHeight: 0, side: 'bottom' })
function update() {
const trigger = triggerRef.value
const content = contentRef.value
if (!trigger || !content) return
const triggerRect = trigger.getBoundingClientRect()
const contentHeight = content.offsetHeight
const spaceBelow = window.innerHeight - triggerRect.bottom - VIEWPORT_MARGIN
const spaceAbove = triggerRect.top - VIEWPORT_MARGIN
const placeAbove = spaceBelow < contentHeight && spaceAbove > spaceBelow
position.side = placeAbove ? 'top' : 'bottom'
position.left = Math.min(
Math.max(triggerRect.left, VIEWPORT_MARGIN),
window.innerWidth - triggerRect.width - VIEWPORT_MARGIN,
)
position.minWidth = triggerRect.width
position.maxHeight = Math.max(placeAbove ? spaceAbove : spaceBelow, 0)
position.top = placeAbove
? triggerRect.top - GAP - Math.min(contentHeight, position.maxHeight)
: triggerRect.bottom + GAP
}
function handleReposition() {
if (open.value) update()
}
watch(
open,
(isOpen) => {
if (isOpen) {
nextTick(update)
window.addEventListener('scroll', handleReposition, true)
window.addEventListener('resize', handleReposition)
} else {
window.removeEventListener('scroll', handleReposition, true)
window.removeEventListener('resize', handleReposition)
}
},
{ immediate: true },
)
onUnmounted(() => {
window.removeEventListener('scroll', handleReposition, true)
window.removeEventListener('resize', handleReposition)
})
return position
}

View file

@ -0,0 +1,44 @@
import type { Ref } from 'vue'
export interface RovingFocusOptions {
orientation?: 'vertical' | 'horizontal'
loop?: boolean
}
export function useRovingFocus(itemsRef: Ref<HTMLElement[]>, options: RovingFocusOptions = {}) {
const orientation = options.orientation ?? 'vertical'
const loop = options.loop ?? true
function focusIndex(index: number) {
const items = itemsRef.value
if (items.length === 0) return
const clamped = loop
? ((index % items.length) + items.length) % items.length
: Math.max(0, Math.min(index, items.length - 1))
items[clamped]?.focus()
}
function handleKeydown(event: KeyboardEvent) {
const items = itemsRef.value
if (items.length === 0) return
const currentIndex = items.indexOf(document.activeElement as HTMLElement)
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
if (event.key === nextKey) {
event.preventDefault()
focusIndex(currentIndex + 1)
} else if (event.key === prevKey) {
event.preventDefault()
focusIndex(currentIndex - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusIndex(0)
} else if (event.key === 'End') {
event.preventDefault()
focusIndex(items.length - 1)
}
}
return { handleKeydown, focusIndex }
}

View file

@ -0,0 +1,7 @@
import type { ClassValue } from "clsx"
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Alert, AlertTitle, AlertDescription } from '../src/lib'
describe('Alert', () => {
it('defaults to the info variant', () => {
const wrapper = mount(Alert)
expect(wrapper.classes().join(' ')).toContain('bg-info/15')
})
it('applies the danger variant class', () => {
const wrapper = mount(Alert, { props: { variant: 'danger' } })
expect(wrapper.classes().join(' ')).toContain('bg-destructive/15')
})
it('renders title and description slots', () => {
const wrapper = mount(Alert, {
slots: {
default: () => [
h(AlertTitle, undefined, { default: () => 'The Oracle has spoken.' }),
h(AlertDescription, undefined, { default: () => 'Heed the omen.' }),
],
},
})
expect(wrapper.text()).toContain('The Oracle has spoken.')
expect(wrapper.text()).toContain('Heed the omen.')
})
it('does not render a close button unless closable is set', () => {
const wrapper = mount(Alert)
expect(wrapper.find('button').exists()).toBe(false)
})
it('emits close when the close button is clicked', async () => {
const wrapper = mount(Alert, { props: { closable: true } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
})

View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Badge } from '../src/lib'
describe('Badge', () => {
it('defaults to the primary variant', () => {
const wrapper = mount(Badge, { slots: { default: 'Sacred' } })
expect(wrapper.classes().join(' ')).toContain('bg-primary')
})
it.each(['secondary', 'info', 'success', 'warning', 'danger'] as const)(
'applies the %s variant class',
(variant) => {
const wrapper = mount(Badge, { props: { variant } })
const expectedClass = variant === 'danger' ? 'bg-destructive' : `bg-${variant}`
expect(wrapper.classes().join(' ')).toContain(expectedClass)
},
)
})

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Button } from '../src/lib'
describe('Button', () => {
it('defaults to the primary variant', () => {
const wrapper = mount(Button, { slots: { default: 'Click' } })
expect(wrapper.classes().join(' ')).toContain('bg-primary')
})
it('applies the danger variant class', () => {
const wrapper = mount(Button, { props: { variant: 'danger' } })
expect(wrapper.classes().join(' ')).toContain('bg-destructive')
})
it('auto-disables when loading is true', () => {
const wrapper = mount(Button, { props: { loading: true } })
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('renders the loading icon when loading is true', () => {
const wrapper = mount(Button, { props: { loading: true } })
expect(wrapper.find('svg.nych-loading-icon').exists()).toBe(true)
})
it('stays enabled when loading is false', () => {
const wrapper = mount(Button)
expect(wrapper.attributes('disabled')).toBeUndefined()
})
})

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../src/lib'
describe('Card', () => {
it('renders header, title, description, content, and footer together', () => {
const wrapper = mount(Card, {
slots: {
default: () => [
h(CardHeader, undefined, {
default: () => [
h(CardTitle, undefined, { default: () => 'Elysium' }),
h(CardDescription, undefined, { default: () => 'The blessed fields' }),
],
}),
h(CardContent, undefined, { default: () => 'A resting place for the virtuous.' }),
h(CardFooter, undefined, { default: () => 'Enter' }),
],
},
})
expect(wrapper.text()).toContain('Elysium')
expect(wrapper.text()).toContain('The blessed fields')
expect(wrapper.text()).toContain('A resting place for the virtuous.')
expect(wrapper.text()).toContain('Enter')
})
it('exposes a data-slot attribute for styling hooks', () => {
const wrapper = mount(Card)
expect(wrapper.attributes('data-slot')).toBe('card')
})
it('resolves size classes through cva instead of data-[size=] selectors', () => {
const wrapper = mount(Card, { props: { size: 'sm' } })
const classes = wrapper.classes()
expect(classes).toContain('gap-3')
expect(classes).toContain('py-3')
expect(classes.some((c) => c.includes('data-[size='))).toBe(false)
})
})

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Checkbox } from '../src/lib'
describe('Checkbox', () => {
it('renders unchecked by default', () => {
const wrapper = mount(Checkbox)
expect(wrapper.get('button').attributes('data-state')).toBe('unchecked')
})
it('reflects modelValue as checked', () => {
const wrapper = mount(Checkbox, { props: { modelValue: true } })
expect(wrapper.get('button').attributes('data-state')).toBe('checked')
})
it('emits update:modelValue on click', async () => {
const wrapper = mount(Checkbox, { props: { modelValue: false } })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
})
})

View file

@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { CheckboxGroup } from '../src/lib'
describe('CheckboxGroup', () => {
it('renders as a group container', () => {
const wrapper = mount(CheckboxGroup, { slots: { default: '<span>child</span>' } })
expect(wrapper.attributes('role')).toBe('group')
expect(wrapper.html()).toContain('<span>child</span>')
})
})

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { h, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { Dialog, DialogContent, DialogTitle } from '../src/lib'
describe('Dialog', () => {
it('does not render content when closed', async () => {
mount(Dialog, {
props: { open: false },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
expect(document.body.textContent).not.toContain('Oracle of Delphi')
})
it('renders content in a teleported portal when open', async () => {
mount(Dialog, {
props: { open: true },
slots: {
default: () => h(DialogContent, undefined, {
default: () => h(DialogTitle, undefined, { default: () => 'Oracle of Delphi' }),
}),
},
attachTo: document.body,
})
await nextTick()
await nextTick()
expect(document.body.textContent).toContain('Oracle of Delphi')
})
})

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Input } from '../src/lib'
describe('Input', () => {
it('renders a native input element', () => {
const wrapper = mount(Input)
expect(wrapper.element.tagName).toBe('INPUT')
})
it('supports v-model', async () => {
const wrapper = mount(Input, { props: { modelValue: 'Styx' } })
expect((wrapper.element as HTMLInputElement).value).toBe('Styx')
await wrapper.setValue('Lethe')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['Lethe'])
})
it('applies the disabled attribute', () => {
const wrapper = mount(Input, { attrs: { disabled: true } })
expect(wrapper.attributes('disabled')).toBeDefined()
})
})

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { RadioGroup, RadioGroupItem } from '../src/lib'
describe('RadioGroup', () => {
it('marks the item matching modelValue as checked', () => {
const wrapper = mount(RadioGroup, {
props: { modelValue: 'hades' },
slots: {
default: () => [
h(RadioGroupItem, { value: 'apollo' }),
h(RadioGroupItem, { value: 'hades' }),
],
},
})
const items = wrapper.findAllComponents(RadioGroupItem)
expect(items[0]?.get('button').attributes('data-state')).toBe('unchecked')
expect(items[1]?.get('button').attributes('data-state')).toBe('checked')
})
it('emits update:modelValue when an item is selected', async () => {
const wrapper = mount(RadioGroup, {
props: { modelValue: 'apollo' },
slots: {
default: () => [h(RadioGroupItem, { value: 'hades' })],
},
})
await wrapper.findComponent(RadioGroupItem).get('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['hades'])
})
})

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../src/lib'
const RIVERS = ['Styx', 'Acheron', 'Cocytus']
function mountSelect(modelValue: string | undefined) {
return mount(Select, {
props: { modelValue },
slots: {
default: () => [
h(SelectTrigger, undefined, {
default: () => h(SelectValue, { placeholder: 'Choose a river' }),
}),
h(SelectContent, undefined, {
default: () => RIVERS.map((r) => h(SelectItem, { value: r }, { default: () => r })),
}),
],
},
attachTo: document.body,
})
}
describe('Select', () => {
it('renders the trigger with the placeholder when no value is selected', () => {
const wrapper = mountSelect(undefined)
expect(wrapper.text()).toContain('Choose a river')
})
it('shows the selected value in the trigger once the item has rendered', async () => {
const wrapper = mountSelect('Styx')
// SelectValue resolves its display label from the matching SelectItem, which only
// mounts once the dropdown has opened at least once.
await wrapper.get('button[role="combobox"]').trigger('click')
expect(wrapper.text()).toContain('Styx')
})
})

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Switch } from '../src/lib'
describe('Switch', () => {
it('renders unchecked by default', () => {
const wrapper = mount(Switch)
expect(wrapper.get('button').attributes('data-state')).toBe('unchecked')
})
it('reflects modelValue as checked', () => {
const wrapper = mount(Switch, { props: { modelValue: true } })
expect(wrapper.get('button').attributes('data-state')).toBe('checked')
})
it('emits update:modelValue on click', async () => {
const wrapper = mount(Switch, { props: { modelValue: false } })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
})
})

View file

@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Textarea } from '../src/lib'
describe('Textarea', () => {
it('renders a native textarea element', () => {
const wrapper = mount(Textarea)
expect(wrapper.element.tagName).toBe('TEXTAREA')
})
it('supports v-model', async () => {
const wrapper = mount(Textarea, { props: { modelValue: 'An omen' } })
expect((wrapper.element as HTMLTextAreaElement).value).toBe('An omen')
await wrapper.setValue('A crow')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['A crow'])
})
})

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import { Primitive } from '@/lib/primitive'
defineProps<{ asChild?: boolean }>()
</script>
<template>
<Primitive as="button" :as-child="asChild" type="button" data-slot="passthrough-wrapper">
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue'
import { createNychthemeron, LoadingIcon } from '../src/index'
describe('createNychthemeron', () => {
it('returns a Vue plugin with install method', () => {
const plugin = createNychthemeron()
expect(typeof plugin.install).toBe('function')
})
it('install registers NychButton and NychLoadingIcon globally', () => {
const plugin = createNychthemeron()
const mockApp = { component: vi.fn() } as unknown as App
plugin.install(mockApp)
expect(mockApp.component).toHaveBeenCalledWith('NychButton', expect.anything())
expect(mockApp.component).toHaveBeenCalledWith('NychLoadingIcon', expect.anything())
})
it('exposes Button and LoadingIcon components', () => {
const { Button: btn, LoadingIcon: icon } = createNychthemeron()
expect(btn).toBeDefined()
expect(icon).toBeDefined()
})
})
describe('LoadingIcon', () => {
it('is a valid Vue component', () => {
expect(LoadingIcon).toBeDefined()
})
})

View file

@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { Primitive } from '../src/lib/primitive'
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
describe('Primitive', () => {
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
const wrapper = mount(Primitive, {
props: { as: 'a' },
attrs: { href: '/somewhere', class: 'link' },
slots: { default: () => 'Go' },
})
const el = wrapper.get('a')
expect(el.attributes('href')).toBe('/somewhere')
expect(el.classes()).toContain('link')
expect(el.text()).toBe('Go')
})
it('clones the single child and merges attrs onto it when asChild is true', async () => {
let clicked = false
const wrapper = mount(Primitive, {
props: { asChild: true },
attrs: { class: 'from-parent', 'data-slot': 'button', onClick: () => { clicked = true } },
slots: {
default: () => h('button', { class: 'from-child', type: 'button' }, 'Click'),
},
})
const el = wrapper.get('button')
expect(el.attributes('data-slot')).toBe('button')
expect(el.attributes('type')).toBe('button')
expect(el.classes()).toContain('from-parent')
expect(el.classes()).toContain('from-child')
await el.trigger('click')
expect(clicked).toBe(true)
})
it('throws if asChild is true with zero or multiple children', () => {
expect(() =>
mount(Primitive, {
props: { asChild: true },
slots: { default: () => [h('span', 'a'), h('span', 'b')] },
}),
).toThrow()
})
it('merges attrs through a real SFC that forwards `<slot />` as asChild content', async () => {
// Regression test: `<slot />` used as a passthrough outlet inside an SFC
// template resolves through Vue's renderSlot() helper, which wraps the
// forwarded content in a Fragment vnode even when there's exactly one
// real child. Slot must unwrap that Fragment before cloning, or the
// merged attrs silently vanish onto the inert wrapper instead of the
// real child element.
let clicked = false
const wrapper = mount(SlotPassthrough, {
props: { asChild: true },
attrs: { onClick: () => { clicked = true } },
slots: {
default: () => h('a', { href: '/somewhere' }, 'Click'),
},
})
const el = wrapper.get('a')
expect(el.attributes('data-slot')).toBe('passthrough-wrapper')
expect(el.attributes('type')).toBe('button')
expect(el.attributes('href')).toBe('/somewhere')
await el.trigger('click')
expect(clicked).toBe(true)
})
})

Some files were not shown because too many files have changed in this diff Show more