-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(cdk-experimental/toolbar): toolbar and widget #31610
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
base: main
Are you sure you want to change the base?
Changes from 3 commits
a2081c7
6371abe
0aa99c0
da7c654
d50e6e3
664a27b
785fee3
bdd8b99
5ffd0f9
b4dbc6f
14bb555
63ce060
a8be36a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") | ||
|
||
package(default_visibility = ["//visibility:public"]) | ||
|
||
ng_project( | ||
name = "toolbar", | ||
srcs = glob( | ||
["**/*.ts"], | ||
exclude = ["**/*.spec.ts"], | ||
), | ||
deps = [ | ||
"//:node_modules/@angular/core", | ||
"//src/cdk-experimental/ui-patterns", | ||
"//src/cdk/a11y", | ||
"//src/cdk/bidi", | ||
], | ||
) | ||
|
||
ts_project( | ||
name = "unit_test_sources", | ||
testonly = True, | ||
srcs = glob( | ||
["**/*.spec.ts"], | ||
), | ||
deps = [ | ||
":toolbar", | ||
"//src/cdk/testing/private", | ||
], | ||
) | ||
|
||
ng_web_test_suite( | ||
name = "unit_tests", | ||
deps = [":unit_test_sources"], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.dev/license | ||
*/ | ||
|
||
export * from './public-api'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.dev/license | ||
*/ | ||
|
||
export {CdkToolbar, CdkToolbarWidget} from './toolbar'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.dev/license | ||
*/ | ||
|
||
import { | ||
afterRenderEffect, | ||
Directive, | ||
ElementRef, | ||
inject, | ||
computed, | ||
input, | ||
booleanAttribute, | ||
signal, | ||
Signal, | ||
OnInit, | ||
OnDestroy, | ||
} from '@angular/core'; | ||
import {ToolbarPattern, RadioButtonPattern, ToolbarWidgetPattern} from '../ui-patterns'; | ||
import {Directionality} from '@angular/cdk/bidi'; | ||
import {_IdGenerator} from '@angular/cdk/a11y'; | ||
|
||
/** Interface for a radio button that can be used with a toolbar. Based on radio-button in ui-patterns */ | ||
interface CdkRadioButtonInterface<V> { | ||
/** The HTML element associated with the radio button. */ | ||
element: Signal<HTMLElement>; | ||
/** Whether the radio button is disabled. */ | ||
disabled: Signal<boolean>; | ||
|
||
pattern: RadioButtonPattern<V>; | ||
} | ||
|
||
interface HasElement { | ||
element: Signal<HTMLElement>; | ||
} | ||
|
||
/** | ||
* Sort directives by their document order. | ||
*/ | ||
function sortDirectives(a: HasElement, b: HasElement) { | ||
return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 | ||
? 1 | ||
: -1; | ||
} | ||
|
||
/** | ||
* A toolbar widget container. | ||
* | ||
* Widgets such as radio groups or buttons are nested within a toolbar to allow for a single | ||
* place of reference for focus and navigation. The CdkToolbar is meant to be used in conjunction | ||
* with CdkToolbarWidget and CdkRadioGroup as follows: | ||
* | ||
* ```html | ||
* <div cdkToolbar> | ||
* <button cdkToolbarWidget>Button</button> | ||
* <div cdkRadioGroup> | ||
* <label cdkRadioButton value="1">Option 1</label> | ||
* <label cdkRadioButton value="2">Option 2</label> | ||
* <label cdkRadioButton value="3">Option 3</label> | ||
* </div> | ||
* </div> | ||
* ``` | ||
*/ | ||
@Directive({ | ||
selector: '[cdkToolbar]', | ||
exportAs: 'cdkToolbar', | ||
host: { | ||
'role': 'toolbar', | ||
'class': 'cdk-toolbar', | ||
'[attr.tabindex]': 'pattern.tabindex()', | ||
'[attr.aria-disabled]': 'pattern.disabled()', | ||
'[attr.aria-orientation]': 'pattern.orientation()', | ||
'[attr.aria-activedescendant]': 'pattern.activedescendant()', | ||
'(keydown)': 'pattern.onKeydown($event)', | ||
'(pointerdown)': 'pattern.onPointerdown($event)', | ||
'(focusin)': 'onFocus()', | ||
}, | ||
}) | ||
export class CdkToolbar<V> { | ||
/** The CdkTabList nested inside of the container. */ | ||
private readonly _cdkWidgets = signal(new Set<CdkRadioButtonInterface<V> | CdkToolbarWidget>()); | ||
|
||
/** A signal wrapper for directionality. */ | ||
textDirection = inject(Directionality).valueSignal; | ||
|
||
/** Sorted UIPatterns of the child widgets */ | ||
items = computed(() => | ||
[...this._cdkWidgets()].sort(sortDirectives).map(widget => widget.pattern), | ||
); | ||
|
||
/** Whether the toolbar is vertically or horizontally oriented. */ | ||
orientation = input<'vertical' | 'horizontal'>('horizontal'); | ||
|
||
/** Whether disabled items in the group should be skipped when navigating. */ | ||
skipDisabled = input(true, {transform: booleanAttribute}); | ||
|
||
/** The focus strategy used by the toolbar. */ | ||
focusMode = input<'roving' | 'activedescendant'>('roving'); | ||
|
||
/** Whether the toolbar is disabled. */ | ||
disabled = input(false, {transform: booleanAttribute}); | ||
|
||
/** Whether focus should wrap when navigating. */ | ||
readonly wrap = input(true, {transform: booleanAttribute}); | ||
|
||
/** The toolbar UIPattern. */ | ||
pattern: ToolbarPattern<V> = new ToolbarPattern<V>({ | ||
...this, | ||
activeItem: signal(undefined), | ||
textDirection: this.textDirection, | ||
focusMode: this.focusMode, | ||
}); | ||
|
||
/** Whether the toolbar has received focus yet. */ | ||
private _hasFocused = signal(false); | ||
|
||
onFocus() { | ||
this._hasFocused.set(true); | ||
} | ||
|
||
constructor() { | ||
afterRenderEffect(() => { | ||
if (!this._hasFocused()) { | ||
this.pattern.setDefaultState(); | ||
} | ||
}); | ||
|
||
afterRenderEffect(() => { | ||
if (typeof ngDevMode === 'undefined' || ngDevMode) { | ||
const violations = this.pattern.validate(); | ||
for (const violation of violations) { | ||
console.error(violation); | ||
} | ||
} | ||
}); | ||
} | ||
|
||
register(widget: CdkRadioButtonInterface<V> | CdkToolbarWidget) { | ||
const widgets = this._cdkWidgets(); | ||
if (!widgets.has(widget)) { | ||
widgets.add(widget); | ||
this._cdkWidgets.set(new Set(widgets)); | ||
} | ||
} | ||
|
||
deregister(widget: CdkRadioButtonInterface<V> | CdkToolbarWidget) { | ||
const widgets = this._cdkWidgets(); | ||
if (widgets.delete(widget)) { | ||
this._cdkWidgets.set(new Set(widgets)); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* A widget within a toolbar. | ||
* | ||
* A widget is anything that is within a toolbar. It should be applied to any native HTML element | ||
* that has the purpose of acting as a widget navigatable within a toolbar. | ||
*/ | ||
@Directive({ | ||
selector: '[cdkToolbarWidget]', | ||
exportAs: 'cdkToolbarWidget', | ||
host: { | ||
'role': 'button', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. widgets might not always be buttons, we should leave the role assignment to developers. https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/examples/toolbar/ at least there are links and spinbuttons. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing the 'role' attribute causes some issues with the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs a larger discussion but we might want to lean on |
||
'class': 'cdk-toolbar-widget', | ||
'[class.cdk-active]': 'pattern.active()', | ||
'[attr.tabindex]': 'pattern.tabindex()', | ||
'[attr.inert]': 'hardDisabled() ? true : null', | ||
'[attr.disabled]': 'hardDisabled() ? true : null', | ||
'[id]': 'pattern.id()', | ||
}, | ||
}) | ||
export class CdkToolbarWidget implements OnInit, OnDestroy { | ||
/** A reference to the widget element. */ | ||
private readonly _elementRef = inject(ElementRef); | ||
|
||
/** The parent CdkToolbar. */ | ||
private readonly _cdkToolbar = inject(CdkToolbar); | ||
|
||
/** A unique identifier for the widget. */ | ||
private readonly _generatedId = inject(_IdGenerator).getId('cdk-toolbar-widget-'); | ||
|
||
/** A unique identifier for the widget. */ | ||
protected id = computed(() => this._generatedId); | ||
|
||
/** The parent Toolbar UIPattern. */ | ||
protected parentToolbar = computed(() => this._cdkToolbar.pattern); | ||
|
||
/** A reference to the widget element to be focused on navigation. */ | ||
element = computed(() => this._elementRef.nativeElement); | ||
|
||
/** Whether the widget is disabled. */ | ||
disabled = input(false, {transform: booleanAttribute}); | ||
|
||
readonly hardDisabled = computed(() => this.pattern.disabled() && this.pattern.tabindex() < 0); | ||
|
||
pattern = new ToolbarWidgetPattern({ | ||
...this, | ||
id: this.id, | ||
element: this.element, | ||
disabled: computed(() => this._cdkToolbar.disabled() || this.disabled() || false), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The list behavior handles the parent/child disabled logic internally, so passing only the current disabled state for the widget should be sufficient. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Due to the fact that widgets do not have selection logic directly applied, the list behavior isn't fully in charge of whether or not a widget can be interacted with. Without having the widget disabled state mimic that of the parent toolbar the native buttons can still be interacted with when the toolbar is disabled. Since interaction begins at the inner most level the Toolbar cannot prevent the click event from propagating to the button. The extra |
||
parentToolbar: this.parentToolbar, | ||
}); | ||
|
||
ngOnInit() { | ||
this._cdkToolbar.register(this); | ||
} | ||
|
||
ngOnDestroy() { | ||
this._cdkToolbar.deregister(this); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.