Compare commits

...

No commits in common. "main" and "fix/ci-pub" have entirely different histories.

120 changed files with 4031 additions and 4367 deletions

View file

@ -8,11 +8,14 @@ on:
jobs:
build:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
runs-on: ubuntu-latest
container: oven/bun:1
outputs:
artifact_name: ${{ steps.meta.outputs.name }}
steps:
- name: Install git and node
run: apt-get update && apt-get install -y git nodejs
- name: Checkout
uses: actions/checkout@v4
@ -49,9 +52,12 @@ jobs:
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Install git
run: apt-get update && apt-get install -y git nodejs npm
- name: Checkout
uses: actions/checkout@v4
@ -68,8 +74,13 @@ jobs:
run: bun pm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
working-directory: packages/library
- name: Debug token presence
run: '[ -n "$FORGEJO_TOKEN" ] && echo "token set, length: ${#FORGEJO_TOKEN}" || echo "token EMPTY"'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- name: Configure registry auth
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${FORGEJO_TOKEN}" > /root/.npmrc
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${FORGEJO_TOKEN}" > .npmrc
working-directory: packages/library
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
@ -81,15 +92,12 @@ jobs:
publish-docs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-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:

756
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,708 @@
# 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.

View file

@ -0,0 +1,222 @@
# Docs Site Docker Image + Install Page 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:** Add an Installation page to the Storybook playground, package the playground's static build as a `nychthemeron-docs` Docker image served by nginx, and wire up CI to build/push that image on version tags.
**Architecture:** A new MDX docs page (`packages/playground/stories/Install.mdx`) documents installing `@nychthemeron/library` via npm/pnpm/yarn/bun from the private Forgejo npm registry. A new multi-stage `packages/playground/Dockerfile` builds the library and Storybook static output with `oven/bun:1`, then copies `storybook-static` into `nginx:alpine`. A root `.dockerignore` keeps the build context lean. A new `publish-docs` job in `.forgejo/workflows/ci.yml`, parallel to `publish`, builds and pushes the image on `v*` tags.
**Tech Stack:** Storybook 10 (`@storybook/addon-docs`), Vue 3, Bun, Docker (multi-stage, nginx:alpine), Forgejo Actions.
---
### Task 1: Add Installation MDX page
**Files:**
- Create: `packages/playground/stories/Install.mdx`
- [ ] **Step 1: Create the MDX file**
```mdx
import { Meta } from '@storybook/addon-docs/blocks'
<Meta title="Get Started/Installation" />
# Installation
`@nychthemeron/library` is published to a private npm registry hosted on
Forgejo. Configure your package manager for the `@nychthemeron` scope, then
install the package.
## Configure the registry
Add the following to your project's `.npmrc`:
```
@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/
```
## npm
```bash
npm install @nychthemeron/library
```
## pnpm
```bash
pnpm add @nychthemeron/library
```
## yarn
```bash
yarn add @nychthemeron/library
```
## bun
```bash
bun add @nychthemeron/library
```
```
- [ ] **Step 2: Build Storybook and verify the page is included**
Run: `cd packages/playground && bun run build`
Expected: Build succeeds with output ending in something like
`Output directory: storybook-static`. Then run:
`grep -o '"title":"Get Started/Installation"' packages/playground/storybook-static/index.json`
Expected: prints `"title":"Get Started/Installation"`.
- [ ] **Step 3: Commit**
```bash
git add packages/playground/stories/Install.mdx
git commit -m "Add installation docs page to playground"
```
---
### Task 2: Add root `.dockerignore`
**Files:**
- Create: `.dockerignore`
- [ ] **Step 1: Create `.dockerignore`**
```
node_modules
**/node_modules
**/dist
**/storybook-static
**/.storybook-static
.git
docs
.tmp
.bun-cache
.pw-browsers
.pw-libs
.shots
*.log
```
- [ ] **Step 2: Commit**
```bash
git add .dockerignore
git commit -m "Add .dockerignore for docs Docker build"
```
---
### Task 3: Add Dockerfile for the docs site
**Files:**
- Create: `packages/playground/Dockerfile`
- [ ] **Step 1: Create the Dockerfile**
```dockerfile
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN cd packages/library && bun run build
RUN cd packages/playground && bun run build
FROM nginx:alpine AS runtime
COPY --from=build /app/packages/playground/storybook-static /usr/share/nginx/html
EXPOSE 80
```
- [ ] **Step 2: Build and run the image locally**
Run (from repo root):
```bash
docker build -t nychthemeron-docs:test -f packages/playground/Dockerfile .
docker run -d --rm --name nychthemeron-docs-test -p 8080:80 nychthemeron-docs:test
sleep 2
curl -sf http://localhost:8080/ | grep -i storybook
docker stop nychthemeron-docs-test
```
Expected: `docker build` completes successfully, the container starts, and
the `curl` output contains a reference to `storybook` (from
`storybook-static/index.html`).
> If the Docker CLI/daemon isn't available in the current environment (e.g.
> sandboxed dev container), skip running this step here and ask the user to
> run it locally before merging.
- [ ] **Step 3: Commit**
```bash
git add packages/playground/Dockerfile
git commit -m "Add Dockerfile for nychthemeron-docs static site"
```
---
### Task 4: Add `publish-docs` CI job
**Files:**
- Modify: `.forgejo/workflows/ci.yml`
- [ ] **Step 1: Add the `publish-docs` job**
Add this job after the existing `publish` job (same indentation level,
i.e. as a sibling under `jobs:`):
```yaml
publish-docs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
```
- [ ] **Step 2: Verify YAML structure**
Run: `bun run --eval "console.log('ok')"` is not a YAML check — instead,
visually confirm in the file that `publish-docs:` is indented exactly like
`publish:` (4 spaces under `jobs:`), and that all of its child keys
(`needs`, `if`, `runs-on`, `steps`, and each `- name:` step) match the
indentation pattern used by the `publish` job. Confirm there are exactly
three top-level jobs: `build`, `publish`, `publish-docs`.
- [ ] **Step 3: Commit**
```bash
git add .forgejo/workflows/ci.yml
git commit -m "Add publish-docs job to build and push nychthemeron-docs image"
```

