Compare commits
No commits in common. "feat/ui" and "main" have entirely different histories.
123 changed files with 4711 additions and 3845 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
|
||||||
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,6 +1,6 @@
|
||||||
# nychthemeron
|
# nychthemeron
|
||||||
|
|
||||||
A CSS-reactive Vue component library built on PrimeVue (Unstyled), shipped with the nychthemeron theme as a default.
|
A CSS-reactive Vue component library built on ShadCN, shipped with the nychthemeron theme as a default.
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
|
|
@ -9,7 +9,6 @@ nychthemeron provides a curated set of Vue 3 components powered by PrimeVue's un
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **CSS-Reactive**: Components respond to CSS changes without requiring JavaScript updates
|
- **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
|
- **Theme Included**: Ships with the nychthemeron theme, ready to use immediately
|
||||||
- **TypeScript**: Full type support for Vue 3 and TypeScript projects
|
- **TypeScript**: Full type support for Vue 3 and TypeScript projects
|
||||||
- **Customizable**: Override or extend the default theme to match your design system
|
- **Customizable**: Override or extend the default theme to match your design system
|
||||||
|
|
|
||||||
|
|
@ -1,708 +0,0 @@
|
||||||
# Library with Playground Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Transform nychthemeron into a monorepo with a Vue component library (with PrimeVue + Tailwind) and interactive Storybook playground.
|
|
||||||
|
|
||||||
**Architecture:** Bun workspaces manage two independent packages — library (builds to ESM) and playground (Storybook dev server and static build). Shared configs at root level.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3, PrimeVue, Tailwind CSS, Vite, Storybook, Vitest, TypeScript, Bun workspaces
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Create monorepo directory structure
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/` directory
|
|
||||||
- Create: `packages/playground/` directory
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create packages directories**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p packages/library/src/{components,composables}
|
|
||||||
mkdir -p packages/library/__tests__
|
|
||||||
mkdir -p packages/playground/.storybook
|
|
||||||
mkdir -p packages/playground/stories
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify structure**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la packages/
|
|
||||||
# Expected output shows: library/ and playground/ directories
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Move existing source to library package
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Move: `src/` → `packages/library/src/`
|
|
||||||
- Delete: `vite.config.ts`, `vitest.config.ts`, `index.html` (will recreate in packages)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Move src directory**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mv src packages/library/src
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify move**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls packages/library/src/
|
|
||||||
# Expected: App.vue, main.ts, __tests__/
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Delete old root-level config files**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm vite.config.ts vitest.config.ts index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Update root package.json for monorepo
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `package.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add workspaces and update root config**
|
|
||||||
|
|
||||||
Replace the entire `package.json` with:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "nychthemeron",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"workspaces": [
|
|
||||||
"packages/*"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"dev": "bun -r --cwd packages/playground dev",
|
|
||||||
"build": "bun -r --cwd packages/library build && bun -r --cwd packages/playground build",
|
|
||||||
"preview": "bun -r --cwd packages/playground preview",
|
|
||||||
"type-check": "vue-tsc --build",
|
|
||||||
"lint": "eslint packages/*/src --fix",
|
|
||||||
"format": "prettier --write --experimental-cli packages/*/src",
|
|
||||||
"test": "bun -r --cwd packages/library test:unit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tsconfig/node24": "^24.0.4",
|
|
||||||
"@types/jsdom": "^28.0.1",
|
|
||||||
"@types/node": "^24.12.2",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
|
||||||
"@vitest/eslint-plugin": "^1.6.16",
|
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"@vue/tsconfig": "^0.9.1",
|
|
||||||
"eslint": "^10.2.1",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-oxlint": "~1.60.0",
|
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
|
||||||
"jiti": "^2.6.1",
|
|
||||||
"jsdom": "^29.0.2",
|
|
||||||
"npm-run-all2": "^8.0.4",
|
|
||||||
"oxlint": "~1.60.0",
|
|
||||||
"prettier": "3.8.3",
|
|
||||||
"typescript": "~6.0.0",
|
|
||||||
"vite": "^8.0.8",
|
|
||||||
"vite-plugin-vue-devtools": "^8.1.1",
|
|
||||||
"vitest": "^4.1.4",
|
|
||||||
"vue": "^3.5.32",
|
|
||||||
"vue-tsc": "^3.2.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Install dependencies for new workspace structure**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output: Bun detects workspaces and creates symlinks in each `packages/*/node_modules`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Create library package.json
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/package.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create library package config**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@nychthemeron/library",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build",
|
|
||||||
"test:unit": "vitest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "^3.5.0",
|
|
||||||
"primevue": "^18.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitest/coverage-v8": "^2.0.0",
|
|
||||||
"@vue/test-utils": "^2.4.6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/package.json`
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify file exists**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat packages/library/package.json | grep '"name"'
|
|
||||||
# Expected: "@nychthemeron/library"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Create library tsconfig.json
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/tsconfig.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create library TypeScript config**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "./dist",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"emitDeclarationOnly": false
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.vue"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"dist",
|
|
||||||
"**/*.spec.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/tsconfig.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Create library vite.config.ts
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/vite.config.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Vite library config**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
|
||||||
name: 'NychthemeronLibrary',
|
|
||||||
formats: ['es'],
|
|
||||||
fileName: () => 'index.js',
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
external: ['vue', 'primevue'],
|
|
||||||
output: {
|
|
||||||
globals: {
|
|
||||||
vue: 'Vue',
|
|
||||||
primevue: 'PrimeVue',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/vite.config.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Create library vitest.config.ts
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/vitest.config.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Vitest config**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
include: ['**/*.spec.ts'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/vitest.config.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Create library export structure
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/library/src/components/index.ts`
|
|
||||||
- Create: `packages/library/src/composables/index.ts`
|
|
||||||
- Modify: `packages/library/src/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create components index (empty for now)**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Re-export all components here as they're added
|
|
||||||
// export { default as Button } from './Button/Button.vue'
|
|
||||||
// export { default as DatePicker } from './DatePicker/DatePicker.vue'
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/src/components/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create composables index (empty for now)**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Re-export all composables here as they're added
|
|
||||||
// export { useDate } from './useDate'
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/src/composables/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create root library entry point**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export * from './components'
|
|
||||||
export * from './composables'
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/library/src/index.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Remove old App.vue and main.ts from library (they were for the app)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm packages/library/src/App.vue packages/library/src/main.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: Create playground package.json
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/package.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create playground package config**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@nychthemeron/playground",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "storybook dev -p 6006",
|
|
||||||
"build": "storybook build",
|
|
||||||
"preview": "npx vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nychthemeron/library": "workspace:*",
|
|
||||||
"primevue": "^18.0.0",
|
|
||||||
"vue": "^3.5.32"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@storybook/addon-essentials": "^8.0.0",
|
|
||||||
"@storybook/addon-interactions": "^8.0.0",
|
|
||||||
"@storybook/addon-links": "^8.0.0",
|
|
||||||
"@storybook/blocks": "^8.0.0",
|
|
||||||
"@storybook/vue3": "^8.0.0",
|
|
||||||
"@storybook/vue3-vite": "^8.0.0",
|
|
||||||
"storybook": "^8.0.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"typescript": "~6.0.0",
|
|
||||||
"vue": "^3.5.32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/package.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: Create playground tsconfig.json
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/tsconfig.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create playground TypeScript config**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": false,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"lib": [
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
".storybook/**/*.ts",
|
|
||||||
".storybook/**/*.tsx",
|
|
||||||
".storybook/**/*.vue",
|
|
||||||
"stories/**/*.ts",
|
|
||||||
"stories/**/*.tsx",
|
|
||||||
"stories/**/*.vue"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/tsconfig.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 11: Create playground Tailwind config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/tailwind.config.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Tailwind config**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Config } from 'tailwindcss'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
'./.storybook/**/*.{ts,tsx,vue}',
|
|
||||||
'./stories/**/*.{ts,tsx,vue}',
|
|
||||||
'../library/src/**/*.vue',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#f0f9ff',
|
|
||||||
500: '#0ea5e9',
|
|
||||||
900: '#0c2d6b',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
} satisfies Config
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/tailwind.config.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 12: Create Storybook main config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/.storybook/main.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Storybook main configuration**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: [
|
|
||||||
'../stories/**/*.stories.ts',
|
|
||||||
],
|
|
||||||
addons: [
|
|
||||||
'@storybook/addon-essentials',
|
|
||||||
'@storybook/addon-interactions',
|
|
||||||
'@storybook/addon-links',
|
|
||||||
],
|
|
||||||
framework: {
|
|
||||||
name: '@storybook/vue3-vite',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
core: {
|
|
||||||
disableTelemetry: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/.storybook/main.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 13: Create Storybook preview config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/.storybook/preview.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Storybook preview configuration**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Preview } from '@storybook/vue3'
|
|
||||||
import 'primevue/resources/themes/lara-light-blue/theme.css'
|
|
||||||
import 'primevue/resources/primevue.min.css'
|
|
||||||
import '../tailwind.css'
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: 'Component documentation',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default preview
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/.storybook/preview.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 14: Create Tailwind CSS entry point
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/tailwind.css`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create Tailwind directives file**
|
|
||||||
|
|
||||||
```css
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/tailwind.css`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 15: Create example story
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `packages/playground/stories/Example.stories.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create example story for testing**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Example/Welcome',
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
},
|
|
||||||
tags: ['autodocs'],
|
|
||||||
} satisfies Meta
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
export const Welcome: Story = {
|
|
||||||
render: () => ({
|
|
||||||
template: `
|
|
||||||
<div class="p-8 text-center">
|
|
||||||
<h1 class="text-3xl font-bold text-primary-500 mb-4">
|
|
||||||
Nychthemeron Component Library
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Interactive playground for Vue components with PrimeVue + Tailwind
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Write this to `packages/playground/stories/Example.stories.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 16: Install dependencies and verify structure
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- None (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Install dependencies for all packages**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: All packages get dependencies installed, workspace links created
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify workspace structure**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la packages/library/node_modules/@nychthemeron/library
|
|
||||||
# Expected: symlink to ../..
|
|
||||||
|
|
||||||
ls -la packages/playground/node_modules/@nychthemeron/library
|
|
||||||
# Expected: symlink to ../../../library
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Type-check across monorepo**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun type-check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 17: Test dev server startup
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- None (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Start Storybook playground**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
Storybook 8.x started
|
|
||||||
Local: http://localhost:6006
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify Storybook loads in browser**
|
|
||||||
|
|
||||||
Open `http://localhost:6006` in a browser.
|
|
||||||
|
|
||||||
Expected: Storybook UI loads with "Example/Welcome" story visible showing the welcome message.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify story renders correctly**
|
|
||||||
|
|
||||||
In Storybook, click "Example/Welcome" → verify the welcome message displays with correct styling.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Title: "Nychthemeron Component Library" (blue, large)
|
|
||||||
- Subtitle: "Interactive playground..." (gray, smaller)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Stop dev server**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Press Ctrl+C in terminal
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 18: Test library build
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- None (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build library**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun -r --cwd packages/library build
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
✓ 123 modules transformed
|
|
||||||
dist/index.js 1.23 kb
|
|
||||||
dist/index.d.ts 0.5 kb
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify dist files exist**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls packages/library/dist/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
```
|
|
||||||
index.d.ts
|
|
||||||
index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify exports are correct**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -20 packages/library/dist/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: JavaScript code with ES module exports (starts with `import` or defines exports)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 19: Initialize git for monorepo
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- None (git initialization only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Initialize git repo (if not already)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add gitignore entries for workspace**
|
|
||||||
|
|
||||||
Append to `.gitignore`:
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/*/dist
|
|
||||||
packages/*/node_modules
|
|
||||||
packages/*/.storybook-static
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create initial commit (SKIP - user requested no commits)**
|
|
||||||
|
|
||||||
Note: Commit skipped per user request
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**Spec coverage:**
|
|
||||||
- ✅ Monorepo structure (Task 1)
|
|
||||||
- ✅ Bun workspaces setup (Tasks 2-3)
|
|
||||||
- ✅ Library package creation (Tasks 4-8)
|
|
||||||
- ✅ Composables structure (Task 8)
|
|
||||||
- ✅ Playground/Storybook setup (Tasks 9-15)
|
|
||||||
- ✅ Tailwind integration (Tasks 11, 14)
|
|
||||||
- ✅ PrimeVue initialization (Task 13)
|
|
||||||
- ✅ Dev workflow verification (Task 17)
|
|
||||||
- ✅ Build verification (Task 18)
|
|
||||||
- ✅ Git initialization (Task 19)
|
|
||||||
|
|
||||||
**Placeholder scan:** No TBDs, "fill in later", incomplete steps. All code blocks are complete and exact.
|
|
||||||
|
|
||||||
**Type consistency:** Package names consistent (`@nychthemeron/library`, `@nychthemeron/playground`). All import paths and config keys match across tasks.
|
|
||||||
|
|
||||||
**Scope:** Plan produces working, testable monorepo. Library builds to `dist/`, Storybook starts and loads stories. Next phase (adding components) is separate.
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
# Library with Playground Design
|
|
||||||
**Date:** 2026-05-11
|
|
||||||
**Project:** nychthemeron
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Transform nychthemeron from a single Vue app into a monorepo containing a Vue component library built on PrimeVue with Tailwind CSS styling, accompanied by a Storybook playground for interactive documentation and development.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Build a reusable Vue component library with custom Tailwind-based styling on PrimeVue
|
|
||||||
- Provide interactive documentation via Storybook for developers and stakeholders
|
|
||||||
- Enable fast, hot-reload development workflow
|
|
||||||
- Support both components and composables (e.g., date utilities for DatePicker)
|
|
||||||
- Keep internal/private — no npm publishing required
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Monorepo Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
nychthemeron/
|
|
||||||
├── packages/
|
|
||||||
│ ├── library/ # Vue component library
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── components/ # Vue components (DatePicker, Button, etc.)
|
|
||||||
│ │ │ ├── composables/ # Reusable logic (useDate, etc.)
|
|
||||||
│ │ │ └── index.ts # Barrel export for all exports
|
|
||||||
│ │ ├── __tests__/ # Unit tests
|
|
||||||
│ │ ├── package.json # Library metadata
|
|
||||||
│ │ ├── tsconfig.json # Library TS config
|
|
||||||
│ │ └── vite.config.ts # Library build config
|
|
||||||
│ │
|
|
||||||
│ └── playground/ # Storybook playground
|
|
||||||
│ ├── .storybook/
|
|
||||||
│ │ ├── main.ts # Storybook configuration
|
|
||||||
│ │ └── preview.ts # Global setup (Tailwind, PrimeVue)
|
|
||||||
│ ├── stories/ # Component stories (.stories.ts)
|
|
||||||
│ ├── package.json # Playground metadata
|
|
||||||
│ ├── tsconfig.json # Storybook TS config
|
|
||||||
│ └── tailwind.config.ts # Shared Tailwind config
|
|
||||||
│
|
|
||||||
├── package.json # Root workspace config (bun workspaces)
|
|
||||||
├── tsconfig.json # Shared base TS config
|
|
||||||
├── eslint.config.ts # Shared ESLint config
|
|
||||||
├── prettier.config.json # Shared Prettier config
|
|
||||||
└── docs/ # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependency Model
|
|
||||||
|
|
||||||
- **Root:** Shared dev dependencies (TypeScript, ESLint, Prettier, bun workspace config)
|
|
||||||
- **Library:** Declares PrimeVue, Vue 3 as peer dependencies; exports components + composables
|
|
||||||
- **Playground:** Imports `@nychthemeron/library` and PrimeVue; bundles Storybook
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|-----------|
|
|
||||||
| Framework | Vue 3 |
|
|
||||||
| Component Base | PrimeVue |
|
|
||||||
| Styling | Tailwind CSS + custom CSS layer |
|
|
||||||
| Build (Library) | Vite (library mode) |
|
|
||||||
| Build (Playground) | Vite + Storybook |
|
|
||||||
| Testing | Vitest + Vue Test Utils |
|
|
||||||
| Package Manager | Bun (with workspaces) |
|
|
||||||
| Type Checking | TypeScript + vue-tsc |
|
|
||||||
|
|
||||||
## Library Package Details
|
|
||||||
|
|
||||||
### Export Model
|
|
||||||
|
|
||||||
**index.ts** exports all components and composables:
|
|
||||||
```typescript
|
|
||||||
export * from './components'
|
|
||||||
export * from './composables'
|
|
||||||
```
|
|
||||||
|
|
||||||
Consumers import as:
|
|
||||||
```typescript
|
|
||||||
import { DatePicker, Button } from '@nychthemeron/library'
|
|
||||||
import { useDate } from '@nychthemeron/library'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Structure
|
|
||||||
|
|
||||||
Each component lives in its own folder:
|
|
||||||
```
|
|
||||||
src/components/
|
|
||||||
├── DatePicker/
|
|
||||||
│ ├── DatePicker.vue
|
|
||||||
│ └── DatePicker.spec.ts
|
|
||||||
├── Button/
|
|
||||||
│ ├── Button.vue
|
|
||||||
│ └── Button.spec.ts
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Composables Structure
|
|
||||||
|
|
||||||
Reusable logic in `src/composables/`:
|
|
||||||
```
|
|
||||||
src/composables/
|
|
||||||
├── useDate.ts # Date utilities (parsing, formatting, etc.)
|
|
||||||
├── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Components use composables internally; consumers can also import them directly.
|
|
||||||
|
|
||||||
### Tailwind Integration
|
|
||||||
|
|
||||||
- `tailwind.config.ts` extends PrimeVue theme with custom utilities
|
|
||||||
- Library does NOT bundle Tailwind — consumers must include it in their setup
|
|
||||||
- Supports portability: teams can apply custom Tailwind themes independently
|
|
||||||
|
|
||||||
### Build Output
|
|
||||||
|
|
||||||
- ESM modules (primary) + optional CommonJS
|
|
||||||
- TypeScript declarations (`.d.ts`)
|
|
||||||
- Sourcemaps for debugging
|
|
||||||
- No dependency bundling (peer deps only)
|
|
||||||
|
|
||||||
## Playground Package Details
|
|
||||||
|
|
||||||
### Storybook Configuration
|
|
||||||
|
|
||||||
- **Addon:** Vue 3 builder
|
|
||||||
- **Global Setup:** `preview.ts` configures:
|
|
||||||
- Tailwind CSS
|
|
||||||
- PrimeVue theme setup
|
|
||||||
- Global styles
|
|
||||||
- **Stories Location:** `stories/` directory
|
|
||||||
|
|
||||||
### Story Structure
|
|
||||||
|
|
||||||
Each component gets a `.stories.ts` file:
|
|
||||||
```typescript
|
|
||||||
// stories/DatePicker.stories.ts
|
|
||||||
import DatePicker from '../components/DatePicker.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: DatePicker,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = {}
|
|
||||||
export const WithCustomTheme = { args: { /* ... */ } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Stories serve triple duty:
|
|
||||||
1. Interactive documentation for developers
|
|
||||||
2. Visual regression baseline
|
|
||||||
3. Live testing environment during dev
|
|
||||||
|
|
||||||
### Preview Configuration
|
|
||||||
|
|
||||||
`preview.ts` sets up:
|
|
||||||
- Tailwind CSS
|
|
||||||
- PrimeVue initialization
|
|
||||||
- Global CSS imports
|
|
||||||
- Example theme configuration
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Scripts
|
|
||||||
|
|
||||||
**Root-level (`bun` commands):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dev": "bun -r --cwd packages/playground dev",
|
|
||||||
"build": "bun -r --cwd packages/library build && bun -r --cwd packages/playground build",
|
|
||||||
"lint": "eslint packages/*/src",
|
|
||||||
"type-check": "vue-tsc --build",
|
|
||||||
"test": "bun -r --cwd packages/library test:unit"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Library scripts:**
|
|
||||||
- `bun dev` — (unused for lib, optional for local testing)
|
|
||||||
- `bun build` — Vite library build
|
|
||||||
- `bun test:unit` — Vitest
|
|
||||||
|
|
||||||
**Playground scripts:**
|
|
||||||
- `bun dev` — Start Storybook dev server (port 6006)
|
|
||||||
- `bun build` — Build static Storybook site
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
|
|
||||||
1. **Start playground:** `bun dev` (root) → Storybook on `localhost:6006`
|
|
||||||
2. **Edit component:** Modify `packages/library/src/components/DatePicker/DatePicker.vue`
|
|
||||||
3. **See update:** Playground hot-reloads via Storybook
|
|
||||||
4. **View story:** Open `stories/DatePicker.stories.ts` to add/update examples
|
|
||||||
5. **Test:** Run `bun test` in library package
|
|
||||||
|
|
||||||
### Hot Reload
|
|
||||||
|
|
||||||
- Library changes trigger Storybook rebuild
|
|
||||||
- Playground imports `@nychthemeron/library` from source (no build step during dev)
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- **Location:** `packages/library/__tests__/`
|
|
||||||
- **Framework:** Vitest + Vue Test Utils
|
|
||||||
- **Coverage:** Component logic, composables, edge cases
|
|
||||||
- **Command:** `bun test` (from library or root)
|
|
||||||
|
|
||||||
### Visual Documentation
|
|
||||||
|
|
||||||
- Storybook stories serve as visual baseline
|
|
||||||
- Can be extended with snapshot testing if needed
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
|
|
||||||
- `vue-tsc --build` runs across monorepo
|
|
||||||
- CI/local checks ensure no type errors
|
|
||||||
|
|
||||||
## Build & Deployment
|
|
||||||
|
|
||||||
### Library Build
|
|
||||||
|
|
||||||
- **Output:** `packages/library/dist/`
|
|
||||||
- **Formats:** ESM (primary) + optional CommonJS
|
|
||||||
- **Declarations:** Full TypeScript support
|
|
||||||
- **Size:** Optimized with tree-shaking enabled
|
|
||||||
|
|
||||||
### Playground Build
|
|
||||||
|
|
||||||
- **Output:** `packages/playground/storybook-static/`
|
|
||||||
- **Deploy:** Can be served on internal server, wiki, or documentation site
|
|
||||||
- **Audience:** Internal team, stakeholders, designers
|
|
||||||
|
|
||||||
### Internal Distribution
|
|
||||||
|
|
||||||
No npm publishing. Teams use library by:
|
|
||||||
- Cloning/pulling monorepo
|
|
||||||
- Installing dependencies: `bun install`
|
|
||||||
- Importing components from `@nychthemeron/library`
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Library Package
|
|
||||||
|
|
||||||
**Peer Dependencies:**
|
|
||||||
- `vue@^3.5.0`
|
|
||||||
- `primevue@^18.0.0` (or latest)
|
|
||||||
|
|
||||||
**Dev Dependencies:** (shared from root)
|
|
||||||
- TypeScript, Vite, ESLint, Prettier
|
|
||||||
|
|
||||||
### Playground Package
|
|
||||||
|
|
||||||
**Dependencies:**
|
|
||||||
- `@nychthemeron/library` (local workspace package)
|
|
||||||
- `primevue`
|
|
||||||
- `vue`
|
|
||||||
|
|
||||||
**Dev Dependencies:**
|
|
||||||
- Storybook (@storybook/vue3)
|
|
||||||
- Vite, TypeScript
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- ✅ Monorepo structure set up with bun workspaces
|
|
||||||
- ✅ Library builds to `dist/` with exports
|
|
||||||
- ✅ Storybook starts with `bun dev` and hot-reloads on library changes
|
|
||||||
- ✅ Components use PrimeVue + Tailwind CSS
|
|
||||||
- ✅ Composables (e.g., `useDate`) are accessible
|
|
||||||
- ✅ TypeScript declarations present and correct
|
|
||||||
- ✅ Unit tests run and pass
|
|
||||||
- ✅ Shared config (ESLint, Prettier, tsconfig) enforced across packages
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Restructure project into monorepo layout
|
|
||||||
2. Set up bun workspaces in root `package.json`
|
|
||||||
3. Create library package with Vite lib config
|
|
||||||
4. Create playground package with Storybook
|
|
||||||
5. Add shared configurations
|
|
||||||
6. Implement first component (e.g., Button or DatePicker)
|
|
||||||
7. Create corresponding Storybook stories
|
|
||||||
8. Test hot-reload workflow
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||||
import storybook from "eslint-plugin-storybook";
|
|
||||||
|
|
||||||
import { globalIgnores } from 'eslint/config'
|
import { globalIgnores } from 'eslint/config'
|
||||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
|
@ -24,6 +22,16 @@ export default defineConfigWithVueTs(
|
||||||
...pluginVue.configs['flat/essential'],
|
...pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
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,
|
...pluginVitest.configs.recommended,
|
||||||
files: ['src/**/__tests__/*'],
|
files: ['src/**/__tests__/*'],
|
||||||
|
|
|
||||||
24
package.json
24
package.json
|
|
@ -4,30 +4,29 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
"@types/jsdom": "^28.0.3",
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vitest/eslint-plugin": "^1.6.19",
|
"@vitest/eslint-plugin": "^1.6.20",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.8.0",
|
||||||
"@vue/test-utils": "^2.4.10",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.60.0",
|
"eslint-plugin-oxlint": "~1.69.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.9.2",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^9.0.1",
|
||||||
"oxlint": "~1.60.0",
|
"oxlint": "~1.69.0",
|
||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.4",
|
||||||
"primevue": "^4.5.5",
|
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-vue-devtools": "^8.1.2",
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.8",
|
||||||
"vue": "^3.5.35",
|
"vue": "^3.5.38",
|
||||||
"vue-tsc": "^3.3.3"
|
"vue-tsc": "^3.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
|
@ -38,6 +37,7 @@
|
||||||
"build:dev": "cd packages/library && bun run build ",
|
"build:dev": "cd packages/library && bun run build ",
|
||||||
"build": "cd packages/library && bun run build && cd ../playground && bun run build",
|
"build": "cd packages/library && bun run build && cd ../playground && bun run build",
|
||||||
"preview": "cd packages/playground && bun run preview",
|
"preview": "cd packages/playground && bun run preview",
|
||||||
|
"test:unit": "cd packages/library && bun run test:unit",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"lint": "eslint packages/*/src --fix",
|
"lint": "eslint packages/*/src --fix",
|
||||||
"format": "prettier --write --experimental-cli packages/*/src",
|
"format": "prettier --write --experimental-cli packages/*/src",
|
||||||
|
|
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"development": "./src/index.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/src/index.d.ts"
|
"types": "./dist/src/index.d.ts"
|
||||||
},
|
},
|
||||||
|
|
@ -12,11 +13,7 @@
|
||||||
"types": "./src/index.d.ts"
|
"types": "./src/index.d.ts"
|
||||||
},
|
},
|
||||||
"./style": {
|
"./style": {
|
||||||
"import": "./src/assets/css/nychthemeron.css",
|
"import": "./src/assets/css/tailwind.css",
|
||||||
"types": "./src/index.d.ts"
|
|
||||||
},
|
|
||||||
"./theme": {
|
|
||||||
"import": "./src/assets/theme/theme.css",
|
|
||||||
"types": "./src/index.d.ts"
|
"types": "./src/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -27,15 +24,29 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"test:unit": "vitest"
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0"
|
||||||
"primevue": "^4.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "^2.0.0",
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"vite-plugin-dts": "^5.0.2"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
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: #fff8e8;
|
||||||
|
|
||||||
|
--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: #fff8e8;
|
||||||
|
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
@ -1,727 +0,0 @@
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +1,36 @@
|
||||||
// Re-export all components here as they're added
|
// Re-export all components here as they're added
|
||||||
export { default as NychLoadingIcon } from './NychLoadingIcon.vue'
|
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-serif 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-serif 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"
|
||||||
|
|
@ -1,25 +1,42 @@
|
||||||
export {
|
|
||||||
ThemeEngine,
|
|
||||||
type ThemeLibrary,
|
|
||||||
type Themeable,
|
|
||||||
type LibComponent,
|
|
||||||
type Key,
|
|
||||||
type Props,
|
|
||||||
} from './engine'
|
|
||||||
export {
|
export {
|
||||||
createNychthemeron,
|
createNychthemeron,
|
||||||
Button,
|
Button,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
InputText,
|
Input,
|
||||||
Textarea,
|
Textarea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
RadioButton,
|
RadioGroup,
|
||||||
RadioButtonGroup,
|
RadioGroupItem,
|
||||||
ToggleSwitch,
|
Switch,
|
||||||
Select,
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel,
|
||||||
|
SelectSeparator,
|
||||||
Card,
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardAction,
|
||||||
Dialog,
|
Dialog,
|
||||||
Message,
|
DialogTrigger,
|
||||||
Tag,
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogScrollContent,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
AlertDescription,
|
||||||
|
AlertAction,
|
||||||
|
Badge,
|
||||||
} from './lib'
|
} from './lib'
|
||||||
|
|
|
||||||
|
|
@ -1,210 +1,217 @@
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import {
|
import {
|
||||||
Button as PrimeButton,
|
NychLoadingIcon,
|
||||||
InputText as PrimeInputText,
|
Button,
|
||||||
Textarea as PrimeTextarea,
|
buttonVariants,
|
||||||
Checkbox as PrimeCheckbox,
|
Input,
|
||||||
CheckboxGroup as PrimeCheckboxGroup,
|
Textarea,
|
||||||
RadioButton as PrimeRadioButton,
|
Switch,
|
||||||
RadioButtonGroup as PrimeRadioButtonGroup,
|
Badge,
|
||||||
ToggleSwitch as PrimeToggleSwitch,
|
badgeVariants,
|
||||||
Select as PrimeSelect,
|
Checkbox,
|
||||||
Card as PrimeCard,
|
CheckboxGroup,
|
||||||
Dialog as PrimeDialog,
|
RadioGroup,
|
||||||
Message as PrimeMessage,
|
RadioGroupItem,
|
||||||
Tag as PrimeTag,
|
Card,
|
||||||
} from 'primevue'
|
CardHeader,
|
||||||
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
|
CardTitle,
|
||||||
import { NychLoadingIcon } from '../components'
|
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 {
|
||||||
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
|
Button,
|
||||||
*
|
buttonVariants,
|
||||||
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
|
Input,
|
||||||
* `nych-<base>-<section>` class onto every additional section so multi-part components
|
Textarea,
|
||||||
* (Checkbox box, Select overlay, Card body, …) can be fully styled in unstyled mode, where
|
Switch,
|
||||||
* PrimeVue emits no classes of its own.
|
Badge,
|
||||||
*/
|
badgeVariants,
|
||||||
const ptThemeable = <T extends LibComponent>(
|
Checkbox,
|
||||||
component: T,
|
CheckboxGroup,
|
||||||
base: string,
|
RadioGroup,
|
||||||
sections: string[] = [],
|
RadioGroupItem,
|
||||||
): Themeable<T> => ({
|
Card,
|
||||||
component,
|
CardHeader,
|
||||||
unstyled: true as Themeable<T>['unstyled'],
|
CardTitle,
|
||||||
classes: [`nych-${base}`],
|
CardDescription,
|
||||||
injectionKeys: { class: 'pt' as Key<T> },
|
CardContent,
|
||||||
propMutator: ((props: Record<string, unknown>) => {
|
CardFooter,
|
||||||
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
|
CardAction,
|
||||||
|
cardVariants,
|
||||||
for (const section of sections) {
|
Alert,
|
||||||
const existing = pt[section]?.class
|
AlertTitle,
|
||||||
pt[section] = {
|
AlertDescription,
|
||||||
...(pt[section] ?? {}),
|
AlertAction,
|
||||||
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
|
alertVariants,
|
||||||
}
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel,
|
||||||
|
SelectSeparator,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogScrollContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
props.pt = pt
|
export const LoadingIcon = NychLoadingIcon
|
||||||
|
|
||||||
return props
|
|
||||||
}) as Themeable<T>['propMutator'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const buttonThemeable: Themeable<typeof PrimeButton> = {
|
|
||||||
component: PrimeButton,
|
|
||||||
unstyled: true,
|
|
||||||
propMutator: (props) => {
|
|
||||||
const severity = (props.severity as string) ?? 'primary'
|
|
||||||
delete props.severity
|
|
||||||
|
|
||||||
if (props.loading) props.disabled = true
|
|
||||||
|
|
||||||
const pt = props.pt as { root?: { class?: string } }
|
|
||||||
|
|
||||||
const classes = pt?.root?.class?.split(' ') ?? []
|
|
||||||
|
|
||||||
classes.push(`nych-button-${severity}`)
|
|
||||||
|
|
||||||
if (!pt.root) pt.root = {}
|
|
||||||
pt.root.class = classes.join(' ')
|
|
||||||
|
|
||||||
props.pt = pt
|
|
||||||
|
|
||||||
return props
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
loadingicon: NychLoadingIcon,
|
|
||||||
},
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
|
|
||||||
component: NychLoadingIcon,
|
|
||||||
unstyled: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const nychthemeron: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: buttonThemeable,
|
|
||||||
LoadingIcon: loadingIconThemeable,
|
|
||||||
InputText: ptThemeable(PrimeInputText, 'input'),
|
|
||||||
Textarea: ptThemeable(PrimeTextarea, 'textarea'),
|
|
||||||
Checkbox: ptThemeable(PrimeCheckbox, 'checkbox', ['box', 'input', 'icon']),
|
|
||||||
CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'),
|
|
||||||
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
|
|
||||||
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
|
|
||||||
ToggleSwitch: ptThemeable(PrimeToggleSwitch, 'toggleswitch', ['input', 'slider', 'handle']),
|
|
||||||
Select: ptThemeable(PrimeSelect, 'select', [
|
|
||||||
'label',
|
|
||||||
'dropdown',
|
|
||||||
'dropdownIcon',
|
|
||||||
'overlay',
|
|
||||||
'header',
|
|
||||||
'listContainer',
|
|
||||||
'list',
|
|
||||||
'option',
|
|
||||||
'optionLabel',
|
|
||||||
'emptyMessage',
|
|
||||||
]),
|
|
||||||
Card: ptThemeable(PrimeCard, 'card', [
|
|
||||||
'header',
|
|
||||||
'body',
|
|
||||||
'caption',
|
|
||||||
'title',
|
|
||||||
'subtitle',
|
|
||||||
'content',
|
|
||||||
'footer',
|
|
||||||
]),
|
|
||||||
Dialog: ptThemeable(PrimeDialog, 'dialog', [
|
|
||||||
'mask',
|
|
||||||
'header',
|
|
||||||
'title',
|
|
||||||
'headerActions',
|
|
||||||
'content',
|
|
||||||
'footer',
|
|
||||||
]),
|
|
||||||
Message: ptThemeable(PrimeMessage, 'message', [
|
|
||||||
'contentWrapper',
|
|
||||||
'content',
|
|
||||||
'icon',
|
|
||||||
'text',
|
|
||||||
'closeButton',
|
|
||||||
'closeIcon',
|
|
||||||
]),
|
|
||||||
Tag: ptThemeable(PrimeTag, 'tag', ['label', 'icon']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const _engine = new ThemeEngine(nychthemeron)
|
|
||||||
|
|
||||||
export const Button: typeof PrimeButton = _engine.getComponent<typeof PrimeButton>('Button')
|
|
||||||
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
|
|
||||||
export const InputText: typeof PrimeInputText =
|
|
||||||
_engine.getComponent<typeof PrimeInputText>('InputText')
|
|
||||||
export const Textarea: typeof PrimeTextarea = _engine.getComponent<typeof PrimeTextarea>('Textarea')
|
|
||||||
export const Checkbox: typeof PrimeCheckbox = _engine.getComponent<typeof PrimeCheckbox>('Checkbox')
|
|
||||||
export const CheckboxGroup: typeof PrimeCheckboxGroup =
|
|
||||||
_engine.getComponent<typeof PrimeCheckboxGroup>('CheckboxGroup')
|
|
||||||
export const RadioButton: typeof PrimeRadioButton =
|
|
||||||
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
|
|
||||||
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
|
|
||||||
_engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup')
|
|
||||||
export const ToggleSwitch: typeof PrimeToggleSwitch =
|
|
||||||
_engine.getComponent<typeof PrimeToggleSwitch>('ToggleSwitch')
|
|
||||||
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
|
|
||||||
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
|
|
||||||
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
|
|
||||||
export const Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
|
|
||||||
export const Tag: typeof PrimeTag = _engine.getComponent<typeof PrimeTag>('Tag')
|
|
||||||
|
|
||||||
export const createNychthemeron = (): {
|
export const createNychthemeron = (): {
|
||||||
Button: typeof PrimeButton
|
Button: typeof Button
|
||||||
LoadingIcon: typeof NychLoadingIcon
|
LoadingIcon: typeof NychLoadingIcon
|
||||||
InputText: typeof PrimeInputText
|
Input: typeof Input
|
||||||
Textarea: typeof PrimeTextarea
|
Textarea: typeof Textarea
|
||||||
Checkbox: typeof PrimeCheckbox
|
Checkbox: typeof Checkbox
|
||||||
CheckboxGroup: typeof PrimeCheckboxGroup
|
CheckboxGroup: typeof CheckboxGroup
|
||||||
RadioButton: typeof PrimeRadioButton
|
RadioGroup: typeof RadioGroup
|
||||||
RadioButtonGroup: typeof PrimeRadioButtonGroup
|
RadioGroupItem: typeof RadioGroupItem
|
||||||
ToggleSwitch: typeof PrimeToggleSwitch
|
Switch: typeof Switch
|
||||||
Select: typeof PrimeSelect
|
Select: typeof Select
|
||||||
Card: typeof PrimeCard
|
SelectTrigger: typeof SelectTrigger
|
||||||
Dialog: typeof PrimeDialog
|
SelectValue: typeof SelectValue
|
||||||
Message: typeof PrimeMessage
|
SelectContent: typeof SelectContent
|
||||||
Tag: typeof PrimeTag
|
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
|
install: (app: App) => void
|
||||||
} => ({
|
} => ({
|
||||||
Button,
|
Button,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
InputText,
|
Input,
|
||||||
Textarea,
|
Textarea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CheckboxGroup,
|
CheckboxGroup,
|
||||||
RadioButton,
|
RadioGroup,
|
||||||
RadioButtonGroup,
|
RadioGroupItem,
|
||||||
ToggleSwitch,
|
Switch,
|
||||||
Select,
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel,
|
||||||
|
SelectSeparator,
|
||||||
Card,
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardAction,
|
||||||
Dialog,
|
Dialog,
|
||||||
Message,
|
DialogTrigger,
|
||||||
Tag,
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogScrollContent,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
AlertDescription,
|
||||||
|
AlertAction,
|
||||||
|
Badge,
|
||||||
install(app: App) {
|
install(app: App) {
|
||||||
app.component('NychButton', Button)
|
app.component('NychButton', Button)
|
||||||
app.component('NychLoadingIcon', LoadingIcon)
|
app.component('NychLoadingIcon', LoadingIcon)
|
||||||
app.component('NychInputText', InputText)
|
app.component('NychInput', Input)
|
||||||
app.component('NychTextarea', Textarea)
|
app.component('NychTextarea', Textarea)
|
||||||
app.component('NychCheckbox', Checkbox)
|
app.component('NychCheckbox', Checkbox)
|
||||||
app.component('NychCheckboxGroup', CheckboxGroup)
|
app.component('NychCheckboxGroup', CheckboxGroup)
|
||||||
app.component('NychRadioButton', RadioButton)
|
app.component('NychRadioGroup', RadioGroup)
|
||||||
app.component('NychRadioButtonGroup', RadioButtonGroup)
|
app.component('NychRadioGroupItem', RadioGroupItem)
|
||||||
app.component('NychToggleSwitch', ToggleSwitch)
|
app.component('NychSwitch', Switch)
|
||||||
app.component('NychSelect', Select)
|
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('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('NychDialog', Dialog)
|
||||||
app.component('NychMessage', Message)
|
app.component('NychDialogTrigger', DialogTrigger)
|
||||||
app.component('NychTag', Tag)
|
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,894 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { h, ref } from 'vue'
|
|
||||||
|
|
||||||
import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine'
|
|
||||||
|
|
||||||
const getTheme = () => {
|
|
||||||
const button: Themeable<typeof Button> = {
|
|
||||||
classes: ['test-class'],
|
|
||||||
styles: ['width: 50px'],
|
|
||||||
propMutator: (props) => {
|
|
||||||
if (props.variant) {
|
|
||||||
const pt = props.pt as { root: { class?: string } }
|
|
||||||
|
|
||||||
const classes = pt.root.class?.split(' ') ?? []
|
|
||||||
|
|
||||||
classes.push(`test-${props.variant}`)
|
|
||||||
|
|
||||||
pt.root.class = classes.join(' ')
|
|
||||||
|
|
||||||
props.pt = pt
|
|
||||||
}
|
|
||||||
|
|
||||||
return props
|
|
||||||
},
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
component: Button,
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonTheme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: button,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return buttonTheme
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSimpleTheme = () => {
|
|
||||||
const button: Themeable<typeof Button> = {
|
|
||||||
classes: ['btn-default'],
|
|
||||||
styles: ['padding: 8px'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
component: Button,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
components: {
|
|
||||||
Button: button,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const callSetup = (component: any, props: Record<string, unknown>) => {
|
|
||||||
// Apply prop defaults from the component definition
|
|
||||||
const mergedProps: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (component.props) {
|
|
||||||
for (const [key, propDef] of Object.entries(component.props)) {
|
|
||||||
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
|
|
||||||
mergedProps[key] = propDef.default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge in user-provided props
|
|
||||||
Object.assign(mergedProps, props)
|
|
||||||
|
|
||||||
if (component.setup) {
|
|
||||||
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
|
|
||||||
// Engine always returns a render function; call it and return the vnode's props
|
|
||||||
// so tests can inspect the final merged props passed to the original component.
|
|
||||||
if (typeof result === 'function') {
|
|
||||||
const vnode = result()
|
|
||||||
return (vnode?.props as Record<string, unknown>) || {}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return mergedProps
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ThemeEngine', () => {
|
|
||||||
it('instantiates properly', () => {
|
|
||||||
const theme = getTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
|
|
||||||
expect(engine).not.toBeNull()
|
|
||||||
expect(engine.lib).toBe(theme)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns a wrapped component', () => {
|
|
||||||
const theme = getTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
expect(themedButton).not.toBeNull()
|
|
||||||
expect(themedButton.props).toBeDefined()
|
|
||||||
expect(themedButton.setup).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('injects classes from theme definition', () => {
|
|
||||||
const theme = getSimpleTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('btn-default')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('injects styles from theme definition', () => {
|
|
||||||
const theme = getSimpleTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.style).toContain('padding: 8px')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies propMutator to modify component props', () => {
|
|
||||||
const theme = getTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
variant: 'primary',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('test-primary')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles PT injection key for PrimeVue components', () => {
|
|
||||||
const theme = getTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt).toBeDefined()
|
|
||||||
expect(result.pt.root).toBeDefined()
|
|
||||||
expect(result.pt.root.class).toBeDefined()
|
|
||||||
expect(result.pt.root.style).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies class prefix when stylePlug is provided', () => {
|
|
||||||
const simpleTheme = getSimpleTheme()
|
|
||||||
const engine = new ThemeEngine(simpleTheme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
stylePlug: 'custom',
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('custom-btn-default')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges user-provided PT with theme-injected classes', () => {
|
|
||||||
const simpleTheme = getSimpleTheme()
|
|
||||||
const engine = new ThemeEngine(simpleTheme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {
|
|
||||||
label: {
|
|
||||||
class: 'user-label-class',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Theme classes should be injected to root
|
|
||||||
expect(result.pt?.root?.class).toContain('btn-default')
|
|
||||||
// User-provided PT structure should be preserved
|
|
||||||
expect(result.pt?.label?.class).toBe('user-label-class')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves existing PT structure when merging', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {
|
|
||||||
root: { class: 'existing' },
|
|
||||||
label: { class: 'label-class' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root).toBeDefined()
|
|
||||||
expect(result.pt?.label?.class).toBe('label-class')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves theme classes when user provides pt.root', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['theme-class'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: { root: { class: 'user-root' } },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('theme-class')
|
|
||||||
expect(result.pt?.root?.class).toContain('user-root')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles missing injectionKeys by defaulting to class and style', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['default-class'],
|
|
||||||
styles: ['color: red'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {})
|
|
||||||
|
|
||||||
expect(result.class).toBe('default-class')
|
|
||||||
expect(result.style).toBe('color: red')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports unstyled prop passthrough', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
unstyled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
expect(themedButton.props.unstyled.default).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('combines multiple classes in correct order', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['class-a', 'class-b', 'class-c'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toBe('class-a class-b class-c')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('combines multiple styles correctly', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
styles: ['padding: 8px', 'margin: 4px', 'border: 1px solid'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.style).toBe('padding: 8px; margin: 4px; border: 1px solid')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('properly injects injectedClasses and injectedStyles props', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['injected-class'],
|
|
||||||
styles: ['injected-style'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
expect(themedButton.props.injectedClasses).toBeDefined()
|
|
||||||
expect(themedButton.props.injectedStyles).toBeDefined()
|
|
||||||
expect(themedButton.props.injectedClasses.default).toEqual(['injected-class'])
|
|
||||||
expect(themedButton.props.injectedStyles.default).toEqual(['injected-style'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies non-PT class injection correctly', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['my-class'],
|
|
||||||
styles: ['my-style'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'class',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {})
|
|
||||||
|
|
||||||
expect(result.class).toBe('my-class')
|
|
||||||
expect(result.style).toBe('my-style')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles object-form class values', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['theme-class'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'class',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
class: { 'active-class': true, 'inactive-class': false },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.class).toContain('active-class')
|
|
||||||
expect(result.class).not.toContain('inactive-class')
|
|
||||||
expect(result.class).toContain('theme-class')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges array and string classes correctly', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['theme-class'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'class',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result1 = callSetup(themedButton, {
|
|
||||||
class: ['user-class-1', 'user-class-2'],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result1.class).toContain('user-class-1')
|
|
||||||
expect(result1.class).toContain('user-class-2')
|
|
||||||
expect(result1.class).toContain('theme-class')
|
|
||||||
|
|
||||||
const result2 = callSetup(themedButton, {
|
|
||||||
class: 'user-string-class',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result2.class).toContain('user-string-class')
|
|
||||||
expect(result2.class).toContain('theme-class')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('wraps components with custom slots', () => {
|
|
||||||
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
slots: {
|
|
||||||
icon: MockSlot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
expect(themedButton.setup).toBeDefined()
|
|
||||||
expect(themedButton.setup({}, {})).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slot wrapper returns a render function', () => {
|
|
||||||
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
slots: {
|
|
||||||
icon: MockSlot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const setupResult = themedButton.setup({}, {})
|
|
||||||
|
|
||||||
expect(typeof setupResult).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('combines slots with classes and styles', () => {
|
|
||||||
const MockSlot = { name: 'MockSlot' }
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['btn-class'],
|
|
||||||
styles: ['color: blue'],
|
|
||||||
slots: {
|
|
||||||
icon: MockSlot,
|
|
||||||
},
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
// With slots, setup returns a render function, not props
|
|
||||||
const setupResult = themedButton.setup(
|
|
||||||
{
|
|
||||||
injectedClasses: ['btn-class'],
|
|
||||||
injectedStyles: ['color: blue'],
|
|
||||||
pt: {},
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(typeof setupResult).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders original component with modified props via h()', () => {
|
|
||||||
const ComponentWithSetup = {
|
|
||||||
props: { class: String },
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup(props: Record<string, unknown>) {
|
|
||||||
return () => h('div', { class: props.class })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Custom: {
|
|
||||||
component: ComponentWithSetup,
|
|
||||||
classes: ['custom-class'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedComponent = engine.getComponent('Custom')
|
|
||||||
|
|
||||||
const result = callSetup(themedComponent, {})
|
|
||||||
|
|
||||||
// Engine wraps via h() — vnode props contain the theme-injected class
|
|
||||||
expect(result.class).toBe('custom-class')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slot functions are created for each slot component', () => {
|
|
||||||
const MockSlot = {
|
|
||||||
name: 'MockSlot',
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup() {
|
|
||||||
return () => h('span', 'slot content')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
slots: {
|
|
||||||
icon: MockSlot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const renderFn = themedButton.setup({}, { slots: {} })
|
|
||||||
|
|
||||||
expect(typeof renderFn).toBe('function')
|
|
||||||
const vnode = renderFn()
|
|
||||||
expect(vnode).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('forwards update:modelValue so v-model works on wrapped components', async () => {
|
|
||||||
// Regression: a cloned wrapper inherited the component's emits/extends, so
|
|
||||||
// Vue withheld onUpdate:modelValue from ctx.attrs and the engine never
|
|
||||||
// forwarded it — breaking two-way v-model on every wrapped component.
|
|
||||||
const Emitter = {
|
|
||||||
name: 'Emitter',
|
|
||||||
props: { modelValue: { type: String, default: '' } },
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup(_props: Record<string, unknown>, { emit }: { emit: (e: string, v: string) => void }) {
|
|
||||||
return () => h('button', { onClick: () => emit('update:modelValue', 'changed') }, 'x')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = { components: { Emitter: { component: Emitter } } }
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const Wrapped = engine.getComponent('Emitter')
|
|
||||||
|
|
||||||
const Parent = {
|
|
||||||
components: { Wrapped },
|
|
||||||
setup() {
|
|
||||||
const val = ref('initial')
|
|
||||||
return { val }
|
|
||||||
},
|
|
||||||
template: `<div><Wrapped v-model="val" /><span class="out">{{ val }}</span></div>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = mount(Parent)
|
|
||||||
expect(wrapper.find('.out').text()).toBe('initial')
|
|
||||||
|
|
||||||
await wrapper.find('button').trigger('click')
|
|
||||||
expect(wrapper.find('.out').text()).toBe('changed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges theme slots with original component slots', () => {
|
|
||||||
const ThemeSlot = {
|
|
||||||
name: 'ThemeSlot',
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup() {
|
|
||||||
return () => h('span', 'theme slot')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
slots: {
|
|
||||||
icon: ThemeSlot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const originalSlots = {
|
|
||||||
label: () => h('span', 'label slot'),
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderFn = themedButton.setup({}, { slots: originalSlots })
|
|
||||||
|
|
||||||
expect(typeof renderFn).toBe('function')
|
|
||||||
const vnode = renderFn()
|
|
||||||
expect(vnode).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles non-string style values by defaulting to empty', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
styles: ['theme-style'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
style: { someObject: 'value' },
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// When style is not a string, it defaults to empty array, so only theme styles are applied
|
|
||||||
expect(result.pt?.root?.style).toBe('theme-style')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves already-prefixed classes', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['my-class', 'custom-already-prefixed'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
stylePlug: 'custom',
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('custom-my-class')
|
|
||||||
expect(result.pt?.root?.class).toContain('custom-already-prefixed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles styleKey as pt injection key', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
styles: ['style-value'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
style: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.style).toContain('style-value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slot wrapper works with non-function modifiedSetup', () => {
|
|
||||||
const MockSlot = {
|
|
||||||
name: 'MockSlot',
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup() {
|
|
||||||
return () => h('span', 'slot')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const ComponentWithoutSetup = {
|
|
||||||
name: 'ComponentWithoutSetup',
|
|
||||||
template: '<div></div>',
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Custom: {
|
|
||||||
component: ComponentWithoutSetup,
|
|
||||||
slots: {
|
|
||||||
icon: MockSlot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedComponent: any = engine.getComponent('Custom')
|
|
||||||
|
|
||||||
const renderFn = themedComponent.setup({}, { slots: {} })
|
|
||||||
|
|
||||||
expect(typeof renderFn).toBe('function')
|
|
||||||
const vnode = renderFn()
|
|
||||||
expect(vnode).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not pass internal engine props to the wrapped component', () => {
|
|
||||||
const theme = getSimpleTheme()
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, { pt: {}, stylePlug: 'custom' })
|
|
||||||
|
|
||||||
expect(result).not.toHaveProperty('injectedClasses')
|
|
||||||
expect(result).not.toHaveProperty('injectedStyles')
|
|
||||||
expect(result).not.toHaveProperty('stylePlug')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles classKey that is not pt', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['my-class'],
|
|
||||||
styles: ['my-style'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'customClass',
|
|
||||||
style: 'customStyle',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {})
|
|
||||||
|
|
||||||
expect(result.customClass).toBe('my-class')
|
|
||||||
expect(result.customStyle).toBe('my-style')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('spreads existing pt object when merging theme props', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['theme-class'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
pt: {
|
|
||||||
label: { class: 'label-class', style: 'color: red' },
|
|
||||||
icon: { class: 'icon-class' },
|
|
||||||
tooltip: { content: 'help text' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.class).toContain('theme-class')
|
|
||||||
expect(result.pt?.label?.class).toBe('label-class')
|
|
||||||
expect(result.pt?.label?.style).toBe('color: red')
|
|
||||||
expect(result.pt?.icon?.class).toBe('icon-class')
|
|
||||||
expect(result.pt?.tooltip?.content).toBe('help text')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles style as string and splits it correctly', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
styles: ['margin: 10px'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {
|
|
||||||
style: 'padding: 5px; color: blue',
|
|
||||||
pt: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.pt?.root?.style).toContain('padding: 5px')
|
|
||||||
expect(result.pt?.root?.style).toContain('color: blue')
|
|
||||||
expect(result.pt?.root?.style).toContain('margin: 10px')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates pt structure when pt is not provided', () => {
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
classes: ['btn-class'],
|
|
||||||
styles: ['btn-style'],
|
|
||||||
injectionKeys: {
|
|
||||||
class: 'pt',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
const themedButton = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const result = callSetup(themedButton, {})
|
|
||||||
|
|
||||||
expect(result.pt).toBeDefined()
|
|
||||||
expect(result.pt?.root?.class).toBe('btn-class')
|
|
||||||
expect(result.pt?.root?.style).toBe('btn-style')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slot functions created with proper scope handling', () => {
|
|
||||||
const mockSlotComponent = {
|
|
||||||
name: 'IconSlot',
|
|
||||||
//eslint-disable-next-line
|
|
||||||
setup(props: Record<string, unknown>) {
|
|
||||||
return () => h('i', `Icon: ${props.name}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme: ThemeLibrary = {
|
|
||||||
components: {
|
|
||||||
Button: {
|
|
||||||
component: Button,
|
|
||||||
slots: {
|
|
||||||
icon: mockSlotComponent,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const engine = new ThemeEngine(theme)
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const themedButton: any = engine.getComponent<typeof Button>('Button')
|
|
||||||
|
|
||||||
const renderFn = themedButton.setup({}, { slots: {} })
|
|
||||||
|
|
||||||
expect(typeof renderFn).toBe('function')
|
|
||||||
const vnode = renderFn()
|
|
||||||
expect(vnode).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
11
packages/library/tests/fixtures/SlotPassthrough.vue
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Primitive } from '@/lib/primitive'
|
||||||
|
|
||||||
|
defineProps<{ asChild?: boolean }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive as="button" :as-child="asChild" type="button" data-slot="passthrough-wrapper">
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
|
|
@ -1,47 +1,6 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import {
|
import { createNychthemeron, LoadingIcon } from '../src/index'
|
||||||
createNychthemeron,
|
|
||||||
Button,
|
|
||||||
LoadingIcon,
|
|
||||||
InputText,
|
|
||||||
Textarea,
|
|
||||||
Checkbox,
|
|
||||||
RadioButton,
|
|
||||||
CheckboxGroup,
|
|
||||||
RadioButtonGroup,
|
|
||||||
ToggleSwitch,
|
|
||||||
Select,
|
|
||||||
Card,
|
|
||||||
Dialog,
|
|
||||||
Message,
|
|
||||||
Tag,
|
|
||||||
} from '../src/index'
|
|
||||||
|
|
||||||
//eslint-disable-next-line
|
|
||||||
const callSetup = (component: any, props: Record<string, unknown>) => {
|
|
||||||
const mergedProps: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (component.props) {
|
|
||||||
for (const [key, propDef] of Object.entries(component.props)) {
|
|
||||||
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
|
|
||||||
mergedProps[key] = (propDef as { default: unknown }).default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(mergedProps, props)
|
|
||||||
|
|
||||||
if (component.setup) {
|
|
||||||
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
|
|
||||||
if (typeof result === 'function') {
|
|
||||||
const vnode = result()
|
|
||||||
return (vnode?.props as Record<string, unknown>) || {}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return mergedProps
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createNychthemeron', () => {
|
describe('createNychthemeron', () => {
|
||||||
it('returns a Vue plugin with install method', () => {
|
it('returns a Vue plugin with install method', () => {
|
||||||
|
|
@ -64,94 +23,8 @@ describe('createNychthemeron', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Button', () => {
|
|
||||||
it('is a valid Vue component', () => {
|
|
||||||
expect(Button).toBeDefined()
|
|
||||||
expect(typeof (Button as Record<string, unknown>).setup).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-disables when loading is true', () => {
|
|
||||||
const result = callSetup(Button, { pt: {}, loading: true })
|
|
||||||
expect(result.disabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stays enabled when loading is false', () => {
|
|
||||||
const result = callSetup(Button, { pt: {}, loading: false })
|
|
||||||
expect(result.disabled).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('injects severity class into pt.root', () => {
|
|
||||||
const result = callSetup(Button, { pt: {}, severity: 'danger' })
|
|
||||||
expect(result.pt?.root?.class).toContain('nych-button-danger')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults severity to primary', () => {
|
|
||||||
const result = callSetup(Button, { pt: {} })
|
|
||||||
expect(result.pt?.root?.class).toContain('nych-button-primary')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not leak engine internal props', () => {
|
|
||||||
const result = callSetup(Button, { pt: {} })
|
|
||||||
expect(result).not.toHaveProperty('injectedClasses')
|
|
||||||
expect(result).not.toHaveProperty('injectedStyles')
|
|
||||||
expect(result).not.toHaveProperty('stylePlug')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not leak severity to inner component', () => {
|
|
||||||
const result = callSetup(Button, { pt: {}, severity: 'danger' })
|
|
||||||
expect(result).not.toHaveProperty('severity')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('LoadingIcon', () => {
|
describe('LoadingIcon', () => {
|
||||||
it('is a valid Vue component', () => {
|
it('is a valid Vue component', () => {
|
||||||
expect(LoadingIcon).toBeDefined()
|
expect(LoadingIcon).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('themed pt components', () => {
|
|
||||||
const cases = [
|
|
||||||
{ name: 'InputText', component: InputText, base: 'nych-input' },
|
|
||||||
{ name: 'Textarea', component: Textarea, base: 'nych-textarea' },
|
|
||||||
{ name: 'Checkbox', component: Checkbox, base: 'nych-checkbox' },
|
|
||||||
{ name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' },
|
|
||||||
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
|
|
||||||
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
|
|
||||||
{ name: 'ToggleSwitch', component: ToggleSwitch, base: 'nych-toggleswitch' },
|
|
||||||
{ name: 'Select', component: Select, base: 'nych-select' },
|
|
||||||
{ name: 'Card', component: Card, base: 'nych-card' },
|
|
||||||
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
|
|
||||||
{ name: 'Message', component: Message, base: 'nych-message' },
|
|
||||||
{ name: 'Tag', component: Tag, base: 'nych-tag' },
|
|
||||||
]
|
|
||||||
|
|
||||||
it.each(cases)('$name is a valid Vue component', ({ component }) => {
|
|
||||||
expect(component).toBeDefined()
|
|
||||||
expect(typeof (component as Record<string, unknown>).setup).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(cases)('$name injects its base class onto pt.root', ({ component, base }) => {
|
|
||||||
const result = callSetup(component, { pt: {} })
|
|
||||||
expect(result.pt?.root?.class).toContain(base)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(cases)('$name does not leak engine internal props', ({ component }) => {
|
|
||||||
const result = callSetup(component, { pt: {} })
|
|
||||||
expect(result).not.toHaveProperty('injectedClasses')
|
|
||||||
expect(result).not.toHaveProperty('injectedStyles')
|
|
||||||
expect(result).not.toHaveProperty('stylePlug')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Checkbox themes its box, input and icon sections', () => {
|
|
||||||
const result = callSetup(Checkbox, { pt: {} })
|
|
||||||
expect(result.pt?.box?.class).toContain('nych-checkbox-box')
|
|
||||||
expect(result.pt?.input?.class).toContain('nych-checkbox-input')
|
|
||||||
expect(result.pt?.icon?.class).toContain('nych-checkbox-icon')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Select themes its overlay and option sections', () => {
|
|
||||||
const result = callSetup(Select, { pt: {} })
|
|
||||||
expect(result.pt?.overlay?.class).toContain('nych-select-overlay')
|
|
||||||
expect(result.pt?.option?.class).toContain('nych-select-option')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
69
packages/library/tests/primitive.spec.ts
Normal file
69
packages/library/tests/primitive.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { Primitive } from '../src/lib/primitive'
|
||||||
|
import SlotPassthrough from './fixtures/SlotPassthrough.vue'
|
||||||
|
|
||||||
|
describe('Primitive', () => {
|
||||||
|
it('renders the "as" tag with forwarded attrs when asChild is false', () => {
|
||||||
|
const wrapper = mount(Primitive, {
|
||||||
|
props: { as: 'a' },
|
||||||
|
attrs: { href: '/somewhere', class: 'link' },
|
||||||
|
slots: { default: () => 'Go' },
|
||||||
|
})
|
||||||
|
const el = wrapper.get('a')
|
||||||
|
expect(el.attributes('href')).toBe('/somewhere')
|
||||||
|
expect(el.classes()).toContain('link')
|
||||||
|
expect(el.text()).toBe('Go')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clones the single child and merges attrs onto it when asChild is true', async () => {
|
||||||
|
let clicked = false
|
||||||
|
const wrapper = mount(Primitive, {
|
||||||
|
props: { asChild: true },
|
||||||
|
attrs: { class: 'from-parent', 'data-slot': 'button', onClick: () => { clicked = true } },
|
||||||
|
slots: {
|
||||||
|
default: () => h('button', { class: 'from-child', type: 'button' }, 'Click'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const el = wrapper.get('button')
|
||||||
|
expect(el.attributes('data-slot')).toBe('button')
|
||||||
|
expect(el.attributes('type')).toBe('button')
|
||||||
|
expect(el.classes()).toContain('from-parent')
|
||||||
|
expect(el.classes()).toContain('from-child')
|
||||||
|
await el.trigger('click')
|
||||||
|
expect(clicked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if asChild is true with zero or multiple children', () => {
|
||||||
|
expect(() =>
|
||||||
|
mount(Primitive, {
|
||||||
|
props: { asChild: true },
|
||||||
|
slots: { default: () => [h('span', 'a'), h('span', 'b')] },
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges attrs through a real SFC that forwards `<slot />` as asChild content', async () => {
|
||||||
|
// Regression test: `<slot />` used as a passthrough outlet inside an SFC
|
||||||
|
// template resolves through Vue's renderSlot() helper, which wraps the
|
||||||
|
// forwarded content in a Fragment vnode even when there's exactly one
|
||||||
|
// real child. Slot must unwrap that Fragment before cloning, or the
|
||||||
|
// merged attrs silently vanish onto the inert wrapper instead of the
|
||||||
|
// real child element.
|
||||||
|
let clicked = false
|
||||||
|
const wrapper = mount(SlotPassthrough, {
|
||||||
|
props: { asChild: true },
|
||||||
|
attrs: { onClick: () => { clicked = true } },
|
||||||
|
slots: {
|
||||||
|
default: () => h('a', { href: '/somewhere' }, 'Click'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const el = wrapper.get('a')
|
||||||
|
expect(el.attributes('data-slot')).toBe('passthrough-wrapper')
|
||||||
|
expect(el.attributes('type')).toBe('button')
|
||||||
|
expect(el.attributes('href')).toBe('/somewhere')
|
||||||
|
await el.trigger('click')
|
||||||
|
expect(clicked).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
28
packages/library/tests/tailwind-tokens.spec.ts
Normal file
28
packages/library/tests/tailwind-tokens.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const cssPath = resolve(import.meta.dirname, '../src/assets/css/tailwind.css')
|
||||||
|
|
||||||
|
describe('tailwind token mapping', () => {
|
||||||
|
const css = readFileSync(cssPath, 'utf-8')
|
||||||
|
|
||||||
|
it('maps --color-primary onto the existing --primary token', () => {
|
||||||
|
expect(css).toMatch(/--color-primary:\s*var\(--primary\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps --color-destructive onto the existing --danger token', () => {
|
||||||
|
expect(css).toMatch(/--color-destructive:\s*var\(--danger\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defines the extra info/success/warning color slots', () => {
|
||||||
|
expect(css).toMatch(/--color-info:\s*var\(--info\)/)
|
||||||
|
expect(css).toMatch(/--color-success:\s*var\(--success\)/)
|
||||||
|
expect(css).toMatch(/--color-warning:\s*var\(--warn\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps both Hades and Apollo theme selectors', () => {
|
||||||
|
expect(css).toContain("[data-theme='hades']")
|
||||||
|
expect(css).toContain("[data-theme='apollo']")
|
||||||
|
})
|
||||||
|
})
|
||||||
143
packages/library/tests/use-dismissable-layer.spec.ts
Normal file
143
packages/library/tests/use-dismissable-layer.spec.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { useDismissableLayer } from '../src/lib/use-dismissable-layer'
|
||||||
|
|
||||||
|
function firePointerDown(target: EventTarget) {
|
||||||
|
const event = new PointerEvent('pointerdown', { bubbles: true, cancelable: true })
|
||||||
|
target.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useDismissableLayer', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDismiss on Escape keydown while active', () => {
|
||||||
|
const containerRef = ref<HTMLElement | null>(document.createElement('div'))
|
||||||
|
const active = ref(true)
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
useDismissableLayer(containerRef, active, { onDismiss })
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onDismiss on Escape when inactive', () => {
|
||||||
|
const containerRef = ref<HTMLElement | null>(document.createElement('div'))
|
||||||
|
const active = ref(false)
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
useDismissableLayer(containerRef, active, { onDismiss })
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDismiss on pointerdown outside the container', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const outside = document.createElement('div')
|
||||||
|
document.body.append(container, outside)
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(true)
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
useDismissableLayer(containerRef, active, { onDismiss })
|
||||||
|
|
||||||
|
firePointerDown(outside)
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call onDismiss on pointerdown inside the container', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const inside = document.createElement('span')
|
||||||
|
container.append(inside)
|
||||||
|
document.body.append(container)
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(true)
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
useDismissableLayer(containerRef, active, { onDismiss })
|
||||||
|
|
||||||
|
firePointerDown(inside)
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips onDismiss when onPointerDownOutside calls preventDefault', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const outside = document.createElement('div')
|
||||||
|
document.body.append(container, outside)
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(true)
|
||||||
|
const onDismiss = vi.fn()
|
||||||
|
useDismissableLayer(containerRef, active, {
|
||||||
|
onDismiss,
|
||||||
|
onPointerDownOutside: (event) => event.preventDefault(),
|
||||||
|
})
|
||||||
|
|
||||||
|
firePointerDown(outside)
|
||||||
|
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only dismisses the topmost layer on Escape when layers are stacked', () => {
|
||||||
|
const container1 = document.createElement('div')
|
||||||
|
const container2 = document.createElement('div')
|
||||||
|
document.body.append(container1, container2)
|
||||||
|
const active1 = ref(true)
|
||||||
|
const active2 = ref(true)
|
||||||
|
const onDismiss1 = vi.fn()
|
||||||
|
const onDismiss2 = vi.fn()
|
||||||
|
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||||
|
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
|
||||||
|
expect(onDismiss2).toHaveBeenCalledOnce()
|
||||||
|
expect(onDismiss1).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
active1.value = false
|
||||||
|
active2.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only dismisses the topmost layer on pointerdown outside it, even inside a lower layer', () => {
|
||||||
|
const container1 = document.createElement('div')
|
||||||
|
const container2 = document.createElement('div')
|
||||||
|
document.body.append(container1, container2)
|
||||||
|
const active1 = ref(true)
|
||||||
|
const active2 = ref(true)
|
||||||
|
const onDismiss1 = vi.fn()
|
||||||
|
const onDismiss2 = vi.fn()
|
||||||
|
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||||
|
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||||
|
|
||||||
|
firePointerDown(container1)
|
||||||
|
|
||||||
|
expect(onDismiss2).toHaveBeenCalledOnce()
|
||||||
|
expect(onDismiss1).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
active1.value = false
|
||||||
|
active2.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes the next layer to topmost once the top layer deactivates', async () => {
|
||||||
|
const container1 = document.createElement('div')
|
||||||
|
const container2 = document.createElement('div')
|
||||||
|
document.body.append(container1, container2)
|
||||||
|
const active1 = ref(true)
|
||||||
|
const active2 = ref(true)
|
||||||
|
const onDismiss1 = vi.fn()
|
||||||
|
const onDismiss2 = vi.fn()
|
||||||
|
useDismissableLayer(ref(container1), active1, { onDismiss: onDismiss1 })
|
||||||
|
useDismissableLayer(ref(container2), active2, { onDismiss: onDismiss2 })
|
||||||
|
|
||||||
|
active2.value = false
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
|
||||||
|
expect(onDismiss1).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
active1.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
92
packages/library/tests/use-focus-trap.spec.ts
Normal file
92
packages/library/tests/use-focus-trap.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { useFocusTrap } from '../src/lib/use-focus-trap'
|
||||||
|
|
||||||
|
function appendContainer() {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const first = document.createElement('button')
|
||||||
|
first.textContent = 'first'
|
||||||
|
const last = document.createElement('button')
|
||||||
|
last.textContent = 'last'
|
||||||
|
container.append(first, last)
|
||||||
|
document.body.append(container)
|
||||||
|
return { container, first, last }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFocusTrap', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus into the container on activation', async () => {
|
||||||
|
const { container, first } = appendContainer()
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(false)
|
||||||
|
useFocusTrap(containerRef, active)
|
||||||
|
|
||||||
|
active.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the initialFocus override when provided', async () => {
|
||||||
|
const { container, last } = appendContainer()
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(false)
|
||||||
|
useFocusTrap(containerRef, active, { initialFocus: () => last })
|
||||||
|
|
||||||
|
active.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps Tab from the last focusable back to the first', async () => {
|
||||||
|
const { container, first, last } = appendContainer()
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(true)
|
||||||
|
useFocusTrap(containerRef, active)
|
||||||
|
last.focus()
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps Shift+Tab from the first focusable back to the last', async () => {
|
||||||
|
const { container, first, last } = appendContainer()
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(true)
|
||||||
|
useFocusTrap(containerRef, active)
|
||||||
|
first.focus()
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true })
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores focus to the previously-focused element on deactivation', async () => {
|
||||||
|
const opener = document.createElement('button')
|
||||||
|
opener.textContent = 'opener'
|
||||||
|
document.body.append(opener)
|
||||||
|
opener.focus()
|
||||||
|
|
||||||
|
const { container } = appendContainer()
|
||||||
|
const containerRef = ref<HTMLElement | null>(container)
|
||||||
|
const active = ref(false)
|
||||||
|
useFocusTrap(containerRef, active)
|
||||||
|
|
||||||
|
active.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
active.value = false
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(opener)
|
||||||
|
})
|
||||||
|
})
|
||||||
57
packages/library/tests/use-popover-position.spec.ts
Normal file
57
packages/library/tests/use-popover-position.spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { usePopoverPosition } from '../src/lib/use-popover-position'
|
||||||
|
|
||||||
|
function mockRect(el: HTMLElement, rect: Partial<DOMRect>) {
|
||||||
|
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0,
|
||||||
|
toJSON: () => {},
|
||||||
|
...rect,
|
||||||
|
} as DOMRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePopoverPosition', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places the content below the trigger when there is enough room', async () => {
|
||||||
|
const trigger = document.createElement('button')
|
||||||
|
const content = document.createElement('div')
|
||||||
|
document.body.append(trigger, content)
|
||||||
|
mockRect(trigger, { top: 100, bottom: 130, left: 20, width: 80 })
|
||||||
|
Object.defineProperty(content, 'offsetHeight', { value: 40, configurable: true })
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000)
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const position = usePopoverPosition(ref(trigger), ref(content), open)
|
||||||
|
open.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(position.side).toBe('bottom')
|
||||||
|
expect(position.top).toBe(134)
|
||||||
|
expect(position.minWidth).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips above the trigger when there is not enough room below', async () => {
|
||||||
|
const trigger = document.createElement('button')
|
||||||
|
const content = document.createElement('div')
|
||||||
|
document.body.append(trigger, content)
|
||||||
|
mockRect(trigger, { top: 700, bottom: 730, left: 20, width: 80 })
|
||||||
|
Object.defineProperty(content, 'offsetHeight', { value: 200, configurable: true })
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000)
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const position = usePopoverPosition(ref(trigger), ref(content), open)
|
||||||
|
open.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(position.side).toBe('top')
|
||||||
|
expect(position.top).toBeLessThan(700)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
packages/library/tests/use-roving-focus.spec.ts
Normal file
71
packages/library/tests/use-roving-focus.spec.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRovingFocus } from '../src/lib/use-roving-focus'
|
||||||
|
|
||||||
|
function makeItems(count: number) {
|
||||||
|
const items = Array.from({ length: count }, (_, i) => {
|
||||||
|
const el = document.createElement('button')
|
||||||
|
el.textContent = `item-${i}`
|
||||||
|
document.body.append(el)
|
||||||
|
return el
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrowDown() {
|
||||||
|
return new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })
|
||||||
|
}
|
||||||
|
function arrowUp() {
|
||||||
|
return new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useRovingFocus', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focusIndex focuses the item at that index', () => {
|
||||||
|
const items = makeItems(3)
|
||||||
|
const { focusIndex } = useRovingFocus(ref(items))
|
||||||
|
focusIndex(1)
|
||||||
|
expect(document.activeElement).toBe(items[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focusIndex wraps by default when loop is not set', () => {
|
||||||
|
const items = makeItems(3)
|
||||||
|
const { focusIndex } = useRovingFocus(ref(items))
|
||||||
|
focusIndex(3)
|
||||||
|
expect(document.activeElement).toBe(items[0])
|
||||||
|
focusIndex(-1)
|
||||||
|
expect(document.activeElement).toBe(items[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focusIndex clamps instead of wrapping when loop is false', () => {
|
||||||
|
const items = makeItems(3)
|
||||||
|
const { focusIndex } = useRovingFocus(ref(items), { loop: false })
|
||||||
|
focusIndex(5)
|
||||||
|
expect(document.activeElement).toBe(items[2])
|
||||||
|
focusIndex(-5)
|
||||||
|
expect(document.activeElement).toBe(items[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleKeydown moves focus forward/backward on ArrowDown/ArrowUp for vertical orientation', () => {
|
||||||
|
const items = makeItems(3)
|
||||||
|
const { handleKeydown } = useRovingFocus(ref(items))
|
||||||
|
items[0]!.focus()
|
||||||
|
handleKeydown(arrowDown())
|
||||||
|
expect(document.activeElement).toBe(items[1])
|
||||||
|
handleKeydown(arrowUp())
|
||||||
|
expect(document.activeElement).toBe(items[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleKeydown jumps to first/last on Home/End', () => {
|
||||||
|
const items = makeItems(3)
|
||||||
|
const { handleKeydown } = useRovingFocus(ref(items))
|
||||||
|
items[1]!.focus()
|
||||||
|
handleKeydown(new KeyboardEvent('keydown', { key: 'End', cancelable: true }))
|
||||||
|
expect(document.activeElement).toBe(items[2])
|
||||||
|
handleKeydown(new KeyboardEvent('keydown', { key: 'Home', cancelable: true }))
|
||||||
|
expect(document.activeElement).toBe(items[0])
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue