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 (