View file

@ -0,0 +1,284 @@
# 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

View file

@ -0,0 +1,55 @@
# Docs site (playground) Docker image + install page
## Goal
Publish the Storybook playground as a static "docs" site, package it as a
Docker image (`nychthemeron-docs`), and add an Installation page documenting
how to install `@nychthemeron/library` from the private Forgejo npm registry
via npm, pnpm, yarn, and bun.
## Install page
- New file: `packages/playground/stories/Install.mdx`
- Storybook addon-docs MDX page (no story export), `Meta` title
`Get Started/Installation` so it sorts near the top of the sidebar.
- Content:
- Heading + short description of the library.
- Registry config snippet (`.npmrc`) showing how to point the scoped
`@nychthemeron` registry at `https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/`.
- Four code blocks showing install commands for `@nychthemeron/library`:
npm, pnpm, yarn, bun.
## Docker image (`nychthemeron-docs`)
- New file: `packages/playground/Dockerfile`, multi-stage build:
- **Stage 1 (`oven/bun:1`)**: copy the workspace, `bun install --frozen-lockfile`,
build `@nychthemeron/library` (`cd packages/library && bun run build`),
then build Storybook (`cd packages/playground && bun run build` →
`packages/playground/storybook-static`).
- **Stage 2 (`nginx:alpine`)**: copy `storybook-static` into
`/usr/share/nginx/html`, default nginx config, `EXPOSE 80`.
- Build context is the repo root (so the workspace lockfile and all
packages are available to stage 1).
## CI changes
In `.forgejo/workflows/ci.yml`, add a new `publish-docs` job:
- Parallel to the existing `publish` job (both depend on `build`).
- Gated on `startsWith(github.ref, 'refs/tags/v')`.
- Steps:
1. Checkout
2. Log in to `git.mcpeakdev.com` using `docker/login-action` with
`secrets.FORGEJO_TOKEN` (same credential as the library publish job).
3. Extract version from tag (`${GITHUB_REF_NAME#v}`).
4. `docker/build-push-action` with context `.`, dockerfile
`packages/playground/Dockerfile`, push `true`, tags:
- `git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:<version>`
- `git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:latest`
## Out of scope
- No changes to the existing `build` or `publish` (npm) jobs beyond what's
already in place.
- No SPA routing / custom nginx config — Storybook's static output is
served as-is.

