Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/Form/NumberField/NumberField.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<string[] | undefined>();
const [value, setValue] = useState<number | undefined>();
return (
<NumberField
error={error}
kind="number"
label="number-field"
name="number-field"
options={options}
setError={setError}
setValue={setValue}
value={value}
variant="radio"
/>
);
};

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(<TestNumberFieldRadio options={FIVE_OPTIONS} />);
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.getAllByRole('radio')).toHaveLength(5);
});
it('should stack two options vertically', () => {
render(<TestNumberFieldRadio options={TWO_OPTIONS} />);
const radioGroup = screen.getByTestId('radio-group');
expect(radioGroup).not.toHaveClass('justify-between');
expect(radioGroup.style.gridTemplateColumns).toBe('');
});
it('should stack three options vertically', () => {
render(<TestNumberFieldRadio options={THREE_OPTIONS} />);
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(<TestNumberFieldRadio options={FIVE_OPTIONS} />);
const radioGroup = screen.getByTestId('radio-group');
expect(radioGroup).toHaveClass('justify-between');
expect(radioGroup.style.gridTemplateColumns).toBe('repeat(5, auto)');
});
});
});
49 changes: 49 additions & 0 deletions src/components/Form/NumberField/NumberField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,55 @@ export const Radio: Story = {
]
};

export const RadioTwoOptions: Story = {
decorators: [
(Story) => {
const [value, setValue] = useState<number | undefined>();
return (
<Story
args={{
description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
label: 'Two Option Radio Field',
name: 'number-radio-two-options',
options: {
0: 'The first in the morning',
1: 'Any other'
},
setValue,
value,
variant: 'radio'
}}
/>
);
}
]
};

export const RadioThreeOptions: Story = {
decorators: [
(Story) => {
const [value, setValue] = useState<number | undefined>();
return (
<Story
args={{
description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
label: 'Three Option Radio Field',
name: 'number-radio-three-options',
options: {
1: 'Never',
2: 'Sometimes',
3: 'Always'
},
setValue,
value,
variant: 'radio'
}}
/>
);
}
]
};

export const Select: Story = {
decorators: [
(Story) => {
Expand Down
32 changes: 21 additions & 11 deletions src/components/Form/NumberField/NumberFieldRadio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> & Extract<NumberFormField, { options: object }>
>;
Expand All @@ -26,23 +35,26 @@ export const NumberFieldRadio = ({
value
}: NumberFieldRadioProps) => {
const radioGroupRef = useRef<HTMLDivElement>(null);
const [isColumnLayout, setIsColumnLayout] = useState<boolean>(false);
const [isOverflowing, setIsOverflowing] = useState<boolean>(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 (
<FieldGroup name={name}>
Expand All @@ -51,12 +63,10 @@ export const NumberFieldRadio = ({
<FieldGroup.Description description={description} />
</FieldGroup.Row>
<RadioGroup
className="grid justify-between"
className={cn('grid', !isColumnLayout && 'justify-between')}
name={name}
ref={radioGroupRef}
style={{
gridTemplateColumns: isColumnLayout ? 'repeat(1, 1fr)' : `repeat(${optionsCount}, auto)`
}}
style={isColumnLayout ? undefined : { gridTemplateColumns: `repeat(${optionsCount}, auto)` }}
value={value?.toString() ?? ''}
onValueChange={(value) => setValue(parseInt(value, 10))}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/RadioGroup/RadioGroupItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const RadioGroupItem: React.FC<React.ComponentProps<typeof Item>> = ({ cl
return (
<Item
className={cn(
'border-primary text-primary focus-visible:ring-ring flex aspect-square h-4 w-4 items-center justify-center rounded-full border shadow-sm focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
'border-primary text-primary focus-visible:ring-ring flex aspect-square h-4 w-4 shrink-0 items-center justify-center rounded-full border shadow-sm focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
Expand Down
Loading