Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for select multiple #21

Merged
merged 1 commit into from
Apr 4, 2024
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
5 changes: 5 additions & 0 deletions .changeset/small-avocados-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"signal-form": minor
---

Add support for multi selects
41 changes: 40 additions & 1 deletion lib/controls/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ export type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
};

export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ name, onChange, ...props }, ref) => {
({ multiple, ...props }, ref) => {
if (multiple) {
return <MultipleSelect {...props} multiple ref={ref} />;
} else {
return <SingleSelect {...props} ref={ref} />;
}
}
);

export const SingleSelect = forwardRef<HTMLSelectElement, SelectProps>(
({ name, multiple, onChange, ...props }, ref) => {
let field = useField<string>(name, { defaultValue: "" });

let onChangeHandler: ChangeEventHandler<HTMLSelectElement> = useCallback(
Expand All @@ -24,6 +34,35 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(

return (
<select
multiple={multiple}
ref={ref}
{...props}
name={field.name}
value={field.data.value}
onChange={onChangeHandler}
/>
);
}
);

export const MultipleSelect = forwardRef<HTMLSelectElement, SelectProps>(
({ name, multiple, onChange, ...props }, ref) => {
let field = useField<string[]>(name, { defaultValue: "" });

let onChangeHandler: ChangeEventHandler<HTMLSelectElement> = useCallback(
(e) => {
onChange?.(e);
batch(() => {
field.setData(Array.from(e.target.selectedOptions, (o) => o.value));
field.setTouched();
});
},
[]
);

return (
<select
multiple={multiple}
ref={ref}
{...props}
name={field.name}
Expand Down
18 changes: 16 additions & 2 deletions lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ export const schema = {
.transform(
(v) => !![v].flat().find((i) => [true, "on", "true"].includes(i))
),
select: (options: string[]) => yup.string().oneOf(options),
radioButton: (options: string[]) => yup.string().oneOf(options),
select: <T extends string>(options: readonly T[], message?: string) =>
yup.mixed<T>().oneOf(options, message),
selectMultiple: <T extends string>(options: readonly T[], message?: string) =>
yup
.array()
.of(yup.mixed<T>().required())
.ensure()
.test({
name: "oneOf",
message:
message ||
`must be one of the following values: ${options.join(", ")}`,
test: (value) => (value || []).every((v) => v && options.includes(v)),
}),
radioButton: <T extends string>(options: readonly T[]) =>
yup.mixed<T>().oneOf(options),
};
147 changes: 147 additions & 0 deletions stories/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { StoryObj, Meta } from "@storybook/react";
import { userEvent, within, expect } from "@storybook/test";

import { Select, SignalForm, schema, useFormContext } from "~/index";
import { createRemixStoryDecorator } from "./utils/decorators";
import { FieldErrors } from "~/controls/field-errors";

const meta = {
title: "SignalForm/Select",
component: SignalForm,
decorators: [createRemixStoryDecorator()],
} satisfies Meta<typeof SignalForm>;

export default meta;

type Story = StoryObj<typeof meta>;

function FormValues(): JSX.Element {
let form = useFormContext();
return (
<pre data-testid="values">{JSON.stringify(form.data.value, null, 2)}</pre>
);
}

export const Single: Story = {
render() {
let validPets = [
"cat",
"dog",
"bird",
"fish",
"rabbit",
"hamster",
] as const;

let Schema = schema.object().shape({
pet: schema.select(validPets, "that's not a pet!").required(),
});

return (
<SignalForm schema={Schema} defaultData={{ pet: "cat" }}>
<p>
<label htmlFor="pet">Pet</label>
</p>
<p>
<Select name="pet" id="pet">
<option value="cat">Cat</option>
<option value="dog">Dog</option>
<option value="bird">Bird</option>
<option value="fish">Fish</option>
<option value="rabbit">Rabbit</option>
<option value="hamster">Hamster</option>
<option value="dinosaur">Dinosaur</option>
</Select>
</p>
<FieldErrors name="pet" />
<FormValues />

<button type="submit">Submit</button>
</SignalForm>
);
},
play: async ({ canvasElement }) => {
let canvas = within(canvasElement);

let petField = await canvas.findByLabelText("Pet");
let valuesDiv = await canvas.findByTestId("values");

expect(JSON.parse(valuesDiv.textContent!).pet).toEqual("cat");

await userEvent.selectOptions(petField, "Bird");

expect(JSON.parse(valuesDiv.textContent!).pet).toEqual("bird");

await userEvent.selectOptions(petField, "Dinosaur");
await canvas.findByText("that's not a pet!");
await userEvent.click(canvasElement);
},
};

export const Multiple: Story = {
render() {
let validPets = [
"cat",
"dog",
"bird",
"fish",
"rabbit",
"hamster",
] as const;

let Schema = schema.object().shape({
pets: schema
.selectMultiple(validPets, "that's not a pet!")
.required()
.max(3, "cannot have more than three pets"),
});

return (
<SignalForm schema={Schema} defaultData={{ pets: ["cat", "dog"] }}>
<p>
<label htmlFor="pets">Pets</label>
</p>
<p>
<Select name="pets" id="pets" multiple style={{ height: "10rem" }}>
<option value="cat">Cat</option>
<option value="dog">Dog</option>
<option value="bird">Bird</option>
<option value="fish">Fish</option>
<option value="rabbit">Rabbit</option>
<option value="hamster">Hamster</option>
<option value="dinosaur">Dinosaur</option>
</Select>
</p>
<FieldErrors name="pets" />
<FormValues />

<button type="submit">Submit</button>
</SignalForm>
);
},
play: async ({ canvasElement }) => {
let canvas = within(canvasElement);

let petsField = await canvas.findByLabelText("Pets");
let valuesDiv = await canvas.findByTestId("values");

expect(JSON.parse(valuesDiv.textContent!).pets).toEqual(["cat", "dog"]);

await userEvent.selectOptions(petsField, ["Bird", "Fish"]);

await canvas.findByText("cannot have more than three pets");

expect(JSON.parse(valuesDiv.textContent!).pets).toEqual([
"cat",
"dog",
"bird",
"fish",
]);

await userEvent.deselectOptions(petsField, ["Cat", "Fish"]);
expect(JSON.parse(valuesDiv.textContent!).pets).toEqual(["dog", "bird"]);

await userEvent.selectOptions(petsField, ["Dinosaur"]);
await canvas.findByText("that's not a pet!");
},
};
Loading