Skip to content
This repository was archived by the owner on Oct 1, 2024. It is now read-only.

Commit e787b55

Browse files
author
george
committed
proof edit javascript.md, add focus trap error message tests
1 parent 12a9a65 commit e787b55

File tree

3 files changed

+140
-74
lines changed

3 files changed

+140
-74
lines changed

app/docs/javascript.md

Lines changed: 82 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,44 @@
1-
The API for Undernet's scripts is straightforward. The main rule of thumb is to use the scripts when you know the DOM is fully rendered.
1+
The JS API for Undernet is fairly straightforward. The main rule of thumb is to only use the JS when you know the DOM is fully parsed and ready.
22

33
Enabling Undernet, including all its component plugins, is as easy as this:
44

55
```html
6-
<script src="path/to/undernet.js"></script>
7-
<script>
8-
// Undernet will be attached to the `window`
9-
if (document) document.addEventListener("DOMContentLoaded", Undernet.start)
10-
</script>
6+
<body>
7+
<!-- at the end of the body tag -->
8+
<script src="path/to/undernet.min.js"></script>
9+
<script>
10+
// Undernet will be attached to the `window`
11+
if (document) document.addEventListener("DOMContentLoaded", Undernet.start)
12+
</script>
13+
</body>
1114
```
1215

1316
## Core API
1417

15-
There are a few ways to use Undernet. The most direct way is using the `start` and `stop` methods off the default `Undernet` object:
18+
You can enable and disable all components on the global `Undernet` object using the `start` and `stop` methods.
1619

1720
### start
1821

1922
```js
20-
Undernet.start(scopeString, enableFocusRing)
23+
Undernet.start(scopeString, useFocusRing)
2124
```
2225

2326
#### `scopeString` (string)
2427

2528
Default: `undefined`
2629

27-
Limits Undernet's initialization to a specific DOM with selector string `scopeString`. E.g., `#my-element-id`.
30+
Limits Undernet's initialization to a specific DOM with selector string `scopeString`. E.g., `#my-element-id`. Scroll down to learn more.
2831

29-
#### `enableFocusRing` (boolean)
32+
#### `useFocusRing` (boolean)
3033

3134
Default: `false`
3235

33-
Enables a utility which adds a distinct focus ring on elements focused while using a keyboard.
36+
Enables a utility which adds a distinct focus ring on elements focused while using a keyboard. Scroll down to learn more.
3437

3538
### stop
3639

3740
```js
38-
Undernet.stop(scopeString, enableFocusRing)
41+
Undernet.stop(scopeString, useFocusRing)
3942
```
4043

4144
#### `scopeString` (string)
@@ -44,37 +47,57 @@ Default: `undefined`
4447

4548
Runs a teardown of components previously initialized via `start(scopeString)`.
4649

47-
#### `enableFocusRing` (boolean)
50+
#### `useFocusRing` (boolean)
4851

4952
Default: `false`
5053

5154
Disables the focus ring utility.
5255

53-
## Using Modules
56+
## Individual Components
57+
58+
You can use the same API above to enable or disable individual components, as well. The main difference is there isn't a second `useFocusRing` parameter.
5459

55-
If you use npm, the default export is the `Undernet` object. Importing this gives you everything, including all JS components and utilities.
60+
### start
5661

5762
```js
58-
import Undernet from "undernet"
63+
Undernet.Modals.start()
64+
Undernet.Accordions.start("#wrapper-element")
65+
// or, if you're using named imports via npm:
66+
Modals.start()
67+
Accordions.start("#wrapper-element")
5968
```
6069

61-
You can also do a named import of just one component. Bonus: it's tree-shakable if you use tools which enable it (webpack and rollup both offer it out of the box).
70+
### stop
6271

6372
```js
64-
import { Modals, createFocusRing } from "undernet"
73+
Undernet.Modals.stop()
74+
Undernet.Accordions.stop("#wrapper-element")
75+
// or, if you're using named imports via npm:
76+
Modals.stop()
77+
Accordions.stop("#wrapper-element")
6578
```
6679

