Skip to content

Commit d7d88b5

Browse files
authored
fix(Calendar): handle keyboard navigation with disabled dates (#1757)
1 parent d55454f commit d7d88b5

File tree

4 files changed

+193
-110
lines changed

4 files changed

+193
-110
lines changed

packages/core/src/Calendar/Calendar.test.ts

+38-1
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ describe('calendar', async () => {
520520
const { getByTestId, user } = setup({
521521
calendarProps: {
522522
placeholder: calendarDate,
523-
isDateUnavailable: (date) => {
523+
isDateUnavailable: (date: DateValue) => {
524524
return date.day === 3
525525
},
526526
},
@@ -534,6 +534,43 @@ describe('calendar', async () => {
534534
expect(thirdDayInMonth).not.toHaveAttribute('data-selected')
535535
})
536536

537+
it('handles disabled dates appropriately', async () => {
538+
const { getByTestId, user } = setup({
539+
calendarProps: {
540+
placeholder: calendarDate,
541+
isDateDisabled: (date: DateValue) => {
542+
return date.day === 3
543+
},
544+
},
545+
})
546+
547+
const thirdDayInMonth = getByTestId('date-1-3')
548+
expect(thirdDayInMonth).toHaveTextContent('3')
549+
expect(thirdDayInMonth).toHaveAttribute('data-disabled')
550+
expect(thirdDayInMonth).toHaveAttribute('aria-disabled', 'true')
551+
await user.click(thirdDayInMonth)
552+
expect(thirdDayInMonth).not.toHaveFocus()
553+
554+
const secondDayInMonth = getByTestId('date-1-2')
555+
secondDayInMonth.focus()
556+
557+
expect(secondDayInMonth).toHaveFocus()
558+
await user.keyboard(kbd.ARROW_RIGHT)
559+
expect(getByTestId('date-1-4')).toHaveFocus()
560+
await user.keyboard(kbd.ARROW_LEFT)
561+
expect(secondDayInMonth).toHaveFocus()
562+
563+
const tenthDayOfMonth = getByTestId('date-1-10')
564+
tenthDayOfMonth.focus()
565+
expect(tenthDayOfMonth).toHaveFocus()
566+
567+
await user.keyboard(kbd.ARROW_UP)
568+
expect(getByTestId('date-12-27')).toHaveFocus()
569+
570+
await user.keyboard(kbd.ARROW_DOWN)
571+
expect(getByTestId('date-1-10')).toHaveFocus()
572+
})
573+
537574
it('doesnt allow focus or interaction when `disabled` is `true`', async () => {
538575
const { getByTestId, user } = setup({
539576
calendarProps: {

packages/core/src/Calendar/CalendarCellTrigger.vue

+75-54
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { computed, nextTick } from 'vue'
1111
import { useKbd } from '@/shared'
1212
import { getDaysInMonth, toDate } from '@/date'
13+
import { getSelectableCells } from './utils'
1314
1415
export interface CalendarCellTriggerProps extends PrimitiveProps {
1516
/** The date value provided to the cell trigger */
@@ -83,9 +84,6 @@ const isFocusedDate = computed(() => {
8384
})
8485
const isSelectedDate = computed(() => rootContext.isDateSelected(props.day))
8586
86-
const SELECTOR
87-
= '[data-reka-calendar-cell-trigger]:not([data-outside-view]):not([data-outside-visible-view])'
88-
8987
function changeDate(date: DateValue) {
9088
if (rootContext.readonly.value)
9189
return
@@ -103,82 +101,105 @@ function handleArrowKey(e: KeyboardEvent) {
103101
e.preventDefault()
104102
e.stopPropagation()
105103
const parentElement = rootContext.parentElement.value!
106-
const allCollectionItems: HTMLElement[] = parentElement
107-
? Array.from(parentElement.querySelectorAll(SELECTOR))
108-
: []
109-
const index = allCollectionItems.indexOf(currentElement.value)
110-
let newIndex = index
111104
const indexIncrementation = 7
112105
const sign = rootContext.dir.value === 'rtl' ? -1 : 1
113106
switch (e.code) {
114107
case kbd.ARROW_RIGHT:
115-
newIndex += sign
108+
shiftFocus(currentElement.value, sign)
116109
break
117110
case kbd.ARROW_LEFT:
118-
newIndex -= sign
111+
shiftFocus(currentElement.value, -sign)
119112
break
120113
case kbd.ARROW_UP:
121-
newIndex -= indexIncrementation
114+
shiftFocus(currentElement.value, -indexIncrementation)
122115
break
123116
case kbd.ARROW_DOWN:
124-
newIndex += indexIncrementation
117+
shiftFocus(currentElement.value, indexIncrementation)
125118
break
126119
case kbd.ENTER:
127120
case kbd.SPACE_CODE:
128121
changeDate(props.day)
129-
return
130-
default:
131-
return
132122
}
133123
134-
if (newIndex >= 0 && newIndex < allCollectionItems.length) {
135-
allCollectionItems[newIndex].focus()
136-
return
137-
}
124+
function shiftFocus(node: HTMLElement, add: number) {
125+
const allCollectionItems: HTMLElement[] = getSelectableCells(parentElement)
126+
if (!allCollectionItems.length)
127+
return
128+
129+
const index = allCollectionItems.indexOf(node)
130+
const newIndex = index + add
138131
139-
if (newIndex < 0) {
140-
if (rootContext.isPrevButtonDisabled())
132+
if (newIndex >= 0 && newIndex < allCollectionItems.length) {
133+
if (allCollectionItems[newIndex].hasAttribute('data-disabled')) {
134+
shiftFocus(allCollectionItems[newIndex], add)
135+
}
136+
allCollectionItems[newIndex].focus()
141137
return
142-
rootContext.prevPage()
143-
nextTick(() => {
144-
const newCollectionItems: HTMLElement[] = parentElement
145-
? Array.from(parentElement.querySelectorAll(SELECTOR))
146-
: []
147-
if (!rootContext.pagedNavigation.value && rootContext.numberOfMonths.value > 1) {
138+
}
139+
140+
if (newIndex < 0) {
141+
if (rootContext.isPrevButtonDisabled())
142+
return
143+
rootContext.prevPage()
144+
nextTick(() => {
145+
const newCollectionItems: HTMLElement[] = getSelectableCells(parentElement)
146+
if (!newCollectionItems.length)
147+
return
148+
if (!rootContext.pagedNavigation.value && rootContext.numberOfMonths.value > 1) {
148149
// Placeholder is set to first month of the new page
149-
const numberOfDays = getDaysInMonth(rootContext.placeholder.value)
150+
const numberOfDays = getDaysInMonth(rootContext.placeholder.value)
151+
const computedIndex = numberOfDays - Math.abs(newIndex)
152+
if (newCollectionItems[computedIndex].hasAttribute('data-disabled')) {
153+
shiftFocus(newCollectionItems[computedIndex], add)
154+
}
155+
newCollectionItems[
156+
computedIndex
157+
].focus()
158+
return
159+
}
160+
const computedIndex = newCollectionItems.length - Math.abs(newIndex)
161+
if (newCollectionItems[computedIndex].hasAttribute('data-disabled')) {
162+
shiftFocus(newCollectionItems[computedIndex], add)
163+
}
150164
newCollectionItems[
151-
numberOfDays - Math.abs(newIndex)
165+
computedIndex
152166
].focus()
153-
return
154-
}
155-
newCollectionItems[
156-
newCollectionItems.length - Math.abs(newIndex)
157-
].focus()
158-
})
159-
return
160-
}
161-
162-
if (newIndex >= allCollectionItems.length) {
163-
if (rootContext.isNextButtonDisabled())
167+
})
164168
return
165-
rootContext.nextPage()
166-
nextTick(() => {
167-
const newCollectionItems: HTMLElement[] = parentElement
168-
? Array.from(parentElement.querySelectorAll(SELECTOR))
169-
: []
169+
}
170170
171-
if (!rootContext.pagedNavigation.value && rootContext.numberOfMonths.value > 1) {
172-
// Placeholder is set to first month of the new page
173-
const numberOfDays = getDaysInMonth(
174-
rootContext.placeholder.value.add({ months: rootContext.numberOfMonths.value - 1 }),
175-
)
176-
newCollectionItems[newIndex - allCollectionItems.length + (newCollectionItems.length - numberOfDays)].focus()
171+
if (newIndex >= allCollectionItems.length) {
172+
if (rootContext.isNextButtonDisabled())
177173
return
178-
}
174+
rootContext.nextPage()
175+
nextTick(() => {
176+
const newCollectionItems: HTMLElement[] = getSelectableCells(parentElement)
177+
if (!newCollectionItems.length)
178+
return
179179
180-
newCollectionItems[newIndex - allCollectionItems.length].focus()
181-
})
180+
if (!rootContext.pagedNavigation.value && rootContext.numberOfMonths.value > 1) {
181+
// Placeholder is set to first month of the new page
182+
const numberOfDays = getDaysInMonth(
183+
rootContext.placeholder.value.add({ months: rootContext.numberOfMonths.value - 1 }),
184+
)
185+
186+
const computedIndex = newIndex - allCollectionItems.length + (newCollectionItems.length - numberOfDays)
187+
188+
if (newCollectionItems[computedIndex].hasAttribute('data-disabled')) {
189+
shiftFocus(newCollectionItems[computedIndex], add)
190+
}
191+
newCollectionItems[computedIndex].focus()
192+
return
193+
}
194+
195+
const computedIndex = newIndex - allCollectionItems.length
196+
if (newCollectionItems[computedIndex].hasAttribute('data-disabled')) {
197+
shiftFocus(newCollectionItems[computedIndex], add)
198+
}
199+
200+
newCollectionItems[computedIndex].focus()
201+
})
202+
}
182203
}
183204
}
184205
</script>

packages/core/src/Calendar/utils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const SELECTOR
2+
= '[data-reka-calendar-cell-trigger]:not([data-outside-view]):not([data-outside-visible-view])'
3+
export function getSelectableCells(calendar: HTMLElement): HTMLElement[] {
4+
return Array.from(calendar.querySelectorAll(SELECTOR)) ?? []
5+
}

0 commit comments

Comments
 (0)