Skip to content

Commit

Permalink
finish
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds committed Mar 11, 2024
1 parent f42796b commit fd1eb90
Show file tree
Hide file tree
Showing 40 changed files with 274 additions and 72 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Compound Components Validation

Change <InlineFile file="app.tsx" /> to this (temporarily):
👨‍💼 Change <InlineFile file="app.tsx" /> to this (temporarily):

```javascript
```tsx
import { ToggleButton } from './toggle'

export const App = () => <ToggleButton />
Expand All @@ -12,7 +12,7 @@ Why doesn't that work (it's not supposed to, but can you explain why)? Can you
figure out a way to give the developer a better error message that explains what
they're doing wrong and how to fix it?

🚨 The tests will tell you the message for the error you must throw when the
🚨 The tests will tell you in the message for the error you must throw when the
context is undefined.

🦺 Additionally, this is where we can make TypeScript happier (TypeScript knew
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Compound Components Validation

Runtime validation isn't the best (it would be better if we could enforce this
statically via TypeScript), but unfortunately it's the best we can do with the
composition model offered by React. That said, we it's unlikely people will mess
this up now that we have this runtime validation in place.
👨‍💼 Runtime validation isn't the best (it would be better if we could enforce
this statically via TypeScript), but unfortunately it's the best we can do with
the composition model offered by React. That said, we it's unlikely people will
mess this up now that we have this runtime validation in place.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Toggle({ children }: { children: React.ReactNode }) {

function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
5 changes: 5 additions & 0 deletions exercises/03.compound-components/FINISHED.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
# Compound Components

👨‍💼 Great work! You now know how to create one of the best composition APIs for
UI component libraries. The vast majority of component libraries employ this
pattern and even if you don't build your own, you'll be much better able to use
these libraries because you understand how they work. Good job!
26 changes: 26 additions & 0 deletions exercises/04.slots/01.problem.context/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
# Slot Context

👨‍💼 It's a tale as old as time. Our `label` and `input` are not properly
associated in this form and so clicking the `label` will not focus the `input`
as expected (in addition to other accessibility issues).

But we don't want developers to be able to make this mistake. So we've made a
`TextField` component which will generate the `id` for the relationship (if one
is not provided). The tricky bit is we want people to be able to structure their
label and input however they want, so we can't render the `input` and `label`
for them. Instead, we want to be able to provide the `id` and `htmlFor` props to
the `label` and `input`.

So what we want you to do is first create a `SlotContext` and `useSlotProps`
hook in <InlineFile file="slots.tsx" />, then use those in the `Label` and
`Input` components to retrieve the necessary props.

The `useSlotProps` hook should accept a props object and a slot name and return
the props to be applied to the element for that slot. It should merge the props
it's been given with the props from the `SlotContext` for that slot.

Once you've finished that, then render the `SlotContext` provider in the
`TextField` component in <InlineFile file="text-field.tsx" /> to provide slot
props for the `label` and `input`.

When you're finished, the label and input should be properly associated and
clicking the label should focus the input.
2 changes: 1 addition & 1 deletion exercises/04.slots/01.problem.context/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Toggle({ children }: { children: React.ReactNode }) {

function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
3 changes: 3 additions & 0 deletions exercises/04.slots/01.solution.context/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Slot Context

👨‍💼 That's a great start! Now we have that in place, I think we can use this in
our toggle component!
2 changes: 1 addition & 1 deletion exercises/04.slots/01.solution.context/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Toggle({ children }: { children: React.ReactNode }) {

function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
14 changes: 14 additions & 0 deletions exercises/04.slots/02.problem.generic/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
# Generic Slot Components

👨‍💼 You'll notice our party mode toggle button is using `useId` to properly
associate the toggle button with its label. We'd like to make that implicit and
reuse the `Label` component for the `Toggle` as well.

Please update the `Toggle` component in <InlineFile file="toggle.tsx" /> to
render a `SlotContext` provider (in addition to the `ToggleContext` provider
it's already rendering) so it can provide props for a `label` slot (the slot
name for a `Label`). You'll also want to put the `id` in the `ToggleContext` so
the `ToggleButton` can grab it.

Once you're finished with that, you can remove the manual `id`/`htmlFor` props
in the <InlineFile file="app.tsx" /> file and the `id` should be provided
automatically.
2 changes: 1 addition & 1 deletion exercises/04.slots/02.problem.generic/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function Toggle({ children }: { children: React.ReactNode }) {

function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
3 changes: 3 additions & 0 deletions exercises/04.slots/02.solution.generic/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Generic Slot Components

👨‍💼 Great! Now we can reuse the `Label` component in our set of `Toggle` compound
components. Let's go a step further with a generic `Text` component.
2 changes: 1 addition & 1 deletion exercises/04.slots/02.solution.generic/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function Toggle({

function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
42 changes: 42 additions & 0 deletions exercises/04.slots/03.problem.prop/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,43 @@
# Slot Prop

👨‍💼 We have `ToggleOn` and `ToggleOff` components, but really we could make those
components a simple `Text` component that accepts a `slot` prop. Then the
`Toggle` component could define the props that the individual `Text` components
should have based on which slot they're taking.

In fact, we could do this with the `Switch` as well!

🧝‍♂️ I've added `Text` and `Switch` components to the
<InlineFile file="slots.tsx" /> file for you to use. These are both already
wired up to consume a `slot` named `text` and `switch`. You can
<DiffLink app1={-1}>check the diff</DiffLink> for details.

What we want to do in this exercise is add a `slot` prop to each of our slot
components so the slot they're taking can be defined by the parent component.

Then you'll need to update `Toggle` to get rid of the `ToggleContext` provider
and instead use the `SlotProvider` for all the components it wants to send props
to:

- `label` - `htmlFor`
- `onText` - `hidden` (`undefined` if `isOn` is true, and `true` if `isOn` is
`false`)
- `offText` - `hidden` (`undefined` if `isOn` is false, and `true` if `isOn` is
`true`)
- `switch` - `id`, `on`, and `onClick`

So by the end of all of this, here's what I want the API to be like:

```tsx
<Toggle>
<Label>Party mode</Label>
<Switch />
<Text slot="onText">Let's party 🥳</Text>
<Text slot="offText">Sad town 😭</Text>
</Toggle>
```

Once that's been updated, you can delete the `useToggle` hook and the
`ToggleOn`, `ToggleOff`, and `ToggleButton` components.

Reusability FTW!
2 changes: 1 addition & 1 deletion exercises/04.slots/03.problem.prop/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function Toggle({
// 🐨 delete everything below here!
function useToggle() {
const context = use(ToggleContext)
if (context === undefined) {
if (!context) {
throw new Error(
'Cannot find ToggleContext. All Toggle components must be rendered within <Toggle />',
)
Expand Down
6 changes: 6 additions & 0 deletions exercises/04.slots/03.solution.prop/README.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
# Slot Prop

👨‍💼 Great! Now we can use the same component for multiple slots!

One thing to note is that there's not a great way to have good type safety on
these props, though if you're creative, you could add nice runtime errors,
[for example](https://github.com/adobe/react-spectrum/blob/3db98d2d378f977a88d94e9f2501feca8ef8ce51/packages/react-aria-components/src/utils.tsx#L169-L181).
3 changes: 3 additions & 0 deletions exercises/04.slots/FINISHED.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Slots

👨‍💼 This is a really nice pattern if you find yourself building a library of
components that have a lot of common components. Great job!
67 changes: 67 additions & 0 deletions exercises/04.slots/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,70 @@
**One liner:** Slots allow you to specify an element which takes on a
particular role in the overall collection of components.
</callout-success>

If you're building a component library, you have to deal with two competing
interests:

1. Correctness
2. Flexibility

You want to make sure people don't mess up things like accessibility, but you
also want to give them the flexibility to build things the way their diverse
needs require. Slots can help with this.

Here's a quick example of a component that uses slots (from the
[`react-aria`](https://react-spectrum.adobe.com/react-aria/index.html) docs):

```tsx
<CheckboxGroup>
<Label>Pets</Label>
<MyCheckbox value="dogs">Dogs</MyCheckbox>
<MyCheckbox value="cats">Cats</MyCheckbox>
<MyCheckbox value="dragons">Dragons</MyCheckbox>
<Text slot="description">Select your pets.</Text>
</CheckboxGroup>
```

The `slot="description"` prop is letting the `Text` component know that it needs
to look for special props that are meant to be used as a description. Those
special props will be provided by the `CheckboxGroup` component.

Essentially, the `CheckboxGroup` component will say: "here's a bucket of props
for any component that takes on the role of a description." The `Text` component
will then say: "Oh look, I've got a `slot` prop that matches the `description`
slot, so I'll use these props to render myself."

All of this is built using context.

What this enables is a powerfully flexible capability to have components which
are highly reusable. The `Text` component can be used in many different
contexts, and it can adapt to the needs of the parent component. For example,
it's also used in react-aria's `ComboBox` components. Here's the anatomy of a
react-aria `ComboBox` component:

```tsx lines=5,10,11
<ComboBox>
<Label />
<Input />
<Button />
<Text slot="description" />
<FieldError />
<Popover>
<ListBox>
<ListBoxItem>
<Text slot="label" />
<Text slot="description" />
</ListBoxItem>
<Section>
<Header />
<ListBoxItem />
</Section>
</ListBox>
</Popover>
</ComboBox>
```

This can be used to apply appropriate `aria-` attributes as well as `id`s and
event handlers. You might think about it as a way to implement compound
components in a way that doesn't require an individual component for every
single use case.
6 changes: 5 additions & 1 deletion exercises/05.prop-getters/01.problem.collections/README.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Prop Collections

In our simple example, this isn't too much for folks to remember, but in more
🧝‍♂️ I've removed the work we did with the Slot pattern and we're also going to be
focusing the next few exercises on the hook. So we've simplified things a little
bit to give use a better focus on the hook.

👨‍💼 In our simple example, this isn't too much for folks to remember, but in more
complex components, the list of props that need to be applied to elements can be
extensive, so it can be a good idea to take the common use cases for our hook
and/or components and make objects of props that people can simply spread across
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Prop Collections

Prop collections are great for handling common use cases for your hooks, but
👨‍💼 Prop collections are great for handling common use cases for your hooks, but
there's a subtle but important limitation with them that we should address next.
6 changes: 3 additions & 3 deletions exercises/05.prop-getters/02.problem.getters/README.mdx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Prop Getters

Uh oh! Someone wants to use our `togglerProps` object, but they need to apply
👨‍💼 Uh oh! Someone wants to use our `togglerProps` object, but they need to apply
their own `onClick` handler! Try doing that by updating the `App` component to
this:

```javascript
```tsx
function App() {
const { on, togglerProps } = useToggle()
return (
Expand Down Expand Up @@ -36,4 +36,4 @@ new API we're responsible for creating. See if you can make that API work.

🦺 The types for the argument to the `getTogglerProps` component might be a bit
tricky, so here's a little tip: you can get the `onClick` prop from:
`React.DOMAttributes<HTMLButtonElement>['onClick']`.
`React.ComponentProps<'button'>['onClick']`.
2 changes: 1 addition & 1 deletion exercises/05.prop-getters/02.solution.getters/README.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Prop Getters

Great, now users don't have to worry about whether they're overriding us or
👨‍💼 Great, now users don't have to worry about whether they're overriding us or
we're overriding them and everything can be an implementation detail which we
can change as needed without worrying about breaking people's expectations.
Composition FTW!
2 changes: 1 addition & 1 deletion exercises/05.prop-getters/02.solution.getters/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function useToggle() {
onClick,
...props
}: {
onClick?: React.DOMAttributes<HTMLButtonElement>['onClick']
onClick?: React.ComponentProps<'button'>['onClick']
} & Props) {
return {
'aria-checked': on,
Expand Down
4 changes: 4 additions & 0 deletions exercises/05.prop-getters/FINISHED.mdx
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Prop Collections and Getters

👨‍💼 Great work! This is a highly flexible pattern that gives consumers a great
deal of control over how the component is rendered while also handling common
cases with ease.
20 changes: 11 additions & 9 deletions exercises/06.state-reducer/01.problem/README.mdx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# State Reducer

In this exercise, we want to prevent the toggle from updating the toggle state
after it's been clicked 4 times in a row before resetting. We could easily add
that logic to our reducer, but instead we're going to apply a computer science
pattern called "Inversion of Control" where we effectively say: "Here you go!
You have complete control over how this thing works. It's now your
👨‍💼 In this exercise, we want to prevent the toggle from updating the toggle
state after it's been clicked 4 times in a row before resetting. We could easily
add that logic to our reducer, but instead we're going to apply a computer
science pattern called "Inversion of Control" where we effectively say: "Here
you go! You have complete control over how this thing works. It's now your
responsibility."

> As an aside, before React Hooks were a thing, this was pretty tricky to
> implement and resulted in pretty weird code, but with useReducer, this is WAY
> better. I ❤️ hooks. 😍
Your job is to enable people to provide a custom `reducer` so they can have
complete control over how state updates happen in our `<Toggle />` component.

<callout-info class="aside">
As an aside, before React Hooks were a thing, this was pretty tricky to
implement and resulted in pretty weird code, but with useReducer, this is WAY
better. I ❤️ hooks. 😍
</callout-info>
4 changes: 2 additions & 2 deletions exercises/06.state-reducer/01.problem/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function useToggle({ initialOn = false } = {}) {
function getTogglerProps<Props>({
onClick,
...props
}: { onClick?: React.DOMAttributes<HTMLButtonElement>['onClick'] } & Props) {
}: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
return {
'aria-checked': on,
onClick: callAll(onClick, toggle),
Expand All @@ -48,7 +48,7 @@ export function useToggle({ initialOn = false } = {}) {
function getResetterProps<Props>({
onClick,
...props
}: { onClick?: React.DOMAttributes<HTMLButtonElement>['onClick'] } & Props) {
}: { onClick?: React.ComponentProps<'button'>['onClick'] } & Props) {
return {
onClick: callAll(onClick, reset),
...props,
Expand Down
2 changes: 1 addition & 1 deletion exercises/06.state-reducer/01.solution/README.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# State Reducer

This is a powerful example of inversion of control that allows users to
👨‍💼 This is a powerful example of inversion of control that allows users to
overwrite our entire reducer. But it could be exhausting to users to have to
duplicate most of our reducer just to change a few things. So let's address that
common scenario next.
Loading

0 comments on commit fd1eb90

Please sign in to comment.