From 0bda907cbf46abdc6db969afa470c35b06d835dc Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 26 Aug 2026 12:06:59 -0400 Subject: [PATCH] fix: stack number radio fields vertically when there are few options NumberFieldRadio was the only radio variant with a horizontal layout, rendering its RadioGroup with `grid justify-between` and an inline `gridTemplateColumns: repeat(N, auto)`. A ResizeObserver already collapsed the group to a single column on overflow, but there was no lower bound, so two short options in a wide container stayed on one row and were pushed to opposite edges - far enough apart that the second option was easy to miss. Add a MIN_OPTIONS_FOR_HORIZONTAL_LAYOUT threshold of 4. Below it the group stacks vertically, matching BaseRadioField and BooleanFieldRadio, and no ResizeObserver is constructed at all. `justify-between` and the inline grid template are now applied only in horizontal mode, so the vertical rendering is identical to the other radio variants. Likert-style scales of 4+ options are unchanged, including the overflow fallback and the column alignment across NumberRecordField rows. Also add `shrink-0` to RadioGroupItem so the fixed h-4 w-4 circle cannot deform next to a wrapping label in a tight container. Note: this also changes existing 3-option number/radio fields, which now render vertically. Closes #118 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015o4HSmKyMDf4DMXiHLsAPD --- .../Form/NumberField/NumberField.spec.tsx | 56 +++++++++++++++++++ .../Form/NumberField/NumberField.stories.tsx | 49 ++++++++++++++++ .../Form/NumberField/NumberFieldRadio.tsx | 32 +++++++---- src/components/RadioGroup/RadioGroupItem.tsx | 2 +- 4 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/components/Form/NumberField/NumberField.spec.tsx diff --git a/src/components/Form/NumberField/NumberField.spec.tsx b/src/components/Form/NumberField/NumberField.spec.tsx new file mode 100644 index 0000000..0edc8b4 --- /dev/null +++ b/src/components/Form/NumberField/NumberField.spec.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { NumberField } from './NumberField.tsx'; + +const TestNumberFieldRadio = ({ options }: { options: { [key: number]: string } }) => { + const [error, setError] = useState(); + const [value, setValue] = useState(); + return ( + + ); +}; + +const TWO_OPTIONS = { 0: 'The first in the morning', 1: 'Any other' }; +const THREE_OPTIONS = { 1: 'Never', 2: 'Sometimes', 3: 'Always' }; +const FIVE_OPTIONS = { 1: 'Very Low', 2: 'Low', 3: 'Medium', 4: 'High', 5: 'Very High' }; + +describe('NumberField', () => { + describe('radio variant', () => { + it('should render one radio per option', () => { + render(); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(5); + }); + it('should stack two options vertically', () => { + render(); + const radioGroup = screen.getByTestId('radio-group'); + expect(radioGroup).not.toHaveClass('justify-between'); + expect(radioGroup.style.gridTemplateColumns).toBe(''); + }); + it('should stack three options vertically', () => { + render(); + const radioGroup = screen.getByTestId('radio-group'); + expect(radioGroup).not.toHaveClass('justify-between'); + expect(radioGroup.style.gridTemplateColumns).toBe(''); + }); + it('should spread four or more options horizontally', () => { + render(); + const radioGroup = screen.getByTestId('radio-group'); + expect(radioGroup).toHaveClass('justify-between'); + expect(radioGroup.style.gridTemplateColumns).toBe('repeat(5, auto)'); + }); + }); +}); diff --git a/src/components/Form/NumberField/NumberField.stories.tsx b/src/components/Form/NumberField/NumberField.stories.tsx index 4374a77..8513d0a 100644 --- a/src/components/Form/NumberField/NumberField.stories.tsx +++ b/src/components/Form/NumberField/NumberField.stories.tsx @@ -75,6 +75,55 @@ export const Radio: Story = { ] }; +export const RadioTwoOptions: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + +export const RadioThreeOptions: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + export const Select: Story = { decorators: [ (Story) => { diff --git a/src/components/Form/NumberField/NumberFieldRadio.tsx b/src/components/Form/NumberField/NumberFieldRadio.tsx index ddba5dc..fa48eec 100644 --- a/src/components/Form/NumberField/NumberFieldRadio.tsx +++ b/src/components/Form/NumberField/NumberFieldRadio.tsx @@ -4,11 +4,20 @@ import type { NumberFormField } from '@douglasneuroinformatics/libui-form-types' import type { Simplify } from 'type-fest'; import { Label, RadioGroup } from '#components'; +import { cn } from '#utils'; import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; import type { BaseFieldComponentProps } from '../types.ts'; +/** + * The minimum number of options required to render the group horizontally. Below this, options are + * stacked vertically, matching the other radio field variants, since spreading two or three options + * across the full width of a wide container pushes them far apart and makes the later options easy + * to miss. + */ +const MIN_OPTIONS_FOR_HORIZONTAL_LAYOUT = 4; + export type NumberFieldRadioProps = Simplify< BaseFieldComponentProps & Extract >; @@ -26,23 +35,26 @@ export const NumberFieldRadio = ({ value }: NumberFieldRadioProps) => { const radioGroupRef = useRef(null); - const [isColumnLayout, setIsColumnLayout] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); const optionsCount = Object.keys(options).length; + const canUseHorizontalLayout = optionsCount >= MIN_OPTIONS_FOR_HORIZONTAL_LAYOUT; + const isColumnLayout = !canUseHorizontalLayout || isOverflowing; useEffect(() => { + if (!canUseHorizontalLayout || !radioGroupRef.current) { + return; + } const observer = new ResizeObserver(([entry]) => { const { width: rootWidth } = entry!.target.getBoundingClientRect(); const children = Array.from(entry!.target.children); const totalChildWidth = children.reduce((sum, child) => sum + child.scrollWidth, 0); - const isOverflowing = totalChildWidth > rootWidth - children.length * 24; // to additional provide spacing between items - setIsColumnLayout(isOverflowing); + // subtract an allowance so that items always have some spacing between them + setIsOverflowing(totalChildWidth > rootWidth - children.length * 24); }); - if (radioGroupRef.current) { - observer.observe(radioGroupRef.current); - } + observer.observe(radioGroupRef.current); return () => observer.disconnect(); - }, []); + }, [canUseHorizontalLayout]); return ( @@ -51,12 +63,10 @@ export const NumberFieldRadio = ({ setValue(parseInt(value, 10))} > diff --git a/src/components/RadioGroup/RadioGroupItem.tsx b/src/components/RadioGroup/RadioGroupItem.tsx index e2285a0..86691a5 100644 --- a/src/components/RadioGroup/RadioGroupItem.tsx +++ b/src/components/RadioGroup/RadioGroupItem.tsx @@ -7,7 +7,7 @@ export const RadioGroupItem: React.FC> = ({ cl return (