8000 feat(BPagination): add keyboard shortcuts by loicduong · Pull Request #2325 · bootstrap-vue-next/bootstrap-vue-next · GitHub
[go: up one dir, main page]

Skip to content

feat(BPagination): add keyboard shortcuts #2325

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

Merged
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
11 changes: 10 additions & 1 deletion apps/docs/src/docs/components/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,16 @@ recommended unless the content of the button textually conveys its purpose.

### Keyboard navigation support

<NotYetImplemented />
`<BPagination>` supports keyboard navigation out of the box, and follows the
[WAI-ARIA roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex)
pattern.

- Tabbing into the pagination component will autofocus the current active page button
- <kbd>Left</kbd> (or <kbd>Up</kbd>) and <kbd>Right</kbd> (or <kbd>Down</kbd>) arrow keys will focus
the previous and next buttons, respectively, in the page list
- <kbd>Enter</kbd> or <kbd>Space</kbd> keys will select (click) the currently focused page button
- Pressing <kbd>Tab</kbd> will move to the next control or link on the page, while pressing
<kbd>Shift</kbd>+<kbd>Tab</kbd> will move to the previous control or link on the page.

<ComponentReference :data="data" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import type {BFormSpinbuttonProps} from '../../types/ComponentProps'
import {eventOnOff} from '../../utils/event'
import {eventOnOff, stopEvent} from '../../utils/event'
import {
CODE_DOWN,
CODE_END,
Expand Down Expand Up @@ -295,11 +295,6 @@ const stepDown = (multiplier = 1) => {
stepValue(-1 * multiplier)
}

const stopEvent = (event: Readonly<Event>) => {
event.preventDefault()
event.stopImmediatePropagation()
}

onKeyStroke(
KEY_CODES,
(event) => {
Expand All @@ -308,7 +303,7 @@ onKeyStroke(
if (props.disabled || props.readonly || altKey || ctrlKey || metaKey) return

// https://w3c.github.io/aria-practices/#spinbutton
stopEvent(event)
stopEvent(event, {immediatePropagation: true})
if ($_keyIsDown) {
// Keypress is already in progress
return
Expand Down Expand Up @@ -357,7 +352,7 @@ onKeyStroke(

if (props.disabled || props.readonly || altKey || ctrlKey || metaKey) return

stopEvent(event)
stopEvent(event, {immediatePropagation: true})
resetTimers()
$_keyIsDown = false
emit('change', modelValue.value)
Expand Down Expand Up @@ -410,7 +405,7 @@ const onMouseup: EventListener = (event: Readonly<Event>) => {
}
}

stopEvent(event)
stopEvent(event, {immediatePropagation: true})
resetTimers()
setMouseup(false)
// Trigger the change event
Expand Down Expand Up @@ -477,7 +472,7 @@ const buttons = computed(() => {

const handler = (event: Readonly<Event>, stepper: (multiplier?: number) => void) => {
if (!props.disabled && !props.readonly) {
stopEvent(event)
stopEvent(event, {immediatePropagation: true})
setMouseup(true)
// Since we `preventDefault()`, we must manually focus the button
// Though it's likely captured from the element click focus
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
role="menubar"
:aria-disabled="props.disabled"
:aria-label="props.ariaLabel || undefined"
@keydown="handleKeyNav"
>
<template v-for="page in pages" :key="`page-${page.id}`">
<li v-bind="page.li">
<li v-bind="page.li" ref="pageElements">
<span
v-if="page.id === FIRST_ELLIPSIS || page.id === LAST_ELLIPSIS"
v-bind="ellipsisProps.span"
Expand Down Expand Up @@ -35,12 +36,15 @@

<script setup lang="ts">
import {BvEvent} from '../../utils'
import {computed, watch} from 'vue'
import {computed, nextTick, useTemplateRef, watch} from 'vue'
import type {BPaginationProps} from '../../types/ComponentProps'
import {useAlignment} from '../../composables/useAlignment'
import {useToNumber} from '@vueuse/core'
import {useDefaults} from '../../composables/useDefaults'
import type {ClassValue} from '../../types/AnyValuedAttributes'
import {CODE_DOWN, CODE_LEFT, CODE_RIGHT, CODE_UP} from '../../utils/constants'
import {stopEvent} from '../../utils/event'
import {getActiveElement} from '../../utils/dom'

// Threshold of limit size when we start/stop showing ellipsis
const ELLIPSIS_THRESHOLD = 3
Expand Down Expand Up @@ -100,6 +104,8 @@ const emit = defineEmits<{

const modelValue = defineModel<Exclude<BPaginationProps['modelValue'], undefined>>({default: 1})

const pageElements = useTemplateRef<HTMLLIElement[]>('pageElements')

const limitNumber = useToNumber(() => props.limit, {nanToZero: true, method: 'parseInt'})
const perPageNumber = useToNumber(() => props.perPage, {nanToZero: true, method: 'parseInt'})
const totalRowsNumber = useToNumber(() => props.totalRows, {nanToZero: true, method: 'parseInt'})
Expand Down Expand Up @@ -320,6 +326,77 @@ const pageClick = (event: Readonly<MouseEvent>, pageNumber: number) => {
// })
}

const isDisabled = (el: HTMLButtonElement) => {
const isElement = !!(el && el.nodeType === Node.ELEMENT_NODE)
const hasAttr = isElement ? el.hasAttribute('disabled') : null
const hasClass = isElement && el.classList ? el.classList.contains('disabled') : false

return !isElement || el.disabled || hasAttr || hasClass
}

const getButtons = () =>
pageElements.value
?.map((page) => page.children[0] as HTMLButtonElement)
.filter((btn) => {
if (btn.getAttribute('display') === 'none') {
return false
}

const bcr = btn.getBoundingClientRect()

return !!(bcr && bcr.height > 0 && bcr.width > 0)
}) ?? []

const focusFirst = () => {
nextTick(() => {
const btn = getButtons().find((el) => !isDisabled(el))
btn?.focus()
})
}

const focusPrev = () => {
nextTick(() => {
const buttons = getButtons()
const index = buttons.indexOf(getActiveElement() as HTMLButtonElement)

if (index > 0 && !isDisabled(buttons[index - 1])) {
buttons[index - 1]?.focus()
}
})
}

const focusLast = () => {
nextTick(() => {
const btn = getButtons()
.reverse()
.find((el) => !isDisabled(el))
btn?.focus()
})
}

const focusNext = () => {
nextTick(() => {
const buttons = getButtons()
const index = buttons.indexOf(getActiveElement() as HTMLButtonElement)

if (index < buttons.length - 1 && !isDisabled(buttons[index + 1])) {
buttons[index + 1]?.focus()
}
})
}

const handleKeyNav = (event: KeyboardEvent) => {
const {code, shiftKey} = event

if (code === CODE_LEFT || code === CODE_UP) {
stopEvent(event)
shiftKey ? focusFirst() : focusPrev()
} else if (code === CODE_RIGHT || code === CODE_DOWN) {
stopEvent(event)
shiftKey ? focusLast() : focusNext()
}
}

watch(modelValueNumber, (newValue) => {
const sanitizeCurrentPage = (value: number, numberOfPages: number) => {
const page = value || 1
Expand Down
4 changes: 2 additions & 2 deletions packages/bootstrap-vue-next/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export const CODE_END = 'End'
export const CODE_ENTER = 13
export const CODE_ESC = 27
export const CODE_HOME = 'Home'
export const CODE_LEFT = 37
export const CODE_LEFT = 'ArrowLeft'
export const CODE_PAGEDOWN = 'PageDown'
export const CODE_PAGEUP = 'PageUp'
export const CODE_RIGHT = 39
export const CODE_RIGHT = 'ArrowRight'
export const CODE_SPACE = 32
export const CODE_UP = 'ArrowUp'

Expand Down
11 changes: 6 additions & 5 deletions packages/bootstrap-vue-next/src/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type {Slot} from 'vue'

// Get the currently active HTML element
export const getActiveElement = (excludes: readonly HTMLElement[] = []): Element | null => {
const {activeElement} = document
return activeElement && !excludes?.some((el) => el === activeElement) ? activeElement : null
}

/**
* @deprecated only used in BFormGroup, which is not an SFC... Function could probably be replaced with pure Vue
*/
export const attemptFocus = (
el: Readonly<HTMLElement>,
options: Readonly<FocusOptions> = {}
): boolean => {
const getActiveElement = (excludes: readonly HTMLElement[] = []): Element | null => {
const {activeElement} = document
return activeElement && !excludes.some((el) => el === activeElement) ? activeElement : null
}

const isActiveElement = (el: Readonly<HTMLElement>): boolean => el === getActiveElement()

try {
Expand Down
16 changes: 16 additions & 0 deletions packages/bootstrap-vue-next/src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,19 @@ export const eventOnOff = (on: boolean, eventParams: Parameters<typeof eventOff>
const method = on ? eventOn : eventOff
method(...eventParams)
}

// Utility method to prevent the default event handling and propagation
export const stopEvent = (
event: Readonly<Event>,
{pr 597D eventDefault = true, propagation = false, immediatePropagation = false} = {}
) => {
if (preventDefault) {
event.preventDefault()
}
if (propagation) {
event.stopPropagation()
}
if (immediatePropagation) {
event.stopImmediatePropagation()
}
}
Loading
0