Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Calendar navigation #24124

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ node_modules
/.vs
*.swo
*.swp
.vimrc
.nvimrc

# misc
.DS_Store
Expand Down
87 changes: 53 additions & 34 deletions src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,59 @@
[style.paddingBottom]="_cellPadding">
{{_firstRowOffset >= labelMinRequiredCells ? label : ''}}
</td>
<td *ngFor="let item of row; let colIndex = index"
role="gridcell"
class="mat-calendar-body-cell"
[ngClass]="item.cssClasses"
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
[attr.data-mat-row]="rowIndex"
[attr.data-mat-col]="colIndex"
[class.mat-calendar-body-disabled]="!item.enabled"
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
[attr.aria-label]="item.ariaLabel"
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-selected]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
(click)="_cellClicked(item, $event)"
[style.width]="_cellWidth"
[style.paddingTop]="_cellPadding"
[style.paddingBottom]="_cellPadding">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
[class.mat-calendar-body-today]="todayValue === item.compareValue">
{{item.displayValue}}
<!--
The anatomy of a normal cell in the callendar body has two main parts.
1. A `button` role element to visually displays each cell and provides interaction
2. A `gridcell` role element wraps the button to make it part of a table

<td role="gridcell class="mat-caldnar-body-cell-wrapper">
<div role="button" class="mat-calendar-body-cell" ...>
...details omitted
</div>
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
</td>

We render the calendar body cells as buttons to ensure that VoiceOver announces them as
interactable (issue #23476). A `gridcell` wraps each button, so that table-specific navigation
features are available to assistive technology. The gridcell is only used for semantic purposes
and does not affect the visual appearance.
-->
<td *ngFor="let item of row; let colIndex = index" role="gridcell" class="mat-calendar-body-cell-wrapper">
<div
role="button"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we use a button element instead of a div with a role?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the draft implementation of it, it was the path of least resistance to use div role="button" because it doesn't affect the visual styles. We would need to override a few (not sure exactly how many) style rules to get a button element to look correct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class="mat-calendar-body-cell"
[ngClass]="item.cssClasses"
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
[attr.data-mat-row]="rowIndex"
[attr.data-mat-col]="colIndex"
[class.mat-calendar-body-disabled]="!item.enabled"
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
[attr.aria-label]="item.ariaLabel"
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-selected]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
(click)="_cellClicked(item, $event)"
(focus)="_cellFocused(item, $event)"
[style.width]="_cellWidth"
[style.paddingTop]="_cellPadding"
[style.paddingBottom]="_cellPadding">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
[class.mat-calendar-body-today]="todayValue === item.compareValue">
{{item.displayValue}}
</div>
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
</div>
</td>
</tr>
5 changes: 5 additions & 0 deletions src/material/datepicker/calendar-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ $calendar-range-end-body-cell-size:
padding-right: $calendar-body-label-side-padding;
}

.mat-calendar-body-cell-wrapper {
display: contents;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? Also it seems this isn't supported in Safari.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, well, the MDN Compatibility table for display says there is some issues on Safari with unusual elements 🤔 . I tried this on Safari and it seems to render fine.

Safari Version 15.1 (17612.2.9.1.20)

}

.mat-calendar-body-cell {
display: table-cell;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had an issue where NVDA would announce an extra "table" because something had display: table. I wouldn't be surprised if it becomes an issue here too. See #23446.

position: relative;
height: 0;
line-height: 0;
Expand Down
18 changes: 16 additions & 2 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
/** Emits when a new value is selected. */
@Output() readonly selectedValueChange = new EventEmitter<MatCalendarUserEvent<number>>();

/** Emits when a new date becomes active. */
@Output() readonly activeValueChange = new EventEmitter<MatCalendarUserEvent<number>>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this event used for? It seems like we're only emitting it/forwarding it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good question. What this is trying to accomplish is making it so that the calendar body can update the value of the activeDate when a cell receives the focus event. activeDate has a reference in three components – the root component MatCalendarBody, the component for the specific view month/year/multiyear and the calendar body.

This might be easier to do with banana in a box rather than passing an event. That is currently (in master branch) how the MatCalendar component gives the activeDate to the MatMonthView component (and also MatMonthView and MatMultiYearView).

[(activeDate)]="activeDate"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so if I'm understanding it correctly, what it's trying to achieve is to sync the activeDate back up with the calendar if focus is moved by something we don't control (e.g. a keyboard shortcut)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, if I had more time, I would even go as far as renaming activeDate to focusedDate, but I'm not sure that would be worth changing public API for.


/** Emits when the preview has changed as a result of a user action. */
@Output() readonly previewChange = new EventEmitter<
MatCalendarUserEvent<MatCalendarCell | null>
Expand Down Expand Up @@ -153,6 +156,13 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
}
}

/** Called when a cell is focused. */
_cellFocused(cell: MatCalendarCell, event: any): void {
if (cell.enabled) {
this.activeValueChange.emit({value: cell.value, event});
}
}

