diff --git a/create-pr.js b/create-pr.js new file mode 100644 index 0000000..cfb803f --- /dev/null +++ b/create-pr.js @@ -0,0 +1,46 @@ +const https = require('https'); +const task = 'dashboard-card'; +const run = 'f8c1a4e2'; +const branch = 'sdlc/' + task + '-' + run; +const token = process.env.GITHUB_TOKEN || ''; +const repo = 'webdevcom01-cell/webdevcom01-cell-sdlc-sandbox'; + +if (!token) { + const manualUrl = 'https://github.com/' + repo + '/compare/' + branch + '?expand=1'; + console.log(JSON.stringify({success: false, error: 'GITHUB_TOKEN not set', branch: branch, manualUrl: manualUrl})); + process.exit(0); +} + +const bodyData = JSON.stringify({ + title: 'feat(' + task + '): autonomous SDLC [' + run + ']', + body: 'Automated PR from SDLC Pipeline\n\nTask: ' + task + '\nRun: ' + run + '\nBranch: ' + branch, + head: branch, + base: 'main' +}); + +const opts = { + hostname: 'api.github.com', + path: '/repos/' + repo + '/pulls', + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyData), + 'User-Agent': 'SDLC-Pipeline/1.0' + } +}; + +const req = https.request(opts, function(res) { + let data = ''; + res.on('data', function(chunk) { data += chunk; }); + res.on('end', function() { console.log(data); process.exit(0); }); +}); + +req.on('error', function(e) { + console.log(JSON.stringify({success: false, error: e.message, branch: branch})); + process.exit(0); +}); + +req.write(bodyData); +req.end(); diff --git a/create-pr.sh b/create-pr.sh new file mode 100644 index 0000000..f8065d7 --- /dev/null +++ b/create-pr.sh @@ -0,0 +1,20 @@ +#!/bin/sh +TASK='dashboard-card' +RUN='8d4a3e7c' +BRANCH="sdlc/$TASK-$RUN" +REPO='webdevcom01-cell/webdevcom01-cell-sdlc-sandbox' +TOKEN="$GITHUB_TOKEN" + +if ! command -v curl > /dev/null 2>&1; then + echo '{"success":false,"error":"curl not available","branch":"'$BRANCH'"}' + exit 0 +fi + +if [ -z "$TOKEN" ]; then + echo '{"success":false,"error":"GITHUB_TOKEN not set","branch":"'$BRANCH'","manualUrl":"https://github.com/webdevcom01-cell/webdevcom01-cell-sdlc-sandbox/compare/'$BRANCH'?expand=1"}' + exit 0 +fi + +DATA='{"title":"feat('$TASK'): autonomous SDLC ['$RUN']","body":"Automated PR from SDLC Pipeline","head":"'$BRANCH'","base":"main"}' +RESULT=$(curl -s -X POST "https://api.github.com/repos/$REPO/pulls" -H 'Authorization: Bearer '$TOKEN -H 'Accept: application/vnd.github+json' -H 'Content-Type: application/json' -d "$DATA") +echo "$RESULT" diff --git a/dashboard-card.test.ts b/dashboard-card.test.ts new file mode 100644 index 0000000..2aafbad --- /dev/null +++ b/dashboard-card.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const src = readFileSync(join(process.cwd(), 'dashboard-card.tsx'), 'utf8'); + +describe('dashboard-card exports', () => { + it('has a default export', () => { + expect(src.includes('export default')).toBe(true); + }); + it('has a named export', () => { + expect(src.includes('export function') || src.includes('export const') || src.includes('export class')).toBe(true); + }); + it('uses React', () => { + expect(src.includes('react')).toBe(true); + }); + it('has TypeScript interface', () => { + expect(src.includes('Props')).toBe(true); + }); + it('has ARIA attributes', () => { + expect(src.includes('aria-') || src.includes('role=')).toBe(true); + }); +}); \ No newline at end of file diff --git a/dashboard-card.tsx b/dashboard-card.tsx new file mode 100644 index 0000000..a320282 --- /dev/null +++ b/dashboard-card.tsx @@ -0,0 +1,290 @@ +import React, { ReactNode, KeyboardEvent, useRef } from 'react'; +import { + borderRadiusLg, + elevation3, + elevation4, + elevation0, + outline, + surface, + surfaceContainerHighest, + surfaceVariantAlpha80, + stateHoverOverlay, + primary, + onSurface, + onSurfaceVariant, + spacing6, + spacing4, + typescaleTitleMediumSize, + typescaleTitleMediumWeight, + typescaleTitleMediumFamily, + typescaleTitleMediumLineHeight, + typescaleBodySmallSize, + typescaleBodySmallWeight, + typescaleBodySmallFamily, + typescaleBodySmallLineHeight, + typescaleBodyMediumSize, + typescaleBodyMediumWeight, + typescaleBodyMediumFamily, + typescaleBodyMediumLineHeight, + transitionsStandard +} from './design-tokens'; + +/** + * Props for DashboardCard component. + */ +export interface DashboardCardProps { + /** + * Primary heading displayed on the card. + */ + title: string; + /** + * Secondary text displayed beneath the title. + */ + subtitle?: string; + /** + * Content or children to render inside the card. + */ + content: ReactNode; + /** + * Optional image displayed at the top or side of the card. + */ + imageUrl?: string; + /** + * Callback fired when the card is clicked. + */ + onClick?: () => void; + /** + * Visual style of the card, aligning to Material 3 variants. + */ + variant?: 'elevated' | 'filled' | 'outlined'; + /** + * Displays a loading state overlay when true. + */ + loading?: boolean; +} + +const variantCardClass: Record<'elevated' | 'filled' | 'outlined', string> = { + elevated: `bg-[${surface}] shadow-[${elevation3}] border-0`, + filled: `bg-[${surfaceContainerHighest}] shadow-[${elevation0}] border-0`, + outlined: `bg-[${surface}] shadow-[${elevation0}] border border-[${outline}]` +}; + +const variantCardClassDark: Record<'elevated' | 'filled' | 'outlined', string> = { + elevated: `dark:bg-[${surface}] dark:shadow-[${elevation3}] dark:border-0`, + filled: `dark:bg-[${surfaceContainerHighest}] dark:shadow-[${elevation0}] dark:border-0`, + outlined: `dark:bg-[${surface}] dark:shadow-[${elevation0}] dark:border dark:border-[${outline}]` +}; + +const hoverClass: Record<'elevated' | 'filled' | 'outlined', string> = { + elevated: `hover:bg-[${stateHoverOverlay}] hover:shadow-[${elevation4}]`, + filled: `hover:bg-[${stateHoverOverlay}]`, + outlined: `hover:bg-[${stateHoverOverlay}]` +}; + +const hoverClassDark: Record<'elevated' | 'filled' | 'outlined', string> = { + elevated: `dark:hover:bg-[${stateHoverOverlay}] dark:hover:shadow-[${elevation4}]`, + filled: `dark:hover:bg-[${stateHoverOverlay}]`, + outlined: `dark:hover:bg-[${stateHoverOverlay}]` +}; + +const focusRingClass = `focus-visible:ring-2 focus-visible:ring-[${primary}] focus-visible:z-10`; + +const titleClass = ` + text-[${typescaleTitleMediumSize}] + font-[${typescaleTitleMediumWeight}] + font-[${typescaleTitleMediumFamily}] + leading-[${typescaleTitleMediumLineHeight}] + text-[${onSurface}] + truncate + dark:text-[${onSurface}] +`; + +const subtitleClass = ` + text-[${typescaleBodySmallSize}] + font-[${typescaleBodySmallWeight}] + font-[${typescaleBodySmallFamily}] + leading-[${typescaleBodySmallLineHeight}] + text-[${onSurfaceVariant}] + truncate + dark:text-[${onSurfaceVariant}] +`; + +const contentClass = ` + text-[${typescaleBodyMediumSize}] + font-[${typescaleBodyMediumWeight}] + font-[${typescaleBodyMediumFamily}] + leading-[${typescaleBodyMediumLineHeight}] + text-[${onSurface}] + flex-1 min-h-0 + dark:text-[${onSurface}] +`; + +const imageClass = ` + w-full + h-[112px] + rounded-[${borderRadiusLg}] + object-cover + mb-[${spacing4}] + dark:w-full dark:h-[112px] dark:rounded-[${borderRadiusLg}] +`; + +const cardBaseClass = ` + relative + flex flex-col min-w-0 + transition-all + outline-none + p-[${spacing6}] + gap-[${spacing4}] + rounded-[${borderRadiusLg}] + font-sans + min-h-[44px] + min-w-[44px] + duration-200 + ease-[cubic-bezier(0.2,0,0,1)] + focus-visible:outline-none + select-none + dark:font-sans +`; + +const loadingOverlayClass = ` + absolute inset-0 flex items-center justify-center + bg-[${surfaceVariantAlpha80}] + dark:bg-[${surfaceVariantAlpha80}] + rounded-[${borderRadiusLg}] + transition-all + z-10 +`; + +export const DashboardCard: React.FC = ({ + title, + subtitle, + content, + imageUrl, + onClick, + variant = 'elevated', + loading = false, +}) => { + const cardRef = useRef(null); + + // Interactive when onClick & not loading + const isInteractive = !!onClick && !loading; + + // Accessibility + const ariaLabelledById = `dashboard-card-title-${Math.random().toString(36).slice(2, 8)}`; + + // Keyboard interaction handlers + const handleKeyDown = (e: KeyboardEvent) => { + if (!isInteractive) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick && onClick(); + } + if (e.key === 'Escape') { + cardRef.current?.blur(); + } + }; + + // Disabled pointer events when loading + const pointerEventsClass = loading ? 'pointer-events-none opacity-70' : ''; + + // Compose variant classes + const variantClass = `${variantCardClass[variant]} ${variantCardClassDark[variant]}`; + const hoverVariantClass = isInteractive ? `${hoverClass[variant]} ${hoverClassDark[variant]}` : ''; + const transitionClass = `transition-[box-shadow,background,border] ${transitionsStandard}`; + + return ( +
+ {imageUrl && ( + + )} +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+
+ {content} +
+ {loading && ( + + )} +
+ ); +}; + +export default DashboardCard; diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..8f3c7e8 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.4","results":[[":src/utils/formatCurrency.test.ts",{"duration":9.332760000000007,"failed":false}],[":primary-button-component.test.ts",{"duration":0,"failed":true}],[":primary-button.test.ts",{"duration":7.938678999999979,"failed":false}],[":dashboard-card.test.ts",{"duration":8.993096999999977,"failed":false}]]} \ No newline at end of file diff --git a/primary-button-component.test.ts b/primary-button-component.test.ts new file mode 100644 index 0000000..b32b47c --- /dev/null +++ b/primary-button-component.test.ts @@ -0,0 +1,133 @@ +import React from 'react'; + +export interface PrimaryButtonProps { + /** Button's visible label */ + label: string; + /** Click handler */ + onClick?: React.MouseEventHandler; + /** Disabled state */ + disabled?: boolean; + /** Show loading spinner */ + loading?: boolean; + /** Button size */ + size?: 'sm' | 'md' | 'lg'; + /** Button visual variant */ + variant?: 'primary' | 'secondary' | 'ghost'; +} + +const SIZE_MAP = { + sm: { + height: 36, + fontSize: 14, + padding: '0 16px', + spinner: 16, + }, + md: { + height: 44, + fontSize: 16, + padding: '0 20px', + spinner: 18, + }, + lg: { + height: 52, + fontSize: 18, + padding: '0 24px', + spinner: 20, + }, +}; + +const VARIANT_MAP = { + primary: (disabled: boolean) => ({ + background: disabled ? '#EADDFF' : '#6750A4', + color: disabled ? '#A09EA4' : '#fff', + border: 'none', + boxShadow: disabled ? 'none' : '0px 1px 2px #6750A440', + }), + secondary: (disabled: boolean) => ({ + background: disabled ? '#F3EDF7' : '#fff', + color: disabled ? '#A09EA4' : '#6750A4', + border: `1px solid ${disabled ? '#E7E0EC' : '#79747E'}`, + boxShadow: 'none', + }), + ghost: (disabled: boolean) => ({ + background: 'transparent', + color: disabled ? '#A09EA4' : '#6750A4', + border: 'none', + boxShadow: 'none', + }), +}; + +const Spinner = ({ size = 18 }: { size?: number }) => ( + +); + +export const PrimaryButton: React.FC = ({ + label, + onClick, + disabled = false, + loading = false, + size = 'md', + variant = 'primary', +}) => { + const sizeStyles = SIZE_MAP[size]; + const variantStyles = VARIANT_MAP[variant](disabled || loading); + + return ( + + ); +}; + +export default PrimaryButton; \ No newline at end of file diff --git a/primary-button-component.tsx b/primary-button-component.tsx new file mode 100644 index 0000000..1231277 --- /dev/null +++ b/primary-button-component.tsx @@ -0,0 +1,217 @@ +import React, { useRef } from "react"; +import * as tokens from "./design-tokens"; + +/** + * Button props for Material 3 Expressive PrimaryButton. + */ +export interface PrimaryButtonProps { + /** + * Button text label (required, visible to users and AT) + */ + label: string; + /** + * Click handler for the button. + */ + onClick?: React.MouseEventHandler; + /** + * If true, the button is disabled and cannot be interacted with. + */ + disabled?: boolean; + /** + * If true, shows a loading spinner and disables interaction. + */ + loading?: boolean; + /** + * Button size: 'sm' | 'md' | 'lg'. Default is 'md'. + */ + size?: "sm" | "md" | "lg"; + /** + * Visual style variant: 'primary' | 'secondary' | 'ghost'. Default is 'primary'. + */ + variant?: "primary" | "secondary" | "ghost"; + /** + * Optional id for aria-labelledby scenarios. + */ + id?: string; +} + +const SIZE_MAP = { + sm: { + height: "min-h-[44px] h-11", // Tailwind: min-h-44px, h-44px (rounded to 44px) + px: "px-4", // 16px + text: "text-base", // 16px + icon: 16, + gap: "gap-2", + }, + md: { + height: "min-h-[44px] h-12", // 48px, always min 44px + px: "px-5", // 20px + text: "text-lg", // 18px + icon: 18, + gap: "gap-2.5", + }, + lg: { + height: "min-h-[44px] h-[56px]", // 56px, always min 44px + px: "px-6", // 24px + text: "text-xl", // 20px + icon: 20, + gap: "gap-3", + }, +}; + +/** + * Styling map for button variants, referencing only design tokens. + */ +function getVariantClasses(variant: "primary" | "secondary" | "ghost", disabled: boolean, loading: boolean) { + // Always match dark/light, never hardcode + const isInactive = disabled || loading; + switch (variant) { + case "primary": + return [ + `bg-[${tokens.sysColorPrimary}] dark:bg-[${tokens.sysColorPrimaryDark}]`, + `text-[${tokens.sysColorOnPrimary}] dark:text-[${tokens.sysColorOnPrimaryDark}]`, + "border-0 shadow", + isInactive + ? `opacity-40 bg-[${tokens.sysColorPrimaryDisabled}] dark:bg-[${tokens.sysColorPrimaryDisabledDark}] text-[${tokens.sysColorOnPrimaryDisabled}] dark:text-[${tokens.sysColorOnPrimaryDisabledDark}] shadow-none` + : "", + "focus-visible:ring-2", + `focus-visible:ring-[${tokens.sysColorPrimary}] dark:focus-visible:ring-[${tokens.sysColorPrimaryDark}]`, + "focus-visible:ring-offset-2", + ].join(" "); + case "secondary": + return [ + `bg-[${tokens.sysColorSurface}] dark:bg-[${tokens.sysColorSurfaceDark}]`, + `text-[${tokens.sysColorPrimary}] dark:text-[${tokens.sysColorPrimaryDark}]`, + `border border-[${tokens.sysColorOutline}] dark:border-[${tokens.sysColorOutlineDark}]`, + isInactive + ? `opacity-40 bg-[${tokens.sysColorSurfaceDisabled}] dark:bg-[${tokens.sysColorSurfaceDisabledDark}] border-[${tokens.sysColorOutlineDisabled}] dark:border-[${tokens.sysColorOutlineDisabledDark}] text-[${tokens.sysColorOnSurfaceDisabled}] dark:text-[${tokens.sysColorOnSurfaceDisabledDark}]` + : "", + "shadow-none", + "focus-visible:ring-2", + `focus-visible:ring-[${tokens.sysColorPrimary}] dark:focus-visible:ring-[${tokens.sysColorPrimaryDark}]`, + "focus-visible:ring-offset-2" + ].join(" "); + case "ghost": + return [ + "bg-transparent dark:bg-transparent", + `text-[${tokens.sysColorPrimary}] dark:text-[${tokens.sysColorPrimaryDark}]`, + "border-0 shadow-none", + isInactive + ? `opacity-40 text-[${tokens.sysColorOnSurfaceDisabled}] dark:text-[${tokens.sysColorOnSurfaceDisabledDark}]` + : "", + "focus-visible:ring-2", + `focus-visible:ring-[${tokens.sysColorPrimary}] dark:focus-visible:ring-[${tokens.sysColorPrimaryDark}]`, + "focus-visible:ring-offset-2" + ].join(" "); + default: + return ""; + } +} + +/** + * Spinner shown when loading, accessible to screen readers. + */ +const Spinner: React.FC<{ size: number }> = ({ size }) => ( + +); + +export const PrimaryButton: React.FC = ({ + label, + onClick, + disabled = false, + loading = false, + size = "md", + variant = "primary", + id, +}) => { + const buttonRef = useRef(null); + const { height, px, text, icon, gap } = SIZE_MAP[size]; + + // Keyboard: Enter/Space/Tab/ESC (ESC here does nothing, but pattern shown for extension) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled || loading) return; + if (e.key === "Escape") { + (e.target as HTMLButtonElement).blur(); + } + // Enter/Space handled natively by + ); +}; + +export default PrimaryButton; \ No newline at end of file diff --git a/primary-button.test.ts b/primary-button.test.ts new file mode 100644 index 0000000..3c44c23 --- /dev/null +++ b/primary-button.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const src = readFileSync(join(process.cwd(), 'primary-button.tsx'), 'utf8'); + +describe('primary-button exports', () => { + it('has a default export', () => { + expect(src.includes('export default')).toBe(true); + }); + it('has a named export', () => { + expect(src.includes('export function') || src.includes('export const') || src.includes('export class')).toBe(true); + }); + it('uses React', () => { + expect(src.includes('react')).toBe(true); + }); + it('has TypeScript interface', () => { + expect(src.includes('Props')).toBe(true); + }); + it('has ARIA attributes', () => { + expect(src.includes('aria-') || src.includes('role=')).toBe(true); + }); +}); \ No newline at end of file diff --git a/primary-button.tsx b/primary-button.tsx new file mode 100644 index 0000000..b8d50d7 --- /dev/null +++ b/primary-button.tsx @@ -0,0 +1,302 @@ +import React, { ButtonHTMLAttributes, useRef } from "react"; +import { tokens } from "./design-tokens"; + +/** + * Material 3 Expressive PrimaryButton Props + */ +export interface PrimaryButtonProps + extends Omit, "onClick" | "disabled"> { + /** + * Button text label + */ + label: string; + /** + * Callback for button press + */ + onClick: () => void; + /** + * Disables the button + */ + disabled?: boolean; + /** + * Shows a loading spinner + */ + loading?: boolean; + /** + * Button size (defaults to "md") + */ + size?: "sm" | "md" | "lg"; + /** + * Button visual style (defaults to "primary") + */ + variant?: "primary" | "secondary" | "ghost"; +} + +/** + * Spinner for loading state (uses currentColor) + */ +const Spinner: React.FC = () => ( + +); + +/** + * Button size class map + */ +const sizeClassMap = { + sm: { + minHeight: tokens.component.primaryButton.height.sm, + minWidth: tokens.component.primaryButton.minWidth, + px: tokens.component.primaryButton.padding.smX, + py: tokens.component.primaryButton.padding.smY, + font: tokens.component.primaryButton.label.fontSize.sm, + gap: tokens.component.primaryButton.icon.gap.sm, + radius: tokens.component.primaryButton.shape.rounded, + }, + md: { + minHeight: tokens.component.primaryButton.height.md, + minWidth: tokens.component.primaryButton.minWidth, + px: tokens.component.primaryButton.padding.mdX, + py: tokens.component.primaryButton.padding.mdY, + font: tokens.component.primaryButton.label.fontSize.md, + gap: tokens.component.primaryButton.icon.gap.md, + radius: tokens.component.primaryButton.shape.rounded, + }, + lg: { + minHeight: tokens.component.primaryButton.height.lg, + minWidth: tokens.component.primaryButton.minWidth, + px: tokens.component.primaryButton.padding.lgX, + py: tokens.component.primaryButton.padding.lgY, + font: tokens.component.primaryButton.label.fontSize.lg, + gap: tokens.component.primaryButton.icon.gap.lg, + radius: tokens.component.primaryButton.shape.rounded, + }, +}; + +/** + * Variant class map, using Tailwind and tokens + */ +function getVariantClasses( + variant: "primary" | "secondary" | "ghost" +): string { + switch (variant) { + case "primary": + return [ + // Light + `bg-[${tokens.component.primaryButton.container.color.primary.light}]`, + `text-[${tokens.component.primaryButton.label.color.primary.light}]`, + // Dark + `dark:bg-[${tokens.component.primaryButton.container.color.primary.dark}]`, + `dark:text-[${tokens.component.primaryButton.label.color.primary.dark}]`, + // Shadow + tokens.component.primaryButton.elevation.light + ? `shadow-[${tokens.component.primaryButton.elevation.light}]` + : "", + `dark:shadow-[${tokens.component.primaryButton.elevation.dark}]`, + // No border + "border-none", + // State layer + "hover:bg-[var(--primary-hover,rgba(103,80,164,0.92))]", + "dark:hover:bg-[var(--primary-hover-dark,rgba(208,188,255,0.12))]", + "focus-visible:ring-2 focus-visible:ring-[var(--primary-focus,#6750A4)] focus-visible:ring-offset-2 dark:focus-visible:ring-[var(--primary-focus-dark,#D0BCFF)]", + ].join(" "); + case "secondary": + return [ + `bg-[${tokens.component.primaryButton.container.color.secondary.light}]`, + `text-[${tokens.component.primaryButton.label.color.secondary.light}]`, + `dark:bg-[${tokens.component.primaryButton.container.color.secondary.dark}]`, + `dark:text-[${tokens.component.primaryButton.label.color.secondary.dark}]`, + `border border-[${tokens.component.primaryButton.outline.color.secondary.light}]`, + `dark:border-[${tokens.component.primaryButton.outline.color.secondary.dark}]`, + tokens.component.primaryButton.elevation.light + ? `shadow-[${tokens.component.primaryButton.elevation.light}]` + : "", + `dark:shadow-[${tokens.component.primaryButton.elevation.dark}]`, + "hover:bg-[var(--secondary-hover,rgba(103,80,164,0.08))]", + "dark:hover:bg-[var(--secondary-hover-dark,rgba(208,188,255,0.08))]", + "focus-visible:ring-2 focus-visible:ring-[var(--secondary-focus,#625B71)] focus-visible:ring-offset-2 dark:focus-visible:ring-[var(--secondary-focus-dark,#EADDFF)]", + ].join(" "); + case "ghost": + return [ + "bg-transparent", + `text-[${tokens.component.primaryButton.label.color.ghost.light}]`, + `dark:text-[${tokens.component.primaryButton.label.color.ghost.dark}]`, + "hover:bg-[var(--ghost-hover,rgba(103,80,164,0.08))]", + "dark:hover:bg-[var(--ghost-hover-dark,rgba(208,188,255,0.12))]", + "focus-visible:ring-2 focus-visible:ring-[var(--ghost-focus,#6750A4)] focus-visible:ring-offset-2 dark:focus-visible:ring-[var(--ghost-focus-dark,#D0BCFF)]", + "border-none", + ].join(" "); + default: + return ""; + } +} + +/** + * Disabled classes per variant + */ +function getDisabledClasses(variant: "primary" | "secondary" | "ghost"): string { + switch (variant) { + case "primary": + return [ + `bg-[${tokens.component.primaryButton.container.color.primaryDisabled.light}]`, + `text-[${tokens.component.primaryButton.label.color.primaryDisabled.light}]`, + `dark:bg-[${tokens.component.primaryButton.container.color.primaryDisabled.dark}]`, + `dark:text-[${tokens.component.primaryButton.label.color.primaryDisabled.dark}]`, + "opacity-[0.38]", + "pointer-events-none", + "cursor-not-allowed", + ].join(" "); + case "secondary": + return [ + `bg-[${tokens.component.primaryButton.container.color.secondaryDisabled.light}]`, + `text-[${tokens.component.primaryButton.label.color.secondaryDisabled.light}]`, + `border border-[${tokens.component.primaryButton.outline.color.secondaryDisabled.light}]`, + `dark:bg-[${tokens.component.primaryButton.container.color.secondaryDisabled.dark}]`, + `dark:text-[${tokens.component.primaryButton.label.color.secondaryDisabled.dark}]`, + `dark:border-[${tokens.component.primaryButton.outline.color.secondaryDisabled.dark}]`, + "opacity-[0.38]", + "pointer-events-none", + "cursor-not-allowed", + ].join(" "); + case "ghost": + return [ + "bg-transparent", + `text-[${tokens.component.primaryButton.label.color.ghostDisabled.light}]`, + `dark:text-[${tokens.component.primaryButton.label.color.ghostDisabled.dark}]`, + "opacity-[0.38]", + "pointer-events-none", + "cursor-not-allowed", + ].join(" "); + default: + return ""; + } +} + +/** + * Main PrimaryButton component + */ +export const PrimaryButton: React.FC = ({ + label, + onClick, + disabled = false, + loading = false, + size = "md", + variant = "primary", + ...rest +}) => { + const btnRef = useRef(null); + + // Compose size classes from tokens + const sizeTokens = sizeClassMap[size]; + // Responsive: min-h and min-w always 44px (touch target) + const sizeClasses = [ + "inline-flex items-center justify-center select-none", + `min-h-[44px] min-w-[44px]`, // Always ensure minimum touch targets + `h-[${sizeTokens.minHeight}] min-w-[${sizeTokens.minWidth}]`, + `px-[${sizeTokens.px}] py-[${sizeTokens.py}]`, + `text-[${sizeTokens.font}] font-[${tokens.component.primaryButton.label.fontWeight}]`, + "uppercase tracking-[.02em]", + `rounded-[${sizeTokens.radius}]`, + `gap-[${sizeTokens.gap}]`, + `transition-[background,box-shadow,border,color] duration-[${tokens.transitions.standard}] ease-in-out`, + "outline-none", + "focus-visible:z-10", + ].join(" "); + + // Main color/variant classes + const variantClasses = disabled + ? getDisabledClasses(variant) + : getVariantClasses(variant); + + // Loading disables interaction and triggers ARIA + const isDisabled = disabled || loading; + + // ARIA attributes + const ariaLabelledBy = rest["aria-labelledby"]; + const ariaLabel = rest["aria-label"] || label; + + // Keyboard events: activate on Enter/Space, dismiss on Escape + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + if (e.key === "Escape") { + if (btnRef.current) btnRef.current.blur(); + } + if (rest.onKeyDown) rest.onKeyDown(e); + }; + + return ( + + ); +}; + +export default PrimaryButton; diff --git a/security-result.json b/security-result.json new file mode 100644 index 0000000..dd370dc --- /dev/null +++ b/security-result.json @@ -0,0 +1 @@ +{"status":"PASS","severity":"NONE","findings":[],"summary":"UI React/TypeScript component with no security vulnerabilities detected."} \ No newline at end of file diff --git a/src/components/ui/primary-button.test.tsx b/src/components/ui/primary-button.test.tsx new file mode 100644 index 0000000..ee610b7 --- /dev/null +++ b/src/components/ui/primary-button.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import * as React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { PrimaryButton } from './primary-button'; + +describe('PrimaryButton', () => { + it('renders label prop', () => { + const { getByText } = render( + {}} /> + ); + expect(getByText('Submit')).toBeDefined(); + }); + + it('calls onClick handler when clicked', () => { + let called = false; + const { getByRole } = render( + { called = true; }} /> + ); + fireEvent.click(getByRole('button')); + expect(called).toBe(true); + }); + + it('is disabled when disabled=true', () => { + const { getByRole } = render( + {}} /> + ); + expect(getByRole('button')).toBeDisabled(); + }); + + it('shows loading spinner and disables interaction when loading', () => { + const { getByRole, getByLabelText } = render( + {}} /> + ); + const btn = getByRole('button'); + expect(btn).toHaveAttribute('aria-busy', 'true'); + expect(btn).toBeDisabled(); + expect(btn.querySelector('svg')).toBeDefined(); + }); + + it('renders with size and variant props', () => { + const { getByRole } = render( + {}} /> + ); + const btn = getByRole('button'); + expect(btn.className).toMatch(/py-3/); + expect(btn.className).toMatch(/bg-secondary-700/); + }); + + it('renders ghost variant', () => { + const { getByRole } = render( + {}} /> + ); + expect(getByRole('button').className).toMatch(/bg-transparent/); + }); + + it('renders small size', () => { + const { getByRole } = render( + {}} /> + ); + expect(getByRole('button').className).toMatch(/py-1.5/); + }); + + it('does not fire onClick when disabled', () => { + let called = false; + const { getByRole } = render( + { called = true; }} /> + ); + fireEvent.click(getByRole('button')); + expect(called).toBe(false); + }); + + it('does not fire onClick when loading', () => { + let called = false; + const { getByRole } = render( + { called = true; }} /> + ); + fireEvent.click(getByRole('button')); + expect(called).toBe(false); + }); + + it('handles empty string label', () => { + const { getByRole } = render( + {}} /> + ); + expect(getByRole('button')).toBeDefined(); + }); +}); diff --git a/src/components/ui/primary-button.tsx b/src/components/ui/primary-button.tsx new file mode 100644 index 0000000..28f578b --- /dev/null +++ b/src/components/ui/primary-button.tsx @@ -0,0 +1,69 @@ +'use client'; +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { Loader2 } from 'lucide-react'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center font-medium rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-600 dark:focus-visible:ring-primary-400 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none select-none', + { + variants: { + variant: { + primary: + 'bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white shadow-md dark:bg-primary-400 dark:hover:bg-primary-300 dark:active:bg-primary-500', + secondary: + 'bg-secondary-700 hover:bg-secondary-600 active:bg-secondary-800 text-white shadow dark:bg-secondary-300 dark:text-black dark:hover:bg-secondary-200 dark:active:bg-secondary-400', + ghost: + 'bg-transparent hover:bg-primary-100 active:bg-primary-200 text-primary-700 dark:text-primary-300 dark:hover:bg-primary-900 dark:active:bg-primary-800 shadow-none', + }, + size: { + sm: 'px-3 py-1.5 text-sm min-h-8', + md: 'px-4 py-2 text-base min-h-10', + lg: 'px-6 py-3 text-lg min-h-12', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + } +); + +export interface PrimaryButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + label: string; + loading?: boolean; +} + +export const PrimaryButton = React.forwardRef( + function PrimaryButton( + { label, onClick, disabled, loading = false, size, variant, className, ...props }, + ref + ) { + return ( + + ); + } +); +PrimaryButton.displayName = 'PrimaryButton'; diff --git a/src/utils/formatCurrency.test.ts b/src/utils/formatCurrency.test.ts new file mode 100644 index 0000000..251b007 --- /dev/null +++ b/src/utils/formatCurrency.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { formatCurrency } from './formatCurrency'; + +describe('formatCurrency', () => { + it('formats 123.456 as $123.46', () => { + expect(formatCurrency(123.456)).toBe('$123.46'); + }); + it('formats 0 as $0.00', () => { + expect(formatCurrency(0)).toBe('$0.00'); + }); + it('formats -42.8 as -$42.80', () => { + expect(formatCurrency(-42.8)).toBe('-$42.80'); + }); + it('formats 5 as $5.00', () => { + expect(formatCurrency(5)).toBe('$5.00'); + }); + it('formats 9999999.9 as $9999999.90', () => { + expect(formatCurrency(9999999.9)).toBe('$9999999.90'); + }); + it('throws on NaN', () => { + expect(() => formatCurrency(NaN)).toThrow('Input must be a finite number'); + }); + it('throws on Infinity', () => { + expect(() => formatCurrency(Infinity)).toThrow('Input must be a finite number'); + }); + it('throws on -Infinity', () => { + expect(() => formatCurrency(-Infinity)).toThrow('Input must be a finite number'); + }); +}); diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts new file mode 100644 index 0000000..05395dd --- /dev/null +++ b/src/utils/formatCurrency.ts @@ -0,0 +1,8 @@ +export function formatCurrency(value: number): string { + if (typeof value !== 'number' || !isFinite(value)) { + throw new TypeError('Input must be a finite number'); + } + const sign = value < 0 ? '-' : ''; + const absValue = Math.abs(value); + return `${sign}$${absValue.toFixed(2)}`; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..51c6f5d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +export default { + test: { + environment: 'node', + globals: false, + include: ['**/*.test.ts', '**/*.spec.ts'] + } +}