67-
## Scope
80+
## Using Modules
6881

69-
Undernet is a global framework by default. This means it will capture the entire document when searching for its component instances. Sadly that won't mesh well when you're using UI frameworks like React, for example, where React components could be tapping into Undernet with redundancy. If you do this in the ways described in the sections above, you'll inadvertently reset Undernet's internal trackers, resulting in components being changed outside the scope of your given React component.
82+
If you use npm, the default export is the `Undernet` object, whose API is the same as above.
7083

71-
The solution? **Force initialization to a DOM fragment.** You can achieve this by passing a selector string to the `start` and `stop` methods of Undernet or one of its components. The selector string will be queried and Undernet will limit its search to within that element.
84+
```js
85+
import Undernet from "undernet"
86+
Undernet.start()
87+
```
7288

73-
Let's look at some examples, using React with hooks to demonstrate how scope works.
89+
You can also do a named import of just one component or utility. Bonus: it's tree-shakable if you use tools which enable the feature (webpack and rollup, for example).
7490

75-
---
91+
```js
92+
import { Modals } from "undernet"
93+
Modals.start()
94+
```
7695

77-
As described, the DOM must be ready before Undernet can run. Run `.start` in a `useEffect` block with no dependencies (empty array) so it only runs once. Likewise, when the component(s) are about to removed from the DOM, stop Undernet by returning a function which calls `.stop`. Use the outer-most element's id as your scope string.
96+
## Scope
97+
98+
By default, the `start` and `stop` methods will search the entire DOM to enable/disable components. This is undesirable in frameworks like React, which are fragment-based. To work around this issue, you can pass a selector string which will keep track of Undernet only within the scope specified.
99+
100+
As a practical but simple example, `start` Undernet or a single component when the React component is mounted, and then `stop` if the React component will be unmounted. Use the ID selector (or class, attribute, etc) of the outermost element for the scope:
78101

79102
```js
80103
export default function Sidebar(props) {
@@ -88,30 +111,31 @@ export default function Sidebar(props) {
88111
}
89112
```
90113

91-
Now all Collapsibles used are scoped to the markup defined in `<Sidebar />`.
114+
Now all Collapsibles used are scoped to the `#sidebar-wrapper` element!
92115

93-
NOTE: Don't initialize an Undernet scope within a child React component. This will duplicate events and cause unexpected behavior.
116+
NOTE: Be careful about using Undernet this way if you have child components; calling Undernet in a child will duplicate events and cause bugs.
94117

95118
### Handling DOM State
96119

97-
If you're specifically removing nodes from the DOM or virtual DOM, you'll need to be careful. Undernet isn't smart enough to know that your DOM changed, but luckily most UI frameworks provide lifecycle methods that tell us the DOM is rendered or about to re-render, so we can piggy-back off that!
120+
If you're removing/adding nodes from/to the DOM, you'll need to be careful. Undernet isn't smart enough to know that your DOM changed. Luckily most UI frameworks provide lifecycle functionality that tells us the DOM is rendered or about to re-render, so we can piggy-back off that!
98121

99122
Let's extend the sidebar example from before, but this time we'll toggle its visibility using a button:
100123

