refactor(webapp): vue3 migration - phase 2 - setup (#9161)
68
.github/dependabot.yml
vendored
@ -126,3 +126,71 @@ updates:
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
|
||||
# ui library
|
||||
- package-ecosystem: npm
|
||||
open-pull-requests-limit: 99
|
||||
directory: "/packages/ui"
|
||||
rebase-strategy: "disabled"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
groups:
|
||||
vue:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "vue*"
|
||||
- "@vue*"
|
||||
vite:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "vite*"
|
||||
- "@vitejs*"
|
||||
vitest:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "vitest*"
|
||||
- "@vitest*"
|
||||
|
||||
# ui examples
|
||||
- package-ecosystem: npm
|
||||
open-pull-requests-limit: 99
|
||||
directory: "/packages/ui/examples/vue3-tailwind"
|
||||
rebase-strategy: "disabled"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
|
||||
- package-ecosystem: npm
|
||||
open-pull-requests-limit: 99
|
||||
directory: "/packages/ui/examples/vue3-css"
|
||||
rebase-strategy: "disabled"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
|
||||
- package-ecosystem: npm
|
||||
open-pull-requests-limit: 99
|
||||
directory: "/packages/ui/examples/vue2-tailwind"
|
||||
rebase-strategy: "disabled"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
|
||||
- package-ecosystem: npm
|
||||
open-pull-requests-limit: 99
|
||||
directory: "/packages/ui/examples/vue2-css"
|
||||
rebase-strategy: "disabled"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "saturday"
|
||||
timezone: "Europe/Berlin"
|
||||
time: "03:00"
|
||||
|
||||
81
.github/workflows/ui-build.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
name: UI Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build library
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking build output..."
|
||||
|
||||
# Check that dist directory exists
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "::error::dist directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required files exist
|
||||
FILES=(
|
||||
"dist/index.mjs"
|
||||
"dist/index.cjs"
|
||||
"dist/index.d.ts"
|
||||
"dist/index.d.cts"
|
||||
"dist/tailwind.preset.mjs"
|
||||
"dist/tailwind.preset.cjs"
|
||||
"dist/tailwind.preset.d.ts"
|
||||
"dist/tailwind.preset.d.cts"
|
||||
"dist/style.css"
|
||||
)
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "::error::Missing required file: $file"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $file"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "All build outputs verified!"
|
||||
|
||||
- name: Validate package
|
||||
run: npm run validate
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: packages/ui/dist/
|
||||
retention-days: 7
|
||||
88
.github/workflows/ui-compatibility.yml
vendored
Normal file
@ -0,0 +1,88 @@
|
||||
name: UI Compatibility
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
jobs:
|
||||
build-library:
|
||||
name: Build Library
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build library
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-dist
|
||||
path: packages/ui/dist/
|
||||
retention-days: 1
|
||||
|
||||
test-compatibility:
|
||||
name: Test ${{ matrix.example }}
|
||||
needs: build-library
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
example:
|
||||
- vue3-tailwind
|
||||
- vue3-css
|
||||
- vue2-tailwind
|
||||
- vue2-css
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui/examples/${{ matrix.example }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-dist
|
||||
path: packages/ui/dist/
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/examples/${{ matrix.example }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build example app
|
||||
run: npm run build
|
||||
45
.github/workflows/ui-docker.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
name: UI Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build development image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./packages/ui
|
||||
file: ./packages/ui/Dockerfile
|
||||
target: development
|
||||
push: false
|
||||
tags: ocelot-social/ui:development
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build production image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./packages/ui
|
||||
file: ./packages/ui/Dockerfile
|
||||
target: production
|
||||
push: false
|
||||
tags: ocelot-social/ui:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
40
.github/workflows/ui-lint.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
name: UI Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: ESLint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run typecheck
|
||||
65
.github/workflows/ui-release.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
name: UI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'release-please-config.json'
|
||||
- '.release-please-manifest.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs['packages/ui--release_created'] }}
|
||||
tag_name: ${{ steps.release.outputs['packages/ui--tag_name'] }}
|
||||
version: ${{ steps.release.outputs['packages/ui--version'] }}
|
||||
|
||||
steps:
|
||||
- name: Release Please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Validate package
|
||||
run: npm run validate
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
40
.github/workflows/ui-size.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
name: UI Size
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
size:
|
||||
name: Bundle Size Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check bundle size
|
||||
run: npm run size
|
||||
60
.github/workflows/ui-storybook.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
name: UI Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Storybook
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Storybook
|
||||
run: npm run storybook:build
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking Storybook build output..."
|
||||
|
||||
if [ ! -d "storybook-static" ]; then
|
||||
echo "::error::storybook-static directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "storybook-static/index.html" ]; then
|
||||
echo "::error::index.html not found in storybook-static"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Storybook build verified!"
|
||||
|
||||
- name: Upload Storybook artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: packages/ui/storybook-static/
|
||||
retention-days: 7
|
||||
45
.github/workflows/ui-test.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
name: UI Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: packages/ui/coverage/
|
||||
retention-days: 7
|
||||
37
.github/workflows/ui-verify.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: UI Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Completeness Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check component completeness
|
||||
run: npm run verify
|
||||
50
.github/workflows/ui-visual.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
name: UI Visual
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/ui
|
||||
|
||||
jobs:
|
||||
visual:
|
||||
name: Visual Regression
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: 'packages/ui/.tool-versions'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: packages/ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run visual tests
|
||||
run: npm run test:visual
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: visual-test-results
|
||||
path: |
|
||||
packages/ui/test-results/
|
||||
packages/ui/playwright-report/
|
||||
retention-days: 7
|
||||
3
.release-please-manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"packages/ui": "0.0.1"
|
||||
}
|
||||
@ -91,5 +91,15 @@ services:
|
||||
S3_RESULT_STORAGE_BUCKET: ocelot # enable S3 result storage by specifying bucket
|
||||
HTTP_LOADER_BASE_URL: http://minio:9000
|
||||
|
||||
ui:
|
||||
image: ghcr.io/ocelot-social-community/ocelot-social/ui:local-development
|
||||
build:
|
||||
target: development
|
||||
ports:
|
||||
- 6006:6006
|
||||
volumes:
|
||||
- ./packages/ui:/app
|
||||
- /app/node_modules
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
|
||||
@ -83,5 +83,13 @@ services:
|
||||
# bring the database in offline mode to export or load dumps
|
||||
# command: ["tail", "-f", "/dev/null"]
|
||||
|
||||
ui:
|
||||
image: ghcr.io/ocelot-social-community/ocelot-social/ui:${OCELOT_VERSION:-latest}
|
||||
build:
|
||||
context: ./packages/ui
|
||||
target: production
|
||||
ports:
|
||||
- 6006:80
|
||||
|
||||
volumes:
|
||||
neo4j_data:
|
||||
|
||||
34
packages/ui/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Storybook
|
||||
storybook-static/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
26
packages/ui/.storybook/main.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {},
|
||||
},
|
||||
viteFinal(viteConfig) {
|
||||
// Remove plugins that are only needed for library build
|
||||
viteConfig.plugins = viteConfig.plugins?.filter((plugin) => {
|
||||
const name = plugin && 'name' in plugin ? plugin.name : ''
|
||||
return name !== 'vite:dts' && name !== 'build-css'
|
||||
})
|
||||
|
||||
// Remove library build config
|
||||
if (viteConfig.build) {
|
||||
delete viteConfig.build.lib
|
||||
delete viteConfig.build.rollupOptions
|
||||
}
|
||||
|
||||
return viteConfig
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
11
packages/ui/.storybook/preview.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// eslint-disable-next-line import-x/no-unassigned-import
|
||||
import './storybook.css'
|
||||
|
||||
export const parameters = {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
}
|
||||
34
packages/ui/.storybook/storybook.css
Normal file
@ -0,0 +1,34 @@
|
||||
/* Inter font for consistent rendering across platforms */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Greyscale theme for Storybook - demonstrates that colors come from the app */
|
||||
/* All colors meet WCAG AA contrast requirements (4.5:1 for normal text) */
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
|
||||
--color-primary: #404040;
|
||||
--color-primary-hover: #262626;
|
||||
--color-primary-contrast: #ffffff;
|
||||
|
||||
--color-secondary: #595959;
|
||||
--color-secondary-hover: #404040;
|
||||
--color-secondary-contrast: #ffffff;
|
||||
|
||||
--color-danger: #525252;
|
||||
--color-danger-hover: #404040;
|
||||
--color-danger-contrast: #ffffff;
|
||||
|
||||
--color-warning: #d4d4d4;
|
||||
--color-warning-hover: #a3a3a3;
|
||||
--color-warning-contrast: #000000;
|
||||
|
||||
--color-success: #525252;
|
||||
--color-success-hover: #404040;
|
||||
--color-success-contrast: #ffffff;
|
||||
|
||||
--color-info: #595959;
|
||||
--color-info-hover: #404040;
|
||||
--color-info-contrast: #ffffff;
|
||||
}
|
||||
1
packages/ui/.tool-versions
Normal file
@ -0,0 +1 @@
|
||||
nodejs 25.5.0
|
||||
217
packages/ui/CONTRIBUTING.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Contributing to @ocelot-social/ui
|
||||
|
||||
Thank you for contributing to the ocelot.social UI library!
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Creating a New Component
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/components/
|
||||
└── OsButton/
|
||||
├── OsButton.vue # Component
|
||||
├── OsButton.spec.ts # Tests
|
||||
└── index.ts # Export
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
- All components use the `Os` prefix: `OsButton`, `OsCard`, `OsModal`
|
||||
- Files use PascalCase: `OsButton.vue`
|
||||
|
||||
### Component Template
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue-demi'
|
||||
|
||||
import type { Size, Variant } from '@/types'
|
||||
|
||||
interface Props {
|
||||
/** Button size */
|
||||
size?: Size
|
||||
/** Visual variant */
|
||||
variant?: Variant
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
variant: 'primary',
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'os-button',
|
||||
`os-button--${props.size}`,
|
||||
`os-button--${props.variant}`,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- `strict: true` is enabled
|
||||
- All props must be typed
|
||||
- Use JSDoc comments for documentation
|
||||
|
||||
### Props
|
||||
|
||||
Use the complete Tailwind-based scales:
|
||||
|
||||
| Prop | Values |
|
||||
|------|--------|
|
||||
| `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl` |
|
||||
| `rounded` | `none`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `full` |
|
||||
| `shadow` | `none`, `sm`, `md`, `lg`, `xl`, `2xl` |
|
||||
| `variant` | `primary`, `secondary`, `danger`, `warning`, `success`, `info` |
|
||||
|
||||
### Styling
|
||||
|
||||
- Use CSS Custom Properties (no hardcoded colors)
|
||||
- Use Tailwind utility classes
|
||||
- Dark mode via `dark:` prefix
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button class="bg-[--button-primary-bg] dark:bg-[--button-primary-bg-dark]">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Vue 2/3 Compatibility
|
||||
|
||||
Always import from `vue-demi`, not `vue`:
|
||||
|
||||
```typescript
|
||||
// Correct
|
||||
import { ref, computed } from 'vue-demi'
|
||||
|
||||
// Wrong
|
||||
import { ref, computed } from 'vue'
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Requirements
|
||||
|
||||
- **100% code coverage** is required
|
||||
- Tests must pass for both Vue 2.7 and Vue 3
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```typescript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import OsButton from './OsButton.vue'
|
||||
|
||||
describe('OsButton', () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
slots: { default: 'Click me' },
|
||||
})
|
||||
expect(wrapper.text()).toBe('Click me')
|
||||
})
|
||||
|
||||
it('applies size class', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { size: 'lg' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('os-button--lg')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run once
|
||||
npm test
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
|
||||
# With coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) for automatic releases:
|
||||
|
||||
```bash
|
||||
# Features (minor version bump)
|
||||
feat(button): add loading state
|
||||
|
||||
# Bug fixes (patch version bump)
|
||||
fix(modal): correct focus trap behavior
|
||||
|
||||
# Breaking changes (major version bump)
|
||||
feat(input)!: rename value prop to modelValue
|
||||
|
||||
# Other types
|
||||
docs: update README
|
||||
test: add button accessibility tests
|
||||
chore: update dependencies
|
||||
refactor: simplify dropdown logic
|
||||
```
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
Before submitting a PR, ensure:
|
||||
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Linter passes (`npm run lint`)
|
||||
- [ ] Build succeeds (`npm run build`)
|
||||
- [ ] 100% code coverage maintained
|
||||
- [ ] New components have Histoire stories
|
||||
- [ ] Props have JSDoc documentation
|
||||
- [ ] Commit messages follow Conventional Commits
|
||||
|
||||
## Example Apps
|
||||
|
||||
Test your changes in the example apps:
|
||||
|
||||
```bash
|
||||
# Vue 3 + Tailwind
|
||||
cd examples/vue3-tailwind && npm install && npm run dev
|
||||
|
||||
# Vue 3 + CSS
|
||||
cd examples/vue3-css && npm install && npm run dev
|
||||
|
||||
# Vue 2 + Tailwind
|
||||
cd examples/vue2-tailwind && npm install && npm run dev
|
||||
|
||||
# Vue 2 + CSS
|
||||
cd examples/vue2-css && npm install && npm run dev
|
||||
```
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check the [main CONTRIBUTING.md](../../CONTRIBUTING.md) for general guidelines
|
||||
- Join the [Discord](https://discord.gg/AJSX9DCSUA) for questions
|
||||
- Open an issue for bugs or feature requests
|
||||
29
packages/ui/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
FROM node:25.5.0-alpine AS base
|
||||
LABEL org.label-schema.name="ocelot.social:ui"
|
||||
LABEL org.label-schema.description="UI Component Library for ocelot.social"
|
||||
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/packages/ui/README.md"
|
||||
LABEL org.label-schema.url="https://ocelot.social"
|
||||
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/packages/ui"
|
||||
LABEL org.label-schema.vendor="ocelot.social Community"
|
||||
LABEL org.label-schema.schema-version="1.0"
|
||||
LABEL maintainer="devops@ocelot.social"
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS development
|
||||
ENV NODE_ENV="development"
|
||||
EXPOSE 6006
|
||||
CMD ["/bin/sh", "-c", "npm install && npm run dev"]
|
||||
|
||||
FROM base AS build
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run storybook:build
|
||||
|
||||
FROM nginx:alpine AS production
|
||||
LABEL org.label-schema.name="ocelot.social:ui-storybook"
|
||||
LABEL org.label-schema.description="UI Component Library Storybook for ocelot.social"
|
||||
COPY --from=build /app/storybook-static /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
1075
packages/ui/KATALOG.md
Normal file
190
packages/ui/LICENSE
Normal file
@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Ocelot-Social-Community
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
1988
packages/ui/PROJEKT.md
Normal file
64
packages/ui/README.md
Normal file
@ -0,0 +1,64 @@
|
||||
# @ocelot-social/ui
|
||||
|
||||
Vue component library for ocelot.social - works with Vue 2.7+ and Vue 3.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @ocelot-social/ui
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Option 1: Individual Imports (recommended)
|
||||
|
||||
Import only the components you need. This enables tree-shaking for smaller bundle sizes.
|
||||
|
||||
```ts
|
||||
import { OsButton } from '@ocelot-social/ui'
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<OsButton variant="primary">Click me</OsButton>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { OsButton } from '@ocelot-social/ui'
|
||||
</script>
|
||||
```
|
||||
|
||||
### Option 2: Global Registration
|
||||
|
||||
Register all components globally via the Vue plugin. No tree-shaking - all components are included in the bundle.
|
||||
|
||||
```ts
|
||||
// main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { OcelotUI } from '@ocelot-social/ui'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(OcelotUI)
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
Components are then available globally without imports:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<OsButton variant="primary">Click me</OsButton>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Vue 2.7 Support
|
||||
|
||||
This library uses [vue-demi](https://github.com/vueuse/vue-demi) for Vue 2/3 compatibility.
|
||||
|
||||
```ts
|
||||
// Vue 2.7
|
||||
import Vue from 'vue'
|
||||
import { OcelotUI } from '@ocelot-social/ui'
|
||||
|
||||
Vue.use(OcelotUI)
|
||||
```
|
||||
126
packages/ui/eslint.config.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// TODO: Update eslint-config-it4c to support ESLint 10 (currently incompatible)
|
||||
import config, { vue3, vitest } from 'eslint-config-it4c'
|
||||
import jsdocPlugin from 'eslint-plugin-jsdoc'
|
||||
import playwrightPlugin from 'eslint-plugin-playwright'
|
||||
import storybookPlugin from 'eslint-plugin-storybook'
|
||||
import vuejsAccessibilityPlugin from 'eslint-plugin-vuejs-accessibility'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'dist/',
|
||||
'coverage/',
|
||||
'storybook-static/',
|
||||
'**/node_modules/',
|
||||
'examples/',
|
||||
'test-results/',
|
||||
'playwright-report/',
|
||||
],
|
||||
},
|
||||
...config,
|
||||
...vue3,
|
||||
...vitest,
|
||||
{
|
||||
// TODO: Move these Vue-standard rules to eslint-config-it4c
|
||||
rules: {
|
||||
// TODO(it4c): Add .css/.scss to vue3 config
|
||||
'n/file-extension-in-import': [
|
||||
'error',
|
||||
'never',
|
||||
{
|
||||
'.vue': 'always',
|
||||
'.json': 'always',
|
||||
'.css': 'always',
|
||||
'.scss': 'always',
|
||||
},
|
||||
],
|
||||
// TODO(it4c): Add CSS/SCSS exception to vue3 config
|
||||
'import-x/no-unassigned-import': [
|
||||
'error',
|
||||
{
|
||||
allow: ['**/*.css', '**/*.scss'],
|
||||
},
|
||||
],
|
||||
// TODO(it4c): Disable in vue3 config (alias imports)
|
||||
'import-x/no-relative-parent-imports': 'off',
|
||||
// TODO(it4c): Disable in vue3 config (Prettier handles)
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Disable TypeScript rules for JSON files
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: Move these Vitest rules to eslint-config-it4c vitest config
|
||||
files: ['**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
// TODO(it4c): Add to vitest config (standard pattern)
|
||||
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
||||
// TODO(it4c): Disable in vitest config
|
||||
'vitest/prefer-expect-assertions': 'off',
|
||||
// TODO(it4c): Disable in vitest config
|
||||
'vitest/no-hooks': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// CLI scripts - allow sync methods and console
|
||||
files: ['scripts/**/*.ts'],
|
||||
rules: {
|
||||
'n/shebang': 'off',
|
||||
'n/no-sync': 'off',
|
||||
'no-console': 'off',
|
||||
'security/detect-non-literal-fs-filename': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Playwright visual tests
|
||||
files: ['**/*.visual.spec.ts'],
|
||||
...playwrightPlugin.configs['flat/recommended'],
|
||||
rules: {
|
||||
...playwrightPlugin.configs['flat/recommended'].rules,
|
||||
'n/no-process-env': 'off',
|
||||
'vitest/require-hook': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Playwright config
|
||||
files: ['playwright.config.ts'],
|
||||
rules: {
|
||||
'n/no-process-env': 'off',
|
||||
},
|
||||
},
|
||||
// Storybook files
|
||||
// eslint-disable-next-line import-x/no-named-as-default-member -- flat config access pattern
|
||||
...storybookPlugin.configs['flat/recommended'],
|
||||
{
|
||||
// Vue components - accessibility and JSDoc
|
||||
files: ['src/components/**/*.vue'],
|
||||
plugins: {
|
||||
jsdoc: jsdocPlugin,
|
||||
'vuejs-accessibility': vuejsAccessibilityPlugin,
|
||||
},
|
||||
rules: {
|
||||
// Require JSDoc comments on Props interface properties
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
contexts: ['TSPropertySignature'],
|
||||
require: {
|
||||
ClassDeclaration: false,
|
||||
ClassExpression: false,
|
||||
ArrowFunctionExpression: false,
|
||||
FunctionDeclaration: false,
|
||||
FunctionExpression: false,
|
||||
MethodDefinition: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Accessibility rules
|
||||
...vuejsAccessibilityPlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
]
|
||||
32
packages/ui/examples/vue2-css/eslint.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import config, { vue2, vitest } from 'eslint-config-it4c'
|
||||
|
||||
export default [
|
||||
...config,
|
||||
...vue2,
|
||||
...vitest,
|
||||
{ ignores: ['dist/', 'node_modules/'] },
|
||||
{
|
||||
rules: {
|
||||
'n/file-extension-in-import': [
|
||||
'error',
|
||||
'never',
|
||||
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
|
||||
],
|
||||
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
||||
'vitest/prefer-expect-assertions': 'off',
|
||||
'vitest/no-hooks': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
12
packages/ui/examples/vue2-css/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue 2 + CSS Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10162
packages/ui/examples/vue2-css/package-lock.json
generated
Normal file
26
packages/ui/examples/vue2-css/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "vue2-css",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint --max-warnings 0 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocelot-social/ui": "file:../..",
|
||||
"vue": "^2.7.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue2": "^2.3.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-it4c": "^0.8.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
3
packages/ui/examples/vue2-css/prettier.config.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import prettierConfig from 'eslint-config-it4c/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
18
packages/ui/examples/vue2-css/src/App.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
describe('vue 2 CSS Example App', () => {
|
||||
it('mounts successfully', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the correct heading', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.find('h1').text()).toBe('Vue 2.7 + CSS + @ocelot-social/ui')
|
||||
})
|
||||
})
|
||||
19
packages/ui/examples/vue2-css/src/App.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// Example app to verify @ocelot-social/ui works with Vue 2.7 + pre-compiled CSS
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<h1>Vue 2.7 + CSS + @ocelot-social/ui</h1>
|
||||
<p>
|
||||
This example verifies that the UI library works with Vue 2.7 and pre-compiled CSS (no
|
||||
Tailwind).
|
||||
</p>
|
||||
<!-- Components will be added here as they are developed -->
|
||||
</div>
|
||||
</template>
|
||||
9
packages/ui/examples/vue2-css/src/main.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import '@ocelot-social/ui/style.css'
|
||||
import Vue from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
new Vue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app')
|
||||
14
packages/ui/examples/vue2-css/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
|
||||
}
|
||||
10
packages/ui/examples/vue2-css/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import vue from '@vitejs/plugin-vue2'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
32
packages/ui/examples/vue2-tailwind/eslint.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import config, { vue2, vitest } from 'eslint-config-it4c'
|
||||
|
||||
export default [
|
||||
...config,
|
||||
...vue2,
|
||||
...vitest,
|
||||
{ ignores: ['dist/', 'node_modules/'] },
|
||||
{
|
||||
rules: {
|
||||
'n/file-extension-in-import': [
|
||||
'error',
|
||||
'never',
|
||||
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
|
||||
],
|
||||
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
||||
'vitest/prefer-expect-assertions': 'off',
|
||||
'vitest/no-hooks': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
12
packages/ui/examples/vue2-tailwind/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue 2.7 + @ocelot-social/ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10552
packages/ui/examples/vue2-tailwind/package-lock.json
generated
Normal file
28
packages/ui/examples/vue2-tailwind/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vue2-tailwind",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint --max-warnings 0 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocelot-social/ui": "file:../..",
|
||||
"vue": "^2.7.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vitejs/plugin-vue2": "^2.3.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-it4c": "^0.8.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
3
packages/ui/examples/vue2-tailwind/prettier.config.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import prettierConfig from 'eslint-config-it4c/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
16
packages/ui/examples/vue2-tailwind/src/App.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
describe('vue 2 Example App', () => {
|
||||
it('mounts successfully', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the correct heading', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.find('h1').text()).toBe('Vue 2.7 + Tailwind + @ocelot-social/ui')
|
||||
})
|
||||
})
|
||||
16
packages/ui/examples/vue2-tailwind/src/App.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// Example app to verify @ocelot-social/ui works with Vue 2.7 + Tailwind
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<h1>Vue 2.7 + Tailwind + @ocelot-social/ui</h1>
|
||||
<p>This example verifies that the UI library works with Vue 2.7 and Tailwind CSS.</p>
|
||||
<!-- Components will be added here as they are developed -->
|
||||
</div>
|
||||
</template>
|
||||
9
packages/ui/examples/vue2-tailwind/src/main.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import './style.css'
|
||||
|
||||
new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app')
|
||||
6
packages/ui/examples/vue2-tailwind/src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
4
packages/ui/examples/vue2-tailwind/src/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Scan UI library dist for utility classes */
|
||||
@source '../../../dist/**/*.mjs';
|
||||
14
packages/ui/examples/vue2-tailwind/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
|
||||
}
|
||||
11
packages/ui/examples/vue2-tailwind/vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue2'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
32
packages/ui/examples/vue3-css/eslint.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import config, { vue3, vitest } from 'eslint-config-it4c'
|
||||
|
||||
export default [
|
||||
...config,
|
||||
...vue3,
|
||||
...vitest,
|
||||
{ ignores: ['dist/', 'node_modules/'] },
|
||||
{
|
||||
rules: {
|
||||
'n/file-extension-in-import': [
|
||||
'error',
|
||||
'never',
|
||||
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
|
||||
],
|
||||
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
||||
'vitest/prefer-expect-assertions': 'off',
|
||||
'vitest/no-hooks': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
12
packages/ui/examples/vue3-css/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue 3 + CSS Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10140
packages/ui/examples/vue3-css/package-lock.json
generated
Normal file
26
packages/ui/examples/vue3-css/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "vue3-css",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint --max-warnings 0 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocelot-social/ui": "file:../..",
|
||||
"vue": "^3.5.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-it4c": "^0.8.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
3
packages/ui/examples/vue3-css/prettier.config.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import prettierConfig from 'eslint-config-it4c/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
16
packages/ui/examples/vue3-css/src/App.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
describe('vue 3 CSS Example App', () => {
|
||||
it('mounts successfully', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the correct heading', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.find('h1').text()).toBe('Vue 3 + CSS + @ocelot-social/ui')
|
||||
})
|
||||
})
|
||||
13
packages/ui/examples/vue3-css/src/App.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// Example app to verify @ocelot-social/ui works with Vue 3 + pre-compiled CSS
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<h1>Vue 3 + CSS + @ocelot-social/ui</h1>
|
||||
<p>
|
||||
This example verifies that the UI library works with Vue 3 and pre-compiled CSS (no Tailwind).
|
||||
</p>
|
||||
<!-- Components will be added here as they are developed -->
|
||||
</div>
|
||||
</template>
|
||||
8
packages/ui/examples/vue3-css/src/main.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import '@ocelot-social/ui/style.css'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
14
packages/ui/examples/vue3-css/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
|
||||
}
|
||||
10
packages/ui/examples/vue3-css/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
32
packages/ui/examples/vue3-tailwind/eslint.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import config, { vue3, vitest } from 'eslint-config-it4c'
|
||||
|
||||
export default [
|
||||
...config,
|
||||
...vue3,
|
||||
...vitest,
|
||||
{ ignores: ['dist/', 'node_modules/'] },
|
||||
{
|
||||
rules: {
|
||||
'n/file-extension-in-import': [
|
||||
'error',
|
||||
'never',
|
||||
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
|
||||
],
|
||||
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.spec.tsx'],
|
||||
rules: {
|
||||
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
|
||||
'vitest/prefer-expect-assertions': 'off',
|
||||
'vitest/no-hooks': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
12
packages/ui/examples/vue3-tailwind/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue 3 + @ocelot-social/ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10530
packages/ui/examples/vue3-tailwind/package-lock.json
generated
Normal file
28
packages/ui/examples/vue3-tailwind/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vue3-tailwind",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint --max-warnings 0 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocelot-social/ui": "file:../..",
|
||||
"vue": "^3.5.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-it4c": "^0.8.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
3
packages/ui/examples/vue3-tailwind/prettier.config.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import prettierConfig from 'eslint-config-it4c/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
16
packages/ui/examples/vue3-tailwind/src/App.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
describe('vue 3 Example App', () => {
|
||||
it('mounts successfully', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the correct heading', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.find('h1').text()).toBe('Vue 3 + Tailwind + @ocelot-social/ui')
|
||||
})
|
||||
})
|
||||
11
packages/ui/examples/vue3-tailwind/src/App.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// Example app to verify @ocelot-social/ui works with Vue 3 + Tailwind
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<h1>Vue 3 + Tailwind + @ocelot-social/ui</h1>
|
||||
<p>This example verifies that the UI library works with Vue 3 and Tailwind CSS.</p>
|
||||
<!-- Components will be added here as they are developed -->
|
||||
</div>
|
||||
</template>
|
||||
8
packages/ui/examples/vue3-tailwind/src/main.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.mount('#app')
|
||||
6
packages/ui/examples/vue3-tailwind/src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
4
packages/ui/examples/vue3-tailwind/src/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Scan UI library dist for utility classes */
|
||||
@source '../../../dist/**/*.mjs';
|
||||
14
packages/ui/examples/vue3-tailwind/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
|
||||
}
|
||||
11
packages/ui/examples/vue3-tailwind/vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
14458
packages/ui/package-lock.json
generated
Normal file
144
packages/ui/package.json
Normal file
@ -0,0 +1,144 @@
|
||||
{
|
||||
"name": "@ocelot-social/ui",
|
||||
"version": "0.0.1",
|
||||
"description": "Vue component library for ocelot.social - works with Vue 2.7+ and Vue 3",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./style.css": "./dist/style.css",
|
||||
"./tailwind.preset": {
|
||||
"style": "./dist/tailwind.preset.mjs",
|
||||
"import": {
|
||||
"types": "./dist/tailwind.preset.d.ts",
|
||||
"default": "./dist/tailwind.preset.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/tailwind.preset.d.cts",
|
||||
"default": "./dist/tailwind.preset.cjs"
|
||||
},
|
||||
"default": "./dist/tailwind.preset.mjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "storybook dev -p 6006",
|
||||
"build": "vite build",
|
||||
"preview": "storybook build && npx http-server storybook-static -p 6006",
|
||||
"storybook:build": "storybook build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:visual": "playwright test",
|
||||
"test:visual:update": "playwright test --update-snapshots",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint --max-warnings 0 .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"verify": "tsx scripts/check-completeness.ts",
|
||||
"size": "size-limit",
|
||||
"size:check": "size-limit --json",
|
||||
"validate": "publint && attw --pack . --profile node16 --exclude-entrypoints ./style.css",
|
||||
"prepublishOnly": "npm run build && npm run validate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tailwindcss": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"vue-demi": "^0.14.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "^0.18.2",
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@size-limit/file": "^12.0.0",
|
||||
"@storybook/vue3-vite": "^10.2.7",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^25.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-it4c": "^0.8.0",
|
||||
"eslint-plugin-jsdoc": "^62.5.3",
|
||||
"eslint-plugin-playwright": "^2.5.1",
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
||||
"glob": "^13.0.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"publint": "^0.3.17",
|
||||
"size-limit": "^12.0.0",
|
||||
"storybook": "^10.2.7",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"vitest": "^4.0.18",
|
||||
"vue": "^3.5.27",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Ocelot-Social-Community/Ocelot-Social.git",
|
||||
"directory": "packages/ui"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Ocelot-Social-Community/Ocelot-Social/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/packages/ui#readme",
|
||||
"keywords": [
|
||||
"vue",
|
||||
"vue3",
|
||||
"vue2",
|
||||
"components",
|
||||
"ui",
|
||||
"ocelot-social",
|
||||
"design-system"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "dist/index.mjs",
|
||||
"limit": "15 kB",
|
||||
"brotli": true
|
||||
},
|
||||
{
|
||||
"path": "dist/tailwind.preset.mjs",
|
||||
"limit": "2 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/style.css",
|
||||
"limit": "10 kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
packages/ui/playwright.config.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Visual regression testing configuration for @ocelot-social/ui
|
||||
*
|
||||
* Tests run against Storybook to capture component screenshots.
|
||||
* Baseline images are stored in e2e/__screenshots__ and committed to git.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './src/components',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
|
||||
/* Run tests in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
/* Reporter to use */
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
|
||||
/* Shared settings for all the projects below */
|
||||
use: {
|
||||
/* Base URL for Storybook */
|
||||
baseURL: 'http://localhost:6006',
|
||||
|
||||
/* Collect trace when retrying the failed test */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run Storybook before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run storybook:build && npx http-server storybook-static -p 6006 -s',
|
||||
url: 'http://localhost:6006',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
|
||||
/* Snapshot configuration */
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
/* Allow slight differences due to anti-aliasing */
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
},
|
||||
|
||||
/* Output folder for test artifacts */
|
||||
outputDir: 'test-results',
|
||||
|
||||
/* Snapshot path template - colocated with component */
|
||||
snapshotPathTemplate: '{testDir}/{testFileDir}/__screenshots__/{projectName}/{arg}{ext}',
|
||||
})
|
||||
3
packages/ui/prettier.config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import prettierConfig from 'eslint-config-it4c/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
179
packages/ui/scripts/check-completeness.ts
Normal file
@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Completeness checker for @ocelot-social/ui components
|
||||
*
|
||||
* Checks:
|
||||
* 1. Every component has a story file (documentation)
|
||||
* 2. Every component has a visual regression test file (quality)
|
||||
* 3. Visual tests include accessibility checks via checkA11y() (quality)
|
||||
* 4. Every component has keyboard accessibility tests (quality)
|
||||
* 5. All variant values are demonstrated in stories (coverage)
|
||||
* 6. All stories have visual regression tests (coverage)
|
||||
*
|
||||
* Note: JSDoc comments on props are checked via ESLint (jsdoc/require-jsdoc)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
import { glob } from 'glob'
|
||||
|
||||
/**
|
||||
* Convert PascalCase to kebab-case (e.g., "AllVariants" -> "all-variants")
|
||||
*/
|
||||
function toKebabCase(str: string): string {
|
||||
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
component: string
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const results: CheckResult[] = []
|
||||
let hasErrors = false
|
||||
|
||||
// Find all Vue components (excluding index files)
|
||||
const components = glob.sync('src/components/**/Os*.vue')
|
||||
|
||||
for (const componentPath of components) {
|
||||
const componentName = basename(componentPath, '.vue')
|
||||
const componentDir = dirname(componentPath)
|
||||
const storyPath = join(componentDir, `${componentName}.stories.ts`)
|
||||
const visualTestPath = join(componentDir, `${componentName}.visual.spec.ts`)
|
||||
const unitTestPath = join(componentDir, `${componentName}.spec.ts`)
|
||||
const variantsPath = join(
|
||||
componentDir,
|
||||
`${componentName.toLowerCase().replace('os', '')}.variants.ts`,
|
||||
)
|
||||
|
||||
const result: CheckResult = {
|
||||
component: componentName,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
// Check 1: Story file exists
|
||||
if (!existsSync(storyPath)) {
|
||||
result.errors.push(`Missing story file: ${storyPath}`)
|
||||
}
|
||||
|
||||
// Check 2: Visual regression test file exists
|
||||
if (!existsSync(visualTestPath)) {
|
||||
result.errors.push(`Missing visual test file: ${visualTestPath}`)
|
||||
}
|
||||
|
||||
// Check 3: Visual tests include accessibility checks
|
||||
if (existsSync(visualTestPath)) {
|
||||
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
|
||||
if (!visualTestContent.includes('checkA11y(')) {
|
||||
result.errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Keyboard accessibility tests exist
|
||||
if (existsSync(unitTestPath)) {
|
||||
const unitTestContent = readFileSync(unitTestPath, 'utf-8')
|
||||
if (!unitTestContent.includes("describe('keyboard accessibility'")) {
|
||||
result.errors.push(`Missing keyboard accessibility tests in: ${unitTestPath}`)
|
||||
}
|
||||
} else {
|
||||
result.errors.push(`Missing unit test file: ${unitTestPath}`)
|
||||
}
|
||||
|
||||
// Check 5 & 6: Story and visual test coverage
|
||||
if (existsSync(storyPath) && existsSync(visualTestPath)) {
|
||||
const storyContent = readFileSync(storyPath, 'utf-8')
|
||||
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
|
||||
|
||||
// Extract exported story names (e.g., "export const Primary: Story")
|
||||
const storyExports = storyContent.matchAll(/export\s+const\s+(\w+):\s*Story/g)
|
||||
|
||||
for (const match of storyExports) {
|
||||
const storyName = match[1]
|
||||
const kebabName = toKebabCase(storyName)
|
||||
|
||||
// Check if this story is tested in visual tests (URL pattern: --story-name)
|
||||
if (!visualTestContent.includes(`--${kebabName}`)) {
|
||||
result.warnings.push(`Story "${storyName}" missing visual test (--${kebabName})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Variant values are demonstrated in stories
|
||||
if (existsSync(storyPath) && existsSync(variantsPath)) {
|
||||
const variantsContent = readFileSync(variantsPath, 'utf-8')
|
||||
const storyContent = readFileSync(storyPath, 'utf-8')
|
||||
|
||||
// Extract variants block
|
||||
const variantsBlockMatch = /variants:\s*\{([\s\S]*?)\n\s{4}\},/m.exec(variantsContent)
|
||||
|
||||
if (variantsBlockMatch) {
|
||||
const variantsBlock = variantsBlockMatch[1]
|
||||
|
||||
// Extract each variant type (variant, size, etc.)
|
||||
const variantTypeMatches = variantsBlock.matchAll(/^\s{6}(\w+):\s*\{([\s\S]*?)\n\s{6}\}/gm)
|
||||
|
||||
for (const match of variantTypeMatches) {
|
||||
const variantName = match[1]
|
||||
const variantValues = match[2]
|
||||
|
||||
// Extract individual values
|
||||
const valueMatches = variantValues.matchAll(/^\s+(\w+):\s*\[/gm)
|
||||
|
||||
for (const valueMatch of valueMatches) {
|
||||
const value = valueMatch[1]
|
||||
// Check if this value appears in stories (multiple patterns)
|
||||
const patterns = [
|
||||
`${variantName}="${value}"`,
|
||||
`${variantName}='${value}'`,
|
||||
`${variantName}: '${value}'`,
|
||||
`${variantName}: "${value}"`,
|
||||
]
|
||||
|
||||
const found = patterns.some((p) => storyContent.includes(p))
|
||||
if (!found) {
|
||||
result.warnings.push(`Variant "${variantName}=${value}" not demonstrated in story`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0 || result.warnings.length > 0) {
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (results.length === 0) {
|
||||
console.log('✓ All completeness checks passed!')
|
||||
} else {
|
||||
console.log('Completeness check results:\n')
|
||||
|
||||
for (const result of results) {
|
||||
console.log(`${result.component}:`)
|
||||
|
||||
for (const error of result.errors) {
|
||||
console.log(` ✗ ${error}`)
|
||||
}
|
||||
|
||||
for (const warning of result.warnings) {
|
||||
console.log(` ⚠ ${warning}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.log('Completeness check failed with errors.')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('Completeness check passed with warnings.')
|
||||
}
|
||||
}
|
||||
97
packages/ui/src/components/OsButton/OsButton.spec.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import OsButton from './OsButton.vue'
|
||||
|
||||
describe('osButton', () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
slots: { default: 'Click me' },
|
||||
})
|
||||
expect(wrapper.text()).toBe('Click me')
|
||||
})
|
||||
|
||||
it('applies default variant classes', () => {
|
||||
const wrapper = mount(OsButton)
|
||||
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
|
||||
})
|
||||
|
||||
it('applies size variant classes', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { size: 'sm' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('h-8')
|
||||
expect(wrapper.classes()).toContain('text-sm')
|
||||
})
|
||||
|
||||
it('applies variant classes', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { variant: 'danger' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
|
||||
})
|
||||
|
||||
it('applies fullWidth class', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { fullWidth: true },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('w-full')
|
||||
})
|
||||
|
||||
it('merges custom classes', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { class: 'my-custom-class' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('my-custom-class')
|
||||
})
|
||||
|
||||
it('sets disabled attribute', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { disabled: true },
|
||||
})
|
||||
expect(wrapper.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('sets button type', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { type: 'submit' },
|
||||
})
|
||||
expect(wrapper.attributes('type')).toBe('submit')
|
||||
})
|
||||
|
||||
it('emits click event', async () => {
|
||||
const wrapper = mount(OsButton)
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('keyboard accessibility', () => {
|
||||
it('renders as native button element for keyboard support', () => {
|
||||
const wrapper = mount(OsButton)
|
||||
// Native button elements have built-in Enter/Space key support
|
||||
expect((wrapper.element as HTMLElement).tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('is focusable by default', () => {
|
||||
const wrapper = mount(OsButton)
|
||||
// No tabindex=-1 means button is in natural tab order
|
||||
expect(wrapper.attributes('tabindex')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remains focusable when disabled via aria', () => {
|
||||
const wrapper = mount(OsButton, {
|
||||
props: { disabled: true },
|
||||
})
|
||||
// Disabled buttons have disabled attribute which browsers handle correctly
|
||||
expect(wrapper.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('can receive focus programmatically', () => {
|
||||
const wrapper = mount(OsButton, { attachTo: document.body })
|
||||
const button = wrapper.element as HTMLButtonElement
|
||||
button.focus()
|
||||
expect(document.activeElement).toBe(button)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
131
packages/ui/src/components/OsButton/OsButton.stories.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import OsButton from './OsButton.vue'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
const meta: Meta<typeof OsButton> = {
|
||||
title: 'Components/OsButton',
|
||||
component: OsButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info', 'ghost', 'outline'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof OsButton>
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
default: 'Primary Button',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { OsButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
|
||||
}),
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
default: 'Secondary Button',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { OsButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
|
||||
}),
|
||||
}
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: 'danger',
|
||||
default: 'Danger Button',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { OsButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
|
||||
}),
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { OsButton },
|
||||
template: `
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<OsButton variant="primary">Primary</OsButton>
|
||||
<OsButton variant="secondary">Secondary</OsButton>
|
||||
<OsButton variant="danger">Danger</OsButton>
|
||||
<OsButton variant="warning">Warning</OsButton>
|
||||
<OsButton variant="success">Success</OsButton>
|
||||
<OsButton variant="info">Info</OsButton>
|
||||
<OsButton variant="ghost">Ghost</OsButton>
|
||||
<OsButton variant="outline">Outline</OsButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { OsButton },
|
||||
template: `
|
||||
<div class="flex flex-col gap-2 items-start">
|
||||
<OsButton size="xs">Extra Small</OsButton>
|
||||
<OsButton size="sm">Small</OsButton>
|
||||
<OsButton size="md">Medium</OsButton>
|
||||
<OsButton size="lg">Large</OsButton>
|
||||
<OsButton size="xl">Extra Large</OsButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
default: 'Disabled Button',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { OsButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
|
||||
}),
|
||||
}
|
||||
|
||||
export const FullWidth: Story = {
|
||||
args: {
|
||||
fullWidth: true,
|
||||
default: 'Full Width Button',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { OsButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
|
||||
}),
|
||||
}
|
||||
82
packages/ui/src/components/OsButton/OsButton.visual.spec.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { AxeBuilder } from '@axe-core/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Visual regression tests for OsButton component
|
||||
*
|
||||
* These tests capture screenshots of Storybook stories and compare them
|
||||
* against baseline images to detect unintended visual changes.
|
||||
* Each test also runs accessibility checks using axe-core.
|
||||
*/
|
||||
|
||||
const STORY_URL = '/iframe.html?id=components-osbutton'
|
||||
const STORY_ROOT = '#storybook-root'
|
||||
|
||||
/**
|
||||
* Helper to run accessibility check on the current page
|
||||
*/
|
||||
async function checkA11y(page: Page) {
|
||||
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
|
||||
|
||||
expect(results.violations).toEqual([])
|
||||
}
|
||||
|
||||
test.describe('OsButton visual regression', () => {
|
||||
test('primary variant', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--primary&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('button')).toHaveScreenshot('primary.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('secondary variant', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--secondary&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('button')).toHaveScreenshot('secondary.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('danger variant', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--danger&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('button')).toHaveScreenshot('danger.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('all variants', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--all-variants&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('.flex')).toHaveScreenshot('all-variants.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('all sizes', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('.flex')).toHaveScreenshot('all-sizes.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('disabled state', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--disabled&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('button')).toHaveScreenshot('disabled.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('full width', async ({ page }) => {
|
||||
await page.goto(`${STORY_URL}--full-width&viewMode=story`)
|
||||
const root = page.locator(STORY_ROOT)
|
||||
await root.waitFor()
|
||||
await expect(root.locator('button')).toHaveScreenshot('full-width.png')
|
||||
await checkA11y(page)
|
||||
})
|
||||
})
|
||||
50
packages/ui/src/components/OsButton/OsButton.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue-demi'
|
||||
|
||||
import { cn } from '../../utils'
|
||||
|
||||
import { buttonVariants } from './button.variants'
|
||||
|
||||
import type { ButtonVariants } from './button.variants'
|
||||
|
||||
export interface OsButtonProps {
|
||||
/** Visual style variant */
|
||||
variant?: ButtonVariants['variant']
|
||||
/** Size of the button */
|
||||
size?: ButtonVariants['size']
|
||||
/** Whether button takes full width of container */
|
||||
fullWidth?: boolean
|
||||
/** HTML button type attribute */
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
/** Whether the button is disabled */
|
||||
disabled?: boolean
|
||||
/** Additional CSS classes */
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<OsButtonProps>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
fullWidth: false,
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
class: '',
|
||||
})
|
||||
|
||||
const classes = computed(() =>
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: props.variant,
|
||||
size: props.size,
|
||||
fullWidth: props.fullWidth,
|
||||
}),
|
||||
props.class,
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled" :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
251
packages/ui/src/components/OsButton/STATUS.md
Normal file
@ -0,0 +1,251 @@
|
||||
# OsButton Status
|
||||
|
||||
> Status-Tracking der OsButton-Komponente
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Aspekt | Status |
|
||||
|--------|--------|
|
||||
| **Implementierung** | Basis implementiert |
|
||||
| **CVA-Integration** | Vollständig |
|
||||
| **Tests** | 9 Tests vorhanden |
|
||||
| **Storybook** | Ausstehend |
|
||||
| **A11y** | Teilweise |
|
||||
|
||||
---
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Typ | Default | Status | Notizen |
|
||||
|------|-----|---------|--------|---------|
|
||||
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'warning' \| 'success' \| 'info' \| 'ghost' \| 'outline'` | `'primary'` | Implementiert | 8 Varianten via CVA |
|
||||
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Implementiert | 5 Größen via CVA |
|
||||
| `fullWidth` | `boolean` | `false` | Implementiert | |
|
||||
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Implementiert | |
|
||||
| `disabled` | `boolean` | `false` | Implementiert | |
|
||||
| `class` | `string` | `''` | Implementiert | Via cn() gemerged |
|
||||
|
||||
### Slots
|
||||
|
||||
| Slot | Status | Notizen |
|
||||
|------|--------|---------|
|
||||
| `default` | Implementiert | Button-Content |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Status | Notizen |
|
||||
|-------|--------|---------|
|
||||
| Native Events | Implementiert | click, focus, etc. werden durchgereicht |
|
||||
|
||||
---
|
||||
|
||||
## Fehlende Features (aus KATALOG.md)
|
||||
|
||||
### Hohe Priorität
|
||||
|
||||
| Feature | Beschreibung | Aufwand |
|
||||
|---------|--------------|---------|
|
||||
| `icon` | Icon-Name/Komponente einbinden | Mittel - erfordert OsIcon |
|
||||
| `iconPosition` | `'left' \| 'right'` | Klein - nach Icon-Support |
|
||||
| `loading` | Ladezustand mit Spinner | Mittel - erfordert OsSpinner |
|
||||
|
||||
### Mittlere Priorität
|
||||
|
||||
| Feature | Beschreibung | Aufwand |
|
||||
|---------|--------------|---------|
|
||||
| `to` | Vue Router Link-Support | Mittel - erfordert router-link/NuxtLink |
|
||||
| `href` | Externer Link-Support | Klein - `<a>` statt `<button>` |
|
||||
| `circle` | Runder Button | Klein - CVA-Variant hinzufügen |
|
||||
|
||||
### Niedrige Priorität
|
||||
|
||||
| Feature | Beschreibung | Aufwand |
|
||||
|---------|--------------|---------|
|
||||
| `filled` vs `ghost` | Explizite Unterscheidung | Diskussion nötig - aktuell via `variant` |
|
||||
|
||||
### Nicht geplant
|
||||
|
||||
| Feature | Begründung |
|
||||
|---------|------------|
|
||||
| `bullet` | Zu spezifisch, kann mit `circle` + custom size erreicht werden |
|
||||
| `hover` prop | CSS :hover reicht |
|
||||
| `padding` prop | Sollte über size geregelt werden |
|
||||
|
||||
---
|
||||
|
||||
## Vergleich: Aktuell vs. Zielzustand
|
||||
|
||||
### KATALOG.md Vorschlag (OsButtonProps)
|
||||
|
||||
```typescript
|
||||
interface OsButtonProps {
|
||||
// Variante
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'danger'
|
||||
filled?: boolean
|
||||
ghost?: boolean
|
||||
|
||||
// Größe & Form
|
||||
size?: 'tiny' | 'small' | 'base' | 'large'
|
||||
circle?: boolean
|
||||
fullWidth?: boolean
|
||||
|
||||
// Icon
|
||||
icon?: string
|
||||
iconPosition?: 'left' | 'right'
|
||||
|
||||
// Zustände
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
|
||||
// Link-Support
|
||||
to?: string | RouteLocationRaw
|
||||
href?: string
|
||||
|
||||
// Button-Typ
|
||||
type?: 'button' | 'submit'
|
||||
}
|
||||
```
|
||||
|
||||
### Aktuelle Implementierung
|
||||
|
||||
```typescript
|
||||
interface OsButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'ghost' | 'outline'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
fullWidth?: boolean
|
||||
type?: 'button' \| 'submit' \| 'reset'
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Unterschiede
|
||||
|
||||
| Aspekt | KATALOG.md | Implementiert | Kommentar |
|
||||
|--------|------------|---------------|-----------|
|
||||
| **Varianten** | 4 + filled/ghost Modifikatoren | 8 eigenständige | Besser: Mehr Varianten ohne Modifikatoren |
|
||||
| **Sizes** | tiny, small, base, large | xs, sm, md, lg, xl | Besser: 5 statt 4, Standard-Naming |
|
||||
| **Icon-Support** | icon, iconPosition | - | Fehlt: erfordert OsIcon |
|
||||
| **Loading** | loading | - | Fehlt: erfordert OsSpinner |
|
||||
| **Link-Support** | to, href | - | Fehlt: Router-Integration |
|
||||
| **Circle** | circle | - | Fehlt: einfach hinzuzufügen |
|
||||
|
||||
---
|
||||
|
||||
## Test-Coverage
|
||||
|
||||
**Datei:** `OsButton.spec.ts`
|
||||
|
||||
| Test | Beschreibung |
|
||||
|------|--------------|
|
||||
| renders slot content | Slot-Inhalt wird gerendert |
|
||||
| applies default variant classes | Primary-Variante als Default |
|
||||
| applies size variant classes | Size-Classes korrekt |
|
||||
| applies variant classes | Varianten-Classes korrekt |
|
||||
| applies fullWidth class | w-full wird gesetzt |
|
||||
| merges custom classes | Custom Classes werden via cn() gemerged |
|
||||
| sets disabled attribute | disabled-Attribut wird gesetzt |
|
||||
| sets button type | type-Attribut wird gesetzt |
|
||||
| emits click event | Click-Event wird emittiert |
|
||||
|
||||
### Fehlende Tests
|
||||
|
||||
- [ ] Alle 8 Varianten testen
|
||||
- [ ] Alle 5 Sizes testen
|
||||
- [ ] Keyboard-Navigation (Enter, Space)
|
||||
- [ ] Focus-States
|
||||
- [ ] Disabled-State verhindert Click
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
### Datei-Struktur
|
||||
|
||||
```
|
||||
src/components/OsButton/
|
||||
├── OsButton.vue # Hauptkomponente
|
||||
├── OsButton.spec.ts # Tests
|
||||
├── button.variants.ts # CVA-Varianten-Definition
|
||||
├── index.ts # Exports
|
||||
└── STATUS.md # Diese Datei
|
||||
```
|
||||
|
||||
### CVA-Pattern
|
||||
|
||||
Die Komponente nutzt das CVA-Pattern (Class Variance Authority):
|
||||
|
||||
1. **button.variants.ts** - Definiert alle Varianten als Type-Safe Funktion
|
||||
2. **OsButton.vue** - Nutzt `buttonVariants()` + `cn()` für finale Klassen
|
||||
3. **Export** - Varianten-Funktion + Typen werden exportiert für Composability
|
||||
|
||||
### CSS-Variablen
|
||||
|
||||
Die Komponente nutzt CSS Custom Properties für Theming:
|
||||
|
||||
```css
|
||||
--color-primary
|
||||
--color-primary-contrast
|
||||
--color-primary-hover
|
||||
--color-secondary (etc.)
|
||||
--color-danger (etc.)
|
||||
--color-warning (etc.)
|
||||
--color-success (etc.)
|
||||
--color-info (etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
### Phase 1: Icon-Support (erfordert OsIcon)
|
||||
|
||||
1. OsIcon-Komponente erstellen
|
||||
2. `icon` Prop hinzufügen (string für Icon-Name oder Komponente)
|
||||
3. `iconPosition` Prop hinzufügen ('left' | 'right')
|
||||
4. Layout für Icon + Text anpassen
|
||||
|
||||
### Phase 2: Loading-State (erfordert OsSpinner)
|
||||
|
||||
1. OsSpinner-Komponente erstellen
|
||||
2. `loading` Prop hinzufügen
|
||||
3. Bei loading: Spinner anzeigen, Button disabled
|
||||
|
||||
### Phase 3: Link-Support
|
||||
|
||||
1. `to` Prop für Vue Router Links
|
||||
2. `href` Prop für externe Links
|
||||
3. Dynamisches Element: `<button>` | `<router-link>` | `<a>`
|
||||
|
||||
### Phase 4: Circle-Variant
|
||||
|
||||
1. `circle` Prop hinzufügen
|
||||
2. CVA-Variant für runden Button
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
```
|
||||
OsButton
|
||||
├── Benötigt: cn() utility Vorhanden
|
||||
├── Benötigt: CVA Vorhanden
|
||||
├── Für Icon: OsIcon Ausstehend (Tier 1)
|
||||
├── Für Loading: OsSpinner Ausstehend (Tier 1)
|
||||
└── Für Link: Vue Router Optional (nur für SPA-Links)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-02-07 | Status-Datei erstellt |
|
||||
| 2026-02-07 | CVA-Integration abgeschlossen |
|
||||
| 2026-02-07 | 8 Varianten, 5 Sizes implementiert |
|
||||
| 2026-02-07 | 9 Tests vorhanden |
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
82
packages/ui/src/components/OsButton/button.variants.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
/**
|
||||
* Button variants using CVA (Class Variance Authority)
|
||||
*
|
||||
* This pattern allows:
|
||||
* - Type-safe variant props
|
||||
* - Composable class combinations
|
||||
* - Easy customization via class prop
|
||||
*/
|
||||
export const buttonVariants = cva(
|
||||
// Base classes (always applied)
|
||||
[
|
||||
'inline-flex items-center justify-center',
|
||||
'font-medium',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
'bg-[var(--color-primary)] text-[var(--color-primary-contrast)]',
|
||||
'hover:bg-[var(--color-primary-hover)]',
|
||||
'focus-visible:ring-[var(--color-primary)]',
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)] text-[var(--color-secondary-contrast)]',
|
||||
'hover:bg-[var(--color-secondary-hover)]',
|
||||
'focus-visible:ring-[var(--color-secondary)]',
|
||||
],
|
||||
danger: [
|
||||
'bg-[var(--color-danger)] text-[var(--color-danger-contrast)]',
|
||||
'hover:bg-[var(--color-danger-hover)]',
|
||||
'focus-visible:ring-[var(--color-danger)]',
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)] text-[var(--color-warning-contrast)]',
|
||||
'hover:bg-[var(--color-warning-hover)]',
|
||||
'focus-visible:ring-[var(--color-warning)]',
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)] text-[var(--color-success-contrast)]',
|
||||
'hover:bg-[var(--color-success-hover)]',
|
||||
'focus-visible:ring-[var(--color-success)]',
|
||||
],
|
||||
info: [
|
||||
'bg-[var(--color-info)] text-[var(--color-info-contrast)]',
|
||||
'hover:bg-[var(--color-info-hover)]',
|
||||
'focus-visible:ring-[var(--color-info)]',
|
||||
],
|
||||
ghost: ['bg-transparent', 'hover:bg-gray-100', 'focus-visible:ring-gray-400'],
|
||||
outline: [
|
||||
'border border-current bg-transparent',
|
||||
'hover:bg-gray-100',
|
||||
'focus-visible:ring-gray-400',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: 'h-6 px-2 text-xs rounded',
|
||||
sm: 'h-8 px-3 text-sm rounded-md',
|
||||
md: 'h-10 px-4 text-base rounded-md',
|
||||
lg: 'h-12 px-6 text-lg rounded-lg',
|
||||
xl: 'h-14 px-8 text-xl rounded-lg',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
fullWidth: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
2
packages/ui/src/components/OsButton/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as OsButton } from './OsButton.vue'
|
||||
export { buttonVariants, type ButtonVariants } from './button.variants'
|
||||
10
packages/ui/src/components/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Component exports
|
||||
*
|
||||
* Single source of truth for all components.
|
||||
* Add new components here - they will automatically be:
|
||||
* - Available as named exports: import { OsButton } from '@ocelot-social/ui'
|
||||
* - Registered globally when using the plugin: app.use(OcelotUI)
|
||||
*/
|
||||
|
||||
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'
|
||||
20
packages/ui/src/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @ocelot-social/ui
|
||||
*
|
||||
* Vue component library for ocelot.social
|
||||
* Works with Vue 2.7+ and Vue 3
|
||||
*
|
||||
* Note: CSS is built separately - import '@ocelot-social/ui/style.css' in your app
|
||||
*/
|
||||
|
||||
// Re-export all components
|
||||
export * from './components'
|
||||
|
||||
// Export Vue plugin for global registration
|
||||
export { default as OcelotUI } from './plugin'
|
||||
|
||||
// Export utilities
|
||||
export { cn } from './utils'
|
||||
|
||||
// Export prop types
|
||||
export type { Size, Rounded, Shadow, Variant } from './types'
|
||||
50
packages/ui/src/plugin.spec.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// eslint-disable-next-line import-x/no-namespace -- needed to verify all components are registered
|
||||
import * as components from './components'
|
||||
import OcelotUI from './plugin'
|
||||
|
||||
describe('ocelotUI Plugin', () => {
|
||||
it('has an install function', () => {
|
||||
expect(OcelotUI.install).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('registers only Os-prefixed components', () => {
|
||||
const mockApp = {
|
||||
component: vi.fn(),
|
||||
}
|
||||
|
||||
OcelotUI.install?.(mockApp as never)
|
||||
|
||||
// Filter to only Os-prefixed entries (actual Vue components)
|
||||
const osComponents = Object.entries(components).filter(([name]) => name.startsWith('Os'))
|
||||
|
||||
expect(mockApp.component).toHaveBeenCalledTimes(osComponents.length)
|
||||
|
||||
for (const [name, component] of osComponents) {
|
||||
expect(mockApp.component).toHaveBeenCalledWith(name, component)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not register non-component exports', () => {
|
||||
const mockApp = {
|
||||
component: vi.fn(),
|
||||
}
|
||||
|
||||
OcelotUI.install?.(mockApp as never)
|
||||
|
||||
// buttonVariants should NOT be registered
|
||||
const callArgs = mockApp.component.mock.calls.map((call: unknown[]) => call[0])
|
||||
expect(callArgs).not.toContain('buttonVariants')
|
||||
})
|
||||
|
||||
it('works without throwing', () => {
|
||||
const mockApp = {
|
||||
component: vi.fn(),
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
OcelotUI.install?.(mockApp as never)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
26
packages/ui/src/plugin.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// eslint-disable-next-line import-x/no-namespace -- needed for dynamic component registration
|
||||
import * as components from './components'
|
||||
|
||||
import type { App, Component, Plugin } from 'vue-demi'
|
||||
|
||||
/**
|
||||
* Vue plugin for global component registration
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { OcelotUI } from '@ocelot-social/ui'
|
||||
* app.use(OcelotUI)
|
||||
* ```
|
||||
*/
|
||||
const OcelotUI: Plugin = {
|
||||
install(app: App) {
|
||||
for (const [name, component] of Object.entries(components)) {
|
||||
// Only register Vue components (starting with 'Os')
|
||||
if (name.startsWith('Os')) {
|
||||
app.component(name, component as Component)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default OcelotUI
|
||||
5
packages/ui/src/styles/index.css
Normal file
@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Scan component files for utility classes */
|
||||
@source "../components/**/*.vue";
|
||||
@source "../**/*.ts";
|
||||
94
packages/ui/src/tailwind.preset.spec.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import { ocelotPreset, requiredCssVariables, validateCssVariables } from './tailwind.preset'
|
||||
|
||||
describe('tailwind.preset', () => {
|
||||
describe('ocelotPreset', () => {
|
||||
it('exports a valid Tailwind preset with theme.extend structure', () => {
|
||||
expect(ocelotPreset).toBeDefined()
|
||||
expect(ocelotPreset).toHaveProperty('theme')
|
||||
expect(ocelotPreset.theme).toHaveProperty('extend')
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiredCssVariables', () => {
|
||||
it('exports an array', () => {
|
||||
expect(Array.isArray(requiredCssVariables)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('contains only strings', () => {
|
||||
for (const variable of requiredCssVariables) {
|
||||
expect(typeof variable).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('all variables start with --', () => {
|
||||
// This test validates the format constraint.
|
||||
for (const variable of requiredCssVariables) {
|
||||
expect(variable.startsWith('--')).toBeTruthy()
|
||||
}
|
||||
// Ensure test runs even with empty array
|
||||
expect(requiredCssVariables.every((v) => v.startsWith('--'))).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateCssVariables', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
expect(() => {
|
||||
validateCssVariables()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not warn when all variables are defined', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const mockGetPropertyValue = vi.fn().mockReturnValue('some-value')
|
||||
|
||||
vi.stubGlobal('window', {})
|
||||
vi.stubGlobal('document', {
|
||||
documentElement: {},
|
||||
})
|
||||
vi.stubGlobal('getComputedStyle', () => ({
|
||||
getPropertyValue: mockGetPropertyValue,
|
||||
}))
|
||||
|
||||
validateCssVariables()
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when variables are missing', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const mockGetPropertyValue = vi.fn().mockReturnValue('')
|
||||
|
||||
vi.stubGlobal('window', {})
|
||||
vi.stubGlobal('document', {
|
||||
documentElement: {},
|
||||
})
|
||||
vi.stubGlobal('getComputedStyle', () => ({
|
||||
getPropertyValue: mockGetPropertyValue,
|
||||
}))
|
||||
|
||||
// Temporarily add a required variable for testing
|
||||
const originalVariables = [...requiredCssVariables]
|
||||
requiredCssVariables.push('--test-variable')
|
||||
|
||||
validateCssVariables()
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Missing required CSS variables'),
|
||||
)
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('--test-variable'))
|
||||
|
||||
// Restore original state
|
||||
requiredCssVariables.length = 0
|
||||
requiredCssVariables.push(...originalVariables)
|
||||
})
|
||||
})
|
||||
})
|
||||
95
packages/ui/src/tailwind.preset.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Tailwind CSS Preset for @ocelot-social/ui
|
||||
*
|
||||
* This preset defines CSS Custom Properties used by components.
|
||||
* The library does NOT provide default values - the consuming app must define all variables.
|
||||
*
|
||||
* Branding hierarchy:
|
||||
* 1. Webapp defines default branding (base colors)
|
||||
* 2. Specialized brandings override the defaults
|
||||
*
|
||||
* Usage in your tailwind.config.js:
|
||||
* ```js
|
||||
* import { ocelotPreset } from '@ocelot-social/ui/tailwind.preset'
|
||||
*
|
||||
* export default {
|
||||
* presets: [ocelotPreset],
|
||||
* // your config...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Required CSS Variables (defined by webapp):
|
||||
* - See `requiredCssVariables` export for the full list
|
||||
*/
|
||||
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
/**
|
||||
* List of CSS Custom Properties that must be defined by the consuming app.
|
||||
* This list grows as components are added to the library.
|
||||
*/
|
||||
export const requiredCssVariables: string[] = [
|
||||
// Primary
|
||||
'--color-primary',
|
||||
'--color-primary-hover',
|
||||
'--color-primary-contrast',
|
||||
// Secondary
|
||||
'--color-secondary',
|
||||
'--color-secondary-hover',
|
||||
'--color-secondary-contrast',
|
||||
// Danger
|
||||
'--color-danger',
|
||||
'--color-danger-hover',
|
||||
'--color-danger-contrast',
|
||||
// Warning
|
||||
'--color-warning',
|
||||
'--color-warning-hover',
|
||||
'--color-warning-contrast',
|
||||
// Success
|
||||
'--color-success',
|
||||
'--color-success-hover',
|
||||
'--color-success-contrast',
|
||||
// Info
|
||||
'--color-info',
|
||||
'--color-info-hover',
|
||||
'--color-info-contrast',
|
||||
]
|
||||
|
||||
/**
|
||||
* Validates that all required CSS variables are defined.
|
||||
* Call this in development to catch missing variables early.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { validateCssVariables } from '@ocelot-social/ui/tailwind.preset'
|
||||
*
|
||||
* if (process.env.NODE_ENV === 'development') {
|
||||
* validateCssVariables()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validateCssVariables(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const missing = requiredCssVariables.filter(
|
||||
(variable) => !styles.getPropertyValue(variable).trim(),
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[@ocelot-social/ui] Missing required CSS variables:\n${missing.map((v) => ` - ${v}`).join('\n')}\n\nDefine these in your app's CSS.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const ocelotPreset: Partial<Config> = {
|
||||
theme: {
|
||||
extend: {
|
||||
// Colors and other theme extensions will be added here as components are developed.
|
||||
// All values use CSS Custom Properties WITHOUT defaults.
|
||||
// Example: primary: { DEFAULT: 'var(--color-primary)' }
|
||||
},
|
||||
},
|
||||
}
|
||||
29
packages/ui/src/types.d.ts
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Component prop types based on Tailwind CSS scales
|
||||
*
|
||||
* These types ensure consistency across all components.
|
||||
* When a component supports a prop, it must support all values of that scale.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Size scale for components (buttons, inputs, avatars, etc.)
|
||||
* Maps to Tailwind's text/spacing scale
|
||||
*/
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
/**
|
||||
* Border radius scale
|
||||
* Maps to Tailwind's rounded-* utilities
|
||||
*/
|
||||
export type Rounded = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
|
||||
|
||||
/**
|
||||
* Box shadow scale
|
||||
* Maps to Tailwind's shadow-* utilities
|
||||
*/
|
||||
export type Shadow = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
/**
|
||||
* Semantic color variants for interactive components
|
||||
*/
|
||||
export type Variant = 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info'
|
||||
34
packages/ui/src/utils/cn.spec.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cn } from './cn'
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges class names', () => {
|
||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
||||
})
|
||||
|
||||
it('handles conditional classes with false', () => {
|
||||
expect(cn('foo', false, 'baz')).toBe('foo baz')
|
||||
})
|
||||
|
||||
it('handles conditional classes with string', () => {
|
||||
expect(cn('foo', 'bar', 'baz')).toBe('foo bar baz')
|
||||
})
|
||||
|
||||
it('handles undefined and null', () => {
|
||||
expect(cn('foo', undefined, null, 'bar')).toBe('foo bar')
|
||||
})
|
||||
|
||||
it('merges conflicting Tailwind classes', () => {
|
||||
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4')
|
||||
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
|
||||
})
|
||||
|
||||
it('handles arrays', () => {
|
||||
expect(cn(['foo', 'bar'], 'baz')).toBe('foo bar baz')
|
||||
})
|
||||
|
||||
it('handles objects', () => {
|
||||
expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz')
|
||||
})
|
||||
})
|
||||
16
packages/ui/src/utils/cn.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import type { ClassValue } from 'clsx'
|
||||
|
||||
/**
|
||||
* Utility function for merging Tailwind CSS classes.
|
||||
* Combines clsx for conditional classes with tailwind-merge for deduplication.
|
||||
*
|
||||
* @example
|
||||
* cn('px-2 py-1', 'px-4') // => 'py-1 px-4' (px-4 overrides px-2)
|
||||
* cn('text-red-500', condition && 'text-blue-500')
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
1
packages/ui/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { cn } from './cn'
|
||||