Skip to content

Commit

Permalink
add 3 guides with examples (#540)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexasselin008 authored Dec 4, 2024
2 parents 32ce5dd + 4c34ee2 commit 388f597
Show file tree
Hide file tree
Showing 16 changed files with 416 additions and 183 deletions.
76 changes: 74 additions & 2 deletions apps/docs/content/components/concepts/client-side-routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,87 @@
title: Client Side Routing
description: Many Hopper components support rendering as HTML links. This page discusses how to set up your app to integrate Hopper links with your framework or client side router.
order: 4
status: WIP
---

This page will be heavily inspired by https://react-spectrum.adobe.com/react-spectrum/routing.html
_Since Hopper components are designed on top of React Aria, this article is heavily inspired by the [Client Side Routing](https://react-spectrum.adobe.com/react-spectrum/routing.html) article in React-Aria's documentation._

## Introduction

Hopper components such as [Link](./navigation/Link), Menu, Tabs, Table, and many others support rendering elements as links that perform navigation when the user interacts with them. Each component that supports link behavior accepts the href prop, which causes the component to render an `<a>` element. Other link DOM props such as target and download are also supported.

Depending on the component, users may interact with links in different ways. For example, users can navigate between tabs using the arrow keys, or open a link in a ComboBox using the enter key. Because Hopper components accept the href prop rather than supporting arbitrary element overrides, they can ensure that link navigation occurs when it is appropriate for the component.

By default, links perform native browser navigation when they are interacted with. However, many apps and frameworks use client side routers to avoid a full page reload when navigating between pages. The `HopperProvider` component configures all Hopper components within it to navigate using the client side router you provide. Set this up once in the root of your app, and any Hopper component with the href prop will automatically navigate using your router.

## Provider setup

The HopperProvider component accepts two props: `navigate` and `useHref`. `navigate` should be set to a function received from your router for performing a client side navigation programmatically. `useHref` is an optional prop that converts a router-specific href to a native HTML href, e.g. prepending a base path. The following example shows the general pattern. Framework-specific examples are shown below.

```tsx
import { HopperProvider } from "@hopper-ui/components";
import { useNavigate, useHref } from "your-router";

function App() {
const navigate = useNavigate();

return (
<HopperProvider navigate={navigate} useHref={useHref}>
{/* ... */}
</HopperProvider>
);
}
```

### Router options

All link components accept a `routerOptions` prop, which is an object that is passed through to the client side router's navigate function as the second argument. This can be used to control any router-specific behaviors, such as scrolling, replacing instead of pushing to the history, etc.

```tsx
<Link href="/login" routerOptions={{ replace: true }}>
{/* ...*/}
</Link>
```
When using TypeScript, you can configure the RouterConfig type globally so that all link components have auto complete and type safety using a type provided by your router.

```tsx
import type { RouterOptions } from "your-router";

declare module "react-aria-components" {
interface RouterConfig {
routerOptions: RouterOptions
}
}
```

### React Router

The [useNavigate](https://reactrouter.com/en/main/hooks/use-navigate) hook from `react-router-dom` returns a navigate function you can pass to `HopperProvider`. The [useHref](https://reactrouter.com/en/main/hooks/use-href) hook can also be provided if you're using React Router's `basename` option. Ensure that the component that calls `useNavigate` and renders `HopperProvider` is inside the router component (e.g. BrowserRouter) so that it has access to React Router's internal context. The React Router `<Routes>` element should also be defined inside Hopper's `<HopperProvider>` so that links inside the rendered routes have access to the router.

```tsx
import { BrowserRouter, type NavigateOptions, useHref, useNavigate } from "react-router-dom";
import { HopperProvider } from "@hopper-ui/components";

declare module "react-aria-components" {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}

function App() {
const navigate = useNavigate();

return (
<HopperProvider navigate={navigate} useHref={useHref}>
{/* Your app here... */}
<Routes>
<Route path="/" element={<HomePage />} />
{/* ... */}
</Routes>
</HopperProvider>
);
}

<BrowserRouter>
<App />
</BrowserRouter>
```
43 changes: 41 additions & 2 deletions apps/docs/content/components/concepts/controlled-mode.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
---
title: Controlled Mode
description: Controlled vs Uncontrolled mode in Hopper components
order: 4
status: WIP
---
When working with Hopper components, you can customize a component's behavior using **controlled** or **uncontrolled** properties, depending on your needs. This flexibility is the foundation for **building custom components** on top of Hopper, enabling you to implement interactive features or modify the default behavior of components while preserving their visual style and structure.

**Tip**: To dive deeper into the concept of controlled and uncontrolled components in React, read [React's guide here](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components).

## Uncontrolled Mode

**Uncontrolled mode** is great for situations where you don’t need to manage the component’s behavior with your own code.

In uncontrolled mode, the component manages its internal state. You provide an initial value using _defaultX_ properties, and the component updates its state automatically in response to user interactions.

For example, to create a [TagGroup](../collections/TagGroup) where some tags are initially selected, use the `defaultSelectedKeys` prop:

<CodeOnlyExample src="HopperProvider/docs/controlled-mode/uncontrolled" />

In this example:
- `defaultSelectedKeys`: Specifies the initially selected items.
- The component handles the selection state internally.

## Controlled Mode

**Controlled mode** is suitable for scenarios where the component's state depends on external data or when you need to respond programmatically to user interactions or when you need to build a custom component.

In controlled mode, you manage the state of the component externally by providing the `X` and `onXChanged` properties. This allows for full control over the component's behavior and is ideal for complex interactions or when the component's state is derived from external logic.

For example, to fully manage the selected tags:

<CodeOnlyExample src="HopperProvider/docs/controlled-mode/controlled" />

In this example:
- `selectedKeys`: Represents the current selection, controlled externally.
- `onSelectionChange`: Callback invoked when the selection changes, allowing you to update the external state.

## Choosing Between Controlled and Uncontrolled Modes
- Use uncontrolled mode (defaultX) for simpler use cases where internal state management by the component suffices.
- Use controlled mode (X and onXChanged) when external logic or advanced control is required.

By leveraging these modes, you can tailor Hopper components to meet your application's functional requirements while maintaining consistency and reusability.




62 changes: 59 additions & 3 deletions apps/docs/content/components/concepts/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,75 @@
title: Forms
description: Forms allow users to enter and submit data, and provide them with feedback along the way. Hopper includes many components that integrate with HTML forms, with support for custom validation, labels, and help text.
order: 6
status: WIP
---

This page will be heavily inspired by https://react-spectrum.adobe.com/react-spectrum/forms.html
_Since Hopper components are designed on top of React Aria, this article is heavily inspired by the [Forms](https://react-spectrum.adobe.com/react-spectrum/forms.html) article in React-Aria's documentation._

## Labels and help text

Accessible forms start with clear, descriptive labels for each field. All Hopper form components support labeling using the `label` prop. In addition, help text associates additional context with a field such as a description or error message.

<Example src="Form/docs/forms-concept/labels" isOpen />

Most fields should have a visible label. In rare exceptions, the aria-label or aria-labelledby attribute must be provided instead to identify the element.

## Submitting data

How you submit form data depends on your framework, application, and server. By default, HTML forms are submitted by the browser using a full page refresh. You can take control of form submission by calling preventDefault during the onSubmit event, and make an API call to submit the data however you like.

### Uncontrolled forms

The simplest way to get data from a form is using the browser's FormData API during the onSubmit event. This can be passed directly to [fetch](https://developer.mozilla.org/en-US/docs/Web/API/fetch), or converted into a regular JavaScript object using [Object.fromEntries](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries). Each field should have a name prop to identify it, and values will be serialized to strings by the browser.

<Example src="Form/docs/forms-concept/formData" isOpen />

By default, all Hopper components are uncontrolled, which means that the state is stored internally on your behalf. If you need access to the value in realtime, as the user is editing, you can make it controlled. You'll need to manage the state using React's [useState](https://react.dev/reference/react/useState) hook, and pass the current value and a change handler into each form component.

<Example src="Form/docs/forms-concept/accessValue" isOpen />

## Validation

Form validation is important to ensure user input is in an expected format and meets business requirements. Well-designed form validation assists the user with specific, helpful error messages without confusing and frustrating them with unnecessary errors on partial input. Hopper supports native HTML constraint validation with customizable UI, custom validation functions, realtime validation, and integration with server-side validation errors.

### Built-in validation

All Hopper form components integrate with native HTML [constraint validation](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation). This allows you to define constraints on each field such as required, minimum and maximum values, text formats such as email addresses, and even custom regular expression patterns. These constraints are checked by the browser when the user commits changes to the value (e.g. on blur) or submits the form.

To enable native validation, set the `validationBehavior="native"` prop on the [Form](../forms/Form) component. This example shows a required email field, which is validated by the browser and displayed with a help text.

<Example src="Form/docs/forms-concept/nativeValidation" isOpen />

Supported constraints include:

- `isRequired` indicates that a field must have a value before the form can be submitted.
- `minValue` and `maxValue` specify the minimum and maximum value in a date picker or number field.
- `minLength` and `maxLength` specify the minimum and length of text input.
- `pattern` provides a custom [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions) that a text input must conform to.
- `type="email"` and `type="url"` provide builtin validation for email addresses and URLs.

### Customizing error messages

By default, Hopper displays the error message provided by the browser, which is localized in the user's preferred language. You can customize these messages by providing a function to the `errorMessage` prop. This receives a list of error strings along with a [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) object describing why the field is invalid, and should return an error message to display.

<Example src="Form/docs/forms-concept/errorMessage" isOpen />

**Note**: The default error messages are localized by the browser using the browser/operating system language setting. Hopper's Provider has no effect on validation errors.

### Custom validation

In addition to the built-in constraints, custom validation is supported by providing a function to the validate prop. This function receives the current field value, and can return a string or array of strings representing one or more error messages. These are displayed to the user after the value is committed (e.g. on blur) to avoid distracting them on each keystroke.

<Example src="Form/docs/forms-concept/customValidation" isOpen />

## Form libraries

In most cases, uncontrolled forms with the builtin validation features are enough. However, if you are building a truly complex form, or integrating Hopper components into an existing form, a separate form library such as [React Hook Form](https://react-hook-form.com/) or [Formik](https://formik.org/) may be helpful.


### React Hook Form

{/* TODO: react-hook-form + valibot? */}
[React Hook Form](https://react-hook-form.com/) is a popular form library for React. It is primarily designed to work directly with plain HTML input elements, but supports custom form components like the ones in Hopper as well.

Since Hopper manages the state for components internally, you can use the (Controller)[https://react-hook-form.com/docs/usecontroller/controller] component from React Hook Form to integrate Hopper components. Pass the props for the `field` render prop through to the Hopper component you're using, and use the `fieldState` to get validation errors to display.

<CodeOnlyExample src="Form/docs/forms-concept/reactHookForm" isOpen />
18 changes: 18 additions & 0 deletions apps/docs/examples/Preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,24 @@ export const Previews: Record<string, Preview> = {
"HopperProvider/docs/color-scheme/useColorSchemeValue": {
component: lazy(() => import("@/../../packages/components/src/HopperProvider/docs/color-scheme/useColorSchemeValue.tsx"))
},
"Form/docs/forms-concept/labels": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/labels.tsx"))
},
"Form/docs/forms-concept/formData": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/formData.tsx"))
},
"Form/docs/forms-concept/accessValue": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/accessValue.tsx"))
},
"Form/docs/forms-concept/nativeValidation": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/nativeValidation.tsx"))
},
"Form/docs/forms-concept/errorMessage": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/errorMessage.tsx"))
},
"Form/docs/forms-concept/customValidation": {
component: lazy(() => import("@/../../packages/components/src/Form/docs/forms-concept/customValidation.tsx"))
},
"layout/docs/flex": {
component: lazy(() => import("@/../../packages/components/src/layout/docs/flex.tsx"))
},
Expand Down
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"react-aria": "^3.36",
"react-aria-components": "^1.5",
"react-dom": "18.3.1",
"react-hook-form": "7.53.2",
"react-toggle": "4.1.3",
"rehype-parse": "^9.0.1",
"rehype-pretty-code": "0.14.0",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"build:apps": "pnpm -r --filter \"{apps/**}\" build ",
"changeset": "changeset",
"ci-release": "pnpm build:pkg && changeset publish",
"aa": "cd ./apps/docs && pnpm generate:previewRef",
"generate-icons": "pnpm --filter=\"svg-icons\" generate-icons && pnpm --filter=\"@hopper-ui/icons*\" generate-icons",
"lint": "pnpm run \"/^lint:.*/\"",
"lint:eslint": "eslint . --max-warnings=0 --cache --cache-location node_modules/.cache/eslint",
Expand Down Expand Up @@ -80,6 +81,7 @@
"prop-types": "15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.53.2",
"react-refresh": "0.14.2",
"react-router-dom": "6.27.0",
"storybook": "^8.4.5",
Expand Down
24 changes: 24 additions & 0 deletions packages/components/src/Form/docs/forms-concept/accessValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Button, ButtonGroup, Form, TextField } from "@hopper-ui/components";
import { useState, type FormEvent } from "react";

export default function Example() {
const [name, setName] = useState("");

const onSubmit = (e: FormEvent) => {
e.preventDefault();

// Submit data to your backend API...
alert(name);
};

return (
<Form onSubmit={onSubmit}>
<TextField label="Name" value={name} onChange={setName} />
<div>You entered: {name}</div>
<ButtonGroup>
<Button type="submit" variant="primary">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
</Form>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Button, ButtonGroup, Form, TextField } from "@hopper-ui/components";
import { useState, type FormEvent } from "react";

export default function Example() {
const [submitted, setSubmitted] = useState<Record<string, FormDataEntryValue> | null>(null);

const onSubmit = (e: FormEvent<HTMLFormElement>) => {
// Prevent default browser page refresh.
e.preventDefault();

// Get form data as an object.
const data = Object.fromEntries(new FormData(e.currentTarget));

// Submit to your backend API...
setSubmitted(data);
};

return (
<Form onSubmit={onSubmit}>
<TextField name="name" label="Name" />
<ButtonGroup>
<Button type="submit" variant="primary">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
{submitted && (
<div>
You submitted: <code>{JSON.stringify(submitted)}</code>
</div>
)}
</Form>
);
}
20 changes: 20 additions & 0 deletions packages/components/src/Form/docs/forms-concept/errorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Button, ButtonGroup, Form, TextField } from "@hopper-ui/components";

export default function Example() {
return (
<Form validationBehavior="native">
<TextField
label="Name"
name="name"
isRequired
errorMessage={({ validationDetails }) => (
validationDetails.valueMissing ? "Please enter a name." : ""
)}
/>
<ButtonGroup>
<Button type="submit" variant="primary">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
</Form>
);
}
16 changes: 16 additions & 0 deletions packages/components/src/Form/docs/forms-concept/formData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Button, ButtonGroup, Form, TextField } from "@hopper-ui/components";

export default function Example() {
return (
<Form validationBehavior="native">
<TextField
label="Username"
validate={value => value === "admin" ? "Nice try!" : null}
/>
<ButtonGroup>
<Button type="submit" variant="primary">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
</Form>
);
}
10 changes: 10 additions & 0 deletions packages/components/src/Form/docs/forms-concept/labels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { PasswordField } from "@hopper-ui/components";

export default function Example() {
return (
<PasswordField
label="Password"
description="Password must be at least 8 characters."
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Button, ButtonGroup, Form, TextField } from "@hopper-ui/components";

export default function Example() {
return (
<Form validationBehavior="native">
<TextField label="Email" name="email" type="email" isRequired />
<ButtonGroup>
<Button type="submit" variant="primary">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
</Form>
);
}
Loading

0 comments on commit 388f597

Please sign in to comment.