From 2f8cc927b788c13e712ccc90bf22d05dba0486a8 Mon Sep 17 00:00:00 2001 From: tjshiu <35056071+tjshiu@users.noreply.github.com> Date: Fri, 22 May 2026 00:18:38 -0700 Subject: [PATCH] docs: modernize combobox and select guides and examples Upgrades the guides and interactive examples to use modern signal-based APIs, restoring the nested search dialogs and introducing datepicker grids. --- .../combobox/src/datepicker/basic/app/app.css | 205 +++++++++ .../src/datepicker/basic/app/app.html | 157 +++++++ .../combobox/src/datepicker/basic/app/app.ts | 388 ++++++++++++++++++ .../src/datepicker/material/app/app.css | 195 +++++++++ .../src/datepicker/material/app/app.html | 157 +++++++ .../src/datepicker/material/app/app.ts | 388 ++++++++++++++++++ .../combobox/src/datepicker/retro/app/app.css | 215 ++++++++++ .../src/datepicker/retro/app/app.html | 157 +++++++ .../combobox/src/datepicker/retro/app/app.ts | 388 ++++++++++++++++++ .../aria/combobox/src/dialog/app/app.css | 231 +++++++++++ .../aria/combobox/src/dialog/app/app.html | 103 +++++ .../aria/combobox/src/dialog/app/app.ts | 100 +++++ .../combobox/src/dialog/material/app/app.css | 233 +++++++++++ .../combobox/src/dialog/material/app/app.html | 103 +++++ .../combobox/src/dialog/material/app/app.ts | 100 +++++ .../combobox/src/dialog/retro/app/app.css | 215 ++++++++++ .../combobox/src/dialog/retro/app/app.html | 103 +++++ .../aria/combobox/src/dialog/retro/app/app.ts | 100 +++++ adev/src/content/guide/aria/combobox.md | 176 ++++---- 19 files changed, 3626 insertions(+), 88 deletions(-) create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.ts create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.ts create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.ts create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/app/app.ts create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/material/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/material/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.css create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.html create mode 100644 adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.css b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.css new file mode 100644 index 000000000000..188030829f0b --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.css @@ -0,0 +1,205 @@ +@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); + +:host { + display: flex; + justify-content: center; + font-family: var(--inter-font, sans-serif); + --border-color: var(--quaternary-contrast, #e0e0e0); +} + +/* Universal/Basic Trigger Layout Styles */ +.example-combobox-container { + position: relative; + width: 15rem; + display: flex; + flex-direction: column; + border: 1px solid var(--quinary-contrast, #e0e0e0); + border-radius: 0.25rem; + background-color: var(--page-background, #ffffff); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: 0.25rem; +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--primary-contrast, #1a1a1a); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-combobox-input { + border-radius: 0.25rem; + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: transparent; + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-container:focus-within { + border-color: var(--hot-pink, #ff007f); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink, #ff007f) 20%, transparent); +} + +/* Overlay Bounding Popover */ +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + background-color: var(--septenary-contrast, #ffffff); + overflow: hidden; +} + +.example-datepicker-popup { + padding: 16px; + width: 320px; + max-height: none; + overflow: visible; + background-color: transparent; + border: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.example-datepicker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 12px; +} + +.example-datepicker-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--primary-contrast, #1a1a1a); +} + +.example-datepicker-nav-button { + background-color: transparent; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--primary-contrast, #1a1a1a); + transition: background-color 0.2s ease; +} + +.example-datepicker-nav-button:hover { + background-color: var(--senary-contrast, #f0f0f0); +} + +.example-datepicker-nav-button:focus { + outline-offset: -1px; + outline: 1px solid color-mix(in srgb, var(--hot-pink, #ff007f) 60%, transparent); +} +.example-datepicker-grid { + width: 100%; + border-collapse: collapse; +} + +.example-datepicker-cell { + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; + padding: 0; +} + +.example-datepicker-weekday { + font-size: 0.75rem; + font-weight: 500; + color: var(--secondary-contrast, #707070); + padding-bottom: 8px; +} + +.example-datepicker-empty { + color: var(--senary-contrast, #c0c0c0); + font-size: 0.8rem; +} + +.example-datepicker-day-button { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background-color: transparent; + cursor: pointer; + font-size: 0.85rem; + color: var(--primary-contrast, #1a1a1a); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.example-datepicker-cell:hover .example-datepicker-day-button { + background-color: var(--senary-contrast, #f0f0f0); +} + +.example-datepicker-cell:focus-within { + outline: 2px solid var(--hot-pink, #ff007f); + outline-offset: -2px; +} + +@media (forced-colors: active) { + .example-datepicker-cell:focus-within { + outline: 2px solid CanvasText; + } +} + +.example-datepicker-day-button:focus { + outline: none; +} + +thead { + background-image: var( + --pink-to-purple-horizontal-gradient, + linear-gradient(to right, #ff007f, #6200ee) + ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} + +.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { + background-color: var(--electric-violet, #6200ee); + color: var(--octonary-contrast, #ffffff); +} + +.example-datepicker-nav-button[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.example-combobox-hint { + font-size: 0.75rem; + color: var(--secondary-contrast, #707070); + margin-top: 4px; +} diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.html b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.html new file mode 100644 index 000000000000..82b50588eb55 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.html @@ -0,0 +1,157 @@ + + +
+
+
+ calendar_month + + +
+ + + +
+ +
+ +
+ {{ activeMonthAnnouncement() }} +
+ +
+ +
{{ monthYearLabel() }}
+ +
+ + + + + + + @for (day of weekdays(); track day.long) { + + } + + + + + @for (week of weeks(); track $index) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track $index) { + + } + } + + @for (day of week; track $index) { + + } + + @if ($last && week.length < 7) { + @for (day of daysInNextMonth(); track $index) { + + } + } + + } + +
+ {{ day.narrow }} +
+ + +
+
+
+
+
+
+
Format: MM/DD/YYYY
+
diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.ts b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.ts new file mode 100644 index 000000000000..6c0ad4122ed6 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/basic/app/app.ts @@ -0,0 +1,388 @@ +/** + * @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 { + Component, + computed, + effect, + inject, + signal, + Signal, + untracked, + viewChild, + viewChildren, + WritableSignal, + ElementRef, +} from '@angular/core'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + provideNativeDateAdapter, +} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: boolean; +} + +/** @title Combobox with Datepicker Grid. */ +@Component({ + selector: 'app-root', + templateUrl: 'app.html', + styleUrl: 'app.css', + providers: [provideNativeDateAdapter()], + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + A11yModule, + ], +}) +export class App { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets + + readonly grid = viewChild(Grid); + readonly gridTable = viewChild>('gridTable'); + readonly comboboxInput = viewChild>('comboboxInput'); + + readonly selection = signal(''); + readonly popupExpanded = signal(false); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + + // Track the target date that must receive focus post-render + readonly focusTargetDate = signal(null); + + // Helper to identify the current focus target in templates + isFocusTarget(date: D): boolean { + const target = this.focusTargetDate(); + return target ? this._dateAdapter.compareDate(date, target) === 0 : false; + } + + constructor() { + // Safe, post-render focus restoration loop + effect(() => { + const target = this.focusTargetDate(); + if (!target) return; + + // Grab dynamic dependency on day buttons list query + const buttons = this._dayButtons(); + + // Locate the focus button marked with our target attribute + const targetBtn = buttons.find( + (btn) => btn.element.getAttribute('data-focus-target') === 'true', + ); + + if (targetBtn) { + targetBtn.element.focus(); + + // Schedule cleanup in separate microtask to avoid circular signal write errors + Promise.resolve().then(() => { + untracked(() => this.focusTargetDate.set(null)); + }); + } + }); + } + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + + readonly activeMonthAnnouncement = computed( + () => + `Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`, + ); + + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + + // Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row. + readonly daysInNextMonth: Signal = computed(() => { + const activeWeeks = this.weeks(); + const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0; + const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0; + const days: number[] = []; + for (let i = 1; i <= trailingCount; i++) { + days.push(i); + } + return days; + }); + + // Shift the weekday names array reactively to align with the localized starting day of the week. + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + + // Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes. + readonly weeks = computed(() => { + this._activeDate(); // Create dependency on active date + const viewMonth = this.viewMonth(); + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0, + }); + } + return weeks; + }); + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + selectDate(cell: CalendarCell, event?: Event): void { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); + this.selection.set(formatted); + this._activeDate.set(cell.date); + + // Synchronously restore focus to the trigger input element before destroying popup to avoid drop + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + } + + // Parse and reconcile dynamic input typing to calendar state + onInputInput(value: string): void { + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + } + } + + // Handle keyboard inputs on the trigger input field. + onInputKeydown(event: KeyboardEvent) { + // Pressing Enter parses the input text and updates the selected date. + if (event.key === 'Enter') { + const value = this.selection(); + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + this.popupExpanded.set(false); + } + // Pressing ArrowDown shifts focus into the active cell of the calendar grid. + } else if (event.key === 'ArrowDown' && this.popupExpanded()) { + setTimeout(() => { + const tableEl = this.gridTable()?.nativeElement; + if (tableEl) { + const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement; + (tabbable || tableEl).focus(); + } + }); + } + } + + // Safe W3C calendar grid boundaries keys navigation checks + onGridKeydown(event: KeyboardEvent): void { + const arrowUp = event.key === 'ArrowUp'; + const arrowDown = event.key === 'ArrowDown'; + const arrowLeft = event.key === 'ArrowLeft'; + const arrowRight = event.key === 'ArrowRight'; + const pageUp = event.key === 'PageUp'; + const pageDown = event.key === 'PageDown'; + const homeKey = event.key === 'Home'; + const endKey = event.key === 'End'; + + if ( + !arrowUp && + !arrowDown && + !arrowLeft && + !arrowRight && + !pageUp && + !pageDown && + !homeKey && + !endKey + ) { + return; + } + + // Extract the day number of the currently focused button cell + const targetEl = event.target as HTMLElement; + const dayAttr = targetEl.getAttribute('data-day'); + if (!dayAttr) return; + + const day = Number(dayAttr); + const year = this._dateAdapter.getYear(this.viewMonth()); + const month = this._dateAdapter.getMonth(this.viewMonth()); + const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth()); + + // Reconstitute focused cell date Adapter entity + const currentFocusedDate = this._dateAdapter.createDate(year, month, day); + let targetDate: D | null = null; + + // W3C APG Standard calendar keyboard rules + switch (event.key) { + case 'ArrowLeft': + // Day 1 boundary crossing: jump to the last day of the previous month + if (day === 1) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1); + } + break; + case 'ArrowRight': + // Last day boundary crossing: jump to the first day of the next month + if (day === viewMonthNumDays) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1); + } + break; + case 'ArrowUp': + // First week boundary crossing: jump back 7 days to the previous month + if (day <= 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7); + } + break; + case 'ArrowDown': + // Last week boundary crossing: jump forward 7 days to the next month + if (day > viewMonthNumDays - 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7); + } + break; + case 'PageUp': + // Shift back 12 months on Control-PageUp, otherwise shift back 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? -12 : -1, + ); + break; + case 'PageDown': + // Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? 12 : 1, + ); + break; + case 'Home': + // Jump to the 1st of the current month + targetDate = this._dateAdapter.createDate(year, month, 1); + break; + case 'End': + // Jump to the last day of the current month + targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays); + break; + } + + if (targetDate) { + // Mute downstream event listeners inside the grid parent to prevent roving races + event.preventDefault(); + event.stopImmediatePropagation(); + this.navigateToDate(targetDate); + } + } + + navigateToDate(targetDate: D): void { + const currentMonth = this._dateAdapter.getMonth(this.viewMonth()); + const currentYear = this._dateAdapter.getYear(this.viewMonth()); + const targetMonth = this._dateAdapter.getMonth(targetDate); + const targetYear = this._dateAdapter.getYear(targetDate); + + const monthShift = currentMonth !== targetMonth || currentYear !== targetYear; + + if (monthShift) { + // 1. Focus stable table container to stop focus drop to body (prevent overlay crash) + this.gridTable()?.nativeElement.focus(); + + // 2. Reset active grid state synchronously to avoid focus hijacking (Solution B) + const gridBehavior = this.grid()?._pattern.gridBehavior; + if (gridBehavior) { + gridBehavior.focusBehavior.activeCell.set(undefined); + gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1}); + } + + // 3. Set target state so the reactive effect knows what to grab post-render + this.focusTargetDate.set(targetDate); + + // 4. Perform reactive month view transition + this.viewMonth.set(targetDate); + } else { + // Same month traversal: just set the target and the constructor effect will fire immediately + this.focusTargetDate.set(targetDate); + } + } + + handleWidgetKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + event.preventDefault(); + event.stopPropagation(); + } + } +} diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.css b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.css new file mode 100644 index 000000000000..78e82921ed74 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.css @@ -0,0 +1,195 @@ +@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); + +:host { + display: flex; + justify-content: center; + font-family: var(--inter-font, sans-serif); + --border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff)); +} + +/* Input Trigger Styles */ +.example-combobox-container { + position: relative; + width: 15rem; + display: flex; + flex-direction: column; + border: 1px solid var(--quinary-contrast, #e0e0e0); + border-radius: 3rem; + background-color: var(--page-background, #ffffff); + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: 0.25rem; +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--primary-contrast, #1a1a1a); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-combobox-input { + border-radius: 3rem; + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: transparent; + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-input::placeholder { + color: var(--quaternary-contrast, #888888); +} + +.example-combobox-container:focus-within { + border-color: var(--hot-pink); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 20%, transparent); +} + +/* Overlay Bounding Popover */ +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 2rem; + background-color: var(--septenary-contrast, #f5f5f5); + overflow: hidden; +} + +.example-datepicker-popup { + padding: 16px; + width: 320px; + max-height: none; + overflow: visible; + background-color: transparent; + border: none; + box-shadow: var(--mat-sys-level2-shadow); +} + +.example-datepicker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + margin-bottom: 12px; +} + +.example-datepicker-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--mat-sys-on-surface); +} + +.example-datepicker-nav-button { + background-color: transparent; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--mat-sys-on-surface); + transition: background-color 0.2s ease; +} + +.example-datepicker-nav-button:hover { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-datepicker-nav-button:focus { + outline-offset: -1px; + outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent); +} + +.example-datepicker-grid { + width: 100%; + border-collapse: collapse; +} + +.example-datepicker-cell { + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; + padding: 0; +} + +.example-datepicker-weekday { + font-size: 0.75rem; + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + padding-bottom: 8px; +} + +.example-datepicker-empty { + color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent); + font-size: 0.8rem; +} + +.example-datepicker-day-button { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background-color: transparent; + cursor: pointer; + font-size: 0.85rem; + color: var(--mat-sys-on-surface); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.example-datepicker-cell:hover .example-datepicker-day-button { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); +} + +.example-datepicker-cell:focus-within { + outline: 2px solid var(--hot-pink); + outline-offset: -2px; +} + +@media (forced-colors: active) { + .example-datepicker-cell:focus-within { + outline: 2px solid CanvasText; + } +} + +.example-datepicker-day-button:focus { + outline: none; +} + +.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { + background-color: var(--electric-violet, var(--mat-sys-primary)); + color: var(--octonary-contrast, #ffffff); +} + +.example-combobox-hint { + font-size: 0.75rem; + color: var(--mat-sys-on-surface-variant); + margin-top: 4px; +} diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.html b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.html new file mode 100644 index 000000000000..82b50588eb55 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.html @@ -0,0 +1,157 @@ + + +
+
+
+ calendar_month + + +
+ + + +
+ +
+ +
+ {{ activeMonthAnnouncement() }} +
+ +
+ +
{{ monthYearLabel() }}
+ +
+ + + + + + + @for (day of weekdays(); track day.long) { + + } + + + + + @for (week of weeks(); track $index) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track $index) { + + } + } + + @for (day of week; track $index) { + + } + + @if ($last && week.length < 7) { + @for (day of daysInNextMonth(); track $index) { + + } + } + + } + +
+ {{ day.narrow }} +
+ + +
+
+
+
+
+
+
Format: MM/DD/YYYY
+
diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.ts b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.ts new file mode 100644 index 000000000000..8b8b3fb8e580 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/material/app/app.ts @@ -0,0 +1,388 @@ +/** + * @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 { + Component, + computed, + effect, + inject, + signal, + Signal, + untracked, + viewChild, + viewChildren, + WritableSignal, + ElementRef, +} from '@angular/core'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + provideNativeDateAdapter, +} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: boolean; +} + +/** @title Combobox with Datepicker Grid. */ +@Component({ + selector: 'app-root:not([theme="basic-material"])', + templateUrl: 'app.html', + styleUrl: 'app.css', + providers: [provideNativeDateAdapter()], + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + A11yModule, + ], +}) +export class App { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets + + readonly grid = viewChild(Grid); + readonly gridTable = viewChild>('gridTable'); + readonly comboboxInput = viewChild>('comboboxInput'); + + readonly selection = signal(''); + readonly popupExpanded = signal(false); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + + // Track the target date that must receive focus post-render + readonly focusTargetDate = signal(null); + + // Helper to identify the current focus target in templates + isFocusTarget(date: D): boolean { + const target = this.focusTargetDate(); + return target ? this._dateAdapter.compareDate(date, target) === 0 : false; + } + + constructor() { + // Safe, post-render focus restoration loop + effect(() => { + const target = this.focusTargetDate(); + if (!target) return; + + // Grab dynamic dependency on day buttons list query + const buttons = this._dayButtons(); + + // Locate the focus button marked with our target attribute + const targetBtn = buttons.find( + (btn) => btn.element.getAttribute('data-focus-target') === 'true', + ); + + if (targetBtn) { + targetBtn.element.focus(); + + // Schedule cleanup in separate microtask to avoid circular signal write errors + Promise.resolve().then(() => { + untracked(() => this.focusTargetDate.set(null)); + }); + } + }); + } + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + + readonly activeMonthAnnouncement = computed( + () => + `Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`, + ); + + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + + // Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row. + readonly daysInNextMonth: Signal = computed(() => { + const activeWeeks = this.weeks(); + const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0; + const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0; + const days: number[] = []; + for (let i = 1; i <= trailingCount; i++) { + days.push(i); + } + return days; + }); + + // Shift the weekday names array reactively to align with the localized starting day of the week. + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + + // Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes. + readonly weeks = computed(() => { + this._activeDate(); // Create dependency on active date + const viewMonth = this.viewMonth(); + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0, + }); + } + return weeks; + }); + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + selectDate(cell: CalendarCell, event?: Event): void { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); + this.selection.set(formatted); + this._activeDate.set(cell.date); + + // Synchronously restore focus to the trigger input element before destroying popup to avoid drop + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + } + + // Parse and reconcile dynamic input typing to calendar state + onInputInput(value: string): void { + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + } + } + + // Handle keyboard inputs on the trigger input field. + onInputKeydown(event: KeyboardEvent) { + // Pressing Enter parses the input text and updates the selected date. + if (event.key === 'Enter') { + const value = this.selection(); + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + this.popupExpanded.set(false); + } + // Pressing ArrowDown shifts focus into the active cell of the calendar grid. + } else if (event.key === 'ArrowDown' && this.popupExpanded()) { + setTimeout(() => { + const tableEl = this.gridTable()?.nativeElement; + if (tableEl) { + const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement; + (tabbable || tableEl).focus(); + } + }); + } + } + + // Safe W3C calendar grid boundaries keys navigation checks + onGridKeydown(event: KeyboardEvent): void { + const arrowUp = event.key === 'ArrowUp'; + const arrowDown = event.key === 'ArrowDown'; + const arrowLeft = event.key === 'ArrowLeft'; + const arrowRight = event.key === 'ArrowRight'; + const pageUp = event.key === 'PageUp'; + const pageDown = event.key === 'PageDown'; + const homeKey = event.key === 'Home'; + const endKey = event.key === 'End'; + + if ( + !arrowUp && + !arrowDown && + !arrowLeft && + !arrowRight && + !pageUp && + !pageDown && + !homeKey && + !endKey + ) { + return; + } + + // Extract the day number of the currently focused button cell + const targetEl = event.target as HTMLElement; + const dayAttr = targetEl.getAttribute('data-day'); + if (!dayAttr) return; + + const day = Number(dayAttr); + const year = this._dateAdapter.getYear(this.viewMonth()); + const month = this._dateAdapter.getMonth(this.viewMonth()); + const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth()); + + // Reconstitute focused cell date Adapter entity + const currentFocusedDate = this._dateAdapter.createDate(year, month, day); + let targetDate: D | null = null; + + // W3C APG Standard calendar keyboard rules + switch (event.key) { + case 'ArrowLeft': + // Day 1 boundary crossing: jump to the last day of the previous month + if (day === 1) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1); + } + break; + case 'ArrowRight': + // Last day boundary crossing: jump to the first day of the next month + if (day === viewMonthNumDays) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1); + } + break; + case 'ArrowUp': + // First week boundary crossing: jump back 7 days to the previous month + if (day <= 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7); + } + break; + case 'ArrowDown': + // Last week boundary crossing: jump forward 7 days to the next month + if (day > viewMonthNumDays - 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7); + } + break; + case 'PageUp': + // Shift back 12 months on Control-PageUp, otherwise shift back 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? -12 : -1, + ); + break; + case 'PageDown': + // Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? 12 : 1, + ); + break; + case 'Home': + // Jump to the 1st of the current month + targetDate = this._dateAdapter.createDate(year, month, 1); + break; + case 'End': + // Jump to the last day of the current month + targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays); + break; + } + + if (targetDate) { + // Mute downstream event listeners inside the grid parent to prevent roving races + event.preventDefault(); + event.stopImmediatePropagation(); + this.navigateToDate(targetDate); + } + } + + navigateToDate(targetDate: D): void { + const currentMonth = this._dateAdapter.getMonth(this.viewMonth()); + const currentYear = this._dateAdapter.getYear(this.viewMonth()); + const targetMonth = this._dateAdapter.getMonth(targetDate); + const targetYear = this._dateAdapter.getYear(targetDate); + + const monthShift = currentMonth !== targetMonth || currentYear !== targetYear; + + if (monthShift) { + // 1. Focus stable table container to stop focus drop to body (prevent overlay crash) + this.gridTable()?.nativeElement.focus(); + + // 2. Reset active grid state synchronously to avoid focus hijacking (Solution B) + const gridBehavior = this.grid()?._pattern.gridBehavior; + if (gridBehavior) { + gridBehavior.focusBehavior.activeCell.set(undefined); + gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1}); + } + + // 3. Set target state so the reactive effect knows what to grab post-render + this.focusTargetDate.set(targetDate); + + // 4. Perform reactive month view transition + this.viewMonth.set(targetDate); + } else { + // Same month traversal: just set the target and the constructor effect will fire immediately + this.focusTargetDate.set(targetDate); + } + } + + handleWidgetKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + event.preventDefault(); + event.stopPropagation(); + } + } +} diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.css b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.css new file mode 100644 index 000000000000..31f30c4e47fb --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.css @@ -0,0 +1,215 @@ +@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); + +:host { + display: flex; + justify-content: center; + font-family: 'Press Start 2P', Courier, monospace; + font-size: 0.6rem; + --border-color: var(--tertiary-contrast, #000000); + + --retro-button-color: var(--septenary-contrast, #ffffff); + --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #ffffff); + --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000000); + --retro-elevated-shadow: + inset 4px 4px 0px 0px var(--retro-shadow-light), + inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--border-color), + 0px 4px 0px 0px var(--border-color), -4px 0px 0px 0px var(--border-color), + 0px -4px 0px 0px var(--border-color); + --retro-flat-shadow: + 4px 0px 0px 0px var(--border-color), 0px 4px 0px 0px var(--border-color), + -4px 0px 0px 0px var(--border-color), 0px -4px 0px 0px var(--border-color); +} + +/* Input Trigger Styles */ +.example-combobox-container { + position: relative; + width: 16rem; + display: flex; + flex-direction: column; + border: 4px solid var(--border-color); + border-radius: 0; + background-color: var(--retro-button-color); + box-shadow: var(--retro-flat-shadow); +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--border-color); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 0.6rem; + font-family: 'Press Start 2P', Courier, monospace; + word-spacing: -4px; + padding: 0.75rem 1rem 0.75rem 2.5rem; + background-color: transparent; + color: var(--primary-contrast, #000000); +} + +.example-combobox-container:focus-within { + outline: 3px solid var(--vivid-pink, #ff007f); +} + +/* Overlay Bounding Popover & Dividers */ +.example-popover { + margin: 0; + padding: 0; + border: 4px solid var(--border-color); + background-color: var(--septenary-contrast, #ffffff); + box-shadow: 8px 8px 0px var(--border-color); /* 3D offset shadow */ +} + +.example-datepicker-popup { + padding: 12px; + width: 320px; + max-height: none; + overflow: visible; + background-color: transparent; + border: none; + box-shadow: none; /* Border & shadow handled by popover */ + font-family: 'Press Start 2P', Courier, monospace; +} + +.example-datepicker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 8px; + border-bottom: 3px double var(--border-color); + margin-bottom: 12px; +} + +.example-datepicker-title { + font-weight: bold; + font-size: 0.6rem; + color: var(--border-color); +} + +.example-datepicker-nav-button { + background-color: var(--retro-button-color); + border: 2px solid var(--border-color); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--border-color); + font-weight: bold; + box-shadow: 2px 2px 0px var(--border-color); +} + +.example-datepicker-nav-button:active { + transform: translate(2px, 2px); + box-shadow: none; +} + +.example-datepicker-nav-button:focus { + outline: 2px dashed var(--vivid-pink, #ff007f); + outline-offset: 2px; +} + +.example-datepicker-grid { + width: 100%; + border-collapse: collapse; +} + +.example-datepicker-cell { + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; + padding: 0; +} + +.example-datepicker-weekday { + font-size: 0.5rem; + font-weight: bold; + color: var(--border-color); + padding-bottom: 8px; +} + +.example-datepicker-empty { + color: var(--senary-contrast, #aaaaaa); + font-size: 0.5rem; +} + +.example-datepicker-day-button { + width: 32px; + height: 32px; + border: 2px solid transparent; + border-radius: 0; + background-color: transparent; + cursor: pointer; + font-size: 0.6rem; + font-weight: bold; + color: var(--border-color); +} + +.example-datepicker-cell:hover .example-datepicker-day-button { + background-color: var(--senary-contrast, #dddddd); + border-color: var(--border-color); +} + +/* Suppress native focus rings and force solid high contrast outlines */ +.example-datepicker-cell:focus-within { + outline: 3px solid var(--border-color); + outline-offset: -3px; +} + +@media (forced-colors: active) { + .example-datepicker-cell:focus-within { + outline: 3px solid CanvasText; + } +} + +.example-datepicker-day-button:focus { + outline: none; +} + +thead { + background-image: var( + --orange-to-pink-vertical-gradient, + linear-gradient(to bottom, #ffaa00, #ff007f) + ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} + +.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button { + background-color: var(--vivid-pink, #ff007f); + color: var(--page-background, #ffffff); +} + +.example-combobox-hint { + font-size: 0.5rem; + font-weight: bold; + font-family: 'Press Start 2P', Courier, monospace; + color: var(--border-color); + margin-top: 6px; +} diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.html b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.html new file mode 100644 index 000000000000..82b50588eb55 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.html @@ -0,0 +1,157 @@ + + +
+
+
+ calendar_month + + +
+ + + +
+ +
+ +
+ {{ activeMonthAnnouncement() }} +
+ +
+ +
{{ monthYearLabel() }}
+ +
+ + + + + + + @for (day of weekdays(); track day.long) { + + } + + + + + @for (week of weeks(); track $index) { + + @if ($first) { + @for (day of daysFromPrevMonth(); track $index) { + + } + } + + @for (day of week; track $index) { + + } + + @if ($last && week.length < 7) { + @for (day of daysInNextMonth(); track $index) { + + } + } + + } + +
+ {{ day.narrow }} +
+ + +
+
+
+
+
+
+
Format: MM/DD/YYYY
+
diff --git a/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.ts b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.ts new file mode 100644 index 000000000000..7f31cdaf837f --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/datepicker/retro/app/app.ts @@ -0,0 +1,388 @@ +/** + * @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 { + Component, + computed, + effect, + inject, + signal, + Signal, + untracked, + viewChild, + viewChildren, + WritableSignal, + ElementRef, +} from '@angular/core'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + provideNativeDateAdapter, +} from '@angular/material/core'; +import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid'; +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {A11yModule} from '@angular/cdk/a11y'; + +const DAYS_PER_WEEK = 7; + +interface CalendarCell { + displayName: string; + ariaLabel: string; + date: D; + selected: boolean; +} + +/** @title Combobox with Datepicker Grid. */ +@Component({ + selector: 'app-root:not([theme="basic-retro"])', + templateUrl: 'app.html', + styleUrl: 'app.css', + providers: [provideNativeDateAdapter()], + imports: [ + Grid, + GridRow, + GridCell, + GridCellWidget, + Combobox, + ComboboxPopup, + ComboboxWidget, + OverlayModule, + A11yModule, + ], +}) +export class App { + private readonly _dateAdapter = inject>(DateAdapter, {optional: true})!; + private readonly _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private readonly _dayButtons = viewChildren(GridCellWidget); // Dynamic capture of grid cell button widgets + + readonly grid = viewChild(Grid); + readonly gridTable = viewChild>('gridTable'); + readonly comboboxInput = viewChild>('comboboxInput'); + + readonly selection = signal(''); + readonly popupExpanded = signal(false); + readonly viewMonth: WritableSignal = signal(this._dateAdapter.today()); + private readonly _activeDate: WritableSignal = signal(this._dateAdapter.today()); + + // Track the target date that must receive focus post-render + readonly focusTargetDate = signal(null); + + // Helper to identify the current focus target in templates + isFocusTarget(date: D): boolean { + const target = this.focusTargetDate(); + return target ? this._dateAdapter.compareDate(date, target) === 0 : false; + } + + constructor() { + // Safe, post-render focus restoration loop + effect(() => { + const target = this.focusTargetDate(); + if (!target) return; + + // Grab dynamic dependency on day buttons list query + const buttons = this._dayButtons(); + + // Locate the focus button marked with our target attribute + const targetBtn = buttons.find( + (btn) => btn.element.getAttribute('data-focus-target') === 'true', + ); + + if (targetBtn) { + targetBtn.element.focus(); + + // Schedule cleanup in separate microtask to avoid circular signal write errors + Promise.resolve().then(() => { + untracked(() => this.focusTargetDate.set(null)); + }); + } + }); + } + + readonly monthYearLabel: Signal = computed(() => + this._dateAdapter + .format(this.viewMonth(), this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(), + ); + + readonly activeMonthAnnouncement = computed( + () => + `Showing ${this._dateAdapter.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)}`, + ); + + private readonly _firstWeekOffset: Signal = computed(() => { + const firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.viewMonth()), + this._dateAdapter.getMonth(this.viewMonth()), + 1, + ); + + return ( + (DAYS_PER_WEEK + + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % + DAYS_PER_WEEK + ); + }); + + readonly prevMonthNumDays: Signal = computed(() => + this._dateAdapter.getNumDaysInMonth(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)), + ); + + readonly daysFromPrevMonth: Signal = computed(() => { + const days: number[] = []; + for (let i = this._firstWeekOffset() - 1; i >= 0; i--) { + days.push(this.prevMonthNumDays() - i); + } + return days; + }); + + // Calculate the trailing empty days from the next month reactively to complete the final calendar grid week row. + readonly daysInNextMonth: Signal = computed(() => { + const activeWeeks = this.weeks(); + const lastWeekLength = activeWeeks[activeWeeks.length - 1]?.length || 0; + const trailingCount = lastWeekLength > 0 ? 7 - lastWeekLength : 0; + const days: number[] = []; + for (let i = 1; i <= trailingCount; i++) { + days.push(i); + } + return days; + }); + + // Shift the weekday names array reactively to align with the localized starting day of the week. + readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); + + const weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); + return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + }); + + // Reconstruct the two-dimensional week-by-week calendar grid whenever the month or selection changes. + readonly weeks = computed(() => { + this._activeDate(); // Create dependency on active date + const viewMonth = this.viewMonth(); + const daysInMonth = this._dateAdapter.getNumDaysInMonth(viewMonth); + const dateNames = this._dateAdapter.getDateNames(); + const weeks: CalendarCell[][] = [[]]; + + for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + weeks.push([]); + cell = 0; + } + const date = this._dateAdapter.createDate( + this._dateAdapter.getYear(viewMonth), + this._dateAdapter.getMonth(viewMonth), + i + 1, + ); + const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + + weeks[weeks.length - 1].push({ + displayName: dateNames[i], + ariaLabel, + date, + selected: this._dateAdapter.compareDate(date, this._activeDate()) === 0, + }); + } + return weeks; + }); + + nextMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1)); + } + + prevMonth(): void { + this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1)); + } + + selectDate(cell: CalendarCell, event?: Event): void { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const formatted = this._dateAdapter.format(cell.date, this._dateFormats.display.dateInput); + this.selection.set(formatted); + this._activeDate.set(cell.date); + + // Synchronously restore focus to the trigger input element before destroying popup to avoid drop + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + } + + // Parse and reconcile dynamic input typing to calendar state + onInputInput(value: string): void { + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + } + } + + // Handle keyboard inputs on the trigger input field. + onInputKeydown(event: KeyboardEvent) { + // Pressing Enter parses the input text and updates the selected date. + if (event.key === 'Enter') { + const value = this.selection(); + const parsedDate = this._dateAdapter.parse(value, this._dateFormats.display.dateInput); + if (parsedDate && this._dateAdapter.isValid(parsedDate)) { + this._activeDate.set(parsedDate); + this.viewMonth.set(parsedDate); + this.popupExpanded.set(false); + } + // Pressing ArrowDown shifts focus into the active cell of the calendar grid. + } else if (event.key === 'ArrowDown' && this.popupExpanded()) { + setTimeout(() => { + const tableEl = this.gridTable()?.nativeElement; + if (tableEl) { + const tabbable = tableEl.querySelector('[tabindex="0"]') as HTMLElement; + (tabbable || tableEl).focus(); + } + }); + } + } + + // Safe W3C calendar grid boundaries keys navigation checks + onGridKeydown(event: KeyboardEvent): void { + const arrowUp = event.key === 'ArrowUp'; + const arrowDown = event.key === 'ArrowDown'; + const arrowLeft = event.key === 'ArrowLeft'; + const arrowRight = event.key === 'ArrowRight'; + const pageUp = event.key === 'PageUp'; + const pageDown = event.key === 'PageDown'; + const homeKey = event.key === 'Home'; + const endKey = event.key === 'End'; + + if ( + !arrowUp && + !arrowDown && + !arrowLeft && + !arrowRight && + !pageUp && + !pageDown && + !homeKey && + !endKey + ) { + return; + } + + // Extract the day number of the currently focused button cell + const targetEl = event.target as HTMLElement; + const dayAttr = targetEl.getAttribute('data-day'); + if (!dayAttr) return; + + const day = Number(dayAttr); + const year = this._dateAdapter.getYear(this.viewMonth()); + const month = this._dateAdapter.getMonth(this.viewMonth()); + const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth()); + + // Reconstitute focused cell date Adapter entity + const currentFocusedDate = this._dateAdapter.createDate(year, month, day); + let targetDate: D | null = null; + + // W3C APG Standard calendar keyboard rules + switch (event.key) { + case 'ArrowLeft': + // Day 1 boundary crossing: jump to the last day of the previous month + if (day === 1) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -1); + } + break; + case 'ArrowRight': + // Last day boundary crossing: jump to the first day of the next month + if (day === viewMonthNumDays) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 1); + } + break; + case 'ArrowUp': + // First week boundary crossing: jump back 7 days to the previous month + if (day <= 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, -7); + } + break; + case 'ArrowDown': + // Last week boundary crossing: jump forward 7 days to the next month + if (day > viewMonthNumDays - 7) { + targetDate = this._dateAdapter.addCalendarDays(currentFocusedDate, 7); + } + break; + case 'PageUp': + // Shift back 12 months on Control-PageUp, otherwise shift back 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? -12 : -1, + ); + break; + case 'PageDown': + // Shift forward 12 months on Control-PageDown, otherwise shift forward 1 month + targetDate = this._dateAdapter.addCalendarMonths( + currentFocusedDate, + event.ctrlKey ? 12 : 1, + ); + break; + case 'Home': + // Jump to the 1st of the current month + targetDate = this._dateAdapter.createDate(year, month, 1); + break; + case 'End': + // Jump to the last day of the current month + targetDate = this._dateAdapter.createDate(year, month, viewMonthNumDays); + break; + } + + if (targetDate) { + // Mute downstream event listeners inside the grid parent to prevent roving races + event.preventDefault(); + event.stopImmediatePropagation(); + this.navigateToDate(targetDate); + } + } + + navigateToDate(targetDate: D): void { + const currentMonth = this._dateAdapter.getMonth(this.viewMonth()); + const currentYear = this._dateAdapter.getYear(this.viewMonth()); + const targetMonth = this._dateAdapter.getMonth(targetDate); + const targetYear = this._dateAdapter.getYear(targetDate); + + const monthShift = currentMonth !== targetMonth || currentYear !== targetYear; + + if (monthShift) { + // 1. Focus stable table container to stop focus drop to body (prevent overlay crash) + this.gridTable()?.nativeElement.focus(); + + // 2. Reset active grid state synchronously to avoid focus hijacking (Solution B) + const gridBehavior = this.grid()?._pattern.gridBehavior; + if (gridBehavior) { + gridBehavior.focusBehavior.activeCell.set(undefined); + gridBehavior.focusBehavior.activeCoords.set({row: -1, col: -1}); + } + + // 3. Set target state so the reactive effect knows what to grab post-render + this.focusTargetDate.set(targetDate); + + // 4. Perform reactive month view transition + this.viewMonth.set(targetDate); + } else { + // Same month traversal: just set the target and the constructor effect will fire immediately + this.focusTargetDate.set(targetDate); + } + } + + handleWidgetKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + this.comboboxInput()?.nativeElement.focus(); + this.popupExpanded.set(false); + event.preventDefault(); + event.stopPropagation(); + } + } +} diff --git a/adev/src/content/examples/aria/combobox/src/dialog/app/app.css b/adev/src/content/examples/aria/combobox/src/dialog/app/app.css new file mode 100644 index 000000000000..a81b6848d9f6 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/app/app.css @@ -0,0 +1,231 @@ +/** + * @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 url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); + +:host { + display: flex; + justify-content: center; + font-family: var(--inter-font, system-ui, sans-serif); + --border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff)); +} + +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-container:has(.example-dialog-input) { + width: 15rem; + border-radius: 0.25rem; + background-color: var(--page-background, #ffffff); + border: 1px solid var(--quinary-contrast, #e0e0e0); + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: 0.25rem; +} + +.example-combobox-input { + border-radius: 0.25rem; + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--septenary-contrast, #f5f5f5); + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-input::-webkit-search-cancel-button, +.example-combobox-input::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.example-combobox-input.example-dialog-input { + cursor: pointer; + text-align: left; + padding: 0 3.5rem 0 1rem; + height: 2.5rem; + background-color: transparent; + color: inherit; + font-weight: 500; + border: none; + outline: none; +} + +.example-combobox-container:focus-within { + border-color: var(--hot-pink); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--primary-contrast, #1a1a1a); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + 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-popover { + margin: 0; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + background-color: var(--septenary-contrast, #f5f5f5); + overflow: hidden; +} + +.example-popup { + width: 100%; + margin-block-start: 0; + border: none; + border-radius: 0; + background-color: transparent; + max-height: 15rem; + height: auto; +} + +.example-popup.example-popup-no-margin { + margin-block-start: 0; +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 13rem; + height: auto; + box-sizing: border-box; + padding: 0.5rem; + gap: 4px; + outline: none; +} + +.example-option { + cursor: pointer; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + min-height: 2.25rem; + box-sizing: border-box; + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--primary-contrast, #1a1a1a); +} + +.example-selected-icon { + visibility: hidden; + font-size: 0.9rem; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--hot-pink); + background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 5%, transparent); +} + +.example-combobox-container:not(.no-active-outline):focus-within + [data-active='true']:not(.no-active-outline), +.example-option[data-active='true'] { + outline-offset: -2px; + outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent); +} + +@media (forced-colors: active) { + .example-combobox-container:not(.no-active-outline):focus-within + [data-active='true']:not(.no-active-outline), + .example-option[data-active='true'] { + outline: 2px solid CanvasText; + } +} + +.example-dialog { + position: relative; + padding: 0; + background-color: var(--septenary-contrast, #f5f5f5); + width: 15rem; + box-sizing: border-box; +} + +.example-dialog .example-combobox-container { + border: none; + border-radius: inherit; + box-shadow: none; + background-color: transparent; +} + +.example-dialog .example-combobox-input-container { + border-bottom: 1px solid var(--border-color); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.example-no-results { + padding: 1rem; +} + +.example-empty { + position: absolute; + visibility: hidden; + pointer-events: none; + height: 0; + width: 0; + overflow: hidden; +} + +.example-dialog-input[disabled], +.example-dialog-input[aria-disabled='true'] { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/adev/src/content/examples/aria/combobox/src/dialog/app/app.html b/adev/src/content/examples/aria/combobox/src/dialog/app/app.html new file mode 100644 index 000000000000..b3ae7e172cc4 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/app/app.html @@ -0,0 +1,103 @@ + + +
+
+ + +
+ + + +
+
+
+ +
+ + +
+ +
+ {{ options().length === 0 ? 'No results found for ' + searchString() : '' }} +
+ + + +
+ @if (options().length === 0) { +
No results found
+ } +
+ @for (option of options(); track option) { +
+ {{ option }} + +
+ } +
+
+
+
+
+
+
+
+
diff --git a/adev/src/content/examples/aria/combobox/src/dialog/app/app.ts b/adev/src/content/examples/aria/combobox/src/dialog/app/app.ts new file mode 100644 index 000000000000..1272be2aedea --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/app/app.ts @@ -0,0 +1,100 @@ +/** + * @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, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + Component, + computed, + signal, + viewChild, + untracked, + ElementRef, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-root', + templateUrl: './app.html', + styleUrl: './app.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], +}) +export class App { + readonly listbox = viewChild>(Listbox); + readonly combobox = viewChild(Combobox); + readonly searchInput = viewChild>('searchInput'); + + readonly value = signal(''); + readonly searchString = signal(''); + readonly selectedCountries = signal([]); + readonly popupExpanded = signal(false); + + readonly options = computed(() => + ALL_COUNTRIES.filter((country) => + country.toLowerCase().startsWith(this.searchString().toLowerCase()), + ), + ); + + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); + } + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); + } + }); + } + + onCommit() { + const selected = this.selectedCountries(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } + } + + onSearchEscape(event: Event) { + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } +} + +const ALL_COUNTRIES = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Argentina', + 'Armenia', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Brazil', + 'Canada', + 'Egypt', + 'France', + 'Germany', + 'India', + 'Japan', + 'Mexico', + 'United Kingdom', + 'United States of America', +]; diff --git a/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.css b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.css new file mode 100644 index 000000000000..599dda8b6d7d --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.css @@ -0,0 +1,233 @@ +/** + * @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 url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); + +:host { + display: flex; + justify-content: center; + font-family: var(--inter-font, system-ui, sans-serif); + --border-color: color-mix(in srgb, var(--full-contrast, #000) 20%, var(--page-background, #fff)); +} + +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.example-combobox-container:has(.example-dialog-input) { + width: 15rem; + border-radius: 3rem; + background-color: var(--page-background, #ffffff); + border: 1px solid var(--quinary-contrast, #e0e0e0); + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; + border-radius: 0.25rem; +} + +.example-combobox-input { + border-radius: 0.25rem; + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--septenary-contrast, #f5f5f5); + color: var(--primary-contrast, #1a1a1a); +} + +.example-combobox-input::-webkit-search-cancel-button, +.example-combobox-input::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.example-combobox-input.example-dialog-input { + cursor: pointer; + text-align: left; + padding: 0 3.5rem 0 1.5rem; + height: 3rem; + background-color: transparent; + color: inherit; + font-weight: 500; + border: none; + outline: none; +} + +.example-combobox-container:focus-within { + border-color: var(--hot-pink); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--hot-pink) 25%, transparent); +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 24px; + color: var(--primary-contrast, #1a1a1a); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 1rem; + opacity: 0.9; + color: var(--primary-contrast, #1a1a1a); + transition: transform 0.2s ease; +} + +.example-combobox-input[aria-expanded='true'] + .example-arrow-icon { + transform: rotate(180deg); +} + +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 2rem; + background-color: var(--septenary-contrast, #f5f5f5); + overflow: hidden; +} + +.example-popup { + width: 100%; + margin-block-start: 0; + border: none; + border-radius: 0; + background-color: transparent; + max-height: 15rem; + height: auto; +} + +.example-popup.example-popup-no-margin { + margin-block-start: 0; +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 13rem; + height: auto; + box-sizing: border-box; + padding: 0.5rem; + gap: 4px; + outline: none; +} + +.example-option { + cursor: pointer; + padding: 0 1.25rem; + border-radius: 3rem; + min-height: 3rem; + box-sizing: border-box; + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--primary-contrast, #1a1a1a); +} + +.example-selected-icon { + visibility: hidden; + font-size: 0.9rem; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--hot-pink); + background-color: color-mix(in srgb, var(--hot-pink) 10%, transparent); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 5%, transparent); +} + +.example-combobox-container:not(.no-active-outline):focus-within + [data-active='true']:not(.no-active-outline), +.example-option[data-active='true'] { + outline-offset: -2px; + outline: 2px solid var(--hot-pink); +} + +@media (forced-colors: active) { + .example-combobox-container:not(.no-active-outline):focus-within + [data-active='true']:not(.no-active-outline), + .example-option[data-active='true'] { + outline: 2px solid CanvasText; + } +} + +.example-dialog { + position: relative; + padding: 0; + background-color: transparent; + width: 15rem; + box-sizing: border-box; +} + +.example-dialog .example-combobox-container { + border: none; + border-radius: inherit; + box-shadow: none; + background-color: transparent; + padding: 10px; +} + +.example-dialog .example-combobox-input-container { + border-bottom: 1px solid var(--border-color); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.example-no-results { + padding: 1rem; +} + +.example-empty { + position: absolute; + visibility: hidden; + pointer-events: none; + height: 0; + width: 0; + overflow: hidden; +} + +.example-dialog-input[disabled], +.example-dialog-input[aria-disabled='true'] { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.html b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.html new file mode 100644 index 000000000000..b3ae7e172cc4 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.html @@ -0,0 +1,103 @@ + + +
+
+ + +
+ + + +
+
+
+ +
+ + +
+ +
+ {{ options().length === 0 ? 'No results found for ' + searchString() : '' }} +
+ + + +
+ @if (options().length === 0) { +
No results found
+ } +
+ @for (option of options(); track option) { +
+ {{ option }} + +
+ } +
+
+
+
+
+
+
+
+
diff --git a/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts new file mode 100644 index 000000000000..1661728c81e9 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/material/app/app.ts @@ -0,0 +1,100 @@ +/** + * @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, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + Component, + computed, + signal, + viewChild, + untracked, + ElementRef, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-root:not([theme="basic-material"])', + templateUrl: './app.html', + styleUrl: './app.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], +}) +export class App { + readonly listbox = viewChild>(Listbox); + readonly combobox = viewChild(Combobox); + readonly searchInput = viewChild>('searchInput'); + + readonly value = signal(''); + readonly searchString = signal(''); + readonly selectedCountries = signal([]); + readonly popupExpanded = signal(false); + + readonly options = computed(() => + ALL_COUNTRIES.filter((country) => + country.toLowerCase().startsWith(this.searchString().toLowerCase()), + ), + ); + + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); + } + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); + } + }); + } + + onCommit() { + const selected = this.selectedCountries(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } + } + + onSearchEscape(event: Event) { + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } +} + +const ALL_COUNTRIES = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Argentina', + 'Armenia', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Brazil', + 'Canada', + 'Egypt', + 'France', + 'Germany', + 'India', + 'Japan', + 'Mexico', + 'United Kingdom', + 'United States of America', +]; diff --git a/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.css b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.css new file mode 100644 index 000000000000..20a56f37d41b --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.css @@ -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 url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined'); +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); + +:host { + display: flex; + justify-content: center; + font-family: 'Press Start 2P', Courier, monospace; + font-size: 0.6rem; + --border-color: var(--tertiary-contrast, #000000); + + --retro-button-color: var(--septenary-contrast, #ffffff); + --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #ffffff); + --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000000); + --retro-elevated-shadow: + inset 4px 4px 0px 0px var(--retro-shadow-light), + inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--border-color), + 0px 4px 0px 0px var(--border-color), -4px 0px 0px 0px var(--border-color), + 0px -4px 0px 0px var(--border-color); + --retro-flat-shadow: + 4px 0px 0px 0px var(--border-color), 0px 4px 0px 0px var(--border-color), + -4px 0px 0px 0px var(--border-color), 0px -4px 0px 0px var(--border-color); +} + +.example-combobox-container { + position: relative; + width: 100%; + display: flex; + flex-direction: column; + border: 4px solid var(--border-color); + border-radius: 0; + background-color: var(--retro-button-color); +} + +.example-combobox-container:has(.example-dialog-input) { + width: 16rem; + box-shadow: var(--retro-flat-shadow); +} + +.example-combobox-input-container { + display: flex; + position: relative; + align-items: center; +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 0.6rem; + font-family: 'Press Start 2P', Courier, monospace; + word-spacing: -4px; + padding: 0.75rem 1rem 0.75rem 2.5rem; + background-color: transparent; + color: var(--primary-contrast, #000000); +} + +.example-combobox-input::-webkit-search-cancel-button, +.example-combobox-input::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.example-combobox-input.example-dialog-input { + cursor: pointer; + text-align: left; + padding: 0.75rem 1rem; +} + +.example-combobox-container:focus-within { + outline: 3px solid var(--vivid-pink); +} + +@media (forced-colors: active) { + .example-combobox-container:focus-within { + outline: 3px solid CanvasText; + } +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 20px; + color: var(--primary-contrast, #000000); + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-arrow-icon { + padding: 0 0.5rem; + position: absolute; + right: 0; + opacity: 0.8; +} + +.example-popover { + margin: 0; + padding: 0; + border: 4px solid var(--border-color); + background-color: var(--septenary-contrast, #f5f5f5); + box-shadow: 8px 8px 0px var(--border-color); /* 3D offset shadow */ +} + +.example-popup { + width: 100%; + background-color: transparent; + max-height: 15rem; + height: auto; +} + +.example-popup.example-popup-no-margin { + margin-block-start: 0; +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 13rem; + height: auto; + box-sizing: border-box; + padding: 0.5rem; + gap: 4px; + outline: none; +} + +.example-option { + cursor: pointer; + padding: 0.3rem 1rem; + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--primary-contrast, #000000); + font-size: 0.6rem; + border: 2px solid transparent; +} + +.example-selected-icon { + visibility: hidden; + font-size: 0.9rem; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[aria-selected='true'] { + color: var(--page-background, #ffffff); + background-color: var(--vivid-pink); +} + +.example-option:hover { + background-color: color-mix(in srgb, var(--primary-contrast, #1a1a1a) 10%, transparent); + border-color: var(--border-color); +} + +.example-option[data-active='true'] { + outline: 3px dashed var(--vivid-pink); + outline-offset: -3px; +} + +@media (forced-colors: active) { + .example-option[data-active='true'] { + outline: 3px solid CanvasText; + } +} + +.example-dialog { + position: relative; + padding: 0; + background-color: transparent; + width: 16rem; + box-sizing: border-box; +} + +.example-dialog .example-combobox-input-container { + border-bottom: 3px double var(--border-color); +} + +.example-no-results { + padding: 1rem; + font-weight: bold; +} + +.example-empty { + position: absolute; + visibility: hidden; + pointer-events: none; + height: 0; + width: 0; + overflow: hidden; +} + +.example-dialog-input[disabled], +.example-dialog-input[aria-disabled='true'] { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.html b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.html new file mode 100644 index 000000000000..b3ae7e172cc4 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.html @@ -0,0 +1,103 @@ + + +
+
+ + +
+ + + +
+
+
+ +
+ + +
+ +
+ {{ options().length === 0 ? 'No results found for ' + searchString() : '' }} +
+ + + +
+ @if (options().length === 0) { +
No results found
+ } +
+ @for (option of options(); track option) { +
+ {{ option }} + +
+ } +
+
+
+
+
+
+
+
+
diff --git a/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts new file mode 100644 index 000000000000..aab8177eb463 --- /dev/null +++ b/adev/src/content/examples/aria/combobox/src/dialog/retro/app/app.ts @@ -0,0 +1,100 @@ +/** + * @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, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + Component, + computed, + signal, + viewChild, + untracked, + ElementRef, +} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-root:not([theme="basic-retro"])', + templateUrl: './app.html', + styleUrl: './app.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule], +}) +export class App { + readonly listbox = viewChild>(Listbox); + readonly combobox = viewChild(Combobox); + readonly searchInput = viewChild>('searchInput'); + + readonly value = signal(''); + readonly searchString = signal(''); + readonly selectedCountries = signal([]); + readonly popupExpanded = signal(false); + + readonly options = computed(() => + ALL_COUNTRIES.filter((country) => + country.toLowerCase().startsWith(this.searchString().toLowerCase()), + ), + ); + + constructor() { + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => { + setTimeout(() => { + this.searchInput()?.nativeElement.focus(); + }); + }); + } + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + this.listbox()?.scrollActiveItemIntoView(); + } + }); + } + + onCommit() { + const selected = this.selectedCountries(); + if (selected.length > 0) { + this.value.set(selected[0]); + this.searchString.set(''); + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } + } + + onSearchEscape(event: Event) { + this.popupExpanded.set(false); + this.combobox()?.element.focus(); + } +} + +const ALL_COUNTRIES = [ + 'Afghanistan', + 'Albania', + 'Algeria', + 'Andorra', + 'Angola', + 'Argentina', + 'Armenia', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Brazil', + 'Canada', + 'Egypt', + 'France', + 'Germany', + 'India', + 'Japan', + 'Mexico', + 'United Kingdom', + 'United States of America', +]; diff --git a/adev/src/content/guide/aria/combobox.md b/adev/src/content/guide/aria/combobox.md index c787c2d2fa65..6d3c6ea94227 100644 --- a/adev/src/content/guide/aria/combobox.md +++ b/adev/src/content/guide/aria/combobox.md @@ -8,42 +8,42 @@ ## Overview -A directive that coordinates a text input with a popup, providing the primitive directive for autocomplete, select, and multiselect patterns. +A directive that coordinates a trigger element (such as a text input, button, or `div`) with a popup, providing the primitive directive for autocomplete, select, and multiselect patterns. - + ## Usage -Combobox is the primitive directive that coordinates a text input with a popup. It provides the foundation for autocomplete, select, and multiselect patterns. Consider using combobox directly when: +Combobox is the primitive directive that coordinates an interactive trigger element (such as a text input, button, or `div`) with a popup. It provides the foundation for autocomplete, select, and multiselect patterns. Consider using combobox directly when: - **Building custom autocomplete patterns** - Creating specialized filtering or suggestion behavior - **Creating custom selection components** - Developing dropdowns with unique requirements - **Coordinating input with popup** - Pairing text input with listbox, tree, or dialog content -- **Implementing specific filter modes** - Using manual, auto-select, or highlight behaviors +- **Implementing custom filtering** - Filtering and orchestrating matching options in user space Use documented patterns instead when: @@ -57,8 +57,8 @@ NOTE: The [Autocomplete](guide/aria/autocomplete), [Select](guide/aria/select), Angular's combobox provides a fully accessible input-popup coordination system with: -- **Text Input with Popup** - Coordinates input field with popup content -- **Three Filter Modes** - Manual, auto-select, or highlight behaviors +- **Trigger Element with Popup** - Coordinates trigger element with popup content +- **Flexible Coordination** - Integrates seamlessly with standard layouts (listbox, tree, grid, or dialog) - **Keyboard Navigation** - Arrow keys, Enter, Escape handling - **Screen Reader Support** - Built-in ARIA attributes including role="combobox" and aria-expanded - **Popup Management** - Automatic show/hide based on user interaction @@ -70,7 +70,7 @@ Angular's combobox provides a fully accessible input-popup coordination system w An accessible input field that filters and suggests options as users type, helping them find and select values from a list. - + -The `filterMode="manual"` setting gives complete control over filtering and selection. The input updates a signal that filters the options list. Users navigate with arrow keys and select with Enter or click. This mode provides the most flexibility for custom filtering logic. See the [Autocomplete guide](guide/aria/autocomplete) for complete filtering patterns and examples. +Filtering is managed in user space by updating a signal that reactively filters the options list. Users navigate with arrow keys and select with Enter or click. This provides complete control and maximum flexibility for custom selection logic. See the [Autocomplete guide](guide/aria/autocomplete) for complete filtering patterns and examples. ### Readonly mode A pattern that combines a readonly combobox with listbox to create single-selection dropdowns with keyboard navigation and screen reader support. - + -The `readonly` attribute prevents typing in the input field. The popup opens on click or arrow keys. Users navigate options with keyboard and select with Enter or click. +Triggering a dropdown without text input can be achieved using a button as the host trigger, or applying the native HTML `readonly` attribute to the input trigger. The popup opens on click or arrow keys. This configuration provides the foundation for the [Select](guide/aria/select) and [Multiselect](guide/aria/multiselect) patterns. See those guides for complete dropdown implementations with triggers and overlay positioning. +### Datepicker grid + +Combobox can coordinate with a two-dimensional grid to create accessible datepickers. Users navigate dates inside the calendar grid table using directional arrow keys and confirm selection with click, Enter, or Spacebar. + + + + + + + + + + + + + + + + + + + + + + + + + + + ### Dialog popup Popups sometimes need modal behavior with a backdrop and focus trap. The combobox dialog directive provides this pattern for specialized use cases. - + -The `ngComboboxDialog` directive creates a modal popup using the native dialog element. This provides backdrop behavior and focus trapping. Use dialog popups when the selection interface requires modal interaction or when the popup content is complex enough to warrant full-screen focus. +Dialog popups combine the combobox trigger with standard dialog layouts and focus traps (such as CDK's `cdkTrapFocus`). Use dialog popups when the overlay requires modal behavior or backdrop interaction. ## APIs ### Combobox Directive -The `ngCombobox` directive coordinates a text input with a popup. - -#### Inputs - -| Property | Type | Default | Description | -| ---------------- | ---------------------------------------------- | ---------- | ------------------------------------------------ | -| `filterMode` | `'manual'` \| `'auto-select'` \| `'highlight'` | `'manual'` | Controls selection behavior | -| `disabled` | `boolean` | `false` | Disables the combobox | -| `readonly` | `boolean` | `false` | Makes combobox readonly (for Select/Multiselect) | -| `firstMatch` | `V` | - | Value of first matching item for auto-select | -| `alwaysExpanded` | `boolean` | `false` | Keeps popup always open | +Coordinates an interactive trigger element (such as a text input, button, or div) with a popup container. -**Filter Modes:** +#### Inputs / Model -- **`'manual'`** - User controls filtering and selection explicitly. The popup shows options based on your filtering logic. Users select with Enter or click. This mode provides the most flexibility. -- **`'auto-select'`** - Input value automatically updates to the first matching option as users type. Requires the `firstMatch` input for coordination. See the [Autocomplete guide](guide/aria/autocomplete#auto-select-mode) for examples. -- **`'highlight'`** - Highlights matching text without changing the input value. Users navigate with arrow keys and select with Enter. +| Property | Type | Default | Description | +| ------------------ | ---------------------- | ------- | ------------------------------------------------------------------- | +| `value` | `ModelSignal` | `''` | Two-way bindable text value of the combobox | +| `expanded` | `ModelSignal` | `false` | Two-way bindable open/closed expanded state of the popup | +| `disabled` | `boolean` | `false` | Disables the combobox trigger element | +| `softDisabled` | `boolean` | `true` | Disables interaction while keeping the element keyboard focusable | +| `alwaysExpanded` | `boolean` | `false` | Forces the popup to always remain open | +| `inlineSuggestion` | `string \| undefined` | - | Sets an inline suggestion to be highlighted at the end of the input | +| `tabIndex` | `number \| undefined` | - | Tabindex of the combobox element (aliased to `tabindex`) | -#### Signals +All keyboard events, focus coordination, and ARIA state properties (including `role="combobox"`, `aria-autocomplete`, and `aria-expanded`) are handled automatically on the host element. -| Property | Type | Description | -| ---------- | ----------------- | ------------------------------- | -| `expanded` | `Signal` | Whether popup is currently open | - -#### Methods - -| Method | Parameters | Description | -| ---------- | ---------- | ---------------------- | -| `open` | none | Opens the combobox | -| `close` | none | Closes the combobox | -| `expand` | none | Expands the combobox | -| `collapse` | none | Collapses the combobox | - -### ComboboxInput Directive - -The `ngComboboxInput` directive connects an input element to the combobox. - -#### Model - -| Property | Type | Description | -| -------- | -------- | ---------------------------------------- | -| `value` | `string` | Two-way bindable value using `[(value)]` | - -The input element receives keyboard handling and ARIA attributes automatically. +--- ### ComboboxPopup Directive -The `ngComboboxPopup` directive (host directive) manages popup visibility and coordination. Typically used with `ngComboboxPopupContainer` in an `ng-template` or with CDK Overlay. +Marks an `` as the popup container for the combobox. -### ComboboxPopupContainer Directive +#### Inputs -The `ngComboboxPopupContainer` directive marks an `ng-template` as the popup content. +| Property | Type | Default | Description | +| ----------- | ------------------------------------------- | ----------- | ---------------------------------------------- | +| `combobox` | `Combobox` | (Required) | Reference to the parent `Combobox` directive | +| `popupType` | `'listbox' \| 'tree' \| 'grid' \| 'dialog'` | `'listbox'` | Specifies the layout/role profile of the popup | -```html - -
...
-
-``` +--- -Used with Popover API or CDK Overlay for positioning. +### ComboboxWidget Directive -### ComboboxDialog Directive +Connects the popup contents (such as a listbox or grid) with the parent combobox trigger. -The `ngComboboxDialog` directive creates a modal combobox popup. +#### Inputs -```html - -
...
-
-``` +| Property | Type | Description | +| ------------------ | --------------------- | ----------------------------------------------------------------------------------- | +| `activeDescendant` | `string \| undefined` | The ID of the currently active option (bound to the active option ID in the widget) | -Use for modal popup behavior with backdrop and focus trap. +--- ### Related patterns and directives Combobox is the primitive directive for these documented patterns: -- **[Autocomplete](guide/aria/autocomplete)** - Filtering and suggestions pattern (uses Combobox with filter modes) -- **[Select](guide/aria/select)** - Single selection dropdown pattern (uses Combobox with `readonly`) -- **[Multiselect](guide/aria/multiselect)** - Multiple selection pattern (uses Combobox with `readonly` + multi-enabled Listbox) +- **[Autocomplete](guide/aria/autocomplete)** - Filtering and suggestions pattern (coordinates input typing with options list) +- **[Select](guide/aria/select)** - Single selection dropdown pattern (applied directly on non-editable button triggers) +- **[Multiselect](guide/aria/multiselect)** - Multiple selection pattern (applied on non-editable triggers with multi-enabled Listbox) Combobox typically combines with: