-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #33 from chialab/introduce-behaviors
Introduce behaviors
- Loading branch information
Showing
12 changed files
with
785 additions
and
484 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@chialab/loock': major | ||
--- | ||
|
||
Introduce focus and keyboard navigation behaviors. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ name: Pages | |
on: | ||
workflow_dispatch: | ||
push: | ||
branches: ['main', 'v4'] | ||
branches: ['main'] | ||
|
||
jobs: | ||
build: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
/** | ||
* Default focusable selectors. | ||
*/ | ||
export const DEFAULT_SELECTORS = [ | ||
'a[href]', | ||
'area[href]', | ||
'button', | ||
'input', | ||
'select', | ||
'textarea', | ||
'video[controls]', | ||
'audio[controls]', | ||
'embed', | ||
'iframe', | ||
'summary', | ||
'[contenteditable]', | ||
'[tabindex]', | ||
]; | ||
|
||
/** | ||
* Default ignore selectors. | ||
*/ | ||
export const DEFAULT_IGNORE_SELECTORS = [ | ||
'[tabindex="-1"]', | ||
'[disabled]', | ||
'[hidden]', | ||
'[aria-hidden="true"]', | ||
'[aria-disabled="true"]', | ||
'[inert]', | ||
'details:not([open]) *:not(summary)', | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { DEFAULT_IGNORE_SELECTORS, DEFAULT_SELECTORS } from './constants'; | ||
|
||
/** | ||
* Find all focusable elements by options. | ||
* @param node The target node. | ||
* @param options The options. | ||
*/ | ||
export function findFocusableByOptions( | ||
node: HTMLElement, | ||
options: { | ||
elements?: HTMLElement[] | (() => HTMLElement[]); | ||
include?: string[]; | ||
exclude?: string[]; | ||
} | ||
) { | ||
const { include, exclude, elements } = options; | ||
if (!elements) { | ||
return findFocusableChildren(node, include, exclude); | ||
} | ||
|
||
if (typeof elements === 'function') { | ||
return elements(); | ||
} | ||
|
||
return elements; | ||
} | ||
|
||
/** | ||
* Find all focusable children of a node. | ||
* @param node The target node. | ||
* @param include The selectors to include. | ||
* @param exclude The selectors to exclude. | ||
* @returns The focusable children list. | ||
*/ | ||
export function findFocusableChildren( | ||
node: Element, | ||
include: string[] = DEFAULT_SELECTORS, | ||
exclude: string[] = DEFAULT_IGNORE_SELECTORS | ||
) { | ||
return (Array.from(node.querySelectorAll(include.join(', '))) as HTMLElement[]).filter((element) => { | ||
if (exclude.some((selector) => element.matches(selector))) { | ||
return false; | ||
} | ||
|
||
if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'radio') { | ||
const name = (element as HTMLInputElement).name; | ||
const inputs = node.querySelectorAll(`input[type="radio"][name="${name}"]`); | ||
const checked = Array.from(inputs).find((input) => (input as HTMLInputElement).checked); | ||
if (checked) { | ||
if (checked !== element) { | ||
return false; | ||
} | ||
} else if (element !== inputs[0]) { | ||
return false; | ||
} | ||
} | ||
|
||
const { width, height } = element.getBoundingClientRect(); | ||
return !!height && !!width; | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
/** | ||
* The focus enter options. | ||
*/ | ||
export interface FocusEnterOptions { | ||
/** | ||
* The callback when focus enter. | ||
*/ | ||
onEnter?: (element: Element) => void; | ||
/** | ||
* The callback when focus exit. | ||
*/ | ||
onExit?: () => void; | ||
} | ||
|
||
/** | ||
* Focus first option on focus enter. | ||
* @param node The target element. | ||
* @param options The options. | ||
* @returns The behavior controller. | ||
*/ | ||
export function focusEnterBehavior(node: HTMLElement, options: FocusEnterOptions = {}) { | ||
const document = node.ownerDocument; | ||
const { onEnter, onExit } = options; | ||
let focused = false; | ||
let connected = false; | ||
|
||
const onFocusIn = () => { | ||
const activeElement = document.activeElement; | ||
if (focused || !activeElement) { | ||
return; | ||
} | ||
|
||
focused = true; | ||
onEnter?.(activeElement); | ||
}; | ||
|
||
const onFocusOut = () => { | ||
if (!focused) { | ||
return; | ||
} | ||
|
||
setTimeout(() => { | ||
const activeElement = document.activeElement; | ||
if (node !== activeElement && !node.contains(activeElement)) { | ||
focused = false; | ||
onExit?.(); | ||
} | ||
}); | ||
}; | ||
|
||
return { | ||
get connected() { | ||
return connected; | ||
}, | ||
connect() { | ||
if (connected) { | ||
return; | ||
} | ||
connected = true; | ||
focused = false; | ||
if (document.activeElement && node.contains(document.activeElement)) { | ||
onFocusIn(); | ||
} | ||
node.addEventListener('focusin', onFocusIn); | ||
node.addEventListener('focusout', onFocusOut); | ||
}, | ||
disconnect() { | ||
if (!connected) { | ||
return; | ||
} | ||
connected = false; | ||
focused = false; | ||
node.removeEventListener('focusin', onFocusIn); | ||
node.removeEventListener('focusout', onFocusOut); | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { findFocusableByOptions } from './findFocusableChildren'; | ||
import { restoreAttribute } from './helpers'; | ||
|
||
/** | ||
* The options for focus first option on focus enter. | ||
*/ | ||
export interface FocusFirstChildOptions { | ||
/** | ||
* The focusable elements. | ||
*/ | ||
elements?: HTMLElement[] | (() => HTMLElement[]); | ||
/** | ||
* The selectors for focusable nodes. | ||
*/ | ||
include?: string[]; | ||
/** | ||
* The selectors for ignored nodes. | ||
*/ | ||
exclude?: string[]; | ||
} | ||
|
||
/** | ||
* Focus first option on focus enter. | ||
* @param node The target element. | ||
* @param options The options. | ||
* @returns The behavior controller. | ||
*/ | ||
export function focusFirstChildBehavior(node: HTMLElement, options: FocusFirstChildOptions = {}) { | ||
const document = node.ownerDocument; | ||
let activeElement: HTMLElement | null = null; | ||
let connected = false; | ||
let tabindex: string | null = null; | ||
|
||
const onFocus = () => { | ||
const elements = findFocusableByOptions(node, options); | ||
const target = document.activeElement as HTMLElement; | ||
if (target === node) { | ||
if (activeElement && node.contains(activeElement)) { | ||
activeElement.focus(); | ||
return; | ||
} | ||
|
||
elements[0]?.focus(); | ||
return; | ||
} else if (elements.includes(target)) { | ||
activeElement = target; | ||
} | ||
}; | ||
|
||
return { | ||
get connected() { | ||
return connected; | ||
}, | ||
connect() { | ||
if (connected) { | ||
return; | ||
} | ||
connected = true; | ||
activeElement = null; | ||
tabindex = node.getAttribute('tabindex'); | ||
node.setAttribute('tabindex', '-1'); | ||
node.addEventListener('focus', onFocus, true); | ||
}, | ||
disconnect() { | ||
if (!connected) { | ||
return; | ||
} | ||
connected = false; | ||
activeElement = null; | ||
restoreAttribute(node, 'tabindex', tabindex); | ||
node.removeEventListener('focus', onFocus, true); | ||
}, | ||
}; | ||
} |
Oops, something went wrong.