Use ShadCN
This commit is contained in:
commit
e2f140af78
130 changed files with 7256 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
8
.editorconfig
Normal 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
112
.forgejo/workflows/ci.yml
Normal 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal 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
10
.oxlintrc.json
Normal 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
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal 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
133
README.md
Normal 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
|
||||
3
env.d.ts
vendored
Normal file
3
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css' {}
|
||||
43
eslint.config.ts
Normal file
43
eslint.config.ts
Normal 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
50
package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
26
packages/library/components.json
Normal file
26
packages/library/components.json
Normal 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": {}
|
||||
}
|
||||
56
packages/library/package.json
Normal file
56
packages/library/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
207
packages/library/src/assets/css/tailwind.css
Normal file
207
packages/library/src/assets/css/tailwind.css
Normal 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);
|
||||
}
|
||||
70
packages/library/src/components/NychLoadingIcon.vue
Normal file
70
packages/library/src/components/NychLoadingIcon.vue
Normal 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>
|
||||
36
packages/library/src/components/index.ts
Normal file
36
packages/library/src/components/index.ts
Normal 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'
|
||||
32
packages/library/src/components/ui/alert/Alert.vue
Normal file
32
packages/library/src/components/ui/alert/Alert.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/alert/AlertAction.vue
Normal file
17
packages/library/src/components/ui/alert/AlertAction.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
17
packages/library/src/components/ui/alert/AlertTitle.vue
Normal file
17
packages/library/src/components/ui/alert/AlertTitle.vue
Normal 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>
|
||||
26
packages/library/src/components/ui/alert/index.ts
Normal file
26
packages/library/src/components/ui/alert/index.ts
Normal 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>
|
||||
19
packages/library/src/components/ui/badge/Badge.vue
Normal file
19
packages/library/src/components/ui/badge/Badge.vue
Normal 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>
|
||||
24
packages/library/src/components/ui/badge/index.ts
Normal file
24
packages/library/src/components/ui/badge/index.ts
Normal 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>
|
||||
41
packages/library/src/components/ui/button/Button.vue
Normal file
41
packages/library/src/components/ui/button/Button.vue
Normal 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>
|
||||
31
packages/library/src/components/ui/button/index.ts
Normal file
31
packages/library/src/components/ui/button/index.ts
Normal 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>
|
||||
23
packages/library/src/components/ui/card/Card.vue
Normal file
23
packages/library/src/components/ui/card/Card.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardAction.vue
Normal file
17
packages/library/src/components/ui/card/CardAction.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardContent.vue
Normal file
17
packages/library/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardDescription.vue
Normal file
17
packages/library/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardFooter.vue
Normal file
17
packages/library/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardHeader.vue
Normal file
17
packages/library/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/card/CardTitle.vue
Normal file
17
packages/library/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
26
packages/library/src/components/ui/card/index.ts
Normal file
26
packages/library/src/components/ui/card/index.ts
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CheckboxGroup } from './CheckboxGroup.vue'
|
||||
export const CHECKBOX_GROUP_NAME_KEY = 'nych-checkbox-group-name'
|
||||
55
packages/library/src/components/ui/checkbox/Checkbox.vue
Normal file
55
packages/library/src/components/ui/checkbox/Checkbox.vue
Normal 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>
|
||||
1
packages/library/src/components/ui/checkbox/index.ts
Normal file
1
packages/library/src/components/ui/checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
23
packages/library/src/components/ui/dialog/Dialog.vue
Normal file
23
packages/library/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
21
packages/library/src/components/ui/dialog/DialogClose.vue
Normal file
21
packages/library/src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
63
packages/library/src/components/ui/dialog/DialogContent.vue
Normal file
63
packages/library/src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
27
packages/library/src/components/ui/dialog/DialogFooter.vue
Normal file
27
packages/library/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
17
packages/library/src/components/ui/dialog/DialogHeader.vue
Normal file
17
packages/library/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
22
packages/library/src/components/ui/dialog/DialogOverlay.vue
Normal file
22
packages/library/src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
19
packages/library/src/components/ui/dialog/DialogTitle.vue
Normal file
19
packages/library/src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
23
packages/library/src/components/ui/dialog/DialogTrigger.vue
Normal file
23
packages/library/src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
9
packages/library/src/components/ui/dialog/context.ts
Normal file
9
packages/library/src/components/ui/dialog/context.ts
Normal 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')
|
||||
10
packages/library/src/components/ui/dialog/index.ts
Normal file
10
packages/library/src/components/ui/dialog/index.ts
Normal 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"
|
||||
31
packages/library/src/components/ui/input/Input.vue
Normal file
31
packages/library/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
packages/library/src/components/ui/input/index.ts
Normal file
1
packages/library/src/components/ui/input/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Input } from "./Input.vue"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
11
packages/library/src/components/ui/radio-group/context.ts
Normal file
11
packages/library/src/components/ui/radio-group/context.ts
Normal 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')
|
||||
2
packages/library/src/components/ui/radio-group/index.ts
Normal file
2
packages/library/src/components/ui/radio-group/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as RadioGroup } from "./RadioGroup.vue"
|
||||
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
|
||||
45
packages/library/src/components/ui/select/Select.vue
Normal file
45
packages/library/src/components/ui/select/Select.vue
Normal 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>
|
||||
121
packages/library/src/components/ui/select/SelectContent.vue
Normal file
121
packages/library/src/components/ui/select/SelectContent.vue
Normal 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>
|
||||
12
packages/library/src/components/ui/select/SelectGroup.vue
Normal file
12
packages/library/src/components/ui/select/SelectGroup.vue
Normal 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>
|
||||
64
packages/library/src/components/ui/select/SelectItem.vue
Normal file
64
packages/library/src/components/ui/select/SelectItem.vue
Normal 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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<span data-slot="select-item-text"><slot /></span>
|
||||
</template>
|
||||
12
packages/library/src/components/ui/select/SelectLabel.vue
Normal file
12
packages/library/src/components/ui/select/SelectLabel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
49
packages/library/src/components/ui/select/SelectTrigger.vue
Normal file
49
packages/library/src/components/ui/select/SelectTrigger.vue
Normal 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>
|
||||
16
packages/library/src/components/ui/select/SelectValue.vue
Normal file
16
packages/library/src/components/ui/select/SelectValue.vue
Normal 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>
|
||||
13
packages/library/src/components/ui/select/context.ts
Normal file
13
packages/library/src/components/ui/select/context.ts
Normal 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')
|
||||
11
packages/library/src/components/ui/select/index.ts
Normal file
11
packages/library/src/components/ui/select/index.ts
Normal 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"
|
||||
56
packages/library/src/components/ui/switch/Switch.vue
Normal file
56
packages/library/src/components/ui/switch/Switch.vue
Normal 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>
|
||||
1
packages/library/src/components/ui/switch/index.ts
Normal file
1
packages/library/src/components/ui/switch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Switch } from "./Switch.vue"
|
||||
28
packages/library/src/components/ui/textarea/Textarea.vue
Normal file
28
packages/library/src/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
1
packages/library/src/components/ui/textarea/index.ts
Normal file
1
packages/library/src/components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Textarea } from "./Textarea.vue"
|
||||
2
packages/library/src/composables/index.ts
Normal file
2
packages/library/src/composables/index.ts
Normal 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
3
packages/library/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
declare module '@nychthemeron/library/style'
|
||||
declare module '@nychthemeron/library/theme'
|
||||
declare module '@nychthemeron/library'
|
||||
42
packages/library/src/index.ts
Normal file
42
packages/library/src/index.ts
Normal 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'
|
||||
217
packages/library/src/lib/index.ts
Normal file
217
packages/library/src/lib/index.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
21
packages/library/src/lib/primitive.ts
Normal file
21
packages/library/src/lib/primitive.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
28
packages/library/src/lib/slot.ts
Normal file
28
packages/library/src/lib/slot.ts
Normal 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>))
|
||||
}
|
||||
},
|
||||
})
|
||||
67
packages/library/src/lib/use-dismissable-layer.ts
Normal file
67
packages/library/src/lib/use-dismissable-layer.ts
Normal 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)
|
||||
}
|
||||
81
packages/library/src/lib/use-focus-trap.ts
Normal file
81
packages/library/src/lib/use-focus-trap.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
70
packages/library/src/lib/use-popover-position.ts
Normal file
70
packages/library/src/lib/use-popover-position.ts
Normal 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
|
||||
}
|
||||
44
packages/library/src/lib/use-roving-focus.ts
Normal file
44
packages/library/src/lib/use-roving-focus.ts
Normal 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 }
|
||||
}
|
||||
7
packages/library/src/lib/utils.ts
Normal file
7
packages/library/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
40
packages/library/tests/Alert.spec.ts
Normal file
40
packages/library/tests/Alert.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
19
packages/library/tests/Badge.spec.ts
Normal file
19
packages/library/tests/Badge.spec.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
})
|
||||
30
packages/library/tests/Button.spec.ts
Normal file
30
packages/library/tests/Button.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
40
packages/library/tests/Card.spec.ts
Normal file
40
packages/library/tests/Card.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
packages/library/tests/Checkbox.spec.ts
Normal file
21
packages/library/tests/Checkbox.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
11
packages/library/tests/CheckboxGroup.spec.ts
Normal file
11
packages/library/tests/CheckboxGroup.spec.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
35
packages/library/tests/Dialog.spec.ts
Normal file
35
packages/library/tests/Dialog.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
23
packages/library/tests/Input.spec.ts
Normal file
23
packages/library/tests/Input.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
32
packages/library/tests/RadioGroup.spec.ts
Normal file
32
packages/library/tests/RadioGroup.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
38
packages/library/tests/Select.spec.ts
Normal file
38
packages/library/tests/Select.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
21
packages/library/tests/Switch.spec.ts
Normal file
21
packages/library/tests/Switch.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
18
packages/library/tests/Textarea.spec.ts
Normal file
18
packages/library/tests/Textarea.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal 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>
|
||||
30
packages/library/tests/lib.spec.ts
Normal file
30
packages/library/tests/lib.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
69
packages/library/tests/primitive.spec.ts
Normal file
69
packages/library/tests/primitive.spec.ts
Normal 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
Loading…
Reference in a new issue