Add Photo and Icon components
Icon wraps a lucide-react icon component at stroke width 2.75, sized 22 px (nav) or 16 px (inline) by its CSS module, aria-hidden unless a label names it. Photo renders the 400 and 1200 renditions as a lazy srcset through the washed filter, with configurable radius and inset, and falls back to the striped "dish photo" placeholder when there is no photo or it fails to load. Adds lucide-react. Maestro task: a9214566-6076-49e5-98b8-89ba98d6d838 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+18
-2
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
@@ -736,7 +737,7 @@
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1261,7 +1262,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -1760,6 +1761,21 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz",
|
||||
"integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { House } from 'lucide-react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Icon } from './Icon.tsx'
|
||||
|
||||
function svg(container: HTMLElement) {
|
||||
return container.querySelector('svg')!
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
it('is hidden and unnamed without a label', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
expect(svg(container)).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(svg(container)).not.toHaveAttribute('aria-label')
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a named image when given a label', () => {
|
||||
const { container } = render(<Icon icon={House} label="Home" />)
|
||||
expect(svg(container)).not.toHaveAttribute('aria-hidden')
|
||||
expect(screen.getByRole('img', { name: 'Home' })).toBe(svg(container))
|
||||
})
|
||||
|
||||
it('draws at stroke width 2.75', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
expect(svg(container)).toHaveAttribute('stroke-width', '2.75')
|
||||
})
|
||||
|
||||
it('is 22 px at the nav size', () => {
|
||||
const { container } = render(<Icon icon={House} size="nav" />)
|
||||
const style = getComputedStyle(svg(container))
|
||||
expect([style.width, style.height]).toEqual(['22px', '22px'])
|
||||
})
|
||||
|
||||
it('is 16 px inline by default', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
const style = getComputedStyle(svg(container))
|
||||
expect([style.width, style.height]).toEqual(['16px', '16px'])
|
||||
})
|
||||
|
||||
it('merges a caller className', () => {
|
||||
const { container } = render(<Icon icon={House} className="extra" />)
|
||||
expect(svg(container)).toHaveClass('extra')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import styles from './Icon.module.css'
|
||||
|
||||
export type IconSize = 'nav' | 'inline'
|
||||
|
||||
export type IconProps = {
|
||||
// A lucide-react icon component, e.g. `import { House } from 'lucide-react'`.
|
||||
icon: LucideIcon
|
||||
// nav = 22 px (bottom bar, sidebar); inline = 16 px (beside text).
|
||||
size?: IconSize
|
||||
// Accessible name. Without one the icon is decorative and hidden.
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Lucide icon at the handoff's stroke width 2.75, sized by the stylesheet.
|
||||
export function Icon({ icon: LucideGlyph, size = 'inline', label, className }: IconProps) {
|
||||
const classes = [styles.icon, styles[size], className].filter(Boolean).join(' ')
|
||||
const a11y = label === undefined ? { 'aria-hidden': true } : { role: 'img', 'aria-label': label }
|
||||
return <LucideGlyph strokeWidth={2.75} className={classes} {...a11y} />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Radius and inset come from the --photo-radius and --photo-inset custom
|
||||
properties the component sets from its props. Size it with className. */
|
||||
.frame {
|
||||
display: block;
|
||||
margin: var(--photo-inset);
|
||||
border-radius: var(--photo-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
object-fit: cover;
|
||||
filter: saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--color-neutral-300) 0 8px,
|
||||
var(--color-neutral-200) 8px 16px
|
||||
);
|
||||
color: var(--color-neutral-700);
|
||||
font: 11px var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { Photo } from './Photo.tsx'
|
||||
|
||||
const id = '01JABCDEFGHJKMNPQRSTVWXYZ0'
|
||||
|
||||
describe('Photo', () => {
|
||||
it('offers the 400 and 1200 renditions as a srcset', () => {
|
||||
render(<Photo id={id} name="Korean beef bowls" />)
|
||||
const img = screen.getByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(img).toHaveAttribute('src', `/media/photos/${id}-400.jpg`)
|
||||
expect(img.getAttribute('srcset')!.split(', ')).toEqual([
|
||||
`/media/photos/${id}-400.jpg 400w`,
|
||||
`/media/photos/${id}-1200.jpg 1200w`,
|
||||
])
|
||||
})
|
||||
|
||||
it('loads lazily with the dinner name as alt text', () => {
|
||||
render(<Photo id={id} name="Pizza night" />)
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('loading', 'lazy')
|
||||
expect(img).toHaveAttribute('alt', 'Pizza night')
|
||||
})
|
||||
|
||||
it('applies the washed filter', () => {
|
||||
render(<Photo id={id} name="Pizza night" />)
|
||||
expect(declaredStyle(screen.getByRole('img')).filter).toBe(
|
||||
'saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)',
|
||||
)
|
||||
})
|
||||
|
||||
it('takes its radius and inset from props', () => {
|
||||
render(<Photo id={id} name="Pizza night" radius="var(--radius-lg)" inset="10px" />)
|
||||
const img = screen.getByRole('img')
|
||||
const style = declaredStyle(img)
|
||||
expect(style.borderRadius).toBe('var(--photo-radius)')
|
||||
expect(style.margin).toBe('var(--photo-inset)')
|
||||
expect(img.style.getPropertyValue('--photo-radius')).toBe('var(--radius-lg)')
|
||||
expect(img.style.getPropertyValue('--photo-inset')).toBe('10px')
|
||||
})
|
||||
|
||||
it('shows the striped placeholder when there is no photo', () => {
|
||||
const { container } = render(<Photo name="Beef tacos" />)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
const placeholder = screen.getByRole('img', { name: 'Beef tacos' })
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
expect(declaredStyle(placeholder).background.replace(/\s+/g, ' ')).toBe(
|
||||
'repeating-linear-gradient( 135deg, var(--color-neutral-300) 0 8px, var(--color-neutral-200) 8px 16px )',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the placeholder when the image fails to load', () => {
|
||||
const { container } = render(<Photo id={id} name="Beef tacos" />)
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(screen.getByRole('img', { name: 'Beef tacos' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('tries again when given a new id after a failure', () => {
|
||||
const { container, rerender } = render(<Photo id={id} name="Beef tacos" />)
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
rerender(<Photo id="01JZZZZZZZZZZZZZZZZZZZZZZZ" name="Beef tacos" />)
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/media/photos/01JZZZZZZZZZZZZZZZZZZZZZZZ-400.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, type CSSProperties } from 'react'
|
||||
import styles from './Photo.module.css'
|
||||
|
||||
export type PhotoProps = {
|
||||
// Photo id; the server stores /media/photos/{id}-400.jpg and -1200.jpg.
|
||||
// Without one the striped "dish photo" placeholder is shown.
|
||||
id?: string
|
||||
// The dinner name, used as the alt text.
|
||||
name: string
|
||||
// Corner radius and the margin from the containing card edge, as CSS lengths.
|
||||
radius?: string
|
||||
inset?: string
|
||||
// The rendered width hint for srcset selection.
|
||||
sizes?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
// Dinner photo through the Organic "washed" filter. Falls back to the
|
||||
// striped placeholder when there is no photo or it fails to load.
|
||||
export function Photo({ id, name, radius = '24px', inset = '0', sizes = '100vw', className, style }: PhotoProps) {
|
||||
// The id whose image failed, so a new id gets a fresh attempt.
|
||||
const [failedId, setFailedId] = useState<string>()
|
||||
const frame = { '--photo-radius': radius, '--photo-inset': inset, ...style } as CSSProperties
|
||||
|
||||
if (id === undefined || failedId === id) {
|
||||
const classes = [styles.frame, styles.placeholder, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<div role="img" aria-label={name} className={classes} style={frame}>
|
||||
<span aria-hidden="true">dish photo</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const base = `/media/photos/${encodeURIComponent(id)}`
|
||||
const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<img
|
||||
src={`${base}-400.jpg`}
|
||||
srcSet={`${base}-400.jpg 400w, ${base}-1200.jpg 1200w`}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
alt={name}
|
||||
className={classes}
|
||||
style={frame}
|
||||
onError={() => setFailedId(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,10 @@ export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
|
||||
export { Chip, type ChipProps } from './Chip.tsx'
|
||||
export { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
|
||||
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.tsx'
|
||||
export { Icon, type IconProps, type IconSize } from './Icon.tsx'
|
||||
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
|
||||
export { ListRow, type ListRowProps } from './ListRow.tsx'
|
||||
export { Photo, type PhotoProps } from './Photo.tsx'
|
||||
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
||||
export { Sheet, type SheetProps } from './Sheet.tsx'
|
||||
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
||||
|
||||
Reference in New Issue
Block a user