Skip to content

Commit

Permalink
Introduce behaviors
Browse files Browse the repository at this point in the history
  • Loading branch information
edoardocavazza committed Nov 8, 2023
1 parent 232e351 commit 99940f5
Show file tree
Hide file tree
Showing 10 changed files with 753 additions and 483 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.
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)',
];
36 changes: 36 additions & 0 deletions src/findFocusableChildren.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { DEFAULT_IGNORE_SELECTORS, DEFAULT_SELECTORS } from './constants';

/**
* 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;
});
}
74 changes: 74 additions & 0 deletions src/focusEnterBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* 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 element The target element.
* @param options The options.
* @returns The behavior controller.
*/
export function focusEnterBehavior(element: HTMLElement, options: FocusEnterOptions) {
const document = element.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 (element !== activeElement && !element.contains(activeElement)) {
focused = false;
onExit();
}
});
};

return {
get connected() {
return connected;
},
connect() {
if (connected) {
return;
}
connected = true;
focused = false;
element.addEventListener('focusin', onFocusIn);
element.addEventListener('focusout', onFocusOut);
},
disconnect() {
if (!connected) {
return;
}
connected = false;
focused = false;
element.removeEventListener('focusin', onFocusIn);
element.removeEventListener('focusout', onFocusOut);
},
};
}
69 changes: 69 additions & 0 deletions src/focusFirstChildBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { findFocusableChildren } from './findFocusableChildren';

/**
* The options for focus first option on focus enter.
*/
export interface FocusFirstChildOptions {
/**
* The focusable elements.
*/
elements?: HTMLElement[];
/**
* The selectors for focusable nodes.
*/
include?: string[];
/**
* The selectors for ignored nodes.
*/
exclude?: string[];
}

/**
* Focus first option on focus enter.
* @param element The target element.
* @param options The options.
* @returns The behavior controller.
*/
export function focusFirstChildBehavior(element: HTMLElement, options: FocusFirstChildOptions = {}) {
const document = element.ownerDocument;
let activeElement: HTMLElement | null = null;
let connected = false;

const onFocus = () => {
const { include, exclude, elements = findFocusableChildren(element, include, exclude) } = options;
const target = document.activeElement as HTMLElement;
if (target === element) {
if (activeElement && element.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;
element.addEventListener('focus', onFocus, true);
},
disconnect() {
if (!connected) {
return;
}
connected = false;
activeElement = null;
element.removeEventListener('focus', onFocus, true);
},
};
}
Loading

0 comments on commit 99940f5

Please sign in to comment.