Skip to content

docs(cdk-experimental/listbox): add docs & examples #25317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/cdk-experimental/listbox/listbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
The `@angular/cdk/listbox` module provides directives to help create custom listbox interactions
based on the [WAI ARIA listbox pattern][aria].

By using `@angular/cdk/listbox` you get all the expected behaviors for an accessible experience,
including bidi layout support, keyboard interaction, and focus management. All directives apply
their associated ARIA roles to their host element.

### Supported ARIA Roles

The directives in `@angular/cdk/listbox` set the appropriate roles on their host element.

| Directive | ARIA Role |
|------------|-----------|
| cdkOption | option |
| cdkListbox | listbox |

### CSS Styles and Classes

The `@angular/cdk/listbox` is designed to be highly customizable to your needs. It therefore does not
make any assumptions about how elements should be styled. You are expected to apply any required
CSS styles, but the directives do apply CSS classes to make it easier for you to add custom styles.
The available CSS classes are listed below, by directive.

| Directive | CSS Class | Applied... |
|:---------------|--------------------|-------------------------|
| cdkOption | .cdk-option | Always |
| cdkOption | .cdk-option-active | If the option is active |
| cdkListbox | .cdk-listbox | Always |

In addition to CSS classes, these directives add aria attributes that can be targeted in CSS.

| Directive | Attribute Selector | Applied... |
|:-----------|----------------------------------|------------------------------------------|
| cdkOption | \[aria-disabled="true"] | If the option is disabled |
| cdkOption | \[aria-selected="true"] | If the option is selected |
| cdkListbox | \[aria-disabled="true"] | If the listbox is selected |
| cdkListbox | \[aria-multiselectable="true"] | If the listbox allows multiple selection |
| cdkListbox | \[aria-orientation="horizontal"] | If the listbox is oriented horizontally |
| cdkListbox | \[aria-orientation="vertical"] | If the listbox is oriented vertically |

### Getting started

Import the `CdkListboxModule` into the `NgModule` in which you want to create a listbox. You can
then apply listbox directives to build your custom listbox. A typical listbox consists of the
following directives:

- `cdkListbox` - Added to the container element containing the options to be selected
- `cdkOption` - Added to each selectable option in the listbox

<!-- example({
"example": "cdk-listbox-overview",
"file": "cdk-listbox-overview-example.html",
"region": "listbox"
}) -->

### Option values

Each option in a listbox is bound to the value it represents when selected, e.g.
`<li cdkOption="red">Red</li>`. Within a single listbox, each option must have a unique value. If
an option is not explicitly given a value, its value is considered to be `''` (empty string), e.g.
`<li cdkOption>No color preference</li>`.

<!-- example({
"example": "cdk-listbox-overview",
"file": "cdk-listbox-overview-example.html",
"region": "option"
}) -->

### Single vs multiple selection

Listboxes only support a single selected option at a time by default, but adding
`cdkListboxMultiple` will enable selecting more than one option.

<!-- example({
"example": "cdk-listbox-multiple",
"file": "cdk-listbox-multiple-example.html",
"region": "listbox"
}) -->

### Listbox value

The listbox's value is an array containing the values of the selected option(s). This is true even
for the single selection listbox, whose value is an array containing a single element. The listbox's
value can be bound using `[cdkListboxValue]` and `(cdkListboxValueChange)`.

<!-- example({
"example": "cdk-listbox-value-binding",
"file": "cdk-listbox-value-binding-example.html",
"region": "listbox"
}) -->

Internally the listbox compares the listbox value against the individual option values using
`Object.is` to determine which options should appear selected. If your option values are complex
objects, you should provide a custom comparison function instead. This can be set via the
`cdkListboxCompareWith` input on the listbox.

<!-- example({
"example": "cdk-listbox-compare-with",
"file": "cdk-listbox-compare-with-example.html",
"region": "listbox"
}) -->

### Angular Forms support

The CDK Listbox supports both template driven forms and reactive forms.

<!-- example({
"example": "cdk-listbox-template-forms",
"file": "cdk-listbox-template-forms-example.html",
"region": "listbox"
}) -->

<!-- example({
"example": "cdk-listbox-reactive-forms",
"file": "cdk-listbox-reactive-forms-example.html",
"region": "listbox"
}) -->

#### Forms validation

The CDK listbox integrates with Angular's form validation API and has the following built-in
validation errors:

- `cdkListboxUnexpectedOptionValues` - Raised when the bound value contains values that do not
appear as option value in the listbox. The validation error contains a `values` property that
lists the invalid values
- `cdkListboxUnexpectedMultipleValues` - Raised when a single-selection listbox is bound to a value
containing multiple selected options.

<!-- example({
"example": "cdk-listbox-forms-validation",
"file": "cdk-listbox-forms-validation-example.ts",
"region": "errors"
}) -->

### Disabling options

