mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
style: format all files with prettier
This commit is contained in:
@@ -21,30 +21,35 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
## Core Capabilities
|
||||
|
||||
### 1. WCAG 2.2 Guidelines
|
||||
|
||||
- Perceivable: Content must be presentable in different ways
|
||||
- Operable: Interface must be navigable with keyboard and assistive tech
|
||||
- Understandable: Content and operation must be clear
|
||||
- Robust: Content must work with current and future assistive technologies
|
||||
|
||||
### 2. ARIA Patterns
|
||||
|
||||
- Roles: Define element purpose (button, dialog, navigation)
|
||||
- States: Indicate current condition (expanded, selected, disabled)
|
||||
- Properties: Describe relationships and additional info (labelledby, describedby)
|
||||
- Live regions: Announce dynamic content changes
|
||||
|
||||
### 3. Keyboard Navigation
|
||||
|
||||
- Focus order and tab sequence
|
||||
- Focus indicators and visible focus states
|
||||
- Keyboard shortcuts and hotkeys
|
||||
- Focus trapping for modals and dialogs
|
||||
|
||||
### 4. Screen Reader Support
|
||||
|
||||
- Semantic HTML structure
|
||||
- Alternative text for images
|
||||
- Proper heading hierarchy
|
||||
- Skip links and landmarks
|
||||
|
||||
### 5. Mobile Accessibility
|
||||
|
||||
- Touch target sizing (44x44dp minimum)
|
||||
- VoiceOver and TalkBack compatibility
|
||||
- Gesture alternatives
|
||||
@@ -54,18 +59,18 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
|
||||
### WCAG 2.2 Success Criteria Checklist
|
||||
|
||||
| Level | Criterion | Description |
|
||||
|-------|-----------|-------------|
|
||||
| A | 1.1.1 | Non-text content has text alternatives |
|
||||
| A | 1.3.1 | Info and relationships programmatically determinable |
|
||||
| A | 2.1.1 | All functionality keyboard accessible |
|
||||
| A | 2.4.1 | Skip to main content mechanism |
|
||||
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
|
||||
| AA | 1.4.11 | Non-text contrast 3:1 |
|
||||
| AA | 2.4.7 | Focus visible |
|
||||
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
|
||||
| AAA | 1.4.6 | Enhanced contrast 7:1 |
|
||||
| AAA | 2.5.5 | Target size minimum 44x44px |
|
||||
| Level | Criterion | Description |
|
||||
| ----- | --------- | ---------------------------------------------------- |
|
||||
| A | 1.1.1 | Non-text content has text alternatives |
|
||||
| A | 1.3.1 | Info and relationships programmatically determinable |
|
||||
| A | 2.1.1 | All functionality keyboard accessible |
|
||||
| A | 2.4.1 | Skip to main content mechanism |
|
||||
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
|
||||
| AA | 1.4.11 | Non-text contrast 3:1 |
|
||||
| AA | 2.4.7 | Focus visible |
|
||||
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
|
||||
| AAA | 1.4.6 | Enhanced contrast 7:1 |
|
||||
| AAA | 2.5.5 | Target size minimum 44x44px |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -73,13 +78,13 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary';
|
||||
variant?: "primary" | "secondary";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function AccessibleButton({
|
||||
children,
|
||||
variant = 'primary',
|
||||
variant = "primary",
|
||||
isLoading = false,
|
||||
disabled,
|
||||
...props
|
||||
@@ -94,11 +99,11 @@ function AccessibleButton({
|
||||
aria-disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
// Visible focus ring
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
// Minimum touch target size (44x44px)
|
||||
'min-h-[44px] min-w-[44px]',
|
||||
variant === 'primary' && 'bg-primary text-primary-foreground',
|
||||
(disabled || isLoading) && 'opacity-50 cursor-not-allowed'
|
||||
"min-h-[44px] min-w-[44px]",
|
||||
variant === "primary" && "bg-primary text-primary-foreground",
|
||||
(disabled || isLoading) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -118,8 +123,8 @@ function AccessibleButton({
|
||||
### Pattern 2: Accessible Modal Dialog
|
||||
|
||||
```tsx
|
||||
import * as React from 'react';
|
||||
import { FocusTrap } from '@headlessui/react';
|
||||
import * as React from "react";
|
||||
import { FocusTrap } from "@headlessui/react";
|
||||
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -135,21 +140,21 @@ function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
|
||||
// Close on Escape key
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -227,7 +232,9 @@ function AccessibleForm() {
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block font-medium">
|
||||
Email address
|
||||
<span aria-hidden="true" className="text-destructive ml-1">*</span>
|
||||
<span aria-hidden="true" className="text-destructive ml-1">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -237,10 +244,10 @@ function AccessibleForm() {
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.email}
|
||||
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
|
||||
aria-describedby={errors.email ? "email-error" : "email-hint"}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-md',
|
||||
errors.email && 'border-destructive'
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
errors.email && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -271,10 +278,10 @@ function SkipLink() {
|
||||
href="#main-content"
|
||||
className={cn(
|
||||
// Hidden by default, visible on focus
|
||||
'sr-only focus:not-sr-only',
|
||||
'focus:absolute focus:top-4 focus:left-4 focus:z-50',
|
||||
'focus:bg-background focus:px-4 focus:py-2 focus:rounded-md',
|
||||
'focus:ring-2 focus:ring-primary'
|
||||
"sr-only focus:not-sr-only",
|
||||
"focus:absolute focus:top-4 focus:left-4 focus:z-50",
|
||||
"focus:bg-background focus:px-4 focus:py-2 focus:rounded-md",
|
||||
"focus:ring-2 focus:ring-primary",
|
||||
)}
|
||||
>
|
||||
Skip to main content
|
||||
@@ -302,12 +309,15 @@ function Layout({ children }) {
|
||||
|
||||
```tsx
|
||||
function useAnnounce() {
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const announce = React.useCallback((text: string, priority: 'polite' | 'assertive' = 'polite') => {
|
||||
setMessage(''); // Clear first to ensure re-announcement
|
||||
setTimeout(() => setMessage(text), 100);
|
||||
}, []);
|
||||
const announce = React.useCallback(
|
||||
(text: string, priority: "polite" | "assertive" = "polite") => {
|
||||
setMessage(""); // Clear first to ensure re-announcement
|
||||
setTimeout(() => setMessage(text), 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const Announcer = () => (
|
||||
<div
|
||||
|
||||
@@ -73,7 +73,7 @@ function Accordion({ items }) {
|
||||
onClick={() => setOpenIndex(isOpen ? -1 : index)}
|
||||
>
|
||||
{item.title}
|
||||
<span aria-hidden="true">{isOpen ? '−' : '+'}</span>
|
||||
<span aria-hidden="true">{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
@@ -103,16 +103,16 @@ function Tabs({ tabs }) {
|
||||
let newIndex = index;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case "ArrowRight":
|
||||
newIndex = (index + 1) % tabs.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case "ArrowLeft":
|
||||
newIndex = (index - 1 + tabs.length) % tabs.length;
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
newIndex = tabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
@@ -172,7 +172,7 @@ function MenuButton({ label, items }) {
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
@@ -181,16 +181,16 @@ function MenuButton({ label, items }) {
|
||||
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (isOpen && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
items[activeIndex].onClick();
|
||||
@@ -253,36 +253,36 @@ function MenuButton({ label, items }) {
|
||||
|
||||
```tsx
|
||||
function Combobox({ options, onSelect, placeholder }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const inputRef = useRef(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
opt.toLowerCase().includes(inputValue.toLowerCase())
|
||||
opt.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex((prev) =>
|
||||
Math.min(prev + 1, filteredOptions.length - 1)
|
||||
Math.min(prev + 1, filteredOptions.length - 1),
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
case "Enter":
|
||||
if (activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectOption(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
break;
|
||||
@@ -396,16 +396,16 @@ function Toolbar({ items }) {
|
||||
let newIndex = activeIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case "ArrowRight":
|
||||
newIndex = (activeIndex + 1) % items.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case "ArrowLeft":
|
||||
newIndex = (activeIndex - 1 + items.length) % items.length;
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
newIndex = items.length - 1;
|
||||
break;
|
||||
default:
|
||||
@@ -414,7 +414,7 @@ function Toolbar({ items }) {
|
||||
|
||||
e.preventDefault();
|
||||
setActiveIndex(newIndex);
|
||||
toolbarRef.current?.querySelectorAll('button')[newIndex]?.focus();
|
||||
toolbarRef.current?.querySelectorAll("button")[newIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,12 +34,11 @@ Mobile accessibility ensures apps work for users with disabilities on iOS and An
|
||||
// Ensure adequate spacing between touch targets
|
||||
function ButtonGroup({ buttons }) {
|
||||
return (
|
||||
<div className="flex gap-3"> {/* 12px minimum gap */}
|
||||
<div className="flex gap-3">
|
||||
{" "}
|
||||
{/* 12px minimum gap */}
|
||||
{buttons.map((btn) => (
|
||||
<button
|
||||
key={btn.id}
|
||||
className="min-w-[44px] min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<button key={btn.id} className="min-w-[44px] min-h-[44px] px-4 py-2">
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -66,7 +65,7 @@ function IconButton({ icon, label, onClick }) {
|
||||
### React Native Accessibility Props
|
||||
|
||||
```tsx
|
||||
import { View, Text, TouchableOpacity, AccessibilityInfo } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, AccessibilityInfo } from "react-native";
|
||||
|
||||
// Basic accessible button
|
||||
function AccessibleButton({ onPress, title, hint }) {
|
||||
@@ -91,15 +90,15 @@ function ProductCard({ product }) {
|
||||
accessibilityLabel={`${product.name}, ${product.price}, ${product.rating} stars`}
|
||||
accessibilityRole="button"
|
||||
accessibilityActions={[
|
||||
{ name: 'activate', label: 'View details' },
|
||||
{ name: 'addToCart', label: 'Add to cart' },
|
||||
{ name: "activate", label: "View details" },
|
||||
{ name: "addToCart", label: "Add to cart" },
|
||||
]}
|
||||
onAccessibilityAction={(event) => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'addToCart':
|
||||
case "addToCart":
|
||||
addToCart(product);
|
||||
break;
|
||||
case 'activate':
|
||||
case "activate":
|
||||
viewDetails(product);
|
||||
break;
|
||||
}
|
||||
@@ -356,11 +355,9 @@ function SwipeableCard({ item, onDelete }) {
|
||||
return (
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{ name: 'delete', label: 'Delete item' },
|
||||
]}
|
||||
accessibilityActions={[{ name: "delete", label: "Delete item" }]}
|
||||
onAccessibilityAction={(event) => {
|
||||
if (event.nativeEvent.actionName === 'delete') {
|
||||
if (event.nativeEvent.actionName === "delete") {
|
||||
onDelete(item);
|
||||
}
|
||||
}}
|
||||
@@ -382,7 +379,7 @@ function SwipeableCard({ item, onDelete }) {
|
||||
<TouchableOpacity
|
||||
accessibilityLabel={`Delete ${item.title}`}
|
||||
onPress={() => onDelete(item)}
|
||||
style={{ position: 'absolute', right: 0 }}
|
||||
style={{ position: "absolute", right: 0 }}
|
||||
>
|
||||
<Text>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -395,7 +392,7 @@ function SwipeableCard({ item, onDelete }) {
|
||||
|
||||
```tsx
|
||||
// Respect reduced motion preference
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
import { AccessibilityInfo } from "react-native";
|
||||
|
||||
function AnimatedComponent() {
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
@@ -404,8 +401,8 @@ function AnimatedComponent() {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
||||
|
||||
const subscription = AccessibilityInfo.addEventListener(
|
||||
'reduceMotionChanged',
|
||||
setReduceMotion
|
||||
"reduceMotionChanged",
|
||||
setReduceMotion,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
@@ -414,9 +411,7 @@ function AnimatedComponent() {
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: reduceMotion
|
||||
? []
|
||||
: [{ translateX: animatedValue }],
|
||||
transform: reduceMotion ? [] : [{ translateX: animatedValue }],
|
||||
opacity: reduceMotion ? 1 : animatedOpacity,
|
||||
}}
|
||||
>
|
||||
@@ -503,6 +498,7 @@ const scaledFontSize = (size: number) => {
|
||||
|
||||
```markdown
|
||||
## VoiceOver (iOS) Testing
|
||||
|
||||
- [ ] All interactive elements have labels
|
||||
- [ ] Swipe navigation covers all content in logical order
|
||||
- [ ] Custom actions available for complex interactions
|
||||
@@ -511,6 +507,7 @@ const scaledFontSize = (size: number) => {
|
||||
- [ ] Images have appropriate descriptions or are hidden
|
||||
|
||||
## TalkBack (Android) Testing
|
||||
|
||||
- [ ] Focus order is logical
|
||||
- [ ] Touch exploration works correctly
|
||||
- [ ] Custom actions available
|
||||
@@ -519,12 +516,14 @@ const scaledFontSize = (size: number) => {
|
||||
- [ ] Grouped content read together
|
||||
|
||||
## Motor Accessibility
|
||||
|
||||
- [ ] Touch targets at least 44x44 points
|
||||
- [ ] Adequate spacing between targets (8dp minimum)
|
||||
- [ ] Alternatives to complex gestures
|
||||
- [ ] No time-limited interactions
|
||||
|
||||
## Visual Accessibility
|
||||
|
||||
- [ ] Text scales to 200% without loss
|
||||
- [ ] Content visible in high contrast mode
|
||||
- [ ] Color not sole indicator
|
||||
|
||||
@@ -259,7 +259,7 @@ function Tooltip({ content, children }) {
|
||||
<div
|
||||
role="tooltip"
|
||||
// Dismissible: user can close without moving pointer
|
||||
onKeyDown={(e) => e.key === 'Escape' && setIsVisible(false)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setIsVisible(false)}
|
||||
// Hoverable: content stays visible when pointer moves to it
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
@@ -292,7 +292,7 @@ function CustomButton({ onClick, children }) {
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
@@ -331,10 +331,10 @@ function Modal({ isOpen, onClose, children }) {
|
||||
// Allow Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
@@ -357,12 +357,18 @@ function Modal({ isOpen, onClose, children }) {
|
||||
```tsx
|
||||
// Skip links
|
||||
<body>
|
||||
<a href="#main" className="skip-link">Skip to main content</a>
|
||||
<a href="#nav" className="skip-link">Skip to navigation</a>
|
||||
<a href="#main" className="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
<a href="#nav" className="skip-link">
|
||||
Skip to navigation
|
||||
</a>
|
||||
|
||||
<header>...</header>
|
||||
|
||||
<nav id="nav" aria-label="Main">...</nav>
|
||||
<nav id="nav" aria-label="Main">
|
||||
...
|
||||
</nav>
|
||||
|
||||
<main id="main" tabIndex={-1}>
|
||||
{/* Main content */}
|
||||
@@ -455,8 +461,12 @@ Content and interface must be understandable.
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>...</head>
|
||||
<body>...</body>
|
||||
<head>
|
||||
...
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
@@ -571,7 +581,7 @@ function CustomCheckbox({ checked, onChange, label }) {
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
{checked ? '✓' : '○'} {label}
|
||||
{checked ? "✓" : "○"} {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -587,8 +597,8 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowRight') onChange(Math.min(value + 1, max));
|
||||
if (e.key === 'ArrowLeft') onChange(Math.max(value - 1, min));
|
||||
if (e.key === "ArrowRight") onChange(Math.min(value + 1, max));
|
||||
if (e.key === "ArrowLeft") onChange(Math.max(value - 1, min));
|
||||
}}
|
||||
>
|
||||
<div style={{ width: `${((value - min) / (max - min)) * 100}%` }} />
|
||||
@@ -601,6 +611,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
|
||||
```markdown
|
||||
## Keyboard Testing
|
||||
|
||||
- [ ] All interactive elements focusable with Tab
|
||||
- [ ] Focus order matches visual order
|
||||
- [ ] Focus indicator always visible
|
||||
@@ -609,6 +620,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
- [ ] Enter/Space activates buttons and links
|
||||
|
||||
## Screen Reader Testing
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Headings in logical order
|
||||
@@ -617,6 +629,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
- [ ] Error messages announced
|
||||
|
||||
## Visual Testing
|
||||
|
||||
- [ ] Text contrast at least 4.5:1
|
||||
- [ ] UI component contrast at least 3:1
|
||||
- [ ] Works at 200% zoom
|
||||
|
||||
@@ -20,6 +20,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
## Core Capabilities
|
||||
|
||||
### 1. Design Tokens
|
||||
|
||||
- Primitive tokens (raw values: colors, sizes, fonts)
|
||||
- Semantic tokens (contextual meaning: text-primary, surface-elevated)
|
||||
- Component tokens (specific usage: button-bg, card-border)
|
||||
@@ -27,6 +28,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Multi-platform token generation (CSS, iOS, Android)
|
||||
|
||||
### 2. Theming Infrastructure
|
||||
|
||||
- CSS custom properties architecture
|
||||
- Theme context providers in React
|
||||
- Dynamic theme switching
|
||||
@@ -35,6 +37,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Reduced motion and high contrast modes
|
||||
|
||||
### 3. Component Architecture
|
||||
|
||||
- Compound component patterns
|
||||
- Polymorphic components (as prop)
|
||||
- Variant and size systems
|
||||
@@ -43,6 +46,7 @@ Master design system architecture to create consistent, maintainable, and scalab
|
||||
- Style props and responsive variants
|
||||
|
||||
### 4. Token Pipeline
|
||||
|
||||
- Figma to code synchronization
|
||||
- Style Dictionary configuration
|
||||
- Token transformation and formatting
|
||||
@@ -56,32 +60,32 @@ const tokens = {
|
||||
colors: {
|
||||
// Primitive tokens
|
||||
gray: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
900: '#171717',
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
900: "#171717",
|
||||
},
|
||||
blue: {
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
},
|
||||
},
|
||||
// Semantic tokens (reference primitives)
|
||||
semantic: {
|
||||
light: {
|
||||
'text-primary': 'var(--color-gray-900)',
|
||||
'text-secondary': 'var(--color-gray-600)',
|
||||
'surface-default': 'var(--color-white)',
|
||||
'surface-elevated': 'var(--color-gray-50)',
|
||||
'border-default': 'var(--color-gray-200)',
|
||||
'interactive-primary': 'var(--color-blue-500)',
|
||||
"text-primary": "var(--color-gray-900)",
|
||||
"text-secondary": "var(--color-gray-600)",
|
||||
"surface-default": "var(--color-white)",
|
||||
"surface-elevated": "var(--color-gray-50)",
|
||||
"border-default": "var(--color-gray-200)",
|
||||
"interactive-primary": "var(--color-blue-500)",
|
||||
},
|
||||
dark: {
|
||||
'text-primary': 'var(--color-gray-50)',
|
||||
'text-secondary': 'var(--color-gray-400)',
|
||||
'surface-default': 'var(--color-gray-900)',
|
||||
'surface-elevated': 'var(--color-gray-800)',
|
||||
'border-default': 'var(--color-gray-700)',
|
||||
'interactive-primary': 'var(--color-blue-400)',
|
||||
"text-primary": "var(--color-gray-50)",
|
||||
"text-secondary": "var(--color-gray-400)",
|
||||
"surface-default": "var(--color-gray-900)",
|
||||
"surface-elevated": "var(--color-gray-800)",
|
||||
"border-default": "var(--color-gray-700)",
|
||||
"interactive-primary": "var(--color-blue-400)",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -135,13 +139,13 @@ const tokens = {
|
||||
### Pattern 2: Theme Switching with React
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
@@ -149,37 +153,37 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("theme") as Theme) || "system";
|
||||
}
|
||||
return 'system';
|
||||
return "system";
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const applyTheme = (isDark: boolean) => {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(isDark ? 'dark' : 'light');
|
||||
setResolvedTheme(isDark ? 'dark' : 'light');
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
setResolvedTheme(isDark ? "dark" : "light");
|
||||
};
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
if (theme === "system") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
applyTheme(mediaQuery.matches);
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
} else {
|
||||
applyTheme(theme === 'dark');
|
||||
applyTheme(theme === "dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('theme', theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
@@ -191,7 +195,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
||||
if (!context) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return context;
|
||||
};
|
||||
```
|
||||
@@ -199,45 +203,52 @@ export const useTheme = () => {
|
||||
### Pattern 3: Variant System with CVA
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-11 px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -247,44 +258,52 @@ export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
```javascript
|
||||
// style-dictionary.config.js
|
||||
module.exports = {
|
||||
source: ['tokens/**/*.json'],
|
||||
source: ["tokens/**/*.json"],
|
||||
platforms: {
|
||||
css: {
|
||||
transformGroup: 'css',
|
||||
buildPath: 'dist/css/',
|
||||
files: [{
|
||||
destination: 'variables.css',
|
||||
format: 'css/variables',
|
||||
options: {
|
||||
outputReferences: true, // Preserve token references
|
||||
transformGroup: "css",
|
||||
buildPath: "dist/css/",
|
||||
files: [
|
||||
{
|
||||
destination: "variables.css",
|
||||
format: "css/variables",
|
||||
options: {
|
||||
outputReferences: true, // Preserve token references
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
},
|
||||
scss: {
|
||||
transformGroup: 'scss',
|
||||
buildPath: 'dist/scss/',
|
||||
files: [{
|
||||
destination: '_variables.scss',
|
||||
format: 'scss/variables',
|
||||
}],
|
||||
transformGroup: "scss",
|
||||
buildPath: "dist/scss/",
|
||||
files: [
|
||||
{
|
||||
destination: "_variables.scss",
|
||||
format: "scss/variables",
|
||||
},
|
||||
],
|
||||
},
|
||||
ios: {
|
||||
transformGroup: 'ios-swift',
|
||||
buildPath: 'dist/ios/',
|
||||
files: [{
|
||||
destination: 'DesignTokens.swift',
|
||||
format: 'ios-swift/class.swift',
|
||||
className: 'DesignTokens',
|
||||
}],
|
||||
transformGroup: "ios-swift",
|
||||
buildPath: "dist/ios/",
|
||||
files: [
|
||||
{
|
||||
destination: "DesignTokens.swift",
|
||||
format: "ios-swift/class.swift",
|
||||
className: "DesignTokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
android: {
|
||||
transformGroup: 'android',
|
||||
buildPath: 'dist/android/',
|
||||
files: [{
|
||||
destination: 'colors.xml',
|
||||
format: 'android/colors',
|
||||
filter: { attributes: { category: 'color' } },
|
||||
}],
|
||||
transformGroup: "android",
|
||||
buildPath: "dist/android/",
|
||||
files: [
|
||||
{
|
||||
destination: "colors.xml",
|
||||
format: "android/colors",
|
||||
filter: { attributes: { category: "color" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,20 +10,22 @@ Compound components share implicit state through React context, allowing flexibl
|
||||
|
||||
```tsx
|
||||
// Compound component pattern
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
type: 'single' | 'multiple';
|
||||
type: "single" | "multiple";
|
||||
}
|
||||
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useAccordionContext() {
|
||||
const context = React.useContext(AccordionContext);
|
||||
if (!context) {
|
||||
throw new Error('Accordion components must be used within an Accordion');
|
||||
throw new Error("Accordion components must be used within an Accordion");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -31,13 +33,17 @@ function useAccordionContext() {
|
||||
// Root component
|
||||
interface AccordionProps {
|
||||
children: React.ReactNode;
|
||||
type?: 'single' | 'multiple';
|
||||
type?: "single" | "multiple";
|
||||
defaultOpen?: string[];
|
||||
}
|
||||
|
||||
function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionProps) {
|
||||
function Accordion({
|
||||
children,
|
||||
type = "single",
|
||||
defaultOpen = [],
|
||||
}: AccordionProps) {
|
||||
const [openItems, setOpenItems] = React.useState<Set<string>>(
|
||||
new Set(defaultOpen)
|
||||
new Set(defaultOpen),
|
||||
);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
@@ -47,7 +53,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
if (type === 'single') {
|
||||
if (type === "single") {
|
||||
next.clear();
|
||||
}
|
||||
next.add(id);
|
||||
@@ -55,7 +61,7 @@ function Accordion({ children, type = 'single', defaultOpen = [] }: AccordionPro
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[type]
|
||||
[type],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -93,7 +99,7 @@ function AccordionTrigger({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -120,7 +126,7 @@ export const AccordionCompound = Object.assign(Accordion, {
|
||||
// Usage
|
||||
function Example() {
|
||||
return (
|
||||
<AccordionCompound type="single" defaultOpen={['item-1']}>
|
||||
<AccordionCompound type="single" defaultOpen={["item-1"]}>
|
||||
<AccordionCompound.Item id="item-1">
|
||||
<AccordionCompound.Trigger>Is it accessible?</AccordionCompound.Trigger>
|
||||
<AccordionCompound.Content>
|
||||
@@ -144,7 +150,7 @@ Polymorphic components can render as different HTML elements or other components
|
||||
|
||||
```tsx
|
||||
// Polymorphic component with proper TypeScript support
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type AsProp<C extends React.ElementType> = {
|
||||
as?: C;
|
||||
@@ -154,64 +160,71 @@ type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
|
||||
|
||||
type PolymorphicComponentProp<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
Props = {},
|
||||
> = React.PropsWithChildren<Props & AsProp<C>> &
|
||||
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
|
||||
|
||||
type PolymorphicRef<C extends React.ElementType> =
|
||||
React.ComponentPropsWithRef<C>['ref'];
|
||||
React.ComponentPropsWithRef<C>["ref"];
|
||||
|
||||
type PolymorphicComponentPropWithRef<
|
||||
C extends React.ElementType,
|
||||
Props = {}
|
||||
Props = {},
|
||||
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
|
||||
|
||||
// Button component
|
||||
interface ButtonOwnProps {
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: "default" | "outline" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
type ButtonProps<C extends React.ElementType = 'button'> =
|
||||
type ButtonProps<C extends React.ElementType = "button"> =
|
||||
PolymorphicComponentPropWithRef<C, ButtonOwnProps>;
|
||||
|
||||
const Button = React.forwardRef(
|
||||
<C extends React.ElementType = 'button'>(
|
||||
{ as, variant = 'default', size = 'md', className, children, ...props }: ButtonProps<C>,
|
||||
ref?: PolymorphicRef<C>
|
||||
<C extends React.ElementType = "button">(
|
||||
{
|
||||
as,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps<C>,
|
||||
ref?: PolymorphicRef<C>,
|
||||
) => {
|
||||
const Component = as || 'button';
|
||||
const Component = as || "button";
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline: "border border-input bg-background hover:bg-accent",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
|
||||
// Usage
|
||||
function Example() {
|
||||
@@ -242,31 +255,31 @@ Slots allow users to replace default elements with custom implementations.
|
||||
|
||||
```tsx
|
||||
// Slot pattern for customizable components
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
variant?: 'default' | 'outline';
|
||||
variant?: "default" | "outline";
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ asChild = false, variant = 'default', className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
({ asChild = false, variant = "default", className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
variant === 'default' && 'bg-primary text-primary-foreground',
|
||||
variant === 'outline' && 'border border-input bg-background',
|
||||
className
|
||||
"inline-flex items-center justify-center rounded-md font-medium",
|
||||
variant === "default" && "bg-primary text-primary-foreground",
|
||||
variant === "outline" && "border border-input bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Usage - Button styles applied to child element
|
||||
@@ -285,7 +298,7 @@ Headless components provide behavior without styling, enabling complete visual c
|
||||
|
||||
```tsx
|
||||
// Headless toggle hook
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
interface UseToggleProps {
|
||||
defaultPressed?: boolean;
|
||||
@@ -315,8 +328,8 @@ function useToggle({
|
||||
pressed,
|
||||
toggle,
|
||||
buttonProps: {
|
||||
role: 'switch' as const,
|
||||
'aria-checked': pressed,
|
||||
role: "switch" as const,
|
||||
"aria-checked": pressed,
|
||||
onClick: toggle,
|
||||
},
|
||||
};
|
||||
@@ -334,7 +347,8 @@ function useListbox<T>({
|
||||
defaultSelectedIndex = -1,
|
||||
onSelect,
|
||||
}: UseListboxProps<T>) {
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(defaultSelectedIndex);
|
||||
const [selectedIndex, setSelectedIndex] =
|
||||
React.useState(defaultSelectedIndex);
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
||||
|
||||
const select = React.useCallback(
|
||||
@@ -342,40 +356,40 @@ function useListbox<T>({
|
||||
setSelectedIndex(index);
|
||||
onSelect?.(items[index], index);
|
||||
},
|
||||
[items, onSelect]
|
||||
[items, onSelect],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < items.length - 1 ? prev + 1 : prev
|
||||
prev < items.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
select(highlightedIndex);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(items.length - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[items.length, highlightedIndex, select]
|
||||
[items.length, highlightedIndex, select],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -384,13 +398,13 @@ function useListbox<T>({
|
||||
select,
|
||||
setHighlightedIndex,
|
||||
listboxProps: {
|
||||
role: 'listbox' as const,
|
||||
role: "listbox" as const,
|
||||
tabIndex: 0,
|
||||
onKeyDown: handleKeyDown,
|
||||
},
|
||||
getOptionProps: (index: number) => ({
|
||||
role: 'option' as const,
|
||||
'aria-selected': index === selectedIndex,
|
||||
role: "option" as const,
|
||||
"aria-selected": index === selectedIndex,
|
||||
onClick: () => select(index),
|
||||
onMouseEnter: () => setHighlightedIndex(index),
|
||||
}),
|
||||
@@ -461,28 +475,28 @@ function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
## Responsive Variants
|
||||
|
||||
```tsx
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
// Responsive variant configuration
|
||||
const containerVariants = cva('mx-auto w-full px-4', {
|
||||
const containerVariants = cva("mx-auto w-full px-4", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
full: 'max-w-full',
|
||||
sm: "max-w-screen-sm",
|
||||
md: "max-w-screen-md",
|
||||
lg: "max-w-screen-lg",
|
||||
xl: "max-w-screen-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
padding: {
|
||||
none: 'px-0',
|
||||
sm: 'px-4 md:px-6',
|
||||
md: 'px-4 md:px-8 lg:px-12',
|
||||
lg: 'px-6 md:px-12 lg:px-20',
|
||||
none: "px-0",
|
||||
sm: "px-4 md:px-6",
|
||||
md: "px-4 md:px-8 lg:px-12",
|
||||
lg: "px-6 md:px-12 lg:px-20",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'lg',
|
||||
padding: 'md',
|
||||
size: "lg",
|
||||
padding: "md",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -498,23 +512,23 @@ interface ResponsiveValue<T> {
|
||||
function getResponsiveClasses<T extends string>(
|
||||
prop: T | ResponsiveValue<T> | undefined,
|
||||
classMap: Record<T, string>,
|
||||
responsiveClassMap: Record<string, Record<T, string>>
|
||||
responsiveClassMap: Record<string, Record<T, string>>,
|
||||
): string {
|
||||
if (!prop) return '';
|
||||
if (!prop) return "";
|
||||
|
||||
if (typeof prop === 'string') {
|
||||
if (typeof prop === "string") {
|
||||
return classMap[prop];
|
||||
}
|
||||
|
||||
return Object.entries(prop)
|
||||
.map(([breakpoint, value]) => {
|
||||
if (breakpoint === 'base') {
|
||||
if (breakpoint === "base") {
|
||||
return classMap[value as T];
|
||||
}
|
||||
return responsiveClassMap[breakpoint]?.[value as T];
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
}
|
||||
```
|
||||
|
||||
@@ -555,7 +569,7 @@ function DataList<T>({
|
||||
keyExtractor={(user) => user.id}
|
||||
renderItem={(user) => <UserCard user={user} />}
|
||||
renderEmpty={() => <EmptyState message="No users found" />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
### Children as Function
|
||||
@@ -577,11 +591,11 @@ function Disclosure({ children, defaultOpen = false }: DisclosureProps) {
|
||||
<Disclosure>
|
||||
{({ isOpen, toggle }) => (
|
||||
<>
|
||||
<button onClick={toggle}>{isOpen ? 'Close' : 'Open'}</button>
|
||||
<button onClick={toggle}>{isOpen ? "Close" : "Open"}</button>
|
||||
{isOpen && <div>Content</div>}
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
</Disclosure>;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -129,9 +129,15 @@ Design tokens are the atomic values of a design system - the smallest pieces tha
|
||||
{
|
||||
"shadow": {
|
||||
"sm": { "value": "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
|
||||
"md": { "value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" },
|
||||
"lg": { "value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)" },
|
||||
"xl": { "value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)" }
|
||||
"md": {
|
||||
"value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"lg": {
|
||||
"value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"xl": {
|
||||
"value": "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"none": { "value": "0" },
|
||||
@@ -302,13 +308,13 @@ Examples:
|
||||
### Style Dictionary Transforms
|
||||
|
||||
```javascript
|
||||
const StyleDictionary = require('style-dictionary');
|
||||
const StyleDictionary = require("style-dictionary");
|
||||
|
||||
// Custom transform for px to rem
|
||||
StyleDictionary.registerTransform({
|
||||
name: 'size/pxToRem',
|
||||
type: 'value',
|
||||
matcher: (token) => token.attributes.category === 'size',
|
||||
name: "size/pxToRem",
|
||||
type: "value",
|
||||
matcher: (token) => token.attributes.category === "size",
|
||||
transformer: (token) => {
|
||||
const value = parseFloat(token.value);
|
||||
return `${value / 16}rem`;
|
||||
@@ -317,14 +323,14 @@ StyleDictionary.registerTransform({
|
||||
|
||||
// Custom format for CSS custom properties
|
||||
StyleDictionary.registerFormat({
|
||||
name: 'css/customProperties',
|
||||
formatter: function({ dictionary, options }) {
|
||||
const tokens = dictionary.allTokens.map(token => {
|
||||
const name = token.name.replace(/\./g, '-');
|
||||
name: "css/customProperties",
|
||||
formatter: function ({ dictionary, options }) {
|
||||
const tokens = dictionary.allTokens.map((token) => {
|
||||
const name = token.name.replace(/\./g, "-");
|
||||
return ` --${name}: ${token.value};`;
|
||||
});
|
||||
|
||||
return `:root {\n${tokens.join('\n')}\n}`;
|
||||
return `:root {\n${tokens.join("\n")}\n}`;
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -399,10 +405,10 @@ interface TokenValidation {
|
||||
function validateContrast(
|
||||
foreground: string,
|
||||
background: string,
|
||||
level: 'AA' | 'AAA' = 'AA'
|
||||
level: "AA" | "AAA" = "AA",
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(foreground, background);
|
||||
return level === 'AA' ? ratio >= 4.5 : ratio >= 7;
|
||||
return level === "AA" ? ratio >= 4.5 : ratio >= 7;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* Base tokens that don't change */
|
||||
--font-sans: Inter, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* Animation tokens */
|
||||
--duration-fast: 150ms;
|
||||
@@ -34,7 +34,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* 2. Light theme (default) */
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
--color-bg-muted: #f1f5f9;
|
||||
@@ -57,7 +57,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
}
|
||||
|
||||
/* 3. Dark theme */
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0f172a;
|
||||
--color-bg-subtle: #1e293b;
|
||||
--color-bg-muted: #334155;
|
||||
@@ -81,7 +81,7 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
/* 4. System preference detection */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
:root:not([data-theme="light"]) {
|
||||
/* Inherit dark theme values */
|
||||
--color-bg: #0f172a;
|
||||
/* ... other dark values */
|
||||
@@ -129,16 +129,16 @@ A robust theming system enables applications to support multiple visual appearan
|
||||
|
||||
```tsx
|
||||
// theme-provider.tsx
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
attribute?: 'class' | 'data-theme';
|
||||
attribute?: "class" | "data-theme";
|
||||
enableSystem?: boolean;
|
||||
disableTransitionOnChange?: boolean;
|
||||
}
|
||||
@@ -150,31 +150,32 @@ interface ThemeProviderState {
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(
|
||||
undefined
|
||||
);
|
||||
const ThemeProviderContext = React.createContext<
|
||||
ThemeProviderState | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'theme',
|
||||
attribute = 'data-theme',
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
attribute = "data-theme",
|
||||
enableSystem = true,
|
||||
disableTransitionOnChange = false,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return defaultTheme;
|
||||
if (typeof window === "undefined") return defaultTheme;
|
||||
return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<ResolvedTheme>('light');
|
||||
const [resolvedTheme, setResolvedTheme] =
|
||||
React.useState<ResolvedTheme>("light");
|
||||
|
||||
// Get system preference
|
||||
const getSystemTheme = React.useCallback((): ResolvedTheme => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}, []);
|
||||
|
||||
// Apply theme to DOM
|
||||
@@ -184,11 +185,11 @@ export function ThemeProvider({
|
||||
|
||||
// Disable transitions temporarily
|
||||
if (disableTransitionOnChange) {
|
||||
const css = document.createElement('style');
|
||||
const css = document.createElement("style");
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`*,*::before,*::after{transition:none!important}`
|
||||
)
|
||||
`*,*::before,*::after{transition:none!important}`,
|
||||
),
|
||||
);
|
||||
document.head.appendChild(css);
|
||||
|
||||
@@ -202,8 +203,8 @@ export function ThemeProvider({
|
||||
}
|
||||
|
||||
// Apply attribute
|
||||
if (attribute === 'class') {
|
||||
root.classList.remove('light', 'dark');
|
||||
if (attribute === "class") {
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(newTheme);
|
||||
} else {
|
||||
root.setAttribute(attribute, newTheme);
|
||||
@@ -214,27 +215,27 @@ export function ThemeProvider({
|
||||
|
||||
setResolvedTheme(newTheme);
|
||||
},
|
||||
[attribute, disableTransitionOnChange]
|
||||
[attribute, disableTransitionOnChange],
|
||||
);
|
||||
|
||||
// Handle theme changes
|
||||
React.useEffect(() => {
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
applyTheme(resolved);
|
||||
}, [theme, applyTheme, getSystemTheme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
React.useEffect(() => {
|
||||
if (!enableSystem || theme !== 'system') return;
|
||||
if (!enableSystem || theme !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const handleChange = () => {
|
||||
applyTheme(getSystemTheme());
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme, enableSystem, applyTheme, getSystemTheme]);
|
||||
|
||||
// Persist to localStorage
|
||||
@@ -243,11 +244,11 @@ export function ThemeProvider({
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setThemeState(newTheme);
|
||||
},
|
||||
[storageKey]
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
|
||||
setTheme(resolvedTheme === "light" ? "dark" : "light");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
@@ -257,7 +258,7 @@ export function ThemeProvider({
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, toggleTheme]
|
||||
[theme, resolvedTheme, setTheme, toggleTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -270,7 +271,7 @@ export function ThemeProvider({
|
||||
export function useTheme() {
|
||||
const context = React.useContext(ThemeProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -280,8 +281,8 @@ export function useTheme() {
|
||||
|
||||
```tsx
|
||||
// theme-toggle.tsx
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTheme } from './theme-provider';
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -289,27 +290,27 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
onClick={() => setTheme("light")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'light' ? 'bg-background shadow-sm' : ''
|
||||
theme === "light" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="Light theme"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
onClick={() => setTheme("dark")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'dark' ? 'bg-background shadow-sm' : ''
|
||||
theme === "dark" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="Dark theme"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
onClick={() => setTheme("system")}
|
||||
className={`rounded-md p-2 ${
|
||||
theme === 'system' ? 'bg-background shadow-sm' : ''
|
||||
theme === "system" ? "bg-background shadow-sm" : ""
|
||||
}`}
|
||||
aria-label="System theme"
|
||||
>
|
||||
@@ -326,42 +327,42 @@ export function ThemeToggle() {
|
||||
|
||||
```css
|
||||
/* Brand A - Corporate Blue */
|
||||
[data-brand='corporate'] {
|
||||
[data-brand="corporate"] {
|
||||
--brand-primary: #0066cc;
|
||||
--brand-primary-hover: #0052a3;
|
||||
--brand-secondary: #f0f7ff;
|
||||
--brand-accent: #00a3e0;
|
||||
|
||||
--brand-font-heading: 'Helvetica Neue', sans-serif;
|
||||
--brand-font-body: 'Open Sans', sans-serif;
|
||||
--brand-font-heading: "Helvetica Neue", sans-serif;
|
||||
--brand-font-body: "Open Sans", sans-serif;
|
||||
|
||||
--brand-radius: 0.25rem;
|
||||
--brand-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Brand B - Modern Startup */
|
||||
[data-brand='startup'] {
|
||||
[data-brand="startup"] {
|
||||
--brand-primary: #7c3aed;
|
||||
--brand-primary-hover: #6d28d9;
|
||||
--brand-secondary: #faf5ff;
|
||||
--brand-accent: #f472b6;
|
||||
|
||||
--brand-font-heading: 'Poppins', sans-serif;
|
||||
--brand-font-body: 'Inter', sans-serif;
|
||||
--brand-font-heading: "Poppins", sans-serif;
|
||||
--brand-font-body: "Inter", sans-serif;
|
||||
|
||||
--brand-radius: 1rem;
|
||||
--brand-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
/* Brand C - Minimal */
|
||||
[data-brand='minimal'] {
|
||||
[data-brand="minimal"] {
|
||||
--brand-primary: #171717;
|
||||
--brand-primary-hover: #404040;
|
||||
--brand-secondary: #fafafa;
|
||||
--brand-accent: #171717;
|
||||
|
||||
--brand-font-heading: 'Space Grotesk', sans-serif;
|
||||
--brand-font-body: 'IBM Plex Sans', sans-serif;
|
||||
--brand-font-heading: "Space Grotesk", sans-serif;
|
||||
--brand-font-body: "IBM Plex Sans", sans-serif;
|
||||
|
||||
--brand-radius: 0;
|
||||
--brand-shadow: none;
|
||||
@@ -402,7 +403,7 @@ export function ThemeToggle() {
|
||||
--color-accent: #0000ee;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--color-text: #ffffff;
|
||||
--color-text-muted: #ffffff;
|
||||
--color-bg: #000000;
|
||||
@@ -470,9 +471,9 @@ export default function RootLayout({ children }) {
|
||||
|
||||
```tsx
|
||||
// theme.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider, useTheme } from './theme-provider';
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ThemeProvider, useTheme } from "./theme-provider";
|
||||
|
||||
function TestComponent() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
@@ -480,34 +481,34 @@ function TestComponent() {
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||
<button onClick={() => setTheme("dark")}>Set Dark</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
it('should default to system theme', () => {
|
||||
describe("ThemeProvider", () => {
|
||||
it("should default to system theme", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('system');
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||
});
|
||||
|
||||
it('should switch to dark theme', async () => {
|
||||
it("should switch to dark theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Set Dark'));
|
||||
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
await user.click(screen.getByText("Set Dark"));
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("dark");
|
||||
expect(document.documentElement).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
@@ -23,6 +23,7 @@ Create engaging, intuitive interactions through motion, feedback, and thoughtful
|
||||
### 1. Purposeful Motion
|
||||
|
||||
Motion should communicate, not decorate:
|
||||
|
||||
- **Feedback**: Confirm user actions occurred
|
||||
- **Orientation**: Show where elements come from/go to
|
||||
- **Focus**: Direct attention to important changes
|
||||
@@ -30,27 +31,27 @@ Motion should communicate, not decorate:
|
||||
|
||||
### 2. Timing Guidelines
|
||||
|
||||
| Duration | Use Case |
|
||||
|----------|----------|
|
||||
| 100-150ms | Micro-feedback (hovers, clicks) |
|
||||
| 200-300ms | Small transitions (toggles, dropdowns) |
|
||||
| Duration | Use Case |
|
||||
| --------- | ----------------------------------------- |
|
||||
| 100-150ms | Micro-feedback (hovers, clicks) |
|
||||
| 200-300ms | Small transitions (toggles, dropdowns) |
|
||||
| 300-500ms | Medium transitions (modals, page changes) |
|
||||
| 500ms+ | Complex choreographed animations |
|
||||
| 500ms+ | Complex choreographed animations |
|
||||
|
||||
### 3. Easing Functions
|
||||
|
||||
```css
|
||||
/* Common easings */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate - entering */
|
||||
--ease-in: cubic-bezier(0.55, 0, 1, 0.45); /* Accelerate - exiting */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* Both - moving between */
|
||||
--spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot - playful */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate - entering */
|
||||
--ease-in: cubic-bezier(0.55, 0, 1, 0.45); /* Accelerate - exiting */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* Both - moving between */
|
||||
--spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot - playful */
|
||||
```
|
||||
|
||||
## Quick Start: Button Microinteraction
|
||||
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function InteractiveButton({ children, onClick }) {
|
||||
return (
|
||||
@@ -58,7 +59,7 @@ export function InteractiveButton({ children, onClick }) {
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
{children}
|
||||
@@ -72,6 +73,7 @@ export function InteractiveButton({ children, onClick }) {
|
||||
### 1. Loading States
|
||||
|
||||
**Skeleton Screens**: Preserve layout while loading
|
||||
|
||||
```tsx
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
@@ -85,6 +87,7 @@ function CardSkeleton() {
|
||||
```
|
||||
|
||||
**Progress Indicators**: Show determinate progress
|
||||
|
||||
```tsx
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
@@ -93,7 +96,7 @@ function ProgressBar({ progress }: { progress: number }) {
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ ease: 'easeOut' }}
|
||||
transition={{ ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -103,6 +106,7 @@ function ProgressBar({ progress }: { progress: number }) {
|
||||
### 2. State Transitions
|
||||
|
||||
**Toggle with smooth transition**:
|
||||
|
||||
```tsx
|
||||
function Toggle({ checked, onChange }) {
|
||||
return (
|
||||
@@ -112,13 +116,13 @@ function Toggle({ checked, onChange }) {
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`
|
||||
relative w-12 h-6 rounded-full transition-colors duration-200
|
||||
${checked ? 'bg-blue-600' : 'bg-gray-300'}
|
||||
${checked ? "bg-blue-600" : "bg-gray-300"}
|
||||
`}
|
||||
>
|
||||
<motion.span
|
||||
className="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow"
|
||||
animate={{ x: checked ? 24 : 0 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -128,8 +132,9 @@ function Toggle({ checked, onChange }) {
|
||||
### 3. Page Transitions
|
||||
|
||||
**Framer Motion layout animations**:
|
||||
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
function PageTransition({ children, key }) {
|
||||
return (
|
||||
@@ -151,6 +156,7 @@ function PageTransition({ children, key }) {
|
||||
### 4. Feedback Patterns
|
||||
|
||||
**Ripple effect on click**:
|
||||
|
||||
```tsx
|
||||
function RippleButton({ children, onClick }) {
|
||||
const [ripples, setRipples] = useState([]);
|
||||
@@ -162,9 +168,9 @@ function RippleButton({ children, onClick }) {
|
||||
y: e.clientY - rect.top,
|
||||
id: Date.now(),
|
||||
};
|
||||
setRipples(prev => [...prev, ripple]);
|
||||
setRipples((prev) => [...prev, ripple]);
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== ripple.id));
|
||||
setRipples((prev) => prev.filter((r) => r.id !== ripple.id));
|
||||
}, 600);
|
||||
onClick?.(e);
|
||||
};
|
||||
@@ -172,7 +178,7 @@ function RippleButton({ children, onClick }) {
|
||||
return (
|
||||
<button onClick={handleClick} className="relative overflow-hidden">
|
||||
{children}
|
||||
{ripples.map(ripple => (
|
||||
{ripples.map((ripple) => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white/30 rounded-full animate-ripple"
|
||||
@@ -187,6 +193,7 @@ function RippleButton({ children, onClick }) {
|
||||
### 5. Gesture Interactions
|
||||
|
||||
**Swipe to dismiss**:
|
||||
|
||||
```tsx
|
||||
function SwipeCard({ children, onDismiss }) {
|
||||
return (
|
||||
@@ -212,29 +219,50 @@ function SwipeCard({ children, onDismiss }) {
|
||||
|
||||
```css
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn { animation: fadeIn 0.3s ease-out; }
|
||||
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Transitions
|
||||
|
||||
```css
|
||||
.card {
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
transition:
|
||||
transform 0.2s ease-out,
|
||||
box-shadow 0.2s ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@@ -248,7 +276,9 @@ function SwipeCard({ children, onDismiss }) {
|
||||
```css
|
||||
/* Respect user motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
@@ -259,7 +289,7 @@ function SwipeCard({ children, onDismiss }) {
|
||||
```tsx
|
||||
function AnimatedComponent() {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)'
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ The most popular React animation library with declarative API.
|
||||
### Basic Animations
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
// Simple animation
|
||||
function FadeIn({ children }) {
|
||||
@@ -29,7 +29,7 @@ function InteractiveCard() {
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
className="p-6 bg-white rounded-lg shadow"
|
||||
>
|
||||
Hover or tap me
|
||||
@@ -44,9 +44,9 @@ function PulseButton() {
|
||||
animate={{
|
||||
scale: [1, 1.05, 1],
|
||||
boxShadow: [
|
||||
'0 0 0 0 rgba(59, 130, 246, 0.5)',
|
||||
'0 0 0 10px rgba(59, 130, 246, 0)',
|
||||
'0 0 0 0 rgba(59, 130, 246, 0)',
|
||||
"0 0 0 0 rgba(59, 130, 246, 0.5)",
|
||||
"0 0 0 10px rgba(59, 130, 246, 0)",
|
||||
"0 0 0 0 rgba(59, 130, 246, 0)",
|
||||
],
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
@@ -61,7 +61,7 @@ function PulseButton() {
|
||||
### Layout Animations
|
||||
|
||||
```tsx
|
||||
import { motion, LayoutGroup } from 'framer-motion';
|
||||
import { motion, LayoutGroup } from "framer-motion";
|
||||
|
||||
// Shared layout animation
|
||||
function TabIndicator({ activeTab, tabs }) {
|
||||
@@ -78,7 +78,7 @@ function TabIndicator({ activeTab, tabs }) {
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -131,11 +131,7 @@ const itemVariants = {
|
||||
|
||||
function StaggeredList({ items }) {
|
||||
return (
|
||||
<motion.ul
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
|
||||
{items.map((item) => (
|
||||
<motion.li key={item.id} variants={itemVariants}>
|
||||
{item.content}
|
||||
@@ -149,8 +145,8 @@ function StaggeredList({ items }) {
|
||||
### Page Transitions
|
||||
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, x: -20 },
|
||||
@@ -185,8 +181,8 @@ Industry-standard animation library for complex, performant animations.
|
||||
### Basic Timeline
|
||||
|
||||
```tsx
|
||||
import { useRef, useLayoutEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { useRef, useLayoutEffect } from "react";
|
||||
import gsap from "gsap";
|
||||
|
||||
function AnimatedHero() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -195,7 +191,7 @@ function AnimatedHero() {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
|
||||
|
||||
tl.from(titleRef.current, {
|
||||
y: 50,
|
||||
@@ -209,9 +205,9 @@ function AnimatedHero() {
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
},
|
||||
'-=0.4' // Start 0.4s before previous ends
|
||||
"-=0.4", // Start 0.4s before previous ends
|
||||
)
|
||||
.from('.cta-button', {
|
||||
.from(".cta-button", {
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: 0.4,
|
||||
@@ -234,9 +230,9 @@ function AnimatedHero() {
|
||||
### ScrollTrigger
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -249,24 +245,24 @@ function ParallaxSection() {
|
||||
// Parallax image
|
||||
gsap.to(imageRef.current, {
|
||||
yPercent: -20,
|
||||
ease: 'none',
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fade in content
|
||||
gsap.from('.content-block', {
|
||||
gsap.from(".content-block", {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
stagger: 0.2,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top 80%',
|
||||
end: 'top 20%',
|
||||
start: "top 80%",
|
||||
end: "top 20%",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
@@ -290,9 +286,9 @@ function ParallaxSection() {
|
||||
### Text Animation
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { SplitText } from 'gsap/SplitText';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
|
||||
gsap.registerPlugin(SplitText);
|
||||
|
||||
@@ -301,8 +297,8 @@ function AnimatedHeadline({ text }) {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const split = new SplitText(textRef.current, {
|
||||
type: 'chars,words',
|
||||
charsClass: 'char',
|
||||
type: "chars,words",
|
||||
charsClass: "char",
|
||||
});
|
||||
|
||||
gsap.from(split.chars, {
|
||||
@@ -311,7 +307,7 @@ function AnimatedHeadline({ text }) {
|
||||
rotateX: -90,
|
||||
stagger: 0.02,
|
||||
duration: 0.8,
|
||||
ease: 'back.out(1.7)',
|
||||
ease: "back.out(1.7)",
|
||||
});
|
||||
|
||||
return () => split.revert();
|
||||
@@ -362,7 +358,7 @@ Native browser animation API for simple animations.
|
||||
function useWebAnimation(
|
||||
ref: RefObject<HTMLElement>,
|
||||
keyframes: Keyframe[],
|
||||
options: KeyframeAnimationOptions
|
||||
options: KeyframeAnimationOptions,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -380,14 +376,14 @@ function SlideIn({ children }) {
|
||||
useWebAnimation(
|
||||
elementRef,
|
||||
[
|
||||
{ transform: 'translateX(-100%)', opacity: 0 },
|
||||
{ transform: 'translateX(0)', opacity: 1 },
|
||||
{ transform: "translateX(-100%)", opacity: 0 },
|
||||
{ transform: "translateX(0)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
fill: 'forwards',
|
||||
}
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
fill: "forwards",
|
||||
},
|
||||
);
|
||||
|
||||
return <div ref={elementRef}>{children}</div>;
|
||||
@@ -400,7 +396,7 @@ Native browser API for page transitions.
|
||||
|
||||
```tsx
|
||||
// Check support
|
||||
const supportsViewTransitions = 'startViewTransition' in document;
|
||||
const supportsViewTransitions = "startViewTransition" in document;
|
||||
|
||||
// Simple page transition
|
||||
async function navigateTo(url: string) {
|
||||
@@ -456,7 +452,9 @@ function ProductCard({ product }) {
|
||||
|
||||
/* Only animate transform and opacity for 60fps */
|
||||
.smooth {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Avoid animating these (cause reflow) */
|
||||
@@ -472,12 +470,12 @@ function useReducedMotion() {
|
||||
const [prefersReduced, setPrefersReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setPrefersReduced(mq.matches);
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return prefersReduced;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
### Loading Button
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface LoadingButtonProps {
|
||||
isLoading: boolean;
|
||||
@@ -71,17 +71,19 @@ function Spinner({ className }: { className?: string }) {
|
||||
|
||||
```tsx
|
||||
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [state, setState] = useState<"idle" | "loading" | "success" | "error">(
|
||||
"idle",
|
||||
);
|
||||
|
||||
const handleClick = async () => {
|
||||
setState('loading');
|
||||
setState("loading");
|
||||
try {
|
||||
await onSubmit();
|
||||
setState('success');
|
||||
setTimeout(() => setState('idle'), 2000);
|
||||
setState("success");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} catch {
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 2000);
|
||||
setState("error");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,18 +95,20 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
|
||||
};
|
||||
|
||||
const colors = {
|
||||
idle: 'bg-blue-600 hover:bg-blue-700',
|
||||
loading: 'bg-blue-600',
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600',
|
||||
idle: "bg-blue-600 hover:bg-blue-700",
|
||||
loading: "bg-blue-600",
|
||||
success: "bg-green-600",
|
||||
error: "bg-red-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
disabled={state === 'loading'}
|
||||
disabled={state === "loading"}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg transition-colors ${colors[state]}`}
|
||||
animate={{ scale: state === 'success' || state === 'error' ? [1, 1.05, 1] : 1 }}
|
||||
animate={{
|
||||
scale: state === "success" || state === "error" ? [1, 1.05, 1] : 1,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{icons[state] && (
|
||||
@@ -118,10 +122,10 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{state === 'idle' && 'Submit'}
|
||||
{state === 'loading' && 'Submitting...'}
|
||||
{state === 'success' && 'Done!'}
|
||||
{state === 'error' && 'Failed'}
|
||||
{state === "idle" && "Submit"}
|
||||
{state === "loading" && "Submitting..."}
|
||||
{state === "success" && "Done!"}
|
||||
{state === "error" && "Failed"}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -132,10 +136,16 @@ function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
|
||||
### Floating Label Input
|
||||
|
||||
```tsx
|
||||
import { useState, useId } from 'react';
|
||||
import { useState, useId } from "react";
|
||||
|
||||
function FloatingInput({ label, type = 'text' }: { label: string; type?: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
function FloatingInput({
|
||||
label,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
type?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
@@ -156,9 +166,10 @@ function FloatingInput({ label, type = 'text' }: { label: string; type?: string
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`absolute left-4 transition-all duration-200 pointer-events-none
|
||||
${isFloating
|
||||
? 'top-0 -translate-y-1/2 text-xs bg-white px-1 text-blue-600'
|
||||
: 'top-1/2 -translate-y-1/2 text-gray-500'
|
||||
${
|
||||
isFloating
|
||||
? "top-0 -translate-y-1/2 text-xs bg-white px-1 text-blue-600"
|
||||
: "top-1/2 -translate-y-1/2 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -171,7 +182,7 @@ function FloatingInput({ label, type = 'text' }: { label: string; type?: string
|
||||
### Shake on Error
|
||||
|
||||
```tsx
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
import { motion, useAnimation } from "framer-motion";
|
||||
|
||||
function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
|
||||
const controls = useAnimation();
|
||||
@@ -190,7 +201,7 @@ function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
|
||||
<input
|
||||
{...props}
|
||||
className={`w-full px-4 py-2 border rounded-lg ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
error ? "border-red-500" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
@@ -211,7 +222,7 @@ function ShakeInput({ error, ...props }: InputProps & { error?: string }) {
|
||||
|
||||
```tsx
|
||||
function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = maxLength - value.length;
|
||||
const isNearLimit = remaining <= 20;
|
||||
const isOverLimit = remaining < 0;
|
||||
@@ -226,7 +237,11 @@ function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
|
||||
/>
|
||||
<motion.span
|
||||
className={`absolute bottom-2 right-2 text-sm ${
|
||||
isOverLimit ? 'text-red-500' : isNearLimit ? 'text-yellow-500' : 'text-gray-400'
|
||||
isOverLimit
|
||||
? "text-red-500"
|
||||
: isNearLimit
|
||||
? "text-yellow-500"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
animate={{ scale: isNearLimit ? [1, 1.1, 1] : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
@@ -243,23 +258,23 @@ function TextareaWithCount({ maxLength = 280 }: { maxLength?: number }) {
|
||||
### Toast Notifications
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
type: "success" | "error" | "info";
|
||||
}
|
||||
|
||||
const ToastContext = createContext<{
|
||||
addToast: (message: string, type: Toast['type']) => void;
|
||||
addToast: (message: string, type: Toast["type"]) => void;
|
||||
} | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: Toast['type']) => {
|
||||
const addToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = Date.now().toString();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
@@ -279,8 +294,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg ${
|
||||
toast.type === 'success' ? 'bg-green-600' :
|
||||
toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||
toast.type === "success"
|
||||
? "bg-green-600"
|
||||
: toast.type === "error"
|
||||
? "bg-red-600"
|
||||
: "bg-blue-600"
|
||||
} text-white`}
|
||||
>
|
||||
{toast.message}
|
||||
@@ -294,7 +312,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be within ToastProvider');
|
||||
if (!context) throw new Error("useToast must be within ToastProvider");
|
||||
return context;
|
||||
}
|
||||
```
|
||||
@@ -304,7 +322,7 @@ export function useToast() {
|
||||
```tsx
|
||||
function ConfirmButton({
|
||||
onConfirm,
|
||||
confirmText = 'Click again to confirm',
|
||||
confirmText = "Click again to confirm",
|
||||
children,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
@@ -333,13 +351,13 @@ function ConfirmButton({
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
isPending ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-800'
|
||||
isPending ? "bg-red-600 text-white" : "bg-gray-200 text-gray-800"
|
||||
}`}
|
||||
animate={{ scale: isPending ? [1, 1.02, 1] : 1 }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={isPending ? 'confirm' : 'idle'}
|
||||
key={isPending ? "confirm" : "idle"}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
@@ -357,8 +375,8 @@ function ConfirmButton({
|
||||
### Active Link Indicator
|
||||
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
function Navigation({ items }: { items: { href: string; label: string }[] }) {
|
||||
const pathname = usePathname();
|
||||
@@ -372,14 +390,14 @@ function Navigation({ items }: { items: { href: string; label: string }[] }) {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative px-4 py-2 text-sm font-medium ${
|
||||
isActive ? 'text-white' : 'text-gray-600 hover:text-gray-900'
|
||||
isActive ? "text-white" : "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeNav"
|
||||
className="absolute inset-0 bg-blue-600 rounded-md"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
@@ -400,9 +418,9 @@ function MenuIcon({ isOpen }: { isOpen: boolean }) {
|
||||
<motion.span
|
||||
className="absolute left-0 h-0.5 w-6 bg-current"
|
||||
animate={{
|
||||
top: isOpen ? '50%' : '25%',
|
||||
top: isOpen ? "50%" : "25%",
|
||||
rotate: isOpen ? 45 : 0,
|
||||
translateY: isOpen ? '-50%' : 0,
|
||||
translateY: isOpen ? "-50%" : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
@@ -414,9 +432,9 @@ function MenuIcon({ isOpen }: { isOpen: boolean }) {
|
||||
<motion.span
|
||||
className="absolute left-0 h-0.5 w-6 bg-current"
|
||||
animate={{
|
||||
bottom: isOpen ? '50%' : '25%',
|
||||
bottom: isOpen ? "50%" : "25%",
|
||||
rotate: isOpen ? -45 : 0,
|
||||
translateY: isOpen ? '50%' : 0,
|
||||
translateY: isOpen ? "50%" : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
@@ -453,7 +471,7 @@ function LikeButton({ postId, initialLiked, initialCount }) {
|
||||
<motion.button
|
||||
onClick={handleLike}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={`flex items-center gap-2 ${liked ? 'text-red-500' : 'text-gray-500'}`}
|
||||
className={`flex items-center gap-2 ${liked ? "text-red-500" : "text-gray-500"}`}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ scale: liked ? [1, 1.3, 1] : 1 }}
|
||||
@@ -479,7 +497,7 @@ function LikeButton({ postId, initialLiked, initialCount }) {
|
||||
### Pull to Refresh
|
||||
|
||||
```tsx
|
||||
import { motion, useMotionValue, useTransform } from 'framer-motion';
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
|
||||
function PullToRefresh({ onRefresh, children }) {
|
||||
const y = useMotionValue(0);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Intersection Observer Hook
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
interface UseInViewOptions {
|
||||
threshold?: number | number[];
|
||||
@@ -13,7 +13,7 @@ interface UseInViewOptions {
|
||||
|
||||
function useInView<T extends HTMLElement>({
|
||||
threshold = 0,
|
||||
rootMargin = '0px',
|
||||
rootMargin = "0px",
|
||||
triggerOnce = false,
|
||||
}: UseInViewOptions = {}): [RefObject<T>, boolean] {
|
||||
const ref = useRef<T>(null);
|
||||
@@ -31,7 +31,7 @@ function useInView<T extends HTMLElement>({
|
||||
observer.unobserve(element);
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
{ threshold, rootMargin },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
@@ -49,7 +49,7 @@ function FadeInSection({ children }) {
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ${
|
||||
isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
@@ -61,7 +61,7 @@ function FadeInSection({ children }) {
|
||||
## Scroll Progress Indicator
|
||||
|
||||
```tsx
|
||||
import { motion, useScroll, useSpring } from 'framer-motion';
|
||||
import { motion, useScroll, useSpring } from "framer-motion";
|
||||
|
||||
function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
@@ -104,26 +104,23 @@ function ScrollProgress() {
|
||||
### Framer Motion Parallax
|
||||
|
||||
```tsx
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
|
||||
function ParallaxHero() {
|
||||
const ref = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start start', 'end start'],
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
||||
const y = useTransform(scrollYProgress, [0, 1], ["0%", "50%"]);
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1, 1.2]);
|
||||
|
||||
return (
|
||||
<section ref={ref} className="relative h-screen overflow-hidden">
|
||||
{/* Background image with parallax */}
|
||||
<motion.div
|
||||
style={{ y, scale }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<motion.div style={{ y, scale }} className="absolute inset-0">
|
||||
<img src="/hero-bg.jpg" alt="" className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
|
||||
@@ -148,7 +145,7 @@ function ScrollAnimation() {
|
||||
const containerRef = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start end', 'end start'],
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
// Different transformations based on scroll progress
|
||||
@@ -157,7 +154,7 @@ function ScrollAnimation() {
|
||||
const backgroundColor = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.5, 1],
|
||||
['#3b82f6', '#8b5cf6', '#ec4899']
|
||||
["#3b82f6", "#8b5cf6", "#ec4899"],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -180,13 +177,13 @@ function HorizontalScroll({ items }) {
|
||||
const containerRef = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end end'],
|
||||
offset: ["start start", "end end"],
|
||||
});
|
||||
|
||||
const x = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 1],
|
||||
['0%', `-${(items.length - 1) * 100}%`]
|
||||
["0%", `-${(items.length - 1) * 100}%`],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -239,7 +236,7 @@ function StaggeredList({ items }) {
|
||||
```tsx
|
||||
function TextReveal({ text }) {
|
||||
const [ref, isInView] = useInView({ threshold: 0.5, triggerOnce: true });
|
||||
const words = text.split(' ');
|
||||
const words = text.split(" ");
|
||||
|
||||
return (
|
||||
<p ref={ref} className="text-4xl font-bold">
|
||||
@@ -268,8 +265,8 @@ function ClipReveal({ children }) {
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ clipPath: 'inset(0 100% 0 0)' }}
|
||||
animate={isInView ? { clipPath: 'inset(0 0% 0 0)' } : {}}
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={isInView ? { clipPath: "inset(0 0% 0 0)" } : {}}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
@@ -285,7 +282,7 @@ function StickySection({ title, content, image }) {
|
||||
const ref = useRef(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start start', 'end start'],
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 1, 0]);
|
||||
@@ -373,7 +370,10 @@ function FullPageScroll({ sections }) {
|
||||
return (
|
||||
<div className="snap-container">
|
||||
{sections.map((section, i) => (
|
||||
<section key={i} className="snap-section flex items-center justify-center">
|
||||
<section
|
||||
key={i}
|
||||
className="snap-section flex items-center justify-center"
|
||||
>
|
||||
{section}
|
||||
</section>
|
||||
))}
|
||||
@@ -403,14 +403,14 @@ function useThrottledScroll(callback, delay = 16) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handler, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
window.addEventListener("scroll", handler, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handler);
|
||||
}, [callback, delay]);
|
||||
}
|
||||
|
||||
// Use transform instead of top/left
|
||||
// Good
|
||||
const goodAnimation = { transform: 'translateY(100px)' };
|
||||
const goodAnimation = { transform: "translateY(100px)" };
|
||||
// Bad (causes reflow)
|
||||
const badAnimation = { top: '100px' };
|
||||
const badAnimation = { top: "100px" };
|
||||
```
|
||||
|
||||
@@ -27,6 +27,7 @@ Master Material Design 3 (Material You) and Jetpack Compose to build modern, ada
|
||||
**Large Screens**: Responsive layouts for tablets and foldables
|
||||
|
||||
**Material Components:**
|
||||
|
||||
- Cards, Buttons, FABs, Chips
|
||||
- Navigation (rail, drawer, bottom nav)
|
||||
- Text fields, Dialogs, Sheets
|
||||
@@ -35,6 +36,7 @@ Master Material Design 3 (Material You) and Jetpack Compose to build modern, ada
|
||||
### 2. Jetpack Compose Layout System
|
||||
|
||||
**Column and Row:**
|
||||
|
||||
```kotlin
|
||||
// Vertical arrangement with alignment
|
||||
Column(
|
||||
@@ -69,6 +71,7 @@ Row(
|
||||
```
|
||||
|
||||
**Lazy Lists and Grids:**
|
||||
|
||||
```kotlin
|
||||
// Lazy column with sticky headers
|
||||
LazyColumn {
|
||||
@@ -105,6 +108,7 @@ LazyVerticalGrid(
|
||||
### 3. Navigation Patterns
|
||||
|
||||
**Bottom Navigation:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
@@ -151,6 +155,7 @@ fun MainScreen() {
|
||||
```
|
||||
|
||||
**Navigation Drawer:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DrawerNavigation() {
|
||||
@@ -205,6 +210,7 @@ fun DrawerNavigation() {
|
||||
### 4. Material 3 Theming
|
||||
|
||||
**Color Scheme:**
|
||||
|
||||
```kotlin
|
||||
// Dynamic color (Android 12+)
|
||||
val dynamicColorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -231,6 +237,7 @@ private val LightColorScheme = lightColorScheme(
|
||||
```
|
||||
|
||||
**Typography:**
|
||||
|
||||
```kotlin
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
@@ -269,6 +276,7 @@ val AppTypography = Typography(
|
||||
### 5. Component Examples
|
||||
|
||||
**Cards:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
@@ -312,6 +320,7 @@ fun FeatureCard(
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
|
||||
```kotlin
|
||||
// Filled button (primary action)
|
||||
Button(onClick = { }) {
|
||||
|
||||
@@ -27,6 +27,7 @@ Master iOS Human Interface Guidelines (HIG) and SwiftUI patterns to build polish
|
||||
**Depth**: Visual layers and motion convey hierarchy and enable navigation
|
||||
|
||||
**Platform Considerations:**
|
||||
|
||||
- **iOS**: Touch-first, compact displays, portrait orientation
|
||||
- **iPadOS**: Larger canvas, multitasking, pointer support
|
||||
- **visionOS**: Spatial computing, eye/hand input
|
||||
@@ -34,6 +35,7 @@ Master iOS Human Interface Guidelines (HIG) and SwiftUI patterns to build polish
|
||||
### 2. SwiftUI Layout System
|
||||
|
||||
**Stack-Based Layouts:**
|
||||
|
||||
```swift
|
||||
// Vertical stack with alignment
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -55,6 +57,7 @@ HStack {
|
||||
```
|
||||
|
||||
**Grid Layouts:**
|
||||
|
||||
```swift
|
||||
// Adaptive grid that fills available width
|
||||
LazyVGrid(columns: [
|
||||
@@ -80,6 +83,7 @@ LazyVGrid(columns: [
|
||||
### 3. Navigation Patterns
|
||||
|
||||
**NavigationStack (iOS 16+):**
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
@@ -101,6 +105,7 @@ struct ContentView: View {
|
||||
```
|
||||
|
||||
**TabView:**
|
||||
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
@@ -132,6 +137,7 @@ struct MainTabView: View {
|
||||
### 4. System Integration
|
||||
|
||||
**SF Symbols:**
|
||||
|
||||
```swift
|
||||
// Basic symbol
|
||||
Image(systemName: "heart.fill")
|
||||
@@ -150,6 +156,7 @@ Image(systemName: "bell.fill")
|
||||
```
|
||||
|
||||
**Dynamic Type:**
|
||||
|
||||
```swift
|
||||
// Use semantic fonts
|
||||
Text("Headline")
|
||||
@@ -166,6 +173,7 @@ Text("Custom")
|
||||
### 5. Visual Design
|
||||
|
||||
**Colors and Materials:**
|
||||
|
||||
```swift
|
||||
// Semantic colors that adapt to light/dark mode
|
||||
Text("Primary")
|
||||
@@ -185,6 +193,7 @@ Text("Overlay")
|
||||
```
|
||||
|
||||
**Shadows and Depth:**
|
||||
|
||||
```swift
|
||||
// Standard card shadow
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## Lists and Collections
|
||||
|
||||
### Basic List
|
||||
|
||||
```swift
|
||||
struct ItemListView: View {
|
||||
@State private var items: [Item] = []
|
||||
@@ -32,6 +33,7 @@ struct ItemListView: View {
|
||||
```
|
||||
|
||||
### Sectioned List
|
||||
|
||||
```swift
|
||||
struct SectionedListView: View {
|
||||
let groupedItems: [String: [Item]]
|
||||
@@ -52,6 +54,7 @@ struct SectionedListView: View {
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
```swift
|
||||
struct SearchableListView: View {
|
||||
@State private var searchText = ""
|
||||
@@ -85,6 +88,7 @@ struct SearchableListView: View {
|
||||
## Forms and Input
|
||||
|
||||
### Settings Form
|
||||
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@AppStorage("notifications") private var notificationsEnabled = true
|
||||
@@ -125,6 +129,7 @@ struct SettingsView: View {
|
||||
```
|
||||
|
||||
### Custom Input Fields
|
||||
|
||||
```swift
|
||||
struct ValidatedTextField: View {
|
||||
let title: String
|
||||
@@ -171,6 +176,7 @@ struct ValidatedTextField: View {
|
||||
## Buttons and Actions
|
||||
|
||||
### Button Styles
|
||||
|
||||
```swift
|
||||
// Primary filled button
|
||||
Button("Continue") {
|
||||
@@ -202,6 +208,7 @@ struct ScaleButtonStyle: ButtonStyle {
|
||||
```
|
||||
|
||||
### Menu and Context Menu
|
||||
|
||||
```swift
|
||||
// Menu button
|
||||
Menu {
|
||||
@@ -226,6 +233,7 @@ Text("Long press me")
|
||||
## Sheets and Modals
|
||||
|
||||
### Sheet Presentation
|
||||
|
||||
```swift
|
||||
struct ParentView: View {
|
||||
@State private var showSettings = false
|
||||
@@ -269,6 +277,7 @@ struct SettingsSheet: View {
|
||||
```
|
||||
|
||||
### Confirmation Dialog
|
||||
|
||||
```swift
|
||||
struct DeleteConfirmationView: View {
|
||||
@State private var showConfirmation = false
|
||||
@@ -296,6 +305,7 @@ struct DeleteConfirmationView: View {
|
||||
## Loading and Progress
|
||||
|
||||
### Progress Indicators
|
||||
|
||||
```swift
|
||||
// Indeterminate spinner
|
||||
ProgressView()
|
||||
@@ -334,6 +344,7 @@ struct LoadingOverlay: View {
|
||||
```
|
||||
|
||||
### Skeleton Loading
|
||||
|
||||
```swift
|
||||
struct SkeletonRow: View {
|
||||
@State private var isAnimating = false
|
||||
@@ -366,6 +377,7 @@ struct SkeletonRow: View {
|
||||
## Async Content Loading
|
||||
|
||||
### AsyncImage
|
||||
|
||||
```swift
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
@@ -387,6 +399,7 @@ AsyncImage(url: imageURL) { phase in
|
||||
```
|
||||
|
||||
### Task-Based Loading
|
||||
|
||||
```swift
|
||||
struct AsyncContentView: View {
|
||||
@State private var items: [Item] = []
|
||||
@@ -435,6 +448,7 @@ struct AsyncContentView: View {
|
||||
## Animations
|
||||
|
||||
### Implicit Animations
|
||||
|
||||
```swift
|
||||
struct AnimatedCard: View {
|
||||
@State private var isExpanded = false
|
||||
@@ -461,6 +475,7 @@ struct AnimatedCard: View {
|
||||
```
|
||||
|
||||
### Custom Transitions
|
||||
|
||||
```swift
|
||||
extension AnyTransition {
|
||||
static var slideAndFade: AnyTransition {
|
||||
@@ -477,6 +492,7 @@ extension AnyTransition {
|
||||
```
|
||||
|
||||
### Phase Animator (iOS 17+)
|
||||
|
||||
```swift
|
||||
struct PulsingButton: View {
|
||||
var body: some View {
|
||||
@@ -495,6 +511,7 @@ struct PulsingButton: View {
|
||||
## Gestures
|
||||
|
||||
### Drag Gesture
|
||||
|
||||
```swift
|
||||
struct DraggableCard: View {
|
||||
@State private var offset = CGSize.zero
|
||||
@@ -524,6 +541,7 @@ struct DraggableCard: View {
|
||||
```
|
||||
|
||||
### Simultaneous Gestures
|
||||
|
||||
```swift
|
||||
struct ZoomableImage: View {
|
||||
@State private var scale: CGFloat = 1.0
|
||||
|
||||
@@ -23,6 +23,7 @@ Master React Native styling patterns, React Navigation, and Reanimated 3 to buil
|
||||
### 1. StyleSheet and Styling
|
||||
|
||||
**Basic StyleSheet:**
|
||||
|
||||
```typescript
|
||||
import { StyleSheet, View, Text } from 'react-native';
|
||||
|
||||
@@ -56,6 +57,7 @@ function Card() {
|
||||
```
|
||||
|
||||
**Dynamic Styles:**
|
||||
|
||||
```typescript
|
||||
interface CardProps {
|
||||
variant: 'primary' | 'secondary';
|
||||
@@ -99,30 +101,31 @@ const styles = StyleSheet.create({
|
||||
### 2. Flexbox Layout
|
||||
|
||||
**Row and Column Layouts:**
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
// Vertical stack (column)
|
||||
column: {
|
||||
flexDirection: 'column',
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
},
|
||||
// Horizontal stack (row)
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
// Space between items
|
||||
spaceBetween: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Centered content
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Fill remaining space
|
||||
fill: {
|
||||
@@ -134,6 +137,7 @@ const styles = StyleSheet.create({
|
||||
### 3. React Navigation Setup
|
||||
|
||||
**Stack Navigator:**
|
||||
|
||||
```typescript
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
@@ -177,6 +181,7 @@ function AppNavigator() {
|
||||
```
|
||||
|
||||
**Tab Navigator:**
|
||||
|
||||
```typescript
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -216,6 +221,7 @@ function TabNavigator() {
|
||||
### 4. Reanimated 3 Basics
|
||||
|
||||
**Animated Values:**
|
||||
|
||||
```typescript
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
@@ -248,6 +254,7 @@ function AnimatedBox() {
|
||||
```
|
||||
|
||||
**Gesture Handler Integration:**
|
||||
|
||||
```typescript
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
@@ -290,14 +297,14 @@ function DraggableCard() {
|
||||
### 5. Platform-Specific Styling
|
||||
|
||||
```typescript
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
@@ -308,14 +315,14 @@ const styles = StyleSheet.create({
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
fontFamily: Platform.OS === 'ios' ? 'SF Pro Text' : 'Roboto',
|
||||
fontFamily: Platform.OS === "ios" ? "SF Pro Text" : "Roboto",
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
// Platform-specific components
|
||||
import { Platform } from 'react-native';
|
||||
const StatusBarHeight = Platform.OS === 'ios' ? 44 : 0;
|
||||
import { Platform } from "react-native";
|
||||
const StatusBarHeight = Platform.OS === "ios" ? 44 : 0;
|
||||
```
|
||||
|
||||
## Quick Start Component
|
||||
|
||||
@@ -18,9 +18,12 @@ npm install react-native-screens react-native-safe-area-context
|
||||
|
||||
```typescript
|
||||
// navigation/types.ts
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { BottomTabScreenProps } from "@react-navigation/bottom-tabs";
|
||||
import {
|
||||
CompositeScreenProps,
|
||||
NavigatorScreenParams,
|
||||
} from "@react-navigation/native";
|
||||
|
||||
// Define param lists for each navigator
|
||||
export type RootStackParamList = {
|
||||
@@ -63,9 +66,9 @@ declare global {
|
||||
|
||||
```typescript
|
||||
// hooks/useAppNavigation.ts
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from './types';
|
||||
import { useNavigation, useRoute, RouteProp } from "@react-navigation/native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "./types";
|
||||
|
||||
export function useAppNavigation() {
|
||||
return useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
@@ -464,9 +467,9 @@ function App() {
|
||||
### Handling Deep Links
|
||||
|
||||
```typescript
|
||||
import { useEffect } from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useEffect } from "react";
|
||||
import { Linking } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
|
||||
function useDeepLinkHandler() {
|
||||
const navigation = useNavigation();
|
||||
@@ -481,7 +484,7 @@ function useDeepLinkHandler() {
|
||||
};
|
||||
|
||||
// Handle URL changes
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => {
|
||||
const subscription = Linking.addEventListener("url", ({ url }) => {
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
|
||||
@@ -712,9 +712,12 @@ function BottomSheet({ children }) {
|
||||
|
||||
```typescript
|
||||
// Memoize animated style when dependencies don't change
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}), []); // Empty deps if no external dependencies
|
||||
const animatedStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}),
|
||||
[],
|
||||
); // Empty deps if no external dependencies
|
||||
|
||||
// Use useMemo for complex calculations outside worklets
|
||||
const threshold = useMemo(() => calculateThreshold(screenWidth), [screenWidth]);
|
||||
@@ -725,7 +728,7 @@ const threshold = useMemo(() => calculateThreshold(screenWidth), [screenWidth]);
|
||||
```typescript
|
||||
// Do: Keep worklets simple
|
||||
const simpleWorklet = () => {
|
||||
'worklet';
|
||||
"worklet";
|
||||
return scale.value * 2;
|
||||
};
|
||||
|
||||
@@ -738,7 +741,7 @@ const onComplete = () => {
|
||||
};
|
||||
|
||||
opacity.value = withTiming(1, {}, (finished) => {
|
||||
'worklet';
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onComplete)();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
### Creating Styles
|
||||
|
||||
```typescript
|
||||
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from 'react-native';
|
||||
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from "react-native";
|
||||
|
||||
// Typed styles for better IDE support
|
||||
interface Styles {
|
||||
@@ -18,12 +18,12 @@ const styles = StyleSheet.create<Styles>({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
fontWeight: "700",
|
||||
color: "#1f2937",
|
||||
},
|
||||
image: {
|
||||
width: 100,
|
||||
@@ -425,13 +425,10 @@ export function Spacer({ size, flex }: SpacerProps) {
|
||||
### Cross-Platform Shadows
|
||||
|
||||
```typescript
|
||||
import { Platform, ViewStyle } from 'react-native';
|
||||
import { Platform, ViewStyle } from "react-native";
|
||||
|
||||
export function createShadow(
|
||||
elevation: number,
|
||||
color = '#000000'
|
||||
): ViewStyle {
|
||||
if (Platform.OS === 'android') {
|
||||
export function createShadow(elevation: number, color = "#000000"): ViewStyle {
|
||||
if (Platform.OS === "android") {
|
||||
return { elevation };
|
||||
}
|
||||
|
||||
@@ -483,7 +480,7 @@ export const shadows = {
|
||||
// Usage
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
...shadows.md,
|
||||
|
||||
@@ -21,24 +21,28 @@ Master modern responsive design techniques to create interfaces that adapt seaml
|
||||
## Core Capabilities
|
||||
|
||||
### 1. Container Queries
|
||||
|
||||
- Component-level responsiveness independent of viewport
|
||||
- Container query units (cqi, cqw, cqh)
|
||||
- Style queries for conditional styling
|
||||
- Fallbacks for browser support
|
||||
|
||||
### 2. Fluid Typography & Spacing
|
||||
|
||||
- CSS clamp() for fluid scaling
|
||||
- Viewport-relative units (vw, vh, dvh)
|
||||
- Fluid type scales with min/max bounds
|
||||
- Responsive spacing systems
|
||||
|
||||
### 3. Layout Patterns
|
||||
|
||||
- CSS Grid for 2D layouts
|
||||
- Flexbox for 1D distribution
|
||||
- Intrinsic layouts (content-based sizing)
|
||||
- Subgrid for nested grid alignment
|
||||
|
||||
### 4. Breakpoint Strategy
|
||||
|
||||
- Mobile-first media queries
|
||||
- Content-based breakpoints
|
||||
- Design token integration
|
||||
@@ -51,11 +55,21 @@ Master modern responsive design techniques to create interfaces that adapt seaml
|
||||
```css
|
||||
/* Mobile-first breakpoints */
|
||||
/* Base: Mobile (< 640px) */
|
||||
@media (min-width: 640px) { /* sm: Landscape phones, small tablets */ }
|
||||
@media (min-width: 768px) { /* md: Tablets */ }
|
||||
@media (min-width: 1024px) { /* lg: Laptops, small desktops */ }
|
||||
@media (min-width: 1280px) { /* xl: Desktops */ }
|
||||
@media (min-width: 1536px) { /* 2xl: Large desktops */ }
|
||||
@media (min-width: 640px) {
|
||||
/* sm: Landscape phones, small tablets */
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
/* md: Tablets */
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
/* lg: Laptops, small desktops */
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
/* xl: Desktops */
|
||||
}
|
||||
@media (min-width: 1536px) {
|
||||
/* 2xl: Large desktops */
|
||||
}
|
||||
|
||||
/* Tailwind CSS equivalent */
|
||||
/* sm: @media (min-width: 640px) */
|
||||
@@ -148,10 +162,18 @@ function ResponsiveCard({ title, image, description }) {
|
||||
}
|
||||
|
||||
/* Usage */
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
p { font-size: var(--text-base); }
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Fluid spacing scale */
|
||||
:root {
|
||||
@@ -165,7 +187,12 @@ p { font-size: var(--text-base); }
|
||||
|
||||
```tsx
|
||||
// Utility function for fluid values
|
||||
function fluidValue(minSize: number, maxSize: number, minWidth = 320, maxWidth = 1280) {
|
||||
function fluidValue(
|
||||
minSize: number,
|
||||
maxSize: number,
|
||||
minWidth = 320,
|
||||
maxWidth = 1280,
|
||||
) {
|
||||
const slope = (maxSize - minSize) / (maxWidth - minWidth);
|
||||
const yAxisIntersection = -minWidth * slope + minSize;
|
||||
|
||||
@@ -178,7 +205,7 @@ const fluidTypeScale = {
|
||||
base: fluidValue(1, 1.125),
|
||||
lg: fluidValue(1.25, 1.5),
|
||||
xl: fluidValue(1.5, 2),
|
||||
'2xl': fluidValue(2, 3),
|
||||
"2xl": fluidValue(2, 3),
|
||||
};
|
||||
```
|
||||
|
||||
@@ -230,19 +257,23 @@ const fluidTypeScale = {
|
||||
}
|
||||
}
|
||||
|
||||
.header { grid-area: header; }
|
||||
.main { grid-area: main; }
|
||||
.sidebar { grid-area: sidebar; }
|
||||
.footer { grid-area: footer; }
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
.main {
|
||||
grid-area: main;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Responsive grid component
|
||||
function ResponsiveGrid({
|
||||
children,
|
||||
minItemWidth = '250px',
|
||||
gap = '1.5rem',
|
||||
}) {
|
||||
function ResponsiveGrid({ children, minItemWidth = "250px", gap = "1.5rem" }) {
|
||||
return (
|
||||
<div
|
||||
className="grid"
|
||||
@@ -292,12 +323,12 @@ function ResponsiveNav({ items }) {
|
||||
id="nav-menu"
|
||||
className={cn(
|
||||
// Base: hidden on mobile
|
||||
'absolute top-full left-0 right-0 bg-background border-b',
|
||||
'flex flex-col',
|
||||
"absolute top-full left-0 right-0 bg-background border-b",
|
||||
"flex flex-col",
|
||||
// Mobile: slide down
|
||||
isOpen ? 'flex' : 'hidden',
|
||||
isOpen ? "flex" : "hidden",
|
||||
// Desktop: always visible, horizontal
|
||||
'lg:static lg:flex lg:flex-row lg:border-0 lg:bg-transparent'
|
||||
"lg:static lg:flex lg:flex-row lg:border-0 lg:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
@@ -305,9 +336,9 @@ function ResponsiveNav({ items }) {
|
||||
<a
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'block px-4 py-3',
|
||||
'lg:px-3 lg:py-2',
|
||||
'hover:bg-muted lg:hover:bg-transparent lg:hover:text-primary'
|
||||
"block px-4 py-3",
|
||||
"lg:px-3 lg:py-2",
|
||||
"hover:bg-muted lg:hover:bg-transparent lg:hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -52,11 +52,21 @@ Start with the smallest screen, then progressively enhance for larger screens.
|
||||
/* xl: 1280px - Desktops */
|
||||
/* 2xl: 1536px - Large desktops */
|
||||
|
||||
@media (min-width: 640px) { /* sm */ }
|
||||
@media (min-width: 768px) { /* md */ }
|
||||
@media (min-width: 1024px) { /* lg */ }
|
||||
@media (min-width: 1280px) { /* xl */ }
|
||||
@media (min-width: 1536px) { /* 2xl */ }
|
||||
@media (min-width: 640px) {
|
||||
/* sm */
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
/* md */
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
/* lg */
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
/* xl */
|
||||
}
|
||||
@media (min-width: 1536px) {
|
||||
/* 2xl */
|
||||
}
|
||||
```
|
||||
|
||||
### Bootstrap 5
|
||||
@@ -69,11 +79,21 @@ Start with the smallest screen, then progressively enhance for larger screens.
|
||||
/* xl: 1200px */
|
||||
/* xxl: 1400px */
|
||||
|
||||
@media (min-width: 576px) { /* sm */ }
|
||||
@media (min-width: 768px) { /* md */ }
|
||||
@media (min-width: 992px) { /* lg */ }
|
||||
@media (min-width: 1200px) { /* xl */ }
|
||||
@media (min-width: 1400px) { /* xxl */ }
|
||||
@media (min-width: 576px) {
|
||||
/* sm */
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
/* md */
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
/* lg */
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
/* xl */
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
/* xxl */
|
||||
}
|
||||
```
|
||||
|
||||
### Minimalist Scale
|
||||
@@ -89,8 +109,12 @@ Start with the smallest screen, then progressively enhance for larger screens.
|
||||
--bp-lg: 1024px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) { /* Medium */ }
|
||||
@media (min-width: 1024px) { /* Large */ }
|
||||
@media (min-width: 600px) {
|
||||
/* Medium */
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
/* Large */
|
||||
}
|
||||
```
|
||||
|
||||
## Content-Based Breakpoints
|
||||
@@ -101,7 +125,9 @@ Instead of using device-based breakpoints, identify where your content naturally
|
||||
|
||||
```css
|
||||
/* Bad: Device-based thinking */
|
||||
@media (min-width: 768px) { /* iPad breakpoint */ }
|
||||
@media (min-width: 768px) {
|
||||
/* iPad breakpoint */
|
||||
}
|
||||
|
||||
/* Good: Content-based thinking */
|
||||
/* Breakpoint where sidebar fits comfortably next to content */
|
||||
@@ -133,7 +159,7 @@ function findBreakpoints(selector) {
|
||||
|
||||
// Check for overflow, wrapping, or layout issues
|
||||
if (element.scrollWidth > element.clientWidth) {
|
||||
breakpoints.push({ width, issue: 'overflow' });
|
||||
breakpoints.push({ width, issue: "overflow" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +205,7 @@ export const breakpoints = {
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
"2xl": 1536,
|
||||
} as const;
|
||||
|
||||
// Media query hook
|
||||
@@ -191,8 +217,8 @@ function useMediaQuery(query: string): boolean {
|
||||
setMatches(media.matches);
|
||||
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
@@ -209,7 +235,15 @@ function useBreakpoint() {
|
||||
isMobile: !isSmall,
|
||||
isTablet: isSmall && !isLarge,
|
||||
isDesktop: isLarge,
|
||||
current: isXLarge ? 'xl' : isLarge ? 'lg' : isMedium ? 'md' : isSmall ? 'sm' : 'base',
|
||||
current: isXLarge
|
||||
? "xl"
|
||||
: isLarge
|
||||
? "lg"
|
||||
: isMedium
|
||||
? "md"
|
||||
: isSmall
|
||||
? "sm"
|
||||
: "base",
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -444,11 +478,14 @@ function useBreakpoint() {
|
||||
}
|
||||
|
||||
/* Handle page breaks */
|
||||
h1, h2, h3 {
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
img, table {
|
||||
img,
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
@@ -519,13 +556,16 @@ async function testBreakpoints(page, breakpoints) {
|
||||
|
||||
// Check for horizontal overflow
|
||||
const hasOverflow = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
return (
|
||||
document.documentElement.scrollWidth >
|
||||
document.documentElement.clientWidth
|
||||
);
|
||||
});
|
||||
|
||||
// Check for elements going off-screen
|
||||
const offscreenElements = await page.evaluate(() => {
|
||||
const elements = document.querySelectorAll('*');
|
||||
return Array.from(elements).filter(el => {
|
||||
const elements = document.querySelectorAll("*");
|
||||
return Array.from(elements).filter((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.right > window.innerWidth || rect.left < 0;
|
||||
}).length;
|
||||
|
||||
@@ -63,22 +63,30 @@ Container queries have excellent modern browser support (Chrome 105+, Firefox 11
|
||||
|
||||
/* Minimum width */
|
||||
@container (min-width: 300px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
/* Maximum width */
|
||||
@container (max-width: 500px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
/* Range syntax */
|
||||
@container (300px <= width <= 600px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
/* Exact width */
|
||||
@container (width: 400px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -87,17 +95,23 @@ Container queries have excellent modern browser support (Chrome 105+, Firefox 11
|
||||
```css
|
||||
/* AND condition */
|
||||
@container (min-width: 400px) and (max-width: 800px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
/* OR condition */
|
||||
@container (max-width: 300px) or (min-width: 800px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
/* NOT condition */
|
||||
@container not (min-width: 400px) {
|
||||
.element { /* styles */ }
|
||||
.element {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -403,9 +417,7 @@ Style queries allow querying CSS custom property values. Currently limited suppo
|
||||
// Tailwind v3.2+ supports container queries
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/container-queries'),
|
||||
],
|
||||
plugins: [require("@tailwindcss/container-queries")],
|
||||
};
|
||||
|
||||
// Component usage
|
||||
@@ -437,13 +449,9 @@ function Dashboard() {
|
||||
return (
|
||||
<div className="@container/main">
|
||||
<aside className="@container/sidebar">
|
||||
<nav className="flex flex-col @lg/sidebar:flex-row">
|
||||
{/* ... */}
|
||||
</nav>
|
||||
<nav className="flex flex-col @lg/sidebar:flex-row">{/* ... */}</nav>
|
||||
</aside>
|
||||
<main className="@lg/main:grid @lg/main:grid-cols-2">
|
||||
{/* ... */}
|
||||
</main>
|
||||
<main className="@lg/main:grid @lg/main:grid-cols-2">{/* ... */}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -506,10 +514,18 @@ function Dashboard() {
|
||||
```css
|
||||
/* Avoid over-nesting containers */
|
||||
/* Bad: Too many nested containers */
|
||||
.level-1 { container-type: inline-size; }
|
||||
.level-2 { container-type: inline-size; }
|
||||
.level-3 { container-type: inline-size; }
|
||||
.level-4 { container-type: inline-size; }
|
||||
.level-1 {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.level-2 {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.level-3 {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.level-4 {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Good: Strategic container placement */
|
||||
.component-wrapper {
|
||||
@@ -528,16 +544,16 @@ function Dashboard() {
|
||||
|
||||
```javascript
|
||||
// Test container query support
|
||||
const supportsContainerQueries = CSS.supports('container-type', 'inline-size');
|
||||
const supportsContainerQueries = CSS.supports("container-type", "inline-size");
|
||||
|
||||
// Resize observer for testing
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
console.log('Container width:', entry.contentRect.width);
|
||||
console.log("Container width:", entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.querySelector('.container'));
|
||||
observer.observe(document.querySelector(".container"));
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -61,9 +61,9 @@ const typeScale = {
|
||||
base: fluidType({ minFontSize: 16, maxFontSize: 18 }),
|
||||
lg: fluidType({ minFontSize: 18, maxFontSize: 20 }),
|
||||
xl: fluidType({ minFontSize: 20, maxFontSize: 24 }),
|
||||
'2xl': fluidType({ minFontSize: 24, maxFontSize: 32 }),
|
||||
'3xl': fluidType({ minFontSize: 30, maxFontSize: 48 }),
|
||||
'4xl': fluidType({ minFontSize: 36, maxFontSize: 60 }),
|
||||
"2xl": fluidType({ minFontSize: 24, maxFontSize: 32 }),
|
||||
"3xl": fluidType({ minFontSize: 30, maxFontSize: 48 }),
|
||||
"4xl": fluidType({ minFontSize: 36, maxFontSize: 60 }),
|
||||
};
|
||||
```
|
||||
|
||||
@@ -98,14 +98,34 @@ body {
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); line-height: var(--leading-tight); }
|
||||
h2 { font-size: var(--text-3xl); line-height: var(--leading-tight); }
|
||||
h3 { font-size: var(--text-2xl); line-height: var(--leading-tight); }
|
||||
h4 { font-size: var(--text-xl); line-height: var(--leading-normal); }
|
||||
h5 { font-size: var(--text-lg); line-height: var(--leading-normal); }
|
||||
h6 { font-size: var(--text-base); line-height: var(--leading-normal); }
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
small { font-size: var(--text-sm); }
|
||||
small {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
```
|
||||
|
||||
## Fluid Spacing
|
||||
@@ -184,10 +204,7 @@ small { font-size: var(--text-sm); }
|
||||
/* Grid that fills available space */
|
||||
.auto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, 250px), 1fr)
|
||||
);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -338,8 +355,8 @@ small { font-size: var(--text-sm); }
|
||||
}
|
||||
|
||||
/* Limit columns */
|
||||
.switcher > :nth-last-child(n+4),
|
||||
.switcher > :nth-last-child(n+4) ~ * {
|
||||
.switcher > :nth-last-child(n + 4),
|
||||
.switcher > :nth-last-child(n + 4) ~ * {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
```
|
||||
@@ -405,10 +422,7 @@ small { font-size: var(--text-sm); }
|
||||
|
||||
.card-grid {
|
||||
/* Each card at least 200px, fill available space */
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(max(200px, 100%/4), 1fr)
|
||||
);
|
||||
grid-template-columns: repeat(auto-fit, minmax(max(200px, 100%/4), 1fr));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -473,21 +487,47 @@ small { font-size: var(--text-sm); }
|
||||
|
||||
```css
|
||||
/* Tailwind-style fluid utilities */
|
||||
.text-fluid-sm { font-size: var(--text-sm); }
|
||||
.text-fluid-base { font-size: var(--text-base); }
|
||||
.text-fluid-lg { font-size: var(--text-lg); }
|
||||
.text-fluid-xl { font-size: var(--text-xl); }
|
||||
.text-fluid-2xl { font-size: var(--text-2xl); }
|
||||
.text-fluid-3xl { font-size: var(--text-3xl); }
|
||||
.text-fluid-4xl { font-size: var(--text-4xl); }
|
||||
.text-fluid-sm {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-fluid-base {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.text-fluid-lg {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.text-fluid-xl {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.text-fluid-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.text-fluid-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
.text-fluid-4xl {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
|
||||
.p-fluid-sm { padding: var(--space-sm); }
|
||||
.p-fluid-md { padding: var(--space-md); }
|
||||
.p-fluid-lg { padding: var(--space-lg); }
|
||||
.p-fluid-sm {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
.p-fluid-md {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.p-fluid-lg {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.gap-fluid-sm { gap: var(--space-sm); }
|
||||
.gap-fluid-md { gap: var(--space-md); }
|
||||
.gap-fluid-lg { gap: var(--space-lg); }
|
||||
.gap-fluid-sm {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.gap-fluid-md {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.gap-fluid-lg {
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -23,17 +23,18 @@ Build cohesive, accessible visual systems using typography, color, spacing, and
|
||||
### 1. Typography Scale
|
||||
|
||||
**Modular Scale** (ratio-based sizing):
|
||||
|
||||
```css
|
||||
:root {
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 1.875rem; /* 30px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
--font-size-5xl: 3rem; /* 48px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
--font-size-5xl: 3rem; /* 48px */
|
||||
}
|
||||
```
|
||||
|
||||
@@ -47,24 +48,26 @@ Build cohesive, accessible visual systems using typography, color, spacing, and
|
||||
### 2. Spacing System
|
||||
|
||||
**8-point grid** (industry standard):
|
||||
|
||||
```css
|
||||
:root {
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Color System
|
||||
|
||||
**Semantic color tokens**:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Brand */
|
||||
@@ -100,29 +103,29 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||
sm: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
base: ['1rem', { lineHeight: '1.5rem' }],
|
||||
lg: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
xl: ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
50: "#eff6ff",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
// Extends default with custom values
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
18: "4.5rem",
|
||||
88: "22rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -134,6 +137,7 @@ module.exports = {
|
||||
### Font Pairing
|
||||
|
||||
**Safe combinations**:
|
||||
|
||||
- Heading: **Inter** / Body: **Inter** (single family)
|
||||
- Heading: **Playfair Display** / Body: **Source Sans Pro** (contrast)
|
||||
- Heading: **Space Grotesk** / Body: **IBM Plex Sans** (geometric)
|
||||
@@ -159,8 +163,8 @@ p {
|
||||
```css
|
||||
/* Prevent layout shift */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter.woff2') format('woff2');
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/Inter.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
font-weight: 400 700;
|
||||
}
|
||||
@@ -170,12 +174,12 @@ p {
|
||||
|
||||
### Contrast Requirements (WCAG)
|
||||
|
||||
| Element | Minimum Ratio |
|
||||
|---------|---------------|
|
||||
| Body text | 4.5:1 (AA) |
|
||||
| Large text (18px+) | 3:1 (AA) |
|
||||
| UI components | 3:1 (AA) |
|
||||
| Enhanced | 7:1 (AAA) |
|
||||
| Element | Minimum Ratio |
|
||||
| ------------------ | ------------- |
|
||||
| Body text | 4.5:1 (AA) |
|
||||
| Large text (18px+) | 3:1 (AA) |
|
||||
| UI components | 3:1 (AA) |
|
||||
| Enhanced | 7:1 (AAA) |
|
||||
|
||||
### Dark Mode Strategy
|
||||
|
||||
@@ -204,7 +208,7 @@ p {
|
||||
function getContrastRatio(foreground: string, background: string): number {
|
||||
const getLuminance = (hex: string) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
const [r, g, b] = rgb.map(c => {
|
||||
const [r, g, b] = rgb.map((c) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
@@ -268,7 +272,7 @@ Icon-text gap: 8px (--space-2)
|
||||
```tsx
|
||||
interface IconProps {
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -280,12 +284,12 @@ const sizeMap = {
|
||||
xl: 32,
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 'md', className }: IconProps) {
|
||||
export function Icon({ name, size = "md", className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={sizeMap[size]}
|
||||
height={sizeMap[size]}
|
||||
className={cn('inline-block flex-shrink-0', className)}
|
||||
className={cn("inline-block flex-shrink-0", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href={`/icons.svg#${name}`} />
|
||||
|
||||
@@ -15,7 +15,7 @@ Using OKLCH for perceptually uniform color scales:
|
||||
--blue-200: oklch(86% 0.08 250);
|
||||
--blue-300: oklch(75% 0.12 250);
|
||||
--blue-400: oklch(65% 0.16 250);
|
||||
--blue-500: oklch(55% 0.20 250); /* Primary */
|
||||
--blue-500: oklch(55% 0.2 250); /* Primary */
|
||||
--blue-600: oklch(48% 0.18 250);
|
||||
--blue-700: oklch(40% 0.16 250);
|
||||
--blue-800: oklch(32% 0.12 250);
|
||||
@@ -27,26 +27,29 @@ Using OKLCH for perceptually uniform color scales:
|
||||
### Programmatic Scale Generation
|
||||
|
||||
```tsx
|
||||
function generateColorScale(hue: number, saturation: number = 100): Record<string, string> {
|
||||
function generateColorScale(
|
||||
hue: number,
|
||||
saturation: number = 100,
|
||||
): Record<string, string> {
|
||||
const lightnessStops = [
|
||||
{ name: '50', l: 97 },
|
||||
{ name: '100', l: 93 },
|
||||
{ name: '200', l: 85 },
|
||||
{ name: '300', l: 75 },
|
||||
{ name: '400', l: 65 },
|
||||
{ name: '500', l: 55 },
|
||||
{ name: '600', l: 45 },
|
||||
{ name: '700', l: 35 },
|
||||
{ name: '800', l: 25 },
|
||||
{ name: '900', l: 18 },
|
||||
{ name: '950', l: 12 },
|
||||
{ name: "50", l: 97 },
|
||||
{ name: "100", l: 93 },
|
||||
{ name: "200", l: 85 },
|
||||
{ name: "300", l: 75 },
|
||||
{ name: "400", l: 65 },
|
||||
{ name: "500", l: 55 },
|
||||
{ name: "600", l: 45 },
|
||||
{ name: "700", l: 35 },
|
||||
{ name: "800", l: 25 },
|
||||
{ name: "900", l: 18 },
|
||||
{ name: "950", l: 12 },
|
||||
];
|
||||
|
||||
return Object.fromEntries(
|
||||
lightnessStops.map(({ name, l }) => [
|
||||
name,
|
||||
`hsl(${hue}, ${saturation}%, ${l}%)`,
|
||||
])
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,49 +170,50 @@ const error = generateColorScale(0); // Red
|
||||
### React Theme Context
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
resolvedTheme: "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
setResolvedTheme(systemTheme);
|
||||
root.setAttribute('data-theme', systemTheme);
|
||||
root.setAttribute("data-theme", systemTheme);
|
||||
} else {
|
||||
setResolvedTheme(theme);
|
||||
root.setAttribute('data-theme', theme);
|
||||
root.setAttribute("data-theme", theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
if (theme !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
const newTheme = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
@@ -221,7 +225,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be within ThemeProvider');
|
||||
if (!context) throw new Error("useTheme must be within ThemeProvider");
|
||||
return context;
|
||||
}
|
||||
```
|
||||
@@ -233,7 +237,7 @@ export function useTheme() {
|
||||
```tsx
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) throw new Error('Invalid hex color');
|
||||
if (!result) throw new Error("Invalid hex color");
|
||||
return [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
@@ -265,8 +269,8 @@ function getContrastRatio(hex1: string, hex2: string): number {
|
||||
function meetsWCAG(
|
||||
foreground: string,
|
||||
background: string,
|
||||
size: 'normal' | 'large' = 'normal',
|
||||
level: 'AA' | 'AAA' = 'AA'
|
||||
size: "normal" | "large" = "normal",
|
||||
level: "AA" | "AAA" = "AA",
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(foreground, background);
|
||||
|
||||
@@ -279,8 +283,8 @@ function meetsWCAG(
|
||||
}
|
||||
|
||||
// Usage
|
||||
meetsWCAG('#ffffff', '#3b82f6'); // true (4.5:1 for AA normal)
|
||||
meetsWCAG('#ffffff', '#60a5fa'); // false (below 4.5:1)
|
||||
meetsWCAG("#ffffff", "#3b82f6"); // true (4.5:1 for AA normal)
|
||||
meetsWCAG("#ffffff", "#60a5fa"); // false (below 4.5:1)
|
||||
```
|
||||
|
||||
### Accessible Color Pairs
|
||||
@@ -292,14 +296,14 @@ function getAccessibleTextColor(backgroundColor: string): string {
|
||||
const luminance = getLuminance(r, g, b);
|
||||
|
||||
// Use white text on dark backgrounds, black on light
|
||||
return luminance > 0.179 ? '#111827' : '#ffffff';
|
||||
return luminance > 0.179 ? "#111827" : "#ffffff";
|
||||
}
|
||||
|
||||
// Find the nearest accessible shade
|
||||
function findAccessibleShade(
|
||||
textColor: string,
|
||||
backgroundScale: string[],
|
||||
minContrast: number = 4.5
|
||||
minContrast: number = 4.5,
|
||||
): string | null {
|
||||
for (const shade of backgroundScale) {
|
||||
if (getContrastRatio(textColor, shade) >= minContrast) {
|
||||
@@ -315,26 +319,22 @@ function findAccessibleShade(
|
||||
### Harmony Functions
|
||||
|
||||
```tsx
|
||||
type HarmonyType = 'complementary' | 'triadic' | 'analogous' | 'split-complementary';
|
||||
type HarmonyType =
|
||||
| "complementary"
|
||||
| "triadic"
|
||||
| "analogous"
|
||||
| "split-complementary";
|
||||
|
||||
function generateHarmony(baseHue: number, type: HarmonyType): number[] {
|
||||
switch (type) {
|
||||
case 'complementary':
|
||||
case "complementary":
|
||||
return [baseHue, (baseHue + 180) % 360];
|
||||
case 'triadic':
|
||||
case "triadic":
|
||||
return [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
|
||||
case 'analogous':
|
||||
return [
|
||||
(baseHue - 30 + 360) % 360,
|
||||
baseHue,
|
||||
(baseHue + 30) % 360,
|
||||
];
|
||||
case 'split-complementary':
|
||||
return [
|
||||
baseHue,
|
||||
(baseHue + 150) % 360,
|
||||
(baseHue + 210) % 360,
|
||||
];
|
||||
case "analogous":
|
||||
return [(baseHue - 30 + 360) % 360, baseHue, (baseHue + 30) % 360];
|
||||
case "split-complementary":
|
||||
return [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
|
||||
default:
|
||||
return [baseHue];
|
||||
}
|
||||
@@ -343,13 +343,13 @@ function generateHarmony(baseHue: number, type: HarmonyType): number[] {
|
||||
// Generate palette from harmony
|
||||
function generateHarmoniousPalette(
|
||||
baseHue: number,
|
||||
type: HarmonyType
|
||||
type: HarmonyType,
|
||||
): Record<string, string> {
|
||||
const hues = generateHarmony(baseHue, type);
|
||||
const names = ['primary', 'secondary', 'tertiary'];
|
||||
const names = ["primary", "secondary", "tertiary"];
|
||||
|
||||
return Object.fromEntries(
|
||||
hues.map((hue, i) => [names[i] || `color-${i}`, `hsl(${hue}, 70%, 50%)`])
|
||||
hues.map((hue, i) => [names[i] || `color-${i}`, `hsl(${hue}, 70%, 50%)`]),
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -358,7 +358,7 @@ function generateHarmoniousPalette(
|
||||
|
||||
```tsx
|
||||
// Simulate color blindness
|
||||
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
||||
type ColorBlindnessType = "protanopia" | "deuteranopia" | "tritanopia";
|
||||
|
||||
// Matrix transforms for common types
|
||||
const colorBlindnessMatrices: Record<ColorBlindnessType, number[][]> = {
|
||||
|
||||
@@ -14,28 +14,28 @@ The 8-point grid is the industry standard for consistent spacing.
|
||||
/* Spacing scale */
|
||||
--space-0: 0;
|
||||
--space-px: 1px;
|
||||
--space-0-5: calc(var(--space-unit) * 0.5); /* 2px */
|
||||
--space-1: var(--space-unit); /* 4px */
|
||||
--space-1-5: calc(var(--space-unit) * 1.5); /* 6px */
|
||||
--space-2: calc(var(--space-unit) * 2); /* 8px */
|
||||
--space-2-5: calc(var(--space-unit) * 2.5); /* 10px */
|
||||
--space-3: calc(var(--space-unit) * 3); /* 12px */
|
||||
--space-3-5: calc(var(--space-unit) * 3.5); /* 14px */
|
||||
--space-4: calc(var(--space-unit) * 4); /* 16px */
|
||||
--space-5: calc(var(--space-unit) * 5); /* 20px */
|
||||
--space-6: calc(var(--space-unit) * 6); /* 24px */
|
||||
--space-7: calc(var(--space-unit) * 7); /* 28px */
|
||||
--space-8: calc(var(--space-unit) * 8); /* 32px */
|
||||
--space-9: calc(var(--space-unit) * 9); /* 36px */
|
||||
--space-10: calc(var(--space-unit) * 10); /* 40px */
|
||||
--space-11: calc(var(--space-unit) * 11); /* 44px */
|
||||
--space-12: calc(var(--space-unit) * 12); /* 48px */
|
||||
--space-14: calc(var(--space-unit) * 14); /* 56px */
|
||||
--space-16: calc(var(--space-unit) * 16); /* 64px */
|
||||
--space-20: calc(var(--space-unit) * 20); /* 80px */
|
||||
--space-24: calc(var(--space-unit) * 24); /* 96px */
|
||||
--space-28: calc(var(--space-unit) * 28); /* 112px */
|
||||
--space-32: calc(var(--space-unit) * 32); /* 128px */
|
||||
--space-0-5: calc(var(--space-unit) * 0.5); /* 2px */
|
||||
--space-1: var(--space-unit); /* 4px */
|
||||
--space-1-5: calc(var(--space-unit) * 1.5); /* 6px */
|
||||
--space-2: calc(var(--space-unit) * 2); /* 8px */
|
||||
--space-2-5: calc(var(--space-unit) * 2.5); /* 10px */
|
||||
--space-3: calc(var(--space-unit) * 3); /* 12px */
|
||||
--space-3-5: calc(var(--space-unit) * 3.5); /* 14px */
|
||||
--space-4: calc(var(--space-unit) * 4); /* 16px */
|
||||
--space-5: calc(var(--space-unit) * 5); /* 20px */
|
||||
--space-6: calc(var(--space-unit) * 6); /* 24px */
|
||||
--space-7: calc(var(--space-unit) * 7); /* 28px */
|
||||
--space-8: calc(var(--space-unit) * 8); /* 32px */
|
||||
--space-9: calc(var(--space-unit) * 9); /* 36px */
|
||||
--space-10: calc(var(--space-unit) * 10); /* 40px */
|
||||
--space-11: calc(var(--space-unit) * 11); /* 44px */
|
||||
--space-12: calc(var(--space-unit) * 12); /* 48px */
|
||||
--space-14: calc(var(--space-unit) * 14); /* 56px */
|
||||
--space-16: calc(var(--space-unit) * 16); /* 64px */
|
||||
--space-20: calc(var(--space-unit) * 20); /* 80px */
|
||||
--space-24: calc(var(--space-unit) * 24); /* 96px */
|
||||
--space-28: calc(var(--space-unit) * 28); /* 112px */
|
||||
--space-32: calc(var(--space-unit) * 32); /* 128px */
|
||||
}
|
||||
```
|
||||
|
||||
@@ -44,20 +44,20 @@ The 8-point grid is the industry standard for consistent spacing.
|
||||
```css
|
||||
:root {
|
||||
/* Component-level spacing */
|
||||
--spacing-xs: var(--space-1); /* 4px - tight spacing */
|
||||
--spacing-sm: var(--space-2); /* 8px - compact spacing */
|
||||
--spacing-md: var(--space-4); /* 16px - default spacing */
|
||||
--spacing-lg: var(--space-6); /* 24px - comfortable spacing */
|
||||
--spacing-xl: var(--space-8); /* 32px - loose spacing */
|
||||
--spacing-2xl: var(--space-12); /* 48px - generous spacing */
|
||||
--spacing-3xl: var(--space-16); /* 64px - section spacing */
|
||||
--spacing-xs: var(--space-1); /* 4px - tight spacing */
|
||||
--spacing-sm: var(--space-2); /* 8px - compact spacing */
|
||||
--spacing-md: var(--space-4); /* 16px - default spacing */
|
||||
--spacing-lg: var(--space-6); /* 24px - comfortable spacing */
|
||||
--spacing-xl: var(--space-8); /* 32px - loose spacing */
|
||||
--spacing-2xl: var(--space-12); /* 48px - generous spacing */
|
||||
--spacing-3xl: var(--space-16); /* 64px - section spacing */
|
||||
|
||||
/* Specific use cases */
|
||||
--spacing-inline: var(--space-2); /* Between inline elements */
|
||||
--spacing-stack: var(--space-4); /* Between stacked elements */
|
||||
--spacing-inset: var(--space-4); /* Padding inside containers */
|
||||
--spacing-section: var(--space-16); /* Between major sections */
|
||||
--spacing-page: var(--space-24); /* Page margins */
|
||||
--spacing-inline: var(--space-2); /* Between inline elements */
|
||||
--spacing-stack: var(--space-4); /* Between stacked elements */
|
||||
--spacing-inset: var(--space-4); /* Padding inside containers */
|
||||
--spacing-section: var(--space-16); /* Between major sections */
|
||||
--spacing-page: var(--space-24); /* Page margins */
|
||||
}
|
||||
```
|
||||
|
||||
@@ -67,18 +67,17 @@ The 8-point grid is the industry standard for consistent spacing.
|
||||
// Tailwind-like spacing scale generator
|
||||
function createSpacingScale(baseUnit: number = 4): Record<string, string> {
|
||||
const scale: Record<string, string> = {
|
||||
'0': '0',
|
||||
'px': '1px',
|
||||
"0": "0",
|
||||
px: "1px",
|
||||
};
|
||||
|
||||
const multipliers = [
|
||||
0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10,
|
||||
11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48,
|
||||
52, 56, 60, 64, 72, 80, 96,
|
||||
0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24,
|
||||
28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
|
||||
];
|
||||
|
||||
for (const m of multipliers) {
|
||||
const key = m % 1 === 0 ? String(m) : String(m).replace('.', '-');
|
||||
const key = m % 1 === 0 ? String(m) : String(m).replace(".", "-");
|
||||
scale[key] = `${baseUnit * m}px`;
|
||||
}
|
||||
|
||||
@@ -140,15 +139,15 @@ function createSpacingScale(baseUnit: number = 4): Record<string, string> {
|
||||
```css
|
||||
:root {
|
||||
/* Icon sizes aligned to spacing grid */
|
||||
--icon-xs: 12px; /* Inline decorators */
|
||||
--icon-sm: 16px; /* Small UI elements */
|
||||
--icon-md: 20px; /* Default size */
|
||||
--icon-lg: 24px; /* Emphasis */
|
||||
--icon-xl: 32px; /* Large displays */
|
||||
--icon-2xl: 48px; /* Hero icons */
|
||||
--icon-xs: 12px; /* Inline decorators */
|
||||
--icon-sm: 16px; /* Small UI elements */
|
||||
--icon-md: 20px; /* Default size */
|
||||
--icon-lg: 24px; /* Emphasis */
|
||||
--icon-xl: 32px; /* Large displays */
|
||||
--icon-2xl: 48px; /* Hero icons */
|
||||
|
||||
/* Touch target sizes */
|
||||
--touch-target-min: 44px; /* WCAG minimum */
|
||||
--touch-target-min: 44px; /* WCAG minimum */
|
||||
--touch-target-comfortable: 48px;
|
||||
}
|
||||
```
|
||||
@@ -156,11 +155,11 @@ function createSpacingScale(baseUnit: number = 4): Record<string, string> {
|
||||
### SVG Icon Component
|
||||
|
||||
```tsx
|
||||
import { forwardRef, type SVGProps } from 'react';
|
||||
import { forwardRef, type SVGProps } from "react";
|
||||
|
||||
interface IconProps extends SVGProps<SVGSVGElement> {
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -170,11 +169,11 @@ const sizeMap = {
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
'2xl': 48,
|
||||
"2xl": 48,
|
||||
};
|
||||
|
||||
export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
||||
({ name, size = 'md', label, className, ...props }, ref) => {
|
||||
({ name, size = "md", label, className, ...props }, ref) => {
|
||||
const pixelSize = sizeMap[size];
|
||||
|
||||
return (
|
||||
@@ -185,16 +184,16 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
||||
className={`inline-block flex-shrink-0 ${className}`}
|
||||
aria-hidden={!label}
|
||||
aria-label={label}
|
||||
role={label ? 'img' : undefined}
|
||||
role={label ? "img" : undefined}
|
||||
{...props}
|
||||
>
|
||||
<use href={`/icons.svg#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Icon.displayName = 'Icon';
|
||||
Icon.displayName = "Icon";
|
||||
```
|
||||
|
||||
### Icon Button Patterns
|
||||
@@ -203,27 +202,27 @@ Icon.displayName = 'Icon';
|
||||
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: string;
|
||||
label: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'solid' | 'ghost' | 'outline';
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "solid" | "ghost" | "outline";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-1.5', /* 32px total with 16px icon */
|
||||
md: 'p-2', /* 40px total with 20px icon */
|
||||
lg: 'p-2.5', /* 48px total with 24px icon */
|
||||
sm: "p-1.5" /* 32px total with 16px icon */,
|
||||
md: "p-2" /* 40px total with 20px icon */,
|
||||
lg: "p-2.5" /* 48px total with 24px icon */,
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'sm' as const,
|
||||
md: 'md' as const,
|
||||
lg: 'lg' as const,
|
||||
sm: "sm" as const,
|
||||
md: "md" as const,
|
||||
lg: "lg" as const,
|
||||
};
|
||||
|
||||
export function IconButton({
|
||||
icon,
|
||||
label,
|
||||
size = 'md',
|
||||
variant = 'ghost',
|
||||
size = "md",
|
||||
variant = "ghost",
|
||||
className,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
@@ -233,9 +232,9 @@ export function IconButton({
|
||||
inline-flex items-center justify-center rounded-lg
|
||||
transition-colors focus-visible:outline-none focus-visible:ring-2
|
||||
${sizeClasses[size]}
|
||||
${variant === 'solid' && 'bg-blue-600 text-white hover:bg-blue-700'}
|
||||
${variant === 'ghost' && 'hover:bg-gray-100'}
|
||||
${variant === 'outline' && 'border border-gray-300 hover:bg-gray-50'}
|
||||
${variant === "solid" && "bg-blue-600 text-white hover:bg-blue-700"}
|
||||
${variant === "ghost" && "hover:bg-gray-100"}
|
||||
${variant === "outline" && "border border-gray-300 hover:bg-gray-50"}
|
||||
${className}
|
||||
`}
|
||||
aria-label={label}
|
||||
@@ -251,62 +250,62 @@ export function IconButton({
|
||||
|
||||
```tsx
|
||||
// Build script for SVG sprite
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { optimize } from 'svgo';
|
||||
import { readdir, readFile, writeFile } from "fs/promises";
|
||||
import { optimize } from "svgo";
|
||||
|
||||
async function buildIconSprite(iconDir: string, outputPath: string) {
|
||||
const files = await readdir(iconDir);
|
||||
const svgFiles = files.filter((f) => f.endsWith('.svg'));
|
||||
const svgFiles = files.filter((f) => f.endsWith(".svg"));
|
||||
|
||||
const symbols = await Promise.all(
|
||||
svgFiles.map(async (file) => {
|
||||
const content = await readFile(`${iconDir}/${file}`, 'utf-8');
|
||||
const name = file.replace('.svg', '');
|
||||
const content = await readFile(`${iconDir}/${file}`, "utf-8");
|
||||
const name = file.replace(".svg", "");
|
||||
|
||||
// Optimize SVG
|
||||
const result = optimize(content, {
|
||||
plugins: [
|
||||
'removeDoctype',
|
||||
'removeXMLProcInst',
|
||||
'removeComments',
|
||||
'removeMetadata',
|
||||
'removeTitle',
|
||||
'removeDesc',
|
||||
'removeUselessDefs',
|
||||
'removeEditorsNSData',
|
||||
'removeEmptyAttrs',
|
||||
'removeHiddenElems',
|
||||
'removeEmptyText',
|
||||
'removeEmptyContainers',
|
||||
'convertStyleToAttrs',
|
||||
'convertColors',
|
||||
'convertPathData',
|
||||
'convertTransform',
|
||||
'removeUnknownsAndDefaults',
|
||||
'removeNonInheritableGroupAttrs',
|
||||
'removeUselessStrokeAndFill',
|
||||
'removeUnusedNS',
|
||||
'cleanupNumericValues',
|
||||
'cleanupListOfValues',
|
||||
'moveElemsAttrsToGroup',
|
||||
'moveGroupAttrsToElems',
|
||||
'collapseGroups',
|
||||
'mergePaths',
|
||||
"removeDoctype",
|
||||
"removeXMLProcInst",
|
||||
"removeComments",
|
||||
"removeMetadata",
|
||||
"removeTitle",
|
||||
"removeDesc",
|
||||
"removeUselessDefs",
|
||||
"removeEditorsNSData",
|
||||
"removeEmptyAttrs",
|
||||
"removeHiddenElems",
|
||||
"removeEmptyText",
|
||||
"removeEmptyContainers",
|
||||
"convertStyleToAttrs",
|
||||
"convertColors",
|
||||
"convertPathData",
|
||||
"convertTransform",
|
||||
"removeUnknownsAndDefaults",
|
||||
"removeNonInheritableGroupAttrs",
|
||||
"removeUselessStrokeAndFill",
|
||||
"removeUnusedNS",
|
||||
"cleanupNumericValues",
|
||||
"cleanupListOfValues",
|
||||
"moveElemsAttrsToGroup",
|
||||
"moveGroupAttrsToElems",
|
||||
"collapseGroups",
|
||||
"mergePaths",
|
||||
],
|
||||
});
|
||||
|
||||
// Extract viewBox and content
|
||||
const viewBoxMatch = result.data.match(/viewBox="([^"]+)"/);
|
||||
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
|
||||
const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 24 24";
|
||||
const innerContent = result.data
|
||||
.replace(/<svg[^>]*>/, '')
|
||||
.replace(/<\/svg>/, '');
|
||||
.replace(/<svg[^>]*>/, "")
|
||||
.replace(/<\/svg>/, "");
|
||||
|
||||
return `<symbol id="${name}" viewBox="${viewBox}">${innerContent}</symbol>`;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>`;
|
||||
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join("")}</svg>`;
|
||||
|
||||
await writeFile(outputPath, sprite);
|
||||
console.log(`Generated sprite with ${symbols.length} icons`);
|
||||
@@ -317,7 +316,7 @@ async function buildIconSprite(iconDir: string, outputPath: string) {
|
||||
|
||||
```tsx
|
||||
// Lucide React
|
||||
import { Home, Settings, User, Search } from 'lucide-react';
|
||||
import { Home, Settings, User, Search } from "lucide-react";
|
||||
|
||||
function Navigation() {
|
||||
return (
|
||||
@@ -331,8 +330,8 @@ function Navigation() {
|
||||
}
|
||||
|
||||
// Heroicons
|
||||
import { HomeIcon, Cog6ToothIcon } from '@heroicons/react/24/outline';
|
||||
import { HomeIcon as HomeIconSolid } from '@heroicons/react/24/solid';
|
||||
import { HomeIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
|
||||
import { HomeIcon as HomeIconSolid } from "@heroicons/react/24/solid";
|
||||
|
||||
function ToggleIcon({ active }: { active: boolean }) {
|
||||
const Icon = active ? HomeIconSolid : HomeIcon;
|
||||
@@ -340,7 +339,7 @@ function ToggleIcon({ active }: { active: boolean }) {
|
||||
}
|
||||
|
||||
// Radix Icons
|
||||
import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
|
||||
import { HomeIcon, GearIcon } from "@radix-ui/react-icons";
|
||||
```
|
||||
|
||||
## Sizing Systems
|
||||
@@ -350,29 +349,29 @@ import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
|
||||
```css
|
||||
:root {
|
||||
/* Fixed sizes */
|
||||
--size-4: 1rem; /* 16px */
|
||||
--size-5: 1.25rem; /* 20px */
|
||||
--size-6: 1.5rem; /* 24px */
|
||||
--size-8: 2rem; /* 32px */
|
||||
--size-10: 2.5rem; /* 40px */
|
||||
--size-12: 3rem; /* 48px */
|
||||
--size-14: 3.5rem; /* 56px */
|
||||
--size-16: 4rem; /* 64px */
|
||||
--size-20: 5rem; /* 80px */
|
||||
--size-24: 6rem; /* 96px */
|
||||
--size-32: 8rem; /* 128px */
|
||||
--size-4: 1rem; /* 16px */
|
||||
--size-5: 1.25rem; /* 20px */
|
||||
--size-6: 1.5rem; /* 24px */
|
||||
--size-8: 2rem; /* 32px */
|
||||
--size-10: 2.5rem; /* 40px */
|
||||
--size-12: 3rem; /* 48px */
|
||||
--size-14: 3.5rem; /* 56px */
|
||||
--size-16: 4rem; /* 64px */
|
||||
--size-20: 5rem; /* 80px */
|
||||
--size-24: 6rem; /* 96px */
|
||||
--size-32: 8rem; /* 128px */
|
||||
|
||||
/* Component heights */
|
||||
--height-input-sm: var(--size-8); /* 32px */
|
||||
--height-input-md: var(--size-10); /* 40px */
|
||||
--height-input-lg: var(--size-12); /* 48px */
|
||||
--height-input-sm: var(--size-8); /* 32px */
|
||||
--height-input-md: var(--size-10); /* 40px */
|
||||
--height-input-lg: var(--size-12); /* 48px */
|
||||
|
||||
/* Avatar sizes */
|
||||
--avatar-xs: var(--size-6); /* 24px */
|
||||
--avatar-sm: var(--size-8); /* 32px */
|
||||
--avatar-md: var(--size-10); /* 40px */
|
||||
--avatar-lg: var(--size-12); /* 48px */
|
||||
--avatar-xl: var(--size-16); /* 64px */
|
||||
--avatar-xs: var(--size-6); /* 24px */
|
||||
--avatar-sm: var(--size-8); /* 32px */
|
||||
--avatar-md: var(--size-10); /* 40px */
|
||||
--avatar-lg: var(--size-12); /* 48px */
|
||||
--avatar-xl: var(--size-16); /* 64px */
|
||||
--avatar-2xl: var(--size-24); /* 96px */
|
||||
}
|
||||
```
|
||||
@@ -407,13 +406,13 @@ import { HomeIcon, GearIcon } from '@radix-ui/react-icons';
|
||||
```css
|
||||
:root {
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.125rem; /* 2px */
|
||||
--radius-sm: 0.125rem; /* 2px */
|
||||
--radius-default: 0.25rem; /* 4px */
|
||||
--radius-md: 0.375rem; /* 6px */
|
||||
--radius-lg: 0.5rem; /* 8px */
|
||||
--radius-xl: 0.75rem; /* 12px */
|
||||
--radius-2xl: 1rem; /* 16px */
|
||||
--radius-3xl: 1.5rem; /* 24px */
|
||||
--radius-md: 0.375rem; /* 6px */
|
||||
--radius-lg: 0.5rem; /* 8px */
|
||||
--radius-xl: 0.75rem; /* 12px */
|
||||
--radius-2xl: 1rem; /* 16px */
|
||||
--radius-3xl: 1.5rem; /* 24px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Component-specific */
|
||||
|
||||
@@ -9,17 +9,21 @@ A modular scale creates harmonious relationships between font sizes using a math
|
||||
```tsx
|
||||
// Common ratios
|
||||
const RATIOS = {
|
||||
minorSecond: 1.067, // 16:15
|
||||
majorSecond: 1.125, // 9:8
|
||||
minorThird: 1.2, // 6:5
|
||||
majorThird: 1.25, // 5:4
|
||||
perfectFourth: 1.333, // 4:3
|
||||
minorSecond: 1.067, // 16:15
|
||||
majorSecond: 1.125, // 9:8
|
||||
minorThird: 1.2, // 6:5
|
||||
majorThird: 1.25, // 5:4
|
||||
perfectFourth: 1.333, // 4:3
|
||||
augmentedFourth: 1.414, // √2
|
||||
perfectFifth: 1.5, // 3:2
|
||||
goldenRatio: 1.618, // φ
|
||||
perfectFifth: 1.5, // 3:2
|
||||
goldenRatio: 1.618, // φ
|
||||
};
|
||||
|
||||
function generateScale(baseSize: number, ratio: number, steps: number): number[] {
|
||||
function generateScale(
|
||||
baseSize: number,
|
||||
ratio: number,
|
||||
steps: number,
|
||||
): number[] {
|
||||
const scale: number[] = [];
|
||||
for (let i = -2; i <= steps; i++) {
|
||||
scale.push(Math.round(baseSize * Math.pow(ratio, i) * 100) / 100);
|
||||
@@ -37,17 +41,17 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
|
||||
```css
|
||||
:root {
|
||||
/* Base scale using perfect fourth (1.333) */
|
||||
--font-size-2xs: 0.563rem; /* ~9px */
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-md: 1.125rem; /* 18px */
|
||||
--font-size-lg: 1.333rem; /* ~21px */
|
||||
--font-size-xl: 1.5rem; /* 24px */
|
||||
--font-size-2xl: 1.777rem; /* ~28px */
|
||||
--font-size-3xl: 2.369rem; /* ~38px */
|
||||
--font-size-4xl: 3.157rem; /* ~50px */
|
||||
--font-size-5xl: 4.209rem; /* ~67px */
|
||||
--font-size-2xs: 0.563rem; /* ~9px */
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-md: 1.125rem; /* 18px */
|
||||
--font-size-lg: 1.333rem; /* ~21px */
|
||||
--font-size-xl: 1.5rem; /* 24px */
|
||||
--font-size-2xl: 1.777rem; /* ~28px */
|
||||
--font-size-3xl: 2.369rem; /* ~38px */
|
||||
--font-size-4xl: 3.157rem; /* ~50px */
|
||||
--font-size-5xl: 4.209rem; /* ~67px */
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-normal: 400;
|
||||
@@ -79,8 +83,8 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
|
||||
```css
|
||||
/* Use font-display to control loading behavior */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap; /* Show fallback immediately, swap when loaded */
|
||||
@@ -88,8 +92,8 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
|
||||
|
||||
/* Optional: size-adjust for better fallback matching */
|
||||
@font-face {
|
||||
font-family: 'Inter Fallback';
|
||||
src: local('Arial');
|
||||
font-family: "Inter Fallback";
|
||||
src: local("Arial");
|
||||
size-adjust: 107%; /* Adjust to match Inter metrics */
|
||||
ascent-override: 90%;
|
||||
descent-override: 22%;
|
||||
@@ -97,7 +101,7 @@ const typeScale = generateScale(16, RATIOS.perfectFourth, 6);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
|
||||
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -121,15 +125,17 @@ body {
|
||||
```css
|
||||
/* Variable font with weight and width axes */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter-Variable.woff2') format('woff2');
|
||||
font-family: "Inter";
|
||||
src: url("/fonts/Inter-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-stretch: 75% 125%;
|
||||
}
|
||||
|
||||
/* Use font-variation-settings for fine control */
|
||||
.custom-weight {
|
||||
font-variation-settings: 'wght' 450, 'wdth' 95;
|
||||
font-variation-settings:
|
||||
"wght" 450,
|
||||
"wdth" 95;
|
||||
}
|
||||
|
||||
/* Or use standard properties */
|
||||
@@ -169,9 +175,8 @@ p {
|
||||
--max-vw: 1200;
|
||||
|
||||
line-height: calc(
|
||||
var(--min-line-height) +
|
||||
(var(--max-line-height) - var(--min-line-height)) *
|
||||
((100vw - var(--min-vw) * 1px) / (var(--max-vw) - var(--min-vw)))
|
||||
var(--min-line-height) + (var(--max-line-height) - var(--min-line-height)) *
|
||||
((100vw - var(--min-vw) * 1px) / (var(--max-vw) - var(--min-vw)))
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -183,15 +188,15 @@ p {
|
||||
module.exports = {
|
||||
theme: {
|
||||
fontSize: {
|
||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||
sm: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
base: ['1rem', { lineHeight: '1.5rem' }],
|
||||
lg: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
xl: ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
'5xl': ['3rem', { lineHeight: '1' }],
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
|
||||
"4xl": ["2.25rem", { lineHeight: "2.5rem" }],
|
||||
"5xl": ["3rem", { lineHeight: "1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -268,7 +273,9 @@ p {
|
||||
}
|
||||
|
||||
/* Balance headings */
|
||||
h1, h2, h3 {
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@@ -292,20 +299,20 @@ h1, h2, h3 {
|
||||
```css
|
||||
/* Serif heading + Sans body */
|
||||
:root {
|
||||
--font-heading: 'Playfair Display', Georgia, serif;
|
||||
--font-body: 'Source Sans Pro', -apple-system, sans-serif;
|
||||
--font-heading: "Playfair Display", Georgia, serif;
|
||||
--font-body: "Source Sans Pro", -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Geometric heading + Humanist body */
|
||||
:root {
|
||||
--font-heading: 'Space Grotesk', sans-serif;
|
||||
--font-body: 'IBM Plex Sans', sans-serif;
|
||||
--font-heading: "Space Grotesk", sans-serif;
|
||||
--font-body: "IBM Plex Sans", sans-serif;
|
||||
}
|
||||
|
||||
/* Modern sans heading + Classic serif body */
|
||||
:root {
|
||||
--font-heading: 'Inter', system-ui, sans-serif;
|
||||
--font-body: 'Georgia', Times, serif;
|
||||
--font-heading: "Inter", system-ui, sans-serif;
|
||||
--font-body: "Georgia", Times, serif;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -314,7 +321,7 @@ h1, h2, h3 {
|
||||
```css
|
||||
/* Single variable font family for all uses */
|
||||
:root {
|
||||
--font-family: 'Inter', system-ui, sans-serif;
|
||||
--font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -405,7 +412,7 @@ p {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
|
||||
/* Fractions */
|
||||
font-feature-settings: 'frac' 1;
|
||||
font-feature-settings: "frac" 1;
|
||||
}
|
||||
|
||||
/* Tabular numbers for aligned columns */
|
||||
|
||||
@@ -22,6 +22,7 @@ Build reusable, maintainable UI components using modern frameworks with clean co
|
||||
### 1. Component Composition Patterns
|
||||
|
||||
**Compound Components**: Related components that work together
|
||||
|
||||
```tsx
|
||||
// Usage
|
||||
<Select value={value} onChange={setValue}>
|
||||
@@ -34,15 +35,17 @@ Build reusable, maintainable UI components using modern frameworks with clean co
|
||||
```
|
||||
|
||||
**Render Props**: Delegate rendering to parent
|
||||
|
||||
```tsx
|
||||
<DataFetcher url="/api/users">
|
||||
{({ data, loading, error }) => (
|
||||
{({ data, loading, error }) =>
|
||||
loading ? <Spinner /> : <UserList users={data} />
|
||||
)}
|
||||
}
|
||||
</DataFetcher>
|
||||
```
|
||||
|
||||
**Slots (Vue/Svelte)**: Named content injection points
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Card>
|
||||
@@ -55,20 +58,20 @@ Build reusable, maintainable UI components using modern frameworks with clean co
|
||||
|
||||
### 2. CSS-in-JS Approaches
|
||||
|
||||
| Solution | Approach | Best For |
|
||||
|----------|----------|----------|
|
||||
| **Tailwind CSS** | Utility classes | Rapid prototyping, design systems |
|
||||
| **CSS Modules** | Scoped CSS files | Existing CSS, gradual adoption |
|
||||
| **styled-components** | Template literals | React, dynamic styling |
|
||||
| **Emotion** | Object/template styles | Flexible, SSR-friendly |
|
||||
| **Vanilla Extract** | Zero-runtime | Performance-critical apps |
|
||||
| Solution | Approach | Best For |
|
||||
| --------------------- | ---------------------- | --------------------------------- |
|
||||
| **Tailwind CSS** | Utility classes | Rapid prototyping, design systems |
|
||||
| **CSS Modules** | Scoped CSS files | Existing CSS, gradual adoption |
|
||||
| **styled-components** | Template literals | React, dynamic styling |
|
||||
| **Emotion** | Object/template styles | Flexible, SSR-friendly |
|
||||
| **Vanilla Extract** | Zero-runtime | Performance-critical apps |
|
||||
|
||||
### 3. Component API Design
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
@@ -79,6 +82,7 @@ interface ButtonProps {
|
||||
```
|
||||
|
||||
**Principles**:
|
||||
|
||||
- Use semantic prop names (`isLoading` vs `loading`)
|
||||
- Provide sensible defaults
|
||||
- Support composition via `children`
|
||||
@@ -87,34 +91,35 @@ interface ButtonProps {
|
||||
## Quick Start: React Component with Tailwind
|
||||
|
||||
```tsx
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
|
||||
ghost: 'hover:bg-gray-100 hover:text-gray-900',
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
|
||||
ghost: "hover:bg-gray-100 hover:text-gray-900",
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
variant: "primary",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends ComponentPropsWithoutRef<'button'>,
|
||||
extends
|
||||
ComponentPropsWithoutRef<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -130,9 +135,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
),
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
```
|
||||
|
||||
## Framework Patterns
|
||||
@@ -140,7 +145,7 @@ Button.displayName = 'Button';
|
||||
### React: Compound Components
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
interface AccordionContextValue {
|
||||
openItems: Set<string>;
|
||||
@@ -151,7 +156,7 @@ const AccordionContext = createContext<AccordionContextValue | null>(null);
|
||||
|
||||
function useAccordion() {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) throw new Error('Must be used within Accordion');
|
||||
if (!context) throw new Error("Must be used within Accordion");
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -159,7 +164,7 @@ export function Accordion({ children }: { children: ReactNode }) {
|
||||
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenItems(prev => {
|
||||
setOpenItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
@@ -176,8 +181,12 @@ export function Accordion({ children }: { children: ReactNode }) {
|
||||
Accordion.Item = function AccordionItem({
|
||||
id,
|
||||
title,
|
||||
children
|
||||
}: { id: string; title: string; children: ReactNode }) {
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { openItems, toggle } = useAccordion();
|
||||
const isOpen = openItems.has(id);
|
||||
|
||||
@@ -196,20 +205,22 @@ Accordion.Item = function AccordionItem({
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, provide, inject, type InjectionKey } from 'vue';
|
||||
import { ref, computed, provide, inject, type InjectionKey } from "vue";
|
||||
|
||||
interface TabsContext {
|
||||
activeTab: Ref<string>;
|
||||
setActive: (id: string) => void;
|
||||
}
|
||||
|
||||
const TabsKey: InjectionKey<TabsContext> = Symbol('tabs');
|
||||
const TabsKey: InjectionKey<TabsContext> = Symbol("tabs");
|
||||
|
||||
// Parent component
|
||||
const activeTab = ref('tab-1');
|
||||
const activeTab = ref("tab-1");
|
||||
provide(TabsKey, {
|
||||
activeTab,
|
||||
setActive: (id: string) => { activeTab.value = id; }
|
||||
setActive: (id: string) => {
|
||||
activeTab.value = id;
|
||||
},
|
||||
});
|
||||
|
||||
// Child component usage
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
### Modal Dialog
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -23,27 +23,27 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement;
|
||||
dialogRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = "";
|
||||
(previousActiveElement.current as HTMLElement)?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Tab') trapFocus(e, dialogRef.current);
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Tab") trapFocus(e, dialogRef.current);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -82,7 +82,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
|
||||
if (!container) return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
@@ -108,7 +108,7 @@ function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
|
||||
### Dropdown Menu
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
@@ -124,22 +124,25 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
@@ -147,17 +150,17 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
|
||||
focusNextItem(menuRef.current, 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
focusNextItem(menuRef.current, -1);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusFirstItem(menuRef.current);
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusLastItem(menuRef.current);
|
||||
break;
|
||||
@@ -177,7 +180,7 @@ export function Dropdown({ trigger, children, label }: DropdownProps) {
|
||||
{trigger}
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -217,18 +220,26 @@ export function MenuItem({ children, onClick, disabled }: MenuItemProps) {
|
||||
|
||||
function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) {
|
||||
if (!menu) return;
|
||||
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement);
|
||||
const items = menu.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([disabled])',
|
||||
);
|
||||
const currentIndex = Array.from(items).indexOf(
|
||||
document.activeElement as HTMLElement,
|
||||
);
|
||||
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
function focusFirstItem(menu: HTMLElement | null) {
|
||||
menu?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')?.focus();
|
||||
menu
|
||||
?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')
|
||||
?.focus();
|
||||
}
|
||||
|
||||
function focusLastItem(menu: HTMLElement | null) {
|
||||
const items = menu?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])');
|
||||
const items = menu?.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([disabled])',
|
||||
);
|
||||
items?.[items.length - 1]?.focus();
|
||||
}
|
||||
```
|
||||
@@ -236,7 +247,13 @@ function focusLastItem(menu: HTMLElement | null) {
|
||||
### Combobox / Autocomplete
|
||||
|
||||
```tsx
|
||||
import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useId,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
@@ -259,7 +276,7 @@ export function Combobox({
|
||||
placeholder,
|
||||
}: ComboboxProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -268,7 +285,7 @@ export function Combobox({
|
||||
const listboxId = useId();
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -286,27 +303,27 @@ export function Combobox({
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setActiveIndex((prev) =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && filteredOptions[activeIndex]) {
|
||||
handleSelect(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
@@ -353,8 +370,8 @@ export function Combobox({
|
||||
aria-selected={activeIndex === index}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`cursor-pointer px-3 py-2 ${
|
||||
activeIndex === index ? 'bg-blue-100' : 'hover:bg-gray-100'
|
||||
} ${value === option.value ? 'font-medium' : ''}`}
|
||||
activeIndex === index ? "bg-blue-100" : "hover:bg-gray-100"
|
||||
} ${value === option.value ? "font-medium" : ""}`}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
@@ -375,7 +392,7 @@ export function Combobox({
|
||||
### Form Validation
|
||||
|
||||
```tsx
|
||||
import { useId, type FormEvent } from 'react';
|
||||
import { useId, type FormEvent } from "react";
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
@@ -383,12 +400,17 @@ interface FormFieldProps {
|
||||
required?: boolean;
|
||||
children: (props: {
|
||||
id: string;
|
||||
'aria-describedby': string | undefined;
|
||||
'aria-invalid': boolean;
|
||||
"aria-describedby": string | undefined;
|
||||
"aria-invalid": boolean;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, error, required, children }: FormFieldProps) {
|
||||
export function FormField({
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
children,
|
||||
}: FormFieldProps) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
|
||||
@@ -405,8 +427,8 @@ export function FormField({ label, error, required, children }: FormFieldProps)
|
||||
|
||||
{children({
|
||||
id,
|
||||
'aria-describedby': error ? errorId : undefined,
|
||||
'aria-invalid': !!error,
|
||||
"aria-describedby": error ? errorId : undefined,
|
||||
"aria-invalid": !!error,
|
||||
})}
|
||||
|
||||
{error && (
|
||||
@@ -436,13 +458,16 @@ function ContactForm() {
|
||||
type="email"
|
||||
required
|
||||
className={`w-full rounded border px-3 py-2 ${
|
||||
props['aria-invalid'] ? 'border-red-500' : 'border-gray-300'
|
||||
props["aria-invalid"] ? "border-red-500" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
@@ -476,19 +501,22 @@ export function SkipLinks() {
|
||||
## Live Regions
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface LiveAnnouncerProps {
|
||||
message: string;
|
||||
politeness?: 'polite' | 'assertive';
|
||||
politeness?: "polite" | "assertive";
|
||||
}
|
||||
|
||||
export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) {
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
export function LiveAnnouncer({
|
||||
message,
|
||||
politeness = "polite",
|
||||
}: LiveAnnouncerProps) {
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Clear first, then set - ensures screen readers pick up the change
|
||||
setAnnouncement('');
|
||||
setAnnouncement("");
|
||||
const timer = setTimeout(() => setAnnouncement(message), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [message]);
|
||||
@@ -506,9 +534,15 @@ export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerP
|
||||
}
|
||||
|
||||
// Usage in a search component
|
||||
function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) {
|
||||
function SearchResults({
|
||||
results,
|
||||
loading,
|
||||
}: {
|
||||
results: Item[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
const message = loading
|
||||
? 'Loading results...'
|
||||
? "Loading results..."
|
||||
: `${results.length} results found`;
|
||||
|
||||
return (
|
||||
@@ -544,12 +578,14 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
|
||||
if (!isActive || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableSelector =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
|
||||
const focusableElements =
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector);
|
||||
const first = focusableElements[0];
|
||||
const last = focusableElements[focusableElements.length - 1];
|
||||
|
||||
@@ -562,8 +598,8 @@ function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return () => container.removeEventListener('keydown', handleKeyDown);
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [containerRef, isActive]);
|
||||
}
|
||||
```
|
||||
@@ -595,8 +631,12 @@ function getContrastRatio(fg: string, bg: string): number {
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean {
|
||||
function meetsWCAG(
|
||||
fg: string,
|
||||
bg: string,
|
||||
level: "AA" | "AAA" = "AA",
|
||||
): boolean {
|
||||
const ratio = getContrastRatio(fg, bg);
|
||||
return level === 'AAA' ? ratio >= 7 : ratio >= 4.5;
|
||||
return level === "AAA" ? ratio >= 7 : ratio >= 4.5;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
} from "react";
|
||||
|
||||
// Types
|
||||
interface TabsContextValue {
|
||||
@@ -51,7 +51,7 @@ const TabsContext = createContext<TabsContextValue | null>(null);
|
||||
function useTabs() {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('Tabs components must be used within <Tabs>');
|
||||
throw new Error("Tabs components must be used within <Tabs>");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -62,11 +62,11 @@ export function Tabs({ defaultValue, children, onChange }: TabsProps) {
|
||||
|
||||
const handleChange: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
const newValue = typeof value === 'function' ? value(activeTab) : value;
|
||||
const newValue = typeof value === "function" ? value(activeTab) : value;
|
||||
setActiveTab(newValue);
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[activeTab, onChange]
|
||||
[activeTab, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -100,10 +100,12 @@ Tabs.Tab = function Tab({ value, children, disabled }: TabProps) {
|
||||
onClick={() => setActiveTab(value)}
|
||||
className={`
|
||||
px-4 py-2 font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-600 hover:text-gray-900'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${
|
||||
isActive
|
||||
? "border-b-2 border-blue-600 text-blue-600"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
@@ -138,7 +140,9 @@ Tabs.Panel = function TabPanel({ value, children }: TabPanelProps) {
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="features">Features</Tabs.Tab>
|
||||
<Tabs.Tab value="pricing" disabled>Pricing</Tabs.Tab>
|
||||
<Tabs.Tab value="pricing" disabled>
|
||||
Pricing
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="overview">
|
||||
<h2>Product Overview</h2>
|
||||
@@ -180,14 +184,14 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Fetch failed');
|
||||
if (!response.ok) throw new Error("Fetch failed");
|
||||
const data = await response.json();
|
||||
setState({ data, loading: false, error: null });
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, loading: false, error: error as Error }));
|
||||
setState((prev) => ({ ...prev, loading: false, error: error as Error }));
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
@@ -205,7 +209,7 @@ function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
|
||||
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
|
||||
return <UserList users={data!} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
</DataLoader>;
|
||||
```
|
||||
|
||||
## Polymorphic Components
|
||||
@@ -343,18 +347,10 @@ function Card({ children, header, footer, media }: CardProps) {
|
||||
return (
|
||||
<article className="rounded-lg border bg-white shadow-sm">
|
||||
{media && (
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg">
|
||||
{media}
|
||||
</div>
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg">{media}</div>
|
||||
)}
|
||||
{header && (
|
||||
<header className="border-b px-4 py-3">
|
||||
{header}
|
||||
</header>
|
||||
)}
|
||||
<div className="px-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{header && <header className="border-b px-4 py-3">{header}</header>}
|
||||
<div className="px-4 py-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="border-t px-4 py-3 bg-gray-50 rounded-b-lg">
|
||||
{footer}
|
||||
@@ -371,7 +367,7 @@ function Card({ children, header, footer, media }: CardProps) {
|
||||
footer={<Button>Action</Button>}
|
||||
>
|
||||
<p>Card content goes here.</p>
|
||||
</Card>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
## Forward Ref Pattern
|
||||
@@ -379,7 +375,7 @@ function Card({ children, header, footer, media }: CardProps) {
|
||||
Allow parent components to access the underlying DOM node.
|
||||
|
||||
```tsx
|
||||
import { forwardRef, useRef, useImperativeHandle } from 'react';
|
||||
import { forwardRef, useRef, useImperativeHandle } from "react";
|
||||
|
||||
interface InputHandle {
|
||||
focus: () => void;
|
||||
@@ -399,9 +395,9 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
getValue: () => inputRef.current?.value ?? '',
|
||||
getValue: () => inputRef.current?.value ?? "",
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -415,10 +411,10 @@ const FancyInput = forwardRef<InputHandle, FancyInputProps>(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
FancyInput.displayName = 'FancyInput';
|
||||
FancyInput.displayName = "FancyInput";
|
||||
|
||||
// Usage
|
||||
function Form() {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
|
||||
|----------|---------|-------------|----------------|----------------|-----|
|
||||
| CSS Modules | None | Minimal | Low | Limited | Yes |
|
||||
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
|
||||
| styled-components | Yes | Medium | Medium | Full | Yes* |
|
||||
| Emotion | Yes | Medium | Medium | Full | Yes |
|
||||
| Vanilla Extract | None | Minimal | High | Limited | Yes |
|
||||
| Approach | Runtime | Bundle Size | Learning Curve | Dynamic Styles | SSR |
|
||||
| ----------------- | ------- | -------------- | -------------- | -------------- | ----- |
|
||||
| CSS Modules | None | Minimal | Low | Limited | Yes |
|
||||
| Tailwind | None | Small (purged) | Medium | Via classes | Yes |
|
||||
| styled-components | Yes | Medium | Medium | Full | Yes\* |
|
||||
| Emotion | Yes | Medium | Medium | Full | Yes |
|
||||
| Vanilla Extract | None | Minimal | High | Limited | Yes |
|
||||
|
||||
## CSS Modules
|
||||
|
||||
@@ -56,19 +56,19 @@ Scoped CSS with zero runtime overhead.
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import styles from './Button.module.css';
|
||||
import { clsx } from 'clsx';
|
||||
import styles from "./Button.module.css";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
variant?: "primary" | "secondary";
|
||||
size?: "small" | "medium" | "large";
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
children,
|
||||
onClick,
|
||||
}: ButtonProps) {
|
||||
@@ -77,7 +77,7 @@ export function Button({
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles[variant],
|
||||
size !== 'medium' && styles[size]
|
||||
size !== "medium" && styles[size],
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -104,7 +104,7 @@ export function Button({
|
||||
|
||||
/* Button.module.css */
|
||||
.srOnly {
|
||||
composes: visuallyHidden from './base.module.css';
|
||||
composes: visuallyHidden from "./base.module.css";
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,38 +115,42 @@ Utility-first CSS with design system constraints.
|
||||
### Class Variance Authority (CVA)
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
@@ -160,7 +164,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -168,48 +172,48 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
```tsx
|
||||
// lib/utils.ts
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Usage - handles conflicting classes
|
||||
cn('px-4 py-2', 'px-6'); // => 'py-2 px-6'
|
||||
cn('text-red-500', condition && 'text-blue-500'); // => 'text-blue-500' if condition
|
||||
cn("px-4 py-2", "px-6"); // => 'py-2 px-6'
|
||||
cn("text-red-500", condition && "text-blue-500"); // => 'text-blue-500' if condition
|
||||
```
|
||||
|
||||
### Custom Plugin
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
const plugin = require("tailwindcss/plugin");
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
plugin(function({ addUtilities, addComponents, theme }) {
|
||||
plugin(function ({ addUtilities, addComponents, theme }) {
|
||||
// Add utilities
|
||||
addUtilities({
|
||||
'.text-balance': {
|
||||
'text-wrap': 'balance',
|
||||
".text-balance": {
|
||||
"text-wrap": "balance",
|
||||
},
|
||||
'.scrollbar-hide': {
|
||||
'-ms-overflow-style': 'none',
|
||||
'scrollbar-width': 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
".scrollbar-hide": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add components
|
||||
addComponents({
|
||||
'.card': {
|
||||
backgroundColor: theme('colors.white'),
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
padding: theme('spacing.6'),
|
||||
boxShadow: theme('boxShadow.md'),
|
||||
".card": {
|
||||
backgroundColor: theme("colors.white"),
|
||||
borderRadius: theme("borderRadius.lg"),
|
||||
padding: theme("spacing.6"),
|
||||
boxShadow: theme("boxShadow.md"),
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -222,7 +226,7 @@ module.exports = {
|
||||
CSS-in-JS with template literals.
|
||||
|
||||
```tsx
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
|
||||
// Keyframes
|
||||
const fadeIn = keyframes`
|
||||
@@ -232,8 +236,8 @@ const fadeIn = keyframes`
|
||||
|
||||
// Base button with variants
|
||||
interface ButtonProps {
|
||||
$variant?: 'primary' | 'secondary' | 'ghost';
|
||||
$size?: 'sm' | 'md' | 'lg';
|
||||
$variant?: "primary" | "secondary" | "ghost";
|
||||
$size?: "sm" | "md" | "lg";
|
||||
$isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -287,8 +291,8 @@ const Button = styled.button<ButtonProps>`
|
||||
transition: all 0.2s ease;
|
||||
animation: ${fadeIn} 0.3s ease;
|
||||
|
||||
${({ $size = 'md' }) => sizeStyles[$size]}
|
||||
${({ $variant = 'primary' }) => variantStyles[$variant]}
|
||||
${({ $size = "md" }) => sizeStyles[$size]}
|
||||
${({ $variant = "primary" }) => variantStyles[$variant]}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -312,12 +316,12 @@ const IconButton = styled(Button)`
|
||||
// Theme provider
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
primaryHover: '#1d4ed8',
|
||||
secondary: '#f3f4f6',
|
||||
secondaryHover: '#e5e7eb',
|
||||
ghost: 'rgba(0, 0, 0, 0.05)',
|
||||
text: '#1f2937',
|
||||
primary: "#2563eb",
|
||||
primaryHover: "#1d4ed8",
|
||||
secondary: "#f3f4f6",
|
||||
secondaryHover: "#e5e7eb",
|
||||
ghost: "rgba(0, 0, 0, 0.05)",
|
||||
text: "#1f2937",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -326,7 +330,7 @@ const theme = {
|
||||
<Button $variant="primary" $size="lg">
|
||||
Click me
|
||||
</Button>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>;
|
||||
```
|
||||
|
||||
## Emotion
|
||||
@@ -335,11 +339,11 @@ Flexible CSS-in-JS with object and template syntax.
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @emotion/react */
|
||||
import { css, Theme, ThemeProvider, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { css, Theme, ThemeProvider, useTheme } from "@emotion/react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
// Theme typing
|
||||
declare module '@emotion/react' {
|
||||
declare module "@emotion/react" {
|
||||
export interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
@@ -352,20 +356,21 @@ declare module '@emotion/react' {
|
||||
|
||||
const theme: Theme = {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
primary: "#2563eb",
|
||||
background: "#ffffff",
|
||||
text: "#1f2937",
|
||||
},
|
||||
spacing: (factor: number) => `${factor * 0.25}rem`,
|
||||
};
|
||||
|
||||
// Object syntax
|
||||
const cardStyles = (theme: Theme) => css({
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing(4),
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
const cardStyles = (theme: Theme) =>
|
||||
css({
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing(4),
|
||||
borderRadius: "0.5rem",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
});
|
||||
|
||||
// Template literal syntax
|
||||
const buttonStyles = css`
|
||||
@@ -407,7 +412,7 @@ function Alert({ children }: { children: React.ReactNode }) {
|
||||
<Card>
|
||||
<Alert>Important message</Alert>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>;
|
||||
```
|
||||
|
||||
## Vanilla Extract
|
||||
@@ -416,26 +421,26 @@ Zero-runtime CSS-in-JS with full type safety.
|
||||
|
||||
```tsx
|
||||
// styles.css.ts
|
||||
import { style, styleVariants, createTheme } from '@vanilla-extract/css';
|
||||
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { style, styleVariants, createTheme } from "@vanilla-extract/css";
|
||||
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
|
||||
|
||||
// Theme contract
|
||||
export const [themeClass, vars] = createTheme({
|
||||
color: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
primary: "#2563eb",
|
||||
secondary: "#64748b",
|
||||
background: "#ffffff",
|
||||
text: "#1f2937",
|
||||
},
|
||||
space: {
|
||||
small: '0.5rem',
|
||||
medium: '1rem',
|
||||
large: '1.5rem',
|
||||
small: "0.5rem",
|
||||
medium: "1rem",
|
||||
large: "1.5rem",
|
||||
},
|
||||
radius: {
|
||||
small: '0.25rem',
|
||||
medium: '0.375rem',
|
||||
large: '0.5rem',
|
||||
small: "0.25rem",
|
||||
medium: "0.375rem",
|
||||
large: "0.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -455,54 +460,54 @@ export const text = styleVariants({
|
||||
// Recipe (like CVA)
|
||||
export const button = recipe({
|
||||
base: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 500,
|
||||
borderRadius: vars.radius.medium,
|
||||
transition: 'background-color 0.2s',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
':disabled': {
|
||||
transition: "background-color 0.2s",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
":disabled": {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
primary: {
|
||||
backgroundColor: vars.color.primary,
|
||||
color: 'white',
|
||||
':hover': {
|
||||
backgroundColor: '#1d4ed8',
|
||||
color: "white",
|
||||
":hover": {
|
||||
backgroundColor: "#1d4ed8",
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
backgroundColor: "#f3f4f6",
|
||||
color: vars.color.text,
|
||||
':hover': {
|
||||
backgroundColor: '#e5e7eb',
|
||||
":hover": {
|
||||
backgroundColor: "#e5e7eb",
|
||||
},
|
||||
},
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
medium: {
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '1rem',
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
large: {
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1.125rem',
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -511,7 +516,7 @@ export type ButtonVariants = RecipeVariants<typeof button>;
|
||||
|
||||
```tsx
|
||||
// Button.tsx
|
||||
import { button, type ButtonVariants, themeClass } from './styles.css';
|
||||
import { button, type ButtonVariants, themeClass } from "./styles.css";
|
||||
|
||||
interface ButtonProps extends ButtonVariants {
|
||||
children: React.ReactNode;
|
||||
@@ -545,8 +550,8 @@ function App() {
|
||||
```tsx
|
||||
// Next.js with styled-components
|
||||
// pages/_document.tsx
|
||||
import Document, { DocumentContext } from 'next/document';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
import Document, { DocumentContext } from "next/document";
|
||||
import { ServerStyleSheet } from "styled-components";
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
@@ -576,9 +581,9 @@ export default class MyDocument extends Document {
|
||||
|
||||
```tsx
|
||||
// Dynamically import heavy styled components
|
||||
import dynamic from 'next/dynamic';
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
||||
const HeavyChart = dynamic(() => import("./HeavyChart"), {
|
||||
loading: () => <Skeleton height={400} />,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user