Skip to content

Commit

Permalink
Move focusgroup functionality to a separate polyfill (#51)
Browse files Browse the repository at this point in the history
* Separate focusgroup attribute logic from roles into its own polyfill

* Add hotkey compatibility with focusgroup polyfill

* Adapt and separate test cases for focusgroup polyfill

* Add TS types and export

* Reduce size to 2.2 kB

* Update documentation

* Add additional compatibility tests

* Remove the hotkeys search from the polyfill
  • Loading branch information
echo-vladimir authored Dec 11, 2024
1 parent 8f46cf8 commit 62e3ef3
Show file tree
Hide file tree
Showing 10 changed files with 1,082 additions and 732 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
hotkeyOverrides,
jumpKeyUX,
focusGroupKeyUX,
focusGroupPolyfill,
pressKeyUX,
startKeyUX
} from 'keyux'
Expand All @@ -77,6 +78,7 @@ const overrides = hotkeyOverrides({})
startKeyUX(window, [
hotkeyKeyUX([overrides]),
focusGroupKeyUX(),
focusGroupPolyfill(),
pressKeyUX('is-pressed'),
jumpKeyUX(),
hiddenKeyUX()
Expand Down Expand Up @@ -311,6 +313,16 @@ Key UX supports (you can combine these features):

Key UX doesn’t support `grid` feature.

To enable this feature, call `focusGroupPolyfill`.

```js
import { focusGroupPolyfill } from 'keyux'

startKeyUX(window, [
focusGroupPolyfill()
])
```

### Menu

To reduce Tab-list you can group website’s menu
Expand Down
149 changes: 149 additions & 0 deletions focus-group-polyfill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
function focus(current, next) {
if (next) {
next.tabIndex = 0
next.focus()
current.tabIndex = -1
}
}

function findGroupNodeByEventTarget(target) {
let fg = target.closest('[focusgroup]:not([focusgroup="none"])')
if (fg) return fg
}

function getItems(group) {
if (group.hasAttribute('focusgroup')) {
let items = [...group.querySelectorAll('*:not([focusgroup="none"])')]
return items.filter(item => {
return (
item.role === 'button' ||
item.type === 'button' ||
item.role === 'checkbox' ||
item.type === 'checkbox'
)
})
}
}

function isHorizontalOrientation(group) {
let fg = group.getAttribute('focusgroup')
if (fg !== null) return !fg.split(' ').includes('block')
}

export function focusGroupPolyfill() {
return window => {
let inGroup = false

function keyDown(event) {
let group = findGroupNodeByEventTarget(event.target)

if (!group) {
stop()
return
}

let items = getItems(group)
let index = Array.from(items).indexOf(event.target)

let nextKey = 'ArrowDown'
let prevKey = 'ArrowUp'
if (isHorizontalOrientation(group)) {
if (window.document.dir === 'rtl') {
nextKey = 'ArrowLeft'
prevKey = 'ArrowRight'
} else {
nextKey = 'ArrowRight'
prevKey = 'ArrowLeft'
}
}

if (event.key === nextKey) {
event.preventDefault()
if (items[index + 1]) {
focus(event.target, items[index + 1])
} else if (group.getAttribute('focusgroup').includes('wrap')) {
focus(event.target, items[0])
}
} else if (event.key === prevKey) {
event.preventDefault()
if (items[index - 1]) {
focus(event.target, items[index - 1])
} else if (group.getAttribute('focusgroup').includes('wrap')) {
focus(event.target, items[items.length - 1])
}
} else if (event.key === 'Home') {
event.preventDefault()
focus(event.target, items[0])
} else if (event.key === 'End') {
event.preventDefault()
focus(event.target, items[items.length - 1])
}
}

function stop() {
inGroup = false
window.removeEventListener('keydown', keyDown)
}

function focusIn(event) {
let group = findGroupNodeByEventTarget(event.target)
if (group) {
if (!inGroup) {
inGroup = true
window.addEventListener('keydown', keyDown)
}

let items = getItems(group)
if (!items.some(item => item.getAttribute('tabindex') === '0')) {
items.forEach((item, index) =>
item.setAttribute('tabindex', index === 0 ? 0 : -1)
)
items[0]?.focus()
} else {
items.forEach(item => {
if (item !== event.target) item.setAttribute('tabindex', -1)
})
}
} else if (inGroup) {
stop()
}
}

function focusOut(event) {
let group = findGroupNodeByEventTarget(event.target)
if (group?.getAttribute('focusgroup')?.includes('no-memory')) {
let items = getItems(group)
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? 0 : -1)
})
}

if (!event.relatedTarget || event.relatedTarget === window.document) {
stop()
}
}

function click(event) {
let group = findGroupNodeByEventTarget(event.target)
if (group) {
let items = getItems(group)
for (let item of items) {
if (item !== event.target) {
item.setAttribute('tabindex', -1)
}
}
event.target.setAttribute('tabindex', 0)
}
}

window.addEventListener('click', click)
window.addEventListener('focusin', focusIn)
window.addEventListener('focusout', focusOut)
return () => {
stop()
window.removeEventListener('click', click)
window.removeEventListener('focusin', focusIn)
window.removeEventListener('focusout', focusOut)
}
}
}
60 changes: 9 additions & 51 deletions focus-group.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ function focus(current, next) {
}

