Skip to content

Commit eaf89c2

Browse files
authored
Merge branch 'main' into remove-mouse-intent
2 parents d8a0096 + 0b56781 commit eaf89c2

File tree

13 files changed

+224
-94
lines changed

13 files changed

+224
-94
lines changed

.changeset/clever-dancers-nail.md

Lines changed: 0 additions & 39 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
# @primer/components
22

3+
## 27.0.0
4+
5+
### Major Changes
6+
7+
- [`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).
8+
9+
### Breaking changes
10+
11+
- The following types are no longer exported:
12+
13+
```
14+
BaseProps
15+
UseDetailsProps
16+
AnchoredPositionHookSettings
17+
AnchorAlignment
18+
AnchorSide
19+
PositionSettings
20+
PaginationHrefBuilder
21+
PaginationPageChangeCallback
22+
PositionComponentProps
23+
```
24+
25+
- 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:
26+
27+
```diff
28+
import {BoxProps} from '@primer/components'
29+
30+
- interface MyFancyBox extends BoxProps {...}
31+
+ type MyFancyBox = BoxProps & {...}
32+
```
33+
34+
- Some components now expect more specific ref types. For example:
35+
36+
```diff
37+
- const ref = React.useRef<HTMLElement>(null)
38+
+ const ref = React.useRef<HTMLButtonElement>(null)
39+
40+
return <Button ref={ref}>...</Button>
41+
```
42+
343
## 26.0.0
444
545
### Major Changes

docs/content/focusZone.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ For a more customized focus movement behavior, the consumer has the ability to s
2424

2525
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.
2626

27-
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.
27+
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.
2828

2929
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.
3030

@@ -81,7 +81,7 @@ The `focusZone` function takes the following arguments.
8181
| :- | :- | :-: | :- |
8282
| 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. |
8383
| 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 |
84-
| 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. |
84+
| 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. |
8585
| 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. |
8686
| focusableElementFilter | `Function` | | This is a callback used to cull focusable elements from participating in the focus zone. |
8787
| 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. |

docs/content/useOnEscapePress.mdx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@
22
title: useOnEscapePress
33
---
44

5-
`useOnEscapePress` is a simple utility Hook that calls a user provided function when the `Escape` key is pressed.
5+
`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:
6+
7+
1. The Escape key was pressed
8+
2. The `preventDefault` method has not yet been called on the event object.
9+
10+
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.
11+
12+
### Dependencies
13+
14+
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.
615

716
### Usage
817

918
```javascript live noinline
1019
const OverlayDemo = ({onEscape, children}) => {
11-
useOnEscapePress({onEscape})
20+
useOnEscapePress(onEscape)
1221
return (
1322
<Box height="200px">
1423
{children}
@@ -18,11 +27,17 @@ const OverlayDemo = ({onEscape, children}) => {
1827

1928
function DemoComponent() {
2029
const [isOpen, setIsOpen] = React.useState(false)
30+
const toggleOverlay = React.useCallback(() => {
31+
setIsOpen(!isOpen)
32+
})
33+
const closeOverlay = React.useCallback(() => {
34+
setIsOpen(false)
35+
})
2136
return (
2237
<>
23-
<Button onClick={() => setIsOpen(!isOpen)}>toggle</Button>
38+
<Button onClick={toggleOverlay}>toggle</Button>
2439
{isOpen &&
25-
<OverlayDemo onEscape={() => setIsOpen(false)}>
40+
<OverlayDemo onEscape={closeOverlay}>
2641
<Button>Button One</Button>
2742
<Button>Button Two</Button>
2843
</OverlayDemo>}
@@ -33,9 +48,9 @@ function DemoComponent() {
3348
render(<DemoComponent/>)
3449
```
3550

36-
37-
#### useOnEscapePress settings
51+
#### useOnEscapePress
3852

3953
| Name | Type | Default | Description |
4054
| :- | :- | :-: | :- |
41-
| onEscape | `function` | | Function to call when user presses the Escape key |
55+
| onEscape | `(event: KeyboardEvent) => void` | | Function to call when user presses the Escape key |
56+
| callbackDependencies | `React.DependencyList` | | Array of dependencies for memoizing the given callback |

docs/content/useOverlay.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ render(<DemoComponent/>)
5151
```
5252

5353

54-
#### useOnEscapePress settings
54+
#### UseOverlaySettings
5555

5656
| Name | Type | Required | Description |
5757
| :- | :- | :-: | :- |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@primer/components",
3-
"version": "26.0.0",
3+
"version": "27.0.0",
44
"description": "Primer react components",
55
"main": "lib/index.js",
66
"module": "lib-esm/index.js",

src/__tests__/behaviors/focusZone.tsx

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
/* eslint-disable @typescript-eslint/no-non-null-assertion */
33
import React from 'react'
4-
import {fireEvent, render} from '@testing-library/react'
4+
import {render} from '@testing-library/react'
55
import userEvent from '@testing-library/user-event'
66
import {FocusKeys, focusZone} from '../../behaviors/focusZone'
77

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

217-
// make sure focusin is fired because JSDOM
218-
fireEvent(secondButton, new FocusEvent('focusin', {bubbles: true}))
219-
220217
outsideButton.focus()
221218
userEvent.tab()
222219

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

253-
// make sure focusin is fired because JSDOM
254-
fireEvent(secondButton, new FocusEvent('focusin', {bubbles: true, relatedTarget: firstButton}))
255-
256250
outsideButton.focus()
257251
userEvent.tab()
258252

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

263255
controller.abort()
264256
})
265257

258+
it('Should focus-in to the closest element when focusInStrategy is "closest"', () => {
259+
const {container} = render(
260+
<div>
261+
<button tabIndex={0} id="outsideBefore">
262+
Bad Apple
263+
</button>
264+
<div id="focusZone">
265+
<button id="apple" tabIndex={0}>
266+
Apple
267+
</button>
268+
<button id="banana" tabIndex={0}>
269+
Banana
270+
</button>
271+
<button id="cantaloupe" tabIndex={0}>
272+
Cantaloupe
273+
</button>
274+
</div>
275+
<button tabIndex={0} id="outsideAfter">
276+
Good Apple
277+
</button>
278+
</div>
279+
)
280+
281+
const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
282+
const outsideBefore = container.querySelector<HTMLElement>('#outsideBefore')!
283+
const outsideAfter = container.querySelector<HTMLElement>('#outsideAfter')!
284+
const [firstButton, secondButton, thirdButton] = focusZoneContainer.querySelectorAll('button')!
285+
const controller = focusZone(focusZoneContainer, {focusInStrategy: 'closest'})
286+
287+
firstButton.focus()
288+
expect(document.activeElement).toEqual(firstButton)
289+
290+
userEvent.type(firstButton, '{arrowdown}')
291+
expect(document.activeElement).toEqual(secondButton)
292+
293+
outsideBefore.focus()
294+
userEvent.tab()
295+
296+
expect(document.activeElement).toEqual(firstButton)
297+
298+
outsideAfter.focus()
299+
userEvent.tab({shift: true})
300+
301+
expect(document.activeElement).toEqual(thirdButton)
302+
303+
controller.abort()
304+
})
305+
266306
it('Should call the custom focusInStrategy callback', () => {
267307
const {container} = render(
268308
<div>
@@ -279,13 +319,12 @@ it('Should call the custom focusInStrategy callback', () => {
279319

280320
const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
281321
const outsideButton = container.querySelector<HTMLElement>('#outside')!
282-
const [firstButton, secondButton] = focusZoneContainer.querySelectorAll('button')!
322+
const [, secondButton] = focusZoneContainer.querySelectorAll('button')!
283323
const focusInCallback = jest.fn().mockReturnValue(secondButton)
284324
const controller = focusZone(focusZoneContainer, {focusInStrategy: focusInCallback})
285325

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

src/__tests__/hooks/useOnEscapePress.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import React from 'react'
55
it('should call function when user presses escape', () => {
66
const functionToCall = jest.fn()
77
const Component = () => {
8-
useOnEscapePress({onEscape: functionToCall})
8+
useOnEscapePress(functionToCall)
99
return <div>content</div>
1010
}
1111

src/behaviors/focusZone.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ export interface FocusZoneSettings {
199199
* first focusable element. When set to "previous", the most recently focused element
200200
* will be focused (fallback to first if there was no previous).
201201
*
202+
* The "closest" strategy works like "first", except either the first or the last element
203+
* of the container will be focused, depending on the direction from which focus comes.
204+
*
202205
* If a function is provided, this function should return the HTMLElement intended
203206
* to receive focus. This is useful if you want to focus the currently "selected"
204207
* item or element.
@@ -207,7 +210,7 @@ export interface FocusZoneSettings {
207210
*
208211
* For more information, @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_general_within
209212
*/
210-
focusInStrategy?: 'first' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined)
213+
focusInStrategy?: 'first' | 'closest' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined)
211214
}
212215

213216
function getDirection(keyboardEvent: KeyboardEvent) {
@@ -503,16 +506,20 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
503506
// Set tab indexes and internal state based on the focus handling strategy
504507
if (focusInStrategy === 'previous') {
505508
updateTabIndex(currentFocusedElement, event.target)
506-
} else if (focusInStrategy === 'first') {
507-
if (
508-
event.relatedTarget instanceof Element &&
509-
!container.contains(event.relatedTarget) &&
510-
event.target !== focusableElements[0]
511-
) {
509+
} else if (focusInStrategy === 'closest' || focusInStrategy === 'first') {
510+
if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
512511
// Regardless of the previously focused element, if we're coming from outside the
513-
// container, put focus onto the first element.
514-
currentFocusedIndex = 0
515-
focusableElements[0].focus()
512+
// container, put focus onto the first encountered element (from above, it's The
513+
// first element of the container; from below, it's the last). If the
514+
// focusInStrategy is set to "first", lastKeyboardFocusDirection will always
515+
// be undefined.
516+
if (lastKeyboardFocusDirection === 'previous') {
517+
currentFocusedIndex = focusableElements.length - 1
518+
} else {
519+
currentFocusedIndex = 0
520+
}
521+
focusableElements[currentFocusedIndex].focus()
522+
return
516523
} else {
517524
updateTabIndex(currentFocusedElement, event.target)
518525
}
@@ -540,13 +547,29 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
540547
notifyActiveElement(event.target)
541548
currentFocusedElement = event.target
542549
}
550+
lastKeyboardFocusDirection = undefined
543551
},
544552
{signal}
545553
)
546554
}
547555

548556
const keyboardEventRecipient = activeDescendantControl ?? container
549557

558+
// If the strategy is "closest", we need to capture the direction that the user
559+
// is trying to move focus before our focusin handler is executed.
560+
let lastKeyboardFocusDirection: Direction | undefined = undefined
561+
if (focusInStrategy === 'closest') {
562+
document.addEventListener(
563+
'keydown',
564+
event => {
565+
if (event.key === 'Tab') {
566+
lastKeyboardFocusDirection = getDirection(event)
567+
}
568+
},
569+
{signal, capture: true}
570+
)
571+
}
572+
550573
// "keydown" is the event that triggers DOM focus change, so that is what we use here
551574
keyboardEventRecipient.addEventListener(
552575
'keydown',
@@ -611,6 +634,7 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
611634
if (activeDescendantControl) {
612635
setActiveDescendant(currentFocusedElement, nextElementToFocus)
613636
} else {
637+
lastKeyboardFocusDirection = direction
614638
nextElementToFocus.focus()
615639
}
616640
}

0 commit comments

Comments
 (0)