Skip to content

Commit

Permalink
Merge branch 'main' into remove-mouse-intent
Browse files Browse the repository at this point in the history
  • Loading branch information
dgreif committed Apr 15, 2021
2 parents d8a0096 + 0b56781 commit eaf89c2
Show file tree
Hide file tree
Showing 13 changed files with 224 additions and 94 deletions.
39 changes: 0 additions & 39 deletions .changeset/clever-dancers-nail.md

This file was deleted.

40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
# @primer/components

## 27.0.0

### Major Changes

- [`db478205`](https://github.com/primer/components/commit/db478205bf467a118394e0519034bb87116dc85a) [#1147](https://github.com/primer/components/pull/1147) Thanks [@colebemis](https://github.com/colebemis)! - Type definitions are now being generated by TypeScript instead of manually maintained. These new type definitions may differ from the previous type definitions and cause breaking changes. If you experience any new TypeScript errors, feel free to create an [issue](https://github.com/primer/components/issues) or reach out in Slack (#design-systems).

### Breaking changes

- The following types are no longer exported:

```
BaseProps
UseDetailsProps
AnchoredPositionHookSettings
AnchorAlignment
AnchorSide
PositionSettings
PaginationHrefBuilder
PaginationPageChangeCallback
PositionComponentProps
```

- Props are now defined with types instead of interfaces which means in some cases you may not be able to create interfaces that `extend` them. To work around this issue, you may need to convert your interfaces to types:

```diff
import {BoxProps} from '@primer/components'

- interface MyFancyBox extends BoxProps {...}
+ type MyFancyBox = BoxProps & {...}
```

- Some components now expect more specific ref types. For example:

```diff
- const ref = React.useRef<HTMLElement>(null)
+ const ref = React.useRef<HTMLButtonElement>(null)

return <Button ref={ref}>...</Button>
```

## 26.0.0

### Major Changes
Expand Down
4 changes: 2 additions & 2 deletions docs/content/focusZone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ For a more customized focus movement behavior, the consumer has the ability to s

By default, when focus enters a focus zone, the element that receives focus will be the most recently-focused element within that focus zone. If no element had previously been focused, or if that previously-focused element was removed, focus will revert to the first focusable element within the focus zone, regardless of the direction of focus movement.

Using the `focusInStrategy` option, you can change this behavior. Setting this option to `"first"` will simply cause the first focusable element in the container to be focused whenever focus enters the focus zone. Otherwise, you may provide a callback to choose a custom element to receive initial focus. One scenario where this would be useful is if you wanted to focus an item that is "selected" in a list.
Using the `focusInStrategy` option, you can change this behavior. Setting this option to `"first"` will simply cause the first focusable element in the container to be focused whenever focus enters the focus zone. Setting it to `"closest"` will cause either the first or last focusable element in the container to be focused depending on the direction of focus movement (for example, a shift+tab that brings focus to the container will cause the last focusable element to be focused, whereas a regular tab would cause the first focusable element to be focused). Otherwise, you may provide a callback to choose a custom element to receive initial focus. One scenario where this would be useful is if you wanted to focus an item that is "selected" in a list.

For more information on choosing the right focus in behavior, see [6.6 Keyboard Navigation Inside Components](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_general_within) from the ARIA Authoring Practices document.

Expand Down Expand Up @@ -81,7 +81,7 @@ The `focusZone` function takes the following arguments.
| :- | :- | :-: | :- |
| bindKeys | `FocusKeys` (numeric enum) | `FocusKeys.ArrowVertical` &#124; `FocusKeys.HomeAndEnd` | Bit flags that identify keys that will move focus around the focus zone. Each available key either moves focus to the "next", "previous", "start", or "end" element, so it is best to only bind the keys that make sense to move focus in your UI. Use the `FocusKeys` object to discover supported keys (listed in the "Supported keys" section above). <br /><br />Use the bitwise "OR" operator (&#124;) to combine key types. For example, `FocusKeys.WASD` &#124; `FocusKeys.HJKL` represents all of W, A, S, D, H, J, K, and L.<br /><br />The default for this setting is `FocusKeys.ArrowVertical` &#124; `FocusKeys.HomeAndEnd`, unless `getNextFocusable` is provided, in which case `FocusKeys.ArrowAll` &#124; `FocusKeys.HomeAndEnd` is used as the default. |
| focusOutBehavior | `"stop"` &#124; `"wrap"` | `"stop"` | Choose the behavior applied in cases where focus is currently at either the first or last element of the container. `"stop"` - do nothing and keep focus where it was; `"wrap"` - wrap focus around to the first element from the last, or the last element from the first |
| focusInStrategy | `"first"` &#124; `"previous"` &#124; `Function` | `"previous"` | This option allows customization of the behavior that determines which of the focusable elements should be focused when focus enters the container via the Tab key.<br /><br />When set to `"first"`, whenever focus enters the container via Tab, we will focus the first focusable element. When set to `"previous"`, the most recently focused element will be focused (fallback to first if there was no previous).<br /><br />If a function is provided, this function should return the `HTMLElement` intended to receive focus. This is useful if you want to focus the currently "selected" item or element. |
| focusInStrategy | `"first"` &#124; `"closest"` &#124; `"previous"` &#124; `Function` | `"previous"` | This option allows customization of the behavior that determines which of the focusable elements should be focused when focus enters the container via the Tab key.<br /><br />When set to `"first"`, whenever focus enters the container via Tab, we will focus the first focusable element. When set to `"previous"`, the most recently focused element will be focused (fallback to first if there was no previous).<br /><br />The "closest" strategy works like "first", except either the first or the last element of the container will be focused, depending on the direction from which focus comes.<br /><br />If a function is provided, this function should return the `HTMLElement` intended to receive focus. This is useful if you want to focus the currently "selected" item or element. |
| getNextFocusable | `Function` | | This is a callback used to customize the next element to focus when a bound key is pressed. The function takes 3 arguments: `direction` (`"previous"`, `"next"`, `"start"`, or `"end"`), `from` (Element or `undefined`), and `event` (KeyboardEvent). The function should return the next element to focus, or `undefined`. If `undefined` is returned, the regular algorithm to select the next element to focus will be used. |
| focusableElementFilter | `Function` | | This is a callback used to cull focusable elements from participating in the focus zone. |
| abortSignal | `AbortSignal` | | If passed, the focus zone will be deactivated and all event listeners removed when this signal is aborted. If not passed, an `AbortSignal` will be returned by the `focusZone` function. |
Expand Down
29 changes: 22 additions & 7 deletions docs/content/useOnEscapePress.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@
title: useOnEscapePress
---

`useOnEscapePress` is a simple utility Hook that calls a user provided function when the `Escape` key is pressed.
`useOnEscapePress` is a simple utility Hook that calls a user-provided function when the `Escape` key is pressed. The hook sets up `keydown` event listener on `window.document` and executes the user-provided function if these conditions are met:

1. The Escape key was pressed
2. The `preventDefault` method has not yet been called on the event object.

Furthermore, unlike the normal behavior for multiple event listeners existing on the same DOM Node, if multiple `useOnEscapePress` hooks are active simultaneously, the callbacks will occur in reverse order. In other words, if a parent component and a child component both call `useOnEscapePress`, when the user presses Escape, the child component's callback will execute, followed by the parent's callback. Each callback has the chance to call `.preventDefault()` on the event to prevent further callbacks.

### Dependencies

Similar to `useCallback`, `useOnEscapePress` takes a `React.DependencyList` as its second argument. These are the dependencies used to memoize the callback. Failing to provide the correct dependency list can result in degraded performance. If this argument is omitted, we will assume that the callback is already memoized. In the example below, that memoization occurs in `DemoComponent` with a call to `React.useCallback`, so `OverlayDemo` does not need to pass a dependency list.

### Usage

```javascript live noinline
const OverlayDemo = ({onEscape, children}) => {
useOnEscapePress({onEscape})
useOnEscapePress(onEscape)
return (
<Box height="200px">
{children}
Expand All @@ -18,11 +27,17 @@ const OverlayDemo = ({onEscape, children}) => {

function DemoComponent() {
const [isOpen, setIsOpen] = React.useState(false)
const toggleOverlay = React.useCallback(() => {
setIsOpen(!isOpen)
})
const closeOverlay = React.useCallback(() => {
setIsOpen(false)
})
return (
<>
<Button onClick={() => setIsOpen(!isOpen)}>toggle</Button>
<Button onClick={toggleOverlay}>toggle</Button>
{isOpen &&
<OverlayDemo onEscape={() => setIsOpen(false)}>
<OverlayDemo onEscape={closeOverlay}>
<Button>Button One</Button>
<Button>Button Two</Button>
</OverlayDemo>}
Expand All @@ -33,9 +48,9 @@ function DemoComponent() {
render(<DemoComponent/>)
```


#### useOnEscapePress settings
#### useOnEscapePress

| Name | Type | Default | Description |
| :- | :- | :-: | :- |
| onEscape | `function` | | Function to call when user presses the Escape key |
| onEscape | `(event: KeyboardEvent) => void` | | Function to call when user presses the Escape key |
| callbackDependencies | `React.DependencyList` | | Array of dependencies for memoizing the given callback |
2 changes: 1 addition & 1 deletion docs/content/useOverlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ render(<DemoComponent/>)
```


#### useOnEscapePress settings
#### UseOverlaySettings

| Name | Type | Required | Description |
| :- | :- | :-: | :- |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@primer/components",
"version": "26.0.0",
"version": "27.0.0",
"description": "Primer react components",
"main": "lib/index.js",
"module": "lib-esm/index.js",
Expand Down
61 changes: 50 additions & 11 deletions src/__tests__/behaviors/focusZone.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import React from 'react'
import {fireEvent, render} from '@testing-library/react'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {FocusKeys, focusZone} from '../../behaviors/focusZone'

Expand Down Expand Up @@ -214,9 +214,6 @@ it('Should focus-in to the most recently-focused element', () => {
userEvent.type(firstButton, '{arrowdown}')
expect(document.activeElement).toEqual(secondButton)

// make sure focusin is fired because JSDOM
fireEvent(secondButton, new FocusEvent('focusin', {bubbles: true}))

outsideButton.focus()
userEvent.tab()

Expand Down Expand Up @@ -250,19 +247,62 @@ it('Should focus-in to the first element when focusInStrategy is "first"', () =>
userEvent.type(firstButton, '{arrowdown}')
expect(document.activeElement).toEqual(secondButton)

// make sure focusin is fired because JSDOM
fireEvent(secondButton, new FocusEvent('focusin', {bubbles: true, relatedTarget: firstButton}))

outsideButton.focus()
userEvent.tab()

// fire focusin on secondButton, since that actually has tabindex=0. The behavior will then it to the first.
fireEvent(secondButton, new FocusEvent('focusin', {bubbles: true, relatedTarget: outsideButton}))
expect(document.activeElement).toEqual(firstButton)

controller.abort()
})

it('Should focus-in to the closest element when focusInStrategy is "closest"', () => {
const {container} = render(
<div>
<button tabIndex={0} id="outsideBefore">
Bad Apple
</button>
<div id="focusZone">
<button id="apple" tabIndex={0}>
Apple
</button>
<button id="banana" tabIndex={0}>
Banana
</button>
<button id="cantaloupe" tabIndex={0}>
Cantaloupe
</button>
</div>
<button tabIndex={0} id="outsideAfter">
Good Apple
</button>
</div>
)

const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
const outsideBefore = container.querySelector<HTMLElement>('#outsideBefore')!
const outsideAfter = container.querySelector<HTMLElement>('#outsideAfter')!
const [firstButton, secondButton, thirdButton] = focusZoneContainer.querySelectorAll('button')!
const controller = focusZone(focusZoneContainer, {focusInStrategy: 'closest'})

firstButton.focus()
expect(document.activeElement).toEqual(firstButton)

userEvent.type(firstButton, '{arrowdown}')
expect(document.activeElement).toEqual(secondButton)

outsideBefore.focus()
userEvent.tab()

expect(document.activeElement).toEqual(firstButton)

outsideAfter.focus()
userEvent.tab({shift: true})

expect(document.activeElement).toEqual(thirdButton)

controller.abort()
})

it('Should call the custom focusInStrategy callback', () => {
const {container} = render(
<div>
Expand All @@ -279,13 +319,12 @@ it('Should call the custom focusInStrategy callback', () => {

const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
const outsideButton = container.querySelector<HTMLElement>('#outside')!
const [firstButton, secondButton] = focusZoneContainer.querySelectorAll('button')!
const [, secondButton] = focusZoneContainer.querySelectorAll('button')!
const focusInCallback = jest.fn().mockReturnValue(secondButton)
const controller = focusZone(focusZoneContainer, {focusInStrategy: focusInCallback})

outsideButton.focus()
userEvent.tab()
fireEvent(firstButton, new FocusEvent('focusin', {bubbles: true, relatedTarget: outsideButton}))
expect(focusInCallback).toHaveBeenCalledWith<[HTMLElement]>(outsideButton)
expect(document.activeElement).toEqual(secondButton)

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/hooks/useOnEscapePress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from 'react'
it('should call function when user presses escape', () => {
const functionToCall = jest.fn()
const Component = () => {
useOnEscapePress({onEscape: functionToCall})
useOnEscapePress(functionToCall)
return <div>content</div>
}

Expand Down
44 changes: 34 additions & 10 deletions src/behaviors/focusZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ export interface FocusZoneSettings {
* first focusable element. When set to "previous", the most recently focused element
* will be focused (fallback to first if there was no previous).
*
* The "closest" strategy works like "first", except either the first or the last element
* of the container will be focused, depending on the direction from which focus comes.
*
* If a function is provided, this function should return the HTMLElement intended
* to receive focus. This is useful if you want to focus the currently "selected"
* item or element.
Expand All @@ -207,7 +210,7 @@ export interface FocusZoneSettings {
*
* For more information, @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_general_within
*/
focusInStrategy?: 'first' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined)
focusInStrategy?: 'first' | 'closest' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined)
}

function getDirection(keyboardEvent: KeyboardEvent) {
Expand Down Expand Up @@ -503,16 +506,20 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
// Set tab indexes and internal state based on the focus handling strategy
if (focusInStrategy === 'previous') {
updateTabIndex(currentFocusedElement, event.target)
} else if (focusInStrategy === 'first') {
if (
event.relatedTarget instanceof Element &&
!container.contains(event.relatedTarget) &&
event.target !== focusableElements[0]
) {
} else if (focusInStrategy === 'closest' || focusInStrategy === 'first') {
if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
// Regardless of the previously focused element, if we're coming from outside the
// container, put focus onto the first element.
currentFocusedIndex = 0
focusableElements[0].focus()
// container, put focus onto the first encountered element (from above, it's The
// first element of the container; from below, it's the last). If the
// focusInStrategy is set to "first", lastKeyboardFocusDirection will always
// be undefined.
if (lastKeyboardFocusDirection === 'previous') {
currentFocusedIndex = focusableElements.length - 1
} else {
currentFocusedIndex = 0
}
focusableElements[currentFocusedIndex].focus()
return
} else {
updateTabIndex(currentFocusedElement, event.target)
}
Expand Down Expand Up @@ -540,13 +547,29 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
notifyActiveElement(event.target)
currentFocusedElement = event.target
}
lastKeyboardFocusDirection = undefined
},
{signal}
)
}

const keyboardEventRecipient = activeDescendantControl ?? container

// If the strategy is "closest", we need to capture the direction that the user
// is trying to move focus before our focusin handler is executed.
let lastKeyboardFocusDirection: Direction | undefined = undefined
if (focusInStrategy === 'closest') {
document.addEventListener(
'keydown',
event => {
if (event.key === 'Tab') {
lastKeyboardFocusDirection = getDirection(event)
}
},
{signal, capture: true}
)
}

// "keydown" is the event that triggers DOM focus change, so that is what we use here
keyboardEventRecipient.addEventListener(
'keydown',
Expand Down Expand Up @@ -611,6 +634,7 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
if (activeDescendantControl) {
setActiveDescendant(currentFocusedElement, nextElementToFocus)
} else {
lastKeyboardFocusDirection = direction
nextElementToFocus.focus()
}
}
Expand Down
Loading

0 comments on commit eaf89c2

Please sign in to comment.