View file

@ -22,16 +22,6 @@ export default defineConfigWithVueTs(
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
// shadcn-vue's generated ui/ components use single-word names by convention
// (Button.vue, Input.vue, ...) so their file names match the CLI's own output.
name: 'app/shadcn-vue-component-names',
files: ['**/components/ui/**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],

View file

@ -21,6 +21,7 @@
"oxlint": "~1.69.0",
"playwright": "^1.60.0",
"prettier": "3.8.4",
"primevue": "^4.5.5",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",

View file

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

View file

@ -4,7 +4,6 @@
"type": "module",
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.js",
"types": "./dist/src/index.d.ts"
},
@ -13,7 +12,11 @@
"types": "./src/index.d.ts"
},
"./style": {
"import": "./src/assets/css/tailwind.css",
"import": "./src/assets/css/nychthemeron.css",
"types": "./src/index.d.ts"
},
"./theme": {
"import": "./src/assets/theme/theme.css",
"types": "./src/index.d.ts"
}
},
@ -28,25 +31,12 @@
"test:unit:watch": "vitest"
},
"peerDependencies": {
"vue": "^3.5.0"
"vue": "^3.5.0",
"primevue": "^4.5.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"@vue/test-utils": "^2.4.6",
"vite-plugin-dts": "^5.0.2"
},
"dependencies": {
"@fontsource/cinzel": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@lucide/vue": "^1.24.0",
"@tailwindcss/vite": "^4.3.2",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"shadcn-vue": "^2.7.4",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0"
}
}

View file