You can disable options for selection by setting `cdkOptionDisabled`.
In addition, the entire listbox control can be disabled by setting `cdkListboxDisabled` on the
listbox element.

<!-- example({
"example": "cdk-listbox-disabled",
"file": "cdk-listbox-disabled-example.html",
"region": "listbox"
}) -->

### Accessibility

The directives defined in `@angular/cdk/listbox` follow accessibility best practices as defined
in the [ARIA spec][aria]. Keyboard interaction is supported as defined in the
[ARIA listbox keyboard interaction spec][keyboard] _without_ the optional selection follows focus
logic (TODO: should we make this an option?).

#### Listbox label

Always give the listbox a meaningful label for screen readers. If your listbox has a visual label,
you can associate it with the listbox using `aria-labelledby`, otherwise you should provide a
screen-reader-only label with `aria-label`.

#### Roving tabindex vs active descendant

By default, the CDK listbox uses the [roving tabindex][roving-tabindex] strategy to manage focus.
If you prefer to use the [aria-activedescendant][activedescendant] strategy instead, set
`useActiveDescendant=true` on the listbox.

<!-- example({
"example": "cdk-listbox-activedescendant",
"file": "cdk-listbox-activedescendant-example.html",
"region": "listbox"
}) -->

#### Orientation

Listboxes assume a vertical orientation by default, but can be customized by setting the
`cdkListboxOrientation` input. Note that this only affects the keyboard navigation. You
will still need to adjust your CSS styles to change the visual appearance.

<!-- example({
"example": "cdk-listbox-horizontal",
"file": "cdk-listbox-horizontal-example.html",
"region": "listbox"
}) -->

#### Option typeahead

The CDK listbox supports typeahead based on the option text. If the typeahead text for your options
needs to be different than the display text (e.g. to exclude emoji), this can be accomplished by
setting the `cdkOptionTypeaheadLabel` on the option.

<!-- example({
"example": "cdk-listbox-custom-typeahead",
"file": "cdk-listbox-custom-typeahead-example.html",
"region": "listbox"
}) -->

#### Keyboard navigation options

When using keyboard navigation to navigate through the options, the navigation wraps when attempting
to navigate past the start or end of the options. To change this, set
`cdkListboxNavigationWrapDisabled` on the listbox.

Keyboard navigation skips disabled options by default. To change this set
`cdkListboxNavigatesDisabledOptions` on the listbox.

<!-- example({
"example": "cdk-listbox-custom-navigation",
"file": "cdk-listbox-custom-navigation-example.html",
"region": "listbox"
}) -->

<!-- links -->

