diff --git a/src/components/Form/Form.stories.tsx b/src/components/Form/Form.stories.tsx index 77912fc..29b4884 100644 --- a/src/components/Form/Form.stories.tsx +++ b/src/components/Form/Form.stories.tsx @@ -536,6 +536,35 @@ export const WithDynamicInitialValues: StoryObj = { ] }; +export const WithNumberRecordInput: StoryObj< + typeof Form> +> = { + args: { + content: { + dimensions: { + items: { + depth: { label: 'Depth (cm)' }, + height: { description: 'Measure from the floor', label: 'Height (cm)' }, + width: { label: 'Width (cm)' } + }, + kind: 'number-record', + label: 'Dimensions', + variant: 'input' + } + }, + validationSchema: z.object({ + dimensions: z.object({ + depth: z.number().positive(), + height: z.number().positive(), + width: z.number().positive() + }) + }), + onSubmit: (data) => { + alert(JSON.stringify(data, null, 2)); + } + } +}; + export const WithResetButton: StoryObj = { args: { content: { diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index be37550..2999cbc 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -399,4 +399,54 @@ describe('Form', () => { expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) }); }); }); + + describe('number record with the input variant', () => { + const schema = z.object({ dimensions: z.object({ height: z.number('Required'), width: z.number('Required') }) }); + + beforeEach(() => { + render( +
+ ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render a number input for each item, and no radio buttons', () => { + expect(screen.getByLabelText('Height').type).toBe('text'); + expect(screen.getByLabelText('Width')).toBeInTheDocument(); + expect(screen.queryAllByRole('radio')).toHaveLength(0); + }); + + it('should submit the number entered for each item', async () => { + await userEvent.type(screen.getByLabelText('Height'), '12.5'); + await userEvent.type(screen.getByLabelText('Width'), '30'); + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.lastCall?.[0]).toStrictEqual({ dimensions: { height: 12.5, width: 30 } }); + }); + + it('should show the error of an item below that item', async () => { + await userEvent.type(screen.getByLabelText('Height'), '12'); + fireEvent.submit(screen.getByTestId(testid)); + await waitFor(() => expect(onError).toHaveBeenCalledOnce()); + const errors = screen.getAllByTestId('error-message-text'); + expect(errors.map((e) => e.innerHTML)).toMatchObject(['Required']); + expect(errors[0]!.closest('[data-field-group]')).toHaveAttribute('data-field-group', 'width'); + }); + }); }); diff --git a/src/components/Form/NumberField/NumberField.spec.tsx b/src/components/Form/NumberField/NumberField.spec.tsx index 0edc8b4..edec416 100644 --- a/src/components/Form/NumberField/NumberField.spec.tsx +++ b/src/components/Form/NumberField/NumberField.spec.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { NumberField } from './NumberField.tsx'; +import type { NumberFieldSliderProps } from './NumberFieldSlider.tsx'; + const TestNumberFieldRadio = ({ options }: { options: { [key: number]: string } }) => { const [error, setError] = useState(); const [value, setValue] = useState(); @@ -23,6 +25,33 @@ const TestNumberFieldRadio = ({ options }: { options: { [key: number]: string } ); }; +const TestNumberFieldSlider = ( + props: Partial< + Pick + > +) => { + const [value, setValue] = useState(); + return ( + <> + undefined} + setValue={setValue} + value={value} + variant="slider" + {...props} + /> + + + ); +}; + 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' }; @@ -53,4 +82,86 @@ describe('NumberField', () => { expect(radioGroup.style.gridTemplateColumns).toBe('repeat(5, auto)'); }); }); + + describe('slider variant', () => { + const getThumbPosition = () => screen.getByRole('slider').getAttribute('aria-valuenow'); + const getDisplayedValue = () => screen.getByTestId('slider-value').textContent; + + it('should render no annotations by default', () => { + render(); + expect(screen.queryByTestId('slider-annotations')).not.toBeInTheDocument(); + }); + + it('should display a dimmed thumb at the midpoint when there is no value', () => { + render(); + expect(getThumbPosition()).toBe('15'); + expect(getDisplayedValue()).toBe('NA'); + expect(screen.getByTestId('slider-thumb').closest('[data-unset]')).toBeInTheDocument(); + }); + + it('should select the midpoint when the user presses the slider without a value', () => { + render(); + // happy-dom does not implement pointer capture, which the slider uses when it is pressed + screen.getByTestId('slider-thumb').setPointerCapture = () => undefined; + fireEvent.pointerDown(screen.getByTestId('slider-thumb')); + expect(getDisplayedValue()).toBe('5'); + expect(screen.getByTestId('slider-thumb').closest('[data-unset]')).not.toBeInTheDocument(); + }); + + it.each(['disabled', 'readOnly'] as const)( + 'should not select a value when the slider is pressed and is %s', + (prop) => { + render(); + screen.getByTestId('slider-thumb').setPointerCapture = () => undefined; + fireEvent.pointerDown(screen.getByTestId('slider-thumb')); + expect(getDisplayedValue()).toBe('NA'); + } + ); + + it('should select a value with the keyboard when there is no value', () => { + render(); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' }); + expect(getDisplayedValue()).toBe('6'); + }); + + it('should set the default value as a real value that is visible', () => { + render(); + expect(getDisplayedValue()).toBe('3'); + expect(getThumbPosition()).toBe('3'); + expect(screen.getByTestId('slider-thumb').closest('[data-unset]')).not.toBeInTheDocument(); + }); + + it('should not set the default value when the slider is read-only', () => { + render(); + expect(getDisplayedValue()).toBe('NA'); + }); + + it('should set the default value again when the value is cleared', () => { + render(); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' }); + expect(getDisplayedValue()).toBe('4'); + fireEvent.click(screen.getByText('clear')); + expect(getDisplayedValue()).toBe('3'); + }); + + it('should display the minimum and maximum when showRange is set', () => { + render(); + expect(screen.getByTestId('slider-range')).toHaveTextContent('1020'); + expect(screen.queryByTestId('slider-marks')).not.toBeInTheDocument(); + }); + + it('should display the marks in order at the position of their value', () => { + render(); + const marks = [...screen.getByTestId('slider-marks').querySelectorAll('[data-mark-value]')]; + expect(marks.map((mark) => mark.textContent)).toStrictEqual(['Not at all', 'Moderately', 'Extremely']); + expect(marks.map((mark) => mark.style.marginLeft)).toStrictEqual(['0%', '50%', '100%']); + expect(screen.queryByTestId('slider-range')).not.toBeInTheDocument(); + }); + + it('should not display a mark that is outside of the range', () => { + render(); + expect(screen.getByTestId('slider-marks')).toHaveTextContent('Moderately'); + expect(screen.queryByText('Too high')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Form/NumberField/NumberField.stories.tsx b/src/components/Form/NumberField/NumberField.stories.tsx index 8513d0a..7d4a8cb 100644 --- a/src/components/Form/NumberField/NumberField.stories.tsx +++ b/src/components/Form/NumberField/NumberField.stories.tsx @@ -37,6 +37,8 @@ export const Slider: Story = { args={{ description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.', label: 'Slider Number Field', + max: 10, + min: 0, name: 'number-slider', setValue, value, @@ -48,6 +50,100 @@ export const Slider: Story = { ] }; +export const SliderWithRange: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + +export const SliderWithMarks: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + +export const SliderWithRangeAndMarks: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + +export const SliderWithDefaultValue: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + export const Radio: Story = { decorators: [ (Story) => { diff --git a/src/components/Form/NumberField/NumberFieldSlider.tsx b/src/components/Form/NumberField/NumberFieldSlider.tsx index 171eebe..21fa045 100644 --- a/src/components/Form/NumberField/NumberFieldSlider.tsx +++ b/src/components/Form/NumberField/NumberFieldSlider.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import type { NumberFormField } from '@douglasneuroinformatics/libui-form-types'; import type { Simplify } from 'type-fest'; @@ -7,22 +9,50 @@ import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; import type { BaseFieldComponentProps } from '../types.ts'; +/** Returns the position of a value on the slider track, as a percentage of the range from min to max */ +const getPosition = (value: number, min: number, max: number) => { + if (max <= min) { + return 0; + } + return Math.min(Math.max(((value - min) / (max - min)) * 100, 0), 100); +}; + export type NumberFieldSliderProps = Simplify< BaseFieldComponentProps & Extract >; export const NumberFieldSlider = ({ + defaultValue, description, disabled, error, label, + marks, max, min, name, readOnly, setValue, + showRange, value }: NumberFieldSliderProps) => { + const isDisabled = Boolean(disabled || readOnly); + + // also applies after the form is reset, since the user cannot clear a slider. A field that the user + // cannot change does not get a value. + useEffect(() => { + if (value === undefined && defaultValue !== undefined && !isDisabled) { + setValue(defaultValue); + } + }, [defaultValue, isDisabled, value]); + + const isUnset = value === undefined; + const midpoint = min + Math.round((max - min) / 2); + const markEntries = Object.entries(marks ?? {}) + .map(([key, text]) => [Number(key), text] as const) + .filter(([markValue]) => markValue >= min && markValue <= max) + .toSorted(([a], [b]) => a - b); + return ( @@ -31,17 +61,65 @@ export const NumberFieldSlider = ({ { + if (isUnset && !isDisabled) { + setValue(midpoint); + } + }} onValueChange={([value]) => setValue(value)} /> - + {value ?? 'NA'} + {(showRange || markEntries.length > 0) && ( + // the horizontal padding matches half the width of the thumb, and the right margin matches the + // width of the value plus the gap, so that 0% and 100% are the centers of the thumb at min and max +
+ {showRange && ( +
+ {min} + {max} +
+ )} + {markEntries.length > 0 && ( +
+ {markEntries.map(([markValue, text]) => { + const position = getPosition(markValue, min, max); + return ( +
+ + {text} + +
+ ); + })} +
+ )} +
+ )}
); diff --git a/src/components/Form/NumberRecordField.tsx b/src/components/Form/NumberRecordField.tsx index 43e2299..b45c6f4 100644 --- a/src/components/Form/NumberRecordField.tsx +++ b/src/components/Form/NumberRecordField.tsx @@ -16,18 +16,20 @@ export type NumberRecordFieldProps & NumberRecordFormField> >; -export const NumberRecordField = ({ - disableAutoPrefix, - disabled, - error: recordError, - items, - label, - options, - readOnly, - setError: setRecordError, - setValue: setRecordValue, - value: recordValue -}: NumberRecordFieldProps) => { +export const NumberRecordField = ( + props: NumberRecordFieldProps +) => { + const { + disabled, + error: recordError, + items, + label, + readOnly, + setError: setRecordError, + setValue: setRecordValue, + value: recordValue + } = props; + const options = props.variant === 'likert' ? props.options : undefined; const optionsRef = useRef(options); useEffect(() => { @@ -55,20 +57,25 @@ export const NumberRecordField = {Object.keys(items).map((name) => { const item = items[name]!; - return ( + const itemProps = { + error: recordError?.[name], + kind: 'number', + name, + readOnly: disabled || readOnly, + setError: (error: string[]) => setRecordError({ ...recordError, [name]: error }), + setValue: (value: number | undefined) => setRecordValue({ ...recordValue, [name]: value }), + value: recordValue?.[name], + ...item + } as const; + return props.variant === 'input' ? ( + + ) : ( setRecordError({ ...recordError, [name]: error })} - setValue={(value) => setRecordValue({ ...recordValue, [name]: value })} - value={recordValue?.[name]} + options={props.options} variant="radio" - {...item} + {...itemProps} /> ); })} diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index 95fa38f..b9e6d68 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -4,10 +4,14 @@ import { Range, Root, Thumb, Track } from '@radix-ui/react-slider'; import { cn } from '#utils'; +/** + * A slider for a number in a range. Set the `data-unset` attribute to dim the thumb, which indicates that + * the position of the thumb is not a value selected by the user. + */ export const Slider = ({ className, disabled, ref, ...props }: ComponentProps) => { return (