function findGroupNodeByEventTarget(target) {
let fg = target.closest('[focusgroup]:not([focusgroup="none"])')
if (fg) return fg

let itemRole = target.role || target.type || target.tagName
if (!itemRole) return null

Expand All @@ -29,14 +26,12 @@ function findGroupNodeByEventTarget(target) {
}

function getItems(target, group) {
if (group.role === 'toolbar' || group.hasAttribute('focusgroup')) {
return getToolbarItems(group)
}
if (group.role === 'toolbar') return getToolbarItems(group)
return group.querySelectorAll(`[role=${target.role}]`)
}

function getToolbarItems(group) {
let items = [...group.querySelectorAll('*:not([focusgroup="none"])')]
let items = [...group.querySelectorAll('*')]
return items.filter(item => {
return (
item.role === 'button' ||
Expand All @@ -48,9 +43,6 @@ function getToolbarItems(group) {
}

function isHorizontalOrientation(group) {
let fg = group.getAttribute('focusgroup')
if (fg !== null) return !fg.split(' ').includes('block')

let ariaOrientation = group.getAttribute('aria-orientation')
if (ariaOrientation === 'vertical') return false
if (ariaOrientation === 'horizontal') return true
Expand All @@ -68,7 +60,6 @@ export function focusGroupKeyUX(options) {

function keyDown(event) {
let group = findGroupNodeByEventTarget(event.target)

if (!group) {
stop()
return
Expand All @@ -91,26 +82,10 @@ export function focusGroupKeyUX(options) {

if (event.key === nextKey) {
event.preventDefault()
if (group.hasAttribute('focusgroup')) {
if (items[index + 1]) {
focus(event.target, items[index + 1])
} else if (group.getAttribute('focusgroup').includes('wrap')) {
focus(event.target, items[0])
}
} else {
focus(event.target, items[index + 1] || items[0])
}
focus(event.target, items[index + 1] || items[0])
} else if (event.key === prevKey) {
event.preventDefault()
if (group.hasAttribute('focusgroup')) {
if (items[index - 1]) {
focus(event.target, items[index - 1])
} else if (group.getAttribute('focusgroup').includes('wrap')) {
focus(event.target, items[items.length - 1])
}
} else {
focus(event.target, items[index - 1] || items[items.length - 1])
}
focus(event.target, items[index - 1] || items[items.length - 1])
} else if (event.key === 'Home') {
event.preventDefault()
focus(event.target, items[0])
Expand Down Expand Up @@ -150,35 +125,18 @@ export function focusGroupKeyUX(options) {
inGroup = true
window.addEventListener('keydown', keyDown)
}

let items = Array.from(getItems(event.target, group))
if (
!items.some(item => item.getAttribute('tabindex') === '0') &&
group.hasAttribute('focusgroup')
) {
items.forEach((item, index) =>
item.setAttribute('tabindex', index === 0 ? 0 : -1)
)
items[0]?.focus()
} else {
items.forEach(item => {
if (item !== event.target) item.setAttribute('tabindex', -1)
})
let items = getItems(event.target, group)
for (let item of items) {
if (item !== event.target) {
item.setAttribute('tabindex', -1)
}
}
} else if (inGroup) {
stop()
}
}

function focusOut(event) {
let group = findGroupNodeByEventTarget(event.target)
if (group?.getAttribute('focusgroup')?.includes('no-memory')) {
let items = getItems(event.target, group)
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? 0 : -1)
})
}

if (!event.relatedTarget || event.relatedTarget === window.document) {
stop()
}
Expand Down
3 changes: 2 additions & 1 deletion hotkey.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export function hotkeyKeyUX(transformers = []) {
event.target.isContentEditable ||
event.target.tagName === 'TEXTAREA' ||
(event.target.tagName === 'INPUT' && !IGNORE_INPUTS[event.target.type])
let insideFocusGroup = event.target.role === 'menuitem'
let insideFocusGroup =
event.target.role === 'menuitem'
if (!isSpecialKey && (insideEditable || insideFocusGroup)) {
return
}
Expand Down
13 changes: 13 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ export function hotkeyKeyUX(transformers?: Transformer[]): KeyUXModule
*/
export function focusGroupKeyUX(options?: FocusGroupKeyUXOptions): KeyUXModule

/**
* Add polyfill for focusgroup attribute to enable arrow-key navigation within groups.
*
* ```js
* import { startKeyUX, focusGroupPolyfill } from 'keyux'
*
* startKeyUX(window, [
* focusGroupPolyfill()
* ])
* ```
*/
export function focusGroupPolyfill(): KeyUXModule

/**
* Add pressed style on button activation from keyboard.
*
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './compat.js'
export * from './focus-group-polyfill.js'
export * from './focus-group.js'
export * from './hidden.js'
export * from './hotkey.js'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"import": {
"./index.js": "{ startKeyUX, hotkeyKeyUX, pressKeyUX, focusGroupKeyUX, jumpKeyUX, hiddenKeyUX, likelyWithKeyboard, getHotKeyHint, hotkeyOverrides, hotkeyMacCompat }"
},
"limit": "2366 B"
"limit": "2196 B"
}
],
"clean-publish": {
Expand Down
2 changes: 2 additions & 0 deletions test/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'
import type { HotkeyOverride } from '../../index.js'
import {
focusGroupKeyUX,
focusGroupPolyfill,
getHotKeyHint,
hiddenKeyUX,
hotkeyKeyUX,
Expand All @@ -22,6 +23,7 @@ let macCompatTransformer = hotkeyMacCompat()
startKeyUX(window, [
hotkeyKeyUX([macCompatTransformer, overridesTransformer]),
focusGroupKeyUX(),
focusGroupPolyfill(),
pressKeyUX('is-pressed'),
jumpKeyUX(),
hiddenKeyUX()
Expand Down
Loading

0 comments on commit 62e3ef3

Please # to comment.