Inital Commit
This commit is contained in:
commit
5dae44a4da
138 changed files with 8770 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
packages/library/src/assets/css/nychthemeron.css
Normal file
169
packages/library/src/assets/css/nychthemeron.css
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/* ============================================================
|
||||||
|
nychthemeron.css
|
||||||
|
Toggle themes by setting data-theme on <html>:
|
||||||
|
<html data-theme="hades">
|
||||||
|
<html data-theme="apollo">
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
/* ── HADES ───────────────────────────────────────────────── */
|
||||||
|
[data-theme='hades'] {
|
||||||
|
/* surfaces */
|
||||||
|
--surface-0: #18160f;
|
||||||
|
--surface-1: #28241a;
|
||||||
|
--surface-2: #2e2a1e;
|
||||||
|
--surface-3: #322e22;
|
||||||
|
--track: #32302a;
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border: #4a4538;
|
||||||
|
--border-hi: #5a5040;
|
||||||
|
--border-lo: #38342a;
|
||||||
|
|
||||||
|
/* primary */
|
||||||
|
--primary: #d4b278;
|
||||||
|
--primary-fg: #0e0c08;
|
||||||
|
|
||||||
|
/* semantic */
|
||||||
|
--warn: #e08840; /* phlegethon */
|
||||||
|
--warn-subtle: #281808;
|
||||||
|
--warn-border: #4a2c10;
|
||||||
|
--warn-fg: #fff8e8;
|
||||||
|
|
||||||
|
--danger: #7a1a1a; /* plague */
|
||||||
|
--danger-subtle: #200808;
|
||||||
|
--danger-border: #3a1010;
|
||||||
|
--danger-fg: #fff8e8;
|
||||||
|
|
||||||
|
--info: #88aec8; /* cocytus */
|
||||||
|
--info-subtle: #101e2c;
|
||||||
|
--info-border: #1e3850;
|
||||||
|
--info-fg: #0a1828;
|
||||||
|
|
||||||
|
--neutral: #7a6e60; /* acheron */
|
||||||
|
--neutral-fg: #fff8e8;
|
||||||
|
|
||||||
|
--success: #6aaa7a; /* laurel */
|
||||||
|
--success-subtle: #0e2214;
|
||||||
|
--success-border: #1e4228;
|
||||||
|
--success-fg: #fff8e8;
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--text-high: #f4f0e8;
|
||||||
|
--text-body: #d0c8b8;
|
||||||
|
--text-muted: #908878;
|
||||||
|
--text-dim: #807868;
|
||||||
|
--text-label: #c0b8a8;
|
||||||
|
|
||||||
|
/* focus ring */
|
||||||
|
--focus-ring: 0 0 0 3px rgba(212, 178, 120, 0.65);
|
||||||
|
|
||||||
|
/* fonts */
|
||||||
|
--font-serif: 'Cinzel', Georgia, serif;
|
||||||
|
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── APOLLO ──────────────────────────────────────────────── */
|
||||||
|
[data-theme='apollo'] {
|
||||||
|
/* surfaces */
|
||||||
|
--surface-0: #faf7f2;
|
||||||
|
--surface-1: #f4f0e8;
|
||||||
|
--surface-2: #eee9df;
|
||||||
|
--surface-3: #e4ddd0;
|
||||||
|
--track: #e4ddd0;
|
||||||
|
|
||||||
|
/* borders */
|
||||||
|
--border: #bab2a0;
|
||||||
|
--border-hi: #c0b8a8;
|
||||||
|
--border-lo: #ccc4b4;
|
||||||
|
|
||||||
|
/* primary */
|
||||||
|
--primary: #b8860b;
|
||||||
|
--primary-fg: #fff8e8;
|
||||||
|
|
||||||
|
/* semantic */
|
||||||
|
--warn: #9a6020; /* harmony */
|
||||||
|
--warn-subtle: #fdf3e0;
|
||||||
|
--warn-border: #e8d0a0;
|
||||||
|
--warn-fg: #fff8e8;
|
||||||
|
|
||||||
|
--danger: #7a1a1a; /* plague */
|
||||||
|
--danger-subtle: #faeaea;
|
||||||
|
--danger-border: #c8a0a0;
|
||||||
|
--danger-fg: #fff8e8;
|
||||||
|
|
||||||
|
--info: #3a5a8a; /* oracle */
|
||||||
|
--info-subtle: #e8eef8;
|
||||||
|
--info-border: #b0c4e0;
|
||||||
|
--info-fg: #fff8e8;
|
||||||
|
|
||||||
|
--neutral: #8a8070; /* marble */
|
||||||
|
--neutral-fg: #fff8e8;
|
||||||
|
|
||||||
|
--success: #5a7a40; /* laurel */
|
||||||
|
--success-subtle: #eef4e8;
|
||||||
|
--success-border: #c0d4a8;
|
||||||
|
--success-fg: #fff8e8;
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--text-high: #1a1610;
|
||||||
|
--text-body: #3a3020;
|
||||||
|
--text-muted: #5a5040;
|
||||||
|
--text-dim: #8a8070;
|
||||||
|
--text-label: #5a5040;
|
||||||
|
|
||||||
|
/* focus ring */
|
||||||
|
--focus-ring: 0 0 0 3px rgba(184, 134, 11, 0.65);
|
||||||
|
|
||||||
|
/* fonts */
|
||||||
|
--font-serif: 'Cinzel', Georgia, serif;
|
||||||
|
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── BASE RESET ──────────────────────────────────────────── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'],
|
||||||
|
[class^='nych-']::before,
|
||||||
|
[class^='nych-']::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-']:focus-visible {
|
||||||
|
outline: 2px solid transparent; /* visible in forced-colors / Windows High Contrast */
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--surface-0);
|
||||||
|
color: var(--text-body);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
727
packages/library/src/assets/theme/theme.css
Normal file
727
packages/library/src/assets/theme/theme.css
Normal file
|
|
@ -0,0 +1,727 @@
|
||||||
|
@import url('../css/nychthemeron.css');
|
||||||
|
|
||||||
|
/* Restore serif font for nych- components even if consumer overrides html font-family */
|
||||||
|
[class^='nych-'] {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-']:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-button'] {
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 0px 10px var(--text-high);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-button']:hover:not(:disabled) {
|
||||||
|
box-shadow: 0px 0px 8px var(--text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-button'][data-p~='loading'] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-button'][data-p~='loading'] span {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-primary'] {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-secondary'],
|
||||||
|
[class^='nych-'][class$='-muted'] {
|
||||||
|
background-color: var(--neutral);
|
||||||
|
color: var(--neutral-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-info'] {
|
||||||
|
background-color: var(--info);
|
||||||
|
color: var(--info-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-success'] {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: var(--success-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-warning'] {
|
||||||
|
background-color: var(--warn);
|
||||||
|
color: var(--warn-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][class$='-danger'],
|
||||||
|
[class^='nych-'][class$='-error'] {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: var(--danger-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-loading-icon .wreath path {
|
||||||
|
fill: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-loading-icon .wreath line {
|
||||||
|
stroke: var(--text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-p~='loading'] > .nych-loading-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-top: 0px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][data-p~='small'] :not(svg, svg *) {
|
||||||
|
height: 15px !important;
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][data-p~='normal'] :not(svg, svg *) {
|
||||||
|
height: 25px !important;
|
||||||
|
font-size: 12pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^='nych-'][data-p~='large'] :not(svg, svg *) {
|
||||||
|
height: 40px !important;
|
||||||
|
font-size: 20pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Text inputs — InputText, Textarea
|
||||||
|
============================================================ */
|
||||||
|
.nych-input,
|
||||||
|
.nych-textarea {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 11px;
|
||||||
|
color: var(--text-body);
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input::placeholder,
|
||||||
|
.nych-textarea::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input:hover:not(:disabled),
|
||||||
|
.nych-textarea:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input:focus,
|
||||||
|
.nych-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input[data-p~='invalid'],
|
||||||
|
.nych-textarea[data-p~='invalid'] {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input[data-p~='small'] {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-input[data-p~='large'] {
|
||||||
|
padding: 11px 13px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 84px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Checkbox
|
||||||
|
============================================================ */
|
||||||
|
.nych-checkbox,
|
||||||
|
.nych-radio {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox-input,
|
||||||
|
.nych-radio-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox-box,
|
||||||
|
.nych-radio-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox-box {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-radio-box {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox:hover .nych-checkbox-box,
|
||||||
|
.nych-radio:hover .nych-radio-box {
|
||||||
|
border-color: var(--border-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox[data-p-checked='true'] .nych-checkbox-box {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox-icon {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
color: var(--primary-fg);
|
||||||
|
fill: var(--primary-fg);
|
||||||
|
stroke: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox[data-p-disabled='true'] .nych-checkbox-box,
|
||||||
|
.nych-radio[data-p-disabled='true'] .nych-radio-box {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group containers (CheckboxGroup / RadioButtonGroup) — layout only. */
|
||||||
|
.nych-checkbox-group,
|
||||||
|
.nych-radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-checkbox-input:focus-visible + .nych-checkbox-box,
|
||||||
|
.nych-radio-input:focus-visible + .nych-radio-box {
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the visually-hidden native control hidden even when disabled — the
|
||||||
|
global [class^='nych-']:disabled opacity would otherwise reveal it. */
|
||||||
|
.nych-checkbox .nych-checkbox-input,
|
||||||
|
.nych-radio .nych-radio-input,
|
||||||
|
.nych-toggleswitch .nych-toggleswitch-input {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Radio button
|
||||||
|
============================================================ */
|
||||||
|
.nych-radio-icon {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-radio[data-p-checked='true'] .nych-radio-box {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-radio[data-p-checked='true'] .nych-radio-icon {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Toggle switch
|
||||||
|
============================================================ */
|
||||||
|
.nych-toggleswitch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--track);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 3px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition:
|
||||||
|
left 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-slider {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-handle {
|
||||||
|
left: 25px;
|
||||||
|
background-color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch-input:focus-visible + .nych-toggleswitch-slider {
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-slider,
|
||||||
|
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-handle {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Select
|
||||||
|
============================================================ */
|
||||||
|
.nych-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 13rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 8px 11px;
|
||||||
|
color: var(--text-body);
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select:hover {
|
||||||
|
border-color: var(--border-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select[data-p~='focus'] {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select[data-p~='disabled'] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-label {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-label[data-p~='placeholder'] {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-dropdownIcon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-overlay {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
margin-top: 4px;
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-option {
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-option[data-p-focused='true'],
|
||||||
|
.nych-select-option:hover {
|
||||||
|
background-color: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-option[data-p-selected='true'] {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-select-emptyMessage {
|
||||||
|
padding: 8px 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Card
|
||||||
|
============================================================ */
|
||||||
|
.nych-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-header :where(img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-caption {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-subtitle {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-content {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Message
|
||||||
|
============================================================ */
|
||||||
|
.nych-message {
|
||||||
|
color: var(--text-body);
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-text {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-closeButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-closeButton:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message-closeIcon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Severity tints are mixed from the semantic colour into the surface so they
|
||||||
|
read consistently in both the dark (Hades) and light (Apollo) themes,
|
||||||
|
rather than relying on the very-dark *-subtle tokens that muddied Hades. */
|
||||||
|
.nych-message[data-p~='info'] {
|
||||||
|
background-color: color-mix(in srgb, var(--info) 15%, var(--surface-1));
|
||||||
|
border-color: color-mix(in srgb, var(--info) 30%, var(--border));
|
||||||
|
border-left-color: var(--info);
|
||||||
|
}
|
||||||
|
.nych-message[data-p~='info'] .nych-message-icon {
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message[data-p~='success'] {
|
||||||
|
background-color: color-mix(in srgb, var(--success) 15%, var(--surface-1));
|
||||||
|
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
|
||||||
|
border-left-color: var(--success);
|
||||||
|
}
|
||||||
|
.nych-message[data-p~='success'] .nych-message-icon {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message[data-p~='warn'] {
|
||||||
|
background-color: color-mix(in srgb, var(--warn) 16%, var(--surface-1));
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 32%, var(--border));
|
||||||
|
border-left-color: var(--warn);
|
||||||
|
}
|
||||||
|
.nych-message[data-p~='warn'] .nych-message-icon {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message[data-p~='error'] {
|
||||||
|
background-color: color-mix(in srgb, var(--danger) 20%, var(--surface-1));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
.nych-message[data-p~='error'] .nych-message-icon {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-message[data-p~='secondary'] {
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
border-left-color: var(--neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Tag
|
||||||
|
============================================================ */
|
||||||
|
.nych-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='rounded'] {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='secondary'] {
|
||||||
|
background-color: var(--neutral);
|
||||||
|
color: var(--neutral-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='info'] {
|
||||||
|
background-color: var(--info);
|
||||||
|
color: var(--info-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='success'] {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: var(--success-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='warn'] {
|
||||||
|
background-color: var(--warn);
|
||||||
|
color: var(--warn-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='danger'] {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: var(--danger-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-tag[data-p~='contrast'] {
|
||||||
|
background-color: var(--text-high);
|
||||||
|
color: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Dialog
|
||||||
|
============================================================ */
|
||||||
|
.nych-dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
color: var(--text-body);
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-headerActions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-headerActions button:hover {
|
||||||
|
color: var(--text-high);
|
||||||
|
background-color: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-headerActions button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-content {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-lo);
|
||||||
|
}
|
||||||
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'
|
||||||
200
packages/library/src/engine/index.ts
Normal file
200
packages/library/src/engine/index.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { h, type SetupContext } from 'vue'
|
||||||
|
|
||||||
|
export type LibComponent =
|
||||||
|
| { new (...args: unknown[]): unknown } // Constructor components
|
||||||
|
| { (...args: unknown[]): unknown } // Functional components
|
||||||
|
| Record<string, unknown> // Object components
|
||||||
|
|
||||||
|
export type Props<T> = T extends { new (...args: unknown[]): { $props: infer P } }
|
||||||
|
? P
|
||||||
|
: T extends { props: infer P }
|
||||||
|
? P
|
||||||
|
: string
|
||||||
|
|
||||||
|
export type Key<T> = T extends { new (...args: unknown[]): { $props: infer P } }
|
||||||
|
? keyof P
|
||||||
|
: T extends { props: infer P }
|
||||||
|
? keyof P
|
||||||
|
: string
|
||||||
|
|
||||||
|
// Try to extract unstyled type from component
|
||||||
|
// If extraction fails, accept any value via unknown
|
||||||
|
type ExtractUnstyled<T> = T extends { props: infer P }
|
||||||
|
? P extends { unstyled: infer U }
|
||||||
|
? U
|
||||||
|
: unknown
|
||||||
|
: T extends { new (...args: unknown[]): { $props: infer P } }
|
||||||
|
? P extends { unstyled: infer U }
|
||||||
|
? U
|
||||||
|
: unknown
|
||||||
|
: unknown
|
||||||
|
|
||||||
|
export type Themeable<
|
||||||
|
T extends LibComponent,
|
||||||
|
K extends Key<T> = Key<T>,
|
||||||
|
I extends Key<T> = Key<T>,
|
||||||
|
> = {
|
||||||
|
component: T
|
||||||
|
propMutator?: (props: Record<Key<T>, unknown>) => Record<Key<T>, unknown>
|
||||||
|
classes?: string[]
|
||||||
|
styles?: string[]
|
||||||
|
injectionKeys?: {
|
||||||
|
class?: K
|
||||||
|
style?: I
|
||||||
|
}
|
||||||
|
unstyled?: ExtractUnstyled<T>
|
||||||
|
slots?: Record<string, LibComponent>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeLibrary<
|
||||||
|
T extends Record<string, Themeable<LibComponent>> = Record<string, Themeable<LibComponent>>,
|
||||||
|
> = {
|
||||||
|
components: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThemeEngine {
|
||||||
|
readonly lib: ThemeLibrary
|
||||||
|
|
||||||
|
constructor(library: ThemeLibrary) {
|
||||||
|
this.lib = library
|
||||||
|
}
|
||||||
|
|
||||||
|
getComponent<I extends LibComponent>(key: keyof ThemeLibrary['components']): I {
|
||||||
|
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
|
||||||
|
this.lib.components[key]
|
||||||
|
|
||||||
|
// Build a MINIMAL pass-through wrapper rather than cloning the wrapped
|
||||||
|
// component. Cloning dragged along the component's `props`, `emits` and
|
||||||
|
// `extends` chain — which made Vue treat listeners like `onUpdate:modelValue`
|
||||||
|
// as the wrapper's own declared events and withhold them from `ctx.attrs`,
|
||||||
|
// silently breaking two-way `v-model`. By declaring only the engine's own
|
||||||
|
// props here, every other prop, listener and attribute falls through
|
||||||
|
// `ctx.attrs` and is forwarded verbatim to the real component below.
|
||||||
|
const componentObj: Record<string, unknown> = {
|
||||||
|
name: `Nych${String(key)}`,
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
injectedClasses: { default: classes },
|
||||||
|
injectedStyles: { default: styles },
|
||||||
|
stylePlug: { type: String },
|
||||||
|
unstyled: { default: unstyled },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyThemeProps = (props: Record<string, unknown>) => {
|
||||||
|
const mergedProps = { ...props }
|
||||||
|
|
||||||
|
const stylePlug =
|
||||||
|
typeof mergedProps['stylePlug'] === 'string' ? mergedProps['stylePlug'] : undefined
|
||||||
|
|
||||||
|
const defaultClassValues = Array.isArray(mergedProps['injectedClasses'])
|
||||||
|
? mergedProps['injectedClasses']
|
||||||
|
: []
|
||||||
|
|
||||||
|
const defaultStyleValues = Array.isArray(mergedProps['injectedStyles'])
|
||||||
|
? mergedProps['injectedStyles']
|
||||||
|
: []
|
||||||
|
|
||||||
|
const classKey = injectionKeys?.class ?? 'class'
|
||||||
|
const styleKey = injectionKeys?.style ?? 'style'
|
||||||
|
|
||||||
|
const classValue = mergedProps[classKey] ?? []
|
||||||
|
const styleValue =
|
||||||
|
typeof mergedProps[styleKey] === 'string'
|
||||||
|
? mergedProps[styleKey]
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
let classArray: string[] | undefined = undefined
|
||||||
|
|
||||||
|
if (typeof classValue === 'object') {
|
||||||
|
if (Array.isArray(classValue)) {
|
||||||
|
classArray = classValue
|
||||||
|
} else if (classKey !== 'pt') {
|
||||||
|
// Support Vue's object-form class syntax: { 'my-class': condition }
|
||||||
|
classArray = Object.entries(classValue as Record<string, boolean>)
|
||||||
|
.filter(([, active]) => Boolean(active))
|
||||||
|
.map(([name]) => name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof classValue === 'string') {
|
||||||
|
classArray = classValue.split(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classArray) {
|
||||||
|
classArray = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalClasses = stylePlug
|
||||||
|
? [...classArray, ...defaultClassValues]
|
||||||
|
.map((cls) => (cls.startsWith(`${stylePlug}-`) ? cls : `${stylePlug}-${cls}`))
|
||||||
|
.join(' ')
|
||||||
|
: [...classArray, ...defaultClassValues].join(' ')
|
||||||
|
const finalStyles = [...styleValue, ...defaultStyleValues].join('; ')
|
||||||
|
|
||||||
|
if (classKey === 'pt' || styleKey === 'pt') {
|
||||||
|
mergedProps['unstyled'] = true
|
||||||
|
const existingPt =
|
||||||
|
typeof mergedProps[classKey] === 'object'
|
||||||
|
? (mergedProps[classKey] as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const existingRoot = (existingPt.root as { class?: string; style?: string }) ?? {}
|
||||||
|
mergedProps[classKey] = {
|
||||||
|
...existingPt,
|
||||||
|
root: {
|
||||||
|
...existingRoot,
|
||||||
|
class: [existingRoot.class, finalClasses].filter(Boolean).join(' '),
|
||||||
|
style: [existingRoot.style, finalStyles].filter(Boolean).join('; '),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergedProps[classKey] = finalClasses
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styleKey !== 'pt') {
|
||||||
|
mergedProps[styleKey] = finalStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propMutator) {
|
||||||
|
// Supports both in-place mutation (returns same ref) and returning a new props object
|
||||||
|
const mutated = propMutator(mergedProps as Record<Key<typeof component>, unknown>)
|
||||||
|
if (mutated !== mergedProps) {
|
||||||
|
Object.assign(mergedProps, mutated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedProps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always wrap via a render function so the original component runs its own
|
||||||
|
// setup naturally through Vue's component system with the modified props.
|
||||||
|
// Slots are merged here so no extra wrapper component layer is needed.
|
||||||
|
componentObj.setup = (props: Record<string, unknown>, ctx: SetupContext) => {
|
||||||
|
return () => {
|
||||||
|
const themeSlots: Record<string, unknown> = {}
|
||||||
|
for (const [name, slotComponent] of Object.entries(slots || {})) {
|
||||||
|
themeSlots[name] = (scope?: Record<string, unknown>) => [h(slotComponent, scope || {})]
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSlots = ctx.slots || {}
|
||||||
|
const mergedSlots = { ...themeSlots, ...originalSlots }
|
||||||
|
|
||||||
|
// Merge attrs into props so the engine sees class/style from fallthrough attrs
|
||||||
|
// before merging theme values — prevents ctx.attrs from overwriting engine output.
|
||||||
|
// Running inside the render function also keeps mergedProps reactive to prop/attr changes.
|
||||||
|
const mergedProps = applyThemeProps({ ...props, ...ctx.attrs })
|
||||||
|
|
||||||
|
for (const key of ['injectedClasses', 'injectedStyles', 'stylePlug']) {
|
||||||
|
delete mergedProps[key as keyof typeof mergedProps]
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(component as LibComponent, mergedProps, mergedSlots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return componentObj as I
|
||||||
|
}
|
||||||
|
}
|
||||||
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue