Separate ListRow trailing control and fail fast on out-of-range Slider
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m32s

ListRow with onClick now renders a container whose leading slot, title
and meta form one button, with the trailing slot as a sibling outside
it, so a trailing toggle or action is never nested in the row button
and never triggers the row's action.

Slider throws when value is outside [min, max] (including NaN) instead
of rendering a fill that disagrees with the clamped native input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:28:00 -04:00
co-authored by Claude Opus 5.5
parent d9a26b81ec
commit 92ffaae1de
5 changed files with 88 additions and 13 deletions
+23 -5
View File
@@ -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;
+26
View File
@@ -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(
<ListRow
title="Dinner reminders"
meta="Push at 4 pm"
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
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()
})
})
+17 -8
View File
@@ -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 && <span className={styles.leading}>{leading}</span>}
<span className={styles.body}>
<span className={styles.title}>{title}</span>
{meta && <span className={styles.meta}>{meta}</span>}
</span>
{trailing && <span className={styles.trailing}>{trailing}</span>}
</>
)
const classes = [styles.row, className].filter(Boolean).join(' ')
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
if (onClick) {
return (
<button type="button" className={`${classes} ${styles.interactive}`} onClick={onClick}>
{content}
</button>
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
<button type="button" className={styles.main} onClick={onClick}>
{main}
</button>
{trailingSlot}
</div>
)
}
return <div className={classes}>{content}</div>
return (
<div className={[styles.row, className].filter(Boolean).join(' ')}>
{main}
{trailingSlot}
</div>
)
}
+19
View File
@@ -47,4 +47,23 @@ describe('Slider', () => {
'max (1) must be greater than min (1)',
)
})
it('accepts values at both ends of the range', () => {
render(
<>
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
</>,
)
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(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
})
})
+3
View File
@@ -12,6 +12,9 @@ export type SliderProps = Omit<ComponentPropsWithRef<'input'>, '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 (
<input