Skip to content

Commit 00cc8c5

Browse files
committed
Add Alert & RadioGroup components (#274)
* add Alert component * expose Alert * rename forgotten FLYOUT to POPOVER * use PopoverRenderPropArg * organize imports in a consistent way * ensure Portals behave as expected Portals can be nested from a React perspective, however in the DOM they are rendered as siblings, this is mostly fine. However, when they are rendered inside a Dialog, the Dialog itself is marked with `role="modal"` which makes all the other content inert. This means that rendering Menu.Items in a Portal or an Alert in a portal makes it non-interactable. Alerts are not even announced. To fix this, we ensure that we make the `root` of the Portal the actual dialog. This allows you to still interact with it, because an open modal is the "root" for the assistive technology. But there is a catch, a Dialog in a Dialog *can* render as a sibling, because you force the focus into the new Dialog. So we also ensured that Dialogs are always rendered in the portal root, and not inside another Dialog. * add dialog with alert example * add internal Description component * add internal Label component * add RadioGroup component * expose RadioGroup * add RadioGroup example * ensure to include tha RadioGroup.Option own id * update changelog * split documentation
1 parent e3cbcc4 commit 00cc8c5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+5230
-3080
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Add `FocusTrap` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
2828
- Add `Popover` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
2929
- All components that accept a `className`, can now also receive a function with the renderProp argument ([#257](https://github.com/tailwindlabs/headlessui/pull/257))
30+
- Add `RadioGroup` component ([#274](https://github.com/tailwindlabs/headlessui/pull/274))
31+
- Add `Alert` component ([#274](https://github.com/tailwindlabs/headlessui/pull/274))
3032

3133
## [Unreleased - Vue]
3234

packages/@headlessui-react/README.md

Lines changed: 11 additions & 1827 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { useState, Fragment } from 'react'
2+
import { Dialog, Portal, Transition, Alert } from '@headlessui/react'
3+
4+
export default function Home() {
5+
let [isOpen, setIsOpen] = useState(false)
6+
let [deleted, setDeleted] = useState(false)
7+
8+
function notifyUser() {
9+
setDeleted(true)
10+
setTimeout(() => {
11+
setDeleted(false)
12+
}, 10 * 1000)
13+
}
14+
15+
return (
16+
<>
17+
<button
18+
type="button"
19+
onClick={() => setIsOpen(v => !v)}
20+
className="m-12 px-4 py-2 text-base font-medium leading-6 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md shadow-sm hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue sm:text-sm sm:leading-5"
21+
>
22+
Toggle!
23+
</button>
24+
25+
<Transition show={isOpen} as={Fragment}>
26+
<Dialog open={isOpen} onClose={setIsOpen} static>
27+
<div className="fixed z-10 inset-0 overflow-y-auto">
28+
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
29+
<Transition.Child
30+
enter="ease-out duration-300"
31+
enterFrom="opacity-0"
32+
enterTo="opacity-100"
33+
leave="ease-in duration-200"
34+
leaveFrom="opacity-100"
35+
leaveTo="opacity-0"
36+
>
37+
<Dialog.Overlay className="fixed inset-0 transition-opacity">
38+
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
39+
</Dialog.Overlay>
40+
</Transition.Child>
41+
42+
<Transition.Child
43+
enter="ease-out transform duration-300"
44+
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
45+
enterTo="opacity-100 translate-y-0 sm:scale-100"
46+
leave="ease-in transform duration-200"
47+
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
48+
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
49+
>
50+
{/* This element is to trick the browser into centering the modal contents. */}
51+
<span
52+
className="hidden sm:inline-block sm:align-middle sm:h-screen"
53+
aria-hidden="true"
54+
>
55+
&#8203;
56+
</span>
57+
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
58+
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
59+
<div className="sm:flex sm:items-start">
60+
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
61+
{/* Heroicon name: exclamation */}
62+
<svg
63+
className="h-6 w-6 text-red-600"
64+
xmlns="http://www.w3.org/2000/svg"
65+
fill="none"
66+
viewBox="0 0 24 24"
67+
stroke="currentColor"
68+
aria-hidden="true"
69+
>
70+
<path
71+
strokeLinecap="round"
72+
strokeLinejoin="round"
73+
strokeWidth={2}
74+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
75+
/>
76+
</svg>
77+
</div>
78+
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
79+
<Dialog.Title
80+
as="h3"
81+
className="text-lg leading-6 font-medium text-gray-900"
82+
>
83+
Deactivate account
84+
</Dialog.Title>
85+
<div className="mt-2">
86+
<p className="text-sm text-gray-500">
87+
Are you sure you want to deactivate your account? All of your data will
88+
be permanently removed. This action cannot be undone.
89+
</p>
90+
{deleted && (
91+
<Portal>
92+
<div className="fixed z-50 bg-blue-400 text-white font-bold text-lg p-4 py-6 top-0 left-0 right-0">
93+
<Alert>I am now deleted!</Alert>
94+
</div>
95+
</Portal>
96+
)}
97+
<div className="relative inline-block text-left mt-10"></div>
98+
</div>
99+
</div>
100+
</div>
101+
</div>
102+
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
103+
<button
104+
type="button"
105+
onClick={() => notifyUser()}
106+
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:shadow-outline-red sm:ml-3 sm:w-auto sm:text-sm"
107+
>
108+
Deactivate
109+
</button>
110+
<button
111+
type="button"
112+
onClick={() => setIsOpen(false)}
113+
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:shadow-outline-indigo sm:mt-0 sm:w-auto sm:text-sm"
114+
>
115+
Cancel
116+
</button>
117+
</div>
118+
</div>
119+
</Transition.Child>
120+
</div>
121+
</div>
122+
</Dialog>
123+
</Transition>
124+
</>
125+
)
126+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React, { useState } from 'react'
2+
import { RadioGroup } from '@headlessui/react'
3+
import { classNames } from '../../src/utils/class-names'
4+
5+
export default function Home() {
6+
let access = [
7+
{
8+
id: 'access-1',
9+
name: 'Public access',
10+
description: 'This project would be available to anyone who has the link',
11+
},
12+
{
13+
id: 'access-2',
14+
name: 'Private to Project Members',
15+
description: 'Only members of this project would be able to access',
16+
},
17+
{
18+
id: 'access-3',
19+
name: 'Private to you',
20+
description: 'You are the only one able to access this project',
21+
},
22+
]
23+
let [active, setActive] = useState()
24+
25+
return (
26+
<div className="p-12 max-w-xl">
27+
<a href="/">Link before</a>
28+
<RadioGroup value={active} onChange={setActive}>
29+
<fieldset className="space-y-4">
30+
<legend>
31+
<h2 className="text-xl">Privacy setting</h2>
32+
</legend>
33+
34+
<div className="bg-white rounded-md -space-y-px">
35+
{access.map(({ id, name, description }, i) => {
36+
return (
37+
<RadioGroup.Option
38+
key={id}
39+
value={id}
40+
className={({ active }) =>
41+
classNames(
42+
// Rounded corners
43+
i === 0 && 'rounded-tl-md rounded-tr-md',
44+
access.length - 1 === i && 'rounded-bl-md rounded-br-md',
45+
46+
// Shared
47+
'relative border p-4 flex focus:outline-none',
48+
active ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200'
49+
)
50+
}
51+
>
52+
{({ active, checked }) => (
53+
<div className="flex justify-between items-center w-full">
54+
<div className="ml-3 flex flex-col cursor-pointer">
55+
<span
56+
className={classNames(
57+
'block text-sm leading-5 font-medium',
58+
active ? 'text-indigo-900' : 'text-gray-900'
59+
)}
60+
>
61+
{name}
62+
</span>
63+
<span
64+
className={classNames(
65+
'block text-sm leading-5',
66+
active ? 'text-indigo-700' : 'text-gray-500'
67+
)}
68+
>
69+
{description}
70+
</span>
71+
</div>
72+
<div>
73+
{checked && (
74+
<svg
75+
xmlns="http://www.w3.org/2000/svg"
76+
fill="none"
77+
viewBox="0 0 24 24"
78+
stroke="currentColor"
79+
className="h-5 w-5 text-indigo-500"
80+
>
81+
<path
82+
strokeLinecap="round"
83+
strokeLinejoin="round"
84+
strokeWidth={2}
85+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
86+
/>
87+
</svg>
88+
)}
89+
</div>
90+
</div>
91+
)}
92+
</RadioGroup.Option>
93+
)
94+
})}
95+
</div>
96+
</fieldset>
97+
</RadioGroup>
98+
<a href="/">Link after</a>
99+
</div>
100+
)
101+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
## Alert
2+
3+
A component for announcing information to screenreader/assistive technology users.
4+
5+
- [Installation](#installation)
6+
- [Basic example](#basic-example)
7+
- [Component API](#component-api)
8+
9+
### Installation
10+
11+
```sh
12+
# npm
13+
npm install @headlessui/react
14+
15+
# Yarn
16+
yarn add @headlessui/react
17+
```
18+
19+
### Basic example
20+
21+
```jsx
22+
<Alert>Notifications have been enabled</Alert>
23+
```
24+
25+
### Component API
26+
27+
#### Alert
28+
29+
```jsx
30+
<Alert>Notifications have been enabled</Alert>
31+
```
32+
33+
##### Props
34+
35+
| Prop | Type | Default | Description |
36+
| :----------- | :---------------------- | :------- | :------------------------------------------------------------------------------ |
37+
| `as` | String \| Component | `div` | The element or component the `Alert` should render as. |
38+
| `importance` | `polite` \| `assertive` | `polite` | The importance of the alert message when it is announced to screenreader users. |
39+
40+
| Importance | Description |
41+
| :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42+
| `polite` | Indicates that updates to the region should be presented at the next graceful opportunity, such as at the end of speaking the current sentence or when the user pauses typing. |
43+
| `assertive` | Indicates that updates to the region have the highest priority and should be presented the user immediately. |
44+
45+
Source: https://www.w3.org/TR/wai-aria-1.2/#aria-live
46+
47+
##### Render prop object
48+
49+
- None
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react'
2+
import { render } from '@testing-library/react'
3+
import { getByText } from '../../test-utils/accessibility-assertions'
4+
5+
import { Alert } from './alert'
6+
7+
describe('Rendering', () => {
8+
it('should be possible to render an Alert', () => {
9+
render(<Alert>This is an alert</Alert>)
10+
11+
expect(getByText('This is an alert')).toHaveAttribute('role', 'status')
12+
})
13+
14+
it('should be possible to render an Alert using a render prop', () => {
15+
render(<Alert>{() => 'This is an alert'}</Alert>)
16+
17+
expect(getByText('This is an alert')).toHaveAttribute('role', 'status')
18+
})
19+
20+
it('should be possible to render an Alert with an explicit level of importance (polite)', () => {
21+
render(<Alert importance="polite">This is a polite message</Alert>)
22+
23+
expect(getByText('This is a polite message')).toHaveAttribute('role', 'status')
24+
})
25+
26+
it('should be possible to render an Alert with an explicit level of importance (assertive)', () => {
27+
render(<Alert importance="assertive">This is a assertive message</Alert>)
28+
29+
expect(getByText('This is a assertive message')).toHaveAttribute('role', 'alert')
30+
})
31+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
useMemo,
3+
4+
// Types
5+
ElementType,
6+
} from 'react'
7+
8+
import { Props } from '../../types'
9+
import { render } from '../../utils/render'
10+
import { match } from '../../utils/match'
11+
12+
type Importance =
13+
/**
14+
* Indicates that updates to the region should be presented at the next
15+
* graceful opportunity, such as at the end of speaking the current sentence
16+
* or when the user pauses typing.
17+
*/
18+
| 'polite'
19+
20+
/**
21+
* Indicates that updates to the region have the highest priority and should
22+
* be presented the user immediately.
23+
*/
24+
| 'assertive'
25+
26+
// ---
27+
28+
let DEFAULT_ALERT_TAG = 'div' as const
29+
interface AlertRenderPropArg {
30+
importance: Importance
31+
}
32+
type AlertPropsWeControl = 'role'
33+
34+
export function Alert<TTag extends ElementType = typeof DEFAULT_ALERT_TAG>(
35+
props: Props<TTag, AlertRenderPropArg, AlertPropsWeControl> & {
36+
importance?: Importance
37+
}
38+
) {
39+
let { importance = 'polite', ...passThroughProps } = props
40+
let propsWeControl = match(importance, {
41+
polite: () => ({ role: 'status' }),
42+
assertive: () => ({ role: 'alert' }),
43+
})
44+
45+
let bag = useMemo<AlertRenderPropArg>(() => ({ importance }), [importance])
46+
47+
return render({ ...passThroughProps, ...propsWeControl }, bag, DEFAULT_ALERT_TAG)
48+
}

0 commit comments

Comments
 (0)