Inital Commit
Some checks failed
ci / build (push) Failing after 27s
ci / publish (push) Has been skipped
ci / publish-docs (push) Has been skipped

This commit is contained in:
Matthew McPeak 2026-07-15 19:32:31 -04:00
commit 8b54df80ab
144 changed files with 11033 additions and 0 deletions

13
.dockerignore Normal file
View file

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

8
.editorconfig Normal file
View file

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

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

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

1
.gitattributes vendored Normal file
View file

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

55
.gitignore vendored Normal file
View file

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

10
.oxlintrc.json Normal file
View file

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

6
.prettierrc.json Normal file
View file

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

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

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

133
README.md Normal file
View file

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

2283
bun.lock Normal file

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,100 @@
# PrimeVue → shadcn-vue Migration Design
**Date:** 2026-07-10
**Project:** nychthemeron
**Status:** Approved
## Overview
Replace PrimeVue as the underlying component primitive layer with shadcn-vue (Reka UI primitives + Tailwind CSS v4 + `class-variance-authority`), while preserving nychthemeron's identity: the Hades/Apollo custom-property theme system, the `@nychthemeron/library` installable-package distribution model, and the `createNychthemeron()` global-registration plugin.
## Goals
- Remove `primevue` as a dependency; components are built on Reka UI primitives instead.
- Adopt Tailwind CSS v4 and `cva`-based variants as the styling mechanism, replacing hand-written per-component CSS.
- Preserve the Hades/Apollo design tokens (CSS custom properties) as the source of truth for color/typography, mapped onto Tailwind's expected theme slots.
- Replace the generic `ThemeEngine`/`Themeable`/pt-injection wrapping layer with the much smaller `cn()` utility (`clsx` + `tailwind-merge`) that shadcn-vue itself generates, plus shared `cva` variant configs where genuinely duplicated.
- Adopt shadcn-vue's idiomatic compound-component API for multi-part components (Dialog, Select, Card), even though this breaks the current single-component prop/slot API.
- Keep the library an installable workspace package (no shadcn-vue CLI/registry distribution model for consumers) — the CLI is used once, at authoring time, to scaffold component source into `packages/library`.
## Non-goals
- No change to the monorepo structure (`packages/library`, `packages/playground`), the bun workspace setup, or the "no npm publishing" distribution model.
- No redesign of the Hades/Apollo color palette or typography — token *values* stay the same; only how they're wired into the styling layer changes.
- No visual-regression parity requirement with the old PrimeVue-based rendering — some spacing/detail differences are expected and acceptable since components are being rebuilt from shadcn-vue's scaffolds.
## Architecture
### Dependencies
- **Removed:** `primevue` (peer dependency of `packages/library`; dependency of `packages/playground`).
- **Added (library peer deps):** `vue`, `reka-ui`.
- **Added (library deps/devDeps):** `tailwindcss`, `@tailwindcss/vite`, `class-variance-authority`, `clsx`, `tailwind-merge`, plus whatever icon package shadcn-vue's scaffolds default to (e.g. `lucide-vue-next`) if generated components reference it.
- shadcn-vue itself is never a runtime dependency — it is a one-time code generator (`npx shadcn-vue@latest init` / `add <component>`) invoked during implementation to scaffold component source directly into `packages/library/src/components`. Generated source is then adapted (token wiring, extra variants, our custom extensions) and owned in-repo like any other component.
### Styling & theming
- Tailwind v4, CSS-first configuration (no `tailwind.config.ts`); Tailwind is imported via `@import "tailwindcss"` in the library's CSS entry point, using the Vite plugin (`@tailwindcss/vite`) in both `packages/library/vite.config.ts` and `packages/playground/vite.config.ts` / Storybook Vite config.
- A `@theme` block maps existing Hades/Apollo custom properties onto the canonical color slots shadcn-vue's generated `cva` variants expect:
- `--color-primary: var(--primary)`, `--color-primary-foreground: var(--primary-fg)`
- `--color-secondary: var(--neutral)`, `--color-secondary-foreground: var(--neutral-fg)`
- `--color-destructive: var(--danger)`, `--color-destructive-foreground: var(--danger-fg)`
- `--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-border: var(--border)`, `--color-input: var(--border)`, `--color-ring: var(--primary)`
- Additional semantic slots for nychthemeron's 5-way severity system beyond shadcn's stock set: `--color-info`, `--color-info-foreground`, `--color-success`, `--color-success-foreground`, `--color-warning`, `--color-warning-foreground` (all mapped from existing `--info`/`--success`/`--warn` tokens and their `-fg` counterparts).
- Theme switching mechanism is unchanged: `[data-theme="hades"]` / `[data-theme="apollo"]` selectors on `<html>` continue to define the underlying custom properties; Tailwind utilities simply read through `var()` at paint time, so the Storybook theme toolbar keeps working without modification.
- Global concerns (font-family tokens, base box-sizing reset, `:focus-visible` ring, `:disabled` opacity) remain in one small global CSS file, imported once. All per-component layout/spacing/color styling that used to live in `nychthemeron.css`/`theme.css` moves into Tailwind utility classes and `cva` variants inside each component's own `.vue` file — the shadcn-vue idiom.
- `packages/library` exports (`./style`, `./theme`) keep the same public paths; the files they point at change from hand-written CSS to the new Tailwind-entry CSS.
### Engine replacement
- `packages/library/src/engine/` (`ThemeEngine`, `Themeable`, `LibComponent`, `Key`, `Props`, pt-injection logic) is deleted in full.
- Its replacement is the CLI-generated `src/lib/utils.ts` exporting `cn()` (`clsx` + `tailwind-merge`), used inside every component for merging caller-provided `class` props with internal variant classes.
- Where a color/severity mapping is genuinely shared across components (e.g. the 5-way severity set used by both `Badge` and `Alert`), factor it into one shared `cva` base (e.g. `src/lib/variants.ts`) rather than duplicating the variant list — but this is a targeted, small shared config, not a generic runtime wrapper.
- No generic "wrap any component and inject classes via props" mechanism is preserved; shadcn-vue's model (own the markup, write the classes directly) makes that unnecessary.
### Component mapping
| Current (PrimeVue-wrapped) | shadcn-vue CLI component(s) | Notes |
|---|---|---|
| `Button` | `button` | Single component; `cva` variant prop replaces `severity` (`primary/secondary/info/success/warning/danger`); loading→disabled behavior and the loading-spinner rendering are coded directly in the SFC. |
| `LoadingIcon` | *(none — custom SVG)* | Unchanged, re-exported as-is. |
| `InputText``Input` | `input` | Direct port. |
| `Textarea` | `textarea` | Direct port. |
| `Checkbox` + `CheckboxGroup` | `checkbox` | shadcn-vue has no stock `CheckboxGroup`; hand-build one using the same provide/inject pattern the CLI's own `radio-group` uses, so it matches the surrounding idiom. |
| `RadioButton` + `RadioButtonGroup` | `radio-group``RadioGroup`, `RadioGroupItem` | Direct CLI equivalent; naming changes to match. |
| `ToggleSwitch``Switch` | `switch` | Direct port, renamed. |
| `Select` | `select``Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem` | Compound API replaces the `options`/`optionLabel`/`optionValue` props; consumers write `<SelectItem>` children. |
| `Card` | `card``Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | `#subtitle` slot → `CardDescription`; header-image use case becomes a plain child placed before `CardHeader`. |
| `Dialog` | `dialog``Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose` | Current `headerActions` (multiple header buttons, not just close) becomes extra children placed in `DialogHeader` alongside `DialogClose`. |
| `Message``Alert` | `alert``Alert`, `AlertTitle`, `AlertDescription` | Stock `Alert` has no close button; add a `closable` prop / optional close-button piece as our extension on top of the scaffolded component. |
| `Tag``Badge` | `badge` | Direct port; variants extended to cover the 5 severities (stock only has `default/secondary/destructive/outline`). |
All components keep the existing `Nych*` global-registration convention via `createNychthemeron()` — the installer gains additional entries for compound sub-components (e.g. `NychDialogTrigger`, `NychSelectItem`).
## Testing strategy
- `packages/library/tests/lib.spec.ts` (entirely PrimeVue-pt-injection-specific, using a `callSetup` helper that simulates the old engine's render function) is deleted and replaced with per-component test files that mount the real compound components via `@vue/test-utils` and assert rendered output/classes/`v-model` behavior/disabled state directly — no engine-internals testing since there's no engine.
- `packages/library/tests/engine.spec.ts` is deleted along with the engine it tests.
## Storybook / playground
- All 13 `.stories.ts` files are rewritten to the new compound API; `Select.stories.ts`, `Dialog.stories.ts`, and `Card.stories.ts` see the largest rewrites since their public shape changes most.
- `.storybook/preview.ts` drops the `app.use(PrimeVue, { unstyled: true })` setup call; the `@nychthemeron/library/theme` import path is kept but now resolves to Tailwind-built CSS instead of hand-written component CSS.
## Build order
1. **Foundation** — wire up Tailwind v4 (`@tailwindcss/vite` in both packages), write the `@theme` token-mapping block, run `shadcn-vue init` to scaffold `src/lib/utils.ts` and CLI config, delete `packages/library/src/engine` and the `primevue` dependency.
2. **Simple components** — Button, Input, Textarea, Switch, Badge, re-export LoadingIcon unchanged.
3. **Grouped/compound-lite** — Checkbox + hand-built CheckboxGroup, RadioGroup + RadioGroupItem, Card, Alert.
4. **Complex compound** — Select, Dialog.
5. **Cleanup** — rewrite all Storybook stories and library tests to match the new API, remove any remaining PrimeVue references from configs/CI.
## Success criteria
- `primevue` no longer appears in any `package.json`, config, or import across the repo.
- All 13 original components have a shadcn-vue-based equivalent, installable the same way as today (`import { X } from '@nychthemeron/library'`, registered via `createNychthemeron()`).
- Hades and Apollo themes both render correctly across every component with no regression in the token *values* used.
- `bun run test`, `bun run type-check`, and `bun run lint` all pass.
- Storybook (`bun run dev` in `packages/playground`) starts and every story renders without console errors, using the new compound APIs.

3
env.d.ts vendored Normal file
View file

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

43
eslint.config.ts Normal file
View file

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

50
package.json Normal file
View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,200 @@
import { h, type SetupContext } from 'vue'
export type LibComponent =
| { new (...args: unknown[]): unknown } // Constructor components
| { (...args: unknown[]): unknown } // Functional components
| Record<string, unknown> // Object components
export type Props<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? P
: T extends { props: infer P }
? P
: string
export type Key<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? keyof P
: T extends { props: infer P }
? keyof P
: string
// Try to extract unstyled type from component
// If extraction fails, accept any value via unknown
type ExtractUnstyled<T> = T extends { props: infer P }
? P extends { unstyled: infer U }
? U
: unknown
: T extends { new (...args: unknown[]): { $props: infer P } }
? P extends { unstyled: infer U }
? U
: unknown
: unknown
export type Themeable<
T extends LibComponent,
K extends Key<T> = Key<T>,
I extends Key<T> = Key<T>,
> = {
component: T
propMutator?: (props: Record<Key<T>, unknown>) => Record<Key<T>, unknown>
classes?: string[]
styles?: string[]
injectionKeys?: {
class?: K
style?: I
}
unstyled?: ExtractUnstyled<T>
slots?: Record<string, LibComponent>
}
export type ThemeLibrary<
T extends Record<string, Themeable<LibComponent>> = Record<string, Themeable<LibComponent>>,
> = {
components: T
}
export class ThemeEngine {
readonly lib: ThemeLibrary
constructor(library: ThemeLibrary) {
this.lib = library
}
getComponent<I extends LibComponent>(key: keyof ThemeLibrary['components']): I {
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
this.lib.components[key]
// Build a MINIMAL pass-through wrapper rather than cloning the wrapped
// component. Cloning dragged along the component's `props`, `emits` and
// `extends` chain — which made Vue treat listeners like `onUpdate:modelValue`
// as the wrapper's own declared events and withhold them from `ctx.attrs`,
// silently breaking two-way `v-model`. By declaring only the engine's own
// props here, every other prop, listener and attribute falls through
// `ctx.attrs` and is forwarded verbatim to the real component below.
const componentObj: Record<string, unknown> = {
name: `Nych${String(key)}`,
inheritAttrs: false,
props: {
injectedClasses: { default: classes },
injectedStyles: { default: styles },
stylePlug: { type: String },
unstyled: { default: unstyled },
},
}
const applyThemeProps = (props: Record<string, unknown>) => {
const mergedProps = { ...props }
const stylePlug =
typeof mergedProps['stylePlug'] === 'string' ? mergedProps['stylePlug'] : undefined
const defaultClassValues = Array.isArray(mergedProps['injectedClasses'])
? mergedProps['injectedClasses']
: []
const defaultStyleValues = Array.isArray(mergedProps['injectedStyles'])
? mergedProps['injectedStyles']
: []
const classKey = injectionKeys?.class ?? 'class'
const styleKey = injectionKeys?.style ?? 'style'
const classValue = mergedProps[classKey] ?? []
const styleValue =
typeof mergedProps[styleKey] === 'string'
? mergedProps[styleKey]
.split(';')
.map((s) => s.trim())
.filter(Boolean)
: []
let classArray: string[] | undefined = undefined
if (typeof classValue === 'object') {
if (Array.isArray(classValue)) {
classArray = classValue
} else if (classKey !== 'pt') {
// Support Vue's object-form class syntax: { 'my-class': condition }
classArray = Object.entries(classValue as Record<string, boolean>)
.filter(([, active]) => Boolean(active))
.map(([name]) => name)
}
}
if (typeof classValue === 'string') {
classArray = classValue.split(' ')
}
if (!classArray) {
classArray = []
}
const finalClasses = stylePlug
? [...classArray, ...defaultClassValues]
.map((cls) => (cls.startsWith(`${stylePlug}-`) ? cls : `${stylePlug}-${cls}`))
.join(' ')
: [...classArray, ...defaultClassValues].join(' ')
const finalStyles = [...styleValue, ...defaultStyleValues].join('; ')
if (classKey === 'pt' || styleKey === 'pt') {
mergedProps['unstyled'] = true
const existingPt =
typeof mergedProps[classKey] === 'object'
? (mergedProps[classKey] as Record<string, unknown>)
: {}
const existingRoot = (existingPt.root as { class?: string; style?: string }) ?? {}
mergedProps[classKey] = {
...existingPt,
root: {
...existingRoot,
class: [existingRoot.class, finalClasses].filter(Boolean).join(' '),
style: [existingRoot.style, finalStyles].filter(Boolean).join('; '),
},
}
} else {
mergedProps[classKey] = finalClasses
}
if (styleKey !== 'pt') {
mergedProps[styleKey] = finalStyles
}
if (propMutator) {
// Supports both in-place mutation (returns same ref) and returning a new props object
const mutated = propMutator(mergedProps as Record<Key<typeof component>, unknown>)
if (mutated !== mergedProps) {
Object.assign(mergedProps, mutated)
}
}
return mergedProps
}
// Always wrap via a render function so the original component runs its own
// setup naturally through Vue's component system with the modified props.
// Slots are merged here so no extra wrapper component layer is needed.
componentObj.setup = (props: Record<string, unknown>, ctx: SetupContext) => {
return () => {
const themeSlots: Record<string, unknown> = {}
for (const [name, slotComponent] of Object.entries(slots || {})) {
themeSlots[name] = (scope?: Record<string, unknown>) => [h(slotComponent, scope || {})]
}
const originalSlots = ctx.slots || {}
const mergedSlots = { ...themeSlots, ...originalSlots }
// Merge attrs into props so the engine sees class/style from fallthrough attrs
// before merging theme values — prevents ctx.attrs from overwriting engine output.
// Running inside the render function also keeps mergedProps reactive to prop/attr changes.
const mergedProps = applyThemeProps({ ...props, ...ctx.attrs })
for (const key of ['injectedClasses', 'injectedStyles', 'stylePlug']) {
delete mergedProps[key as keyof typeof mergedProps]
}
return h(component as LibComponent, mergedProps, mergedSlots)
}
}
return componentObj as I
}
}

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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