101124
```js
102125
export default function Sidebar(props) {
126+
// We'll use a state dependency to determine when to `start` Collapsibles
103127
const [sidebarIsVisible, setSidebarIsVisible] = useState(true)
104-
// This time, we'll remove `.start` and have this `.stop` during component unmount.
128+
// No need to `start` here, but we do want to `stop` on unmount still
105129
useEffect(() => {
106130
return () => Collapsibles.stop("#sidebar-wrapper")
107131
}, [])
108-
// Whenever sidebarIsVisible changes, we'll check its value
109-
// If it's not visible, do nothing and return;
132+
// Whenever sidebarIsVisible changes, we'll check its value:
133+
// If it's not visible, do nothing
110134
// Else, it's visible, so start collapsibles in the sidebar scope
111135
useEffect(() => {
112136
if (sidebarIsVisible) Collapsibles.start("#sidebar-wrapper")
113137
}, [sidebarIsVisible])
114-
// If the sidebar is visible, stop collapsibles before the sidebar is removed from the DOM
138+
// If the sidebar is visible on click, stop collapsibles before the sidebar is removed from the DOM
115139
const handleClick = (e) => {
116140
if (sidebarIsVisible) Collapsibles.stop("#sidebar-wrapper")
117141
setSidebarIsVisible(!sidebarIsVisible)
@@ -127,53 +151,59 @@ export default function Sidebar(props) {
127151

128152
In this component, we have a button that when clicked will toggle visibility of the sidebar, which has some collapsible instances inside it.
129153

130-
When the button is clicked, we want to stop collapsibles in the DOM by checking if `sidebarIsVisible` is currently `true`, before its setter is called. If they are, set visibility to `false`.
154+
`Collapsibles.start` is now dependent on `sidebarIsVisible`, and will call on initial render (and subsequent re-renders) if the state is `true`.
131155

132-
Then, when `sidebarIsVisible` is `true` again (sometime in the future), we know the sidebar DOM is ready, so we can start collapsibles again.
156+
When the button is clicked, we want to stop collapsibles if `sidebarIsVisible` is currently `true`, but before state is flipped in the setter.
157+
158+
The cycle continues for each time the button is clicked.
133159

134160
## Utilities
135161

136-
Undernet comes with two utilities out of the box: `createFocusRing` and `createFocusTrap`. They can be initialized with `start` and `stop` methods like the rest of the component APIs. The only difference is there is no scope available.
162+
Undernet comes with two utilities out of the box: `createFocusRing` and `createFocusTrap`. They can be initialized with `start` and `stop` methods. The only difference is there is no scope available.
137163

138164
### createFocusRing
139165

140166
This will create global event listeners on the page for keyboard and mouse behavior.
141167

142-
If tab, space, or arrow keys are being used, you're in "keyboard mode" so a bright focus ring will show when elements are focused.
168+
```js
169+
import { createFocusRing } from "undernet"
170+
const focusRing = createFocusRing()
171+
focusRing.start()
172+
```
173+
174+
If tab, space, or arrow keys are being used, you're in "keyboard mode," enabling a bright focus ring around the actively focused element.
143175

144176
As soon as a mouse is in use again, the ring goes away.
145177

146-
If you use the utility, whether through `Undernet.start()` or directly like in the previous examples, you should only initialize it once on a page. Enabling it multiple times will create inconsistent behavior between keyboard and mouse interactions, showing the ring when a mouse is used, and potentially hiding it for keyboard users.
178+
If you use the utility, whether through this utility or `Undernet.start` or `Undernet.stop`, only initialize it **once** on a page. Enabling it multiple times will create inconsistent results.
147179

148180
### createFocusTrap
149181

150-
This is less of a common utility, and moreso offered to allow you the same focus trap behavior that the components use. Better to use a utility that's not implemented two times in different ways!
182+
This utility is offered in case you need the functionality outside of the components provided in Undernet.
151183

152-
It's instantiated the same way as `createFocusRing`:
184+
It's instantiated the same way as `createFocusRing`, but takes two parameters:
153185

154186
```js
155187
import { createFocusTrap } from "undernet"
156188
const focusTrap = createFocusTrap()
157189
focusTrap.start(selector, options)
158190
```
159191

160-
Using it is slightly different than in previous APIs.
161-
162192
#### `selector` (string)
163193

164194
**Required**
165195

166-
A string to be queried in the DOM; it's the "container" of possible focusable elements.
196+
A string to be queried in the DOM; it will be treated as the container of possible focusable elements. If this is the only parameter given, `tab` and `shift+tab` will be the key-bindings used for trapping.
167197

168198
```js
169-
focusTrap.start(".my-element")
199+
focusTrap.start(".wrapper-element")
170200
```
171201

172202
#### `options` (object)
173203

174204
Default: `{}`
175205

176-
Change how focus behavior works or what elements to search for.
206+
Customize trapping behavior using the below options.
177207

178208
##### `options.useArrows` (boolean)
179209

@@ -182,28 +212,30 @@ Default: `false`
182212
Trap focus using up and down arrows.
183213

184214
```js
185-
focusRing.start(".my-element", { useArrows: true })
215+
focusRing.start(".wrapper-element", { useArrows: true })
186216
```
187217

188218
##### `options.children` (array)
189219

190-
Default: `undefined`
220+
Default: `[]`
191221

192-
Provide an array the nodes to be used for the focus trap behavior.
222+
Provide a custom array of elements to trap focus within. This overrides the element querying functionality of the utility.
193223

194224
```js
195-
const children = document.querySelectorAll(".special-button")
196-
focusTrap.start(null, { children })
225+
const children = document.querySelectorAll(".my-focusable-element")
226+
focusTrap.start(".wrapper-element", { children })
197227
```
198228

229+
NOTE: You should still pass a selector string for the wrapper as a fallback, in case `children` comes back empty and you aren't using a guard for that case explicitly.
230+
199231
##### `options.matchers` (array)
200232

201233
Default: `["a", "button", "input", "object", "select", "textarea", "[tabindex]"]`
202234

203-
Override the default matchers for focusable elements. You can provide a new kind of matcher, a subset of the defaults, or both:
235+
Override the default matchers for focusable elements. Elements with `is-visually-hidden` are _always_ excluded from the resulting focusable elements.
204236

205237
```js
206-
focusRing.start(".my-element", { matchers: ["button", "input", ".elements-with-this-class"] })
238+
focusRing.start(".wrapper-element", { matchers: ["button", "input", ".elements-with-this-class"] })
207239
```
208240

209241
---

src/js/__tests__/utils.spec.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,47 @@ describe("createFocusTrap(container, options = {})", () => {
319319
})
320320

321321
describe("logging", () => {
322-
it("logs console error if neither first parameter or options.children are given", () => {
322+
it("prints console error if neither first parameter or options.children are given", () => {
323323
// Given
324324
renderDOM(testDOM)
325325
// When
326326
createFocusTrap(null, { children: [] })
327327
// Then
328328
expect(console.error).toBeCalledWith(
329-
"createFocusTrap must be given one or both of: first parameter, options.children (array of elements)."
329+
"createFocusTrap must be given one or both of: first parameter (as selector string) and/or options.children (array of elements)."
330+
)
331+
})
332+
333+
it("prints console error if options.useArrow is not strictly a boolean", () => {
334+
// Given
335+
renderDOM(testDOM)
336+
// When
337+
createFocusTrap(CONTAINER_SELECTOR, { useArrows: "hello" })
338+
// Then
339+
expect(console.error).toBeCalledWith(
340+
"Invalid data type given to options.useArrows for createFocusTrap. Expected: Boolean."
341+
)
342+
})
343+
344+
it("prints console error if options.children is not array-like", () => {
345+
// Given
346+
renderDOM(testDOM)
347+
// When
348+
createFocusTrap(CONTAINER_SELECTOR, { children: { hello: "there" } })
349+
// Then
350+
expect(console.error).toBeCalledWith(
351+
"Invalid data type given to options.children for createFocusTrap. Expected: Array-Like."
352+
)
353+
})
354+
355+
it("prints console error if options.matchers is not strictly an array", () => {
356+
// Given
357+
renderDOM(testDOM)
358+
// When
359+
createFocusTrap(CONTAINER_SELECTOR, { matchers: true })
360+
// Then
361+
expect(console.error).toBeCalledWith(
362+
"Invalid data type given to options.matchers for createFocusTrap. Expected: Array."
330363
)
331364
})
332365
})

0 commit comments

Comments
 (0)