diff --git a/src/components-examples/aria/select/BUILD.bazel b/src/components-examples/aria/select/BUILD.bazel new file mode 100644 index 000000000000..cf82f9bf6ef9 --- /dev/null +++ b/src/components-examples/aria/select/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "select", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/combobox", + "//src/aria/listbox", + "//src/cdk/overlay", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/select/index.ts b/src/components-examples/aria/select/index.ts new file mode 100644 index 000000000000..2ceef481d338 --- /dev/null +++ b/src/components-examples/aria/select/index.ts @@ -0,0 +1,3 @@ +export {SelectDisabledExample} from './select-disabled/select-disabled-example'; +export {SelectMultiExample} from './select-multi/select-multi-example'; +export {SelectExample} from './select/select-example'; diff --git a/src/components-examples/aria/select/select-disabled/select-disabled-example.html b/src/components-examples/aria/select/select-disabled/select-disabled-example.html new file mode 100644 index 000000000000..a64296a5d06d --- /dev/null +++ b/src/components-examples/aria/select/select-disabled/select-disabled-example.html @@ -0,0 +1,28 @@ +
+
+
+ Select an option +
+ + arrow_drop_down +
+ + + +
+
+ @for (item of items; track item) { +
+ + {{item}} + +
+ } +
+
+
+
+
diff --git a/src/components-examples/aria/select/select-disabled/select-disabled-example.ts b/src/components-examples/aria/select/select-disabled/select-disabled-example.ts new file mode 100644 index 000000000000..aacc40f17b4d --- /dev/null +++ b/src/components-examples/aria/select/select-disabled/select-disabled-example.ts @@ -0,0 +1,38 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Aria select disabled example. */ +@Component({ + selector: 'select-disabled-example', + templateUrl: 'select-disabled-example.html', + styleUrl: '../select.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectDisabledExample { + /** The items available for selection. */ + items = ['Option 1', 'Option 2', 'Option 3']; +} diff --git a/src/components-examples/aria/select/select-multi/select-multi-example.html b/src/components-examples/aria/select/select-multi/select-multi-example.html new file mode 100644 index 000000000000..29e40ca53586 --- /dev/null +++ b/src/components-examples/aria/select/select-multi/select-multi-example.html @@ -0,0 +1,27 @@ +
+
+
+ {{ displayValue() }} +
+ + arrow_drop_down +
+ + + +
+
+ @for (item of items; track item) { +
+ {{item}} + +
+ } +
+
+
+
+
diff --git a/src/components-examples/aria/select/select-multi/select-multi-example.ts b/src/components-examples/aria/select/select-multi/select-multi-example.ts new file mode 100644 index 000000000000..8d553c4a84ac --- /dev/null +++ b/src/components-examples/aria/select/select-multi/select-multi-example.ts @@ -0,0 +1,74 @@ +/** + * @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, + computed, + viewChild, + viewChildren, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Aria multiselect example. */ +@Component({ + selector: 'select-multi-example', + templateUrl: 'select-multi-example.html', + styleUrl: '../select.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectMultiExample { + /** The options available in the listbox. */ + options = viewChildren>(Option); + + /** The combobox listbox popup. */ + listbox = viewChild>(Listbox); + + /** The visible label displayed to the user. */ + displayValue = computed(() => { + const values = this.listbox()?.values(); + + if (!values?.length) { + return 'Select a day'; + } + + if (values.length <= 2) { + return values.join(', '); + } + + return `${values[0]} + ${values.length - 1} more`; + }); + + /** The items available for selection. */ + items = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + constructor() { + // Scrolls to the active item when the active option changes. + afterRenderEffect(() => { + const option = this.options().find(opt => opt.active()); + option?.element.scrollIntoView({block: 'nearest'}); + }); + } +} diff --git a/src/components-examples/aria/select/select.css b/src/components-examples/aria/select/select.css new file mode 100644 index 000000000000..282b043531f1 --- /dev/null +++ b/src/components-examples/aria/select/select.css @@ -0,0 +1,137 @@ +.example-select { + position: relative; + display: flex; + align-items: center; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + + /* stylelint-disable-next-line material/no-prefixes -- Valid in all remotely recent browsers. */ + width: fit-content; +} + +.example-select:has([ngComboboxInput][aria-disabled='true']) { + opacity: 0.7; + background-color: var(--mat-sys-surface-dim); +} + +.example-select:focus-within { + outline-offset: -2px; + outline: 2px solid var(--mat-sys-primary); +} + +.example-arrow, +.example-select-value { + position: absolute; + pointer-events: none; +} + +.example-select-value { + display: flex; + gap: 1rem; + left: 1rem; + width: calc(100% - 4rem); +} + +.example-select-label { + text-overflow: ellipsis; + text-wrap-mode: nowrap; + overflow: hidden; +} + +.example-arrow, +.example-select-icon { + font-size: 1.25rem; + opacity: 0.875; +} + +.example-arrow { + right: 1rem; + transition: transform 0.2s ease-in-out; +} + +[ngComboboxInput] { + cursor: pointer; + padding: 0.7rem 3rem; + opacity: 0; +} + +[ngComboboxInput][aria-disabled='true'] { + cursor: default; +} + +[ngComboboxInput][aria-expanded='true'] + .example-arrow { + transform: rotate(180deg); +} + +[ngCombobox]:has([aria-expanded='false']) .example-popup { + display: none; +} + +.example-popup { + width: 100%; + margin-top: 2px; + padding: 0.1rem; + max-height: 11rem; + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); + border: 1px solid var(--mat-sys-outline); +} + +.example-no-results { + padding: 1rem; +} + +[ngListbox] { + gap: 2px; + width: 100%; + height: 100%; + display: flex; + overflow: auto; + flex-direction: column; +} + +[ngOption] { + display: flex; + cursor: pointer; + align-items: center; + margin: 1px; + gap: 1rem; + padding: 0.7rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); +} + +[ngOption][aria-disabled='true'] { + cursor: default; + opacity: 0.5; + background-color: var(--mat-sys-surface-dim); +} + +[ngOption]:hover { + background-color: color-mix(in srgb, var(--mat-sys-outline) 15%, transparent); +} + +[ngOption][data-active='true'] { + outline-offset: -2px; + outline: 2px solid var(--mat-sys-primary); +} + +[ngOption][aria-selected='true'] { + color: var(--mat-sys-primary); + background-color: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent); +} + +[ngOption]:not([aria-selected='true']) .example-option-check { + display: none; +} + +.example-option-text { + flex: 1; +} + +.example-option-icon { + font-size: 1.25rem; +} + +.example-option-check { + font-size: 1rem; +} diff --git a/src/components-examples/aria/select/select/select-example.html b/src/components-examples/aria/select/select/select-example.html new file mode 100644 index 000000000000..9dfd34de9b9f --- /dev/null +++ b/src/components-examples/aria/select/select/select-example.html @@ -0,0 +1,35 @@ +
+
+
+ {{ value().icon }} + {{ value().label }} +
+ + arrow_drop_down +
+ + + +
+
+ @for (item of items; track item.label) { +
+ + {{item.label}} + +
+ } +
+
+
+
+
diff --git a/src/components-examples/aria/select/select/select-example.ts b/src/components-examples/aria/select/select/select-example.ts new file mode 100644 index 000000000000..9a57cc865e74 --- /dev/null +++ b/src/components-examples/aria/select/select/select-example.ts @@ -0,0 +1,66 @@ +/** + * @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, + computed, + viewChild, + viewChildren, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; + +/** @title Aria select example. */ +@Component({ + selector: 'select-example', + templateUrl: 'select-example.html', + styleUrl: '../select.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + OverlayModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectExample { + /** The options available in the listbox. */ + options = viewChildren>(Option); + + /** The combobox listbox popup. */ + listbox = viewChild>(Listbox); + + /** The current value of the select. */ + value = computed(() => this.listbox()?.values()[0] ?? this.items[1]); + + /** The items available for selection. */ + items = [ + {label: 'Light Mode', icon: 'light_mode'}, + {label: 'Dark Mode', icon: 'dark_mode'}, + {label: 'System Default', icon: 'settings'}, + ]; + + constructor() { + // Scrolls to the active item when the active option changes. + afterRenderEffect(() => { + const option = this.options().find(opt => opt.active()); + option?.element.scrollIntoView({block: 'nearest'}); + }); + } +} diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index faf459be208c..b18b11e22ea1 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -32,6 +32,7 @@ ng_project( "//src/dev-app/aria-listbox", "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", + "//src/dev-app/aria-select", "//src/dev-app/aria-tabs", "//src/dev-app/aria-toolbar", "//src/dev-app/aria-tree", diff --git a/src/dev-app/aria-select/BUILD.bazel b/src/dev-app/aria-select/BUILD.bazel new file mode 100644 index 000000000000..8e65d8720b89 --- /dev/null +++ b/src/dev-app/aria-select/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-select", + srcs = glob(["**/*.ts"]), + assets = [ + "select-demo.html", + ":select-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/select", + ], +) diff --git a/src/dev-app/aria-select/select-demo.css b/src/dev-app/aria-select/select-demo.css new file mode 100644 index 000000000000..e91c57a78bd9 --- /dev/null +++ b/src/dev-app/aria-select/select-demo.css @@ -0,0 +1,9 @@ +:host { + display: flex; + flex-wrap: wrap; + gap: 10rem; +} + +.example-container { + width: 250px; +} diff --git a/src/dev-app/aria-select/select-demo.html b/src/dev-app/aria-select/select-demo.html new file mode 100644 index 000000000000..b3c95c298274 --- /dev/null +++ b/src/dev-app/aria-select/select-demo.html @@ -0,0 +1,14 @@ +
+

Select Example

+ +
+ +
+

Multiselect Example

+ +
+ +
+

Disabled Example

+ +
diff --git a/src/dev-app/aria-select/select-demo.ts b/src/dev-app/aria-select/select-demo.ts new file mode 100644 index 000000000000..ef1e85d8c332 --- /dev/null +++ b/src/dev-app/aria-select/select-demo.ts @@ -0,0 +1,22 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import { + SelectDisabledExample, + SelectMultiExample, + SelectExample, +} from '@angular/components-examples/aria/select'; + +@Component({ + templateUrl: 'select-demo.html', + styleUrl: 'select-demo.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SelectDisabledExample, SelectMultiExample, SelectExample], +}) +export class SelectDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index fa06844c3584..8e552a3d0727 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -69,6 +69,7 @@ export class DevAppLayout { {name: 'Aria Listbox', route: '/aria-listbox'}, {name: 'Aria Menu', route: '/aria-menu'}, {name: 'Aria Menubar', route: '/aria-menubar'}, + {name: 'Aria Select', route: '/aria-select'}, {name: 'Aria Tabs', route: '/aria-tabs'}, {name: 'Aria Toolbar', route: '/aria-toolbar'}, {name: 'Aria Tree', route: '/aria-tree'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 03fefd5abd7c..2fab9c4af821 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -44,6 +44,10 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-combobox', loadComponent: () => import('./aria-combobox/combobox-demo').then(m => m.ComboboxDemo), }, + { + path: 'aria-select', + loadComponent: () => import('./aria-select/select-demo').then(m => m.SelectDemo), + }, { path: 'aria-grid', loadComponent: () => import('./aria-grid/grid-demo').then(m => m.GridDemo),