Skip to content

Commit

Permalink
Add Alert & RadioGroup components (#274)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait authored Mar 15, 2021
1 parent 6faff6b commit b99304a
Show file tree
Hide file tree
Showing 43 changed files with 5,230 additions and 3,080 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `FocusTrap` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
- Add `Popover` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
- All components that accept a `className`, can now also receive a function with the renderProp argument ([#257](https://github.com/tailwindlabs/headlessui/pull/257))
- Add `RadioGroup` component ([#274](https://github.com/tailwindlabs/headlessui/pull/274))
- Add `Alert` component ([#274](https://github.com/tailwindlabs/headlessui/pull/274))

## [Unreleased - Vue]

Expand Down
1,838 changes: 11 additions & 1,827 deletions packages/@headlessui-react/README.md

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions packages/@headlessui-react/pages/dialog/dialog-with-alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { useState, Fragment } from 'react'
import { Dialog, Portal, Transition, Alert } from '@headlessui/react'

export default function Home() {
let [isOpen, setIsOpen] = useState(false)
let [deleted, setDeleted] = useState(false)

function notifyUser() {
setDeleted(true)
setTimeout(() => {
setDeleted(false)
}, 10 * 1000)
}

return (
<>
<button
type="button"
onClick={() => setIsOpen(v => !v)}
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"
>
Toggle!
</button>

<Transition show={isOpen} as={Fragment}>
<Dialog open={isOpen} onClose={setIsOpen} static>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 transition-opacity">
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
</Dialog.Overlay>
</Transition.Child>

<Transition.Child
enter="ease-out transform duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in transform duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{/* This element is to trick the browser into centering the modal contents. */}
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<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">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<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">
{/* Heroicon name: exclamation */}
<svg
className="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg leading-6 font-medium text-gray-900"
>
Deactivate account
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data will
be permanently removed. This action cannot be undone.
</p>
{deleted && (
<Portal>
<div className="fixed z-50 bg-blue-400 text-white font-bold text-lg p-4 py-6 top-0 left-0 right-0">
<Alert>I am now deleted!</Alert>
</div>
</Portal>
)}
<div className="relative inline-block text-left mt-10"></div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={() => notifyUser()}
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"
>
Deactivate
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
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"
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</>
)
}
101 changes: 101 additions & 0 deletions packages/@headlessui-react/pages/radio-group/radio-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { useState } from 'react'
import { RadioGroup } from '@headlessui/react'
import { classNames } from '../../src/utils/class-names'

export default function Home() {
let access = [
{
id: 'access-1',
name: 'Public access',
description: 'This project would be available to anyone who has the link',
},
{
id: 'access-2',
name: 'Private to Project Members',
description: 'Only members of this project would be able to access',
},
{
id: 'access-3',
name: 'Private to you',
description: 'You are the only one able to access this project',
},
]
let [active, setActive] = useState()

return (
<div className="p-12 max-w-xl">
<a href="/">Link before</a>
<RadioGroup value={active} onChange={setActive}>
<fieldset className="space-y-4">
<legend>
<h2 className="text-xl">Privacy setting</h2>
</legend>

<div className="bg-white rounded-md -space-y-px">
{access.map(({ id, name, description }, i) => {
return (
<RadioGroup.Option
key={id}
value={id}
className={({ active }) =>
classNames(
// Rounded corners
i === 0 && 'rounded-tl-md rounded-tr-md',
access.length - 1 === i && 'rounded-bl-md rounded-br-md',

// Shared
'relative border p-4 flex focus:outline-none',
active ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200'
)
}
>
{({ active, checked }) => (
<div className="flex justify-between items-center w-full">
<div className="ml-3 flex flex-col cursor-pointer">
<span
className={classNames(
'block text-sm leading-5 font-medium',
active ? 'text-indigo-900' : 'text-gray-900'
)}
>
{name}
</span>
<span
className={classNames(
'block text-sm leading-5',
active ? 'text-indigo-700' : 'text-gray-500'
)}
>
{description}
</span>
</div>
<div>
{checked && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="h-5 w-5 text-indigo-500"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</div>
</div>
)}
</RadioGroup.Option>
)
})}
</div>
</fieldset>
</RadioGroup>
<a href="/">Link after</a>
</div>
)
}
49 changes: 49 additions & 0 deletions packages/@headlessui-react/src/components/alert/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Alert

A component for announcing information to screenreader/assistive technology users.

- [Installation](#installation)
- [Basic example](#basic-example)
- [Component API](#component-api)

### Installation

```sh
# npm
npm install @headlessui/react

# Yarn
yarn add @headlessui/react
```

### Basic example

```jsx
<Alert>Notifications have been enabled</Alert>
```

### Component API

#### Alert

```jsx
<Alert>Notifications have been enabled</Alert>
```

##### Props

| Prop | Type | Default | Description |
| :----------- | :---------------------- | :------- | :------------------------------------------------------------------------------ |
| `as` | String \| Component | `div` | The element or component the `Alert` should render as. |
| `importance` | `polite` \| `assertive` | `polite` | The importance of the alert message when it is announced to screenreader users. |

| Importance | Description |
| :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `assertive` | Indicates that updates to the region have the highest priority and should be presented the user immediately. |

Source: https://www.w3.org/TR/wai-aria-1.2/#aria-live

##### Render prop object

- None
31 changes: 31 additions & 0 deletions packages/@headlessui-react/src/components/alert/alert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react'
import { render } from '@testing-library/react'
import { getByText } from '../../test-utils/accessibility-assertions'

import { Alert } from './alert'

describe('Rendering', () => {
it('should be possible to render an Alert', () => {
render(<Alert>This is an alert</Alert>)

expect(getByText('This is an alert')).toHaveAttribute('role', 'status')
})

it('should be possible to render an Alert using a render prop', () => {
render(<Alert>{() => 'This is an alert'}</Alert>)

expect(getByText('This is an alert')).toHaveAttribute('role', 'status')
})

it('should be possible to render an Alert with an explicit level of importance (polite)', () => {
render(<Alert importance="polite">This is a polite message</Alert>)

expect(getByText('This is a polite message')).toHaveAttribute('role', 'status')
})

it('should be possible to render an Alert with an explicit level of importance (assertive)', () => {
render(<Alert importance="assertive">This is a assertive message</Alert>)

expect(getByText('This is a assertive message')).toHaveAttribute('role', 'alert')
})
})
48 changes: 48 additions & 0 deletions packages/@headlessui-react/src/components/alert/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
useMemo,

// Types
ElementType,
} from 'react'

import { Props } from '../../types'
import { render } from '../../utils/render'
import { match } from '../../utils/match'

type Importance =
/**
* 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.
*/
| 'polite'

/**
* Indicates that updates to the region have the highest priority and should
* be presented the user immediately.
*/
| 'assertive'

// ---

let DEFAULT_ALERT_TAG = 'div' as const
interface AlertRenderPropArg {
importance: Importance
}
type AlertPropsWeControl = 'role'

export function Alert<TTag extends ElementType = typeof DEFAULT_ALERT_TAG>(
props: Props<TTag, AlertRenderPropArg, AlertPropsWeControl> & {
importance?: Importance
}
) {
let { importance = 'polite', ...passThroughProps } = props
let propsWeControl = match(importance, {
polite: () => ({ role: 'status' }),
assertive: () => ({ role: 'alert' }),
})

let bag = useMemo<AlertRenderPropArg>(() => ({ importance }), [importance])

return render({ ...passThroughProps, ...propsWeControl }, bag, DEFAULT_ALERT_TAG)
}
Loading

0 comments on commit b99304a

Please sign in to comment.