/** Returns whether a cell should be marked as selected. */
_isSelected(value: number) {
return this.startValue === value || this.endValue === value;
Expand Down Expand Up @@ -337,7 +347,11 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
// Only reset the preview end value when leaving cells. This looks better, because
// we have a gap between the cells and the rows and we don't want to remove the
// range just for it to show up again when the user moves a few pixels to the side.
if (event.target && isTableCell(event.target as HTMLElement)) {
if (
event.target &&
(event.target as HTMLElement).parentElement &&
isTableCell((event.target as HTMLElement).parentElement as HTMLElement)
) {
this._ngZone.run(() => this.previewChange.emit({value: null, event}));
}
}
Expand All @@ -350,7 +364,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
if (isTableCell(element)) {
cell = element;
} else if (isTableCell(element.parentNode!)) {
cell = element.parentNode as HTMLElement;
cell = element;
}

if (cell) {
Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/month-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
[labelMinRequiredCells]="3"
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
(selectedValueChange)="_dateSelected($event)"
(activeValueChange)="_dayBecomesActive($event)"
(previewChange)="_previewChanged($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
Expand Down
14 changes: 12 additions & 2 deletions src/material/datepicker/month-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,14 @@ describe('MatMonthView', () => {
fixture.detectChanges();
});

it('should decrement date on left arrow press', () => {
fit('should decrement date on left arrow press', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover from debugging?

dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 4));

calendarInstance.date = new Date(2017, JAN, 1);
fixture.detectChanges();
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 1));

dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW);
fixture.detectChanges();
Expand Down Expand Up @@ -653,7 +654,16 @@ describe('MatMonthView', () => {
(_userSelection)="userSelectionSpy($event)"></mat-month-view>`,
})
class StandardMonthView {
date = new Date(2017, JAN, 5);
get date(): Date {
console.log('test code. `get date`', this._date);
return this._date;
}
set date(date: Date) {
console.log('test code. `set date`', date);
this._date = date;
}
_date = new Date(2017, JAN, 5);

selected: Date | DateRange<Date> = new Date(2017, JAN, 10);
selectedChangeSpy = jasmine.createSpy('selectedChange');
userSelectionSpy = jasmine.createSpy('userSelection');
Expand Down
26 changes: 26 additions & 0 deletions src/material/datepicker/month-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,30 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
this._changeDetectorRef.markForCheck();
}

_dayBecomesActive(event: MatCalendarUserEvent<number>) {
const date = event.value;
const selectedYear = this._dateAdapter.getYear(this.activeDate);
const selectedMonth = this._dateAdapter.getMonth(this.activeDate);
const activeDate = this._dateAdapter.createDate(selectedYear, selectedMonth, date);
let rangeStartDate: number | null;
let rangeEndDate: number | null;

if (this._activeDate instanceof DateRange) {
rangeStartDate = this._getDateInCurrentMonth(this._activeDate.start);
rangeEndDate = this._getDateInCurrentMonth(this._activeDate.end);
} else {
rangeStartDate = rangeEndDate = this._getDateInCurrentMonth(this._activeDate);
}

this.activeDate = activeDate;

if (rangeStartDate !== date || rangeEndDate !== date) {
this.activeDateChange.emit(activeDate);
}

this._changeDetectorRef.markForCheck();
}

/** Handles keydown events on the calendar body when calendar is in month view. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
Expand Down Expand Up @@ -326,10 +350,12 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
}

if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
console.log("firing activeDateChange", this.activeDate);
this.activeDateChange.emit(this.activeDate);
}

this._focusActiveCell();

// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/multi-year-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[cellAspectRatio]="4 / 7"
[activeCell]="_getActiveCell()"
(selectedValueChange)="_yearSelected($event)"
(activeValueChange)="_yearBecomesActive($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
</tbody>
Expand Down
16 changes: 15 additions & 1 deletion src/material/datepicker/multi-year-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,21 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
);
}

_yearBecomesActive(event: MatCalendarUserEvent<number>) {
const year = event.value;
let month = this._dateAdapter.getMonth(this.activeDate);
let daysInMonth = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.createDate(year, month, 1),
);
this.activeDateChange.emit(
this._dateAdapter.createDate(
year,
month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
),
);
}

/** Handles keydown events on the calendar body when calendar is in multi-year view. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
const oldActiveDate = this._activeDate;
Expand Down Expand Up @@ -278,7 +293,6 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
this.activeDateChange.emit(this.activeDate);
}

this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/year-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
[cellAspectRatio]="4 / 7"
[activeCell]="_dateAdapter.getMonth(activeDate)"
(selectedValueChange)="_monthSelected($event)"
(activeValueChange)="_monthBecomesActive($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
</tbody>
Expand Down
20 changes: 19 additions & 1 deletion src/material/datepicker/year-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,25 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
);
}

/** Handles when a new month becomes active. */
_monthBecomesActive(event: MatCalendarUserEvent<number>) {
const month = event.value;
const normalizedDate = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
1,
);

const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate);

this.activeDateChange.emit(
this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
),
);
}
/** Handles keydown events on the calendar body when calendar is in year view. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
Expand Down Expand Up @@ -261,7 +280,6 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
this.activeDateChange.emit(this.activeDate);
}

this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand Down