diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 5dacc5cdfb16..bd5a1cba4457 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -52,7 +52,9 @@ describe('Combobox', () => { const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); - function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + function setupCombobox( + opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, + ) { TestBed.configureTestingModule({}); fixture = TestBed.createComponent(ComboboxListboxExample); const testComponent = fixture.componentInstance; @@ -60,6 +62,9 @@ describe('Combobox', () => { if (opts.filterMode) { testComponent.filterMode.set(opts.filterMode); } + if (opts.readonly) { + testComponent.readonly.set(true); + } fixture.detectChanges(); defineTestVariables(); @@ -526,6 +531,35 @@ describe('Combobox', () => { }); }); + describe('Readonly', () => { + beforeEach(() => setupCombobox({readonly: true})); + + it('should close on selection', () => { + focus(); + down(); + click(getOption('Alabama')!); + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should clear selection on escape when closed', () => { + focus(); + down(); + enter(); + expect(inputElement.value).toBe('Alabama'); + escape(); + expect(inputElement.value).toBe(''); + }); + }); + // describe('with programmatic value changes', () => { // // TODO(wagnermaciel): Figure out if there's a way to automatically update the // // input value when the popup value signal is updated programmatically. @@ -590,7 +624,9 @@ describe('Combobox', () => { const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); - function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + function setupCombobox( + opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}, + ) { TestBed.configureTestingModule({}); fixture = TestBed.createComponent(ComboboxTreeExample); const testComponent = fixture.componentInstance; @@ -598,6 +634,9 @@ describe('Combobox', () => { if (opts.filterMode) { testComponent.filterMode.set(opts.filterMode); } + if (opts.readonly) { + testComponent.readonly.set(true); + } fixture.detectChanges(); defineTestVariables(); @@ -1053,6 +1092,40 @@ describe('Combobox', () => { expect(getTreeItem('August')!.getAttribute('aria-selected')).toBe('true'); }); }); + + describe('Readonly', () => { + beforeEach(() => setupCombobox({readonly: true})); + + it('should close on selection', () => { + focus(); + down(); + right(); + right(); + enter(); + expect(inputElement.value).toBe('December'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on escape', () => { + focus(); + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should clear selection on escape when closed', () => { + focus(); + down(); + right(); + right(); + enter(); + expect(inputElement.value).toBe('December'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + escape(); + expect(inputElement.value).toBe(''); + }); + }); }); }); @@ -1061,6 +1134,7 @@ describe('Combobox', () => {
{ imports: [Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer, Listbox, Option], }) class ComboboxListboxExample { + readonly = signal(false); + searchString = signal(''); value = signal([]); - filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - searchString = signal(''); - options = computed(() => states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), ); @@ -1103,6 +1176,7 @@ class ComboboxListboxExample {
@@ -1157,13 +1231,11 @@ class ComboboxListboxExample { ], }) class ComboboxTreeExample { - value = signal([]); - - filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - + readonly = signal(false); searchString = signal(''); - + value = signal([]); nodes = computed(() => this.filterTreeNodes(TREE_NODES)); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); firstMatch = computed(() => { const flatNodes = this.flattenTreeNodes(this.nodes()); diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index 8490508f7476..20d0e170ae23 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -119,6 +119,7 @@ export class Combobox { '[attr.aria-controls]': 'combobox.pattern.popupId()', '[attr.aria-haspopup]': 'combobox.pattern.hasPopup()', '[attr.aria-autocomplete]': 'combobox.pattern.autocomplete()', + '[attr.readonly]': 'combobox.pattern.readonly()', }, }) export class ComboboxInput { diff --git a/src/aria/ui-patterns/combobox/combobox.spec.ts b/src/aria/ui-patterns/combobox/combobox.spec.ts index 9d18703e841e..18fe774b8b9b 100644 --- a/src/aria/ui-patterns/combobox/combobox.spec.ts +++ b/src/aria/ui-patterns/combobox/combobox.spec.ts @@ -585,6 +585,35 @@ describe('Combobox with Listbox Pattern', () => { }); }); }); + + describe('Readonly mode', () => { + it('should select and close on selection', () => { + const {combobox, listbox, inputEl} = getPatterns({readonly: true}); + combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + expect(combobox.expanded()).toBe(false); + }); + + it('should close on escape', () => { + const {combobox} = getPatterns({readonly: true}); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onKeydown(escape()); + expect(combobox.expanded()).toBe(false); + }); + + it('should clear selection on escape when already closed', () => { + const {combobox, listbox} = getPatterns({readonly: true}); + combobox.onPointerup(clickOption(listbox.inputs.items(), 2)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.inputs.value()).toEqual(['Banana']); + combobox.onKeydown(escape()); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); + }); + }); }); describe('Combobox with Tree Pattern', () => { @@ -894,4 +923,36 @@ describe('Combobox with Tree Pattern', () => { }); }); }); + + describe('Readonly mode', () => { + it('should select and close on selection', () => { + const {combobox, tree, inputEl} = getPatterns({readonly: true}); + combobox.onPointerup(clickInput(inputEl)); + expect(combobox.expanded()).toBe(true); + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); + expect(tree.inputs.value()).toEqual(['Fruit']); + expect(inputEl.value).toBe('Fruit'); + expect(combobox.expanded()).toBe(false); + }); + + it('should close on escape', () => { + const {combobox} = getPatterns({readonly: true}); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onKeydown(escape()); + expect(combobox.expanded()).toBe(false); + }); + + it('should clear selection on escape when already closed', () => { + const {combobox, tree, inputEl} = getPatterns({readonly: true}); + combobox.onPointerup(clickInput(inputEl)); + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); + expect(tree.inputs.value()).toEqual(['Fruit']); + expect(inputEl.value).toBe('Fruit'); + expect(combobox.expanded()).toBe(false); + combobox.onKeydown(escape()); + expect(tree.inputs.value()).toEqual([]); + expect(inputEl.value).toBe(''); + }); + }); }); diff --git a/src/aria/ui-patterns/combobox/combobox.ts b/src/aria/ui-patterns/combobox/combobox.ts index 32887abd1e94..2af80ffcfdab 100644 --- a/src/aria/ui-patterns/combobox/combobox.ts +++ b/src/aria/ui-patterns/combobox/combobox.ts @@ -144,16 +144,24 @@ export class ComboboxPattern, V> { /** The ARIA role of the popup associated with the combobox. */ hasPopup = computed(() => this.inputs.popupControls()?.role() || null); - /** Whether the combobox is interactive. */ - isInteractive = computed(() => !this.inputs.disabled() && !this.inputs.readonly()); + /** Whether the combobox is read-only. */ + readonly = computed(() => this.inputs.readonly() || null); /** The keydown event manager for the combobox. */ keydown = computed(() => { if (!this.expanded()) { - return new KeyboardEventManager() + const manager = new KeyboardEventManager() .on('ArrowDown', () => this.open({first: true})) .on('ArrowUp', () => this.open({last: true})) .on('Escape', () => this.close({reset: true})); + + if (this.readonly()) { + manager + .on('Enter', () => this.open({selected: true})) + .on(' ', () => this.open({selected: true})); + } + + return manager; } const popupControls = this.inputs.popupControls(); @@ -170,6 +178,10 @@ export class ComboboxPattern, V> { .on('Escape', () => this.close({reset: true})) .on('Enter', () => this.select({commit: true, close: true})); + if (this.readonly()) { + manager.on(' ', () => this.select({commit: true, close: true})); + } + if (popupControls.role() === 'tree') { const treeControls = popupControls as ComboboxTreeControls; @@ -196,7 +208,11 @@ export class ComboboxPattern, V> { } if (e.target === this.inputs.inputEl()) { - this.open(); + if (this.readonly()) { + this.expanded() ? this.close() : this.open({selected: true}); + } else { + this.open(); + } } }), ); @@ -205,21 +221,21 @@ export class ComboboxPattern, V> { /** Handles keydown events for the combobox. */ onKeydown(event: KeyboardEvent) { - if (this.isInteractive()) { + if (!this.inputs.disabled()) { this.keydown().handle(event); } } /** Handles pointerup events for the combobox. */ onPointerup(event: PointerEvent) { - if (this.isInteractive()) { + if (!this.inputs.disabled()) { this.pointerup().handle(event); } } /** Handles input events for the combobox. */ onInput(event: Event) { - if (!this.isInteractive()) { + if (this.inputs.disabled() || this.inputs.readonly()) { return; } @@ -253,7 +269,7 @@ export class ComboboxPattern, V> { /** Handles focus out events for the combobox. */ onFocusOut(event: FocusEvent) { - if (this.inputs.disabled() || this.inputs.readonly()) { + if (this.inputs.disabled()) { return; } @@ -385,18 +401,23 @@ export class ComboboxPattern, V> { popupControls?.clearSelection(); } } + + this.close(); + + if (!this.readonly()) { + this.inputs.popupControls()?.clearSelection(); + } } /** Opens the combobox. */ - open(nav?: {first?: boolean; last?: boolean}) { + open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) { this.expanded.set(true); const inputEl = this.inputs.inputEl(); - if (inputEl) { + if (inputEl && this.inputs.filterMode() === 'highlight') { const isHighlighting = inputEl.selectionStart !== inputEl.value.length; this.inputs.inputValue?.set(inputEl.value.slice(0, inputEl.selectionStart || 0)); - if (!isHighlighting) { this.highlightedItem.set(undefined); } @@ -408,6 +429,10 @@ export class ComboboxPattern, V> { if (nav?.last) { this.last(); } + if (nav?.selected) { + const selectedItem = this.inputs.popupControls()?.getSelectedItem(); + selectedItem ? this.inputs.popupControls()?.focus(selectedItem) : this.first(); + } } /** Navigates to the next focusable item in the combobox popup. */ diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts index a2cb39aef496..c6064ac2edcd 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts @@ -62,7 +62,7 @@ export class ComboboxAutoSelectExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/combobox-examples.css b/src/components-examples/aria/combobox/combobox-examples.css index 54e96b72490d..a6ff5a0e6e80 100644 --- a/src/components-examples/aria/combobox/combobox-examples.css +++ b/src/components-examples/aria/combobox/combobox-examples.css @@ -2,22 +2,30 @@ position: relative; width: 300px; display: flex; - overflow: hidden; flex-direction: column; border: 1px solid var(--mat-sys-outline); border-radius: var(--mat-sys-corner-extra-small); } -.example-combobox-container:has(.example-combobox-input[aria-expanded='true']) { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} - .example-combobox-input-container { display: flex; - overflow: hidden; position: relative; align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input { + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-input[readonly='true'] { + cursor: pointer; + padding: 0.7rem 1rem; +} + +.example-combobox-container:focus-within .example-combobox-input { + outline: 1.5px solid var(--mat-sys-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 25%, transparent); } .example-icon { @@ -35,6 +43,18 @@ opacity: 0.8; } +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 0; + opacity: 0.8; + transition: transform 0.2s ease; +} + +.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { + transform: rotate(180deg); +} + .example-combobox-input { width: 100%; border: none; @@ -48,8 +68,7 @@ margin: 0; padding: 0; border: 1px solid var(--mat-sys-outline); - border-bottom-right-radius: var(--mat-sys-corner-extra-small); - border-bottom-left-radius: var(--mat-sys-corner-extra-small); + border-radius: var(--mat-sys-corner-extra-small); background-color: var(--mat-sys-surface); } diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts index 074310e88c1f..c5cf68082616 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts @@ -71,7 +71,7 @@ export class ComboboxHighlightExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts index 1493d28eb0b2..b259f5b317f0 100644 --- a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts +++ b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts @@ -71,7 +71,7 @@ export class ComboboxManualExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html new file mode 100644 index 000000000000..d32ee7623a8c --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html @@ -0,0 +1,33 @@ +
+
+ + arrow_drop_down +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts new file mode 100644 index 000000000000..80570ab5ea42 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts @@ -0,0 +1,78 @@ +/** + * @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 { + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, +} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @title Readonly combobox. */ +@Component({ + selector: 'combobox-readonly-example', + templateUrl: 'combobox-readonly-example.html', + styleUrl: '../combobox-examples.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxReadonlyExample { + popover = viewChild('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(Combobox); + + options = () => states; + searchString = signal(''); + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.listbox()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} + +const states = ['Option 1', 'Option 2', 'Option 3']; diff --git a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts index 2ccc5c197a82..ed3900a48bde 100644 --- a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.ts @@ -97,7 +97,7 @@ export class ComboboxTreeAutoSelectExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts index 013aaec24480..2432062b06cd 100644 --- a/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-highlight/combobox-tree-highlight-example.ts @@ -97,7 +97,7 @@ export class ComboboxTreeHighlightExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts index 3e2d77bda2ae..8429cc898e27 100644 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts @@ -97,7 +97,7 @@ export class ComboboxTreeManualExample { if (comboboxRect) { popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; popoverEl.style.left = `${comboboxRect.left - 1}px`; } diff --git a/src/components-examples/aria/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index ca1e1f5c5dde..a5a42a9599e3 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -4,3 +4,4 @@ export {ComboboxHighlightExample} from './combobox-highlight/combobox-highlight- export {ComboboxTreeManualExample} from './combobox-tree-manual/combobox-tree-manual-example'; export {ComboboxTreeAutoSelectExample} from './combobox-tree-auto-select/combobox-tree-auto-select-example'; export {ComboboxTreeHighlightExample} from './combobox-tree-highlight/combobox-tree-highlight-example'; +export {ComboboxReadonlyExample} from './combobox-readonly/combobox-readonly-example'; diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 2a5bd48182a0..1de5491b6488 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -29,5 +29,10 @@

Combobox with tree popup and auto-select filtering

Combobox with tree popup and highlight filtering

+ +
+

Readonly Combobox

+ +
diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index a013d1dc1f5f..5dc4c74ce522 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -13,6 +13,7 @@ import { ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, ComboboxTreeManualExample, + ComboboxReadonlyExample, } from '@angular/components-examples/aria/combobox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @@ -26,6 +27,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; ComboboxTreeManualExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, + ComboboxReadonlyExample, ], changeDetection: ChangeDetectionStrategy.OnPush, })