diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df1ffc..91585c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Install Playwright Chromium + run: yarn playwright install --with-deps chromium + - name: Type check run: yarn type-check @@ -47,3 +50,34 @@ jobs: - name: Build Storybook run: yarn build-storybook + + - name: Verify package consumers + run: yarn test:consumers + + - name: Accessibility checks + run: yarn test:a11y + + visual-regression: + name: Visual regression + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: yarn + cache-dependency-path: yarn.lock + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Install Playwright Chromium + run: yarn playwright install chromium + + - name: Visual regression checks + run: yarn test:visual diff --git a/CHANGELOG.md b/CHANGELOG.md index a2555c1..6a51680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,16 @@ Update `[Unreleased]` during normal development. Before a release, move relevant ### Added -- Initial documentation experience structure. +- V1 editorial catalog and release-readiness documentation. +- `IconButton`, an accessible icon-only action primitive with loading support. +- Playwright visual and axe scripts. +- Tarball-based React + Vite and Next.js App Router consumer validation. ### Changed -- Nothing yet. +- Button now defaults to `type="button"`. +- Progress now has the default accessible name `Progress` when no contextual label is supplied. +- Styled-components imports use the v6 named `styled` export for ESM compatibility. ### Deprecated @@ -26,7 +31,8 @@ Update `[Unreleased]` during normal development. Before a release, move relevant ### Fixed -- Nothing yet. +- ESM and CommonJS package artifacts now load styled-components correctly. +- CommonJS distribution now uses a `.cjs` file extension under the ESM package boundary. ### Security diff --git a/README.md b/README.md index 7ad2a45..0f62f05 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FeitozaUI -> A React and TypeScript UI Engineering Platform focused on foundations, primitives, component APIs, accessibility, testing, documentation, and future package distribution. +> A compact React and TypeScript component library focused on foundations, accessible APIs, testing, documentation, and verified package distribution. ![TypeScript](https://img.shields.io/badge/TypeScript-5-blue) ![React](https://img.shields.io/badge/React-19-61dafb) @@ -9,32 +9,33 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![npm](https://img.shields.io/badge/npm-not%20published-lightgrey) -FeitozaUI is not just a set of isolated components. It is being built as a small but professional component platform that documents engineering decisions, public APIs, design foundations, accessibility rules, testing strategy, and package readiness. +FeitozaUI is a deliberately small component platform for practical React interfaces. It documents public APIs, design foundations, accessibility rules, testing strategy, and package-readiness decisions rather than growing an unbounded component catalog. ## Features - React and TypeScript-first component APIs. - Public package surface with foundational atoms, core molecules, and design tokens. - Storybook documentation for foundations, primitives, guidelines, accessibility, and roadmap. -- Vite library build with ESM, CJS, UMD, and bundled TypeScript declarations. +- Vite library build with verified ESM, CJS, UMD, and bundled TypeScript declarations. - Vitest and Testing Library coverage for public components. - Architecture docs, ADRs, component guidelines, release planning, and maintenance docs. ## Current Status -FeitozaUI is a work in progress. The current package surface is intentionally small and focused on quality before breadth. +FeitozaUI V1 is intentionally compact and focused on quality before breadth. The package is ready for a manual first release, but is not published to npm yet. Current: -- Public components: `Button`, `Box`, `Flex`, `Surface`, `Badge`, `Divider`, `Spinner`, `Skeleton`, `Progress`, `Input`, `Textarea`, `Select`, `Alert`, `EmptyState`, `Tabs`, `Card`. +- Editorial V1 catalog: `Button`, `Checkbox`, `Radio`, `RadioGroup`, `Input`, `Textarea`, `Select`, `Tabs`, `Surface`, and `Card`. +- Supporting public primitives: `Badge`, `Box`, `Divider`, `EmptyState`, `Field`, `Flex`, `IconButton`, `Progress`, `Skeleton`, `Spinner`, and `VisuallyHidden`. - Foundations: colors, typography, spacing, radii, motion, borders, focus, states, and semantic mappings. -- Package build validated locally. +- Package artifacts verified through local tarball consumers for React + Vite and Next.js App Router. - Storybook structured as the documentation surface. - Accessibility and behavior coverage across the active public surface. Not current: -- Published npm package. +- Published npm package (manual publication is the remaining external release action). - Full theme system. - Complete component catalog. - BFF, micro-frontends, or production showcase. @@ -57,7 +58,7 @@ The future package name is: @feitoza-ui/core ``` -Current public exports: +The V1 editorial catalog is documented in [V1 Scope](docs/V1.md). Current public exports include: ```ts export { @@ -66,25 +67,29 @@ export { Box, Button, Card, + Checkbox, Divider, EmptyState, + Field, Flex, + IconButton, Input, Progress, + Radio, + RadioGroup, Select, Skeleton, Spinner, Surface, Tabs, - Textarea + Textarea, + VisuallyHidden } from '@feitoza-ui/core'; ``` ## Installation -The package is not published yet. Installation is future work. - -Future installation: +The package is not published yet. After the first manual publish: ```bash npm install @feitoza-ui/core @@ -106,6 +111,7 @@ Build the package locally: ```bash yarn.cmd build +yarn.cmd test:consumers ``` ## Usage @@ -179,7 +185,7 @@ The project uses Vitest and Testing Library. yarn.cmd test --run ``` -Current tests cover rendering, interactions, disabled behavior, ref forwarding, native prop pass-through, layout props, and basic accessibility expectations for primitives. +Current tests cover rendering, interactions, keyboard behavior, disabled behavior, ref forwarding, native prop pass-through, controlled state, form semantics, and accessibility expectations for primitives. See [Testing Strategy](docs/TESTING_STRATEGY.md). @@ -196,6 +202,24 @@ It currently documents Overview, Getting Started, Foundations, Components, Guide See [Storybook Strategy](docs/STORYBOOK.md). +## Release Validation + +Run the complete local release-readiness set: + +```bash +yarn.cmd lint +yarn.cmd type-check +yarn.cmd test --run +yarn.cmd build +yarn.cmd build-storybook +yarn.cmd test:a11y +yarn.cmd test:visual +yarn.cmd test:consumers +npm.cmd pack --dry-run +``` + +`test:consumers` packs the library, installs that exact tarball into the React + Vite and Next.js fixtures, and runs their type-check and production builds. + ## Roadmap The roadmap is maintained in [docs/roadmap/ROADMAP.md](docs/roadmap/ROADMAP.md). diff --git a/docs/README.md b/docs/README.md index 85763e3..d5b2ff0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ Use it to understand the project vision, architecture, component platform decisi - [Decisions](architecture/DECISIONS.md): ADR index and decision process. - [API Conventions](architecture/API_CONVENTIONS.md): public component API rules. - [ADRs](architecture/adr): architecture decision records. +- [V1 Scope](V1.md): frozen editorial catalog, API boundaries, and release validation. ## AI Guidance diff --git a/docs/V1.md b/docs/V1.md new file mode 100644 index 0000000..e3cb630 --- /dev/null +++ b/docs/V1.md @@ -0,0 +1,50 @@ +# V1 Scope + +FeitozaUI V1 is a compact, public React component package. Its goal is to demonstrate dependable component engineering, not to compete on catalog size. + +## Editorial Catalog + +- Button +- Checkbox +- Radio +- RadioGroup +- Input +- Textarea +- Select +- Tabs +- Surface +- Card + +Other existing public primitives remain available and documented in Storybook, but do not expand the V1 delivery scope. + +## API Boundaries + +- `Button` defaults to `type="button"`; pass another native type explicitly for form submission. +- `Field` composes labels, helper text, error text, and shared state for `Input`, `Textarea`, and `Select`. +- `Checkbox` and `Radio` preserve their independent native control structures because their indicator and label layouts differ from text controls. +- `Box` is a layout primitive, `Surface` provides visual containment, and `Card` is the default padded content grouping built on that containment. +- `Progress` always exposes an accessible name. Use `label` to provide a contextual one. +- Every `Tabs.Trigger` and matching `Tabs.Content` must use a unique, stable `value` within the same `Tabs.Root`. + +## Distribution Contract + +The package exports ESM, CommonJS, UMD, and rolled-up TypeScript declarations from the package root. React, React DOM, and styled-components are peer dependencies. + +The release check packs the exact artifact and validates it in two external fixtures: + +- React + Vite +- Next.js App Router + +## V1 Validation + +```bash +yarn.cmd lint +yarn.cmd type-check +yarn.cmd test --run +yarn.cmd build +yarn.cmd build-storybook +yarn.cmd test:a11y +yarn.cmd test:visual +yarn.cmd test:consumers +npm.cmd pack --dry-run +``` diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index f265068..5edf171 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -2,7 +2,7 @@ FeitozaUI is currently a React and TypeScript project with a Next.js app, Storybook, Vite library build, Styled Components, Vitest, and Testing Library. -The target architecture is a UI Engineering Platform with clear separation between reusable package code, documentation, and future examples. +The architecture is a UI Engineering Platform with clear separation between reusable package code, documentation, and future examples. ## Current Architecture @@ -15,7 +15,7 @@ The target architecture is a UI Engineering Platform with clear separation betwe ## Core Package -The core package should eventually expose reusable foundations, primitives, components, and types through a stable public API. It should not depend on the showcase. +The core package exposes reusable foundations, primitives, components, and types through a stable root public API. It does not depend on the showcase. Target package name: `@feitoza-ui/core`. @@ -31,13 +31,9 @@ Storybook is the primary documentation and exploration surface. It should explai The Vite build should generate package outputs that match `package.json` exports. Build files and publishing settings should be verified before any release. -## Current Limits +## V1 Boundary -- The package identity still needs alignment. -- Public exports need to be made explicit. -- Foundations need stronger structure. -- Storybook needs professional documentation polish. -- Publishing is future work. +V1 freezes the editorial catalog at Button, Checkbox, Radio, RadioGroup, Input, Textarea, Select, Tabs, Surface, and Card. Supporting primitives remain public, but do not expand the V1 documentation scope. See [V1 Scope](../V1.md). ## Future Possibilities diff --git a/docs/design-system/DX.md b/docs/design-system/DX.md index 92666be..52f5bb4 100644 --- a/docs/design-system/DX.md +++ b/docs/design-system/DX.md @@ -12,7 +12,7 @@ import { Button } from '@feitoza-ui/core'; ## Predictable APIs -Components should use consistent prop names for shared concepts such as `variant`, `size`, `disabled`, and `loading`. +Components should use consistent prop names for shared concepts such as `variant`, `size`, and `disabled`. A state prop such as `loading` is added only when the component has a defined loading contract. ## Autocomplete And IntelliSense @@ -33,7 +33,7 @@ Names should match the language of the platform. Avoid mixing product-specific n ## Good API Example ```tsx - ``` diff --git a/eslint.config.js b/eslint.config.js index d039476..cdbf656 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,9 @@ const eslintConfig = [ ignores: [ 'node_modules/**', '.next/**', + '**/.next/**', 'dist/**', + '**/dist/**', 'build/**', 'coverage/**', 'out/**', diff --git a/package.json b/package.json index afb9187..c9c999b 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,14 @@ "license": "MIT", "author": "Maria Fernanda", "type": "module", - "main": "./dist/feitoza-ui.cjs.js", + "main": "./dist/feitoza-ui.cjs", "module": "./dist/feitoza-ui.es.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/feitoza-ui.es.js", - "require": "./dist/feitoza-ui.cjs.js" + "require": "./dist/feitoza-ui.cjs" } }, "files": [ @@ -64,7 +64,10 @@ "type-check": "tsc --noEmit", "coverage": "vitest run --coverage", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "test:visual": "playwright test -c playwright.visual.config.ts", + "test:a11y": "playwright test -c playwright.accessibility.config.ts", + "test:consumers": "node scripts/verify-consumers.mjs" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/playwright.accessibility.config.ts b/playwright.accessibility.config.ts index 7cd5a7d..5c7947e 100644 --- a/playwright.accessibility.config.ts +++ b/playwright.accessibility.config.ts @@ -6,13 +6,14 @@ export default defineConfig({ fullyParallel: false, workers: 1, reporter: 'list', + webServer: { + command: 'yarn build:docs && yarn start:docs -p 3100', + port: 3100, + reuseExistingServer: !process.env.CI + }, use: { baseURL: 'http://127.0.0.1:3100', browserName: 'chromium', - launchOptions: { - executablePath: - 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' - }, locale: 'en-US', trace: 'off', video: 'off', diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index f316513..9fca76f 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -6,13 +6,14 @@ export default defineConfig({ fullyParallel: false, workers: 1, reporter: 'list', + webServer: { + command: 'yarn build:docs && yarn start:docs -p 3100', + port: 3100, + reuseExistingServer: !process.env.CI + }, use: { baseURL: 'http://127.0.0.1:3100', browserName: 'chromium', - launchOptions: { - executablePath: - 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' - }, locale: 'en-US', trace: 'off', video: 'off', diff --git a/scripts/verify-consumers.mjs b/scripts/verify-consumers.mjs new file mode 100644 index 0000000..aae04bb --- /dev/null +++ b/scripts/verify-consumers.mjs @@ -0,0 +1,44 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, unlinkSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const yarnCommand = process.platform === 'win32' ? 'yarn.cmd' : 'yarn'; +const consumers = [ + 'consumer-tests/react-vite', + 'consumer-tests/next-app-router' +]; + +function run(command, args, cwd) { + execFileSync(command, args, { + cwd, + stdio: 'inherit', + shell: process.platform === 'win32' + }); +} + +run(yarnCommand, ['build'], repositoryRoot); + +const packOutput = execFileSync(npmCommand, ['pack', '--ignore-scripts', '--json'], { + cwd: repositoryRoot, + encoding: 'utf8', + shell: process.platform === 'win32' +}); +const [{ filename }] = JSON.parse(packOutput); +const tarballPath = resolve(repositoryRoot, filename); + +try { + for (const consumer of consumers) { + const consumerRoot = resolve(repositoryRoot, consumer); + + run(npmCommand, ['install', '--no-save', '--package-lock=false', tarballPath], consumerRoot); + run(npmCommand, ['run', 'type-check'], consumerRoot); + run(npmCommand, ['run', 'build'], consumerRoot); + } +} finally { + if (existsSync(tarballPath)) { + unlinkSync(tarballPath); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 7fba734..8f62daa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1108,18 +1108,24 @@ html[data-theme='light'] .page-header h1 { background: color-mix(in srgb, var(--docs-surface-raised) 92%, transparent); } +.foundation-preview-surface span { + flex: 1 1 0; + min-inline-size: 0; + inline-size: 100%; +} + .foundation-preview-surface span:nth-child(1) { - width: 56px; + max-inline-size: 56px; height: 28px; } .foundation-preview-surface span:nth-child(2) { - width: 72px; + max-inline-size: 72px; height: 36px; } .foundation-preview-surface span:nth-child(3) { - width: 88px; + max-inline-size: 88px; height: 44px; } @@ -1998,17 +2004,17 @@ body > *::before { } .foundation-detail-preview-frame .foundation-preview-surface span:nth-child(1) { - width: 72px; + max-inline-size: 72px; height: 36px; } .foundation-detail-preview-frame .foundation-preview-surface span:nth-child(2) { - width: 96px; + max-inline-size: 96px; height: 48px; } .foundation-detail-preview-frame .foundation-preview-surface span:nth-child(3) { - width: 124px; + max-inline-size: 124px; height: 60px; } diff --git a/src/components/atoms/Badge/styles.ts b/src/components/atoms/Badge/styles.ts index 4d24e77..42ecfb4 100644 --- a/src/components/atoms/Badge/styles.ts +++ b/src/components/atoms/Badge/styles.ts @@ -1,4 +1,4 @@ -import styled, { css } from 'styled-components'; +import { css, styled } from 'styled-components'; import { colorRoles, motion, diff --git a/src/components/atoms/Box/styles.ts b/src/components/atoms/Box/styles.ts index 3d62cae..05392b6 100644 --- a/src/components/atoms/Box/styles.ts +++ b/src/components/atoms/Box/styles.ts @@ -1,4 +1,4 @@ -import styled, { css } from 'styled-components'; +import { css, styled } from 'styled-components'; import { space, colors, radii } from '@/design-tokens'; interface BoxStyleProps { diff --git a/src/components/atoms/Button/__tests__/Button.test.tsx b/src/components/atoms/Button/__tests__/Button.test.tsx index f40b912..52bd1b1 100644 --- a/src/components/atoms/Button/__tests__/Button.test.tsx +++ b/src/components/atoms/Button/__tests__/Button.test.tsx @@ -10,6 +10,16 @@ describe('Button', () => { expect(screen.getByText('Enviar')).toBeInTheDocument(); }); + it('defaults to type button while preserving an explicit type', () => { + const { rerender } = render(); + + expect(screen.getByRole('button', { name: 'Safe action' })).toHaveAttribute('type', 'button'); + + rerender(); + + expect(screen.getByRole('button', { name: 'Submit action' })).toHaveAttribute('type', 'submit'); + }); + it('calls onClick when clicked', () => { const handleClick = vi.fn(); render(); diff --git a/src/components/atoms/Button/index.tsx b/src/components/atoms/Button/index.tsx index 40993c9..9ef3c31 100644 --- a/src/components/atoms/Button/index.tsx +++ b/src/components/atoms/Button/index.tsx @@ -6,8 +6,8 @@ export type ButtonProps = ButtonHTMLAttributes & { }; const Button = forwardRef( - ({ variant = 'primary', ...props }, ref) => { - return ; + ({ variant = 'primary', type = 'button', ...props }, ref) => { + return ; } ); diff --git a/src/components/atoms/Button/shared.ts b/src/components/atoms/Button/shared.ts new file mode 100644 index 0000000..bb2fee3 --- /dev/null +++ b/src/components/atoms/Button/shared.ts @@ -0,0 +1,128 @@ +import { css } from 'styled-components'; +import { colorRoles, motion, radii, semanticColors, space, typography } from '@/design-tokens'; + +export type ButtonVariant = 'primary' | 'secondary' | 'accent'; + +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export const buttonBaseStyles = css` + display: inline-flex; + align-items: center; + justify-content: center; + gap: ${space[2]}; + font-family: ${typography.roles.interface}; + font-weight: ${typography.weights.semibold}; + line-height: ${typography.lineHeights.tight}; + border-radius: ${radii.small}; + border: 1px solid transparent; + cursor: pointer; + transition: + background-color ${motion.duration.normal} ${motion.easing.standard}, + border-color ${motion.duration.normal} ${motion.easing.standard}, + color ${motion.duration.normal} ${motion.easing.standard}, + box-shadow ${motion.duration.normal} ${motion.easing.standard}, + opacity ${motion.duration.normal} ${motion.easing.standard}, + transform ${motion.duration.fast} ${motion.easing.standard}; + + &:focus-visible { + outline: 2px solid ${semanticColors.dark.focus}; + outline-offset: 2px; + box-shadow: 0 0 0 4px ${colorRoles.dark.focus.shadow}; + } + + &:not(:disabled):active { + transform: translateY(1px); + } + + &:disabled { + cursor: not-allowed; + background-color: ${semanticColors.dark.surfaceRaised}; + color: ${semanticColors.dark.textSecondary}; + border-color: ${semanticColors.dark.borderSubtle}; + opacity: 1; + } +`; + +export const buttonTextSizeStyles = { + sm: css` + min-height: 40px; + padding: ${space[2]} ${space[3]}; + font-size: 0.875rem; + `, + md: css` + min-height: 44px; + padding: ${space[3]} ${space[4]}; + font-size: 0.9375rem; + `, + lg: css` + min-height: 48px; + padding: ${space[3]} ${space[5]}; + font-size: 1rem; + ` +} satisfies Record>; + +export const iconButtonSizeStyles = { + sm: css` + width: calc(${space[8]} + ${space[1]}); + height: calc(${space[8]} + ${space[1]}); + `, + md: css` + width: ${space[9]}; + height: ${space[9]}; + `, + lg: css` + width: calc(${space[9]} + ${space[1]}); + height: calc(${space[9]} + ${space[1]}); + ` +} satisfies Record>; + +export const buttonVariantStyles = ($variant: ButtonVariant = 'primary') => { + switch ($variant) { + case 'secondary': + return css` + background-color: ${semanticColors.dark.actionSecondary}; + color: ${semanticColors.dark.actionSecondaryText}; + border-color: ${semanticColors.dark.border}; + + &:not(:disabled):hover { + background-color: ${semanticColors.dark.actionSecondaryHover}; + border-color: ${semanticColors.dark.borderStrong}; + } + + &:not(:disabled):active { + background-color: ${semanticColors.dark.surfaceFloating}; + } + `; + case 'accent': + return css` + background-color: ${semanticColors.dark.surface}; + color: ${semanticColors.dark.accent}; + border-color: ${colorRoles.dark.accent.brand}; + + &:not(:disabled):hover { + background-color: ${semanticColors.dark.surfaceRaised}; + border-color: ${semanticColors.dark.accentHover}; + } + + &:not(:disabled):active { + background-color: ${semanticColors.dark.actionSecondary}; + } + `; + case 'primary': + default: + return css` + background-color: ${semanticColors.dark.actionPrimary}; + color: ${semanticColors.dark.actionPrimaryText}; + border-color: ${semanticColors.dark.actionPrimary}; + + &:not(:disabled):hover { + background-color: ${semanticColors.dark.actionPrimaryHover}; + border-color: ${semanticColors.dark.actionPrimaryHover}; + } + + &:not(:disabled):active { + box-shadow: inset 0 0 0 1px ${colorRoles.dark.selection.border}; + } + `; + } +}; diff --git a/src/components/atoms/Button/styles.ts b/src/components/atoms/Button/styles.ts index f786e49..ea6ad61 100644 --- a/src/components/atoms/Button/styles.ts +++ b/src/components/atoms/Button/styles.ts @@ -1,100 +1,12 @@ -import styled, { css } from 'styled-components'; -import { colorRoles, motion, radii, semanticColors, space, typography } from '@/design-tokens'; - -type ButtonVariant = 'primary' | 'secondary' | 'accent'; +import { styled } from 'styled-components'; +import { buttonBaseStyles, buttonTextSizeStyles, buttonVariantStyles, type ButtonVariant } from './shared'; interface ButtonProps { $variant?: ButtonVariant; } export const StyledButton = styled.button` - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: ${space[2]}; - font-family: ${typography.roles.interface}; - font-size: 0.9375rem; - font-weight: ${typography.weights.semibold}; - line-height: ${typography.lineHeights.tight}; - border-radius: ${radii.small}; - padding: ${space[3]} ${space[4]}; - border: 1px solid transparent; - cursor: pointer; - transition: - background-color ${motion.duration.normal} ${motion.easing.standard}, - border-color ${motion.duration.normal} ${motion.easing.standard}, - color ${motion.duration.normal} ${motion.easing.standard}, - box-shadow ${motion.duration.normal} ${motion.easing.standard}, - opacity ${motion.duration.normal} ${motion.easing.standard}, - transform ${motion.duration.fast} ${motion.easing.standard}; - - &:focus-visible { - outline: 2px solid ${semanticColors.dark.focus}; - outline-offset: 2px; - box-shadow: 0 0 0 4px ${colorRoles.dark.focus.shadow}; - } - - &:not(:disabled):active { - transform: translateY(1px); - } - - ${({ $variant = 'primary' }) => { - switch ($variant) { - case 'secondary': - return css` - background-color: ${semanticColors.dark.actionSecondary}; - color: ${semanticColors.dark.actionSecondaryText}; - border-color: ${semanticColors.dark.border}; - - &:not(:disabled):hover { - background-color: ${semanticColors.dark.actionSecondaryHover}; - border-color: ${semanticColors.dark.borderStrong}; - } - - &:not(:disabled):active { - background-color: ${semanticColors.dark.surfaceFloating}; - } - `; - case 'accent': - return css` - background-color: ${semanticColors.dark.surface}; - color: ${semanticColors.dark.accent}; - border-color: ${colorRoles.dark.accent.brand}; - - &:not(:disabled):hover { - background-color: ${semanticColors.dark.surfaceRaised}; - border-color: ${semanticColors.dark.accentHover}; - } - - &:not(:disabled):active { - background-color: ${semanticColors.dark.actionSecondary}; - } - `; - case 'primary': - default: - return css` - background-color: ${semanticColors.dark.actionPrimary}; - color: ${semanticColors.dark.actionPrimaryText}; - border-color: ${semanticColors.dark.actionPrimary}; - - &:not(:disabled):hover { - background-color: ${semanticColors.dark.actionPrimaryHover}; - border-color: ${semanticColors.dark.actionPrimaryHover}; - } - - &:not(:disabled):active { - box-shadow: inset 0 0 0 1px ${colorRoles.dark.selection.border}; - } - `; - } - }} - - &:disabled { - cursor: not-allowed; - background-color: ${semanticColors.dark.surfaceRaised}; - color: ${semanticColors.dark.textSecondary}; - border-color: ${semanticColors.dark.borderSubtle}; - opacity: 1; - } + ${buttonBaseStyles} + ${buttonTextSizeStyles.md} + ${({ $variant = 'primary' }) => buttonVariantStyles($variant)} `; diff --git a/src/components/atoms/Checkbox/index.tsx b/src/components/atoms/Checkbox/index.tsx index 17ccdf4..ff45a2a 100644 --- a/src/components/atoms/Checkbox/index.tsx +++ b/src/components/atoms/Checkbox/index.tsx @@ -6,6 +6,7 @@ import { type InputHTMLAttributes, type ReactNode } from 'react'; +import { useComposedRefs } from '@/internal/refs/useComposedRefs'; import * as S from './styles'; export type CheckboxProps = Omit, 'size' | 'type'> & { @@ -47,18 +48,7 @@ const Checkbox = forwardRef( .filter(Boolean) .join(' '); - const setRefs = (node: HTMLInputElement | null) => { - inputRef.current = node; - - if (typeof ref === 'function') { - ref(node); - return; - } - - if (ref) { - ref.current = node; - } - }; + const composedRefs = useComposedRefs(inputRef, ref); useEffect(() => { if (inputRef.current) { @@ -70,7 +60,7 @@ const Checkbox = forwardRef( { + it('associates the label with the nested control', () => { + render( + + Service name + + + ); + + expect(screen.getByLabelText('Service name')).toBeInTheDocument(); + }); + + it('associates helper and error text with the nested control', () => { + render( + + Workspace slug + + Use the public workspace slug. + Only lowercase letters are allowed. + + ); + + const input = screen.getByLabelText('Workspace slug'); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription( + 'Use the public workspace slug. Only lowercase letters are allowed.' + ); + }); + + it('renders optional text when required is false', () => { + render( + + Notes + + + ); + + expect(screen.getByText('Optional')).toBeInTheDocument(); + }); + + it('forwards ref, className, and style to the root element', () => { + const ref = createRef(); + const { container } = render( + + Styled field + + + ); + + expect(ref.current).toBe(container.firstChild); + expect(container.firstChild).toHaveClass('custom-field'); + expect(container.firstChild).toHaveStyle({ width: '320px' }); + }); + + it('uses an explicit controlId when provided', () => { + render( + + Controlled id + + + ); + + expect(screen.getByLabelText('Controlled id')).toHaveAttribute('id', 'custom-control'); + }); + + it('applies field-level disabled and required semantics to the nested control', () => { + render( + + Deployment target + + + ); + + const input = screen.getByRole('textbox', { name: /Deployment target/ }); + + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + }); +}); diff --git a/src/components/atoms/Field/context.ts b/src/components/atoms/Field/context.ts new file mode 100644 index 0000000..1a789c1 --- /dev/null +++ b/src/components/atoms/Field/context.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +export type FieldContextValue = { + controlId: string; + labelId: string; + helperTextId: string; + errorTextId: string; + disabled: boolean; + invalid: boolean; + required: boolean; + fullWidth: boolean; + optionalLabel?: ReactNode; + hasHelperText: boolean; + hasErrorText: boolean; +}; + +export const FieldContext = createContext(null); + +export function useOptionalFieldContext() { + return useContext(FieldContext); +} + +export function useFieldContext() { + const context = useOptionalFieldContext(); + + if (!context) { + throw new Error('Field subcomponents must be used within .'); + } + + return context; +} diff --git a/src/components/atoms/Field/index.tsx b/src/components/atoms/Field/index.tsx new file mode 100644 index 0000000..1e7bf60 --- /dev/null +++ b/src/components/atoms/Field/index.tsx @@ -0,0 +1,149 @@ +import { + Children, + forwardRef, + isValidElement, + useId, + type HTMLAttributes, + type LabelHTMLAttributes, + type ReactNode +} from 'react'; +import { + createFieldSlotIds, + resolveFieldControlId +} from '@/internal/ids/fieldSlotIds'; +import { FieldContext, useFieldContext } from './context'; +import * as S from './styles'; + +function hasChildType(children: ReactNode, component: unknown): boolean { + return Children.toArray(children).some((child) => { + if (!isValidElement(child)) { + return false; + } + + if (child.type === component) { + return true; + } + + return hasChildType((child.props as { children?: ReactNode }).children, component); + }); +} + +export type FieldRootProps = HTMLAttributes & { + controlId?: string; + disabled?: boolean; + invalid?: boolean; + required?: boolean; + fullWidth?: boolean; + optionalLabel?: ReactNode; +}; + +export type FieldLabelProps = LabelHTMLAttributes; + +export type FieldMessageProps = HTMLAttributes; + +const Label = forwardRef(({ children, htmlFor, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + {context.required ? : null} + {!context.required && context.optionalLabel ? ( + {context.optionalLabel} + ) : null} + + ); +}); + +Label.displayName = 'Field.Label'; + +const HelperText = forwardRef(({ children, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + + ); +}); + +HelperText.displayName = 'Field.HelperText'; + +const ErrorText = forwardRef(({ children, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + + ); +}); + +ErrorText.displayName = 'Field.ErrorText'; + +const Root = forwardRef( + ( + { + id, + controlId, + children, + disabled = false, + invalid = false, + required = false, + fullWidth = false, + optionalLabel, + ...props + }, + ref + ) => { + const generatedId = useId(); + const resolvedControlId = resolveFieldControlId({ + id: controlId, + generatedId, + prefix: id ?? 'feitoza-field' + }); + const slotIds = createFieldSlotIds(resolvedControlId); + const hasHelperText = hasChildType(children, HelperText); + const hasErrorText = hasChildType(children, ErrorText); + + return ( + + + {children} + + + ); + } +); + +Root.displayName = 'Field.Root'; + +const Field = { + Root, + Label, + HelperText, + ErrorText +}; + +export { useFieldContext, useOptionalFieldContext } from './context'; +export default Field; diff --git a/src/components/atoms/Field/stories/Field.stories.tsx b/src/components/atoms/Field/stories/Field.stories.tsx new file mode 100644 index 0000000..b03b410 --- /dev/null +++ b/src/components/atoms/Field/stories/Field.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import type { ReactNode } from 'react'; +import Field from '..'; +import Input from '../../Input'; +import Select from '../../Select'; +import Textarea from '../../Textarea'; +import { semanticColors, space, typography } from '@/design-tokens'; + +const meta = { + title: 'Components/Field', + component: Field.Root, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Field provides the shared form anatomy for textual controls in FeitozaUI. It centralizes label, helper text, error text, invalid, required, and layout concerns without forcing the same structure onto selection controls yet.' + } + } + }, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const Surface = ({ children, width = 420 }: { children: ReactNode; width?: number }) => ( +
+ {children} +
+); + +export const InputComposition: Story = { + render: () => ( + + + API key alias + + The label and helper text are managed by Field. + + + ) +}; + +export const ErrorState: Story = { + render: () => ( + + + Workspace slug + + Use lowercase letters, numbers, and hyphens. + Only lowercase letters are allowed. + + + ) +}; + +export const MixedControls: Story = { + render: () => ( + +
+ + Environment + + Textual controls can share the same field anatomy. + + + Release notes +