Skip to content

Commit

Permalink
Merge pull request #33 from chialab/introduce-behaviors
Browse files Browse the repository at this point in the history
Introduce behaviors
  • Loading branch information
edoardocavazza authored Nov 8, 2023
2 parents 232e351 + 7322a9d commit 7ee4aa4
Show file tree
Hide file tree
Showing 12 changed files with 785 additions and 484 deletions.
5 changes: 5 additions & 0 deletions .changeset/serious-flowers-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chialab/loock': major
---

Introduce focus and keyboard navigation behaviors.
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Pages
on:
workflow_dispatch:
push:
branches: ['main', 'v4']
branches: ['main']

jobs:
build:
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,19 @@ yarn add @chialab/loock
### CDN

```ts
import { FocusContext } from 'https://unpkg.com/@chialab/loock?module';
import { focusTrapBehavior } from 'https://unpkg.com/@chialab/loock?module';
```

## Usage

```ts
import { FocusContext } from '@chialab/loock';
import { focusTrapBehavior } from '@chialab/loock';

const dialog = document.getElementById('.dialog');
// define a context
const dialogContext = new FocusContext(dialog);
const trap = focusTrapBehavior(dialog);

dialog.addEventListener('open', () => {
// activate the context
dialogContext.enter();
trap.connect();
});
```

Expand Down
18 changes: 9 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,16 @@ <h1 class="text-3xl font-bold">Loock</h1>
src="https://demo.chialab.io/catalog/media/moon/Apollo_11_Intro.mp4"></video>
</section>
<script type="module">
import { FocusContext } from '/src/index.ts';
import { focusTrapBehavior } from '/src/index.ts';

const article1 = document.querySelector('#article1');
const button1 = article1.querySelector('header button');
const section1 = article1.querySelector('section');
const context1 = new FocusContext(section1);
const behavior1 = focusTrapBehavior(section1);

button1.addEventListener('click', () => {
button1.focus();
context1.enter();
behavior1.connect();
});
</script>
</article>
Expand Down Expand Up @@ -145,18 +145,18 @@ <h1 class="text-3xl font-bold">Loock</h1>
</section>
<code class="text-xs">{ focusContainer: true }</code>
<script type="module">
import { FocusContext } from '/src/index.ts';
import { focusTrapBehavior } from '/src/index.ts';

const article2 = document.querySelector('#article2');
const button2 = article2.querySelector('header button');
const section2 = article2.querySelector('section');
const context2 = new FocusContext(section2, {
const behavior2 = focusTrapBehavior(section2, {
focusContainer: true,
});

button2.addEventListener('click', () => {
button2.focus();
context2.enter();
behavior2.connect();
});
</script>
</article>
Expand Down Expand Up @@ -208,14 +208,14 @@ <h2 class="text-2xl font-bold">Dialog</h2>
</section>
<code class="text-xs">{ inert: true }</code>
<script type="module">
import { FocusContext } from './src/index.ts';
import { focusTrapBehavior } from './src/index.ts';

const article3 = document.querySelector('#article3');
const section3 = article3.querySelector('section');
const button3 = section3.querySelector('button');
const dialog = section3.querySelector('dialog');

const context3 = new FocusContext(dialog.querySelector('.dialog-content'), {
const behavior3 = focusTrapBehavior(dialog.querySelector('.dialog-content'), {
inert: true,
onExit: () => {
dialog.close();
Expand All @@ -224,7 +224,7 @@ <h2 class="text-2xl font-bold">Dialog</h2>
button3.addEventListener('click', () => {
button3.focus();
dialog.showModal();
context3.enter();
behavior3.connect();
});
</script>
</article>
Expand Down
31 changes: 31 additions & 0 deletions src/constants.ts
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)',
];
61 changes: 61 additions & 0 deletions src/findFocusableChildren.ts
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;
});
}
77 changes: 77 additions & 0 deletions src/focusEnterBehavior.ts
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);
},
};
}
74 changes: 74 additions & 0 deletions src/focusFirstChildBehavior.ts
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);
},
};
}
Loading

0 comments on commit 7ee4aa4

Please sign in to comment.