diff --git a/README.md b/README.md index e0698587..8b5e687a 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Radar.initialize('prj_test_pk_...', { /* options */ }); Add the following script in your `html` file ```html - + ``` Then initialize the Radar SDK @@ -73,8 +73,8 @@ To create a map, first initialize the Radar SDK with your publishable key. Then ```html - - + + @@ -98,8 +98,8 @@ To create an autocomplete input, first initialize the Radar SDK with your publis ```html - - + + @@ -130,8 +130,8 @@ To power [geofencing](https://radar.com/documentation/geofencing/overview) exper ```html - - + + diff --git a/package-lock.json b/package-lock.json index 6f2b7bf3..cd681c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "radar-sdk-js", - "version": "4.4.10", + "version": "4.5.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index df56c923..6062efa2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "radar-sdk-js", - "version": "4.4.10", + "version": "4.5.0-beta.1", "description": "Web Javascript SDK for Radar, location infrastructure for mobile and web apps.", "homepage": "https://radar.com", "type": "module", diff --git a/src/types.ts b/src/types.ts index 6491a86c..8bae28f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -578,6 +578,9 @@ export interface RadarAutocompleteUIOptions extends Omit { const fill = color.replace('#', '%23'); - const svg = ` + const svg = ` `.trim(); return `data:image/svg+xml;charset=utf-8,${svg}`; @@ -178,6 +183,16 @@ class AutocompleteUI { this.inputField.setAttribute('aria-haspopup', 'listbox'); this.inputField.setAttribute('aria-autocomplete', 'list'); this.inputField.setAttribute('aria-activedescendant', ''); + this.inputField.setAttribute('aria-owns', CLASSNAMES.RESULTS_LIST); + this.inputField.setAttribute('aria-describedby', 'autocomplete-instructions'); + + // screen reader instructions + const srInstructions = document.createElement('div'); + srInstructions.id = 'autocomplete-instructions'; + srInstructions.className = 'sr-only'; // screen reader only class + srInstructions.textContent = 'When results appear, use up and down arrow keys to navigate and Enter to select'; + this.wrapper.appendChild(srInstructions); + // setup event listeners this.inputField.addEventListener('input', this.handleInput.bind(this)); @@ -186,6 +201,18 @@ class AutocompleteUI { this.inputField.addEventListener('blur', this.close.bind(this), true); } + // mobile accessibility - touch support + this.resultsList.addEventListener('touchstart', (event) => { + const target = event.target as HTMLElement; + const li = target.closest('li'); + if (li) { + const index = Array.from(this.resultsList.children).indexOf(li); + if (index !== -1) { + this.select(index); + } + } + }); + Logger.debug('AutocompleteUI initialized with options', this.config); } @@ -273,15 +300,33 @@ class AutocompleteUI { } public displayResults(results: any[]) { - // Clear the previous results + // clear the previous results this.clearResultsList(); this.results = results; + // create status announcement for screen readers + const statusAnnouncement = document.createElement('div'); + statusAnnouncement.setAttribute('role', 'status'); + statusAnnouncement.className = 'sr-only'; + if (results.length > 0) { + statusAnnouncement.textContent = `${results.length} results available. Use arrow keys to navigate.`; + } else { + statusAnnouncement.textContent = 'No results found.'; + } + this.wrapper.appendChild(statusAnnouncement); + + // Remove the announcement after it's been read + setTimeout(() => { + statusAnnouncement.remove(); + }, 2000); + let marker: HTMLElement; if (this.config.showMarkers) { marker = document.createElement('img'); marker.classList.add(CLASSNAMES.RESULTS_MARKER); marker.setAttribute('src', getMarkerIcon(this.config.markerColor)); + marker.setAttribute('alt', 'Location marker'); // add alt text for screen readers + marker.setAttribute('aria-hidden', 'true'); // hide from screen readers when decorative } // Create and append list items for each result @@ -290,6 +335,9 @@ class AutocompleteUI { li.classList.add(CLASSNAMES.RESULTS_ITEM); li.setAttribute('role', 'option'); li.setAttribute('id', `${CLASSNAMES.RESULTS_ITEM}}-${index}`); + li.setAttribute('aria-selected', 'false'); // default state + + li.setAttribute('tabindex', '-1'); // make focusable but not in tab order // construct result with bolded label let listContent; @@ -314,6 +362,14 @@ class AutocompleteUI { this.select(index); }); + // add keyboard event for each item for better interaction + li.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.select(index); + } + }); + this.resultsList.appendChild(li); }); @@ -392,14 +448,19 @@ class AutocompleteUI { if (this.highlightedIndex > -1) { // clear class names on previously highlighted item resultItems[this.highlightedIndex].classList.remove(CLASSNAMES.SELECTED_ITEM); + resultItems[this.highlightedIndex].setAttribute('aria-selected', 'false'); } // add class name to newly highlighted item resultItems[index].classList.add(CLASSNAMES.SELECTED_ITEM); + resultItems[index].setAttribute('aria-selected', 'true'); // set aria active descendant this.inputField.setAttribute('aria-activedescendant', `${CLASSNAMES.RESULTS_ITEM}-${index}`); + // Make sure the selected item is visible (scroll into view if needed) + resultItems[index].scrollIntoView({ block: 'nearest' }); + this.highlightedIndex = index; } @@ -436,8 +497,11 @@ class AutocompleteUI { break; // Close + case 'Escape': case 'Esc': + event.preventDefault(); this.close(); + setTimeout(() => this.inputField.focus(), 0); // return focus to input break; } } @@ -458,11 +522,26 @@ class AutocompleteUI { } this.inputField.value = inputValue; + // announce selection to screen readers + const announcement = document.createElement('div'); + announcement.setAttribute('role', 'status'); + announcement.className = 'sr-only'; + announcement.textContent = `Selected: ${inputValue}`; + this.wrapper.appendChild(announcement); + + // remove announcement after it's been read + setTimeout(() => { + announcement.remove(); + }, 1000); + const onSelection = this.config.onSelection; if (onSelection) { onSelection(result); } + // Return focus to input after selection + this.inputField.focus(); + // clear results list this.close(); } diff --git a/src/version.ts b/src/version.ts index 6fa3ea42..0fce909d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export default '4.4.10'; \ No newline at end of file +export default '4.5.0-beta.1'; diff --git a/styles/radar.css b/styles/radar.css index 2e958872..1c5d25de 100644 --- a/styles/radar.css +++ b/styles/radar.css @@ -94,6 +94,8 @@ opacity: 1; border: 1px solid var(--radar-gray3); box-shadow: 0px 0px 4px #81BEFF; + outline: 2px solid #4d90fe; + outline-offset: 2px; } .radar-autocomplete-search-icon { @@ -163,6 +165,11 @@ background-color: var(--radar-gray1); } +.radar-autocomplete-results-item:focus { + outline: 2px solid #4d90fe; + outline-offset: -2px; +} + .radar-autocomplete-results-item[aria-selected="true"] { background-color: var(--radar-gray2); } @@ -206,6 +213,19 @@ padding: 8px 16px; } +/* screen reader only class for autocomplete */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .maplibregl-marker:hover { cursor: pointer; }