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

feat(ds-core-form): boilerplate pt 4 #167

Merged
merged 11 commits into from
Mar 25, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/chromatic._template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:

- name: Test
run: bun run test
publish:
publish-to-chromatic:
runs-on: ubuntu-latest
needs: build
steps:
Expand Down
12 changes: 12 additions & 0 deletions configs/storybook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const config = createConfig();
export default { ...config };
```

## Notes

The [autodocs](https://storybook.js.org/docs/writing-docs/autodocs) feature is enabled by this config with ```typescript
{
docs: {
autodocs: true,
},
}
```



## Caveats
- At the moment the factory is not configurable. We are not sure what the best api to pass custom config parameters would be, if any.
- This storybook config for the time being only implementing a factory for react/vite. We imagine this might change to accomodate other frameworks and build tools.
6 changes: 3 additions & 3 deletions packages/react/ds-core-form/src/storybook/decorators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import type React from "react";
import { FormProvider, useForm } from "react-hook-form";

interface FormDecoratorParams {
initialData?: Record<string, unknown>;
defaultValues?: Record<string, unknown>;
}

export const form = ({ initialData = {} }: FormDecoratorParams = {}) => {
export const form = ({ defaultValues = {} }: FormDecoratorParams = {}) => {
return (Story: React.ElementType) => {
const FormWrapper: React.ElementType = () => {
const methods = useForm({
mode: "onChange",
defaultValues: initialData,
defaultValues,
});

const onSubmit = (data: Record<string, unknown>) => {
Expand Down
14 changes: 14 additions & 0 deletions packages/react/ds-core-form/src/storybook/fixtures.options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Option } from "../ui/Field/types.js";

export const base: Option[] = [
{ value: "1", label: "Option 1" },
{ value: "2", label: "Option 2" },
{ value: "3", label: "Option 3" },
];

export const withDisabled: Option[] = [
{ value: "1", label: "Option 1" },
{ value: "2", label: "Option 2", disabled: true },
{ value: "3", label: "Option 3" },
{ value: "4", label: "Option 4", disabled: true },
];
1 change: 0 additions & 1 deletion packages/react/ds-core-form/src/ui/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import * as decorators from "storybook/decorators.js";
import Component from "./Field.js";
import { InputType } from "./types.js";
// Needed for template-based story, safe to remove otherwise
// import type { StoryFn } from '@storybook/react'

Expand Down
1 change: 0 additions & 1 deletion packages/react/ds-core-form/src/ui/Field/Field.tests.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Component from "./Field.js";
import { InputType } from "./types.js";

describe("Field component", () => {
// it("renders", () => {
Expand Down
16 changes: 14 additions & 2 deletions packages/react/ds-core-form/src/ui/Field/Field.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/* @canonical/generator-ds 0.9.0-experimental.4 */
import type React from "react";
import { Checkbox, Text, Textarea } from "./inputs/index.js";
import { InputType } from "./types.js";
import {
Checkbox,
Range,
Select,
SimpleChoices,
Text,
Textarea,
} from "./inputs/index.js";
import type { FieldProps } from "./types.js";

/**
Expand All @@ -18,6 +24,12 @@ const Field = ({
return <Textarea {...props} />;
case "checkbox":
return <Checkbox {...props} />;
case "range":
return <Range {...props} />;
case "select":
return <Select {...props} />;
case "simple-choices":
return <SimpleChoices {...props} />;
case "custom":
// @ts-ignore // TODO Add special type for both or none
return <CustomComponent {...props} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ const Label = ({
name,
isOptional,
messages = defaultMessages,
namePrefix = "form-",
htmlFor,
tag: Element = "label",
}: LabelProps): React.ReactElement => {
return (
<Element
id={id}
style={style}
htmlFor={Element === "label" ? `${namePrefix}${name}` : undefined}
htmlFor={Element === "label" ? htmlFor : undefined}
className={[componentCssClassName, className].filter(Boolean).join(" ")}
>
{children || name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export interface LabelProps {
style?: React.CSSProperties;
/* The name of input labelled */
name: string;
/* The prefix for the name */
namePrefix?: string;
/* Should reference the ID of the input */
htmlFor?: string;
/* Is the field optional */
isOptional?: boolean;
/* Custom messages */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Text as TextInput,
Textarea as TextareaInput,
} from "../../inputs/index.js";
import type { BaseInputProps } from "../../inputs/types.js";
import Component from "./Wrapper.js";
// Needed for template-based story, safe to remove otherwise
// import type { StoryFn } from '@storybook/react'
Expand Down
27 changes: 16 additions & 11 deletions packages/react/ds-core-form/src/ui/Field/common/Wrapper/Wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* @canonical/generator-ds 0.9.0-experimental.4 */
import type React from "react";
import type { WrapperProps } from "./types.js";
import { useMemo } from "react";
import type { BaseInputProps, WrapperProps } from "../../types.js";
import "./styles.css";
import { Description, Error as FieldError, Label } from "../index.js";
import { useFieldWrapper } from "./hooks/index.js";
Expand All @@ -11,9 +12,8 @@ const componentCssClassName = "ds form-wrapper";
* description of the Wrapper component
* @returns {React.ReactElement} - Rendered Wrapper
*/
const Wrapper = ({
const Wrapper = <ComponentProps extends BaseInputProps>({
id,
children,
className,
style,

Expand All @@ -28,7 +28,7 @@ const Wrapper = ({

mockLabel = false,
...otherProps
}: WrapperProps): React.ReactElement => {
}: WrapperProps<ComponentProps>): React.ReactElement => {
const { fieldError, isError, ariaProps, registerProps } = useFieldWrapper(
name,
{
Expand All @@ -40,6 +40,17 @@ const Wrapper = ({
},
);

// biome-ignore lint/correctness/useExhaustiveDependencies: -
const componentProps = useMemo(
() => ({
name,
registerProps,
...ariaProps.input,
...otherProps,
}),
[name, registerProps, ariaProps.input],
) as unknown as ComponentProps;

return (
<div
id={id}
Expand All @@ -55,18 +66,12 @@ const Wrapper = ({
>
{label}
</Label>
<Component
name={name}
registerProps={registerProps}
{...ariaProps.input}
{...otherProps}
/>
<Component {...componentProps} />
{isError && (
<FieldError {...ariaProps.error}>
{fieldError?.message?.toString()}
</FieldError>
)}
{children}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ const useFieldWrapper = (

const { unregister } = useFormContext();

// biome-ignore lint/correctness/useExhaustiveDependencies: Comparing the `name` suffices
useEffect(
() => () => (unregisterOnUnmount ? unregister(name) : undefined),
[unregisterOnUnmount, name, unregister],
[name],
);

return {
Expand Down
40 changes: 0 additions & 40 deletions packages/react/ds-core-form/src/ui/Field/common/Wrapper/types.ts
Original file line number Diff line number Diff line change
@@ -1,40 +0,0 @@
/* @canonical/generator-ds 0.9.0-experimental.4 */
import type React from "react";
import type { BaseInputProps } from "../../inputs/index.ts";
import type { BaseFieldProps, BaseWrapperProps } from "../../types.js";

type BaseComponentProps = BaseInputProps & BaseWrapperProps;

export type WrapperProps<
ComponentProps extends BaseComponentProps = BaseFieldProps,
> = BaseFieldProps & {
/* A unique identifier for the Wrapper */
id?: string;
/* Additional CSS classes */
className?: string;
/* Child elements */
children?: React.ReactNode;
/* Inline styles */
style?: React.CSSProperties;

/* The description of the input */
description?: string;

/* The name of input labelled */
label?: string;

/* Is the field optional */
isOptional?: boolean;

/* TODO */
nestedRegisterProps?: Record<string, unknown>;

/* Whether to unregister the field on unmount */
unregisterOnUnmount?: boolean;

/* Whether to mock the label */
mockLabel?: boolean;

/* The input to render */
Component: React.ComponentType<ComponentProps>;
};
Original file line number Diff line number Diff line change
@@ -1,43 +1,64 @@
import * as React from "react";
import { useMemo } from "react";
import type { InputProps } from "../../inputs/index.js";
import type { BaseFieldProps, FieldProps, FormInputHOC } from "../../types.js";
import type { TextProps } from "../../inputs/index.js";
import type {
BaseInputProps,
BaseWrapperProps,
WrappedComponentProps,
WrapperProps,
} from "../../types.js";
import DefaultWrapper from "./Wrapper.js";
import type { WrapperProps } from "./types.js";
// import MockWrapper from './WrapperContent.js'
// import type { WrapperProps } from './types.js'
// import withConditionalDisplay from './withConditionalDisplay.js'

const withWrapper = (
Component: React.ComponentType<InputProps>,
options?: WrapperProps,
Wrapper: typeof DefaultWrapper = DefaultWrapper,
type WrappedComponentPropsInternal<ComponentProps extends BaseInputProps> =
Omit<WrappedComponentProps<ComponentProps>, "Component">;

const withWrapper = <
ComponentProps extends BaseInputProps,
ComponentWrapperProps extends
BaseWrapperProps<ComponentProps> = WrapperProps<ComponentProps>,
>(
Component: React.ComponentType<ComponentProps>,
options?: Partial<ComponentWrapperProps>,
Wrapper: React.ComponentType<WrapperProps<ComponentProps>> = DefaultWrapper,
// Ideally, we should use something like this, but this creates a type error for the WrappedComponentProps
// Wrapper: React.ComponentType<ComponentWrapperProps> = DefaultWrapper as React.ComponentType<ComponentWrapperProps>,
) => {
const MemoizedComponent = React.memo(Component);

function WrappedComponent({
middleware = [],
WrapperComponent = Wrapper,
...props
}: BaseFieldProps): React.ReactElement {
}: WrappedComponentPropsInternal<ComponentProps>): React.ReactElement {
// We apply the middleware to the component in reverse orderso
// so that the first middleware in the array is the first to be applied to the component

const ExtendedComponent = useMemo(
() =>
// The middleware happens between the Wrapper and the Component,
// hence the return type of P
middleware
.reverse()
.reduce<React.ComponentType<BaseFieldProps>>(
.reduce<React.ComponentType<ComponentProps>>(
(AccumulatedComponent, hoc) => hoc(AccumulatedComponent),
MemoizedComponent,
),
[middleware],
);

return React.createElement(WrapperComponent, {
// Type casting to avoid hard to read overloads.
const finalProps = {
// Component: MemoizedComponent,
Component: ExtendedComponent,
...props,
...options,
});
};
// } as WrapperProps<ComponentProps>; // The typing should be ComponentWrapperProps, but it would require an overload

return React.createElement(
WrapperComponent,
finalProps as WrapperProps<ComponentProps>,
);
}
// return withConditionalDisplay(WrappedComponent)
return WrappedComponent;
Expand Down
1 change: 1 addition & 0 deletions packages/react/ds-core-form/src/ui/Field/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as useFieldError } from "./useFieldError.js";
export { default as useFieldAriaProperties } from "./useFieldAriaProperties.js";
export { default as useOptionAriaProperties } from "./useOptionAriaProperties.js";
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { useId, useMemo } from "react";
* @param {boolean} isError - Indicates if the field is in an error state.
* @returns An object containing ARIA attributes for input, label, description, and error state.
*/
const useFieldAriaProps = (name: string, isError: boolean) =>
useMemo(() => {
const uniqueId = useId();
const useFieldAriaProps = (name: string, isError: boolean) => {
const uniqueId = useId();
const props = useMemo(() => {
const baseId = `${uniqueId}-${name}`;
const labelId = `${baseId}-label`;
const descriptionId = `${baseId}-description`;
Expand All @@ -19,16 +19,21 @@ const useFieldAriaProps = (name: string, isError: boolean) =>
id: baseId,
"aria-labelledby": labelId,
"aria-describedby": `${descriptionId}${isError}` ? ` ${labelId}` : "",
"aria-errormessage": errorId,
"aria-errormessage": isError ? errorId : undefined,
"aria-invalid": isError,
},
label: { id: labelId },
label: {
id: labelId,
htmlFor: baseId,
},
description: { id: descriptionId },
error: {
id: errorId,
// role: "alert",
},
};
}, [name, isError]);
}, [name, isError, uniqueId]);
return props;
};

export default useFieldAriaProps;
Loading