Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
5041947a
Commit
5041947a
authored
Feb 02, 2024
by
Alejandro Celaya
Committed by
Alejandro Celaya
Feb 02, 2024
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Replace local useArrowKeyNavigation with frontend-shared one
parent
60bc6091
Changes
3
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
1 addition
and
500 deletions
+1
-500
keyboard-navigation.ts
src/shared/keyboard-navigation.ts
+0
-195
keyboard-navigation-test.js
src/shared/test/keyboard-navigation-test.js
+0
-304
MarkdownEditor.tsx
src/sidebar/components/MarkdownEditor.tsx
+1
-1
No files found.
src/shared/keyboard-navigation.ts
deleted
100644 → 0
View file @
60bc6091
import
type
{
RefObject
}
from
'preact'
;
import
{
useEffect
}
from
'preact/hooks'
;
import
{
ListenerCollection
}
from
'./listener-collection'
;
function
isElementDisabled
(
element
:
HTMLElement
&
{
disabled
?:
boolean
},
):
element
is
HTMLElement
&
{
disabled
:
true
}
{
return
typeof
element
.
disabled
===
'boolean'
&&
element
.
disabled
;
}
function
isElementVisible
(
element
:
HTMLElement
):
boolean
{
return
element
.
offsetParent
!==
null
;
}
export
type
ArrowKeyNavigationOptions
=
{
/**
* Whether to focus the first element in the set of matching elements when the
* component is mounted
*/
autofocus
?:
boolean
;
/** Enable navigating elements using left/right arrow keys */
horizontal
?:
boolean
;
/** Enable navigating elements using up/down arrow keys */
vertical
?:
boolean
;
/** CSS selector which specifies the elements that navigation moves between */
selector
?:
string
;
};
/**
* Enable arrow key navigation between interactive descendants of a
* container element.
*
* In addition to moving focus between elements when arrow keys are pressed,
* this also implements the "roving tabindex" pattern [1] which sets the
* `tabindex` attribute of elements to control which element gets focus when the
* user tabs into the container.
*
* See [2] for a reference of how keyboard navigation should work in web
* applications and how it applies to various common widgets.
*
* @example
* function MyToolbar() {
* const container = useRef();
*
* // Enable arrow key navigation between interactive elements in the
* // toolbar container.
* useArrowKeyNavigation(container);
*
* return (
* <div ref={container} role="toolbar">
* <button>Bold</bold>
* <button>Italic</bold>
* <a href="https://example.com/help">Help</a>
* </div>
* )
* }
*
* [1] https://www.w3.org/TR/wai-aria-practices/#kbd_roving_tabindex
* [2] https://www.w3.org/TR/wai-aria-practices/#keyboard
*/
export
function
useArrowKeyNavigation
(
containerRef
:
RefObject
<
HTMLElement
>
,
{
autofocus
=
false
,
horizontal
=
true
,
vertical
=
true
,
selector
=
'a,button'
,
}:
ArrowKeyNavigationOptions
=
{},
)
{
useEffect
(()
=>
{
if
(
!
containerRef
.
current
)
{
throw
new
Error
(
'Container ref not set'
);
}
const
container
=
containerRef
.
current
;
const
getNavigableElements
=
()
=>
{
const
elements
:
HTMLElement
[]
=
Array
.
from
(
container
.
querySelectorAll
(
selector
),
);
return
elements
.
filter
(
el
=>
isElementVisible
(
el
)
&&
!
isElementDisabled
(
el
),
);
};
/**
* Update the `tabindex` attribute of navigable elements.
*
* Exactly one element will have `tabindex=0` and all others will have
* `tabindex=1`.
*
* @param currentIndex - Index of element in `elements` to make current.
* Defaults to the current element if there is one, or the first element
* otherwise.
* @param setFocus - Whether to focus the current element
*/
const
updateTabIndexes
=
(
elements
:
HTMLElement
[]
=
getNavigableElements
(),
currentIndex
=
-
1
,
setFocus
=
false
,
)
=>
{
if
(
currentIndex
<
0
)
{
currentIndex
=
elements
.
findIndex
(
el
=>
el
.
tabIndex
===
0
);
if
(
currentIndex
<
0
)
{
currentIndex
=
0
;
}
}
for
(
const
[
index
,
element
]
of
elements
.
entries
())
{
element
.
tabIndex
=
index
===
currentIndex
?
0
:
-
1
;
if
(
index
===
currentIndex
&&
setFocus
)
{
element
.
focus
();
}
}
};
const
onKeyDown
=
(
event
:
KeyboardEvent
)
=>
{
const
elements
=
getNavigableElements
();
let
currentIndex
=
elements
.
findIndex
(
item
=>
item
.
tabIndex
===
0
);
let
handled
=
false
;
if
(
(
horizontal
&&
event
.
key
===
'ArrowLeft'
)
||
(
vertical
&&
event
.
key
===
'ArrowUp'
)
)
{
if
(
currentIndex
===
0
)
{
currentIndex
=
elements
.
length
-
1
;
}
else
{
--
currentIndex
;
}
handled
=
true
;
}
else
if
(
(
horizontal
&&
event
.
key
===
'ArrowRight'
)
||
(
vertical
&&
event
.
key
===
'ArrowDown'
)
)
{
if
(
currentIndex
===
elements
.
length
-
1
)
{
currentIndex
=
0
;
}
else
{
++
currentIndex
;
}
handled
=
true
;
}
else
if
(
event
.
key
===
'Home'
)
{
currentIndex
=
0
;
handled
=
true
;
}
else
if
(
event
.
key
===
'End'
)
{
currentIndex
=
elements
.
length
-
1
;
handled
=
true
;
}
if
(
!
handled
)
{
return
;
}
updateTabIndexes
(
elements
,
currentIndex
,
true
);
event
.
preventDefault
();
event
.
stopPropagation
();
};
updateTabIndexes
(
getNavigableElements
(),
0
,
autofocus
);
const
listeners
=
new
ListenerCollection
();
// Set an element as current when it gains focus. In Safari this event
// may not be received if the element immediately loses focus after it
// is triggered.
listeners
.
add
(
container
,
'focusin'
,
event
=>
{
const
elements
=
getNavigableElements
();
const
targetIndex
=
elements
.
indexOf
(
event
.
target
as
HTMLElement
);
if
(
targetIndex
>=
0
)
{
updateTabIndexes
(
elements
,
targetIndex
);
}
});
listeners
.
add
(
container
,
'keydown'
,
onKeyDown
);
// Update the tab indexes of elements as they are added, removed, enabled
// or disabled.
const
mo
=
new
MutationObserver
(()
=>
{
updateTabIndexes
();
});
mo
.
observe
(
container
,
{
subtree
:
true
,
attributes
:
true
,
attributeFilter
:
[
'disabled'
],
childList
:
true
,
});
return
()
=>
{
listeners
.
removeAll
();
mo
.
disconnect
();
};
},
[
autofocus
,
containerRef
,
horizontal
,
selector
,
vertical
]);
}
src/shared/test/keyboard-navigation-test.js
deleted
100644 → 0
View file @
60bc6091
import
{
waitFor
}
from
'@hypothesis/frontend-testing'
;
import
{
options
as
preactOptions
,
render
}
from
'preact'
;
import
{
useRef
}
from
'preact/hooks'
;
import
{
act
}
from
'preact/test-utils'
;
import
{
useArrowKeyNavigation
}
from
'../keyboard-navigation'
;
function
Toolbar
({
navigationOptions
=
{}
})
{
const
containerRef
=
useRef
();
useArrowKeyNavigation
(
containerRef
,
navigationOptions
);
return
(
<
div
ref
=
{
containerRef
}
data
-
testid
=
"toolbar"
>
<
button
data
-
testid
=
"bold"
>
Bold
<
/button
>
<
button
data
-
testid
=
"italic"
>
Italic
<
/button
>
<
button
data
-
testid
=
"underline"
>
Underline
<
/button
>
<
a
href
=
"/help"
target
=
"_blank"
data
-
testid
=
"help"
>
Help
<
/a
>
<
/div
>
);
}
describe
(
'shared/keyboard-navigation'
,
()
=>
{
describe
(
'useArrowKeyNavigation'
,
()
=>
{
let
container
;
beforeEach
(()
=>
{
container
=
document
.
createElement
(
'div'
);
document
.
body
.
append
(
container
);
renderToolbar
();
});
afterEach
(()
=>
{
container
.
remove
();
});
// Workaround for an issue with `useEffect` throwing exceptions during
// `act` callbacks. Can be removed when https://github.com/preactjs/preact/pull/3530 is shipped.
let
prevDebounceRendering
;
beforeEach
(()
=>
{
prevDebounceRendering
=
preactOptions
.
debounceRendering
;
});
afterEach
(()
=>
{
preactOptions
.
debounceRendering
=
prevDebounceRendering
;
});
function
renderToolbar
(
options
=
{})
{
// We render the component with Preact directly rather than using Enzyme
// for these tests. Since the `tabIndex` state lives only in the DOM,
// and there are no child components involved, this is more convenient.
act
(()
=>
{
render
(
<
Toolbar
navigationOptions
=
{
options
}
/>, container
)
;
});
return
findElementByTestId
(
'toolbar'
);
}
function
findElementByTestId
(
testId
)
{
return
container
.
querySelector
(
`[data-testid=
${
testId
}
]`
);
}
function
pressKey
(
key
)
{
const
event
=
new
KeyboardEvent
(
'keydown'
,
{
bubbles
:
true
,
cancelable
:
true
,
key
,
});
act
(()
=>
{
findElementByTestId
(
'toolbar'
).
dispatchEvent
(
event
);
});
return
event
;
}
function
currentItem
()
{
return
document
.
activeElement
.
innerText
;
}
[
{
forwardKey
:
'ArrowRight'
,
backKey
:
'ArrowLeft'
},
{
forwardKey
:
'ArrowDown'
,
backKey
:
'ArrowUp'
},
].
forEach
(({
forwardKey
,
backKey
})
=>
{
it
(
'should move focus and tab stop between elements when arrow keys are pressed'
,
()
=>
{
const
steps
=
[
// Test navigating forwards.
[
forwardKey
,
'Italic'
],
[
forwardKey
,
'Underline'
],
[
forwardKey
,
'Help'
],
// Test that navigation wraps to start.
[
forwardKey
,
'Bold'
],
// Test that navigation wraps to end.
[
backKey
,
'Help'
],
// Test navigating backwards.
[
backKey
,
'Underline'
],
[
backKey
,
'Italic'
],
[
backKey
,
'Bold'
],
// Test jump to start / end.
[
'End'
,
'Help'
],
[
'Home'
,
'Bold'
],
];
for
(
let
[
key
,
expectedItem
]
of
steps
)
{
pressKey
(
key
);
const
currentElement
=
document
.
activeElement
;
assert
.
equal
(
currentElement
.
innerText
,
expectedItem
);
const
toolbarButtons
=
container
.
querySelectorAll
(
'a,button'
);
for
(
let
element
of
toolbarButtons
)
{
if
(
element
===
currentElement
)
{
assert
.
equal
(
element
.
tabIndex
,
0
);
}
else
{
assert
.
equal
(
element
.
tabIndex
,
-
1
);
}
}
}
});
});
[
// Keys handled with default options.
{
key
:
'ArrowLeft'
,
shouldHandle
:
true
,
},
{
key
:
'ArrowRight'
,
shouldHandle
:
true
,
},
{
key
:
'ArrowUp'
,
shouldHandle
:
true
,
},
{
key
:
'ArrowDown'
,
shouldHandle
:
true
,
},
{
key
:
'End'
,
shouldHandle
:
true
,
},
{
key
:
'Home'
,
shouldHandle
:
true
,
},
// Keys never handled.
{
key
:
'Space'
,
shouldHandle
:
false
,
},
// Keys not handled if horizontal navigation is disabled
{
key
:
'ArrowLeft'
,
horizontal
:
false
,
shouldHandle
:
false
,
},
{
key
:
'ArrowRight'
,
horizontal
:
false
,
shouldHandle
:
false
,
},
// Keys not handled if vertical navigation is disabled
{
key
:
'ArrowUp'
,
vertical
:
false
,
shouldHandle
:
false
,
},
{
key
:
'ArrowDown'
,
vertical
:
false
,
shouldHandle
:
false
,
},
].
forEach
(({
key
,
horizontal
,
vertical
,
shouldHandle
})
=>
{
it
(
'should stop keyboard event propagation if event is handled'
,
()
=>
{
renderToolbar
({
horizontal
,
vertical
});
const
handleKeyDown
=
sinon
.
stub
();
container
.
addEventListener
(
'keydown'
,
handleKeyDown
);
const
event
=
pressKey
(
key
);
assert
.
equal
(
event
.
defaultPrevented
,
shouldHandle
,
`
${
key
}
defaultPrevented`
,
);
assert
.
equal
(
handleKeyDown
.
called
,
!
shouldHandle
,
`
${
key
}
propagated`
);
handleKeyDown
.
resetHistory
();
});
});
it
(
'should skip hidden elements'
,
()
=>
{
renderToolbar
();
findElementByTestId
(
'bold'
).
focus
();
findElementByTestId
(
'italic'
).
style
.
display
=
'none'
;
pressKey
(
'ArrowRight'
);
assert
.
equal
(
currentItem
(),
'Underline'
);
});
it
(
'should skip disabled elements'
,
()
=>
{
renderToolbar
();
findElementByTestId
(
'bold'
).
focus
();
findElementByTestId
(
'italic'
).
disabled
=
true
;
pressKey
(
'ArrowRight'
);
assert
.
equal
(
currentItem
(),
'Underline'
);
});
it
(
'should not respond to Up/Down arrow keys if vertical navigation is disabled'
,
()
=>
{
renderToolbar
({
vertical
:
false
});
findElementByTestId
(
'bold'
).
focus
();
pressKey
(
'ArrowDown'
);
assert
.
equal
(
currentItem
(),
'Bold'
);
});
it
(
'should not respond to Left/Right arrow keys if horizontal navigation is disabled'
,
()
=>
{
renderToolbar
({
horizontal
:
false
});
findElementByTestId
(
'bold'
).
focus
();
pressKey
(
'ArrowRight'
);
assert
.
equal
(
currentItem
(),
'Bold'
);
});
it
(
'shows an error if container ref is not initialized'
,
()
=>
{
function
BrokenToolbar
()
{
const
ref
=
useRef
();
useArrowKeyNavigation
(
ref
);
return
<
div
/>
;
}
// Suppress "Add @babel/plugin-transform-react-jsx-source to get a more
// detailed component stack" warning from the `render` call below.
sinon
.
stub
(
console
,
'warn'
);
let
error
;
try
{
act
(()
=>
render
(
<
BrokenToolbar
/>
,
container
));
}
catch
(
e
)
{
error
=
e
;
}
finally
{
console
.
warn
.
restore
();
}
assert
.
instanceOf
(
error
,
Error
);
assert
.
equal
(
error
.
message
,
'Container ref not set'
);
});
it
(
'should respect a custom element selector'
,
()
=>
{
renderToolbar
({
selector
:
'[data-testid=bold],[data-testid=italic]'
,
});
findElementByTestId
(
'bold'
).
focus
();
pressKey
(
'ArrowRight'
);
assert
.
equal
(
currentItem
(),
'Italic'
);
pressKey
(
'ArrowRight'
);
assert
.
equal
(
currentItem
(),
'Bold'
);
pressKey
(
'ArrowLeft'
);
assert
.
equal
(
currentItem
(),
'Italic'
);
});
it
(
'should re-initialize tabindex attributes if current element is removed'
,
async
()
=>
{
const
toolbar
=
renderToolbar
();
const
boldButton
=
toolbar
.
querySelector
(
'[data-testid=bold]'
);
const
italicButton
=
toolbar
.
querySelector
(
'[data-testid=italic]'
);
boldButton
.
focus
();
assert
.
equal
(
boldButton
.
tabIndex
,
0
);
assert
.
equal
(
italicButton
.
tabIndex
,
-
1
);
boldButton
.
remove
();
// nb. tabIndex update is async because it uses MutationObserver
await
waitFor
(()
=>
italicButton
.
tabIndex
===
0
);
});
it
(
'should re-initialize tabindex attributes if current element is disabled'
,
async
()
=>
{
renderToolbar
();
const
boldButton
=
findElementByTestId
(
'bold'
);
const
italicButton
=
findElementByTestId
(
'italic'
);
boldButton
.
focus
();
assert
.
equal
(
boldButton
.
tabIndex
,
0
);
assert
.
equal
(
italicButton
.
tabIndex
,
-
1
);
boldButton
.
disabled
=
true
;
// nb. tabIndex update is async because it uses MutationObserver
await
waitFor
(()
=>
italicButton
.
tabIndex
===
0
);
});
});
});
src/sidebar/components/MarkdownEditor.tsx
View file @
5041947a
...
...
@@ -9,13 +9,13 @@ import {
LinkIcon
,
ListOrderedIcon
,
ListUnorderedIcon
,
useArrowKeyNavigation
,
}
from
'@hypothesis/frontend-shared'
;
import
type
{
IconComponent
}
from
'@hypothesis/frontend-shared/lib/types'
;
import
classnames
from
'classnames'
;
import
type
{
Ref
,
JSX
}
from
'preact'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'preact/hooks'
;
import
{
useArrowKeyNavigation
}
from
'../../shared/keyboard-navigation'
;
import
{
isMacOS
}
from
'../../shared/user-agent'
;
import
{
LinkType
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment