Skip to content

Commit

Permalink
feat(PlaceholderOption): separate placeholder from useOptions fixes #45
Browse files Browse the repository at this point in the history
  • Loading branch information
MiroslavPetrik committed Jul 12, 2023
1 parent ab2258d commit b2f4726
Show file tree
Hide file tree
Showing 14 changed files with 121 additions and 75 deletions.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@ With others libraries you often lose form state when your component or page unmo

`form-atoms` on the other hand keeps the form state until you clear it, because it lives in jotai atoms. This way, you don't have to warn users about data loss if they navigate out of filled & unsubmitted form. Instead you can display 'continue where you left off' message when they return to the form.

#### Atomic headless components
#### Atomic Components

The `form-atoms` library provides atomic form primitives capable of tracking input value, touch state, validation status and more.
When implementing forms, there are subtle details which you must likely implement yourself. For example you might need to implement a placeholder for a select,
a clickable label which focuses the respective input, or a custom indicator whether the input is required or optional.

`@form-atoms/field` extends these primitives & packages them into hooks & headless components (think 'smart components'), which can be easily wired to UI (think dumb components) checkbox, select or array field.
We take care of these details in atomic 'low-level' components like `PlaceholderOption`, `FieldLabel` and `RequirementIndicator` respectively.

#### Generic Native Components

