Skip to content

Instantly share code, notes, and snippets.

@michaelnero
Created May 20, 2021 10:29
Show Gist options
  • Save michaelnero/d9df3573bd61d86ea2bd98fdb11d09c0 to your computer and use it in GitHub Desktop.
Save michaelnero/d9df3573bd61d86ea2bd98fdb11d09c0 to your computer and use it in GitHub Desktop.
Aurelia Listbox
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Dumber Gist</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<base href="/">
</head>
<!--
Dumber Gist uses dumber bundler, the default bundle file
is /dist/entry-bundle.js.
The starting module is pointed to "main" (data-main attribute on script)
which is your src/main.ts.
-->
<body>
<my-app></my-app>
<script src="/dist/entry-bundle.js" data-main="main"></script>
</body>
</html>
{
"dependencies": {
"aurelia": "latest"
}
}
<button
id.bind="id"
class.bind="classNames"
ref="element"
click.delegate="handleClick($event)"
aria-haspopup="true"
aria-controls.bind="ariaControls"
aria-expanded.bind="ariaExpanded"
aria-labelledby.bind="ariaLabelledBy"
disabled.bind="disabled">
<au-slot></au-slot>
</button>
import { IPlatform, bindable } from "aurelia";
import { IObservation, IEffect } from "@aurelia/runtime";
import { IListboxContext, ListboxStates } from "./listbox-context";
import { useId } from "./use-id";
export class ListboxButton {
@bindable() classNames: string;
id: string;
disabled: boolean;
ariaControls: string;
ariaExpanded: boolean;
ariaLabelledBy: string;
effect: IEffect;
constructor(
@IListboxContext private readonly context: IListboxContext,
@IObservation private readonly observation: IObservation,
@IPlatform private readonly platform: IPlatform) {
this.id = `jg-listbox-button-${useId()}`;
}
bound() {
this.context.buttonElement = this.element;
this.effect = this.observation.run(() => {
this.disabled = this.context.disabled;
this.ariaControls = this.context.optionsElement?.id;
this.ariaExpanded = this.context.state === ListboxStates.Open;
this.ariaLabelledBy = this.context.labelElement?.id;
});
}
unbinding() {
this.context.buttonElement = null;
this.effect?.stop();
this.effect = null;
}
handleClick(event: MouseEvent) {
if (this.context.disabled) return;
if (this.context.state === ListboxStates.Open) {
this.context.close();
this.platform.taskQueue.queueTask(() => {
this.context.buttonElement?.focus({ preventScroll: true });
});
} else {
event.preventDefault();
this.context.open();
this.platform.domWriteQueue.queueTask(() => {
this.context.optionsElement?.focus({ preventScroll: true });
});
}
}
}
import { DI } from "aurelia";
import { observable } from "@aurelia/runtime";
export enum ListboxStates { Open, Closed };
export const IListboxContext = DI.createInterface<IListboxContext>(x => x.transient(ListboxContext));
export interface IListboxContext extends ListboxContext { }
class ListboxContext {
@observable() disabled: boolean;
@observable() state: ListboxStates;
@observable() labelElement: HTMLLabelElement;
@observable() buttonElement: HTMLButtonElement;
@observable() optionsElement: HTMLUListElement;
open(): void {
// In reality, the implementation is more complicated than this.
this.state = ListboxStates.Open;
}
close(): void {
// In reality, the implementation is more complicated than this.
this.state = ListboxStates.Closed;
}
}
<label ref="element" id.bind="id" class.bind="classNames" click.delegate="handleClick()">
<au-slot></au-slot>
</label>
import { bindable } from "aurelia";
import { IListboxContext } from "./listbox-context";
import { useId } from "./use-id";
export class ListboxLabel {
@bindable() classNames: string;
@bindable() element: HTMLLabelElement;
id: string;
constructor(@IListboxContext private readonly context: IListboxContext) {
this.id = `jg-listbox-label-${useId()}`;
}
bound() {
this.context.labelElement = this.element;
}
handleClick() {
this.context.buttonElement?.focus({ preventScroll: true });
}
}
<ul
show.bind="isOpen"
id.bind="id"
class.bind="classNames"
role="listbox"
tabindex="0"
ref="element"
aria-activedescendant.bind="ariaActiveDescendant"
aria-labelledby.bind="ariaLabelledBy">
<au-slot></au-slot>
</ul>
import { bindable } from "aurelia";
import { IObservation, IEffect } from "@aurelia/runtime";
import { IListboxContext } from "./listbox-context";
export class ListboxOptions {
@bindable() classNames: string;
@bindable() element: HTMLUListElement;
id: string;
ariaLabelledBy: string;
ariaActiveDescendant: string;
constructor(@IListboxContext private readonly context: IListboxContext) {
}
bound() {
this.context.buttonElement = this.element;
this.effect = this.observation.run(() => {
this.ariaLabelledBy = this.context.labelElement?.id ?? this.context.buttonElement?.id;
// Also set this.ariaActiveDescendant
});
}
unbinding() {
this.context.buttonElement = null;
this.effect?.stop();
this.effect = null;
}
}
<div id.bind="id">
<au-slot></au-slot>
</div>
import { bindable, IPlatform } from "aurelia";
import { newInstanceForScope } from "@aurelia/kernel";
import { IListboxContext, ListboxStates } from "./listbox-context";
import { useId } from "./use-id";
export class Listbox {
id: string;
constructor(@newInstanceForScope(IListboxContext) context: IListboxContext) {
this.id = `jg-listbox-${useId()}`;
}
}
let id = 0;
function generateId(): number {
return ++id;
}
export function useId(): number {
return generateId();
}
import Aurelia from 'aurelia';
import { MyApp } from './my-app';
Aurelia.app(MyApp).start();
<import from="./components/listbox"></import>
<import from="./components/listbox-button"></import>
<import from="./components/listbox-label"></import>
<import from="./components/listbox-options"></import>
<h1>${message}</h1>
<listbox>
<template au-slot>
<div>
<listbox-label class-names="sr-only">
<template au-slot>Button label</template>
</listbox-label>
</div>
<div>
<listbox-button>
<template au-slot>Click me</template>
</listbox-button>
</div>
<listbox-options>
</listbox-options>
</template>
</listbox>
export class MyApp {
public message: string = 'Hello Aurelia 2!';
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment