diff --git a/web/src/ui/ListRow.module.css b/web/src/ui/ListRow.module.css index e728f22..f182b5b 100644 --- a/web/src/ui/ListRow.module.css +++ b/web/src/ui/ListRow.module.css @@ -11,18 +11,36 @@ } .interactive { - min-width: 44px; - border: 0; + padding: 0; border-radius: var(--radius-md); - background: transparent; - font: inherit; - cursor: pointer; } .interactive:hover { background: var(--color-neutral-200); } +.main { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1 1 auto; + min-width: 44px; + min-height: 44px; + padding: 14px 16px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.interactive > .trailing { + padding-right: 16px; +} + .leading, .trailing { display: inline-flex; diff --git a/web/src/ui/ListRow.test.tsx b/web/src/ui/ListRow.test.tsx index e891d5f..f1e9108 100644 --- a/web/src/ui/ListRow.test.tsx +++ b/web/src/ui/ListRow.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { expectMinHitArea } from '../test/hitArea.ts' import { ListRow } from './ListRow.tsx' +import { Toggle } from './Toggle.tsx' describe('ListRow', () => { it('renders leading, title, meta and trailing slots without being interactive', () => { @@ -28,4 +29,29 @@ describe('ListRow', () => { fireEvent.click(row) expect(onClick).toHaveBeenCalledOnce() }) + + it('keeps a trailing toggle outside the row button so each control fires only its own action', () => { + const onClick = vi.fn() + const onCheckedChange = vi.fn() + render( + } + onClick={onClick} + />, + ) + const row = screen.getByRole('button', { name: /^Dinner reminders/ }) + const toggle = screen.getByRole('switch', { name: 'Dinner reminders' }) + expect(row).not.toContainElement(toggle) + expect(row.querySelector('button')).toBeNull() + + fireEvent.click(toggle) + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true) + expect(onClick).not.toHaveBeenCalled() + + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + expect(onCheckedChange).toHaveBeenCalledOnce() + }) }) diff --git a/web/src/ui/ListRow.tsx b/web/src/ui/ListRow.tsx index eed5613..8004bf2 100644 --- a/web/src/ui/ListRow.tsx +++ b/web/src/ui/ListRow.tsx @@ -8,29 +8,38 @@ export type ListRowProps = { leading?: ReactNode // Trailing slot: chevron, toggle, count or action. trailing?: ReactNode - // When set, the whole row is a button. + // When set, the leading slot, title and meta form one button. The trailing + // slot stays a sibling outside it, so a trailing toggle or action is never + // nested in the row's button and never triggers the row's action. onClick?: () => void className?: string } export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) { - const content = ( + const main = ( <> {leading && {leading}} {title} {meta && {meta}} - {trailing && {trailing}} ) - const classes = [styles.row, className].filter(Boolean).join(' ') + const trailingSlot = trailing && {trailing} if (onClick) { return ( - +
+ + {trailingSlot} +
) } - return
{content}
+ return ( +
+ {main} + {trailingSlot} +
+ ) } diff --git a/web/src/ui/Slider.test.tsx b/web/src/ui/Slider.test.tsx index 42a89bb..ce95efb 100644 --- a/web/src/ui/Slider.test.tsx +++ b/web/src/ui/Slider.test.tsx @@ -47,4 +47,23 @@ describe('Slider', () => { 'max (1) must be greater than min (1)', ) }) + + it('accepts values at both ends of the range', () => { + render( + <> + + + , + ) + expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%') + expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%') + }) + + it.each([ + [2, 'value (2) must be within [0, 1]'], + [-0.1, 'value (-0.1) must be within [0, 1]'], + [Number.NaN, 'value (NaN) must be within [0, 1]'], + ])('fails fast when the value %s is outside the range', (value, message) => { + expect(() => render()).toThrow(message) + }) }) diff --git a/web/src/ui/Slider.tsx b/web/src/ui/Slider.tsx index 35a35c7..af430c8 100644 --- a/web/src/ui/Slider.tsx +++ b/web/src/ui/Slider.tsx @@ -12,6 +12,9 @@ export type SliderProps = Omit, 'type' | 'value' // Name it with aria-label or aria-labelledby. export function Slider({ value, min, max, className, style, ...rest }: SliderProps) { if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`) + if (!(value >= min && value <= max)) { + throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`) + } const fill = `${((value - min) / (max - min)) * 100}%` return (