With other form libraries you might find yourself repeatedly wiring them into recurring scenarios like checkbox multi select or radio group.
We've created highly reusable generic components which integrate the native components.
For example to select a value of generic type you can use the generic [RadioGroup](https://miroslavpetrik.github.io/form-atoms-field/?path=/docs/components-radiogroup--docs) or [Select](https://miroslavpetrik.github.io/form-atoms-field/?path=/docs/components-select--docs).

To select multiple values (array of values) you can use the generic [CheckboxGroup](https://miroslavpetrik.github.io/form-atoms-field/?path=/docs/components-checkboxgroup--docs) or [MultiSelect](https://miroslavpetrik.github.io/form-atoms-field/?path=/docs/components-multiselect--docs)

Lastly to capture a list of objects, you will find the [ListField](https://miroslavpetrik.github.io/form-atoms-field/?path=/docs/components-listfield--docs) handy.

## Docs

Expand Down
28 changes: 18 additions & 10 deletions src/Intro.contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,24 @@

### Components

| | |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [CheckboxGroup](?path=/docs/components-checkboxgroup--docs) | Select multiple values from a list of generic options via `<input type="checkbox">`. |
| [FieldErrors](?path=/docs/components-fielderrors--docs) | A headless component providing the field errors. |
| [FieldLabel](?path=/docs/components-fieldlabel--docs) | A headless component with accessible label. |
| [ListField](?path=/docs/components-listfield--docs) | An advanced headless component to control `FieldAtom<>[]` or `FormFields[]`. |
| [MultiSelect](?path=/docs/components-multiselect--docs) | Select multiple values via `<select multiple>`. |
| [RadioGroup](?path=/docs/components-radiogroup--docs) | Select a generic option via `<input type="radio">`. |
| [RequirementIndicator](?path=/docs/components-requirementindicator--docs) | Displays an indicator whether a field is required or optional. |
| [Select](?path=/docs/components-select--docs) | Select a generic option via `<select>`. |
#### Atomic Components

| | |
| ------------------------------------------------------------------------- | -------------------------------------------------------------- |
| [FieldErrors](?path=/docs/components-fielderrors--docs) | A headless component providing the field errors. |
| [FieldLabel](?path=/docs/components-fieldlabel--docs) | A headless component with accessible label. |
| [PlaceholderOption](?path=/docs/components-placeholderoption--docs) | A special `<option>` to be rendered in an empty `<Select>`. |
| [RequirementIndicator](?path=/docs/components-requirementindicator--docs) | Displays an indicator whether a field is required or optional. |

#### Native Generic Components

| | |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [CheckboxGroup](?path=/docs/components-checkboxgroup--docs) | Select multiple values from a list of generic options via `<input type="checkbox">`. |
| [ListField](?path=/docs/components-listfield--docs) | An advanced headless component to control `FieldAtom<>[]` or `FormFields[]`. |
| [MultiSelect](?path=/docs/components-multiselect--docs) | Select multiple values via `<select multiple>`. |
| [RadioGroup](?path=/docs/components-radiogroup--docs) | Select a generic option via `<input type="radio">`. |
| [Select](?path=/docs/components-select--docs) | Select a generic option via `<select>`. |

### Hooks

Expand Down
2 changes: 1 addition & 1 deletion src/components/field-errors/Docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Description,
} from "@storybook/blocks";

<Meta title="components/FieldErrors" />
<Meta title="components/atomic/FieldErrors" />

# FieldErrors

Expand Down
2 changes: 1 addition & 1 deletion src/components/field-label/Docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as FieldLabelStories from "./FieldLabel.stories";
import Props from "./props.md?raw";
import renderProps from "./renderProps.md?raw";

<Meta title="components/FieldLabel" />
<Meta title="components/atomic/FieldLabel" />

# FieldLabel

Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./field-errors";
export * from "./field-label";
export * from "./list-field";
export * from "./multi-select";
export * from "./placeholder-option";
export * from "./radio";
export * from "./radio-group";
export * from "./requirement-indicator";
Expand Down
3 changes: 1 addition & 2 deletions src/components/multi-select/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ export const MultiSelect = <Option, Field extends ZodArrayField>({
});

const { selectOptions } = useSelectOptions({
placeholder: "",
field,
getLabel,
options,
getLabel,
});

return (
Expand Down
40 changes: 40 additions & 0 deletions src/components/placeholder-option/Docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Meta,
Source,
Canvas,
Controls,
Markdown,
Description,
} from "@storybook/blocks";

<Meta title="components/atomic/PlaceholderOption" />

# PlaceholderOption

An atomic component to be used when your `<select>` is controlled with [useSelectFieldProps](?path=/docs/hooks-useselectfieldprops--docs).
Used internally in the [Select](?path=/docs/components-select--docs) component.

## Usage

```ts
import { PlaceholderOption } from "@form-atoms/field";
```

## Features

- ✅ Renders an `<option>` to appear selected while the respective `<select>` is empty.
- ✅ Enables to clear a select using an optional field.

## Example

```tsx
import { PlaceholderOption } from "@form-atoms/field";

// the placeholder must be used as the first child option:
const Example = () => (
<select>
<PlaceholderOption>Pick an option</PlaceholderOption>
<option>regular options go after the placeholder</option>
</select>
);
```
13 changes: 13 additions & 0 deletions src/components/placeholder-option/PlaceholderOption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { OptionHTMLAttributes } from "react";

import { EMPTY_SELECT_VALUE } from "../../hooks";

export const PlaceholderOption = ({
children = "Please select an option",
disabled = true,
...props
}: OptionHTMLAttributes<HTMLOptionElement>) => (
<option {...props} disabled={disabled} value={EMPTY_SELECT_VALUE}>
{children}
</option>
);
1 change: 1 addition & 0 deletions src/components/placeholder-option/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./PlaceholderOption";
2 changes: 1 addition & 1 deletion src/components/requirement-indicator/Docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import * as RequirementIndicatorStories from "./RequirementIndicator.stories";
import Props from "./props.md?raw";

<Meta title="components/RequirementIndicator" />
<Meta title="components/atomic/RequirementIndicator" />

# RequirementIndicator

Expand Down
20 changes: 14 additions & 6 deletions src/components/select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ import {
useSelectFieldProps,
useSelectOptions,
} from "../../hooks";
import { PlaceholderOption } from "../placeholder-option";

export type SelectProps<
Option,
Field extends SelectField
> = UseSelectFieldProps<Option, Field> &
Omit<UseSelectOptionsProps<Option>, "field" | "placeholderDisabled">;
UseSelectOptionsProps<Option> & { placeholder?: string };

export const Select = <Option, Field extends SelectField>({
field,
getValue,
getLabel,
options,
placeholder,
placeholder = "Please select an option",
}: SelectProps<Option, Field>) => {
const props = useSelectFieldProps({
field,
Expand All @@ -27,11 +28,18 @@ export const Select = <Option, Field extends SelectField>({

const { selectOptions } = useSelectOptions({
field,
getLabel,
options,
placeholder,
placeholderDisabled: props.required,
getLabel,
});

return <select {...props}>{selectOptions}</select>;
return (
<select {...props}>
{placeholder && (
<PlaceholderOption disabled={props.required}>
{placeholder}
</PlaceholderOption>
)}
{selectOptions}
</select>
);
};
2 changes: 1 addition & 1 deletion src/hooks/use-options/useOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { ReactNode, useMemo } from "react";

export type UseOptionsProps<Option> = {
field: FieldAtom<any>;
getLabel: (option: Option) => ReactNode;
options: readonly Option[];
getLabel: (option: Option) => ReactNode;
};

export function useOptions<Option>({
Expand Down
40 changes: 11 additions & 29 deletions src/hooks/use-select-options/Docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,24 @@ A wrapper arround [useOptions](?path=/docs/hooks-useoptions--docs) which renders

```tsx
import { useSelectOptions } from "@form-atoms/field";

// pass your props the same way as in `useOptions` hook:
const { selectOptions } = useSelectOptions({
field,
getLabel,
options,
placeholder,
});

// render as <select> children:
<select {...props}>{selectOptions}</select>;
```

### Features

- ✅ Renders custom placeholder as a disabled option at the start of `selectOptions`.

### Props
- ✅ Renders `<option>` list ready to be used as `<select>` children.

The props extend the [UseOptionsProps](?path=/docs/hooks-useoptions--docs#props) with a `placeholder`:
### Example

```ts
export type UseSelectOptionsProps<Option> = UseOptionsProps<Option> & {
/**
* A text for a custom placeholder option at the start of selectOptions.
* @default "Please select an option"
*/
placeholder?: string;
};
```

To disable the default placeholder, set it to empty string:
```tsx
import { useSelectOptions } from "@form-atoms/field";

```ts
// pass your props the same way as in the `useOptions` hook:
const { selectOptions } = useSelectOptions({
placeholder = "",
// rest of props
field,
options,
getLabel,
});

// render as <select> children:
<select {...props}>{selectOptions}</select>;
```
25 changes: 4 additions & 21 deletions src/hooks/use-select-options/useSelectOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,16 @@
import { useMemo } from "react";

import { UseOptionsProps, useOptions } from "../use-options";
import { EMPTY_SELECT_VALUE } from "../use-select-field-props";

export type UseSelectOptionsProps<Option> = UseOptionsProps<Option> & {
/**
* A text for a custom placeholder option at the start of selectOptions.
* @default "Please select an option"
*/
placeholder?: string;
placeholderDisabled?: boolean;
};
export type UseSelectOptionsProps<Option> = UseOptionsProps<Option>;

export function useSelectOptions<Option>({
placeholder = "Please select an option",
placeholderDisabled = true,
...optionsProps
}: UseSelectOptionsProps<Option>) {
const { renderOptions } = useOptions(optionsProps);
export function useSelectOptions<Option>(props: UseSelectOptionsProps<Option>) {
const { renderOptions } = useOptions(props);

return useMemo(
() => ({
selectOptions: (
<>
{placeholder && (
<option value={EMPTY_SELECT_VALUE} disabled={placeholderDisabled}>
{placeholder}
</option>
)}
{renderOptions.map(({ id, value, label }) => (
<option key={id} value={value}>
{label}
Expand All @@ -36,6 +19,6 @@ export function useSelectOptions<Option>({
</>
),
}),
[renderOptions, placeholder]
[renderOptions]
);
}

0 comments on commit b2f4726

Please sign in to comment.