[aria]: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ 'WAI ARIA Listbox Pattern'
[keyboard]: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/#keyboard-interaction-11 'WAI ARIA Listbox Keyboard Interaction'
[roving-tabindex]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#technique_1_roving_tabindex 'MDN Roving Tabindex Technique'
[activedescendant]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#technique_2_aria-activedescendant 'MDN aria-activedescendant Technique'
4 changes: 2 additions & 2 deletions src/cdk-experimental/listbox/listbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,8 +908,8 @@ describe('CdkOption and CdkListbox', () => {
[cdkListboxDisabled]="isListboxDisabled"
[cdkListboxUseActiveDescendant]="isActiveDescendant"
[cdkListboxOrientation]="orientation"
[cdkListboxKeyboardNavigationWraps]="navigationWraps"
[cdkListboxKeyboardNavigationSkipsDisabled]="navigationSkipsDisabled"
[cdkListboxNavigationWrapDisabled]="!navigationWraps"
[cdkListboxNavigatesDisabledOptions]="!navigationSkipsDisabled"
(cdkListboxValueChange)="onSelectionChange($event)">
<div cdkOption="apple"
[cdkOptionDisabled]="isAppleDisabled"
Expand Down
42 changes: 19 additions & 23 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,8 @@ class ListboxSelectionModel<T> extends SelectionModel<T> {
'[id]': 'id',
'[attr.aria-selected]': 'isSelected() || null',
'[attr.tabindex]': '_getTabIndex()',
'[attr.aria-disabled]': 'disabled',
'[class.cdk-option-disabled]': 'disabled',
'[attr.aria-disabled]': 'disabled || null',
'[class.cdk-option-active]': 'isActive()',
'[class.cdk-option-selected]': 'isSelected()',
'(click)': '_clicked.next($event)',
'(focus)': '_handleFocus()',
},
Expand Down Expand Up @@ -245,8 +243,8 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
'class': 'cdk-listbox',
'[id]': 'id',
'[attr.tabindex]': '_getTabIndex()',
'[attr.aria-disabled]': 'disabled',
'[attr.aria-multiselectable]': 'multiple',
'[attr.aria-disabled]': 'disabled || null',
'[attr.aria-multiselectable]': 'multiple || null',
'[attr.aria-activedescendant]': '_getAriaActiveDescendant()',
'[attr.aria-orientation]': 'orientation',
'(focus)': '_handleFocus()',
Expand Down Expand Up @@ -360,28 +358,28 @@ export class CdkListbox<T = unknown>
* Whether the keyboard navigation should wrap when the user presses arrow down on the last item
* or arrow up on the first item.
*/
@Input('cdkListboxKeyboardNavigationWraps')
get keyboardNavigationWraps() {
return this._keyboardNavigationWraps;
@Input('cdkListboxNavigationWrapDisabled')
get navigationWrapDisabled() {
return this._navigationWrapDisabled;
}
set keyboardNavigationWraps(wrap: BooleanInput) {
this._keyboardNavigationWraps = coerceBooleanProperty(wrap);
this.listKeyManager?.withWrap(this._keyboardNavigationWraps);
set navigationWrapDisabled(wrap: BooleanInput) {
this._navigationWrapDisabled = coerceBooleanProperty(wrap);
this.listKeyManager?.withWrap(!this._navigationWrapDisabled);
}
private _keyboardNavigationWraps = true;
private _navigationWrapDisabled = false;

/** Whether keyboard navigation should skip over disabled items. */
@Input('cdkListboxKeyboardNavigationSkipsDisabled')
get keyboardNavigationSkipsDisabled() {
return this._keyboardNavigationSkipsDisabled;
@Input('cdkListboxNavigatesDisabledOptions')
get navigateDisabledOptions() {
return this._navigateDisabledOptions;
}
set keyboardNavigationSkipsDisabled(skip: BooleanInput) {
this._keyboardNavigationSkipsDisabled = coerceBooleanProperty(skip);
set navigateDisabledOptions(skip: BooleanInput) {
this._navigateDisabledOptions = coerceBooleanProperty(skip);
this.listKeyManager?.skipPredicate(
this._keyboardNavigationSkipsDisabled ? this._skipDisabledPredicate : this._skipNonePredicate,
this._navigateDisabledOptions ? this._skipNonePredicate : this._skipDisabledPredicate,
);
}
private _keyboardNavigationSkipsDisabled = true;
private _navigateDisabledOptions = false;

/** Emits when the selected value(s) in the listbox change. */
@Output('cdkListboxValueChange') readonly valueChange = new Subject<ListboxValueChangeEvent<T>>();
Expand Down Expand Up @@ -862,14 +860,12 @@ export class CdkListbox<T = unknown>
/** Initialize the key manager. */
private _initKeyManager() {
this.listKeyManager = new ActiveDescendantKeyManager(this.options)
.withWrap(this._keyboardNavigationWraps)
.withWrap(!this._navigationWrapDisabled)
.withTypeAhead()
.withHomeAndEnd()
.withAllowedModifierKeys(['shiftKey'])
.skipPredicate(
this._keyboardNavigationSkipsDisabled
? this._skipDisabledPredicate
: this._skipNonePredicate,
this._navigateDisabledOptions ? this._skipNonePredicate : this._skipDisabledPredicate,
);

if (this.orientation === 'vertical') {
Expand Down
26 changes: 26 additions & 0 deletions src/components-examples/cdk-experimental/listbox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
load("//tools:defaults.bzl", "ng_module")

package(default_visibility = ["//visibility:public"])

ng_module(
name = "listbox",
srcs = glob(["**/*.ts"]),
assets = glob([
"**/*.html",
"**/*.css",
]),
deps = [
"//src/cdk-experimental/listbox",
"@npm//@angular/common",
"@npm//@angular/forms",
],
)

filegroup(
name = "source-files",
srcs = glob([
"**/*.html",
"**/*.css",
"**/*.ts",
]),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.example-listbox-container {
display: block;
width: 250px;
border: 1px solid black;
}

.example-listbox-label {
display: block;
padding: 5px;
}

.example-listbox {
list-style: none;
padding: 0;
margin: 0;
}

.example-option {
position: relative;
padding: 5px 5px 5px 25px;
}

.example-option[aria-selected]::before {
content: '';
display: block;
width: 20px;
height: 20px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="m9.55 18-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4Z"/></svg>'); /* stylelint-disable-line */
background-size: cover;
position: absolute;
left: 2px;
}

.example-listbox:focus .cdk-option-active {
background: rgba(0, 0, 0, 0.2);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="example-listbox-container">
<!-- #docregion listbox -->
<label class="example-listbox-label" id="example-spatula-label">
Spatula Features
</label>
<ul cdkListbox
cdkListboxMultiple
cdkListboxUseActiveDescendant
aria-labelledby="example-spatula-label"
class="example-listbox">
<li *ngFor="let feature of features"
[cdkOption]="feature"
class="example-option">
{{feature}}
</li>
</ul>
<!-- #enddocregion listbox -->
</div>
Loading