diff --git a/.changeset/initial-components.md b/.changeset/initial-components.md new file mode 100644 index 0000000..6959853 --- /dev/null +++ b/.changeset/initial-components.md @@ -0,0 +1,12 @@ +--- +"@fried-ui/react": minor +"@fried-ui/styles": minor +--- + +Initial component release. + +Components: Avatar, AvatarGroup, Badge, Button, Chip, Description, FieldError, +Icons, Input, Label, SignalDot, Surface, Textarea. + +Tokens: palette, semantic colors, spacing (golden ratio), motion, layout, +typography. Shared utilities: focus, sizing, status. diff --git a/.claude/agents/design-styles.md b/.claude/agents/design-styles.md deleted file mode 100644 index de52bf9..0000000 --- a/.claude/agents/design-styles.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: design-styles -description: Design, develop, and maintain the fried-ui design token system and component styles in packages/styles -tools: All tools ---- - -You design and develop the fried-ui style system — tokens, utilities, and component CSS in `packages/styles/`. - -Design rules auto-load from `.claude/rules/` when editing style files. - -## Responsibilities - -- Design and create new tokens (palette, colors, layout, motion, typography) -- Create component CSS (BEM with `fri-` prefix) -- Create shared `@utility` patterns -- Audit existing tokens/styles against `.claude/rules/` specs -- Ensure consistency between rules and code - -## Steps - -1. Read all rules from `.claude/rules/*.md` -2. Read all code in `packages/styles/src/` recursively -3. Design, create, or fix as needed -4. Verify: `pnpm -w run build` diff --git a/.claude/agents/implement-component.md b/.claude/agents/implement-component.md deleted file mode 100644 index 5f99d86..0000000 --- a/.claude/agents/implement-component.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: implement-component -description: Implement a new component for @fried-ui/react following the design system conventions -tools: All tools ---- - -You implement components for fried-ui — React Aria Components + Tailwind CSS v4 + pure CSS BEM. - -Design rules auto-load from `.claude/rules/` when editing component files. - -## Steps - -1. Read `packages/react/src/components/button/` as reference pattern -2. Research the matching React Aria component (use context7 MCP or web search) -3. Create CSS → `packages/styles/src/components/{name}.css` - - Add import in `packages/styles/src/components/index.css` -4. Create component → `packages/react/src/components/{name}/` - - `{Name}.tsx` — wrap React Aria, use `cn()` with BEM classes - - `{name}.test.tsx` — Vitest + RTL - - `{name}.stories.tsx` — Storybook 10 with `satisfies Meta` - - `index.ts` — re-exports -5. Register exports: - - `packages/react/src/components/index.ts` - - `packages/react/tsup.config.ts` - - `packages/react/package.json` -6. Verify: - - `pnpm -w run build` - - `pnpm --filter=@fried-ui/react run lint` (run `--fix` first) - - `pnpm --filter=@fried-ui/react test` diff --git a/.claude/hooks/post-edit-format.sh b/.claude/hooks/post-edit-format.sh deleted file mode 100755 index a435c88..0000000 --- a/.claude/hooks/post-edit-format.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') - -if [[ -z "$FILE_PATH" ]]; then - exit 0 -fi - -cd "$CLAUDE_PROJECT_DIR" || exit 0 - -# Format -if [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|json|css|md|mdx)$ ]]; then - npx prettier --write "$FILE_PATH" 2>/dev/null -fi - -# Lint fix -if [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs)$ ]]; then - npx eslint --fix "$FILE_PATH" 2>/dev/null -fi - -exit 0 diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh deleted file mode 100755 index 687adc8..0000000 --- a/.claude/hooks/protect-files.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') -FILE_NAME=$(basename "$FILE_PATH") - -PROTECTED_FILES="pnpm-lock.yaml .env .env.production .env.local firebase.json" - -for f in $PROTECTED_FILES; do - if [ "$FILE_NAME" = "$f" ]; then - echo "Cannot edit protected file: $FILE_NAME" >&2 - exit 2 - fi -done - -exit 0 diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md deleted file mode 100644 index b8220be..0000000 --- a/.claude/rules/architecture.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -description: Component architecture, file structure, data flow, and implementation checklist -paths: - - packages/react/src/**/*.{ts,tsx} - - packages/styles/src/**/*.css ---- - -# Architecture - -## Layer - -```text -@fried-ui/react ← React components (behavior + accessibility) -@fried-ui/styles ← CSS: design tokens + component styles (BEM) -react-aria-components ← Accessibility primitives -tailwindcss v4 ← Styling engine -``` - -## Package Boundary - -```text -@fried-ui/styles (ไม่ depend on React) - exports: - "." → CSS (tokens + component BEM classes) - -@fried-ui/react (depends on react-aria) - exports: - "." → barrel export - "./{name}" → individual component - "./utils/cn" → cn utility -``` - -## Component Anatomy - -### Layer 1: Design Tokens → `packages/styles/src/tokens/` - -ทุก token อยู่ใน `@theme` — Tailwind v4 generate CSS variables ให้ - -### Layer 2: Component Styles → `packages/styles/src/components/{name}.css` - -BEM classes + `@apply` + CSS custom properties สำหรับ variant colors - -### Layer 3: React Component → `packages/react/src/components/{name}/` - -```text -{Name}.tsx # "use client", wrap React Aria component -{name}.test.tsx # Vitest + React Testing Library -{name}.stories.tsx # Storybook 10 -index.ts # re-exports -``` - -## Data Flow - -```text -props → destructure { variant, size, className, children, ...rest } -rest → forward ไป React Aria -className → cn("fri-{name}", "fri-{name}--{variant}", "fri-{name}--{size}", cls) -children → composeRenderProps → wrap กับ internal UI -``` - -## Type Pattern - -```typescript -// Public type ก่อน -export type ButtonProps = { ... } & Omit; -// Internal type หลัง -type ButtonVariant = "primary"; -``` - -## Import Pattern - -```typescript -import type { ReactNode } from "react"; -import { Button as RACButton, type ButtonProps as RACButtonProps, composeRenderProps } from "react-aria-components"; -import { cn } from "src/utils/cn"; -``` - -## BEM Naming - -```text -.fri-{component} → base -.fri-{component}--{variant} → variant -.fri-{component}--{size} → size -.fri-{component}--{state} → state -``` - -React Aria states ใน CSS: `&[data-pressed]`, `&[data-hovered]`, `&[data-focused]` - -## Checklist: Component ใหม่ - -1. สร้าง CSS → `packages/styles/src/components/{name}.css` -2. Import → `packages/styles/src/components/index.css` -3. สร้าง component → `packages/react/src/components/{name}/` -4. Re-export → `packages/react/src/components/index.ts` -5. เพิ่ม tsup entry → `packages/react/tsup.config.ts` -6. เพิ่ม export map → `packages/react/package.json` -7. Build + lint + test ผ่าน diff --git a/.claude/rules/color.md b/.claude/rules/color.md deleted file mode 100644 index 5909d7a..0000000 --- a/.claude/rules/color.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -description: Color state mathematics, contrast rules, and semantic token policy -paths: - - packages/styles/src/tokens/colors.css - - packages/styles/src/components/**/*.css ---- - -# Color - -## State Mathematics - -Hover/Active ใช้ Tailwind palette step ที่มืดลงจาก base: - -```text -Hover = base + 1 step (เช่น 500 → 600) -Active = base + 2 steps (เช่น 500 → 700) -``` - -ทั้ง light mode และ dark mode ใช้ทิศทางเดียวกัน — มืดลงเสมอ - -## Contrast Auto-switch (WCAG 2.1) - -```text -L < 0.6 → foreground = white -L ≥ 0.6 → foreground = gray-900/neutral-950 -``` - -## Semantic Tokens Only - -Component ห้ามใช้สีตรงๆ (`bg-blue-500`) — ใช้แค่ semantic tokens: - -```text -bg-primary, text-primary-foreground, bg-surface, border-border -``` - -Semantic tokens ใช้ `var()` จาก Tailwind built-in colors + custom palette diff --git a/.claude/rules/constraints.md b/.claude/rules/constraints.md deleted file mode 100644 index d8eee55..0000000 --- a/.claude/rules/constraints.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -description: System limits, hardware constraints, and safety nets -paths: - - packages/styles/src/**/*.css - - packages/react/src/components/**/*.tsx ---- - -# Constraints - -## Typography Scale: √φ (1.272) ไม่ใช่ φ - -```text -n=0: 16px (base) -n=1: 20px (lg) -n=2: 26px (heading) -n=3: 33px (display) -``` - -## Body Text Line-height: x × φ - -Single-line (Button) → `leading-none` -Paragraph → `line-height = x × φ` → 16px font = `leading-7` (28px) - -## Borders: ล็อก 1px - -ห้าม scale border/stroke/divider ตาม φ — ล็อก 1px เสมอ -ยกเว้น focus ring = 2px - -## Grid Layout - -```css -grid-template-columns: 1fr 1.618fr; -``` - -## Touch Target (WCAG 2.5.5) - -Mobile (< 768px) ทุก clickable ต้องมี hit area ≥ 44×44px -ใช้ `::after` pseudo-element ขยายแบบโปร่งใส - -## Responsive Collapse - -```text -Desktop: container padding = x × φ → p-6 -Mobile: container padding = x → p-4 -``` - -## Icon Scale - -ไอคอนคู่กับ text ใช้ `w-[1em] h-[1em]` — lock กับ font-size diff --git a/.claude/rules/elevation.md b/.claude/rules/elevation.md deleted file mode 100644 index 14e9cc4..0000000 --- a/.claude/rules/elevation.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Shadow scaling with φ and z-index architecture -paths: - - packages/styles/src/**/*.css ---- - -# Elevation - -## Shadow Tokens - -```text ---shadow-1: 0 1px 2px (base) ---shadow-2: 0 2px 4px (base × φ) ---shadow-3: 0 3px 6px (base × φ²) ---shadow-4: 0 5px 10px (base × φ³) -``` - -## Z-Index Tokens - -```text ---z-floating: 10 → Dropdown, Tooltip, Popover ---z-navigation: 20 → Sticky Header, FAB ---z-overlay: 30 → Modal Backdrop, Drawer Overlay ---z-dialog: 40 → Modal, Bottom Sheet, Drawer ---z-alert: 50 → Toast, Notification -``` - -ห้ามใช้ arbitrary z-index (z-[99], z-[9999]) diff --git a/.claude/rules/motion.md b/.claude/rules/motion.md deleted file mode 100644 index 204a4b3..0000000 --- a/.claude/rules/motion.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -description: Animation duration and easing curve rules -paths: - - packages/styles/src/tokens/motion.css - - packages/styles/src/components/**/*.css ---- - -# Motion - -## Duration - -```text -Instant = 100ms (hover, toggle) -Fast = 150ms (active, focus) -Normal = 200ms (expand, slide) -Slow = 300ms (modal, page) -``` - -ทุกค่าต่ำกว่า Doherty Threshold (400ms) - -## Easing - -```text -ease-smooth = cubic-bezier(0.4, 0, 0.2, 1) ← default -ease-in = cubic-bezier(0.4, 0, 1, 1) ← exit -ease-out = cubic-bezier(0, 0, 0.2, 1) ← entrance -``` - -ห้ามใช้ linear diff --git a/.claude/rules/spacing.md b/.claude/rules/spacing.md deleted file mode 100644 index ff01055..0000000 --- a/.claude/rules/spacing.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -description: Golden ratio spacing formula, size scale, and container anchor base -paths: - - packages/styles/src/components/**/*.css - - packages/react/src/components/**/*.tsx ---- - -# Spacing (Golden Ratio) - -## x = font-size คือตัวตั้ง - -เมื่อ size เปลี่ยน x ต้องเปลี่ยนด้วย — Proportional Scaling - -```text -x = font-size (ตัวตั้ง) -φ = 1.618 - -Padding Inline = x -Padding Block = x × 0.485 -Gap = x / φ -Radius = Tailwind step (rounded-sm → rounded-md → rounded-lg → rounded-xl → rounded-2xl) -Height = auto — บังคับ leading-none -``` - -## Size Scale - -Font scale ใช้ √φ (1.272): 14 → 16 → 20 → 24 — ข้าม 18px (text-lg) เพราะไม่ตรง logarithmic scale - -| Size | x | Font | Padding Inline | Padding Block | Gap | Radius | -| ---- | ---- | ----------- | -------------- | ------------- | --------- | ------------- | -| sm | 14px | `text-sm` | `px-3.5` | `py-1.5` | `gap-2` | `rounded-md` | -| md | 16px | `text-base` | `px-4` | `py-2` | `gap-2.5` | `rounded-lg` | -| lg | 20px | `text-xl` | `px-5` | `py-2.5` | `gap-3` | `rounded-xl` | -| xl | 24px | `text-2xl` | `px-6` | `py-3` | `gap-4` | `rounded-2xl` | - -## Container Anchor Base - -Container components (Card, Modal, Alert) ใช้ body text เป็น x: - -```text -Container padding = x × φ -Internal gap = x -Section gap = x / φ -``` diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3a48495..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-format.sh" - } - ] - } - ] - } -} diff --git a/.commitlintrc.json b/.commitlintrc.json index ec6853e..b46d75e 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -7,7 +7,7 @@ "scope-empty": [2, "never"], "scope-case": [2, "always", "lower-case"], "subject-empty": [2, "never"], - "subject-case": [2, "always", "sentence-case"], + "subject-case": [2, "always", "lower-case"], "subject-max-length": [2, "always", 50], "type-enum": [ 2, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1587241..34b7a88 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1 @@ -# CODEOWNERS - * @joetakara - -/packages/react/ @joetakara -/packages/styles/ @joetakara -/packages/quality/ @joetakara -/packages/vitest/ @joetakara - -/apps/docs/ @joetakara -/apps/storybook/ @joetakara diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d91132d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,84 @@ + + +Closes # + + + +## Summary + + + +## Test plan + + + +- + +## Visual changes + + + +## Is this a breaking change? + + + +## Checklist + +- [ ] Changeset added (`pnpm changeset`) + +- [ ] Storybook stories cover the new prop, variant, or state surface + +- [ ] Storybook a11y panel shows zero violations on new and changed stories + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..b4c1c76 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +The latest stable release of `@fried-ui/react` and `@fried-ui/styles` receives security updates. Earlier major versions receive fixes only when a CVE is filed against them. + +## Reporting a Vulnerability + +Report security issues privately via a GitHub [security advisory](https://github.com/fried-day/fried-ui/security/advisories/new). + +Please do not file public issues for security-sensitive bugs. We aim to acknowledge new reports within 48 hours and to ship a coordinated fix in the next release window. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 219263d..07d3c4e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -5,14 +5,14 @@ runs: using: composite steps: - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b307475762933b98ed359c036b0e51f26b63b74b with: run_install: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: 22 + node-version: 24 cache: pnpm registry-url: https://registry.npmjs.org @@ -21,14 +21,14 @@ runs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm- - name: Cache turbo - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/labeler.yml b/.github/labeler.yml index 7cf13df..463966b 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,13 +6,17 @@ packages/styles: - changed-files: - any-glob-to-any-file: "packages/styles/**" -packages/quality: +packages/eslint-config: - changed-files: - - any-glob-to-any-file: "packages/quality/**" + - any-glob-to-any-file: "packages/eslint-config/**" -packages/vitest: +packages/tsconfig: - changed-files: - - any-glob-to-any-file: "packages/vitest/**" + - any-glob-to-any-file: "packages/tsconfig/**" + +packages/assets: + - changed-files: + - any-glob-to-any-file: "packages/assets/**" apps/docs: - changed-files: @@ -35,3 +39,9 @@ config: - ".prettierrc*" - ".eslint*" - "tsconfig*" + +dependencies: + - changed-files: + - any-glob-to-any-file: + - "**/package.json" + - "pnpm-lock.yaml" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index a17be5c..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,26 +0,0 @@ -# Description - -## Type - -- [ ] `feat` — New feature -- [ ] `fix` — Bug fix -- [ ] `refactor` — Code change (no new feature, no bug fix) -- [ ] `docs` — Documentation -- [ ] `style` — Formatting, lint fixes -- [ ] `test` — Adding or updating tests -- [ ] `chore` — Maintenance -- [ ] `setup` — Project setup or configuration - -## Changes - -- - -## Checklist - -- [ ] Code follows project conventions -- [ ] `pnpm test` passes -- [ ] `pnpm lint` passes -- [ ] `pnpm typecheck` passes -- [ ] `pnpm format` passes -- [ ] Documentation updated (if applicable) -- [ ] Storybook stories updated (if applicable) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 5da0f3c..275478e 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -12,22 +12,17 @@ permissions: contents: read jobs: - qa: - name: QA - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: pnpm lint - - run: pnpm typecheck - - run: pnpm test + ci: + name: CI + uses: ./.github/workflows/ci.yml + secrets: inherit canary: name: Canary Release runs-on: ubuntu-latest - needs: [qa] + needs: [ci] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: ./.github/actions/setup @@ -36,24 +31,12 @@ jobs: run: pnpm build - name: Set canary version - run: | - COMMIT_SHA=$(git rev-parse --short HEAD) - TIMESTAMP=$(date +%Y%m%d%H%M%S) - node -e " - const fs = require('fs'); - for (const pkg of ['packages/react', 'packages/styles']) { - const path = pkg + '/package.json'; - const json = JSON.parse(fs.readFileSync(path, 'utf8')); - json.version = json.version.replace(/^(\d+\.\d+\.\d+).*/, '\$1-canary.${TIMESTAMP}.${COMMIT_SHA}'); - fs.writeFileSync(path, JSON.stringify(json, null, 2) + '\n'); - console.log(json.name + '@' + json.version); - } - " + env: + CANARY_SUFFIX: canary.${{ github.run_id }}.${{ github.sha }} + run: pnpm canary:version - name: Publish canary - run: | - cd packages/styles && pnpm publish --tag canary --no-git-checks --access public - cd ../react && pnpm publish --tag canary --no-git-checks --access public + run: pnpm canary:publish env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/changeset-version-validation.yml b/.github/workflows/changeset-version-validation.yml new file mode 100644 index 0000000..d9e0d9b --- /dev/null +++ b/.github/workflows/changeset-version-validation.yml @@ -0,0 +1,70 @@ +name: Changeset Version Validation + +on: + pull_request: + branches: [main] + +concurrency: + group: changeset-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate-changeset: + name: Validate Changeset + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + ${{ + github.event.pull_request.draft == false + && !startsWith(github.event.pull_request.title, 'chore(release):') + && !startsWith(github.head_ref, 'changeset-release/') + }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + + - name: Detect changes + id: changes + run: | + BASE_SHA=${{ github.event.pull_request.base.sha }} + HEAD_SHA=${{ github.event.pull_request.head.sha }} + CHANGED_FILES=$(git diff --name-only "$BASE_SHA".."$HEAD_SHA") + CODE_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^packages/(react|styles)/src/' || true) + PKG_VERSION_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^packages/(react|styles)/package\.json$' || true) + CHANGELOG_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^packages/(react|styles)/CHANGELOG\.md$' || true) + CHANGESET_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^\.changeset/.*\.md$' | grep -v '^\.changeset/README\.md$' || true) + + VERSION_FIELD_CHANGED=false + if [ -n "$PKG_VERSION_CHANGED" ]; then + for f in $PKG_VERSION_CHANGED; do + if git diff "$BASE_SHA".."$HEAD_SHA" -- "$f" | grep -E '^[+-][[:space:]]*"version":' > /dev/null; then + VERSION_FIELD_CHANGED=true + fi + done + fi + + if [ "$VERSION_FIELD_CHANGED" = "true" ] || [ -n "$CHANGELOG_CHANGED" ]; then + VERSION_CHANGES=true + else + VERSION_CHANGES=false + fi + + echo "code=$([ -n "$CODE_CHANGES" ] && echo true || echo false)" >> $GITHUB_OUTPUT + echo "version=$VERSION_CHANGES" >> $GITHUB_OUTPUT + echo "changeset=$([ -n "$CHANGESET_CHANGES" ] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Block manual version changes + if: ${{ steps.changes.outputs.version == 'true' }} + run: | + echo "Manual version or CHANGELOG changes are not allowed. Run: pnpm changeset" + exit 1 + + - name: Require changeset for code changes + if: ${{ steps.changes.outputs.code == 'true' && steps.changes.outputs.changeset == 'false' }} + run: | + echo "Code changes in packages/react or packages/styles require a changeset. Run: pnpm changeset" + exit 1 diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000..ae7b688 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,33 @@ +name: Chromatic + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: chromatic-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + chromatic: + name: Chromatic + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + - uses: ./.github/actions/setup + - run: pnpm build:storybook + - uses: chromaui/action@a01e849cbc1f51ec5743358b4d7f9a1eadadf44e + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + workingDir: apps/storybook + storybookBuildDir: dist + exitOnceUploaded: true + autoAcceptChanges: main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d736c..780033d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,7 @@ name: CI on: pull_request: branches: [main] - push: - branches: [main] + workflow_call: concurrency: group: ci-${{ github.ref }} @@ -12,67 +11,100 @@ concurrency: permissions: contents: read - pull-requests: write jobs: - audit: - name: Audit + lint: + name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: ./.github/actions/setup - - run: pnpm audit --audit-level moderate + - run: pnpm lint - lint: - name: Lint + sort: + name: Sort runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: ./.github/actions/setup - - run: pnpm lint + - run: pnpm sort typecheck: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: ./.github/actions/setup - run: pnpm typecheck - test: - name: Test + sonar: + name: Sonar runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 - uses: ./.github/actions/setup - - run: pnpm test + - name: Install Playwright Chromium + run: pnpm --filter=storybook playwright:install + - run: pnpm test:coverage + - uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - format: - name: Format + size: + name: Bundle Size + if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: ./.github/actions/setup - - run: pnpm format + - uses: preactjs/compressed-size-action@66325aad6443cb7cf89c4bfcd414aea2367cda94 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + build-script: build:react + pattern: "packages/react/dist/**/*.mjs" + exclude: "**/*.d.ts" build: name: Build runs-on: ubuntu-latest - needs: [audit, lint, typecheck, test, format] + needs: [lint, sort, typecheck, sonar] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: ./.github/actions/setup - run: pnpm build + - name: Verify build output + working-directory: packages/react + run: | + test -f dist/index.mjs + test -f dist/index.d.ts + node --input-type=module -e "import('./dist/index.mjs').then(m => { if (Object.keys(m).length === 0) { console.error('empty exports'); process.exit(1); } console.log('exports:', Object.keys(m).length); })" + + - name: Verify react pack + working-directory: packages/react + run: pnpm pack --pack-destination /tmp + + - name: Verify styles pack + working-directory: packages/styles + run: pnpm pack --pack-destination /tmp + preview-release: name: Preview Release if: github.event_name == 'pull_request' runs-on: ubuntu-latest needs: [build] + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: ./.github/actions/setup - run: pnpm build - - run: pnpx pkg-pr-new publish --pnpm './packages/react' './packages/styles' + - run: pnpm release:preview diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..45412a2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "0 0 * * 1" + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Initialize CodeQL + uses: github/codeql-action/init@65216971a11ded447a6b76263d5a144519e5eee1 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@65216971a11ded447a6b76263d5a144519e5eee1 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml deleted file mode 100644 index ef39404..0000000 --- a/.github/workflows/label.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Label - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -jobs: - label: - name: Auto-label PR - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v5 - with: - configuration-path: .github/labeler.yml diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml new file mode 100644 index 0000000..e5906e3 --- /dev/null +++ b/.github/workflows/pr-validate.yml @@ -0,0 +1,54 @@ +name: PR Validate + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + title: + name: Validate Pull Request Title + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.draft == false }} + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + setup + style + test + requireScope: true + headerPattern: '^([a-z]+)\(([^A-Z]+)\): (.*)' + headerPatternCorrespondence: type, scope, subject + subjectPattern: "^[^A-Z]{1,50}$" + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject: + - is entirely lowercase + - is not empty + - is maximum 50 characters long + ignoreLabels: | + bot + ignore-semantic-pull-request + + label: + name: Auto-label PR + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 + with: + configuration-path: .github/labeler.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cee47a..84e406c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: name: Release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: ./.github/actions/setup @@ -26,12 +26,12 @@ jobs: run: pnpm build - name: Create Release PR or Publish - uses: changesets/action@v1 + uses: changesets/action@e87c8ed249971350e47fab7515075f44eb134e5b with: version: pnpm changeset:version publish: pnpm changeset:release - commit: "chore(release): Version packages" - title: "chore(release): Version packages" + commit: "chore(release): version packages" + title: "chore(release): version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..98e91b7 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,26 @@ +name: Security Audit + +on: + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +concurrency: + group: security-audit-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + audit: + name: pnpm audit + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: ./.github/actions/setup + - run: pnpm audit:deps diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml deleted file mode 100644 index 7b3a7bf..0000000 --- a/.github/workflows/size.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Size - -on: - pull_request: - branches: [main] - paths: - - "packages/react/src/**" - - "packages/styles/src/**" - -permissions: - pull-requests: write - -jobs: - size: - name: Bundle Size - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - - uses: preactjs/compressed-size-action@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - build-script: build:react - pattern: "packages/react/dist/**/*.mjs" - exclude: "**/*.d.ts" diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml deleted file mode 100644 index d71e8d0..0000000 --- a/.github/workflows/sonar.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: SonarCloud - -on: - pull_request: - branches: [main] - push: - branches: [main] - -permissions: - contents: read - pull-requests: read - -jobs: - sonar: - name: SonarCloud - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: ./.github/actions/setup - - - name: Run tests with coverage - run: pnpm --filter=@fried-ui/react test - - - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f9136ef..6160992 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: name: Close stale issues and PRs runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f with: stale-issue-message: > This issue has been automatically marked as stale because it has not had diff --git a/.gitignore b/.gitignore index 50ba887..e806419 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,10 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Sample / scratch -/sample - # Dependencies node_modules -.pnp -.pnp.js # Local env files .env -.env.local -.env.development.local -.env.test.local -.env.production.local *.local # Testing @@ -41,13 +32,18 @@ tsup.config.bundled_* .eslintcache # Debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* pnpm-debug.log* +build-storybook.log -# MCP config (each dev manages their own) +# AI +.agents/ .mcp.json +skills-lock.json + +# Claude +.claude/ +CLAUDE.md +.claude/settings.local.json # Misc .DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/.husky/pre-push b/.husky/pre-push old mode 100644 new mode 100755 index c4e79a1..7ec3662 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,3 @@ -pnpm audit --audit-level moderate -pnpm lint pnpm typecheck pnpm test pnpm build diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..f3539f4 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "*.{ts,tsx,js,jsx,mjs}": ["prettier --write", "eslint --fix --max-warnings 0 --no-warn-ignored"], + "*.css": ["prettier --write", "stylelint --fix --max-warnings 0"], + "*.{md,mdx,json,jsonc,yml,yaml}": ["prettier --write"], + "package.json": ["sort-package-json", "prettier --write"] +} diff --git a/.mcp.json.example b/.mcp.json.example deleted file mode 100644 index 895bd2a..0000000 --- a/.mcp.json.example +++ /dev/null @@ -1,23 +0,0 @@ -{ - "mcpServers": { - "package-registry": { - "command": "npx", - "args": ["-y", "package-registry-mcp"] - }, - "chrome-devtools": { - "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"] - }, - "eslint": { - "command": "npx", - "args": ["@eslint/mcp@latest"] - }, - "context7": { - "type": "http", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": "${CONTEXT7_API_KEY}" - } - } - } -} diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/.prettierignore b/.prettierignore index 1620a0e..146bea9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,4 @@ node_modules -sample .next dist build diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a8cb2ca --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://www.schemastore.org/prettierrc.json", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "packages/styles/tailwind.css", + "tailwindAttributes": ["className"], + "tailwindFunctions": ["clsx", "classes"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4837385..02d8bed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,19 +4,25 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "files.associations": { "*.css": "tailwindcss" }, - "editor.quickSuggestions": { "strings": "on" }, "css.lint.unknownAtRules": "ignore", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "tailwindCSS.experimental.classRegex": [["tv\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]], + "editor.quickSuggestions": { "strings": "on" }, + "files.associations": { "*.css": "tailwindcss" }, + "tailwindCSS.experimental.configFile": "packages/styles/tailwind.css", + "tailwindCSS.includeLanguages": { "css": "css" }, + "tailwindCSS.experimental.classRegex": [["[\"'`]([^\"'`]*).*?[\"'`]"]], "json.schemas": [ { "fileMatch": ["turbo.json"], "url": "https://turborepo.dev/schema.json" } ], + "yaml.schemas": { + "https://www.schemastore.org/github-action.json": [".github/actions/*/action.yml"], + "https://www.schemastore.org/github-workflow.json": [".github/workflows/*.yml"] + }, "sonarlint.connectedMode.project": { "connectionId": "fried-day", "projectKey": "fried_day_fried-ui" diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e396114..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,119 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Prerequisites - -- **Node 22** (see `.nvmrc`) — pnpm only (enforced via `preinstall` script) - -## Build & Dev Commands - -```bash -pnpm install # Install all dependencies -pnpm -w run build # Build all apps and packages (turbo) -pnpm -w run dev # Run all apps in dev mode (turbo) -pnpm -w run lint # Lint all apps and packages (turbo) -pnpm -w run test # Run all tests (turbo) -pnpm -w run check-types # Type-check all (turbo) -pnpm -w run format # Format with Prettier - -# Filter to a single app/package -pnpm --filter=docs dev -pnpm --filter=@fried-ui/react test -pnpm --filter=storybook dev # Storybook on port 6006 - -# Run a single test file -cd packages/react && npx vitest run src/components/button/button.test.tsx -``` - -## Architecture - -**pnpm + Turborepo monorepo** — Tailwind CSS v4, React Aria Components, React 19, TypeScript 5.9. - -### Workspace Layout - -- **`apps/docs`** (port 3001) — Next.js 16 + Fumadocs, MDX documentation site -- **`apps/storybook`** (port 6006) — Storybook 10 with Vite, reads stories from `packages/react` -- **`packages/react`** (`@fried-ui/react`) — Component library (React Aria + Tailwind v4), built with tsup -- **`packages/styles`** (`@fried-ui/styles`) — Pure CSS: design tokens, `@utility`, component BEM styles (`fri-` prefix) -- **`packages/quality`** (`@repo/quality`) — ESLint configs (`eslint/base`, `eslint/next-js`, `eslint/react-internal`) + shared tsconfigs (`tsconfig/base`, `tsconfig/nextjs`, `tsconfig/react-library`) -- **`packages/vitest`** (`@fried-ui/vitest`) — Shared Vitest configs and setup (base + react presets) - -### Component Structure - -Each component lives in its own directory under `packages/react/src/components/`. Component files are **PascalCase**, test/story files are **lowercase**: - -```text -packages/react/src/ - components/ - button/ - Button.tsx # Component implementation - button.test.tsx # Vitest + React Testing Library tests - button.stories.tsx # Storybook stories - index.ts # Re-exports - utils/ - cn/ - cn.ts # Tailwind class merge utility (clsx + tailwind-merge) - index.ts # Re-exports - index.ts # Barrel export -``` - -Test files use `component.test.tsx` naming. Story files use `component.stories.tsx` naming. - -**Exports** are explicit in `packages/react/package.json` with types + import subpaths: - -```ts -import { Button } from "@fried-ui/react/button"; -import { cn } from "@fried-ui/react/utils/cn"; -``` - -When adding a new component, add its export entry to `packages/react/package.json` AND its tsup entry in `tsup.config.ts`. - -### Styling - -- **Tailwind CSS v4** — CSS-first config, no `tailwind.config.js` -- Apps use `@tailwindcss/postcss`, Storybook uses `@tailwindcss/vite` -- Each app's `globals.css` has `@source "../../packages/react/src/**/*.{ts,tsx}"` to scan component classes -- `@fried-ui/styles` is **pure CSS** — no JS build, no tailwind-variants. Apps import via `@import "@fried-ui/styles"` -- Component styles use **CSS + BEM (`fri-` prefix) + @apply** — for multi-framework support -- Shared patterns use **`@utility`** (focus-ring, status-disabled, etc.) -- Use `cn()` from `@fried-ui/react/utils/cn` to merge BEM classes in components - -### Documentation (Fumadocs) - -- Content lives in `apps/docs/src/content/docs/` as MDX files -- Navigation defined in `apps/docs/src/content/docs/meta.json` -- Source config: `apps/docs/source.config.ts` -- Loader: `apps/docs/src/lib/source.ts` uses `docs.toFumadocsSource()` -- `.source/` is auto-generated by fumadocs-mdx — excluded from lint - -### Testing - -- **Vitest** + **jsdom** + **React Testing Library** in `packages/react` -- Shared config: `@fried-ui/vitest` package provides base and react presets -- `@testing-library/jest-dom` matchers available via setup -- Coverage: Istanbul reporter outputs to `coverage.json` - -### Key Patterns - -- **Client components**: Add `"use client"` directive for interactive components -- **TypeScript config chain**: `@repo/quality/tsconfig/base.json` → `react-library.json` (UI lib) or `nextjs.json` (apps) -- **ESLint**: Apps use `@repo/quality/eslint/next-js` config, UI package uses `@repo/quality/eslint/react-internal` config. Configs use factory functions (`createConfig`, `createReactConfig`, `createNextJsConfig`) -- **No `eslint-disable`**: Never use `eslint-disable` comments or turn off rules to suppress warnings. Always fix the source code to satisfy the rule -- **No `@ts-nocheck`**: Never use `// @ts-nocheck`, `// @ts-ignore`, or `// @ts-expect-error`. Always fix the actual type error -- **Workspace deps**: `workspace:*` protocol -- **Turbo tasks**: `build`, `lint`, `check-types`, `test` have `dependsOn: ["^"]`. `dev` is persistent/uncached. -- **Build**: `@fried-ui/react` uses tsup (ESM-only, .mjs output, external react/react-dom/tailwindcss) - -### Git Conventions - -- **Conventional commits** enforced by commitlint: `type(scope): Subject` (sentence-case, max 50 chars) -- Valid types: `build|chore|ci|docs|feat|fix|perf|refactor|revert|setup|style|test` -- Scope is required -- **Husky hooks**: pre-commit runs lint-staged (prettier + eslint --fix), commit-msg validates format, pre-push runs lint + typecheck - -### Changesets - -- Run `pnpm changeset` to create a changeset before submitting PRs that affect published packages -- `@fried-ui/react` and `@fried-ui/styles` are **fixed versioning** — they release together -- `docs` and `storybook` apps are ignored by changesets diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5da2db8..90954d3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -22,7 +22,7 @@ Examples of unacceptable behavior: ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting the maintainers. All complaints will be reviewed and investigated. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainer at . All complaints will be reviewed and investigated. ## Attribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 835bf95..dd94ba0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,40 +1,76 @@ -# Contributing to fried-ui +# Contributing to Fried UI -Thanks for your interest in contributing! +Thanks for your interest in contributing to Fried UI! This guide will help you get started. -## Getting Started +## Table of Contents + +- [Tooling](#tooling) +- [Prerequisites](#prerequisites) +- [Development Setup](#development-setup) +- [Commit Convention](#commit-convention) +- [Steps to PR](#steps-to-pr) +- [Pull Request Guidelines](#pull-request-guidelines) +- [Code Style](#code-style) +- [Testing](#testing) +- [Visual Changes](#visual-changes) +- [Documentation](#documentation) +- [Adding a New Component](#adding-a-new-component) +- [Breaking Changes](#breaking-changes) +- [Becoming a Maintainer](#becoming-a-maintainer) +- [License](#license) + +## Tooling + +- [PNPM](https://pnpm.io/) — Package manager (enforced, no npm/yarn) +- [Turborepo](https://turbo.build/) — Monorepo build system +- [Tsup](https://tsup.egoist.dev/) — TypeScript bundler +- [Storybook](https://storybook.js.org/) — Component development and documentation +- [Vitest](https://vitest.dev/) — Unit testing +- [Testing Library](https://testing-library.com/) — Component testing utilities +- [Changesets](https://github.com/changesets/changesets) — Version management + +## Prerequisites + +- Node.js 22+ +- PNPM 9+ + +## Development Setup + +1. Fork and clone the repository ```bash -git clone https://github.com/fried-day/fried-ui.git +git clone https://github.com//fried-ui.git cd fried-ui pnpm install -pnpm dev ``` -## Development +2. Start development -| Command | Description | -| -------------------- | ------------------------------------- | -| `pnpm dev` | Start all apps + packages in dev mode | -| `pnpm build` | Build all packages | -| `pnpm test` | Run tests | -| `pnpm lint` | Lint | -| `pnpm typecheck` | Type check | -| `pnpm format` | Format check | -| `pnpm dev:storybook` | Storybook on port 6006 | -| `pnpm dev:docs` | Docs on port 3001 | +```bash +pnpm dev # All apps + packages +pnpm dev:storybook # Storybook on port 6006 +pnpm dev:docs # Docs on port 3001 +``` -## Pull Requests +3. Build and test -1. Fork the repo and create a branch from `main` -2. Make your changes -3. Add a changeset: `pnpm changeset` -4. Ensure all checks pass: `pnpm lint && pnpm typecheck && pnpm test && pnpm format` -5. Submit your PR +```bash +pnpm build # Build all +pnpm test # Run tests +pnpm lint # Lint all +pnpm typecheck # Type check all +pnpm format # Format check +``` ## Commit Convention -We use [conventional commits](https://www.conventionalcommits.org/). Format: `type(scope): Subject` +We follow [Conventional Commits](https://www.conventionalcommits.org/). Every commit must use this format: + +```txt +type(scope): Subject +``` + +**Types:** | Type | When to use | | ---------- | ---------------------------------------- | @@ -44,20 +80,139 @@ We use [conventional commits](https://www.conventionalcommits.org/). Format: `ty | `docs` | Documentation | | `style` | Formatting, lint fixes | | `test` | Adding or updating tests | +| `build` | Build system or dependencies | +| `ci` | CI configuration | | `chore` | Maintenance | +| `perf` | Performance improvement | +| `revert` | Revert a previous commit | | `setup` | Project setup or configuration | -## Code Quality +**Scope is required.** Subject must be sentence-case, max 50 characters. + +Examples: + +```txt +feat(chip): Add soft variants +fix(button): Fix focus ring on Safari +docs(styles): Update color token docs +``` + +## Steps to PR + +1. Create a branch from `main`: + +```bash +git checkout -b feat/my-feature +``` + +2. Make your changes and commit following the convention above + +3. Add a changeset if your change affects `@fried-ui/react` or `@fried-ui/styles`: + +```bash +pnpm changeset +``` + +4. Ensure all checks pass: + +```bash +pnpm lint && pnpm typecheck && pnpm test && pnpm format +``` + +5. Push and open a PR against `main` + +## Pull Request Guidelines + +- PRs should be focused — one feature or fix per PR +- Include a clear description of what changed and why +- All tests must pass +- All linting must pass +- All types must check +- Add tests for new features or bug fixes + +## Code Style + +- **No `eslint-disable`** — fix the source code +- **No `@ts-nocheck` / `@ts-ignore` / `@ts-expect-error`** — fix the type error +- **No arbitrary Tailwind values** — use built-in classes only +- **All spacing in rem** — never px (except 1px borders) +- **clsx only** — no tailwind-merge, no tv(). Used internally by `classes()` and inside `composeRenderProps` for render-prop components (e.g. Button). Don't import `clsx` in display components — fold `className` into `classes()` instead. +- **class naming** — `.{component}--{modifier}` +- **`classes()` signature** — `classes({ block, modifiers, className })`. Pass `className` as a field of the config object; **never** wrap with an outer `clsx(classes(...), className)`. + + ```tsx + // Display / non-render-prop + const cn = classes({ block: "avatar", modifiers: { size, radius }, className }); + + // Interactive with render-prop className (Button) + const base = classes({ block: "button", modifiers: { variant, size } }); + const cn = composeRenderProps(className, (c) => clsx(base, c)); + ``` + +- **Field subcomponents use `useFieldState(props)`** — `Label`, `Description`, `FieldError` (and any future TextField child) consume `TextFieldContext` + prop-override through the shared hook. Don't call `useContext(TextFieldContext)` directly in the subcomponent. +- **Disabled styling uses `@apply status-disabled`** — never raw `@apply pointer-events-none opacity-50`. The utility bundles `pointer-events-none cursor-(--cursor-disabled) opacity-(--disabled-opacity)` for consistent cursor feedback across the library. +- Run `pnpm format:fix` before committing + +## Testing + +Every component must have tests covering: + +- Renders with default props (correct HTML element, base class only) +- Every variant value +- Every size value +- Every boolean prop +- className merge +- Ref forwarding +- data-slot attribute +- displayName +- Native HTML attributes passthrough + +Interactive components additionally test: onPress, disabled state, render props, className as function. + +## Visual Changes + +When making visual changes, include a screenshot or screen recording in your PR. + +## Documentation + +Keep documentation in sync with code changes. Docs live in `apps/docs/src/content/docs/` using MDX format. + +## Documentation Style + +All `.md`, JSDoc, and inline comments must use words instead of transition symbols (`->`, `<-`, `=>`, emoji). Math formulas and code fences may keep symbols. See [.claude/rules/writing.md](.claude/rules/writing.md) for the full rule. + +## Adding a New Component + +New components follow the scaffold-then-customize workflow: + +1. Scaffold with turbo gen: + +```bash +pnpm turbo gen display-component # For Chip, Surface, Divider, etc. +pnpm turbo gen interactive-component # For Button, Link, Switch, etc. +``` + +2. Customize the 4 generated files: + +```txt +packages/styles/src/components/{name}.css +packages/react/src/components/{name}/{Name}.tsx +packages/react/src/components/{name}/{name}.stories.tsx +packages/react/src/components/{name}/index.ts +``` + +3. Register exports (alphabetical order): + - `packages/react/tsup.config.ts` — add entry + - `packages/react/package.json` — add to exports map + +## Breaking Changes + +Deprecate before removing. Add a deprecation notice in the docs and changelog at least one minor version before removal. + +## Becoming a Maintainer -- No `eslint-disable` comments. Fix the code. -- No `@ts-nocheck` / `@ts-ignore`. Fix the types. -- All components need tests and Storybook stories. -- Run `pnpm format:fix` before committing. +Start by helping with issues, reviewing PRs, and contributing code. Active contributors will be invited to the team. -## Adding a Component +## License -1. Create directory: `packages/react/src/components//` -2. Add files: `.tsx`, `Test.tsx`, `Stories.tsx`, `index.ts` -3. Add export in `packages/react/package.json` -4. Add tsup entry in `packages/react/tsup.config.ts` -5. Add documentation in `apps/docs/content/docs/components/.mdx` +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/README.md b/README.md index 009f84c..2eb8501 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,28 @@ -# fried-ui +
-Beautiful, accessible React components for building modern web apps at scale. +# Fried UI -## Packages +**Same component in React. Same class in HTML. One source of truth.** -| Package | Description | -| ------------------------------------- | -------------------------------------------------- | -| [`@fried-ui/react`](packages/react) | React component library | -| [`@fried-ui/styles`](packages/styles) | Theme engine, design tokens, and component presets | +Compound primitives, dual selectors, dark mode, and zero runtime. -## Quick Start +[![License](https://img.shields.io/github/license/fried-day/fried-ui)](LICENSE) +[![Checks](https://img.shields.io/github/checks-status/fried-day/fried-ui/main?label=checks)](https://github.com/fried-day/fried-ui/actions) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/quality_gate?project=fried-day_fried-ui)](https://sonarcloud.io/summary/new_code?id=fried-day_fried-ui) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=fried-day_fried-ui&metric=coverage)](https://sonarcloud.io/summary/new_code?id=fried-day_fried-ui) +[![Chromatic](https://img.shields.io/badge/Chromatic-visual%20regression-ff4785?logo=chromatic&logoColor=white)](https://www.chromatic.com/library?appId=69e6968ee69bc24a839f0d65) -```bash -pnpm add @fried-ui/react @fried-ui/styles -``` +
-```css -@import "tailwindcss"; -@import "@fried-ui/styles"; -``` +## Documentation -```tsx -import { Button } from "@fried-ui/react/button"; - -; -``` - -## Development - -```bash -pnpm install # Install dependencies -pnpm dev # Dev mode (all apps + packages) -pnpm build # Build all -pnpm test # Run tests -pnpm lint # Lint -pnpm typecheck # Type check -pnpm format # Format check -pnpm dev:storybook # Storybook on port 6006 -pnpm dev:docs # Docs on port 3001 -``` +- [Documentation](https://fried-ui.vercel.app) +- [Storybook](https://fried-ui-storybook.vercel.app) ## Contributing This project uses [conventional commits](https://www.conventionalcommits.org/) and [changesets](https://github.com/changesets/changesets) for versioning. -```bash -pnpm changeset # Add a changeset before submitting a PR -``` - ## License -[MIT](LICENSE) +Licensed under the MIT License, Copyright © 2026-present fried-ui contributors. See [LICENSE](LICENSE) for details. diff --git a/apps/docs/README.md b/apps/docs/README.md index 15e804a..b31b659 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -1,6 +1,6 @@ -# fried-ui docs +# Fried UI Docs -Documentation site for fried-ui, built with [Next.js 16](https://nextjs.org) and [Fumadocs](https://fumadocs.dev). +Documentation site for Fried UI, built with [Next.js 16](https://nextjs.org) and [Fumadocs](https://fumadocs.dev). ## Development diff --git a/apps/docs/eslint.config.ts b/apps/docs/eslint.config.ts index b407889..0e7d27d 100644 --- a/apps/docs/eslint.config.ts +++ b/apps/docs/eslint.config.ts @@ -1,4 +1,4 @@ -import { createNextJsConfig } from "@repo/quality/eslint/next-js"; +import { createNextJsConfig } from "@repo/eslint-config/next-js"; export default [ ...createNextJsConfig(import.meta.dirname), diff --git a/apps/docs/package.json b/apps/docs/package.json index 71d903d..8cc06ab 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,14 +1,20 @@ { "name": "docs", "version": "0.1.0", - "type": "module", "private": true, + "type": "module", "scripts": { - "dev": "next dev --port 3001", "build": "next build", - "start": "next start", + "clean": "rm -rf .next .turbo .source", + "dev": "next dev --port 3001", "lint": "eslint --max-warnings 0", - "check-types": "next typegen && tsc --noEmit" + "lint:fix": "eslint --fix --max-warnings 0", + "sort": "sort-package-json --check", + "sort:fix": "sort-package-json", + "start": "next start", + "typecheck": "pnpm typecheck:gen && pnpm typecheck:check", + "typecheck:check": "tsc --noEmit", + "typecheck:gen": "next typegen" }, "dependencies": { "@fried-ui/react": "workspace:*", @@ -17,18 +23,20 @@ "fumadocs-core": "^16.7.5", "fumadocs-mdx": "^14.2.11", "fumadocs-ui": "^16.7.5", - "next": "16.2.0", + "next": "^16.2.3", "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^4.3.6" }, "devDependencies": { - "@repo/quality": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@repo/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.2.2", "@types/node": "^22.15.3", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", - "eslint": "^9.39.1", + "eslint": "^10.2.1", + "sort-package-json": "^3.6.1", "tailwindcss": "^4.2.2", "typescript": "5.9.2" } diff --git a/apps/docs/public/file-text.svg b/apps/docs/public/file-text.svg deleted file mode 100644 index 9cfb3c9..0000000 --- a/apps/docs/public/file-text.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/docs/public/globe.svg b/apps/docs/public/globe.svg deleted file mode 100644 index 4230a3d..0000000 --- a/apps/docs/public/globe.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/docs/public/llm/button.md b/apps/docs/public/llm/button.md new file mode 100644 index 0000000..e6bafa7 --- /dev/null +++ b/apps/docs/public/llm/button.md @@ -0,0 +1,355 @@ +# Button + +Accessible button component. Built on React Aria Button. + +## Install + +```bash +pnpm add @fried-ui/react +``` + +## Import + +```tsx +import { Button } from "@fried-ui/react"; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ------------------------------------------------------------ | ----------- | --------------------------------------- | +| `variant` | `ButtonVariant` (see Variants) | `"primary"` | Visual variant (40 options) | +| `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | Size — controls padding, gap, font-size | +| `radius` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Border radius (independent from size) | +| `isIconOnly` | `boolean` | `false` | Icon-only square button | +| `isFullWidth` | `boolean` | `false` | Full width button | +| `isDisabled` | `boolean` | `false` | Disabled state | +| `isPending` | `boolean` | `false` | Loading spinner, disables interaction | +| `onPress` | `(e: PressEvent) => void` | - | Press handler (preferred over onClick) | +| `onPressStart` | `(e: PressEvent) => void` | - | Press start handler | +| `onPressEnd` | `(e: PressEvent) => void` | - | Press end handler | +| `onPressChange` | `(isPressed: boolean) => void` | - | Press state change handler | +| `onHoverStart` | `(e: HoverEvent) => void` | - | Hover start handler | +| `onHoverEnd` | `(e: HoverEvent) => void` | - | Hover end handler | +| `onHoverChange` | `(isHovering: boolean) => void` | - | Hover state change handler | +| `onFocus` | `(e: FocusEvent) => void` | - | Focus handler | +| `onBlur` | `(e: FocusEvent) => void` | - | Blur handler | +| `onFocusChange` | `(isFocused: boolean) => void` | - | Focus state change handler | +| `onKeyDown` | `(e: KeyboardEvent) => void` | - | Key down handler | +| `onKeyUp` | `(e: KeyboardEvent) => void` | - | Key up handler | +| `type` | `"button" \| "submit" \| "reset"` | `"button"` | HTML button type | +| `form` | `string` | - | Associated form ID | +| `formAction` | `string` | - | Form submission URL | +| `autoFocus` | `boolean` | `false` | Auto focus on mount | +| `aria-label` | `string` | - | Accessible label | +| `aria-labelledby` | `string` | - | ID of labelling element | +| `aria-describedby` | `string` | - | ID of describing element | +| `className` | `string` | - | Additional CSS classes | +| `ref` | `Ref` | - | Forwarded ref to button element | +| `children` | `ReactNode \| (renderProps: ButtonRenderProps) => ReactNode` | - | Button content | + +## Inherited from React Aria Button + +Additional props forwarded to the underlying React Aria `Button`. Use these when building composite patterns (menu triggers, toggles, form buttons). React DOM events (`onClick`, `onMouseMove`, etc.) are also inherited but omitted here — prefer React Aria handlers like `onPress`. + +| Prop | Type | Default | Description | +| --------------------- | ---------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------- | +| `id` | `string` | - | Unique identifier | +| `name` | `string` | - | Form submission key (paired with `value`) | +| `value` | `string` | - | Form submission value for this button | +| `slot` | `string \| null` | - | React Aria slot name (receive props from parent component) | +| `excludeFromTabOrder` | `boolean` | `false` | Remove from sequential tab order (use only with alternative keyboard access) | +| `preventFocusOnPress` | `boolean` | `false` | Don't move focus on press (ComboBox triggers, NumberField steppers) | +| `onPressUp` | `(e: PressEvent) => void` | - | Called on press release regardless of start target (unlike `onPress`) | +| `formEncType` | `string` | - | Encoding for form data | +| `formMethod` | `string` | - | HTTP method for form submission | +| `formNoValidate` | `boolean` | - | Skip form validation on submit | +| `formTarget` | `string` | - | Override form target attribute | +| `aria-expanded` | `boolean \| "true" \| "false"` | - | Expanded state (menu triggers, accordions) | +| `aria-haspopup` | `boolean \| "menu" \| "listbox" \| "tree" \| "grid" \| "dialog"` | - | Type of popup the button controls | +| `aria-controls` | `string` | - | ID of the element this button controls | +| `aria-pressed` | `boolean \| "true" \| "false" \| "mixed"` | - | Pressed state for toggle buttons | +| `aria-current` | `boolean \| "page" \| "step" \| "location" \| "date" \| "time"` | - | Represents the current item in a set | +| `aria-details` | `string` | - | ID of element providing detailed description | +| `render` | `DOMRenderFunction<"button", ButtonRenderProps>` | - | Override default DOM element (must render ` {/* dark neutral, main action */} + {/* light neutral, less emphasis */} + {/* purple, brand highlight */} + {/* transparent, dismissive */} + {/* neutral bordered */} + {/* frosted + border (on image) */} + {/* frosted no border */} + {/* dark scrim (on media) */} + {/* green status */} + {/* yellow status */} + {/* red destructive */} + {/* blue info */} +``` + +**Color-prefixed variants** — combine `{color}` with `-soft`, `-flat`, `-outline`, or `-ghost`: + +```tsx +{/* -soft: pale bg + visible border (status tags) */} + + + +{/* -flat: pale bg no border (minimal subtle) */} + + +{/* -outline: transparent + colored border (secondary CTA) */} + + + +{/* -ghost: transparent, reveal on hover (toolbar) */} + + +``` + +**Colors:** `primary` `secondary` `accent` `success` `warning` `danger` `info` +**Modifiers:** `-soft` `-flat` `-outline` `-ghost` + +**When to use:** + +- `primary` — main CTA (save, submit, confirm) +- `accent` — brand feature (upgrade, get started, marketing) +- `ghost` — dismissive (cancel, close, skip) +- `outline` — secondary action next to primary +- `danger` — destructive (delete, remove) +- `{color}-soft/flat` — status labels in content +- `{color}-outline` — secondary variants of colored actions +- `{color}-ghost` — toolbar/icon actions + +### Basic + +Default button with medium size and medium radius. + +```tsx + +``` + +### Sizes + +Golden ratio proportional scaling across all sizes. + +```tsx + + + + +``` + +### Radius + +Border radius independent from size. + +```tsx + + +``` + +### Icon Only + +Square button for icon-only actions. Always provide `aria-label`. + +```tsx + +``` + +### With Icon + +Icons use `size-match-font` to match the font size. Gap scales via golden ratio. + +```tsx + +``` + +### Full Width + +Stretches the button to fill its container width. + +```tsx + +``` + +### Disabled + +Prevents interaction and applies disabled styling. + +```tsx + +``` + +### Pending + +Text becomes transparent, spinner appears centered. All interaction is blocked. + +```tsx + +``` + +### As a Link + +Wrap with anchor or framework Link component. + +```tsx + + + +``` + +### Custom Class + +Use `className` for layout or typography utilities. + +```tsx + +``` + +### Form Submit + +Associate with a form element via `type` and `form` props. + +```tsx + +``` + +### Press Handler + +Use `onPress` instead of `onClick` — handles keyboard, touch, and mouse uniformly. + +```tsx + +``` + +### Render Props + +Access internal state to dynamically change content using the children function. + +```tsx + +``` + +Available render props: `isHovered`, `isPressed`, `isFocused`, `isFocusVisible`, `isDisabled`, `isPending`. + +### Ref Forwarding + +Supports ref forwarding for integration with Tooltip, Popover, Floating UI, etc. + +```tsx +const ref = useRef(null); +; +``` + +## Anatomy + +``` +┌─ Button ──────────────────────────┐ +│ [Icon] Label [Spinner overlay] │ +└───────────────────────────────────┘ +``` + +- `children`: Icon + label as direct children +- Spinner: Rendered internally when `isPending={true}`, absolutely positioned over content +- Icon sizing: Use `className="size-match-font"` on SVG icons +- Ref forwarding: Supports `ref` prop to the underlying ` + ); +}; + +export { CopyMarkdown }; diff --git a/apps/docs/src/content/docs/components/button.mdx b/apps/docs/src/content/docs/components/button.mdx deleted file mode 100644 index c8d49cc..0000000 --- a/apps/docs/src/content/docs/components/button.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Button -description: An accessible button component built on React Aria with focus management, keyboard navigation, and screen reader support. ---- - -## Import - -```tsx -import { Button } from "@fried-ui/react/button"; -``` - -### Usage - -```tsx - -``` - -### Disabled - -```tsx - -``` - -### Pending - -```tsx - -``` - -## API Reference - -| Prop | Type | Default | Description | -| ------------ | ------------------------- | ----------- | ---------------------- | -| `variant` | `"primary"` | `"primary"` | Visual style | -| `size` | `"md"` | `"md"` | Button size | -| `isDisabled` | `boolean` | `false` | Disables the button | -| `isPending` | `boolean` | `false` | Shows loading spinner | -| `onPress` | `(e: PressEvent) => void` | - | Press event handler | -| `className` | `string` | - | Additional CSS classes | -| `children` | `ReactNode` | - | Button content | diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 5adb2f7..998ab8c 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -1,27 +1,20 @@ --- -title: Getting Started -description: Beautiful, accessible React components for building modern web apps at scale. +title: Welcome to Fried UI +description: Same component in React. Same class in HTML. One source of truth. Compound primitives, dual selectors, dark mode, and zero runtime. --- -## Installation +## Build better interfaces, faster -```bash -pnpm add @fried-ui/react @fried-ui/styles -``` +Fried UI is a production-ready React component library with accessible primitives, golden ratio spacing, and a Framer-inspired dark theme. -Import styles in your CSS entry file: +## Get Started -```css -@import "tailwindcss"; -@import "@fried-ui/styles"; -``` +- [Introduction](/docs/overview/introduction) — What is Fried UI and why use it +- [Quick Start](/docs/overview/quick-start) — Install and render your first component +- [Design Tokens](/docs/overview/design-tokens) — Color, spacing, elevation, and motion tokens -## Usage +## Components -```tsx -import { Button } from "@fried-ui/react/button"; - -export default function App() { - return ; -} -``` +- [Chip](/docs/components/chip) — 20 variants: solid, soft, colored outline +- [Button](/docs/components/button) — 8 variants, icon-only, render props, ref forwarding +- [Surface](/docs/components/surface) — Visual layer with bordered, glass, primary variants diff --git a/apps/docs/src/content/docs/meta.json b/apps/docs/src/content/docs/meta.json index a788c9b..ae8575d 100644 --- a/apps/docs/src/content/docs/meta.json +++ b/apps/docs/src/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Documentation", - "pages": ["index", "---Components---", "components/button"] + "pages": ["---Overview---", "index", "overview/introduction", "overview/quick-start"] } diff --git a/apps/docs/src/content/docs/overview/introduction.mdx b/apps/docs/src/content/docs/overview/introduction.mdx new file mode 100644 index 0000000..bbfb4ff --- /dev/null +++ b/apps/docs/src/content/docs/overview/introduction.mdx @@ -0,0 +1,32 @@ +--- +title: Introduction +description: Same component in React. Same class in HTML. One source of truth. Compound primitives, dual selectors, dark mode, and zero runtime. +--- + +## What is Fried UI? + +Fried UI is a React component library built on top of [React Aria Components](https://react-spectrum.adobe.com/react-aria/) and [Tailwind CSS v4](https://tailwindcss.com/). It provides accessible, composable UI primitives with a design system powered by the golden ratio. + +## Key Features + +- **Accessible by default** — Built on React Aria, every component has keyboard navigation, focus management, and screen reader support out of the box +- **Golden ratio spacing** — All sizing uses φ (1.618) for mathematically harmonious proportions +- **Pure CSS tokens** — Design tokens live in `@fried-ui/styles` as plain CSS, usable with any framework +- **Dark mode** — Framer-inspired dark theme with 4-layer elevation system +- **Developer-friendly** — Single `variant` prop, inline union types for IDE autocomplete +- **LLM-friendly** — Structured docs and MCP server for AI consumption + +## Architecture + +```text +@fried-ui/react React 19 components (behavior and accessibility) +@fried-ui/styles Pure CSS: design tokens and component class styles +``` + +`@fried-ui/styles` has no React dependency — it can be used with Vue, Svelte, or plain HTML. + +## Components + +- [Chip](/docs/components/chip) — Display label with 20 variants (solid, soft, outline) +- [Button](/docs/components/button) — Accessible button with 8 variants, golden ratio spacing, and icon-only mode +- [Surface](/docs/components/surface) — Visual layer component for cards and panels diff --git a/apps/docs/src/content/docs/overview/quick-start.mdx b/apps/docs/src/content/docs/overview/quick-start.mdx new file mode 100644 index 0000000..11b29db --- /dev/null +++ b/apps/docs/src/content/docs/overview/quick-start.mdx @@ -0,0 +1,27 @@ +--- +title: Quick Start +description: Install Fried UI and render your first component in under a minute. +--- + +## Installation + +```bash +pnpm add @fried-ui/react @fried-ui/styles +``` + +Import styles in your CSS entry file: + +```css +@import "tailwindcss"; +@import "@fried-ui/styles"; +``` + +## Usage + +```tsx +import { Button } from "@fried-ui/react"; + +export default function App() { + return ; +} +``` diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 626efc0..85d0705 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -1,12 +1,11 @@ { - "extends": "@repo/quality/tsconfig/nextjs.json", + "extends": "@repo/tsconfig/nextjs.json", "compilerOptions": { "plugins": [ { "name": "next" } ], - "strictNullChecks": true, "paths": { "@/*": ["./src/*"], "@/.source/*": ["./.source/*"], diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json index 47dc242..b2e861e 100644 --- a/apps/docs/vercel.json +++ b/apps/docs/vercel.json @@ -1,6 +1,6 @@ { - "buildCommand": "pnpm build", - "installCommand": "pnpm install", + "buildCommand": "cd ../.. && pnpm turbo run build --filter=docs", + "installCommand": "cd ../.. && pnpm install", "framework": "nextjs", "ignoreCommand": "npx turbo-ignore" } diff --git a/apps/storybook/.storybook/format-source.ts b/apps/storybook/.storybook/format-source.ts new file mode 100644 index 0000000..d449f86 --- /dev/null +++ b/apps/storybook/.storybook/format-source.ts @@ -0,0 +1,26 @@ +import prettier from "prettier/standalone"; +import prettierPluginBabel from "prettier/plugins/babel"; +import prettierPluginEstree from "prettier/plugins/estree"; + +async function formatSource(code: string): Promise { + try { + const formatted = await prettier.format(code, { + parser: "babel", + plugins: [prettierPluginBabel, prettierPluginEstree], + printWidth: 120, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: "all", + bracketSpacing: true, + arrowParens: "always", + }); + + return formatted.trim().replace(/;$/, ""); + } catch { + return code; + } +} + +export { formatSource }; diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 10a0b3d..d9ff5a6 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,29 +1,28 @@ -import path from "node:path"; - import type { StorybookConfig } from "@storybook/react-vite"; import tailwindcss from "@tailwindcss/vite"; -const reactPkg = path.resolve(import.meta.dirname, "../../../packages/react"); - const config: StorybookConfig = { stories: ["../../../packages/react/src/**/*.stories.@(ts|tsx)"], framework: "@storybook/react-vite", + typescript: { + reactDocgen: "react-docgen-typescript", + reactDocgenTypescriptOptions: { + include: ["../../packages/react/src/**/*.tsx"], + tsconfigPath: "../../packages/react/tsconfig.json", + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true), + }, + }, + viteFinal(config) { config.plugins = [...(config.plugins || []), tailwindcss()]; - config.resolve = { - ...config.resolve, - alias: { - ...config.resolve?.alias, - src: path.join(reactPkg, "src"), - }, - }; - return config; }, - addons: ["@storybook/addon-docs"], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y", "@storybook/addon-vitest", "@chromatic-com/storybook"], }; export default config; diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index c463dd2..974ff01 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -1,15 +1,62 @@ -import type { Preview } from "@storybook/react"; import "./styles.css"; -const PREVIEW: Preview = { +import type { Preview } from "@storybook/react"; +import { MINIMAL_VIEWPORTS } from "storybook/viewport"; + +import { formatSource } from "./format-source"; + +const preview: Preview = { parameters: { + a11y: { + test: "todo", + options: { + runOnly: { + type: "tag", + values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"], + }, + }, + }, + backgrounds: { + default: "light", + options: { + light: { + name: "light", + value: "var(--color-background, #fff)", + }, + dark: { + name: "dark", + value: "var(--color-background, #000)", + }, + }, + }, + docs: { + codePanel: true, + source: { + type: "dynamic", + transform: formatSource, + }, + }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, + viewport: { + options: MINIMAL_VIEWPORTS, + }, }, + decorators: [ + (Story, context) => { + const bg = context.globals?.backgrounds?.value; + const isDark = bg?.includes("dark") || bg?.includes("000"); + const theme = isDark ? "dark" : "light"; + + document.documentElement.dataset.theme = theme; + + return Story(); + }, + ], }; -export default PREVIEW; +export default preview; diff --git a/apps/storybook/.storybook/styles.css b/apps/storybook/.storybook/styles.css index 4aa500b..a8658a5 100644 --- a/apps/storybook/.storybook/styles.css +++ b/apps/storybook/.storybook/styles.css @@ -1,2 +1,4 @@ +@import "tailwindcss" source(none); @import "@fried-ui/styles"; -@source "../../../packages/react/src/**/*.{ts,tsx}"; + +@source "../../../packages/react/src"; diff --git a/apps/storybook/chromatic.config.json b/apps/storybook/chromatic.config.json new file mode 100644 index 0000000..5b15952 --- /dev/null +++ b/apps/storybook/chromatic.config.json @@ -0,0 +1,6 @@ +{ + "buildScriptName": "build", + "projectId": "Project:69e6968ee69bc24a839f0d65", + "storybookBaseDir": "apps/storybook", + "zip": true +} diff --git a/apps/storybook/eslint.config.ts b/apps/storybook/eslint.config.ts index 2d8976c..9f14ba7 100644 --- a/apps/storybook/eslint.config.ts +++ b/apps/storybook/eslint.config.ts @@ -1,3 +1,3 @@ -import { createReactConfig } from "@repo/quality/eslint/react-internal"; +import { createReactConfig } from "@repo/eslint-config/react-internal"; export default createReactConfig(import.meta.dirname); diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 4750506..09724e4 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -4,9 +4,18 @@ "private": true, "scripts": { "build": "storybook build -o dist", - "check-types": "tsc --noEmit", + "clean": "rm -rf dist .turbo", + "coverage:normalize": "node ../../scripts/normalize-lcov.mjs coverage/lcov.info", "dev": "storybook dev -p 6006", - "lint": "eslint . --max-warnings 0" + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix --max-warnings 0", + "playwright:install": "playwright install chromium --with-deps", + "sort": "sort-package-json --check", + "sort:fix": "sort-package-json", + "test": "vitest run --project storybook", + "test:coverage": "pnpm test:coverage-run && pnpm coverage:normalize", + "test:coverage-run": "vitest run --project storybook --coverage", + "typecheck": "tsc --noEmit" }, "dependencies": { "@fried-ui/react": "workspace:*", @@ -15,18 +24,25 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@repo/quality": "workspace:*", + "@chromatic-com/storybook": "^5.1.2", + "@repo/eslint-config": "workspace:*", + "@repo/tsconfig": "workspace:*", + "@storybook/addon-a11y": "^10.3.5", + "@storybook/addon-docs": "^10.3.3", "@storybook/addon-vitest": "^10.3.3", "@storybook/react": "^10.3.3", "@storybook/react-vite": "^10.3.3", "@tailwindcss/vite": "^4.1.0", - "@vitest/browser-playwright": "^4.1.2", - "@vitest/coverage-v8": "^4.1.2", - "eslint": "^9.39.1", + "@vitest/browser-playwright": "^4.1.5", + "@vitest/coverage-v8": "^4.1.5", + "chromatic": "^16.6.0", + "eslint": "^10.2.1", "playwright": "^1.58.2", + "sort-package-json": "^3.6.1", "storybook": "^10.3.3", "tailwindcss": "^4.1.0", + "typescript": "5.9.2", "vite": "^8.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.5" } } diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index de50dd3..8917716 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -1,9 +1,8 @@ { - "extends": "@repo/quality/tsconfig/react-library.json", + "extends": "@repo/tsconfig/react-library.json", "compilerOptions": { - "rootDir": ".storybook", + "rootDir": ".", "outDir": "dist", - "strictNullChecks": true, "types": ["vite/client"] }, "include": ["**/*.ts", "**/*.tsx", ".storybook/**/*.ts"], diff --git a/apps/storybook/vitest.config.ts b/apps/storybook/vitest.config.ts new file mode 100644 index 0000000..a00fbb0 --- /dev/null +++ b/apps/storybook/vitest.config.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + name: "storybook", + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: "chromium" }], + }, + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + reportsDirectory: "coverage", + include: [path.resolve(here, "../../packages/react/src/**/*.{ts,tsx}")], + exclude: [ + path.resolve(here, "../../packages/react/src/**/*.stories.tsx"), + path.resolve(here, "../../packages/react/src/**/*.test.{ts,tsx}"), + "**/*.d.ts", + ], + allowExternal: true, + }, + }, + plugins: [ + storybookTest({ + configDir: path.join(here, ".storybook"), + storybookScript: "pnpm dev", + tags: { + include: ["autodocs", "test"], + exclude: [], + skip: [], + }, + }), + ], +}); diff --git a/decisions/DESIGN_SYSTEM.md b/decisions/DESIGN_SYSTEM.md deleted file mode 100644 index 258aa56..0000000 --- a/decisions/DESIGN_SYSTEM.md +++ /dev/null @@ -1,92 +0,0 @@ -# fried-ui Design System - -ผลจากการ audit และ discuss แนวทาง design ของ fried-ui component library - -## Design Principles - -### 1. Semantic Intent Over Visual Style - -ใช้ชื่อ variant ตามความหมาย ไม่ใช่ตาม visual (ไม่ใช้ solid, bordered, flat) - -ตัวอย่าง: `variant="danger"` ไม่ใช่ `variant="solid" color="red"` - -### 2. Single-Axis Variant - -fried-ui ใช้ **แกนเดียว** (`variant` prop เดียว) — ไม่แยก color + variant - -เหตุผล: - -- ง่ายสำหรับ developer — 1 prop, 1 decision -- ง่ายสำหรับ LLM — generate code ถูกต้อง, ไม่มี invalid combo - -### 3. Accessibility as Foundation - -ทุก component wrap React Aria Components — ARIA, keyboard, focus, screen reader มาให้ - -### 4. Progressive Disclosure - -```tsx - - -``` - -### 5. Separation of Styles and Logic - -- `@fried-ui/styles` — pure CSS: tokens, `@utility`, component BEM styles -- `@fried-ui/react` — React components, behavior, accessibility - -Multi-framework ready: styles ใช้ได้กับ Vue, Svelte, vanilla HTML - -### 6. CSS-First, No JavaScript Runtime - -- Design tokens ใน `@theme` -- Component styles ใน CSS + BEM (`fri-` prefix) + `@apply` -- ไม่มี tailwind-variants, ไม่มี JS runtime styling -- Dark mode: `prefers-color-scheme` + `@custom-variant dark` -- `prefers-reduced-motion` โดยอัตโนมัติ - -### 7. Golden Ratio Spacing - -- x = font-size เป็นตัวตั้ง ทุกค่า derive จาก φ -- เมื่อ size เปลี่ยน x ต้องเปลี่ยนด้วย (Proportional Scaling) -- Snap ไป Tailwind class ที่ใกล้สุด - -### 8. Color State Mathematics - -- Hover/Active คำนวณจาก Oklch L (Lightness) ที่สมมาตร -- Semantic tokens ใช้ `var()` จาก Tailwind built-in + custom palette -- Custom palette อยู่ใน `tokens/palette.css` - -## Variant System - -### Semantic Variants - -| Variant | Intent | ใช้เมื่อ | -| ----------- | -------------------------- | ------------------------------------------ | -| `primary` | Main forward action | Action หลักของหน้า/section (1 ต่อ context) | -| `secondary` | Alternative action | Action รอง, ทางเลือก | -| `success` | Positive / confirmation | ยืนยัน, สำเร็จ, approve | -| `warning` | Caution / attention | เตือน, ต้องระวัง | -| `danger` | Destructive / irreversible | ลบ, ยกเลิก, action ที่ย้อนกลับไม่ได้ | -| `info` | Informational | แจ้งข้อมูล, neutral context | -| `ghost` | Low-emphasis / dismissive | Cancel, skip, tertiary action | -| `link` | Inline text-like | Navigation, inline action ใน text | - -### Sizes - -| Size | ใช้เมื่อ | -| ---- | ------------------------- | -| `sm` | Compact, dense UI | -| `md` | Default, ใช้ทั่วไป | -| `lg` | Prominent, touch-friendly | - -### Default Values - -ทุก component: `variant="primary"`, `size="md"` เป็น default - -## Component API Conventions - -- `variant` + `size` เป็น optional เสมอ (มี default) -- State props ใช้ `is-` prefix — `isDisabled`, `isPending` (React Aria) -- Event handlers ใช้ `on-` prefix — `onPress` (React Aria) -- `className` สำหรับ override styles diff --git a/eslint.config.ts b/eslint.config.ts index 2862ad6..3b8b998 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,4 +1,4 @@ -import { createConfig } from "@repo/quality/eslint/base"; +import { createConfig } from "@repo/eslint-config/base"; export default [ ...createConfig(import.meta.dirname), diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs deleted file mode 100644 index 0219e24..0000000 --- a/lint-staged.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import("lint-staged").Config} */ -const config = { - "*.{ts,tsx,js,jsx,mjs}": ["prettier --write", "eslint --fix"], - "package.json": ["sort-package-json", "prettier --write"], - "*.{css,md,mdx}": ["prettier --write"], -}; - -export default config; diff --git a/package.json b/package.json index 3728aee..88aa690 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,14 @@ "version": "0.1.0", "private": true, "scripts": { + "audit:deps": "pnpm audit --audit-level moderate", "build": "turbo build", "build:docs": "turbo build --filter=docs", "build:react": "turbo build --filter=@fried-ui/react", "build:storybook": "turbo build --filter=storybook", "build:styles": "turbo build --filter=@fried-ui/styles", + "canary:publish": "turbo run canary:publish", + "canary:version": "node scripts/canary-version.mjs", "changeset": "changeset", "changeset:pre:beta": "changeset pre enter beta", "changeset:pre:exit": "changeset pre exit", @@ -26,30 +29,35 @@ "dev:react": "turbo dev --filter=@fried-ui/react", "dev:storybook": "turbo dev --filter=storybook", "dev:styles": "turbo dev --filter=@fried-ui/styles", - "format": "prettier --check \"**/*.{ts,tsx,js,jsx,mjs,json,css,md,mdx}\"", - "format:fix": "prettier --write \"**/*.{ts,tsx,js,jsx,mjs,json,css,md,mdx}\"", + "format": "prettier --check \"**/*.{ts,tsx,js,jsx,mjs,json,jsonc,yml,yaml,css,md,mdx}\"", + "format:fix": "prettier --write \"**/*.{ts,tsx,js,jsx,mjs,json,jsonc,yml,yaml,css,md,mdx}\"", "preinstall": "npx only-allow pnpm", "lint": "turbo lint", "lint:docs": "turbo lint --filter=docs", - "lint:fix": "turbo lint -- --fix", + "lint:fix": "turbo run lint:fix", "lint:react": "turbo lint --filter=@fried-ui/react", "lint:storybook": "turbo lint --filter=storybook", "lint:styles": "turbo lint --filter=@fried-ui/styles", "prepare": "husky", + "release:preview": "pnpx pkg-pr-new publish --pnpm './packages/react' './packages/styles'", + "sort": "turbo run sort", + "sort:fix": "turbo run sort:fix", "test": "turbo run test", + "test:coverage": "turbo run test:coverage", "test:watch": "turbo run test:watch", - "typecheck": "turbo check-types", - "typecheck:docs": "turbo check-types --filter=docs", - "typecheck:react": "turbo check-types --filter=@fried-ui/react", - "typecheck:storybook": "turbo check-types --filter=storybook", - "typecheck:styles": "turbo check-types --filter=@fried-ui/styles" + "typecheck": "turbo typecheck", + "typecheck:docs": "turbo typecheck --filter=docs", + "typecheck:react": "turbo typecheck --filter=@fried-ui/react", + "typecheck:storybook": "turbo typecheck --filter=storybook", + "typecheck:styles": "turbo typecheck --filter=@fried-ui/styles" }, "devDependencies": { "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.30.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", - "@repo/quality": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@turbo/gen": "^2.9.6", "@types/node": "^22.15.3", "husky": "^9.1.7", "jiti": "^2.6.1", @@ -57,22 +65,22 @@ "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", "sort-package-json": "^3.6.1", - "turbo": "^2.8.20", + "stylelint": "^17.9.0", + "stylelint-config-standard": "^40.0.0", + "stylelint-config-tailwindcss": "^1.0.1", + "turbo": "^2.9.6", "typescript": "5.9.2" }, - "packageManager": "pnpm@9.0.0", + "packageManager": "pnpm@10.0.0", "engines": { - "node": ">=18" + "node": ">=24" }, "pnpm": { "overrides": { - "flatted@<3.4.2": ">=3.4.2", "js-yaml@>=4.0.0 <4.1.1": ">=4.1.1", - "js-yaml@<3.13.1": ">=3.13.1", - "minimatch@>=9.0.0 <9.0.6": ">=9.0.6", - "minimatch@<3.1.3": ">=3.1.3", + "path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0", "picomatch@>=4.0.0 <4.0.4": ">=4.0.4", - "picomatch@<2.3.2": ">=2.3.2" + "postcss": ">=8.5.10" } } } diff --git a/packages/assets/package.json b/packages/assets/package.json new file mode 100644 index 0000000..f390081 --- /dev/null +++ b/packages/assets/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fried-ui/assets", + "version": "0.1.0", + "private": true, + "description": "Shared assets for Fried UI — story fixtures, sample avatars, demo images.", + "exports": { + "./story/*": "./story/*" + }, + "files": [ + "story" + ], + "scripts": { + "sort": "sort-package-json --check", + "sort:fix": "sort-package-json" + }, + "devDependencies": { + "sort-package-json": "^3.6.1" + } +} diff --git a/packages/assets/story/avatar-1.jpg b/packages/assets/story/avatar-1.jpg new file mode 100644 index 0000000..e316c97 Binary files /dev/null and b/packages/assets/story/avatar-1.jpg differ diff --git a/packages/assets/story/avatar-2.jpg b/packages/assets/story/avatar-2.jpg new file mode 100644 index 0000000..5e586d9 Binary files /dev/null and b/packages/assets/story/avatar-2.jpg differ diff --git a/packages/assets/story/avatar-3.jpg b/packages/assets/story/avatar-3.jpg new file mode 100644 index 0000000..3e45edb Binary files /dev/null and b/packages/assets/story/avatar-3.jpg differ diff --git a/packages/assets/story/avatar-4.jpg b/packages/assets/story/avatar-4.jpg new file mode 100644 index 0000000..2e31f45 Binary files /dev/null and b/packages/assets/story/avatar-4.jpg differ diff --git a/packages/assets/story/avatar-5.jpg b/packages/assets/story/avatar-5.jpg new file mode 100644 index 0000000..3866024 Binary files /dev/null and b/packages/assets/story/avatar-5.jpg differ diff --git a/packages/assets/story/avatar-6.jpg b/packages/assets/story/avatar-6.jpg new file mode 100644 index 0000000..e362d99 Binary files /dev/null and b/packages/assets/story/avatar-6.jpg differ diff --git a/packages/quality/eslint/base.ts b/packages/eslint-config/base.ts similarity index 69% rename from packages/quality/eslint/base.ts rename to packages/eslint-config/base.ts index 8f7b203..80b9643 100644 --- a/packages/quality/eslint/base.ts +++ b/packages/eslint-config/base.ts @@ -1,13 +1,11 @@ -import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; -import turboPlugin from "eslint-plugin-turbo"; -import tseslint from "typescript-eslint"; +import js from "@eslint/js"; import nextfriday from "eslint-plugin-nextfriday"; +import tseslint from "typescript-eslint"; +import turboPlugin from "eslint-plugin-turbo"; +import type { Linter } from "eslint"; -/** - * A shared ESLint configuration for the repository. - */ -export function createConfig(tsconfigRootDir: string) { +function createConfig(tsconfigRootDir: string): Linter.Config[] { return [ js.configs.recommended, eslintConfigPrettier, @@ -20,6 +18,8 @@ export function createConfig(tsconfigRootDir: string) { }, }, nextfriday.configs["base/recommended"], + ...nextfriday.configs.sonarjs, + ...nextfriday.configs.unicorn, { plugins: { turbo: turboPlugin, @@ -29,7 +29,9 @@ export function createConfig(tsconfigRootDir: string) { }, }, { - ignores: ["dist/**", "sample/**"], + ignores: ["dist/**", "coverage/**", "**/coverage/**"], }, - ]; + ] as Linter.Config[]; } + +export { createConfig }; diff --git a/packages/eslint-config/eslint.config.ts b/packages/eslint-config/eslint.config.ts new file mode 100644 index 0000000..4c7a1ed --- /dev/null +++ b/packages/eslint-config/eslint.config.ts @@ -0,0 +1,6 @@ +import type { Linter } from "eslint"; +import { createConfig } from "./base"; + +const config: Linter.Config[] = createConfig(import.meta.dirname); + +export default config; diff --git a/packages/quality/eslint/next.ts b/packages/eslint-config/next.ts similarity index 84% rename from packages/quality/eslint/next.ts rename to packages/eslint-config/next.ts index e14033b..72ae397 100644 --- a/packages/quality/eslint/next.ts +++ b/packages/eslint-config/next.ts @@ -1,18 +1,19 @@ -import js from "@eslint/js"; -import { globalIgnores } from "eslint/config"; import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; +import eslintReact from "@eslint-react/eslint-plugin"; import globals from "globals"; -import pluginNext from "@next/eslint-plugin-next"; +import js from "@eslint/js"; import nextfriday from "eslint-plugin-nextfriday"; +import pluginNext from "@next/eslint-plugin-next"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; +import { globalIgnores } from "eslint/config"; import { createConfig } from "./base"; +import type { Linter } from "eslint"; /** * A custom ESLint configuration for libraries that use Next.js. */ -export function createNextJsConfig(tsconfigRootDir: string) { +function createNextJsConfig(tsconfigRootDir: string): Linter.Config[] { return [ ...createConfig(tsconfigRootDir), js.configs.recommended, @@ -20,10 +21,9 @@ export function createNextJsConfig(tsconfigRootDir: string) { ...tseslint.configs.recommended, nextfriday.configs["nextjs/recommended"], globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]), + eslintReact.configs["recommended-typescript"], { - ...pluginReact.configs.flat?.recommended, languageOptions: { - ...pluginReact.configs.flat?.recommended?.languageOptions, globals: { ...globals.serviceworker, }, @@ -42,10 +42,8 @@ export function createNextJsConfig(tsconfigRootDir: string) { plugins: { "react-hooks": pluginReactHooks, }, - settings: { react: { version: "detect" } }, rules: { ...pluginReactHooks.configs.recommended.rules, - "react/react-in-jsx-scope": "off", }, }, { @@ -67,5 +65,7 @@ export function createNextJsConfig(tsconfigRootDir: string) { "nextfriday/enforce-constant-case": "off", }, }, - ]; + ] as Linter.Config[]; } + +export { createNextJsConfig }; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 0000000..dc6bb37 --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,34 @@ +{ + "name": "@repo/eslint-config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./base": "./base.ts", + "./next-js": "./next.ts", + "./react-internal": "./react-internal.ts" + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix --max-warnings 0", + "sort": "sort-package-json --check", + "sort:fix": "sort-package-json", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@eslint-react/eslint-plugin": "^4.2.3", + "@eslint/js": "^10.0.1", + "@next/eslint-plugin-next": "^16.2.1", + "@repo/tsconfig": "workspace:*", + "eslint": "^10.2.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-nextfriday": "^2.0.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-turbo": "^2.8.20", + "globals": "^16.5.0", + "jiti": "^2.6.1", + "sort-package-json": "^3.6.1", + "typescript": "^5.9.2", + "typescript-eslint": "^8.57.2" + } +} diff --git a/packages/quality/eslint/react-internal.ts b/packages/eslint-config/react-internal.ts similarity index 73% rename from packages/quality/eslint/react-internal.ts rename to packages/eslint-config/react-internal.ts index dee8fbf..d7593ff 100644 --- a/packages/quality/eslint/react-internal.ts +++ b/packages/eslint-config/react-internal.ts @@ -1,26 +1,26 @@ -import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; +import eslintReact from "@eslint-react/eslint-plugin"; import globals from "globals"; +import js from "@eslint/js"; import nextfriday from "eslint-plugin-nextfriday"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; import { createConfig } from "./base"; +import type { Linter } from "eslint"; /** * A custom ESLint configuration for libraries that use React. */ -export function createReactConfig(tsconfigRootDir: string) { +function createReactConfig(tsconfigRootDir: string): Linter.Config[] { return [ ...createConfig(tsconfigRootDir), js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, nextfriday.configs["react/recommended"], - pluginReact.configs.flat.recommended!, + eslintReact.configs["recommended-typescript"], { languageOptions: { - ...pluginReact.configs.flat.recommended!.languageOptions, globals: { ...globals.serviceworker, ...globals.browser, @@ -31,11 +31,11 @@ export function createReactConfig(tsconfigRootDir: string) { plugins: { "react-hooks": pluginReactHooks, }, - settings: { react: { version: "detect" } }, rules: { ...pluginReactHooks.configs.recommended.rules, - "react/react-in-jsx-scope": "off", }, }, - ]; + ] as Linter.Config[]; } + +export { createReactConfig }; diff --git a/packages/eslint-config/tsconfig.json b/packages/eslint-config/tsconfig.json new file mode 100644 index 0000000..75ce27c --- /dev/null +++ b/packages/eslint-config/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://www.schemastore.org/tsconfig", + "extends": "@repo/tsconfig/base.json", + "include": ["base.ts", "next.ts", "react-internal.ts", "eslint.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json deleted file mode 100644 index 42f8471..0000000 --- a/packages/mcp/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@fried-ui/mcp", - "version": "0.1.0", - "private": true, - "description": "MCP server for fried-ui — query components, design tokens, and API docs", - "type": "module", - "bin": { - "fried-ui-mcp": "./dist/index.mjs" - }, - "scripts": { - "build": "tsup", - "dev": "tsup --watch" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", - "zod": "^3.25.67" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "tsup": "^8.5.1", - "typescript": "5.9.2" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts deleted file mode 100644 index 59da3b0..0000000 --- a/packages/mcp/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - -const server = new McpServer({ - name: "fried-ui", - version: "0.1.0", -}); - -// TODO: add tools here - -async function main(): Promise { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("fried-ui MCP server running on stdio"); -} - -main().catch((error: unknown) => { - console.error("Fatal error:", error); - process.exit(1); -}); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json deleted file mode 100644 index f7e1ec1..0000000 --- a/packages/mcp/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@repo/quality/tsconfig/base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "strictNullChecks": true, - "types": ["node"] - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts deleted file mode 100644 index ddf35ce..0000000 --- a/packages/mcp/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - index: "src/index.ts", - }, - format: ["esm"], - outExtension: () => ({ js: ".mjs" }), - banner: { - js: "#!/usr/bin/env node", - }, - clean: true, -}); diff --git a/packages/quality/package.json b/packages/quality/package.json deleted file mode 100644 index 53f71e3..0000000 --- a/packages/quality/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@repo/quality", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - "./eslint/base": "./eslint/base.ts", - "./eslint/next-js": "./eslint/next.ts", - "./eslint/react-internal": "./eslint/react-internal.ts", - "./tsconfig/base": "./tsconfig/base.json", - "./tsconfig/base.json": "./tsconfig/base.json", - "./tsconfig/nextjs": "./tsconfig/nextjs.json", - "./tsconfig/nextjs.json": "./tsconfig/nextjs.json", - "./tsconfig/react-library": "./tsconfig/react-library.json", - "./tsconfig/react-library.json": "./tsconfig/react-library.json" - }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@next/eslint-plugin-next": "^16.2.1", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-nextfriday": "^1.21.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-turbo": "^2.8.20", - "globals": "^16.5.0", - "jiti": "^2.6.1", - "typescript": "^5.9.2", - "typescript-eslint": "^8.57.2" - } -} diff --git a/packages/react/README.md b/packages/react/README.md index 3d62960..aac5b1e 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,6 +1,8 @@ # @fried-ui/react -Beautiful, accessible React components for building modern web apps at scale. +**Same component in React. Same class in HTML. One source of truth.** + +Compound primitives, dual selectors, dark mode, and zero runtime. ## Installation @@ -20,21 +22,22 @@ Peer dependencies: `react >=19.0.0`, `react-dom >=19.0.0`, `tailwindcss >=4.0.0` ## Usage ```tsx -import { Box } from "@fried-ui/react/box"; +import { Button } from "@fried-ui/react"; - - Hello -; +; ``` ## Exports -| Path | Description | -| -------------------------- | -------------------------------- | -| `@fried-ui/react` | All components + utilities | -| `@fried-ui/react/box` | Box polymorphic layout primitive | -| `@fried-ui/react/aria` | React Aria Components re-exports | -| `@fried-ui/react/utils/cn` | Tailwind class merge utility | +| Path | Description | +| ------------------------- | --------------------------- | +| `@fried-ui/react` | All components + icons | +| `@fried-ui/react/chip` | Chip component | +| `@fried-ui/react/button` | Button component | +| `@fried-ui/react/icons` | SVG icon components | +| `@fried-ui/react/surface` | Surface container component | ## Documentation diff --git a/packages/react/eslint.config.ts b/packages/react/eslint.config.ts index 1e9862b..415bd8f 100644 --- a/packages/react/eslint.config.ts +++ b/packages/react/eslint.config.ts @@ -1,11 +1,44 @@ -import { createReactConfig } from "@repo/quality/eslint/react-internal"; +import { createReactConfig } from "@repo/eslint-config/react-internal"; export default [ ...createReactConfig(import.meta.dirname), { - files: ["src/components/**/*.{ts,tsx}"], + ignores: ["scripts/**"], + }, + { + files: ["src/**/*.{ts,tsx}"], + rules: { + "nextfriday/no-relative-imports": "off", + }, + }, + { + files: ["src/components/**/*.{ts,tsx}", "src/tests/**/*.{ts,tsx}"], rules: { "nextfriday/jsx-pascal-case": "off", }, }, + { + files: ["src/tests/globals.d.ts"], + rules: { + "@typescript-eslint/no-empty-object-type": "off", + }, + }, + { + files: ["src/components/**/*.stories.tsx"], + rules: { + "nextfriday/enforce-constant-case": "off", + }, + }, + { + files: ["src/components/**/*-context.ts"], + rules: { + "nextfriday/enforce-camel-case": "off", + }, + }, + { + files: ["src/components/field/Field.tsx"], + rules: { + "nextfriday/prefer-import-type": "off", + }, + }, ]; diff --git a/packages/react/package.json b/packages/react/package.json index 4bc6a38..8cfa56d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "name": "@fried-ui/react", "version": "0.1.0", - "description": "Beautiful, accessible React components for building modern web apps at scale", + "description": "Same component in React. Same class in HTML. One source of truth. Compound primitives, dual selectors, dark mode, and zero runtime.", "keywords": [ "react", "components", @@ -27,55 +27,110 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs" }, + "./avatar": { + "types": "./dist/components/avatar/index.d.ts", + "import": "./dist/components/avatar/index.mjs" + }, + "./avatar-group": { + "types": "./dist/components/avatar-group/index.d.ts", + "import": "./dist/components/avatar-group/index.mjs" + }, + "./badge": { + "types": "./dist/components/badge/index.d.ts", + "import": "./dist/components/badge/index.mjs" + }, "./button": { "types": "./dist/components/button/index.d.ts", "import": "./dist/components/button/index.mjs" }, - "./utils/cn": { - "types": "./dist/utils/cn/index.d.ts", - "import": "./dist/utils/cn/index.mjs" + "./chip": { + "types": "./dist/components/chip/index.d.ts", + "import": "./dist/components/chip/index.mjs" + }, + "./description": { + "types": "./dist/components/description/index.d.ts", + "import": "./dist/components/description/index.mjs" + }, + "./field-error": { + "types": "./dist/components/field-error/index.d.ts", + "import": "./dist/components/field-error/index.mjs" + }, + "./icons": { + "types": "./dist/components/icons/index.d.ts", + "import": "./dist/components/icons/index.mjs" + }, + "./input": { + "types": "./dist/components/input/index.d.ts", + "import": "./dist/components/input/index.mjs" + }, + "./label": { + "types": "./dist/components/label/index.d.ts", + "import": "./dist/components/label/index.mjs" + }, + "./signal-dot": { + "types": "./dist/components/signal-dot/index.d.ts", + "import": "./dist/components/signal-dot/index.mjs" + }, + "./surface": { + "types": "./dist/components/surface/index.d.ts", + "import": "./dist/components/surface/index.mjs" + }, + "./textarea": { + "types": "./dist/components/textarea/index.d.ts", + "import": "./dist/components/textarea/index.mjs" } }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "tsup", - "check-types": "tsc --noEmit", - "clean": "rm -rf dist .turbo coverage.json", + "canary:publish": "pnpm publish --tag canary --no-git-checks --access public", + "clean": "rm -rf dist .turbo", + "coverage:normalize": "node ../../scripts/normalize-lcov.mjs coverage/lcov.info", "dev": "tsup --watch", "generate:component": "turbo gen react-component", "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix --max-warnings 0", + "sort": "sort-package-json --check", + "sort:fix": "sort-package-json", "test": "vitest run", - "test:watch": "vitest" + "test:coverage": "pnpm test:coverage-run && pnpm coverage:normalize", + "test:coverage-run": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" }, "dependencies": { + "@fried-ui/assets": "workspace:*", "@fried-ui/styles": "workspace:^", + "@radix-ui/react-avatar": "^1.1.11", "@react-types/color": "^3.1.4", "@react-types/shared": "^3.33.1", "clsx": "^2.1.1", - "react-aria-components": "^1.16.0", - "tailwind-merge": "^3.5.0" + "react-aria-components": "^1.16.0" }, "devDependencies": { - "@fried-ui/vitest": "workspace:*", - "@repo/quality": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@repo/tsconfig": "workspace:*", "@storybook/react": "^10.3.3", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", "@types/node": "^22.15.3", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.39.1", - "jsdom": "^29.0.1", + "@vitest/coverage-v8": "^4.1.5", + "eslint": "^10.2.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "sort-package-json": "^3.6.1", + "storybook": "^10.3.3", "tailwindcss": "^4.2.2", + "ts-morph": "^28.0.0", "tsup": "^8.5.1", + "turbo": "^2.9.6", "typescript": "5.9.2", - "vitest": "^4.1.1" + "vitest": "^4.1.5" }, "peerDependencies": { "react": ">=19.0.0", diff --git a/packages/react/src/components/avatar-group/AvatarGroup.tsx b/packages/react/src/components/avatar-group/AvatarGroup.tsx new file mode 100644 index 0000000..cda0789 --- /dev/null +++ b/packages/react/src/components/avatar-group/AvatarGroup.tsx @@ -0,0 +1,87 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; +import { useMemo } from "react"; + +import { AvatarGroupContext } from "./avatar-group-context"; +import type { AvatarGroupContextProps } from "./avatar-group-context"; +import { classes } from "../../utils/classes"; + +export interface AvatarGroupProps extends Omit, "children" | "className"> { + /** Stack content — a sequence of `` siblings, optionally followed by an `` for the overflow indicator. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the group hides the 2px outline around each avatar. @default false */ + isBorderless?: boolean; + /** Whether the avatars lift on hover. @default false */ + isHoverable?: boolean; + /** Size scale. @default 'md' */ + size?: "xs" | "sm" | "md" | "lg" | "xl"; + /** Overlap amount between adjacent avatars. @default 'default' */ + spacing?: "tighter" | "tight" | "default" | "wide" | "wider"; +} + +/** + * A stack of overlapping avatars representing a group of users or entities. + * Compose an `` as the last child to render an overflow indicator. + */ +const AvatarGroup = (props: Readonly) => { + const { children, className, isBorderless, isHoverable, ref, size, spacing, ...rest } = props; + + const groupClassName = classes({ + block: "avatar-group", + modifiers: { + size, + spacing, + hoverable: isHoverable, + borderless: isBorderless, + }, + className, + }); + + const contextValue = useMemo(() => ({ size }), [size]); + + return ( + +
+ {children} +
+
+ ); +}; + +AvatarGroup.displayName = "AvatarGroup"; + +export interface AvatarGroupCounterProps extends Omit, "children" | "className"> { + /** Counter content — typically a formatted overflow string such as `"+99"`, `"+99+"`, or `"+10.2K"`. Provide compact notation manually (e.g. via `Intl.NumberFormat`). */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Visual style. @default 'fallback' */ + variant?: "fallback" | "text"; +} + +/** + * Overflow indicator for ``. Compose as the last child of the group. + * Provide the formatted count as `children` — the component does not compute or format it. + */ +const AvatarGroupCounter = (props: Readonly) => { + const { children, className, ref, variant, ...rest } = props; + + const counterClassName = classes({ + block: "avatar-group-counter", + modifiers: { variant }, + className, + }); + + return ( + + {children} + + ); +}; + +AvatarGroupCounter.displayName = "AvatarGroupCounter"; + +export { AvatarGroup, AvatarGroupCounter }; diff --git a/packages/react/src/components/avatar-group/avatar-group-context.ts b/packages/react/src/components/avatar-group/avatar-group-context.ts new file mode 100644 index 0000000..a4732e3 --- /dev/null +++ b/packages/react/src/components/avatar-group/avatar-group-context.ts @@ -0,0 +1,18 @@ +"use client"; + +import { createContext, use } from "react"; + +export interface AvatarGroupContextProps { + size?: "xs" | "sm" | "md" | "lg" | "xl"; +} + +const AvatarGroupContext = createContext(undefined); + +/** + * Read the current `` context. Returns `undefined` when used outside a group. + */ +function useAvatarGroupContext(): AvatarGroupContextProps | undefined { + return use(AvatarGroupContext); +} + +export { AvatarGroupContext, useAvatarGroupContext }; diff --git a/packages/react/src/components/avatar-group/avatar-group.stories.tsx b/packages/react/src/components/avatar-group/avatar-group.stories.tsx new file mode 100644 index 0000000..c3e7dd6 --- /dev/null +++ b/packages/react/src/components/avatar-group/avatar-group.stories.tsx @@ -0,0 +1,297 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import AVATAR_1 from "@fried-ui/assets/story/avatar-1.jpg"; + +import AVATAR_2 from "@fried-ui/assets/story/avatar-2.jpg"; +import AVATAR_3 from "@fried-ui/assets/story/avatar-3.jpg"; +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; +import { UserIcon } from "../icons"; +import { AvatarGroup, AvatarGroupCounter } from "./AvatarGroup"; + +const meta = { + title: "Components/AvatarGroup", + component: AvatarGroup, + subcomponents: { + AvatarGroupCounter, + }, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + children: { + control: false, + description: + "A sequence of `` siblings, optionally followed by an `` for the overflow indicator.", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg", "xl"], + description: + "Size scale applied to every avatar in the group on the identification-role rhythm — fixed-px sizes locked to the matching Button height per tier. **Compact:** xs (24px) — inline metadata, count-first activity indicators; sm (32px) — dense rosters, compact toolbars. **Standard:** md (36px, default) — team and member rosters, attendee lists. **Emphasis:** lg (40px) — featured contributors, emphasized cards; xl (44px) — hero showcases, marketing spotlights. Use xs/sm when count matters more than identity, md as default, lg/xl when individual face recognition is essential.", + table: { + type: { summary: '"xs" | "sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + spacing: { + control: "select", + options: ["tighter", "tight", "default", "wide", "wider"], + description: + "Overlap amount between adjacent avatars. **tighter** — 35% overlap, densest pack for long activity logs, contributor counts, or dense tables. **tight** — 30% overlap, compact stack for rosters where count matters more than identity. **default** — 20% overlap, balanced social-proof look for team/attendee groups. **wide** — 10% overlap, airy layout that preserves face recognition for profile highlights. **wider** — 5% overlap, nearly separated tiles for hero sections or showcase marketing blocks where each face is the focal point.", + table: { + type: { summary: '"tighter" | "tight" | "default" | "wide" | "wider"' }, + defaultValue: { summary: "default" }, + category: "Style Variants", + }, + }, + isBorderless: { + control: "boolean", + description: + "Whether the group hides the 2px outline around each avatar — produces a solid stack with no background-colored separator. The default keeps the outline for visual clarity on overlapping avatars.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isHoverable: { + control: "boolean", + description: + "Whether avatars lift on hover — adds a subtle upward scale and z-index boost when the cursor enters an avatar. The default keeps avatars static, ideal for read-only rosters or dense activity logs where pointer hover is incidental.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + render: (args): React.JSX.Element => ( + + + + A1 + + + + + A2 + + + + + A3 + + + ), +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + + + + A1 + + + + + A2 + + + + + A3 + + + ))} +
+ ), +}; + +const Spacing: Story = { + render: (args): React.JSX.Element => ( +
+ {(["wider", "wide", "default", "tight", "tighter"] as const).map((spacing) => ( + + + + A1 + + + + + A2 + + + + + A3 + + + ))} +
+ ), +}; + +const Borderless: Story = { + args: { + isBorderless: true, + }, + render: (args): React.JSX.Element => ( + + + + A1 + + + + + A2 + + + + + A3 + + + ), +}; + +const Hoverable: Story = { + args: { + isHoverable: true, + }, + render: (args): React.JSX.Element => ( + + + + A1 + + + + + A2 + + + + + A3 + + + ), +}; + +const WithCounter: Story = { + render: (args): React.JSX.Element => ( + + + + A1 + + + + + A2 + + + + + A3 + + + +42 + + ), +}; + +const WithTextCounter: Story = { + render: (args): React.JSX.Element => ( + + + + A1 + + + + + A2 + + + + + A3 + + + + +10.2K + + + ), +}; + +const WithFallback: Story = { + render: (args): React.JSX.Element => ( + + + FR + + + + NA + + + + + + + + + ), +}; + +const StandaloneCounter: Story = { + render: (): React.JSX.Element => ( +
+ +42 + + + +10.2K + +
+ ), +}; + +export { + Default, + Sizes, + Spacing, + Borderless, + Hoverable, + WithCounter, + WithTextCounter, + WithFallback, + StandaloneCounter, +}; + +export default meta; diff --git a/packages/react/src/components/avatar-group/index.ts b/packages/react/src/components/avatar-group/index.ts new file mode 100644 index 0000000..5a7b02f --- /dev/null +++ b/packages/react/src/components/avatar-group/index.ts @@ -0,0 +1,2 @@ +export { AvatarGroup, AvatarGroupCounter } from "./AvatarGroup"; +export type { AvatarGroupCounterProps, AvatarGroupProps } from "./AvatarGroup"; diff --git a/packages/react/src/components/avatar/Avatar.tsx b/packages/react/src/components/avatar/Avatar.tsx new file mode 100644 index 0000000..5210e24 --- /dev/null +++ b/packages/react/src/components/avatar/Avatar.tsx @@ -0,0 +1,109 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import * as RadixAvatar from "@radix-ui/react-avatar"; + +import { useAvatarGroupContext } from "../avatar-group/avatar-group-context"; +import { classes } from "../../utils/classes"; + +export interface AvatarProps extends Omit, "children" | "className"> { + /** Avatar content — compose with `` and ``. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the avatar shows a 2px ring matching the background. @default false */ + isBordered?: boolean; + /** Whether the avatar is disabled. @default false */ + isDisabled?: boolean; + /** Border radius scale. @default 'full' */ + radius?: "none" | "xs" | "sm" | "md" | "lg" | "full"; + /** Colored ring around the avatar. @default undefined */ + ring?: "primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info"; + /** Size scale. @default 'md' */ + size?: "xs" | "sm" | "md" | "lg" | "xl"; +} + +/** + * An avatar represents a user or entity with an image, initials, or icon. + * Compose with standalone `AvatarImage` and `AvatarFallback` for image + fallback handling. + */ +const Avatar = (props: Readonly) => { + const { children, className, isBordered, isDisabled, radius, ref, ring, size, ...rest } = props; + const inheritedSize = useAvatarGroupContext()?.size; + const resolvedSize = size ?? inheritedSize; + + const avatarClassName = classes({ + block: "avatar", + modifiers: { + size: resolvedSize, + radius, + ring, + bordered: isBordered, + disabled: isDisabled, + }, + className, + }); + + return ( + + {children} + + ); +}; + +Avatar.displayName = "Avatar"; + +export interface AvatarImageProps extends Omit, "className"> { + /** Additional CSS classes appended after the base class. */ + className?: string; +} + +/** + * Image element rendered inside an Avatar. Hidden until the image loads + * successfully, falling back to AvatarFallback on error. + */ +const AvatarImage = (props: Readonly) => { + const { className, ref, ...rest } = props; + const imageClassName = classes({ block: "avatar-image", modifiers: {}, className }); + + return ; +}; + +AvatarImage.displayName = "AvatarImage"; + +export interface AvatarFallbackProps extends Omit< + ComponentPropsWithRef, + "children" | "className" +> { + /** Fallback content — initials, icon, or any ReactNode shown when the image fails or is absent. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Background color for the fallback initials or icon — useful for differentiating users in chat or member lists. @default 'primary' */ + variant?: "primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info"; +} + +/** + * Content rendered when AvatarImage fails to load or is not provided. + * Typically initials or a placeholder icon. + */ +const AvatarFallback = (props: Readonly) => { + const { children, className, ref, variant, ...rest } = props; + + const fallbackClassName = classes({ + block: "avatar-fallback", + modifiers: { variant }, + className, + }); + + return ( + + {children} + + ); +}; + +AvatarFallback.displayName = "AvatarFallback"; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/react/src/components/avatar/avatar.stories.tsx b/packages/react/src/components/avatar/avatar.stories.tsx new file mode 100644 index 0000000..2ddce57 --- /dev/null +++ b/packages/react/src/components/avatar/avatar.stories.tsx @@ -0,0 +1,297 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import AVATAR_1 from "@fried-ui/assets/story/avatar-1.jpg"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { UserIcon } from "../icons"; +import { Avatar, AvatarFallback, AvatarImage } from "./Avatar"; + +const meta = { + title: "Components/Avatar", + component: Avatar, + subcomponents: { + AvatarImage, + AvatarFallback, + }, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + children: { + control: false, + description: "Compound children — `` and ``", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg", "xl"], + description: + "Size scale of the avatar circle on the identification-role rhythm — fixed-px sizes locked to the matching Button height per tier so avatars and buttons share the same baseline when stacked in a row. **Compact:** xs (24px) — inline beside body text, mention pills, dense table cells; sm (32px) — comment threads, message previews, list rows. **Standard:** md (36px, default) — header user menus, list items, conversation cards. **Emphasis:** lg (40px) — feature cards, profile previews; xl (44px) — hero blocks, settings pages. Use md as default; reach for xs only when paired inline with body text, xl for standalone profile contexts where the face is the focal point.", + table: { + type: { summary: '"xs" | "sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + radius: { + control: "select", + options: ["none", "xs", "sm", "md", "lg", "full"], + description: + "Border radius scale shaping the avatar from a square to a pill. Fixed-px tokens shared with Button / Input so peer surfaces align by absolute radius. **none** — sharp square (logos, brand glyphs). **xs** (2px) — micro softening for tight metadata grids. **sm** (4px) — subtle softening, fits app icons. **md** (6px) — standard rounded square (workspace icons, brand avatars). **lg** (8px) — emphasized soft corners, common in modern social UIs. **full** (default) — circle, the canonical shape for human avatars. Use full for people, md and lg for organizations or workspaces, sm and none for product or brand glyphs.", + table: { + type: { summary: '"none" | "xs" | "sm" | "md" | "lg" | "full"' }, + defaultValue: { summary: "full" }, + category: "Style Variants", + }, + }, + ring: { + control: "select", + options: [undefined, "primary", "secondary", "accent", "success", "warning", "danger", "info"], + description: + "Colored ring around the avatar acting as a status or emphasis indicator. **Brand:** primary (main focus), secondary (neutral gray), accent (brand highlight). **Status:** success (online), warning (away), danger (busy or do-not-disturb), info (notification or unread mention). Use success for online presence, danger for busy, warning for away, primary for selected, accent for featured or pro user, info for unread mentions and notifications.", + table: { + type: { + summary: '"primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info"', + }, + category: "Style Variants", + }, + }, + isBordered: { + control: "boolean", + description: + "Whether the avatar shows a 2px ring matching the background — separates the avatar visually when placed over a photo or colored background", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isDisabled: { + control: "boolean", + description: "Dims the avatar and disables pointer events", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + render: (args): React.JSX.Element => ( + + + A1 + + ), +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ + + A1 + + + + + A2 + + + + + A3 + + + + + A4 + + + + + A5 + +
+ ), +}; + +const Radius: Story = { + render: (args): React.JSX.Element => ( +
+ + + A1 + + + + + A2 + + + + + A3 + + + + + A4 + + + + + A5 + + + + + A6 + +
+ ), +}; + +const Rings: Story = { + render: (args): React.JSX.Element => ( +
+ + + A1 + + + + + A2 + + + + + A3 + + + + + A4 + + + + + A5 + + + + + A6 + + + + + A7 + +
+ ), +}; + +const FallbackVariants: Story = { + render: (args): React.JSX.Element => ( +
+ + PR + + + + SE + + + + AC + + + + SU + + + + WA + + + + DA + + + + IN + +
+ ), +}; + +const Disabled: Story = { + args: { + isDisabled: true, + children: [ + , + A1, + ], + }, +}; + +const Bordered: Story = { + render: (args): React.JSX.Element => ( +
+ + + A1 + +
+ ), +}; + +const Fallback: Story = { + render: (args): React.JSX.Element => ( +
+ + FR + + + + NA + + + + + + + +
+ ), +}; + +const BrokenImage: Story = { + args: { + children: [ + , + FR, + ], + }, +}; + +export { Default, Sizes, Radius, Rings, FallbackVariants, Disabled, Bordered, Fallback, BrokenImage }; + +export default meta; diff --git a/packages/react/src/components/avatar/index.ts b/packages/react/src/components/avatar/index.ts new file mode 100644 index 0000000..917dcd8 --- /dev/null +++ b/packages/react/src/components/avatar/index.ts @@ -0,0 +1,2 @@ +export { Avatar, AvatarImage, AvatarFallback } from "./Avatar"; +export type { AvatarFallbackProps, AvatarImageProps, AvatarProps } from "./Avatar"; diff --git a/packages/react/src/components/badge/Badge.tsx b/packages/react/src/components/badge/Badge.tsx new file mode 100644 index 0000000..6bd09c4 --- /dev/null +++ b/packages/react/src/components/badge/Badge.tsx @@ -0,0 +1,128 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { classes } from "../../utils/classes"; + +export interface BadgeProps extends Omit, "children" | "className"> { + /** Anchor element (Avatar, Button, Icon) followed by a `BadgeIndicator` (text/number) or `BadgeIcon` (icon) subpart positioned at one of its corners. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; +} + +/** + * A positioning wrapper that anchors a `BadgeIndicator` (text/number pill) or + * `BadgeIcon` (icon square) to one of an element's corners. For binary presence + * (online/offline, unread/read) use the standalone `SignalDot` instead. + */ +const Badge = (props: Readonly) => { + const { children, className, ref, ...rest } = props; + + const badgeClassName = classes({ + block: "badge", + modifiers: {}, + className, + }); + + return ( + + {children} + + ); +}; + +Badge.displayName = "Badge"; + +export interface BadgeIndicatorProps extends Omit, "children" | "className"> { + /** Indicator content — text or number such as `"NEW"`, `"99+"`, or `12`. The pill expands width to fit content. For icons use `BadgeIcon`; for binary presence use the standalone `SignalDot`. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the halo border (page-background colored, separates badge from anchor) is hidden — set to `true` for flat badges that sit on a matching surface. @default false */ + isBorderless?: boolean; + /** Cap numeric content; renders as `99+` when the value exceeds this number. @default 99 */ + max?: number; + /** Placement corner relative to the wrapped anchor. @default 'top-right' */ + placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; + /** Visual style. @default 'primary' */ + variant?: "primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info" | "overlay"; +} + +/** + * A pill-shaped indicator for multi-character text or number content (e.g. `"NEW"`, `"99+"`, `12`). + * Width expands with content; padding scales with size. For icon content use `BadgeIcon` (1:1 square); + * for binary presence use the standalone `SignalDot`. + */ +const BadgeIndicator = (props: Readonly) => { + const { children, className, isBorderless, max, placement, ref, size, variant, ...rest } = props; + + const indicatorClassName = classes({ + block: "badge-indicator", + modifiers: { + variant, + size, + placement, + borderless: isBorderless, + }, + className, + }); + + const isOverflow = typeof children === "number" && max !== undefined && children > max; + const content = isOverflow ? `${String(max)}+` : children; + + return ( + + {content} + + ); +}; + +BadgeIndicator.displayName = "BadgeIndicator"; + +export interface BadgeIconProps extends Omit, "children" | "className"> { + /** Icon content rendered in a 1:1 square container. For text or number content, use `BadgeIndicator`; for binary presence use the standalone `SignalDot`. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the halo border (page-background colored, separates badge from anchor) is hidden — set to `true` for flat badges that sit on a matching surface. @default false */ + isBorderless?: boolean; + /** Placement corner relative to the wrapped anchor. @default 'top-right' */ + placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; + /** Visual style. @default 'primary' */ + variant?: "primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info" | "overlay"; +} + +/** + * A square (1:1 aspect ratio) badge anchored at the corner of a parent element. Holds an + * icon or single character. For multi-character text or numbers use `BadgeIndicator` (pill); + * for binary presence use the standalone `SignalDot`. + */ +const BadgeIcon = (props: Readonly) => { + const { children, className, isBorderless, placement, ref, size, variant, ...rest } = props; + + const iconClassName = classes({ + block: "badge-icon", + modifiers: { + variant, + size, + placement, + borderless: isBorderless, + }, + className, + }); + + return ( + + {children} + + ); +}; + +BadgeIcon.displayName = "BadgeIcon"; + +export { Badge, BadgeIcon, BadgeIndicator }; diff --git a/packages/react/src/components/badge/badge.stories.tsx b/packages/react/src/components/badge/badge.stories.tsx new file mode 100644 index 0000000..cb5dd3f --- /dev/null +++ b/packages/react/src/components/badge/badge.stories.tsx @@ -0,0 +1,228 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import AVATAR_1 from "@fried-ui/assets/story/avatar-1.jpg"; +import AVATAR_3 from "@fried-ui/assets/story/avatar-3.jpg"; + +import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; +import { Button } from "../button"; +import { BellIcon } from "../icons"; +import { Badge, BadgeIcon, BadgeIndicator } from "./Badge"; + +const meta: Meta = { + title: "Components/Badge", + component: Badge, + subcomponents: { + BadgeIndicator, + BadgeIcon, + }, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + children: { + control: false, + description: + "Anchor element (Avatar, Button, Icon) plus a `BadgeIndicator` (text/number) or `BadgeIcon` (icon) subpart. Badge provides the positioning container. For binary presence (online/offline), use the standalone `SignalDot` instead.", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + className: { + control: "text", + description: "Additional CSS classes on the Badge wrapper.", + table: { + type: { summary: "string" }, + category: "Styling", + }, + }, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + render: (args): React.JSX.Element => ( + + + + CT + + + NEW + + ), +}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+ {(["primary", "secondary", "accent", "success", "warning", "danger", "info"] as const).map((variant) => ( + + + + CT + + + NEW + + ))} +
+ ), +}; + +const OverlayVariant: Story = { + render: (): React.JSX.Element => ( +
+ +
+ + + CT + +
+ + NEW +
+
+ ), +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ {(["sm", "md", "lg"] as const).map((size) => ( + + + + CT + + + NEW + + ))} +
+ ), +}; + +const ICON_SIZES = [ + { size: "sm", alt: "Icon avatar sm" }, + { size: "md", alt: "Icon avatar md" }, + { size: "lg", alt: "Icon avatar lg" }, +] as const; + +const IconSizes: Story = { + render: (args): React.JSX.Element => ( +
+ {ICON_SIZES.map((item) => ( + + + + CT + + + + + ))} +
+ ), +}; + +const Borderless: Story = { + render: (args): React.JSX.Element => ( + + + + CT + + + NEW + + ), +}; + +const Placements: Story = { + render: (args): React.JSX.Element => ( +
+ {(["top-right", "top-left", "bottom-right", "bottom-left"] as const).map((placement) => ( + + + + CT + + + NEW + + ))} +
+ ), +}; + +const NumericOverflow: Story = { + render: (args): React.JSX.Element => ( + + + + CT + + + {150} + + ), +}; + +const OnButton: Story = { + render: (args): React.JSX.Element => ( + + + 12 + + ), +}; + +const IconContent: Story = { + render: (args): React.JSX.Element => ( +
+ + + + CT + + + + + + + + + + + CT + + + 5 + +
+ ), +}; + +export { + Default, + Variants, + OverlayVariant, + Sizes, + Borderless, + IconSizes, + Placements, + NumericOverflow, + OnButton, + IconContent, +}; + +export default meta; diff --git a/packages/react/src/components/badge/index.ts b/packages/react/src/components/badge/index.ts new file mode 100644 index 0000000..478ac80 --- /dev/null +++ b/packages/react/src/components/badge/index.ts @@ -0,0 +1,2 @@ +export { Badge, BadgeIcon, BadgeIndicator } from "./Badge"; +export type { BadgeIconProps, BadgeIndicatorProps, BadgeProps } from "./Badge"; diff --git a/packages/react/src/components/button/Button.tsx b/packages/react/src/components/button/Button.tsx index 6b3f5ae..1d33dd3 100644 --- a/packages/react/src/components/button/Button.tsx +++ b/packages/react/src/components/button/Button.tsx @@ -1,46 +1,66 @@ "use client"; -import type { ReactNode } from "react"; +import type { ComponentPropsWithRef, ReactNode } from "react"; -import { Button as RACButton, type ButtonProps as RACButtonProps, composeRenderProps } from "react-aria-components"; +import { Button as AriaButton, composeRenderProps } from "react-aria-components"; +import type { ButtonRenderProps } from "react-aria-components"; -import { Spinner } from "src/components/icons"; -import { cn } from "src/utils/cn"; +import { clsx } from "clsx"; -export type ButtonProps = { - variant?: ButtonVariant; - size?: ButtonSize; - className?: string; - children?: ReactNode | ((renderProps: { isPending: boolean }) => ReactNode); -} & Omit; +import { Spinner } from "../icons"; +import { classes } from "../../utils/classes"; -type ButtonVariant = "primary"; -type ButtonSize = "sm" | "md" | "lg" | "xl"; +export interface ButtonProps extends Omit, "className" | "children"> { + /** Button content — text, icons via `slot="icon-start" | "icon-end" | "icon"`, or a render-prop function receiving the current state. */ + children?: ReactNode | ((renderProps: ButtonRenderProps) => ReactNode); + /** Additional CSS classes appended after the base class. Accepts a render-prop function for state-aware styling. */ + className?: string | ((renderProps: ButtonRenderProps) => string); + /** Whether the button stretches to fill its container width. @default false */ + isFullWidth?: boolean; + /** Whether the button renders as a square icon-only button. @default false */ + isIconOnly?: boolean; + /** Border radius scale. @default 'md' */ + radius?: "none" | "xs" | "sm" | "md" | "lg" | "full"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg" | "xl" | "2xl"; + /** Visual style following Mobbin hierarchy (primary/secondary/outline/ghost + destructive + overlay + accent). @default 'primary' */ + variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive" | "overlay" | "accent"; +} + +/** + * A button allows a user to perform an action, with mouse, touch, and keyboard interactions. + * Children with `slot="icon-start" | "icon-end" | "icon"` render as icons. + */ +const Button = (props: Readonly) => { + const { children, className, isFullWidth, isIconOnly, radius, ref, size, variant, ...rest } = props; -function Button(props: Readonly) { - const { size = "md", variant = "primary", children, className, ...rest } = props; + const baseClassName = classes({ + block: "button", + modifiers: { + variant, + size, + radius, + "full-width": isFullWidth, + "icon-only": isIconOnly, + disabled: rest.isDisabled, + pending: rest.isPending, + }, + }); - const pendingClass = rest.isPending ? "fri-button--pending" : undefined; - const buttonClassName = cn("fri-button", `fri-button--${variant}`, `fri-button--${size}`, pendingClass, className); + const buttonClassName = composeRenderProps(className, (consumerClassName) => clsx(baseClassName, consumerClassName)); return ( - + {composeRenderProps(children, (child, { isPending }) => ( <> {child} - - {isPending && ( - - )} + {isPending && + ); -} +}; + +Button.displayName = "Button"; export { Button }; diff --git a/packages/react/src/components/button/button.stories.tsx b/packages/react/src/components/button/button.stories.tsx index e30e7e8..82a305c 100644 --- a/packages/react/src/components/button/button.stories.tsx +++ b/packages/react/src/components/button/button.stories.tsx @@ -1,5 +1,17 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { + CheckCircleIcon, + InformationCircleIcon, + MoreIcon, + PlusIcon, + SettingsIcon, + ShareIcon, + XCircleIcon, +} from "../icons"; import { Button } from "./Button"; const meta = { @@ -8,67 +20,99 @@ const meta = { tags: ["autodocs"], parameters: { layout: "centered", - docs: { - description: { - component: - "Accessible button component built on React Aria. Supports semantic variants, golden-ratio spacing, and pending state with spinner.", - }, - }, }, args: { children: "Button", - variant: "primary", - size: "md", + isIconOnly: false, + isFullWidth: false, + isDisabled: false, + isPending: false, }, argTypes: { children: { control: "text", - description: "Button content", + description: "Button content (text, icons, or both)", + type: { + name: "other", + value: "ReactNode", + required: true, + }, table: { type: { summary: "ReactNode", }, - category: "Content", + category: "Children", }, }, variant: { control: "select", - options: ["primary"], - description: "Visual style variant", + options: ["primary", "secondary", "outline", "ghost", "destructive", "overlay", "accent"], + description: + "Visual style following Mobbin hierarchy. **Hierarchy:** primary (main CTA — dominant), secondary (paired alt — neutral filled), outline (tertiary — border only), ghost (quaternary — transparent). **Special:** destructive (red — safety-critical like delete), overlay (gray on dark bg — for dark surfaces/media), accent (brand highlight — marketing/promo CTA). Use primary for main action, secondary for cancel pair, outline/ghost for subdued actions, destructive for delete, overlay for dark contexts, accent for promo.", table: { type: { - summary: '"primary"', + summary: '"primary" | "secondary" | "outline" | "ghost" | "destructive" | "overlay" | "accent"', }, defaultValue: { - summary: '"primary"', + summary: "primary", }, - category: "Style", + category: "Style Variants", }, }, size: { control: "select", - options: ["sm", "md", "lg", "xl"], - description: "Button size", + options: ["xs", "sm", "md", "lg", "xl", "2xl"], + description: + "Size scale anchored at md (text-base = formula root x = 1em = 1rem). Heights snap to the gold token grid (28 / 32 / 36 / 40 / 45 / 54 px) so every tier sits crisp on the device pixel grid. **xs** (28px, text-xs) — micro chrome bar, dense table actions. **sm** (32px, text-sm) — toolbars, dense tables, secondary CTAs in cards. **md** (36px, text-base, default) — standard call-to-action across most surfaces, form-aligned with Input md. **lg** (40px, text-lg) — landing pages, marketing CTAs, hero blocks. **xl** (45px, text-xl) — display CTAs, pricing tiers. **2xl** (54px, text-2xl) — splash screens, hero marketing surfaces. Use sm for inline actions and toolbars, md for forms and cards, lg through 2xl for marketing surfaces where the button must read across a wide viewport.", table: { type: { - summary: '"sm" | "md" | "lg" | "xl"', + summary: '"xs" | "sm" | "md" | "lg" | "xl" | "2xl"', }, defaultValue: { - summary: '"md"', + summary: "md", }, - category: "Style", + category: "Style Variants", }, }, - isDisabled: { - control: "boolean", - description: "Whether the button is disabled", + radius: { + control: "select", + options: ["none", "xs", "sm", "md", "lg", "full"], + description: + "Border radius scale shaping the button from a tab to a pill. Fixed-px tokens independent of font-size so buttons align with peer surfaces (Card, Input) by absolute radius across size tiers. **none** — architectural sharp edge (data tables, fixed bars). **xs** (2px) — micro softening for tight admin chrome. **sm** (4px) — subtle softening for tight UIs. **md** (6px, default) — standard CTA roundness, the canonical product button. **lg** (8px) — emphasized soft corners for hero buttons. **full** — fully rounded pill, matches search bars and chip-paired actions. Use md for most buttons, full for chip-paired actions, none inside dense data UIs.", table: { type: { - summary: "boolean", + summary: '"none" | "xs" | "sm" | "md" | "lg" | "full"', }, defaultValue: { - summary: "false", + summary: "md", }, + category: "Style Variants", + }, + }, + isIconOnly: { + control: "boolean", + description: "Whether the button is icon-only (square)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isFullWidth: { + control: "boolean", + description: "Whether the button takes full width of its container", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isDisabled: { + control: "boolean", + description: "Whether the button is disabled", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, category: "State", }, }, @@ -76,12 +120,8 @@ const meta = { control: "boolean", description: "Whether the button shows a loading spinner", table: { - type: { - summary: "boolean", - }, - defaultValue: { - summary: "false", - }, + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, category: "State", }, }, @@ -89,22 +129,11 @@ const meta = { control: false, description: "Handler called when the button is pressed", table: { - type: { - summary: "(e: PressEvent) => void", - }, + type: { summary: "(e: PressEvent) => void" }, category: "Events", }, }, - className: { - control: "text", - description: "Additional CSS classes", - table: { - type: { - summary: "string", - }, - category: "Styling", - }, - }, + className: CLASS_NAME_ARG_TYPE, }, } satisfies Meta; @@ -114,20 +143,200 @@ const Default: Story = { args: { children: "Button", }, - render: (args) => + + + + + + + + + + + + ), +}; + +const OverlayVariant: Story = { + render: (args): React.JSX.Element => ( +
+ +
+ ), }; const Sizes: Story = { - render: () => ( -
- - - - + render: (args): React.JSX.Element => ( +
+ + + + + + + + + +
+ ), +}; + +const Radius: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + + + + + + + +
+ ), +}; + +const WithIcon: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + + + +
+ ), +}; + +const IconOnly: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + + +
), }; -export { Default, Sizes }; +const FullWidth: Story = { + args: { + isFullWidth: true, + children: "Full Width", + }, +}; + +const Disabled: Story = { + args: { + children: "Disabled", + isDisabled: true, + onPress: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /disabled/i }); + + await expect(button).toBeDisabled(); + await userEvent.click(button, { pointerEventsCheck: 0 }); + await expect(args.onPress).not.toHaveBeenCalled(); + }, +}; + +const Pending: Story = { + args: { + children: "Pending", + isPending: true, + }, +}; + +const RenderProps: Story = { + render: (args): React.JSX.Element => ( + + ), +}; + +export { + Default, + Variants, + OverlayVariant, + Sizes, + Radius, + Disabled, + Pending, + FullWidth, + WithIcon, + IconOnly, + RenderProps, +}; export default meta; diff --git a/packages/react/src/components/button/button.test.tsx b/packages/react/src/components/button/button.test.tsx deleted file mode 100644 index 5307435..0000000 --- a/packages/react/src/components/button/button.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { Button } from "./Button"; - -describe("Button", () => { - it("renders with default props", () => { - render(); - const el = screen.getByRole("button", { name: "Click me" }); - expect(el).toBeInTheDocument(); - }); - - it("fires onPress when clicked", async () => { - const onPress = vi.fn(); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button")); - - expect(onPress).toHaveBeenCalledOnce(); - }); - - it("does not fire onPress when disabled", async () => { - const onPress = vi.fn(); - const user = userEvent.setup(); - - render( - , - ); - - await user.click(screen.getByRole("button")); - - expect(onPress).not.toHaveBeenCalled(); - }); - - it("applies variant class", () => { - render(); - const el = screen.getByRole("button"); - expect(el.className).toContain("fri-button--primary"); - }); - - it("applies size classes", () => { - const { unmount: u1 } = render(); - expect(screen.getByRole("button").className).toContain("fri-button--sm"); - u1(); - - const { unmount: u2 } = render(); - expect(screen.getByRole("button").className).toContain("fri-button--md"); - u2(); - - const { unmount: u3 } = render(); - expect(screen.getByRole("button").className).toContain("fri-button--lg"); - u3(); - - render(); - expect(screen.getByRole("button").className).toContain("fri-button--xl"); - }); - - it("applies default variant and size", () => { - render(); - const el = screen.getByRole("button"); - expect(el.className).toContain("fri-button--primary"); - expect(el.className).toContain("fri-button--md"); - }); - - it("merges custom className", () => { - render(); - const el = screen.getByRole("button"); - expect(el.className).toContain("mt-4"); - }); - - it("renders as disabled with aria attribute", () => { - render(); - const el = screen.getByRole("button"); - expect(el).toBeDisabled(); - }); - - it("passes through additional props", () => { - render(); - expect(screen.getByTestId("custom-btn")).toBeInTheDocument(); - }); -}); diff --git a/packages/react/src/components/chip/Chip.tsx b/packages/react/src/components/chip/Chip.tsx new file mode 100644 index 0000000..5209799 --- /dev/null +++ b/packages/react/src/components/chip/Chip.tsx @@ -0,0 +1,128 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode, Ref } from "react"; + +import { Button as AriaButton, type ButtonProps as AriaButtonProps, type PressEvent } from "react-aria-components"; + +import { XIcon } from "../icons"; +import { classes } from "../../utils/classes"; + +export interface ChipProps extends Omit, "children" | "className" | "onClick"> { + /** Chip content — label text plus optional icons via `slot="icon-start" | "icon-end" | "icon"` or an avatar via `slot="avatar"`. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Accessible label for the dismiss button. @default 'Dismiss' */ + dismissLabel?: string; + /** Whether the chip is disabled (dims and removes interactions). @default false */ + isDisabled?: boolean; + /** Whether the chip renders as a square icon-only chip. @default false */ + isIconOnly?: boolean; + /** Whether the chip is in selected state (filter/input chip toggle). @default false */ + isSelected?: boolean; + /** Callback fired when the dismiss (×) button is clicked. Renders a dismiss button when provided. */ + onDismiss?: () => void; + /** Click handler for the whole chip (assist/suggestion chip pattern). When provided, chip renders as a button. */ + onPress?: (event: PressEvent) => void; + /** Border radius scale. @default 'full' */ + radius?: "none" | "xs" | "sm" | "md" | "lg" | "full"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg" | "xl"; + /** Visual style — category colors. @default 'primary' */ + variant?: "primary" | "secondary" | "ghost" | "overlay" | "accent" | "success" | "warning" | "danger" | "info"; +} + +/** + * A chip displays a compact data status, category, or tag. Non-interactive by default. + * Provide `onPress` to make the whole chip clickable (assist/suggestion pattern). + * Provide `onDismiss` to render a dismiss button (filter/input pattern). + * Children with `slot="avatar" | "icon-start" | "icon-end" | "icon"` render as leading/trailing elements. + */ +const Chip = (props: Readonly) => { + const { + children, + className, + dismissLabel, + isDisabled, + isIconOnly, + isSelected, + onDismiss, + onPress, + radius, + ref, + size, + variant, + ...rest + } = props; + + const isInteractive = onPress !== undefined; + const ariaDisabled = isDisabled === true ? true : undefined; + const ariaPressed = isInteractive && isSelected !== undefined ? isSelected : undefined; + const buttonRef = ref as Ref; + const dismissAriaLabel = dismissLabel ?? "Dismiss"; + const handleDismiss = (): void => onDismiss?.(); + + const chipClassName = classes({ + block: "chip", + modifiers: { + variant, + size, + radius, + "icon-only": isIconOnly, + selected: isSelected, + disabled: isDisabled, + interactive: isInteractive, + }, + className, + }); + + const dismissButton = + onDismiss === undefined ? null : ( + + + + ); + + const content = ( + <> + {children} + {dismissButton} + + ); + + if (isInteractive) { + const buttonRest = rest as unknown as Omit; + + return ( + + {content} + + ); + } + + const role = isIconOnly ? "img" : undefined; + + return ( + + {content} + + ); +}; + +Chip.displayName = "Chip"; + +export { Chip }; diff --git a/packages/react/src/components/chip/chip.stories.tsx b/packages/react/src/components/chip/chip.stories.tsx new file mode 100644 index 0000000..df585d8 --- /dev/null +++ b/packages/react/src/components/chip/chip.stories.tsx @@ -0,0 +1,449 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { + BellIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + HeartIcon, + InformationCircleIcon, + StarIcon, + UserIcon, + XCircleIcon, +} from "../icons"; +import { Chip } from "./Chip"; + +const AVATAR_SRC = "https://images.unsplash.com/photo-1729017256081-0271b3fcc08e?w=120&auto=format&fit=crop&q=60"; + +const meta = { + title: "Components/Chip", + component: Chip, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + children: "Chip", + isIconOnly: false, + }, + argTypes: { + children: { + control: "text", + description: "Chip content (text, icon, avatar)", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + variant: { + control: "select", + options: ["primary", "secondary", "ghost", "overlay", "accent", "success", "warning", "danger", "info"], + description: + "Visual style — category colors. **Brand:** primary (default filled), secondary (neutral), ghost (transparent, minimal), overlay (on dark media/scrim), accent (brand highlight). **Status:** success (positive/active), warning (caution/pending), danger (negative/error), info (neutral/notice). Use primary for main labels, ghost for low-emphasis tags, overlay on images/dark bg, success for active state, danger for expired/error, info for notices.", + table: { + type: { + summary: + '"primary" | "secondary" | "ghost" | "overlay" | "accent" | "success" | "warning" | "danger" | "info"', + }, + defaultValue: { + summary: "primary", + }, + category: "Style Variants", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg", "xl"], + description: + "Size scale on the display-role rhythm — anchored at text-sm (14px) so chips sit one Tailwind step below form-aligned Button and Input at the same tier name. Geometry scales via em formulas — height, padding, and gap recompute proportionally per tier; only font-size shifts across tiers. **sm** (text-xs) — dense tag clouds, filter rows, table cell labels. **md** (text-sm, default) — standard tags and filter chips paired with Input md inline. **lg** (text-base) — emphasized status pills or hero filters paired with Input lg. **xl** (text-lg) — large display chips for marketing surfaces or status banners. Use md for the typical tag UI, sm in dense list contexts, lg/xl for marketing emphasis.", + table: { + type: { summary: '"sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + radius: { + control: "select", + options: ["none", "xs", "sm", "md", "lg", "full"], + description: + "Border radius scale shaping the chip from a tab to a pill. Fixed-px tokens independent of font-size so chips align with peer surfaces (Button, Card) by absolute radius. **none** — sharp tab look (admin dashboards). **xs** (2px) — micro softening for tight chip rows. **sm** (4px) — subtle softening. **md** (6px) — standard rounded tag. **lg** (8px) — emphasized soft corners. **full** (default) — pill, the canonical chip shape across Material, Apple, and Mobbin. Use full for the typical tag UI, md for square-ish status chips, none in admin or data UIs where chips align with table edges.", + table: { + type: { summary: '"none" | "xs" | "sm" | "md" | "lg" | "full"' }, + defaultValue: { summary: "full" }, + category: "Style Variants", + }, + }, + isIconOnly: { + control: "boolean", + description: "Whether the chip is icon-only (square)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isSelected: { + control: "boolean", + description: "Whether the chip is in selected state (filter/input chip toggle — adds ring)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + isDisabled: { + control: "boolean", + description: "Whether the chip is disabled (dims and blocks interactions, including dismiss button)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + onPress: { + control: false, + description: + "Click handler for the whole chip (assist/suggestion/filter pattern). When provided, chip renders as a button with hover/focus/pressed states.", + table: { + type: { summary: "(e: PressEvent) => void" }, + category: "Events", + }, + }, + onDismiss: { + control: false, + description: + "Callback fired when the dismiss (×) button is clicked. When provided, renders a dismiss button at the trailing end (filter/input chip pattern).", + table: { + type: { summary: "() => void" }, + category: "Events", + }, + }, + dismissLabel: { + control: "text", + description: "Accessible label for the dismiss button", + table: { + type: { summary: "string" }, + defaultValue: { summary: '"Dismiss"' }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + args: { + children: "Chip", + }, +}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+ + Primary + + + + Secondary + + + + Ghost + + + + Accent + + + + Success + + + + Warning + + + + Danger + + + + Info + +
+ ), +}; + +const OverlayVariant: Story = { + render: (args): React.JSX.Element => ( +
+ + Overlay + +
+ ), +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ + Small + + + + Medium + + + + Large + + + + Extra Large + +
+ ), +}; + +const Radius: Story = { + render: (args): React.JSX.Element => ( +
+ + None + + + + Extra Small + + + + Small + + + + Medium + + + + Large + + + + Full + +
+ ), +}; + +const WithIcon: Story = { + render: (args): React.JSX.Element => ( +
+ + + Featured + + + + + Verified + + + + + Pending + + + + + Expired + + + + + Beta + +
+ ), +}; + +const WithAvatar: Story = { + render: (args): React.JSX.Element => ( +
+ + + {"Colm Tuite"} + + + + + Guest User + +
+ ), +}; + +const IconOnly: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + + + + + + + +
+ ), +}; + +const Selected: Story = { + args: { + isSelected: true, + }, + render: (args): React.JSX.Element => ( +
+ + Primary + + + + Secondary + + + + Ghost + + + + Success + + + + Info + +
+ ), +}; + +const Clickable: Story = { + args: { + onPress: (): void => {}, + }, + render: (args): React.JSX.Element => ( +
+ + Assist + + + + + Suggestion + + + + Filter + +
+ ), +}; + +const Dismissible: Story = { + args: { + onDismiss: (): void => {}, + }, + render: (args): React.JSX.Element => ( +
+ + React + + + + TypeScript + + + + + {"Colm Tuite"} + + + + Tailwind + +
+ ), +}; + +const FilterChip: Story = { + render: (args): React.JSX.Element => ( +
+ {}} {...args}> + All + + + {}} isSelected {...args}> + Design + + + {}} onDismiss={(): void => {}} isSelected {...args}> + Engineering + + + {}} {...args}> + Marketing + +
+ ), +}; + +const Disabled: Story = { + args: { + isDisabled: true, + }, + render: (args): React.JSX.Element => ( +
+ + Primary + + + {}} {...args}> + Dismissible + + + {}} {...args}> + Clickable + +
+ ), +}; + +export { + Default, + Variants, + OverlayVariant, + Sizes, + Radius, + Selected, + Disabled, + Clickable, + WithIcon, + WithAvatar, + IconOnly, + Dismissible, + FilterChip, +}; + +export default meta; diff --git a/packages/react/src/components/chip/index.ts b/packages/react/src/components/chip/index.ts new file mode 100644 index 0000000..55330cd --- /dev/null +++ b/packages/react/src/components/chip/index.ts @@ -0,0 +1,2 @@ +export { Chip } from "./Chip"; +export type { ChipProps } from "./Chip"; diff --git a/packages/react/src/components/description/Description.tsx b/packages/react/src/components/description/Description.tsx new file mode 100644 index 0000000..fcc84ce --- /dev/null +++ b/packages/react/src/components/description/Description.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { Text as AriaText } from "react-aria-components"; + +import { classes } from "../../utils/classes"; + +export interface DescriptionProps extends Omit< + ComponentPropsWithRef, + "children" | "className" | "slot" +> { + /** Helper text content describing the paired form field. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the description is disabled (dims and removes pointer events). @default false */ + isDisabled?: boolean; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; +} + +/** + * Helper text describing a form field, paired with a Label and input. + */ +const Description = (props: Readonly) => { + const { children, className, isDisabled, ref, size, ...rest } = props; + + const descriptionClassName = classes({ + block: "description", + modifiers: { + size, + disabled: isDisabled, + }, + className, + }); + + return ( + + {children} + + ); +}; + +Description.displayName = "Description"; + +export { Description }; diff --git a/packages/react/src/components/description/description.stories.tsx b/packages/react/src/components/description/description.stories.tsx new file mode 100644 index 0000000..9401a8a --- /dev/null +++ b/packages/react/src/components/description/description.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Description } from "./Description"; + +const meta = { + title: "Components/Description", + component: Description, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + children: "Description", + }, + argTypes: { + children: { + control: "text", + description: "Description content", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + description: + "Size tier of the description, paired with the matching Input/Textarea tier so the entire form-field reads at a consistent visual weight. Font shifts on the Tailwind text-X scale per tier. **sm** (text-sm) — dense forms, helper text below compact inputs, tooltip-like supporting copy. **md** (text-base, default) — standard form fields and most product UIs. **lg** (text-lg) — emphasized supporting text below hero forms or long-form sections. Use sm in dense table or filter UIs, md for nearly all forms, lg when the description sits below a hero or onboarding input.", + table: { + type: { summary: '"sm" | "md" | "lg"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + isDisabled: { + control: "boolean", + description: "Dims the description and disables pointer events", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = {}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ + Small Description + + + + Medium Description + + + + Large Description + +
+ ), +}; + +const Disabled: Story = { + args: { + children: "Disabled Description", + isDisabled: true, + }, +}; + +export { Default, Sizes, Disabled }; + +export default meta; diff --git a/packages/react/src/components/description/index.ts b/packages/react/src/components/description/index.ts new file mode 100644 index 0000000..8ff39ab --- /dev/null +++ b/packages/react/src/components/description/index.ts @@ -0,0 +1,2 @@ +export { Description } from "./Description"; +export type { DescriptionProps } from "./Description"; diff --git a/packages/react/src/components/field-error/FieldError.tsx b/packages/react/src/components/field-error/FieldError.tsx new file mode 100644 index 0000000..8856aba --- /dev/null +++ b/packages/react/src/components/field-error/FieldError.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { FieldError as AriaFieldError } from "react-aria-components"; + +import { classes } from "../../utils/classes"; + +export interface FieldErrorProps extends Omit, "children" | "className"> { + /** Error message content shown when the paired form field is invalid. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the field error is disabled. @default false */ + isDisabled?: boolean; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; +} + +/** + * An error message for a form field. Renders inside TextField (or similar) and auto-shows when the field is invalid. + */ +const FieldError = (props: Readonly) => { + const { children, className, isDisabled, ref, size, ...rest } = props; + + const fieldErrorClassName = classes({ + block: "field-error", + modifiers: { + size, + disabled: isDisabled, + }, + className, + }); + + return ( + + {children} + + ); +}; + +FieldError.displayName = "FieldError"; + +export { FieldError }; diff --git a/packages/react/src/components/field-error/field-error.stories.tsx b/packages/react/src/components/field-error/field-error.stories.tsx new file mode 100644 index 0000000..6165476 --- /dev/null +++ b/packages/react/src/components/field-error/field-error.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { TextField } from "react-aria-components"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { FieldError } from "./FieldError"; + +const meta: Meta = { + title: "Components/FieldError", + component: FieldError, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + children: "Error message", + }, + argTypes: { + children: { + control: "text", + description: "Error message content shown when the parent field is invalid.", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + description: + "Size tier of the field error message, paired with the matching Input/Textarea tier so the entire form-field reads at a consistent visual weight. Font shifts on the Tailwind text-X scale per tier. **sm** (text-sm) — dense forms and secondary helpers. **md** (text-base, default) — standard form fields. **lg** (text-lg) — emphasized or large form sections. Use md for the majority of forms, sm for compact inline validation, lg only when the form field itself uses a larger size.", + table: { + type: { summary: '"sm" | "md" | "lg"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + isDisabled: { + control: "boolean", + description: + "Whether the field error is disabled (dims and removes pointer events). Match the parent field's disabled state so the error visually aligns with the rest of the form control.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, + decorators: [ + (Story): React.JSX.Element => ( + + + + ), + ], +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = {}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ + + Small FieldError + + + + + + Medium FieldError + + + + + + Large FieldError + + +
+ ), + decorators: [(Story): React.JSX.Element => ], +}; + +const Disabled: Story = { + args: { + children: "Disabled FieldError", + isDisabled: true, + }, +}; + +export { Default, Sizes, Disabled }; + +export default meta; diff --git a/packages/react/src/components/field-error/index.ts b/packages/react/src/components/field-error/index.ts new file mode 100644 index 0000000..fc79c0b --- /dev/null +++ b/packages/react/src/components/field-error/index.ts @@ -0,0 +1,2 @@ +export { FieldError } from "./FieldError"; +export type { FieldErrorProps } from "./FieldError"; diff --git a/packages/react/src/components/icons/ArrowRightIcon.tsx b/packages/react/src/components/icons/ArrowRightIcon.tsx new file mode 100644 index 0000000..988dd4c --- /dev/null +++ b/packages/react/src/components/icons/ArrowRightIcon.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithRef } from "react"; + +const ArrowRightIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + + ); +}; + +ArrowRightIcon.displayName = "ArrowRightIcon"; + +export { ArrowRightIcon }; diff --git a/packages/react/src/components/icons/BellIcon.tsx b/packages/react/src/components/icons/BellIcon.tsx new file mode 100644 index 0000000..0bc625e --- /dev/null +++ b/packages/react/src/components/icons/BellIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const BellIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +BellIcon.displayName = "BellIcon"; + +export { BellIcon }; diff --git a/packages/react/src/components/icons/CheckCircleIcon.tsx b/packages/react/src/components/icons/CheckCircleIcon.tsx new file mode 100644 index 0000000..69b1dec --- /dev/null +++ b/packages/react/src/components/icons/CheckCircleIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const CheckCircleIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +CheckCircleIcon.displayName = "CheckCircleIcon"; + +export { CheckCircleIcon }; diff --git a/packages/react/src/components/icons/ClipboardIcon.tsx b/packages/react/src/components/icons/ClipboardIcon.tsx new file mode 100644 index 0000000..43d7200 --- /dev/null +++ b/packages/react/src/components/icons/ClipboardIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const ClipboardIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +ClipboardIcon.displayName = "ClipboardIcon"; + +export { ClipboardIcon }; diff --git a/packages/react/src/components/icons/ExclamationTriangleIcon.tsx b/packages/react/src/components/icons/ExclamationTriangleIcon.tsx new file mode 100644 index 0000000..0e4508a --- /dev/null +++ b/packages/react/src/components/icons/ExclamationTriangleIcon.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithRef } from "react"; + +const ExclamationTriangleIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + + ); +}; + +ExclamationTriangleIcon.displayName = "ExclamationTriangleIcon"; + +export { ExclamationTriangleIcon }; diff --git a/packages/react/src/components/icons/GitHubIcon.tsx b/packages/react/src/components/icons/GitHubIcon.tsx new file mode 100644 index 0000000..6e865d4 --- /dev/null +++ b/packages/react/src/components/icons/GitHubIcon.tsx @@ -0,0 +1,11 @@ +import type { ComponentPropsWithRef } from "react"; + +const GitHubIcon = (props: Readonly & { slot?: string }>) => ( + + + +); + +GitHubIcon.displayName = "GitHubIcon"; + +export { GitHubIcon }; diff --git a/packages/react/src/components/icons/HeartIcon.tsx b/packages/react/src/components/icons/HeartIcon.tsx new file mode 100644 index 0000000..f5e7c1d --- /dev/null +++ b/packages/react/src/components/icons/HeartIcon.tsx @@ -0,0 +1,25 @@ +import type { ComponentPropsWithRef } from "react"; + +const HeartIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + ); +}; + +HeartIcon.displayName = "HeartIcon"; + +export { HeartIcon }; diff --git a/packages/react/src/components/icons/InformationCircleIcon.tsx b/packages/react/src/components/icons/InformationCircleIcon.tsx new file mode 100644 index 0000000..6098ef1 --- /dev/null +++ b/packages/react/src/components/icons/InformationCircleIcon.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithRef } from "react"; + +const InformationCircleIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + + ); +}; + +InformationCircleIcon.displayName = "InformationCircleIcon"; + +export { InformationCircleIcon }; diff --git a/packages/react/src/components/icons/MoreIcon.tsx b/packages/react/src/components/icons/MoreIcon.tsx new file mode 100644 index 0000000..d13d389 --- /dev/null +++ b/packages/react/src/components/icons/MoreIcon.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithRef } from "react"; + +const MoreIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + + ); +}; + +MoreIcon.displayName = "MoreIcon"; + +export { MoreIcon }; diff --git a/packages/react/src/components/icons/PlusIcon.tsx b/packages/react/src/components/icons/PlusIcon.tsx new file mode 100644 index 0000000..de92610 --- /dev/null +++ b/packages/react/src/components/icons/PlusIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const PlusIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +PlusIcon.displayName = "PlusIcon"; + +export { PlusIcon }; diff --git a/packages/react/src/components/icons/SettingsIcon.tsx b/packages/react/src/components/icons/SettingsIcon.tsx new file mode 100644 index 0000000..8af68b0 --- /dev/null +++ b/packages/react/src/components/icons/SettingsIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const SettingsIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +SettingsIcon.displayName = "SettingsIcon"; + +export { SettingsIcon }; diff --git a/packages/react/src/components/icons/ShareIcon.tsx b/packages/react/src/components/icons/ShareIcon.tsx new file mode 100644 index 0000000..7ecd6f8 --- /dev/null +++ b/packages/react/src/components/icons/ShareIcon.tsx @@ -0,0 +1,29 @@ +import type { ComponentPropsWithRef } from "react"; + +const ShareIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + + + + ); +}; + +ShareIcon.displayName = "ShareIcon"; + +export { ShareIcon }; diff --git a/packages/react/src/components/icons/Spinner.tsx b/packages/react/src/components/icons/Spinner.tsx index 130115a..74832a7 100644 --- a/packages/react/src/components/icons/Spinner.tsx +++ b/packages/react/src/components/icons/Spinner.tsx @@ -1,16 +1,43 @@ -"use client"; +import { type ComponentPropsWithRef, useId } from "react"; -import type { SVGProps } from "react"; - -function Spinner(props: Readonly>) { - const { className, ...rest } = props; +const Spinner = (props: Readonly & { slot?: string }>) => { + const id = useId(); + const grad1 = `spinner-grad-1-${id}`; + const grad2 = `spinner-grad-2-${id}`; + const fillGrad1 = `url(#${grad1})`; + const fillGrad2 = `url(#${grad2})`; return ( - - - + ); -} +}; + +Spinner.displayName = "Spinner"; export { Spinner }; diff --git a/packages/react/src/components/icons/StarIcon.tsx b/packages/react/src/components/icons/StarIcon.tsx new file mode 100644 index 0000000..a2a144e --- /dev/null +++ b/packages/react/src/components/icons/StarIcon.tsx @@ -0,0 +1,25 @@ +import type { ComponentPropsWithRef } from "react"; + +const StarIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + ); +}; + +StarIcon.displayName = "StarIcon"; + +export { StarIcon }; diff --git a/packages/react/src/components/icons/UserIcon.tsx b/packages/react/src/components/icons/UserIcon.tsx new file mode 100644 index 0000000..ad3377f --- /dev/null +++ b/packages/react/src/components/icons/UserIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const UserIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +UserIcon.displayName = "UserIcon"; + +export { UserIcon }; diff --git a/packages/react/src/components/icons/XCircleIcon.tsx b/packages/react/src/components/icons/XCircleIcon.tsx new file mode 100644 index 0000000..c2e903f --- /dev/null +++ b/packages/react/src/components/icons/XCircleIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentPropsWithRef } from "react"; + +const XCircleIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + + ); +}; + +XCircleIcon.displayName = "XCircleIcon"; + +export { XCircleIcon }; diff --git a/packages/react/src/components/icons/XIcon.tsx b/packages/react/src/components/icons/XIcon.tsx new file mode 100644 index 0000000..ea3110d --- /dev/null +++ b/packages/react/src/components/icons/XIcon.tsx @@ -0,0 +1,25 @@ +import type { ComponentPropsWithRef } from "react"; + +const XIcon = (props: Readonly & { slot?: string }>) => { + const { className, ref, ...rest } = props; + + return ( + + + + ); +}; + +XIcon.displayName = "XIcon"; + +export { XIcon }; diff --git a/packages/react/src/components/icons/icons.stories.tsx b/packages/react/src/components/icons/icons.stories.tsx new file mode 100644 index 0000000..edb683a --- /dev/null +++ b/packages/react/src/components/icons/icons.stories.tsx @@ -0,0 +1,33 @@ +import type { ComponentType, SVGProps } from "react"; + +import type { Meta, StoryObj } from "@storybook/react"; + +import * as Icons from "./index"; + +const iconList = Object.entries(Icons) as [string, ComponentType>][]; + +const meta = { + title: "Components/Icons", + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +type Story = StoryObj; + +const Gallery: Story = { + render: (): React.JSX.Element => ( +
+ {iconList.map(([name, Icon]) => ( + + + + ))} +
+ ), +}; + +export { Gallery }; + +export default meta; diff --git a/packages/react/src/components/icons/index.ts b/packages/react/src/components/icons/index.ts index 47cb73c..d7ae2e7 100644 --- a/packages/react/src/components/icons/index.ts +++ b/packages/react/src/components/icons/index.ts @@ -1 +1,17 @@ +export { ArrowRightIcon } from "./ArrowRightIcon"; +export { BellIcon } from "./BellIcon"; +export { CheckCircleIcon } from "./CheckCircleIcon"; +export { ClipboardIcon } from "./ClipboardIcon"; +export { ExclamationTriangleIcon } from "./ExclamationTriangleIcon"; +export { GitHubIcon } from "./GitHubIcon"; +export { HeartIcon } from "./HeartIcon"; +export { InformationCircleIcon } from "./InformationCircleIcon"; +export { MoreIcon } from "./MoreIcon"; +export { PlusIcon } from "./PlusIcon"; +export { SettingsIcon } from "./SettingsIcon"; +export { ShareIcon } from "./ShareIcon"; export { Spinner } from "./Spinner"; +export { StarIcon } from "./StarIcon"; +export { UserIcon } from "./UserIcon"; +export { XCircleIcon } from "./XCircleIcon"; +export { XIcon } from "./XIcon"; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index ee0456e..dc107f7 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,24 +1,21 @@ -export { isRTL, useLocale, useFilter, Virtualizer, TableLayout, ListLayout } from "react-aria-components"; -export { getLocalizationScript } from "react-aria-components/i18n"; - -export { parseColor } from "react-aria-components"; - -export type { - Direction, - HoverEvent, - Key, - KeyboardEvent, - Orientation, - PointerType, - PressEvent, - RangeValue, - RouterConfig, - Selection, - ValidationResult, -} from "@react-types/shared"; - -export type { TimeValue, DateValue, DateRange, SortDescriptor } from "react-aria-components"; -export type { Color, ColorFormat, ColorSpace, ColorChannel, ColorChannelRange, ColorAxes } from "@react-types/color"; -export { Collection, ListBoxLoadMoreItem, RouterProvider, I18nProvider } from "react-aria-components"; +export { RouterProvider } from "react-aria-components"; + +export * from "./avatar"; + +export * from "./avatar-group"; + +export * from "./badge"; export * from "./button"; + +export * from "./chip"; + +export * from "./icons"; + +export * from "./input"; + +export * from "./signal-dot"; + +export * from "./surface"; + +export * from "./textarea"; diff --git a/packages/react/src/components/input/Input.tsx b/packages/react/src/components/input/Input.tsx new file mode 100644 index 0000000..47bff45 --- /dev/null +++ b/packages/react/src/components/input/Input.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { ComponentPropsWithRef } from "react"; + +import { Input as AriaInput } from "react-aria-components"; + +import { classes } from "../../utils/classes"; + +export interface InputProps extends Omit, "className" | "size"> { + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the input stretches to fill its container width. @default false */ + isFullWidth?: boolean; + /** Border radius scale. @default 'md' */ + radius?: "none" | "xs" | "sm" | "md" | "lg" | "full"; + /** Size scale. @default 'md' */ + size?: "xs" | "sm" | "md" | "lg" | "xl"; + /** Visual style. @default 'primary' */ + variant?: "primary" | "secondary" | "overlay"; +} + +/** + * A styled text input primitive. Use native HTML attrs (`disabled`, `readOnly`, `required`, `aria-invalid`) for state. + */ +const Input = (props: Readonly) => { + const { className, isFullWidth, radius, ref, size, variant, ...rest } = props; + + const inputClassName = classes({ + block: "input", + modifiers: { + variant, + size, + radius, + "full-width": isFullWidth, + }, + className, + }); + + return ; +}; + +Input.displayName = "Input"; + +export { Input }; diff --git a/packages/react/src/components/input/index.ts b/packages/react/src/components/input/index.ts new file mode 100644 index 0000000..96a8394 --- /dev/null +++ b/packages/react/src/components/input/index.ts @@ -0,0 +1,2 @@ +export { Input } from "./Input"; +export type { InputProps } from "./Input"; diff --git a/packages/react/src/components/input/input.stories.tsx b/packages/react/src/components/input/input.stories.tsx new file mode 100644 index 0000000..376f588 --- /dev/null +++ b/packages/react/src/components/input/input.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Input } from "./Input"; + +const meta = { + title: "Components/Implementation/Input", + component: Input, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + placeholder: "Type here", + }, + argTypes: { + placeholder: { + control: "text", + description: "Placeholder text shown when input is empty", + table: { + type: { summary: "string" }, + category: "Children", + }, + }, + variant: { + control: "select", + options: ["primary", "secondary", "overlay"], + description: + "Visual style of the input field. **primary** (default) — bordered white field for standard forms. **secondary** — filled muted background with no border, works well inside cards or grouped panels. **overlay** — light field on dark media or scrim, for inputs placed over images or dark backgrounds. Use primary for main forms, secondary for nested inputs inside cards, overlay on dark or media contexts.", + table: { + type: { summary: '"primary" | "secondary" | "overlay"' }, + defaultValue: { summary: "primary" }, + category: "Style Variants", + }, + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg", "xl"], + description: + "Size scale anchored at md (text-base = formula root x = 1em = 1rem). Heights snap to the gold token grid (28 / 32 / 36 / 40 / 45 px). **xs** (28px, text-xs) — micro filter chips inside dense data tables. **sm** (32px, text-sm) — inline filters, table cells, compact admin forms. **md** (36px, text-base, default) — standard form fields, paired with Button md. **lg** (40px, text-lg) — hero search, single-input pages where the input is the primary action and must read at a distance. **xl** (45px, text-xl) — display search bars on marketing landing pages. Use sm for dense UI, md for most forms, lg/xl for emphasized single-input pages.", + table: { + type: { summary: '"xs" | "sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + radius: { + control: "select", + options: ["none", "xs", "sm", "md", "lg", "full"], + description: + "Border radius scale of the input. Fixed-px tokens independent of font-size so inputs align with peer surfaces (Button, Card) by absolute radius across size tiers. **none** — sharp corners for data tables and admin forms. **xs** (2px) — micro softening for tight admin chrome. **sm** (4px) — subtle rounding for tight UIs. **md** (6px, default) — standard form input. **lg** (8px) — emphasized soft corners for hero forms and primary single-input pages. **full** — pill shape, the canonical search-bar look. Use md as the default form input, full for search-bar contexts, none when inputs sit flush inside table rows.", + table: { + type: { summary: '"none" | "xs" | "sm" | "md" | "lg" | "full"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + isFullWidth: { + control: "boolean", + description: "Whether the input stretches to fill its container width", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + disabled: { + control: "boolean", + description: + "Native HTML `disabled` attribute. Dims the input and removes pointer events. Forwarded to the underlying `` element. Inside TextField, React Aria propagates this via slot context automatically.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + readOnly: { + control: "boolean", + description: + "Native HTML `readOnly` attribute. Value is selectable but not editable. Forwarded to the underlying `` element. Inside TextField, React Aria propagates this via slot context automatically.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + required: { + control: "boolean", + description: + "Native HTML `required` attribute. Signals required-field semantics (use Label `isRequired` for the visible asterisk). Does not change the input's border or ring.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + "aria-invalid": { + control: "boolean", + description: + "ARIA `aria-invalid` attribute. Activates error styling (red border + danger ring on focus). Pair with a sibling `FieldError` so the message reads out to assistive tech.", + table: { + type: { summary: '"true" | "false" | boolean' }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = {}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+ + +
+ ), +}; + +const OverlayVariant: Story = { + render: (args): React.JSX.Element => ( +
+ +
+ ), +}; + +const Sizes: Story = { + render: (): React.JSX.Element => ( +
+ + + +
+ ), +}; + +const Radius: Story = { + render: (): React.JSX.Element => ( +
+ + + + + + +
+ ), +}; + +const Invalid: Story = { + args: { + placeholder: "Invalid value", + "aria-invalid": "true", + }, +}; + +const Disabled: Story = { + args: { + placeholder: "Disabled", + disabled: true, + }, +}; + +const ReadOnly: Story = { + args: { + defaultValue: "Read-only value", + readOnly: true, + }, +}; + +const Required: Story = { + args: { + placeholder: "Required field", + required: true, + }, +}; + +const FullWidth: Story = { + args: { + isFullWidth: true, + placeholder: "Full width", + }, +}; + +export { Default, Variants, OverlayVariant, Sizes, Radius, Invalid, Disabled, ReadOnly, Required, FullWidth }; + +export default meta; diff --git a/packages/react/src/components/label/Label.tsx b/packages/react/src/components/label/Label.tsx new file mode 100644 index 0000000..baf0d60 --- /dev/null +++ b/packages/react/src/components/label/Label.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { Label as AriaLabel } from "react-aria-components"; + +import { classes } from "../../utils/classes"; + +export interface LabelProps extends Omit, "children" | "className"> { + /** Label text content identifying the paired form field. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the label is disabled (dims and removes pointer events). @default false */ + isDisabled?: boolean; + /** Whether the label is in error state (text in danger color). @default false */ + isInvalid?: boolean; + /** Whether the field is required (shows red asterisk). @default false */ + isRequired?: boolean; + /** Text shown in muted gray when field is NOT required (e.g. '(Optional)'). Ignored when isRequired is true. @default undefined */ + optionalMessage?: string; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; + /** Font weight. @default 'medium' */ + weight?: "normal" | "medium" | "semibold"; +} + +/** + * A label identifies a form field, paired with `htmlFor` to link to the input. + * Use `isRequired` to show the asterisk or `optionalMessage` to mark optional fields. + */ +const Label = (props: Readonly) => { + const { children, className, isDisabled, isInvalid, isRequired, optionalMessage, ref, size, weight, ...rest } = props; + + const labelClassName = classes({ + block: "label", + modifiers: { + size, + weight, + invalid: isInvalid, + disabled: isDisabled, + }, + className, + }); + + return ( + + {children} + + {isRequired ? ( + + ) : ( + optionalMessage && {optionalMessage} + )} + + ); +}; + +Label.displayName = "Label"; + +export { Label }; diff --git a/packages/react/src/components/label/index.ts b/packages/react/src/components/label/index.ts new file mode 100644 index 0000000..c151a94 --- /dev/null +++ b/packages/react/src/components/label/index.ts @@ -0,0 +1,2 @@ +export { Label } from "./Label"; +export type { LabelProps } from "./Label"; diff --git a/packages/react/src/components/label/label.stories.tsx b/packages/react/src/components/label/label.stories.tsx new file mode 100644 index 0000000..02adef8 --- /dev/null +++ b/packages/react/src/components/label/label.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Label } from "./Label"; + +const meta = { + title: "Components/Label", + component: Label, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + children: "Label", + size: "md", + weight: "medium", + isRequired: false, + isInvalid: false, + isDisabled: false, + }, + argTypes: { + children: { + control: "text", + description: "Label content", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + description: + "Size tier of the label, paired with the matching Input/Textarea tier so the entire form-field reads at a consistent visual weight. Font shifts on the Tailwind text-X scale per tier. **sm** (text-sm) — dense forms, secondary labels below subtle inputs, table cell headers. **md** (text-base, default) — standard form fields paired with Input md and most product UIs. **lg** (text-lg) — emphasized labels above hero forms, settings sections, or primary onboarding inputs. Use md as the default product form label, sm in dense table or filter UIs, lg when the label sits above a single hero input.", + table: { + type: { summary: '"sm" | "md" | "lg"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + weight: { + control: "select", + options: ["normal", "medium", "semibold"], + description: + "Font weight controlling label emphasis. **normal** (400) — subtle labels for secondary forms or dense table headers. **medium** (500, default) — standard form-field emphasis, the canonical product label. **semibold** (600) — strong emphasis for section headings or grouped legend titles inside a fieldset. Use medium for most labels, normal in dense data UIs where the label is informational, semibold when the label doubles as a section heading.", + table: { + type: { summary: '"normal" | "medium" | "semibold"' }, + defaultValue: { summary: "medium" }, + category: "Style Variants", + }, + }, + isRequired: { + control: "boolean", + description: "Shows red asterisk to indicate required field", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isInvalid: { + control: "boolean", + description: "Error state — label text in danger color", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + isDisabled: { + control: "boolean", + description: "Dims the label and disables pointer events", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + htmlFor: { + control: "text", + description: "ID of the form field this label is associated with", + table: { + type: { summary: "string" }, + category: "Children", + }, + }, + optionalMessage: { + control: "text", + description: + "Optional text shown in muted gray when field is NOT required (e.g. '(Optional)'). Ignored when `isRequired` is true.", + table: { + type: { summary: "string" }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = {}; + +const Required: Story = { + args: { + children: "Required Label", + isRequired: true, + }, +}; + +const Optional: Story = { + args: { + children: "Optional Label", + optionalMessage: "(Optional)", + }, +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + +
+ ), +}; + +const Weights: Story = { + render: (args): React.JSX.Element => ( +
+ + + + + +
+ ), +}; + +const Invalid: Story = { + args: { + children: "Invalid Label", + isInvalid: true, + }, +}; + +const Disabled: Story = { + args: { + children: "Disabled Label", + isDisabled: true, + }, +}; + +export { Default, Sizes, Weights, Required, Optional, Invalid, Disabled }; + +export default meta; diff --git a/packages/react/src/components/signal-dot/SignalDot.tsx b/packages/react/src/components/signal-dot/SignalDot.tsx new file mode 100644 index 0000000..93e8aba --- /dev/null +++ b/packages/react/src/components/signal-dot/SignalDot.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { classes } from "../../utils/classes"; + +export interface SignalDotProps extends Omit, "children" | "className"> { + /** Anchor element (Avatar, Button, Icon) the dot is positioned at the corner of. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the halo border (page-background colored, separates the dot from the anchor) is hidden — set to `true` when the dot sits on a matching surface. @default false */ + isBorderless?: boolean; + /** Placement corner relative to the wrapped anchor. @default 'top-right' */ + placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; + /** Visual style. @default 'primary' */ + variant?: "primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info" | "overlay"; +} + +/** + * A presence dot anchored at the corner of an element. Communicates binary status — + * online/offline, unread/read, new/seen — without text. Decorative by default + * (`aria-hidden`); pair the parent anchor with `aria-label` when the dot carries meaning. + */ +const SignalDot = (props: Readonly) => { + const { children, className, isBorderless, placement, ref, size, variant, ...rest } = props; + + const wrapperClassName = classes({ + block: "signal-dot", + modifiers: { + variant, + size, + placement, + borderless: isBorderless, + }, + className, + }); + + return ( + + {children} + + + ); +}; + +SignalDot.displayName = "SignalDot"; + +export { SignalDot }; diff --git a/packages/react/src/components/signal-dot/index.ts b/packages/react/src/components/signal-dot/index.ts new file mode 100644 index 0000000..91124e6 --- /dev/null +++ b/packages/react/src/components/signal-dot/index.ts @@ -0,0 +1,2 @@ +export { SignalDot } from "./SignalDot"; +export type { SignalDotProps } from "./SignalDot"; diff --git a/packages/react/src/components/signal-dot/signal-dot.stories.tsx b/packages/react/src/components/signal-dot/signal-dot.stories.tsx new file mode 100644 index 0000000..c1fd3bb --- /dev/null +++ b/packages/react/src/components/signal-dot/signal-dot.stories.tsx @@ -0,0 +1,157 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import AVATAR_1 from "@fried-ui/assets/story/avatar-1.jpg"; + +import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; +import { SignalDot } from "./SignalDot"; + +const meta: Meta = { + title: "Components/SignalDot", + component: SignalDot, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + children: { + control: false, + description: + "Anchor element (Avatar, Button, Icon) the dot positions at the corner of. Optional — when omitted, the dot renders against an empty wrapper.", + type: { + name: "other", + value: "ReactNode", + required: false, + }, + table: { + type: { summary: "ReactNode" }, + category: "Children", + }, + }, + className: { + control: "text", + description: "Additional CSS classes appended to the wrapper.", + table: { + type: { summary: "string" }, + category: "Styling", + }, + }, + variant: { + control: "select", + options: ["primary", "secondary", "accent", "success", "warning", "danger", "info", "overlay"], + description: + "Visual style — semantic dot color. **Brand:** primary (default focus), secondary (neutral gray), accent (brand highlight). **Status:** success (online/active), warning (away/pending), danger (busy/error), info (notice/unread). **Surface:** overlay (on dark media or scrim). Use success for online presence, danger for busy or unread alerts, warning for away states, info for neutral notifications, primary as the generic attention dot.", + table: { + type: { + summary: '"primary" | "secondary" | "accent" | "success" | "warning" | "danger" | "info" | "overlay"', + }, + defaultValue: { summary: "primary" }, + category: "Style Variants", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + description: + "Size scale of the dot. **sm** (size-1, 4px) — dense lists, compact toolbars, navigation icons. **md** (size-1.5, 6px, default) — standard avatar presence, matches Material Design 3 small badge. **lg** (size-2, 8px) — emphasized presence, hero avatars, profile headers where the status reads at a glance. Pair the size with the parent anchor scale: sm with avatar sm, md with avatar md, lg with avatar lg or larger.", + table: { + type: { summary: '"sm" | "md" | "lg"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + placement: { + control: "select", + options: ["top-right", "top-left", "bottom-right", "bottom-left"], + description: + "Placement corner relative to the wrapped anchor. **top-right** (default) — standard notification or unread placement, matches Material Design 3 upper trailing edge. **top-left** — mirrors the default for right-to-left layouts or when the right edge holds a primary action. **bottom-right** — common for online-presence dots on avatars. **bottom-left** — used when the bottom-right is reserved for an action chip. Choose top-right for alerts and bottom-right for presence.", + table: { + type: { summary: '"top-right" | "top-left" | "bottom-right" | "bottom-left"' }, + defaultValue: { summary: "top-right" }, + category: "Style Variants", + }, + }, + isBorderless: { + control: "boolean", + description: + "Whether the halo border (page-background colored, separates the dot from the anchor) is hidden — set to `true` when the dot sits on a matching surface.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + render: (args): React.JSX.Element => ( + + + + CT + + + ), +}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+ {(["primary", "secondary", "accent", "success", "warning", "danger", "info"] as const).map((variant) => ( + + + + CT + + + ))} +
+ ), +}; + +const Sizes: Story = { + render: (args): React.JSX.Element => ( +
+ {(["sm", "md", "lg"] as const).map((size) => ( + + + + CT + + + ))} +
+ ), +}; + +const Borderless: Story = { + render: (args): React.JSX.Element => ( + + + + CT + + + ), +}; + +const Placements: Story = { + render: (args): React.JSX.Element => ( +
+ {(["top-right", "top-left", "bottom-right", "bottom-left"] as const).map((placement) => ( + + + + CT + + + ))} +
+ ), +}; + +export { Default, Variants, Sizes, Borderless, Placements }; + +export default meta; diff --git a/packages/react/src/components/surface/Surface.tsx b/packages/react/src/components/surface/Surface.tsx new file mode 100644 index 0000000..14cd1fb --- /dev/null +++ b/packages/react/src/components/surface/Surface.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { classes } from "../../utils/classes"; + +export interface SurfaceProps extends Omit, "children" | "className"> { + /** Surface content — anything rendered inside the styled container. */ + children?: ReactNode; + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the surface has an emphasis border. @default false */ + isBordered?: boolean; + /** Border radius scale. @default 'md' */ + radius?: "none" | "sm" | "md" | "lg" | "xl"; + /** Elevation depth. @default 'none' */ + shadow?: "none" | "sm" | "md" | "lg" | "xl"; + /** Background layer tone (layer cake). `default` (neutral-50 — standard card), `subtle` (neutral-100 — nested/inset deeper), `plain` (pure white — elevated/inverted), `overlay` (surface on top of a dim scrim, e.g., popover/modal content). @default 'default' */ + variant?: "default" | "subtle" | "plain" | "overlay"; +} + +/** + * A styled container primitive — foundation for cards, alerts, and other + * composite components. Combine variant, isBordered, radius, and shadow freely. + */ +const Surface = (props: Readonly) => { + const { children, className, isBordered, radius, ref, shadow, variant, ...rest } = props; + + const surfaceClassName = classes({ + block: "surface", + modifiers: { + variant, + radius, + shadow, + bordered: isBordered, + }, + className, + }); + + return ( +
+ {children} +
+ ); +}; + +Surface.displayName = "Surface"; + +export { Surface }; diff --git a/packages/react/src/components/surface/index.ts b/packages/react/src/components/surface/index.ts new file mode 100644 index 0000000..134f069 --- /dev/null +++ b/packages/react/src/components/surface/index.ts @@ -0,0 +1,2 @@ +export { Surface } from "./Surface"; +export type { SurfaceProps } from "./Surface"; diff --git a/packages/react/src/components/surface/surface.stories.tsx b/packages/react/src/components/surface/surface.stories.tsx new file mode 100644 index 0000000..0819a69 --- /dev/null +++ b/packages/react/src/components/surface/surface.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Surface } from "./Surface"; + +const meta = { + title: "Components/Surface", + component: Surface, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + children: "Content", + }, + argTypes: { + children: { + control: false, + description: "Content (ReactNode)", + type: { + name: "other", + value: "ReactNode", + required: true, + }, + table: { + type: { + summary: "ReactNode", + }, + category: "Children", + }, + }, + variant: { + control: "select", + options: ["default", "subtle", "plain", "overlay"], + description: + "Background layer tone (layer cake). **default** — neutral gray (neutral-50, standard card on white page). **subtle** — deeper gray (neutral-100, nested or inset, deeper emphasis). **plain** — pure white (layer-0, inverted or elevated card on dimmed bg). **overlay** — surface on top of a dim scrim (popover/modal content). Use default for standard cards, subtle for nested blocks, plain for modals on dimmed bg, overlay for content above scrim overlays.", + table: { + type: { summary: '"default" | "subtle" | "plain" | "overlay"' }, + defaultValue: { summary: "default" }, + category: "Style Variants", + }, + }, + isBordered: { + control: "boolean", + description: "Whether the surface has an emphasis border", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + radius: { + control: "select", + options: ["none", "sm", "md", "lg", "xl"], + description: + "Border radius scale of the surface container. **none** — sharp architectural corners, common in admin or data dashboards. **sm** — subtle rounding for tight nested cards. **md** (default) — standard product card. **lg** — emphasized soft corners for hero or feature cards. **xl** — large hero cards and marketing splashes. Use md for typical content blocks, lg and xl for hero cards where the surface needs to feel softer, none inside data dashboards.", + table: { + type: { summary: '"none" | "sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + shadow: { + control: "select", + options: ["none", "sm", "md", "lg", "xl"], + description: + "Elevation depth via box-shadow tokens. **none** (default) — flat surface that sits on the page background. **sm** — subtle lift, useful for inset cards or hover states. **md** — standard card elevation, the typical product card. **lg** — popovers, dropdowns, and floating panels. **xl** — modals, dialogs, and large floating sheets. Use none for inline content, md for cards, lg for popover-tier elements, xl for modal-tier dialogs.", + table: { + type: { summary: '"none" | "sm" | "md" | "lg" | "xl"' }, + defaultValue: { summary: "none" }, + category: "Style Variants", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = { + args: { + className: "p-6", + children: [ +

+ Title +

, +

+ Content +

, + ], + }, +}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+ +

Default

+

Standard card (neutral-50)

+
+ + +

Subtle

+

Nested deeper (neutral-100)

+
+ + +

Plain

+

Pure white (layer-0)

+
+
+ ), +}; + +const Bordered: Story = { + args: { + isBordered: true, + }, + render: (args): React.JSX.Element => ( +
+ +

Default

+

Standard card (neutral-50)

+
+ + +

Subtle

+

Nested deeper (neutral-100)

+
+ + +

Plain

+

Pure white (layer-0)

+
+
+ ), +}; + +const OverlayVariant: Story = { + render: (args): React.JSX.Element => ( +
+ +

Overlay

+

On scrim (popover/modal)

+
+
+ ), +}; + +const Radius: Story = { + render: (args): React.JSX.Element => ( +
+ +

None

+

Content

+
+ + +

Small

+

Content

+
+ + +

Medium

+

Content

+
+ + +

Large

+

Content

+
+ + +

Extra Large

+

Content

+
+
+ ), +}; + +const Shadow: Story = { + render: (args): React.JSX.Element => ( +
+ +

None

+

Content

+
+ + +

Small

+

Content

+
+ + +

Medium

+

Content

+
+ + +

Large

+

Content

+
+ + +

Extra Large

+

Content

+
+
+ ), +}; + +export { Default, Variants, OverlayVariant, Radius, Shadow, Bordered }; + +export default meta; diff --git a/packages/react/src/components/textarea/Textarea.tsx b/packages/react/src/components/textarea/Textarea.tsx new file mode 100644 index 0000000..ec59ee8 --- /dev/null +++ b/packages/react/src/components/textarea/Textarea.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ComponentPropsWithRef } from "react"; + +import { TextArea as AriaTextArea } from "react-aria-components"; + +import { classes } from "../../utils/classes"; + +export interface TextareaProps extends Omit, "className" | "size"> { + /** Additional CSS classes appended after the base class. */ + className?: string; + /** Whether the textarea stretches to fill its container width. @default false */ + isFullWidth?: boolean; + /** Border radius scale. @default 'md' */ + radius?: "none" | "xs" | "sm" | "md" | "lg" | "full"; + /** Resize behavior. @default 'vertical' */ + resize?: "none" | "vertical" | "horizontal" | "both"; + /** Size scale. @default 'md' */ + size?: "sm" | "md" | "lg"; + /** Visual style. @default 'primary' */ + variant?: "primary" | "secondary" | "overlay"; +} + +/** + * A styled multiline text input primitive. Use native HTML attrs (`disabled`, `readOnly`, `required`, `aria-invalid`) for state. + */ +const Textarea = (props: Readonly) => { + const { className, isFullWidth, radius, ref, resize, size, variant, ...rest } = props; + + const textareaClassName = classes({ + block: "textarea", + modifiers: { + variant, + size, + radius, + resize, + "full-width": isFullWidth, + }, + className, + }); + + return ; +}; + +Textarea.displayName = "Textarea"; + +export { Textarea }; diff --git a/packages/react/src/components/textarea/index.ts b/packages/react/src/components/textarea/index.ts new file mode 100644 index 0000000..477ec78 --- /dev/null +++ b/packages/react/src/components/textarea/index.ts @@ -0,0 +1,2 @@ +export { Textarea } from "./Textarea"; +export type { TextareaProps } from "./Textarea"; diff --git a/packages/react/src/components/textarea/textarea.stories.tsx b/packages/react/src/components/textarea/textarea.stories.tsx new file mode 100644 index 0000000..2d7860d --- /dev/null +++ b/packages/react/src/components/textarea/textarea.stories.tsx @@ -0,0 +1,211 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CLASS_NAME_ARG_TYPE } from "../../storybook/argtypes"; + +import { Textarea } from "./Textarea"; + +const meta = { + title: "Components/Implementation/Textarea", + component: Textarea, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + placeholder: "Type here", + }, + argTypes: { + placeholder: { + control: "text", + description: "Placeholder text shown when textarea is empty", + table: { + type: { summary: "string" }, + category: "Children", + }, + }, + variant: { + control: "select", + options: ["primary", "secondary", "overlay"], + description: + "Visual style of the textarea field. **primary** (default) — bordered white field for standard forms. **secondary** — filled muted background with no border, works well inside cards or grouped panels. **overlay** — light field on dark media or scrim, for textareas placed over images or dark backgrounds. Use primary for main forms, secondary for nested fields inside cards, overlay on dark or media contexts.", + table: { + type: { summary: '"primary" | "secondary" | "overlay"' }, + defaultValue: { summary: "primary" }, + category: "Style Variants", + }, + }, + size: { + control: "select", + options: ["sm", "md", "lg"], + description: + "Size scale anchored at the form-aligned tier (md = text-base, the formula's `x = 1em` root). Geometry scales via em formulas — padding recomputes proportionally per tier; only font-size shifts across tiers. **sm** (text-sm) — inline comments, table cells, compact admin forms. **md** (text-base, default) — standard composition fields, post bodies, comment boxes. **lg** (text-lg) — hero composition areas like message editors and long-form drafts. Use sm for dense comment threads, md for standard product textareas, lg for editor-class composition where the textarea is the primary surface.", + table: { + type: { summary: '"sm" | "md" | "lg"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + radius: { + control: "select", + options: ["none", "xs", "sm", "md", "lg", "full"], + description: + "Border radius scale of the textarea. Fixed-px tokens independent of font-size. **none** — sharp corners for data tables and admin UIs. **xs** (2px) — micro softening for tight admin chrome. **sm** (4px) — subtle rounding for tight nested forms. **md** (6px, default) — standard textarea. **lg** (8px) — emphasized soft corners for hero composition areas. **full** — pill shape, rare for textarea since most textareas are multi-line. Use md as the default, lg for editor-style composition, none inside data dashboards or admin UIs.", + table: { + type: { summary: '"none" | "xs" | "sm" | "md" | "lg" | "full"' }, + defaultValue: { summary: "md" }, + category: "Style Variants", + }, + }, + resize: { + control: "select", + options: ["none", "vertical", "horizontal", "both"], + description: + "Resize behavior via native CSS `resize`. **none** — fixed size, common for chat input boxes and command bars. **vertical** (default) — user drags the bottom-right corner to grow height, the canonical product textarea. **horizontal** — width only, rare and usually unhelpful. **both** — both axes, useful for drafting tools and design surfaces. Use vertical for most forms, none for fixed-height controls like chat composers.", + table: { + type: { summary: '"none" | "vertical" | "horizontal" | "both"' }, + defaultValue: { summary: "vertical" }, + category: "Style Variants", + }, + }, + isFullWidth: { + control: "boolean", + description: "Whether the textarea stretches to fill its container width", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "Style Variants", + }, + }, + disabled: { + control: "boolean", + description: "Native HTML `disabled` attribute. Dims the textarea and removes pointer events.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + readOnly: { + control: "boolean", + description: "Native HTML `readOnly` attribute. Value is selectable but not editable.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + required: { + control: "boolean", + description: "Native HTML `required` attribute. Signals required-field semantics.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + "aria-invalid": { + control: "boolean", + description: + "ARIA `aria-invalid` attribute. Activates error styling (red border + danger ring on focus). Pair with sibling `FieldError`.", + table: { + type: { summary: '"true" | "false" | boolean' }, + defaultValue: { summary: "false" }, + category: "State", + }, + }, + className: CLASS_NAME_ARG_TYPE, + }, +} satisfies Meta; + +type Story = StoryObj; + +const Default: Story = {}; + +const Variants: Story = { + render: (args): React.JSX.Element => ( +
+