Skip to content

Commit 6f69747

Browse files
committed
feat(TimePicker): add configurable aria-label options and improve accessibility
- Add ariaSelectHoursLabel, ariaSelectMinutesLabel, ariaSelectSecondsLabel, and ariaSelectMeridiemLabel configuration options - Apply aria-labels to both roll and select variants for better screen reader support - Add proper ARIA roles and attributes (listbox, option, aria-selected, aria-label) - Improve keyboard navigation with Home/End key support and better focus management - Enhanced focus trap behavior for roll columns
1 parent 6a2aa61 commit 6f69747

File tree

4 files changed

+156
-77
lines changed

4 files changed

+156
-77
lines changed

docs/content/forms/time-picker.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ const timePickerList = timePickerElementList.map(timePickerEl => {
213213
{{< bs-table >}}
214214
| Name | Type | Default | Description |
215215
| --- | --- | --- | --- |
216+
| `ariaSelectHoursLabel` | string | `'Select hours'` | Accessible label for the hours selection element. |
217+
| `ariaSelectMeridiemLabel` | string | `'Select AM/PM'` | Accessible label for the AM/PM selection element. |
218+
| `ariaSelectMinutesLabel` | string | `'Select minutes'` | Accessible label for the minutes selection element. |
219+
| `ariaSelectSecondsLabel` | string | `'Select seconds'` | Accessible label for the seconds selection element. |
216220
| `cancelButton` | boolean, string | `'Cancel'` | Cancel button inner HTML |
217221
| `cancelButtonClasses` | array, string | `['btn', 'btn-sm', 'btn-ghost-primary']` | CSS class names that will be added to the cancel button |
218222
| `cleaner` | boolean | `true` | Enables selection cleaner element. |

js/src/time-picker.js

Lines changed: 134 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ const DATA_KEY = 'coreui.time-picker'
3232
const EVENT_KEY = `.${DATA_KEY}`
3333
const DATA_API_KEY = '.data-api'
3434

35+
const END_KEY = 'End'
3536
const ENTER_KEY = 'Enter'
3637
const ESCAPE_KEY = 'Escape'
38+
const HOME_KEY = 'Home'
3739
const SPACE_KEY = 'Space'
3840
const TAB_KEY = 'Tab'
3941
const ARROW_UP_KEY = 'ArrowUp'
@@ -80,11 +82,15 @@ const SELECTOR_DATA_TOGGLE =
8082
'[data-coreui-toggle="time-picker"]:not(.disabled):not(:disabled)'
8183
const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`
8284
const SELECTOR_ROLL_CELL = '.time-picker-roll-cell'
83-
const SELECTOR_ROLL_CELL_SELECTED = '.time-picker-roll-cell.selected'
85+
const SELECTOR_ROLL_CELL_FOCUSABLE = '.time-picker-roll-cell[tabindex="0"]'
8486
const SELECTOR_ROLL_COL = '.time-picker-roll-col'
8587
const SELECTOR_WAS_VALIDATED = 'form.was-validated'
8688

8789
const Default = {
90+
ariaSelectHoursLabel: 'Select hours',
91+
ariaSelectMeridiemLabel: 'Select AM/PM',
92+
ariaSelectMinutesLabel: 'Select minutes',
93+
ariaSelectSecondsLabel: 'Select seconds',
8894
cancelButton: 'Cancel',
8995
cancelButtonClasses: ['btn', 'btn-sm', 'btn-ghost-primary'],
9096
cleaner: true,
@@ -112,6 +118,10 @@ const Default = {
112118
}
113119

114120
const DefaultType = {
121+
ariaSelectHoursLabel: 'string',
122+
ariaSelectMeridiemLabel: 'string',
123+
ariaSelectMinutesLabel: 'string',
124+
ariaSelectSecondsLabel: 'string',
115125
cancelButton: '(boolean|string)',
116126
cancelButtonClasses: '(array|string)',
117127
cleaner: 'boolean',
@@ -291,6 +301,42 @@ class TimePicker extends BaseComponent {
291301
})
292302
}
293303

304+
_moveFocusToNextColumn(event) {
305+
if (!this._timePickerBody) {
306+
return
307+
}
308+
309+
const { target } = event
310+
const columnElement = target.parentElement
311+
312+
const columns = SelectorEngine.find(SELECTOR_ROLL_COL, this._timePickerBody)
313+
const currentColumnIndex = columns.indexOf(columnElement)
314+
315+
if (currentColumnIndex < columns.length - 1) {
316+
const firstFocusableCell = SelectorEngine.findOne(SELECTOR_ROLL_CELL_FOCUSABLE, columns[currentColumnIndex + 1])
317+
318+
firstFocusableCell.focus()
319+
}
320+
}
321+
322+
_moveFocusToPreviousColumn(event) {
323+
if (!this._timePickerBody) {
324+
return
325+
}
326+
327+
const { target } = event
328+
const columnElement = target.parentElement
329+
330+
const columns = SelectorEngine.find(SELECTOR_ROLL_COL, this._timePickerBody)
331+
const currentColumnIndex = columns.indexOf(columnElement)
332+
333+
if (currentColumnIndex > 0) {
334+
const firstFocusableCell = SelectorEngine.findOne(SELECTOR_ROLL_CELL_FOCUSABLE, columns[currentColumnIndex - 1])
335+
336+
firstFocusableCell.focus()
337+
}
338+
}
339+
294340
_addEventListeners() {
295341
EventHandler.on(this._indicatorElement, EVENT_CLICK, () => {
296342
if (!this._config.disabled) {
@@ -319,8 +365,10 @@ class TimePicker extends BaseComponent {
319365
})
320366

321367
if (this._config.variant === 'roll') {
322-
EventHandler.on(this._timePickerBody, EVENT_FOCUSOUT, SELECTOR_ROLL_COL, () => {
323-
this._setUpRolls(false)
368+
EventHandler.on(this._timePickerBody, EVENT_FOCUSOUT, SELECTOR_ROLL_COL, event => {
369+
if (!event.delegateTarget.contains(event.relatedTarget)) {
370+
this._setUpRolls(false)
371+
}
324372
})
325373

326374
EventHandler.on(this._timePickerBody, EVENT_KEYDOWN, SELECTOR_ROLL_CELL, event => {
@@ -333,38 +381,37 @@ class TimePicker extends BaseComponent {
333381
return
334382
}
335383

336-
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
384+
const nextElement = getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target))
385+
if (nextElement) {
386+
nextElement.focus()
387+
}
388+
389+
return
337390
}
338391

339-
if (event.key === ARROW_LEFT_KEY || event.key === ARROW_RIGHT_KEY) {
392+
if (event.key === HOME_KEY || event.key === END_KEY) {
340393
event.preventDefault()
341394
const { key, target } = event
342-
const columnElement = target.parentElement
343-
344-
if (this._timePickerBody) {
345-
const columns = SelectorEngine.find(SELECTOR_ROLL_COL, this._timePickerBody)
346-
const currentColumnIndex = columns.indexOf(columnElement)
347-
348-
let targetColumnIndex
349-
const isRtl = isRTL()
350-
const shouldGoLeft = (key === ARROW_LEFT_KEY && !isRtl) || (key === ARROW_RIGHT_KEY && isRtl)
351-
if (shouldGoLeft) {
352-
targetColumnIndex = currentColumnIndex > 0 ? currentColumnIndex - 1 : columns.length - 1
353-
} else {
354-
targetColumnIndex = currentColumnIndex < columns.length - 1 ? currentColumnIndex + 1 : 0
355-
}
356-
357-
const targetColumn = columns[targetColumnIndex]
358-
const selectedCell = SelectorEngine.findOne(SELECTOR_ROLL_CELL_SELECTED, targetColumn)
395+
const items = SelectorEngine.find(SELECTOR_ROLL_CELL, target.parentElement)
359396

360-
if (selectedCell) {
361-
selectedCell.focus()
362-
return
363-
}
397+
if (!items.length) {
398+
return
399+
}
364400

365-
const firstFocusableCell = SelectorEngine.findOne(SELECTOR_ROLL_CELL, targetColumn)
401+
const index = key === HOME_KEY ? 0 : items.length - 1
402+
items[index].focus()
403+
return
404+
}
366405

367-
firstFocusableCell.focus()
406+
if (event.key === ARROW_LEFT_KEY || event.key === ARROW_RIGHT_KEY) {
407+
event.preventDefault()
408+
const { key } = event
409+
const isRtl = isRTL()
410+
const shouldGoLeft = (key === ARROW_LEFT_KEY && !isRtl) || (key === ARROW_RIGHT_KEY && isRtl)
411+
if (shouldGoLeft) {
412+
this._moveFocusToPreviousColumn(event)
413+
} else {
414+
this._moveFocusToNextColumn(event)
368415
}
369416
}
370417
})
@@ -561,17 +608,19 @@ class TimePicker extends BaseComponent {
561608

562609
if (this._config.variant === 'roll') {
563610
timePickerBodyEl.classList.add(CLASS_NAME_ROLL)
611+
timePickerBodyEl.setAttribute('role', 'group')
564612
}
565613

566614
this._timePickerBody = timePickerBodyEl
567615

568616
return timePickerBodyEl
569617
}
570618

571-
_createTimePickerInlineSelect(className, options) {
619+
_createTimePickerInlineSelect(className, options, ariaLabel) {
572620
const selectEl = document.createElement('select')
573621
selectEl.classList.add(CLASS_NAME_INLINE_SELECT, className)
574622
selectEl.disabled = this._config.disabled
623+
selectEl.setAttribute('aria-label', ariaLabel)
575624
selectEl.addEventListener('change', event =>
576625
this._handleTimeChange(className, event.target.value)
577626
)
@@ -595,7 +644,8 @@ class TimePicker extends BaseComponent {
595644
this._timePickerBody.append(
596645
this._createTimePickerInlineSelect(
597646
'hours',
598-
this._localizedTimePartials.listOfHours
647+
this._localizedTimePartials.listOfHours,
648+
this._config.ariaSelectHoursLabel
599649
)
600650
)
601651

@@ -604,7 +654,8 @@ class TimePicker extends BaseComponent {
604654
timeSeparatorEl.cloneNode(true),
605655
this._createTimePickerInlineSelect(
606656
'minutes',
607-
this._localizedTimePartials.listOfMinutes
657+
this._localizedTimePartials.listOfMinutes,
658+
this._config.ariaSelectMinutesLabel
608659
)
609660
)
610661
}
@@ -614,7 +665,8 @@ class TimePicker extends BaseComponent {
614665
timeSeparatorEl,
615666
this._createTimePickerInlineSelect(
616667
'seconds',
617-
this._localizedTimePartials.listOfSeconds
668+
this._localizedTimePartials.listOfSeconds,
669+
this._config.ariaSelectSecondsLabel
618670
)
619671
)
620672
}
@@ -627,8 +679,7 @@ class TimePicker extends BaseComponent {
627679
{ value: 'am', label: 'AM' },
628680
{ value: 'pm', label: 'PM' }
629681
],
630-
'_selectAmPm',
631-
this._ampm
682+
this._config.ariaSelectMeridiemLabel
632683
)
633684
)
634685
}
@@ -638,15 +689,17 @@ class TimePicker extends BaseComponent {
638689
this._timePickerBody.append(
639690
this._createTimePickerRollCol(
640691
this._localizedTimePartials.listOfHours,
641-
'hours'
692+
'hours',
693+
this._config.ariaSelectHoursLabel
642694
)
643695
)
644696

645697
if (this._config.minutes) {
646698
this._timePickerBody.append(
647699
this._createTimePickerRollCol(
648700
this._localizedTimePartials.listOfMinutes,
649-
'minutes'
701+
'minutes',
702+
this._config.ariaSelectMinutesLabel
650703
)
651704
)
652705
}
@@ -655,7 +708,8 @@ class TimePicker extends BaseComponent {
655708
this._timePickerBody.append(
656709
this._createTimePickerRollCol(
657710
this._localizedTimePartials.listOfSeconds,
658-
'seconds'
711+
'seconds',
712+
this._config.ariaSelectSecondsLabel
659713
)
660714
)
661715
}
@@ -668,21 +722,27 @@ class TimePicker extends BaseComponent {
668722
{ value: 'pm', label: 'PM' }
669723
],
670724
'toggle',
671-
this._ampm
725+
this._config.ariaSelectMeridiemLabel
672726
)
673727
)
674728
}
675729
}
676730

677-
_createTimePickerRollCol(options, part) {
731+
_createTimePickerRollCol(options, part, ariaLabel) {
678732
const timePickerRollColEl = document.createElement('div')
679733
timePickerRollColEl.classList.add(CLASS_NAME_ROLL_COL)
734+
timePickerRollColEl.setAttribute('role', 'listbox')
735+
timePickerRollColEl.setAttribute('aria-label', ariaLabel)
680736

681-
for (const option of options) {
737+
for (const [index, option] of options.entries()) {
682738
const timePickerRollCellEl = document.createElement('div')
683739
timePickerRollCellEl.classList.add(CLASS_NAME_ROLL_CELL)
684-
timePickerRollCellEl.setAttribute('role', 'button')
685-
timePickerRollCellEl.tabIndex = 0
740+
741+
timePickerRollCellEl.setAttribute('role', 'option')
742+
timePickerRollCellEl.tabIndex = index === 0 ? 0 : -1
743+
timePickerRollCellEl.setAttribute('aria-label', option.label.toString())
744+
timePickerRollCellEl.setAttribute('aria-selected', 'false')
745+
686746
timePickerRollCellEl.innerHTML = option.label
687747
timePickerRollCellEl.addEventListener('click', () => {
688748
this._handleTimeChange(part, option.value)
@@ -691,6 +751,7 @@ class TimePicker extends BaseComponent {
691751
if (event.code === SPACE_KEY || event.key === ENTER_KEY) {
692752
event.preventDefault()
693753
this._handleTimeChange(part, option.value)
754+
this._moveFocusToNextColumn(event)
694755
}
695756
})
696757

@@ -747,29 +808,41 @@ class TimePicker extends BaseComponent {
747808
}
748809

749810
_setUpRolls(initial = false) {
750-
for (const part of Array.from(['hours', 'minutes', 'seconds', 'toggle'])) {
751-
for (const element of SelectorEngine.find(
752-
`[data-coreui-${part}]`,
753-
this._element
754-
)) {
755-
if (
756-
this._getPartOfTime(part) ===
757-
Manipulator.getDataAttribute(element, part)
758-
) {
759-
element.classList.add(CLASS_NAME_SELECTED)
760-
this._scrollTo(element.parentElement, element, initial)
761-
762-
for (const sibling of element.parentElement.children) {
763-
// eslint-disable-next-line max-depth
764-
if (sibling !== element) {
765-
sibling.classList.remove(CLASS_NAME_SELECTED)
766-
}
767-
}
768-
}
811+
const parts = ['hours', 'minutes', 'seconds', 'toggle']
812+
813+
for (const part of parts) {
814+
const partValue = this._getPartOfTime(part)
815+
if (partValue === null) {
816+
continue
817+
}
818+
819+
const elements = SelectorEngine.find(`[data-coreui-${part}]`, this._element)
820+
const selectedElement = elements.find(element =>
821+
partValue === Manipulator.getDataAttribute(element, part)
822+
)
823+
824+
if (selectedElement) {
825+
this._selectRollElement(selectedElement, initial)
769826
}
770827
}
771828
}
772829

830+
_selectRollElement(element, initial = false) {
831+
const { parentElement } = element
832+
833+
const currentSelected = SelectorEngine.findOne(SELECTOR_ROLL_CELL_FOCUSABLE, parentElement)
834+
if (currentSelected && currentSelected !== element) {
835+
currentSelected.classList.remove(CLASS_NAME_SELECTED)
836+
currentSelected.tabIndex = -1
837+
currentSelected.setAttribute('aria-selected', 'false')
838+
}
839+
840+
element.classList.add(CLASS_NAME_SELECTED)
841+
element.tabIndex = 0
842+
element.setAttribute('aria-selected', 'true')
843+
this._scrollTo(parentElement, element, initial)
844+
}
845+
773846
_setInputValue(date, input = this._input) {
774847
input.value = date instanceof Date ?
775848
date.toLocaleTimeString(this._config.locale, {

0 commit comments

Comments
 (0)