@ -0,0 +1,169 @@
/* ============================================================
nychthemeron.css
Toggle themes by setting data-theme on <html>:
<html data-theme="hades">
<html data-theme="apollo">
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap');
/* ── HADES ───────────────────────────────────────────────── */
[data-theme='hades'] {
/* surfaces */
--surface-0: #18160f;
--surface-1: #28241a;
--surface-2: #2e2a1e;
--surface-3: #322e22;
--track: #32302a;
/* borders */
--border: #4a4538;
--border-hi: #5a5040;
--border-lo: #38342a;
/* primary */
--primary: #d4b278;
--primary-fg: #0e0c08;
/* semantic */
--warn: #e08840; /* phlegethon */
--warn-subtle: #281808;
--warn-border: #4a2c10;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* plague */
--danger-subtle: #200808;
--danger-border: #3a1010;
--danger-fg: #fff8e8;
--info: #88aec8; /* cocytus */
--info-subtle: #101e2c;
--info-border: #1e3850;
--info-fg: #0a1828;
--neutral: #7a6e60; /* acheron */
--neutral-fg: #fff8e8;
--success: #6aaa7a; /* laurel */
--success-subtle: #0e2214;
--success-border: #1e4228;
--success-fg: #fff8e8;
/* text */
--text-high: #f4f0e8;
--text-body: #d0c8b8;
--text-muted: #908878;
--text-dim: #807868;
--text-label: #c0b8a8;
/* focus ring */
--focus-ring: 0 0 0 3px rgba(212, 178, 120, 0.65);
/* fonts */
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* ── APOLLO ──────────────────────────────────────────────── */
[data-theme='apollo'] {
/* surfaces */
--surface-0: #faf7f2;
--surface-1: #f4f0e8;
--surface-2: #eee9df;
--surface-3: #e4ddd0;
--track: #e4ddd0;
/* borders */
--border: #bab2a0;
--border-hi: #c0b8a8;
--border-lo: #ccc4b4;
/* primary */
--primary: #b8860b;
--primary-fg: #fff8e8;
/* semantic */
--warn: #9a6020; /* harmony */
--warn-subtle: #fdf3e0;
--warn-border: #e8d0a0;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* plague */
--danger-subtle: #faeaea;
--danger-border: #c8a0a0;
--danger-fg: #fff8e8;
--info: #3a5a8a; /* oracle */
--info-subtle: #e8eef8;
--info-border: #b0c4e0;
--info-fg: #fff8e8;
--neutral: #8a8070; /* marble */
--neutral-fg: #fff8e8;
--success: #5a7a40; /* laurel */
--success-subtle: #eef4e8;
--success-border: #c0d4a8;
--success-fg: #fff8e8;
/* text */
--text-high: #1a1610;
--text-body: #3a3020;
--text-muted: #5a5040;
--text-dim: #8a8070;
--text-label: #5a5040;
/* focus ring */
--focus-ring: 0 0 0 3px rgba(184, 134, 11, 0.65);
/* fonts */
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* ── BASE RESET ──────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
[class^='nych-'],
[class^='nych-']::before,
[class^='nych-']::after {
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[class^='nych-']:focus-visible {
outline: 2px solid transparent; /* visible in forced-colors / Windows High Contrast */
box-shadow: var(--focus-ring);
}
body {
background-color: var(--surface-0);
color: var(--text-body);
min-height: 100vh;
}
@keyframes nych-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.nych-loading-icon .wreath {
transform-origin: 340px 180px;
animation: nych-spin 2s linear infinite;
}

View file

@ -1,207 +0,0 @@
/* ============================================================
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);
}

View file

@ -0,0 +1,727 @@
@import url('../css/nychthemeron.css');
/* Restore serif font for nych- components even if consumer overrides html font-family */
[class^='nych-'] {
font-family: var(--font-serif);
}
[class^='nych-']:disabled {
cursor: not-allowed;
opacity: 0.5;
}
[class^='nych-button'] {
padding: 7px;
border-radius: 5px;
box-shadow: 0px 0px 10px var(--text-high);
border: none;
cursor: pointer;
transition: box-shadow 0.3s ease;
}
[class^='nych-button']:hover:not(:disabled) {
box-shadow: 0px 0px 8px var(--text-high);
}
[class^='nych-button'][data-p~='loading'] {
display: flex;
}
[class^='nych-button'][data-p~='loading'] span {
margin-top: 4px;
}
[class^='nych-'][class$='-primary'] {
background-color: var(--primary);
color: var(--primary-fg);
}
[class^='nych-'][class$='-secondary'],
[class^='nych-'][class$='-muted'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
[class^='nych-'][class$='-info'] {
background-color: var(--info);
color: var(--info-fg);
}
[class^='nych-'][class$='-success'] {
background-color: var(--success);
color: var(--success-fg);
}
[class^='nych-'][class$='-warning'] {
background-color: var(--warn);
color: var(--warn-fg);
}
[class^='nych-'][class$='-danger'],
[class^='nych-'][class$='-error'] {
background-color: var(--danger);
color: var(--danger-fg);
}
.nych-loading-icon .wreath path {
fill: var(--primary-fg);
}
.nych-loading-icon .wreath line {
stroke: var(--text-high);
}
button[data-p~='loading'] > .nych-loading-icon {
margin-right: 5px;
margin-top: 0px;
height: 25px;
font-size: 12pt;
}
[class^='nych-'][data-p~='small'] :not(svg, svg *) {
height: 15px !important;
font-size: 8pt !important;
}
[class^='nych-'][data-p~='normal'] :not(svg, svg *) {
height: 25px !important;
font-size: 12pt !important;
}
[class^='nych-'][data-p~='large'] :not(svg, svg *) {
height: 40px !important;
font-size: 20pt !important;
}
/* ============================================================
Text inputs InputText, Textarea
============================================================ */
.nych-input,
.nych-textarea {
font-family: var(--font-sans);
width: 100%;
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 5px;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-input::placeholder,
.nych-textarea::placeholder {
color: var(--text-dim);
}
.nych-input:hover:not(:disabled),
.nych-textarea:hover:not(:disabled) {
border-color: var(--border-hi);
}
.nych-input:focus,
.nych-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-input[data-p~='invalid'],
.nych-textarea[data-p~='invalid'] {
border-color: var(--danger);
}
.nych-input[data-p~='small'] {
padding: 5px 8px;
font-size: 0.875rem;
}
.nych-input[data-p~='large'] {
padding: 11px 13px;
font-size: 1.125rem;
}
.nych-textarea {
resize: vertical;
min-height: 84px;
line-height: 1.5;
}
/* ============================================================
Checkbox
============================================================ */
.nych-checkbox,
.nych-radio {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
vertical-align: middle;
}
.nych-checkbox-input,
.nych-radio-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-checkbox-box,
.nych-radio-box {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: var(--surface-1);
border: 1px solid var(--border);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-checkbox-box {
border-radius: 4px;
}
.nych-radio-box {
border-radius: 50%;
}
.nych-checkbox:hover .nych-checkbox-box,
.nych-radio:hover .nych-radio-box {
border-color: var(--border-hi);
}
.nych-checkbox[data-p-checked='true'] .nych-checkbox-box {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-checkbox-icon {
width: 13px;
height: 13px;
color: var(--primary-fg);
fill: var(--primary-fg);
stroke: var(--primary-fg);
}
.nych-checkbox[data-p-disabled='true'] .nych-checkbox-box,
.nych-radio[data-p-disabled='true'] .nych-radio-box {
opacity: 0.5;
}
/* Group containers (CheckboxGroup / RadioButtonGroup) — layout only. */
.nych-checkbox-group,
.nych-radio-group {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.nych-checkbox-input:focus-visible + .nych-checkbox-box,
.nych-radio-input:focus-visible + .nych-radio-box {
box-shadow: var(--focus-ring);
}
/* Keep the visually-hidden native control hidden even when disabled the
global [class^='nych-']:disabled opacity would otherwise reveal it. */
.nych-checkbox .nych-checkbox-input,
.nych-radio .nych-radio-input,
.nych-toggleswitch .nych-toggleswitch-input {
opacity: 0;
}
/* ============================================================
Radio button
============================================================ */
.nych-radio-icon {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: transparent;
transition: background-color 0.2s ease;
}
.nych-radio[data-p-checked='true'] .nych-radio-box {
border-color: var(--primary);
}
.nych-radio[data-p-checked='true'] .nych-radio-icon {
background-color: var(--primary);
}
/* ============================================================
Toggle switch
============================================================ */
.nych-toggleswitch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
}
.nych-toggleswitch-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-toggleswitch-slider {
position: absolute;
inset: 0;
background-color: var(--track);
border: 1px solid var(--border);
border-radius: 999px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-toggleswitch-handle {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
transform: translateY(-50%);
background-color: var(--text-muted);
border-radius: 50%;
transition:
left 0.2s ease,
background-color 0.2s ease;
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-slider {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-handle {
left: 25px;
background-color: var(--primary-fg);
}
.nych-toggleswitch-input:focus-visible + .nych-toggleswitch-slider {
box-shadow: var(--focus-ring);
}
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-slider,
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-handle {
opacity: 0.5;
}
/* ============================================================
Select
============================================================ */
.nych-select {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 13rem;
font-family: var(--font-sans);
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-select:hover {
border-color: var(--border-hi);
}
.nych-select[data-p~='focus'] {
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-select[data-p~='disabled'] {
cursor: not-allowed;
opacity: 0.5;
}
.nych-select-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nych-select-label[data-p~='placeholder'] {
color: var(--text-dim);
}
.nych-select-dropdown {
display: flex;
align-items: center;
color: var(--text-muted);
}
.nych-select-dropdownIcon {
width: 14px;
height: 14px;
}
.nych-select-overlay {
font-family: var(--font-sans);
margin-top: 4px;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.nych-select-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 14rem;
overflow-y: auto;
}
.nych-select-option {
padding: 8px 11px;
border-radius: 4px;
color: var(--text-body);
cursor: pointer;
transition: background-color 0.15s ease;
}
.nych-select-option[data-p-focused='true'],
.nych-select-option:hover {
background-color: var(--surface-3);
}
.nych-select-option[data-p-selected='true'] {
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-select-emptyMessage {
padding: 8px 11px;
color: var(--text-dim);
}
/* ============================================================
Card
============================================================ */
.nych-card {
display: flex;
flex-direction: column;
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
overflow: hidden;
color: var(--text-body);
}
.nych-card-header :where(img) {
display: block;
width: 100%;
}
.nych-card-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.nych-card-caption {
display: flex;
flex-direction: column;
gap: 4px;
}
.nych-card-title {
font-family: var(--font-serif);
font-size: 1.4rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-card-subtitle {
font-family: var(--font-sans);
font-size: 0.95rem;
color: var(--text-muted);
}
.nych-card-content {
font-family: var(--font-sans);
line-height: 1.6;
}
.nych-card-footer {
display: flex;
gap: 8px;
padding-top: 4px;
}
/* ============================================================
Message
============================================================ */
.nych-message {
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 6px;
}
.nych-message-content {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
}
.nych-message-icon {
display: flex;
flex-shrink: 0;
width: 18px;
height: 18px;
}
.nych-message-text {
font-family: var(--font-sans);
line-height: 1.4;
}
.nych-message-closeButton {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
}
.nych-message-closeButton:hover {
opacity: 1;
background-color: rgba(128, 128, 128, 0.18);
}
.nych-message-closeIcon {
width: 12px;
height: 12px;
}
/* Severity tints are mixed from the semantic colour into the surface so they
read consistently in both the dark (Hades) and light (Apollo) themes,
rather than relying on the very-dark *-subtle tokens that muddied Hades. */
.nych-message[data-p~='info'] {
background-color: color-mix(in srgb, var(--info) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--info) 30%, var(--border));
border-left-color: var(--info);
}
.nych-message[data-p~='info'] .nych-message-icon {
color: var(--info);
}
.nych-message[data-p~='success'] {
background-color: color-mix(in srgb, var(--success) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
border-left-color: var(--success);
}
.nych-message[data-p~='success'] .nych-message-icon {
color: var(--success);
}
.nych-message[data-p~='warn'] {
background-color: color-mix(in srgb, var(--warn) 16%, var(--surface-1));
border-color: color-mix(in srgb, var(--warn) 32%, var(--border));
border-left-color: var(--warn);
}
.nych-message[data-p~='warn'] .nych-message-icon {
color: var(--warn);
}
.nych-message[data-p~='error'] {
background-color: color-mix(in srgb, var(--danger) 20%, var(--surface-1));
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
border-left-color: var(--danger);
}
.nych-message[data-p~='error'] .nych-message-icon {
color: var(--danger);
}
.nych-message[data-p~='secondary'] {
background-color: var(--surface-2);
border-left-color: var(--neutral);
}
/* ============================================================
Tag
============================================================ */
.nych-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1;
border-radius: 4px;
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-tag[data-p~='rounded'] {
border-radius: 999px;
}
.nych-tag-icon {
width: 12px;
height: 12px;
}
.nych-tag[data-p~='secondary'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
.nych-tag[data-p~='info'] {
background-color: var(--info);
color: var(--info-fg);
}
.nych-tag[data-p~='success'] {
background-color: var(--success);
color: var(--success-fg);
}
.nych-tag[data-p~='warn'] {
background-color: var(--warn);
color: var(--warn-fg);
}
.nych-tag[data-p~='danger'] {
background-color: var(--danger);
color: var(--danger-fg);
}
.nych-tag[data-p~='contrast'] {
background-color: var(--text-high);
color: var(--surface-0);
}
/* ============================================================
Dialog
============================================================ */
.nych-dialog-mask {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.55);
}
.nych-dialog {
display: flex;
flex-direction: column;
max-width: 92vw;
max-height: 90vh;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.nych-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-lo);
}
.nych-dialog-title {
font-family: var(--font-serif);
font-size: 1.3rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-dialog-headerActions {
display: flex;
align-items: center;
gap: 4px;
}
.nych-dialog-headerActions button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.nych-dialog-headerActions button:hover {
color: var(--text-high);
background-color: var(--surface-3);
}
.nych-dialog-headerActions button svg {
width: 14px;
height: 14px;
}
.nych-dialog-content {
font-family: var(--font-sans);
padding: 20px;
line-height: 1.6;
overflow-y: auto;
}
.nych-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-lo);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +0,0 @@
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>

View file

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

View file

@ -1,31 +0,0 @@
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,42 +1,25 @@
export {
ThemeEngine,
type ThemeLibrary,
type Themeable,
type LibComponent,
type Key,
type Props,
} from './engine'
export {
createNychthemeron,
Button,
LoadingIcon,
Input,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Switch,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge,
Message,
Tag,
} from './lib'

View file

@ -1,217 +1,210 @@
import type { App } from 'vue'
import {
NychLoadingIcon,
Button,
buttonVariants,
Input,
Textarea,
Switch,
Badge,
badgeVariants,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
} from '../components'
Button as PrimeButton,
InputText as PrimeInputText,
Textarea as PrimeTextarea,
Checkbox as PrimeCheckbox,
CheckboxGroup as PrimeCheckboxGroup,
RadioButton as PrimeRadioButton,
RadioButtonGroup as PrimeRadioButtonGroup,
ToggleSwitch as PrimeToggleSwitch,
Select as PrimeSelect,
Card as PrimeCard,
Dialog as PrimeDialog,
Message as PrimeMessage,
Tag as PrimeTag,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { NychLoadingIcon } from '../components'
export {
Button,
buttonVariants,
Input,
Textarea,
Switch,
Badge,
badgeVariants,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
cardVariants,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
alertVariants,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
*
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
* `nych-<base>-<section>` class onto every additional section so multi-part components
* (Checkbox box, Select overlay, Card body, ) can be fully styled in unstyled mode, where
* PrimeVue emits no classes of its own.
*/
const ptThemeable = <T extends LibComponent>(
component: T,
base: string,
sections: string[] = [],
): Themeable<T> => ({
component,
unstyled: true as Themeable<T>['unstyled'],
classes: [`nych-${base}`],
injectionKeys: { class: 'pt' as Key<T> },
propMutator: ((props: Record<string, unknown>) => {
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
for (const section of sections) {
const existing = pt[section]?.class
pt[section] = {
...(pt[section] ?? {}),
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
}
}
props.pt = pt
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',
},
}
export const LoadingIcon = NychLoadingIcon
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 = (): {
Button: typeof Button
Button: typeof PrimeButton
LoadingIcon: typeof NychLoadingIcon
Input: typeof Input
Textarea: typeof Textarea
Checkbox: typeof Checkbox
CheckboxGroup: typeof CheckboxGroup
RadioGroup: typeof RadioGroup
RadioGroupItem: typeof RadioGroupItem
Switch: typeof Switch
Select: typeof Select
SelectTrigger: typeof SelectTrigger
SelectValue: typeof SelectValue
SelectContent: typeof SelectContent
SelectItem: typeof SelectItem
SelectGroup: typeof SelectGroup
SelectLabel: typeof SelectLabel
SelectSeparator: typeof SelectSeparator
Card: typeof Card
CardHeader: typeof CardHeader
CardTitle: typeof CardTitle
CardDescription: typeof CardDescription
CardContent: typeof CardContent
CardFooter: typeof CardFooter
CardAction: typeof CardAction
Dialog: typeof Dialog
DialogTrigger: typeof DialogTrigger
DialogContent: typeof DialogContent
DialogHeader: typeof DialogHeader
DialogTitle: typeof DialogTitle
DialogDescription: typeof DialogDescription
DialogFooter: typeof DialogFooter
DialogClose: typeof DialogClose
DialogOverlay: typeof DialogOverlay
DialogScrollContent: typeof DialogScrollContent
Alert: typeof Alert
AlertTitle: typeof AlertTitle
AlertDescription: typeof AlertDescription
AlertAction: typeof AlertAction
Badge: typeof Badge
InputText: typeof PrimeInputText
Textarea: typeof PrimeTextarea
Checkbox: typeof PrimeCheckbox
CheckboxGroup: typeof PrimeCheckboxGroup
RadioButton: typeof PrimeRadioButton
RadioButtonGroup: typeof PrimeRadioButtonGroup
ToggleSwitch: typeof PrimeToggleSwitch
Select: typeof PrimeSelect
Card: typeof PrimeCard
Dialog: typeof PrimeDialog
Message: typeof PrimeMessage
Tag: typeof PrimeTag
install: (app: App) => void
} => ({
Button,
LoadingIcon,
Input,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioGroup,
RadioGroupItem,
Switch,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
CardAction,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
DialogOverlay,
DialogScrollContent,
Alert,
AlertTitle,
AlertDescription,
AlertAction,
Badge,
Message,
Tag,
install(app: App) {
app.component('NychButton', Button)
app.component('NychLoadingIcon', LoadingIcon)
app.component('NychInput', Input)
app.component('NychInputText', InputText)
app.component('NychTextarea', Textarea)
app.component('NychCheckbox', Checkbox)
app.component('NychCheckboxGroup', CheckboxGroup)
app.component('NychRadioGroup', RadioGroup)
app.component('NychRadioGroupItem', RadioGroupItem)
app.component('NychSwitch', Switch)
app.component('NychRadioButton', RadioButton)
app.component('NychRadioButtonGroup', RadioButtonGroup)
app.component('NychToggleSwitch', ToggleSwitch)
app.component('NychSelect', Select)
app.component('NychSelectTrigger', SelectTrigger)
app.component('NychSelectValue', SelectValue)
app.component('NychSelectContent', SelectContent)
app.component('NychSelectItem', SelectItem)
app.component('NychSelectGroup', SelectGroup)
app.component('NychSelectLabel', SelectLabel)
app.component('NychSelectSeparator', SelectSeparator)
app.component('NychCard', Card)
app.component('NychCardHeader', CardHeader)
app.component('NychCardTitle', CardTitle)
app.component('NychCardDescription', CardDescription)
app.component('NychCardContent', CardContent)
app.component('NychCardFooter', CardFooter)
app.component('NychCardAction', CardAction)
app.component('NychDialog', Dialog)
app.component('NychDialogTrigger', DialogTrigger)
app.component('NychDialogContent', DialogContent)
app.component('NychDialogHeader', DialogHeader)
app.component('NychDialogTitle', DialogTitle)
app.component('NychDialogDescription', DialogDescription)
app.component('NychDialogFooter', DialogFooter)
app.component('NychDialogClose', DialogClose)
app.component('NychDialogOverlay', DialogOverlay)
app.component('NychDialogScrollContent', DialogScrollContent)
app.component('NychAlert', Alert)
app.component('NychAlertTitle', AlertTitle)
app.component('NychAlertDescription', AlertDescription)
app.component('NychAlertAction', AlertAction)
app.component('NychBadge', Badge)
app.component('NychMessage', Message)
app.component('NychTag', Tag)
},
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,894 @@
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()
})
})

View file

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

View file

@ -1,6 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue'
import { createNychthemeron, LoadingIcon } from '../src/index'
import {
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', () => {
it('returns a Vue plugin with install method', () => {
@ -23,8 +64,94 @@ 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', () => {
it('is a valid Vue component', () => {
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')
})
})

View file

@ -1,69 +0,0 @@
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)
})
})

View file

@ -1,28 +0,0 @@
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']")
})
})

View file

@ -1,143 +0,0 @@
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
})
})

View file

@ -1,92 +0,0 @@
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)
})
})

View file

@ -1,57 +0,0 @@
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)
})
})

View file

@ -1,71 +0,0 @@
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