Skip to content

Commit 790d3ad

Browse files
committed
feat: Rewrite arrow navigation, add support for multi-calendars, start focus from an active element. (resolves #1086,#1118)
1 parent 5a94680 commit 790d3ad

File tree

17 files changed

+213
-353
lines changed

17 files changed

+213
-353
lines changed

docs/props/general-configuration/index.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,17 @@ Sets the menu position on the page center, useful for smaller screens where ther
10501050

10511051
Navigate the menu via arrow keys
10521052

1053+
:::tip
1054+
If you wish to integrate arrow navigation within slots, you can set the following attributes:
1055+
1056+
- `:data-dp-action-element="{level}"` - Set on the element that can receive focus
1057+
- `:data-dp-element-active="{level}"` - (optional) Set on the element that can have active state. If present, focus starts from the active element
1058+
1059+
Available levels: `0 | 1 | 2`
1060+
1061+
Levels indicate level in the menu hierarchy. On the initial menu level, you are on level `0`. If you open an overlay, you are on level `1`. If overlay has another overlay (e.g. hours/minutes), you are on level `2`.
1062+
:::
1063+
10531064
<GlobalDemo :arrow-navigation="true"></GlobalDemo>
10541065

10551066
::: details Code Example

src/VueDatePicker/VueDatePicker.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@
9191
import {
9292
useExternalInternalMapper,
9393
useSlotsMapper,
94-
useArrowNavigation,
9594
useTransitions,
9695
useValidation,
9796
useResponsive,
@@ -108,7 +107,6 @@
108107
rootProps,
109108
defaults: { inline, config, textInput, range, multiDates, teleport, floatingConfig },
110109
} = useContext();
111-
const { clearArrowNav } = useArrowNavigation();
112110
const { validateDate, isValidTime } = useValidation();
113111
const { menuTransition, showTransition } = useTransitions();
114112
const { isMobile } = useResponsive();
@@ -348,7 +346,6 @@
348346
isOpen.value = false;
349347
setState('menuFocused', false);
350348
setState('shiftKeyInMenu', false);
351-
clearArrowNav();
352349
rootEmit('closed');
353350
if (inputValue.value) {
354351
parseExternalModelValue(modelValueRef.value);

src/VueDatePicker/components/ActionRow.vue

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
v-if="!inline.enabled && actionRow.showCancel"
3131
ref="cancel-btn"
3232
type="button"
33+
data-dp-action-element="0"
3334
class="dp__action_button dp__action_cancel"
3435
@click="$emit('close-picker')"
3536
@keydown="checkKeyDown($event, () => $emit('close-picker'))"
@@ -39,6 +40,7 @@
3940
<button
4041
v-if="actionRow.showNow"
4142
type="button"
43+
data-dp-action-element="0"
4244
class="dp__action_button dp__action_cancel"
4345
@click="$emit('select-now')"
4446
@keydown="checkKeyDown($event, () => $emit('select-now'))"
@@ -49,6 +51,7 @@
4951
v-if="actionRow.showSelect"
5052
ref="select-btn"
5153
type="button"
54+
data-dp-action-element="0"
5255
class="dp__action_button dp__action_select"
5356
:disabled="boolHtmlAttribute(disabled)"
5457
data-test-id="select-button"
@@ -65,9 +68,8 @@
6568

6669
<script lang="ts" setup>
6770
import { computed, onUnmounted, onMounted, ref, useTemplateRef } from 'vue';
68-
import { unrefElement } from '@vueuse/core';
6971
70-
import { useArrowNavigation, useContext, useFormatter, useHelperFns, useValidation, useUtils } from '@/composables';
72+
import { useContext, useFormatter, useHelperFns, useValidation, useUtils } from '@/composables';
7173
7274
interface ActionRowEmits {
7375
'close-picker': [];
@@ -95,23 +97,17 @@
9597
} = useContext();
9698
9799
const { isTimeValid, isMonthValid } = useValidation();
98-
const { buildMatrix } = useArrowNavigation();
99100
const { formatPreview } = useFormatter();
100101
const { checkKeyDown, convertType } = useHelperFns();
101102
const { boolHtmlAttribute } = useUtils();
102103
103-
const cancelButtonRef = useTemplateRef('cancel-btn');
104-
const selectButtonRef = useTemplateRef('select-btn');
105104
const actionBtnContainer = useTemplateRef('action-buttons-container');
106105
const actionRowRef = useTemplateRef('action-row');
107106
108107
const showPreview = ref(false);
109108
const previewStyle = ref<any>({});
110109
111110
onMounted(() => {
112-
if (rootProps.arrowNavigation) {
113-
buildMatrix([unrefElement(cancelButtonRef), unrefElement(selectButtonRef)] as HTMLElement[], 'actionRow');
114-
}
115111
getPreviewAvailableSpace();
116112
globalThis.addEventListener('resize', getPreviewAvailableSpace);
117113
});

src/VueDatePicker/components/Common/ArrowBtn.vue

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
ref="arrow-btn"
44
type="button"
55
:data-dp-element="elName"
6+
data-dp-action-element="0"
67
class="dp__btn dp--arrow-btn-nav"
78
tabindex="0"
89
:aria-label="ariaLabel"
@@ -17,7 +18,7 @@
1718
</template>
1819

1920
<script lang="ts" setup>
20-
import { onMounted, type Ref, useTemplateRef } from 'vue';
21+
import { type Ref } from 'vue';
2122
import { useHelperFns } from '@/composables';
2223
2324
const { checkKeyDown } = useHelperFns();
@@ -32,8 +33,4 @@
3233
elName?: string;
3334
disabled?: boolean;
3435
}>();
35-
36-
const elRef = useTemplateRef('arrow-btn');
37-
38-
onMounted(() => emit('set-ref', elRef));
3936
</script>

src/VueDatePicker/components/Common/SelectionOverlay.vue

Lines changed: 18 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@
2828
:class="{ dp__flex_row: items.length >= 3 }"
2929
>
3030
<div
31-
v-for="(col, ind) in row"
31+
v-for="col in row"
3232
:key="col.value"
33-
:ref="(el) => assignRef(el, col, i, ind)"
3433
role="gridcell"
3534
:class="cellClassName"
3635
:aria-selected="col.active || undefined"
3736
:aria-disabled="col.disabled || undefined"
37+
:data-dp-action-element="level ?? 1"
38+
:data-dp-element-active="col.active ? (level ?? 1) : undefined"
3839
tabindex="0"
3940
:data-test-id="col.text"
4041
@click.prevent="onClick(col)"
@@ -57,6 +58,7 @@
5758
:aria-label="ariaLabels?.toggleOverlay"
5859
:class="actionButtonClass"
5960
tabindex="0"
61+
:data-dp-action-element="level ?? 1"
6062
@click="toggle"
6163
@keydown="onBtnKeyDown"
6264
>
@@ -69,14 +71,12 @@
6971
import { computed, nextTick, onBeforeUpdate, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
7072
import { unrefElement } from '@vueuse/core';
7173
72-
import { useArrowNavigation, useContext, useHelperFns } from '@/composables';
74+
import { useContext, useHelperFns } from '@/composables';
7375
import { useNavigationDisplay } from '@/components/shared/useNavigationDisplay.ts';
7476
7577
import { EventKey } from '@/constants';
7678
import type { DynamicClass, OverlayGridItem, PickerSection } from '@/types';
7779
78-
const { setSelectionGrid, buildMultiLevelMatrix, setMonthPicker } = useArrowNavigation();
79-
8080
const emit = defineEmits<{
8181
selected: [value: number];
8282
toggle: [];
@@ -87,76 +87,42 @@
8787
const props = defineProps<{
8888
items: OverlayGridItem[][];
8989
type: PickerSection;
90-
isLast: boolean;
91-
skipButtonRef?: boolean;
92-
headerRefs?: (HTMLElement | null)[];
9390
useRelative?: boolean;
9491
height?: number | string;
95-
noOverlayFocus?: boolean;
96-
focusValue?: number;
97-
menuWrapRef?: HTMLElement | null;
9892
overlayLabel?: string;
93+
isLast: boolean;
94+
level?: 0 | 1 | 2;
9995
}>();
10096
10197
const {
102-
rootProps,
103-
defaults: { ariaLabels, textInput, config },
98+
setState,
99+
defaults: { ariaLabels, config },
104100
} = useContext();
105101
const { hideNavigationButtons } = useNavigationDisplay();
106-
const { handleEventPropagation, convertType, checkKeyDown, checkStopPropagation, getElWithin, findFocusableEl } =
107-
useHelperFns();
102+
const { handleEventPropagation, checkKeyDown } = useHelperFns();
108103
109104
const toggleButton = useTemplateRef('toggle-button');
110105
const containerRef = useTemplateRef('overlay-container');
111106
const gridWrapRef = useTemplateRef('grid-wrap');
112107
113108
const scrollable = ref(false);
114109
const selectionActiveRef = ref<HTMLElement | null>(null);
115-
const elementRefs = ref<Array<HTMLElement | null>[]>([]);
116110
const hoverValue = ref();
117111
const containerHeight = ref(0);
118112
119113
onBeforeUpdate(() => {
120114
selectionActiveRef.value = null;
121115
});
122116
123-
/**
124-
* On mounted hook, set the scroll position, if any to a selected value when opening overlay
125-
*/
126-
onMounted(() => {
127-
nextTick().then(() => setContainerHeightAndScroll());
128-
if (!props.noOverlayFocus) {
129-
focusGrid();
130-
}
131-
handleArrowNav(true);
117+
onMounted(async () => {
118+
await nextTick();
119+
setContainerHeightAndScroll();
120+
setState('arrowNavigationLevel', props.level ?? 1);
132121
});
133122
134-
onUnmounted(() => handleArrowNav(false));
135-
136-
const handleArrowNav = (value: boolean): void => {
137-
if (rootProps.arrowNavigation) {
138-
if (props.headerRefs?.length) {
139-
setMonthPicker(value);
140-
} else {
141-
setSelectionGrid(value);
142-
}
143-
}
144-
};
145-
146-
const focusGrid = (): void => {
147-
const elm = unrefElement(gridWrapRef);
148-
if (elm) {
149-
if (!textInput.value.enabled) {
150-
if (selectionActiveRef.value) {
151-
selectionActiveRef.value?.focus({ preventScroll: true });
152-
} else {
153-
elm.focus({ preventScroll: true });
154-
}
155-
}
156-
157-
scrollable.value = elm.clientHeight < elm.scrollHeight;
158-
}
159-
};
123+
onUnmounted(() => {
124+
setState('arrowNavigationLevel', (props.level ?? 1) - 1);
125+
});
160126
161127
// Dynamic class for the overlay
162128
const dpOverlayClass = computed(
@@ -202,7 +168,7 @@
202168
203169
const setContainerHeightAndScroll = (setScroll = true) => {
204170
nextTick().then(() => {
205-
const el = unrefElement(selectionActiveRef);
171+
const el = document.querySelector<HTMLElement>(`[data-dp-element-active="${props.level ?? 1}"]`);
206172
const parent = unrefElement(gridWrapRef);
207173
const btn = unrefElement(toggleButton);
208174
const container = unrefElement(containerRef);
@@ -245,72 +211,21 @@
245211
}
246212
};
247213
248-
const assignRef = (el: any, col: OverlayGridItem, rowInd: number, colInd: number): void => {
249-
if (el) {
250-
if (col.active || col.value === props.focusValue) {
251-
selectionActiveRef.value = el;
252-
}
253-
if (rootProps.arrowNavigation) {
254-
if (Array.isArray(elementRefs.value[rowInd])) {
255-
elementRefs.value[rowInd][colInd] = el;
256-
} else {
257-
elementRefs.value[rowInd] = [el];
258-
}
259-
buildMatrix();
260-
}
261-
}
262-
};
263-
264-
const buildMatrix = () => {
265-
const refs = props.headerRefs?.length
266-
? [props.headerRefs].concat(elementRefs.value)
267-
: elementRefs.value.concat([props.skipButtonRef ? [] : [toggleButton.value]]);
268-
269-
buildMultiLevelMatrix(convertType(refs), props.headerRefs?.length ? 'monthPicker' : 'selectionGrid');
270-
};
271-
272-
const handleArrowKey = (ev: KeyboardEvent) => {
273-
if (rootProps.arrowNavigation) return;
274-
checkStopPropagation(ev, config.value, true);
275-
};
276-
277214
const setHoverValue = (val: number) => {
278215
hoverValue.value = val;
279216
emit('hover-value', val);
280217
};
281218
282-
const onTab = () => {
283-
toggle();
284-
if (!props.isLast) {
285-
const actionRow = getElWithin(props.menuWrapRef ?? null, 'action-row');
286-
if (actionRow) {
287-
const focusable = findFocusableEl(actionRow);
288-
focusable?.focus();
289-
}
290-
}
291-
};
292-
293219
const onKeyDown = (ev: KeyboardEvent) => {
294220
switch (ev.key) {
295221
case EventKey.esc:
296222
return handleEsc(ev);
297-
case EventKey.arrowLeft:
298-
return handleArrowKey(ev);
299-
case EventKey.arrowRight:
300-
return handleArrowKey(ev);
301-
case EventKey.arrowUp:
302-
return handleArrowKey(ev);
303-
case EventKey.arrowDown:
304-
return handleArrowKey(ev);
305223
default:
306224
return;
307225
}
308226
};
309227
310228
const onBtnKeyDown = (ev: KeyboardEvent) => {
311229
if (ev.key === EventKey.enter) return toggle();
312-
if (ev.key === EventKey.tab) return onTab();
313230
};
314-
315-
defineExpose({ focusGrid });
316231
</script>

src/VueDatePicker/components/DatePicker/DatePicker.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@
107107
assignMonthAndYear,
108108
setStartTime,
109109
} = useDatePicker(props, emit, triggerCalendarTransition, updateFlowStep);
110-
111110
const slots = useSlots();
112111
const { setHoverDate, getDayClassData, clearHoverDate } = useCalendarClass();
113112
const {

src/VueDatePicker/components/DatePicker/DpCalendar.vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
:aria-label="ariaLabels?.day?.(dayVal)"
4646
:tabindex="!dayVal.current && rootProps.hideOffsetDates ? undefined : 0"
4747
:data-test-id="getCellId(dayVal.value)"
48+
:data-dp-element-active="!!dayVal.classData.dp__active_date ? 0 : undefined"
49+
data-dp-action-element="0"
4850
@click.prevent="onDateSelect($event, dayVal)"
4951
@touchend="onDateSelect($event, dayVal, false)"
5052
@keydown="checkKeyDown($event, () => $emit('select-date', dayVal))"
@@ -118,7 +120,7 @@
118120
import { unrefElement, useSwipe } from '@vueuse/core';
119121
import { getISOWeek, getWeek, set, type Day, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns';
120122
121-
import { useArrowNavigation, useHelperFns, useDateUtils, useContext, useFormatter } from '@/composables';
123+
import { useHelperFns, useDateUtils, useContext, useFormatter } from '@/composables';
122124
123125
import type { UnwrapRef } from 'vue';
124126
import type { CalendarDay, CalendarWeek, DynamicClass, Marker } from '@/types';
@@ -148,7 +150,6 @@
148150
rootProps,
149151
defaults: { transitions, config, ariaLabels, multiCalendars, weekNumbers, multiDates, ui },
150152
} = useContext();
151-
const { buildMultiLevelMatrix } = useArrowNavigation();
152153
const { isDateAfter, isDateEqual, resetDateTime, getCellId } = useDateUtils();
153154
const { checkKeyDown, checkStopPropagation, isTouchDevice } = useHelperFns();
154155
const { formatWeekDay } = useFormatter();
@@ -319,9 +320,6 @@
319320
dayRefs.value[weekInd] = [el];
320321
}
321322
}
322-
if (rootProps.arrowNavigation) {
323-
buildMultiLevelMatrix(dayRefs.value, 'calendar');
324-
}
325323
};
326324
327325
const onScroll = (ev: WheelEvent) => {

0 commit comments

Comments
 (0)