From ce0a74a2f84acf79cf7d86cdc7995e0da7cb6618 Mon Sep 17 00:00:00 2001 From: Thomas Jaggi Date: Sun, 26 Aug 2018 18:00:07 +0200 Subject: [PATCH 1/4] Compiled code --- autocomplete.coffee | 305 ----------------------------------- autocomplete.css | 69 ++++++++ autocomplete.js | 376 ++++++++++++++++++++++++++++++++++++++++++++ autocomplete.sass | 62 -------- autocomplete.slim | 21 --- index.html | 48 ++++++ 6 files changed, 493 insertions(+), 388 deletions(-) delete mode 100644 autocomplete.coffee create mode 100644 autocomplete.css create mode 100644 autocomplete.js delete mode 100644 autocomplete.sass delete mode 100644 autocomplete.slim create mode 100644 index.html diff --git a/autocomplete.coffee b/autocomplete.coffee deleted file mode 100644 index 55efba9..0000000 --- a/autocomplete.coffee +++ /dev/null @@ -1,305 +0,0 @@ -# Tested in JAWS+IE/FF, NVDA+FF -# -# Known issues: -# - JAWS leaves the input when using up/down without entering something (I guess this is due to screen layout and can be considered intended) -# - Alert not perceivable upon opening options using up/down -# - Possible solution 1: always show options count when filter focused? -# - Possible solution 2: wait a moment before adding the alert? -# - VoiceOver/iOS announces radio buttons as disabled?! -# - iOS doesn't select all text when option was chosen -# -# In general: alerts seem to be most robust in all relevant browsers, but aren't polite. Maybe we'll find a better mechanism to serve browsers individually? -class AdgAutocomplete - uniqueIdCount = 1 - - config = - debugMessage: false - hiddenCssClass: 'adg-visually-hidden' - - optionsContainer: 'fieldset' - optionsContainerLabel: 'legend' - alertsContainerId: 'alerts' - numberInTotalText: '[number] options in total' - numberFilteredText: '[number] of [total] options for [filter]' - - constructor: (el, options = {}) -> - @$el = $(el) - - @config = config - for key, val of options - @config[key] = val - - jsonOptions = @$el.attr(@adgDataAttributeName()) - if jsonOptions - for key, val of jsonOptions - @config[key] = val - - @debugMessage 'start' - - @initFilter() - @initOptions() - @initAlerts() - - @applyCheckedOptionToFilter() - @announceOptionsNumber('') - - @attachEvents() - - # Prints the given message to the console if config['debug'] is true. - debugMessage: (message) -> - console.log "Adg debug: #{message}" if @config.debugMessage - - # Executes the given selector on @$el and returns the element. Makes sure exactly one element exists. - findOne: (selector) -> - result = @$el.find(selector) - switch result.length - when 0 then @throwMessageAndPrintObjectsToConsole "No object found for #{selector}!", result: result - when 1 then $(result.first()) - else @throwMessageAndPrintObjectsToConsole "More than one object found for #{selector}!", result: result - - name: -> - "adg-autosuggest" - - addAdgDataAttribute: ($target, name, value = '') -> - $target.attr(@adgDataAttributeName(name), value) - - removeAdgDataAttribute: ($target, name) -> - $target.removeAttr(@adgDataAttributeName(name)) - - adgDataAttributeName: (name = null) -> - result = "data-#{@name()}" - result += "-#{name}" if name - result - - uniqueId: (name) -> - [@name(), name, uniqueIdCount++].join '-' - - labelOfInput: ($inputs) -> - $inputs.map (i, input) => - $input = $(input) - - id = $input.attr('id') - $label = @findOne("label[for='#{id}']")[0] - - if $label.length == 0 - $label = $input.closest('label') - @throwMessageAndPrintObjectsToConsole "No corresponding input found for input!", input: $input if $label.length == 0 - - $label - - show: ($el) -> - $el.removeAttr('hidden') - $el.show() - - # TODO Would be cool to renounce CSS and solely use the hidden attribute. But jQuery's :visible doesn't seem to work with it!? - # @throwMessageAndPrintObjectsToConsole("Element is still hidden, although hidden attribute was removed! Make sure there's no CSS like display:none or visibility:hidden left on it!", element: $el) if $el.is(':hidden') - - hide: ($el) -> - $el.attr('hidden', '') - $el.hide() - - throwMessageAndPrintObjectsToConsole: (message, elements = {}) -> - console.log elements - throw message - - text: (text, options = {}) -> - text = @config["#{text}Text"] - - for key, value of options - text = text.replace "[#{key}]", value - - text - - initFilter: -> - @$filter = @findOne('input[type="text"]') - @addAdgDataAttribute(@$filter, 'filter') - @$filter.attr('autocomplete', 'off') - @$filter.attr('aria-expanded', 'false') - - initOptions: -> - @$optionsContainer = @findOne(@config.optionsContainer) - @addAdgDataAttribute(@$optionsContainer, 'options') - - @$optionsContainerLabel = @findOne(@config.optionsContainerLabel) - @$optionsContainerLabel.addClass(@config.hiddenCssClass) - - @$options = @$optionsContainer.find('input[type="radio"]') - @addAdgDataAttribute(@labelOfInput(@$options), 'option') - @$options.addClass(@config.hiddenCssClass) - - initAlerts: -> - @$alertsContainer = $("
") - @$optionsContainerLabel.after(@$alertsContainer) - @$filter.attr('aria-describedby', [@$filter.attr('aria-describedby'), @$alertsContainer.attr('id')].join(' ').trim()) - @addAdgDataAttribute(@$alertsContainer, 'alerts') - - attachEvents: -> - @attachClickEventToFilter() - @attachChangeEventToFilter() - - @attachEscapeKeyToFilter() - @attachEnterKeyToFilter() - @attachTabKeyToFilter() - @attachUpDownKeysToFilter() - - @attachChangeEventToOptions() - @attachClickEventToOptions() - - attachClickEventToFilter: -> - @$filter.click => - @debugMessage 'click filter' - if @$optionsContainer.is(':visible') - @hideOptions() - else - @showOptions() - - attachEscapeKeyToFilter: -> - @$filter.keydown (e) => - if e.which == 27 - if @$optionsContainer.is(':visible') - @applyCheckedOptionToFilterAndResetOptions() - e.preventDefault() - else if @$options.is(':checked') - @$options.prop('checked', false) - @applyCheckedOptionToFilterAndResetOptions() - e.preventDefault() - else # Needed for automatic testing only - $('body').append('

Esc passed on.

') - - attachEnterKeyToFilter: -> - @$filter.keydown (e) => - if e.which == 13 - @debugMessage 'enter' - if @$optionsContainer.is(':visible') - @applyCheckedOptionToFilterAndResetOptions() - e.preventDefault() - else # Needed for automatic testing only - $('body').append('

Enter passed on.

') - - attachTabKeyToFilter: -> - @$filter.keydown (e) => - if e.which == 9 - @debugMessage 'tab' - if @$optionsContainer.is(':visible') - @applyCheckedOptionToFilterAndResetOptions() - - attachUpDownKeysToFilter: -> - @$filter.keydown (e) => - if e.which == 38 || e.which == 40 - if @$optionsContainer.is(':visible') - if e.which == 38 - @moveSelection('up') - else - @moveSelection('down') - else - @showOptions() - - e.preventDefault() # TODO: Test! - - showOptions: -> - @debugMessage '(show options)' - @show(@$optionsContainer) - @$filter.attr('aria-expanded', 'true') - - hideOptions: -> - @debugMessage '(hide options)' - @hide(@$optionsContainer) - @$filter.attr('aria-expanded', 'false') - - moveSelection: (direction) -> - $visibleOptions = @$options.filter(':visible') - - maxIndex = $visibleOptions.length - 1 - currentIndex = $visibleOptions.index($visibleOptions.parent().find(':checked')) # TODO: is parent() good here?! - - upcomingIndex = if direction == 'up' - if currentIndex <= 0 - maxIndex - else - currentIndex - 1 - else - if currentIndex == maxIndex - 0 - else - currentIndex + 1 - - $upcomingOption = $($visibleOptions[upcomingIndex]) - $upcomingOption.prop('checked', true).trigger('change') - - attachChangeEventToOptions: -> - @$options.change (e) => - @debugMessage 'option change' - @applyCheckedOptionToFilter() - @$filter.focus().select() - - applyCheckedOptionToFilterAndResetOptions: -> - @applyCheckedOptionToFilter() - @hideOptions() - @filterOptions() - - applyCheckedOptionToFilter: -> - @debugMessage '(apply option to filter)' - - $previouslyCheckedOptionLabel = $("[#{@adgDataAttributeName('option-selected')}]") - if $previouslyCheckedOptionLabel.length == 1 - @removeAdgDataAttribute($previouslyCheckedOptionLabel, 'option-selected') - - $checkedOption = @$options.filter(':checked') - if $checkedOption.length == 1 - $checkedOptionLabel = @labelOfInput($checkedOption) - @$filter.val($.trim($checkedOptionLabel.text())) - @addAdgDataAttribute($checkedOptionLabel, 'option-selected') - else - @$filter.val('') - - attachClickEventToOptions: -> - @$options.click (e) => - @debugMessage 'click option' - @hideOptions() - - attachChangeEventToFilter: -> - @$filter.on 'input propertychange paste', (e) => - @debugMessage '(filter changed)' - @filterOptions(e.target.value) - @showOptions() - - filterOptions: (filter = '') -> - fuzzyFilter = @fuzzifyFilter(filter) - visibleNumber = 0 - - @$options.each (i, el) => - $option = $(el) - $optionContainer = $option.parent() - - regex = new RegExp(fuzzyFilter, 'i') - if regex.test($optionContainer.text()) - visibleNumber++ - @show($optionContainer) - else - @hide($optionContainer) - - @announceOptionsNumber(filter, visibleNumber) - - announceOptionsNumber: (filter = @$filter.val(), number = @$options.length) -> - @$alertsContainer.find('p').remove() # Remove previous alerts (I'm not sure whether this is the best solution, maybe hiding them would be more robust?) - - message = if filter == '' - @text('numberInTotal', number: number) - else - @text('numberFiltered', number: number, total: @$options.length, filter: "#{filter}") - - @$alertsContainer.append("

#{message}

") - - fuzzifyFilter: (filter) -> - i = 0 - fuzzifiedFilter = '' - while i < filter.length - escapedCharacter = filter.charAt(i).replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&") # See https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex - fuzzifiedFilter += "#{escapedCharacter}.*?" - i++ - - fuzzifiedFilter - -$(document).ready -> - $('[data-adg-autosuggest]').each -> - new AdgAutocomplete @ diff --git a/autocomplete.css b/autocomplete.css new file mode 100644 index 0000000..00e06fa --- /dev/null +++ b/autocomplete.css @@ -0,0 +1,69 @@ +.adg-visually-hidden { + position: absolute; + white-space: nowrap; + width: 1px; + height: 1px; + overflow: hidden; + border: 0; + padding: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + margin: -1px; +} + +[data-adg-autosuggest-options] { + position: absolute; + z-index: 1; + background-color: lightyellow; + border: 1px solid; + padding: 5px 0; +} + +[data-adg-autosuggest-option] { + display: block; +} + +[data-adg-autosuggest-option]:hover, +[data-adg-autosuggest-option-selected] { + cursor: pointer; + background-color: #000; + color: lightyellow; +} + +[data-adg-autosuggest-alerts] p { + margin: 0; +} +[data-adg-autosuggest-alerts] kbd::before { + content: "«"; +} +[data-adg-autosuggest-alerts] kbd::after { + content: "»"; +} + +.control { + margin: 6px 0; +} + +input[type="text"] { + width: 140px; +} + +label { + display: inline-block; + width: 120px; + vertical-align: top; +} + +.description { + margin-left: 120px; +} + +fieldset { + margin: -1px 0 0 120px; +} +fieldset .control { + margin: 0; +} +fieldset label { + min-width: 144px; +} diff --git a/autocomplete.js b/autocomplete.js new file mode 100644 index 0000000..98edd6a --- /dev/null +++ b/autocomplete.js @@ -0,0 +1,376 @@ +// Tested in JAWS+IE/FF, NVDA+FF + +// Known issues: +// - JAWS leaves the input when using up/down without entering something (I guess this is due to screen layout and can be considered intended) +// - Alert not perceivable upon opening options using up/down +// - Possible solution 1: always show options count when filter focused? +// - Possible solution 2: wait a moment before adding the alert? +// - VoiceOver/iOS announces radio buttons as disabled?! +// - iOS doesn't select all text when option was chosen + +// In general: alerts seem to be most robust in all relevant browsers, but aren't polite. Maybe we'll find a better mechanism to serve browsers individually? +var AdgAutocomplete; + +AdgAutocomplete = (function() { + var config, uniqueIdCount; + + class AdgAutocomplete { + constructor(el, options = {}) { + var jsonOptions, key, val; + this.$el = $(el); + this.config = config; + for (key in options) { + val = options[key]; + this.config[key] = val; + } + jsonOptions = this.$el.attr(this.adgDataAttributeName()); + if (jsonOptions) { + for (key in jsonOptions) { + val = jsonOptions[key]; + this.config[key] = val; + } + } + this.debugMessage('start'); + this.initFilter(); + this.initOptions(); + this.initAlerts(); + this.applyCheckedOptionToFilter(); + this.announceOptionsNumber(''); + this.attachEvents(); + } + + // Prints the given message to the console if config['debug'] is true. + debugMessage(message) { + if (this.config.debugMessage) { + return console.log(`Adg debug: ${message}`); + } + } + + // Executes the given selector on @$el and returns the element. Makes sure exactly one element exists. + findOne(selector) { + var result; + result = this.$el.find(selector); + switch (result.length) { + case 0: + return this.throwMessageAndPrintObjectsToConsole(`No object found for ${selector}!`, { + result: result + }); + case 1: + return $(result.first()); + default: + return this.throwMessageAndPrintObjectsToConsole(`More than one object found for ${selector}!`, { + result: result + }); + } + } + + name() { + return "adg-autosuggest"; + } + + addAdgDataAttribute($target, name, value = '') { + return $target.attr(this.adgDataAttributeName(name), value); + } + + removeAdgDataAttribute($target, name) { + return $target.removeAttr(this.adgDataAttributeName(name)); + } + + adgDataAttributeName(name = null) { + var result; + result = `data-${this.name()}`; + if (name) { + result += `-${name}`; + } + return result; + } + + uniqueId(name) { + return [this.name(), name, uniqueIdCount++].join('-'); + } + + labelOfInput($inputs) { + return $inputs.map((i, input) => { + var $input, $label, id; + $input = $(input); + id = $input.attr('id'); + $label = this.findOne(`label[for='${id}']`)[0]; + if ($label.length === 0) { + $label = $input.closest('label'); + if ($label.length === 0) { + this.throwMessageAndPrintObjectsToConsole("No corresponding input found for input!", { + input: $input + }); + } + } + return $label; + }); + } + + show($el) { + $el.removeAttr('hidden'); + return $el.show(); + } + + // TODO Would be cool to renounce CSS and solely use the hidden attribute. But jQuery's :visible doesn't seem to work with it!? + // @throwMessageAndPrintObjectsToConsole("Element is still hidden, although hidden attribute was removed! Make sure there's no CSS like display:none or visibility:hidden left on it!", element: $el) if $el.is(':hidden') + hide($el) { + $el.attr('hidden', ''); + return $el.hide(); + } + + throwMessageAndPrintObjectsToConsole(message, elements = {}) { + console.log(elements); + throw message; + } + + text(text, options = {}) { + var key, value; + text = this.config[`${text}Text`]; + for (key in options) { + value = options[key]; + text = text.replace(`[${key}]`, value); + } + return text; + } + + initFilter() { + this.$filter = this.findOne('input[type="text"]'); + this.addAdgDataAttribute(this.$filter, 'filter'); + this.$filter.attr('autocomplete', 'off'); + return this.$filter.attr('aria-expanded', 'false'); + } + + initOptions() { + this.$optionsContainer = this.findOne(this.config.optionsContainer); + this.addAdgDataAttribute(this.$optionsContainer, 'options'); + this.$optionsContainerLabel = this.findOne(this.config.optionsContainerLabel); + this.$optionsContainerLabel.addClass(this.config.hiddenCssClass); + this.$options = this.$optionsContainer.find('input[type="radio"]'); + this.addAdgDataAttribute(this.labelOfInput(this.$options), 'option'); + return this.$options.addClass(this.config.hiddenCssClass); + } + + initAlerts() { + this.$alertsContainer = $(`
`); + this.$optionsContainerLabel.after(this.$alertsContainer); + this.$filter.attr('aria-describedby', [this.$filter.attr('aria-describedby'), this.$alertsContainer.attr('id')].join(' ').trim()); + return this.addAdgDataAttribute(this.$alertsContainer, 'alerts'); + } + + attachEvents() { + this.attachClickEventToFilter(); + this.attachChangeEventToFilter(); + this.attachEscapeKeyToFilter(); + this.attachEnterKeyToFilter(); + this.attachTabKeyToFilter(); + this.attachUpDownKeysToFilter(); + this.attachChangeEventToOptions(); + return this.attachClickEventToOptions(); + } + + attachClickEventToFilter() { + return this.$filter.click(() => { + this.debugMessage('click filter'); + if (this.$optionsContainer.is(':visible')) { + return this.hideOptions(); + } else { + return this.showOptions(); + } + }); + } + + attachEscapeKeyToFilter() { + return this.$filter.keydown((e) => { + if (e.which === 27) { + if (this.$optionsContainer.is(':visible')) { + this.applyCheckedOptionToFilterAndResetOptions(); + return e.preventDefault(); + } else if (this.$options.is(':checked')) { + this.$options.prop('checked', false); + this.applyCheckedOptionToFilterAndResetOptions(); + return e.preventDefault(); // Needed for automatic testing only + } else { + return $('body').append('

Esc passed on.

'); + } + } + }); + } + + attachEnterKeyToFilter() { + return this.$filter.keydown((e) => { + if (e.which === 13) { + this.debugMessage('enter'); + if (this.$optionsContainer.is(':visible')) { + this.applyCheckedOptionToFilterAndResetOptions(); + return e.preventDefault(); // Needed for automatic testing only + } else { + return $('body').append('

Enter passed on.

'); + } + } + }); + } + + attachTabKeyToFilter() { + return this.$filter.keydown((e) => { + if (e.which === 9) { + this.debugMessage('tab'); + if (this.$optionsContainer.is(':visible')) { + return this.applyCheckedOptionToFilterAndResetOptions(); + } + } + }); + } + + attachUpDownKeysToFilter() { + return this.$filter.keydown((e) => { + if (e.which === 38 || e.which === 40) { + if (this.$optionsContainer.is(':visible')) { + if (e.which === 38) { + this.moveSelection('up'); + } else { + this.moveSelection('down'); + } + } else { + this.showOptions(); + } + return e.preventDefault(); // TODO: Test! + } + }); + } + + showOptions() { + this.debugMessage('(show options)'); + this.show(this.$optionsContainer); + return this.$filter.attr('aria-expanded', 'true'); + } + + hideOptions() { + this.debugMessage('(hide options)'); + this.hide(this.$optionsContainer); + return this.$filter.attr('aria-expanded', 'false'); + } + + moveSelection(direction) { + var $upcomingOption, $visibleOptions, currentIndex, maxIndex, upcomingIndex; + $visibleOptions = this.$options.filter(':visible'); + maxIndex = $visibleOptions.length - 1; + currentIndex = $visibleOptions.index($visibleOptions.parent().find(':checked')); // TODO: is parent() good here?! + upcomingIndex = direction === 'up' ? currentIndex <= 0 ? maxIndex : currentIndex - 1 : currentIndex === maxIndex ? 0 : currentIndex + 1; + $upcomingOption = $($visibleOptions[upcomingIndex]); + return $upcomingOption.prop('checked', true).trigger('change'); + } + + attachChangeEventToOptions() { + return this.$options.change((e) => { + this.debugMessage('option change'); + this.applyCheckedOptionToFilter(); + return this.$filter.focus().select(); + }); + } + + applyCheckedOptionToFilterAndResetOptions() { + this.applyCheckedOptionToFilter(); + this.hideOptions(); + return this.filterOptions(); + } + + applyCheckedOptionToFilter() { + var $checkedOption, $checkedOptionLabel, $previouslyCheckedOptionLabel; + this.debugMessage('(apply option to filter)'); + $previouslyCheckedOptionLabel = $(`[${this.adgDataAttributeName('option-selected')}]`); + if ($previouslyCheckedOptionLabel.length === 1) { + this.removeAdgDataAttribute($previouslyCheckedOptionLabel, 'option-selected'); + } + $checkedOption = this.$options.filter(':checked'); + if ($checkedOption.length === 1) { + $checkedOptionLabel = this.labelOfInput($checkedOption); + this.$filter.val($.trim($checkedOptionLabel.text())); + return this.addAdgDataAttribute($checkedOptionLabel, 'option-selected'); + } else { + return this.$filter.val(''); + } + } + + attachClickEventToOptions() { + return this.$options.click((e) => { + this.debugMessage('click option'); + return this.hideOptions(); + }); + } + + attachChangeEventToFilter() { + return this.$filter.on('input propertychange paste', (e) => { + this.debugMessage('(filter changed)'); + this.filterOptions(e.target.value); + return this.showOptions(); + }); + } + + filterOptions(filter = '') { + var fuzzyFilter, visibleNumber; + fuzzyFilter = this.fuzzifyFilter(filter); + visibleNumber = 0; + this.$options.each((i, el) => { + var $option, $optionContainer, regex; + $option = $(el); + $optionContainer = $option.parent(); + regex = new RegExp(fuzzyFilter, 'i'); + if (regex.test($optionContainer.text())) { + visibleNumber++; + return this.show($optionContainer); + } else { + return this.hide($optionContainer); + } + }); + return this.announceOptionsNumber(filter, visibleNumber); + } + + announceOptionsNumber(filter = this.$filter.val(), number = this.$options.length) { + var message; + this.$alertsContainer.find('p').remove(); // Remove previous alerts (I'm not sure whether this is the best solution, maybe hiding them would be more robust?) + message = filter === '' ? this.text('numberInTotal', { + number: number + }) : this.text('numberFiltered', { + number: number, + total: this.$options.length, + filter: `${filter}` + }); + return this.$alertsContainer.append(`

${message}

`); + } + + fuzzifyFilter(filter) { + var escapedCharacter, fuzzifiedFilter, i; + i = 0; + fuzzifiedFilter = ''; + while (i < filter.length) { + escapedCharacter = filter.charAt(i).replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); // See https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex + fuzzifiedFilter += `${escapedCharacter}.*?`; + i++; + } + return fuzzifiedFilter; + } + + }; + + uniqueIdCount = 1; + + config = { + debugMessage: false, + hiddenCssClass: 'adg-visually-hidden', + optionsContainer: 'fieldset', + optionsContainerLabel: 'legend', + alertsContainerId: 'alerts', + numberInTotalText: '[number] options in total', + numberFilteredText: '[number] of [total] options for [filter]' + }; + + return AdgAutocomplete; + +}).call(this); + +$(document).ready(function() { + return $('[data-adg-autosuggest]').each(function() { + return new AdgAutocomplete(this); + }); +}); diff --git a/autocomplete.sass b/autocomplete.sass deleted file mode 100644 index ee6e5e5..0000000 --- a/autocomplete.sass +++ /dev/null @@ -1,62 +0,0 @@ -.adg-visually-hidden - position: absolute - white-space: nowrap - width: 1px - height: 1px - overflow: hidden - border: 0 - padding: 0 - clip: rect(0 0 0 0) - clip-path: inset(50%) - margin: -1px - -[data-adg-autosuggest-options] - position: absolute - z-index: 1 - background-color: lightyellow - border: 1px solid - padding: 5px 0 - -[data-adg-autosuggest-option] - display: block - -[data-adg-autosuggest-option]:hover, -[data-adg-autosuggest-option-selected] - cursor: pointer - background-color: #000 - color: lightyellow - -[data-adg-autosuggest-alerts] - p - margin: 0 - - kbd - &::before - content: '«' - - &::after - content: '»' - -.control - margin: 6px 0 - -input[type="text"] - width: 140px - -label - display: inline-block - width: 120px - vertical-align: top - -.description - margin-left: 120px - -fieldset - margin: -1px 0 0 120px - - .control - margin: 0 - - label - min-width: 144px - // width: 100% diff --git a/autocomplete.slim b/autocomplete.slim deleted file mode 100644 index 5a3c298..0000000 --- a/autocomplete.slim +++ /dev/null @@ -1,21 +0,0 @@ -p: button Focusable element before - -form - div data-adg-autosuggest=true - .control - label for="favorite_hobby_filter" Favorite hobby - input#favorite_hobby_filter type="text" aria-describedby="favorite_hobby_filter_help" - - fieldset hidden=true - legend Favorite hobby suggestions - - - [:hiking, :dancing, :gardening, :meditation, :gaming].each do |hobby| - - id = "favorite_hobby_#{hobby}" - - .control - input id=id type="radio" name="hobby" - label for=id = hobby.capitalize - - #favorite_hobby_filter_help.description Provides auto-suggestions when entering text - -p: button Focusable element after diff --git a/index.html b/index.html new file mode 100644 index 0000000..209bbef --- /dev/null +++ b/index.html @@ -0,0 +1,48 @@ + + + + + + Accessible Autocomplete + + + +

+ +

+
+
+
+ + +
+ Provides auto-suggestions when entering text +
+
+
+
+

+ +

+ + + + + From 6ddd67f2803cfce585605a39b2540bc2286497f1 Mon Sep 17 00:00:00 2001 From: Thomas Jaggi Date: Sun, 26 Aug 2018 18:20:44 +0200 Subject: [PATCH 2/4] Optimized/fixed markup --- index.html | 54 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/index.html b/index.html index 209bbef..6a31d43 100644 --- a/index.html +++ b/index.html @@ -4,40 +4,46 @@ Accessible Autocomplete - +

+
-
-
- - -
- Provides auto-suggestions when entering text +
+ + + +
+ Provides auto-suggestions when entering text
+

From 898a006a9dedd5be491274d62854d9078125a2e7 Mon Sep 17 00:00:00 2001 From: Thomas Jaggi Date: Sun, 26 Aug 2018 18:31:32 +0200 Subject: [PATCH 3/4] Removed debugging helper, removed returns where not needed --- autocomplete.js | 100 +++++++++++++++++++----------------------------- 1 file changed, 39 insertions(+), 61 deletions(-) diff --git a/autocomplete.js b/autocomplete.js index 98edd6a..fe3b51f 100644 --- a/autocomplete.js +++ b/autocomplete.js @@ -30,7 +30,6 @@ AdgAutocomplete = (function() { this.config[key] = val; } } - this.debugMessage('start'); this.initFilter(); this.initOptions(); this.initAlerts(); @@ -39,13 +38,6 @@ AdgAutocomplete = (function() { this.attachEvents(); } - // Prints the given message to the console if config['debug'] is true. - debugMessage(message) { - if (this.config.debugMessage) { - return console.log(`Adg debug: ${message}`); - } - } - // Executes the given selector on @$el and returns the element. Makes sure exactly one element exists. findOne(selector) { var result; @@ -69,11 +61,11 @@ AdgAutocomplete = (function() { } addAdgDataAttribute($target, name, value = '') { - return $target.attr(this.adgDataAttributeName(name), value); + $target.attr(this.adgDataAttributeName(name), value); } removeAdgDataAttribute($target, name) { - return $target.removeAttr(this.adgDataAttributeName(name)); + $target.removeAttr(this.adgDataAttributeName(name)); } adgDataAttributeName(name = null) { @@ -109,14 +101,14 @@ AdgAutocomplete = (function() { show($el) { $el.removeAttr('hidden'); - return $el.show(); + $el.show(); } // TODO Would be cool to renounce CSS and solely use the hidden attribute. But jQuery's :visible doesn't seem to work with it!? // @throwMessageAndPrintObjectsToConsole("Element is still hidden, although hidden attribute was removed! Make sure there's no CSS like display:none or visibility:hidden left on it!", element: $el) if $el.is(':hidden') hide($el) { $el.attr('hidden', ''); - return $el.hide(); + $el.hide(); } throwMessageAndPrintObjectsToConsole(message, elements = {}) { @@ -138,7 +130,7 @@ AdgAutocomplete = (function() { this.$filter = this.findOne('input[type="text"]'); this.addAdgDataAttribute(this.$filter, 'filter'); this.$filter.attr('autocomplete', 'off'); - return this.$filter.attr('aria-expanded', 'false'); + this.$filter.attr('aria-expanded', 'false'); } initOptions() { @@ -148,14 +140,14 @@ AdgAutocomplete = (function() { this.$optionsContainerLabel.addClass(this.config.hiddenCssClass); this.$options = this.$optionsContainer.find('input[type="radio"]'); this.addAdgDataAttribute(this.labelOfInput(this.$options), 'option'); - return this.$options.addClass(this.config.hiddenCssClass); + this.$options.addClass(this.config.hiddenCssClass); } initAlerts() { this.$alertsContainer = $(`
`); this.$optionsContainerLabel.after(this.$alertsContainer); this.$filter.attr('aria-describedby', [this.$filter.attr('aria-describedby'), this.$alertsContainer.attr('id')].join(' ').trim()); - return this.addAdgDataAttribute(this.$alertsContainer, 'alerts'); + this.addAdgDataAttribute(this.$alertsContainer, 'alerts'); } attachEvents() { @@ -166,64 +158,57 @@ AdgAutocomplete = (function() { this.attachTabKeyToFilter(); this.attachUpDownKeysToFilter(); this.attachChangeEventToOptions(); - return this.attachClickEventToOptions(); + this.attachClickEventToOptions(); } attachClickEventToFilter() { - return this.$filter.click(() => { - this.debugMessage('click filter'); + this.$filter.click(() => { if (this.$optionsContainer.is(':visible')) { - return this.hideOptions(); + this.hideOptions(); } else { - return this.showOptions(); + this.showOptions(); } }); } attachEscapeKeyToFilter() { - return this.$filter.keydown((e) => { + this.$filter.keydown((e) => { if (e.which === 27) { if (this.$optionsContainer.is(':visible')) { this.applyCheckedOptionToFilterAndResetOptions(); - return e.preventDefault(); + e.preventDefault(); } else if (this.$options.is(':checked')) { this.$options.prop('checked', false); this.applyCheckedOptionToFilterAndResetOptions(); - return e.preventDefault(); // Needed for automatic testing only - } else { - return $('body').append('

Esc passed on.

'); + e.preventDefault(); } } }); } attachEnterKeyToFilter() { - return this.$filter.keydown((e) => { + this.$filter.keydown((e) => { if (e.which === 13) { - this.debugMessage('enter'); if (this.$optionsContainer.is(':visible')) { this.applyCheckedOptionToFilterAndResetOptions(); - return e.preventDefault(); // Needed for automatic testing only - } else { - return $('body').append('

Enter passed on.

'); + e.preventDefault(); } } }); } attachTabKeyToFilter() { - return this.$filter.keydown((e) => { + this.$filter.keydown((e) => { if (e.which === 9) { - this.debugMessage('tab'); if (this.$optionsContainer.is(':visible')) { - return this.applyCheckedOptionToFilterAndResetOptions(); + this.applyCheckedOptionToFilterAndResetOptions(); } } }); } attachUpDownKeysToFilter() { - return this.$filter.keydown((e) => { + this.$filter.keydown((e) => { if (e.which === 38 || e.which === 40) { if (this.$optionsContainer.is(':visible')) { if (e.which === 38) { @@ -234,21 +219,19 @@ AdgAutocomplete = (function() { } else { this.showOptions(); } - return e.preventDefault(); // TODO: Test! + e.preventDefault(); // TODO: Test! } }); } showOptions() { - this.debugMessage('(show options)'); this.show(this.$optionsContainer); - return this.$filter.attr('aria-expanded', 'true'); + this.$filter.attr('aria-expanded', 'true'); } hideOptions() { - this.debugMessage('(hide options)'); this.hide(this.$optionsContainer); - return this.$filter.attr('aria-expanded', 'false'); + this.$filter.attr('aria-expanded', 'false'); } moveSelection(direction) { @@ -258,26 +241,24 @@ AdgAutocomplete = (function() { currentIndex = $visibleOptions.index($visibleOptions.parent().find(':checked')); // TODO: is parent() good here?! upcomingIndex = direction === 'up' ? currentIndex <= 0 ? maxIndex : currentIndex - 1 : currentIndex === maxIndex ? 0 : currentIndex + 1; $upcomingOption = $($visibleOptions[upcomingIndex]); - return $upcomingOption.prop('checked', true).trigger('change'); + $upcomingOption.prop('checked', true).trigger('change'); } attachChangeEventToOptions() { - return this.$options.change((e) => { - this.debugMessage('option change'); + this.$options.change((e) => { this.applyCheckedOptionToFilter(); - return this.$filter.focus().select(); + this.$filter.focus().select(); }); } applyCheckedOptionToFilterAndResetOptions() { this.applyCheckedOptionToFilter(); this.hideOptions(); - return this.filterOptions(); + this.filterOptions(); } applyCheckedOptionToFilter() { var $checkedOption, $checkedOptionLabel, $previouslyCheckedOptionLabel; - this.debugMessage('(apply option to filter)'); $previouslyCheckedOptionLabel = $(`[${this.adgDataAttributeName('option-selected')}]`); if ($previouslyCheckedOptionLabel.length === 1) { this.removeAdgDataAttribute($previouslyCheckedOptionLabel, 'option-selected'); @@ -286,24 +267,22 @@ AdgAutocomplete = (function() { if ($checkedOption.length === 1) { $checkedOptionLabel = this.labelOfInput($checkedOption); this.$filter.val($.trim($checkedOptionLabel.text())); - return this.addAdgDataAttribute($checkedOptionLabel, 'option-selected'); + this.addAdgDataAttribute($checkedOptionLabel, 'option-selected'); } else { - return this.$filter.val(''); + this.$filter.val(''); } } attachClickEventToOptions() { - return this.$options.click((e) => { - this.debugMessage('click option'); - return this.hideOptions(); + this.$options.click((e) => { + this.hideOptions(); }); } attachChangeEventToFilter() { - return this.$filter.on('input propertychange paste', (e) => { - this.debugMessage('(filter changed)'); + this.$filter.on('input propertychange paste', (e) => { this.filterOptions(e.target.value); - return this.showOptions(); + this.showOptions(); }); } @@ -318,12 +297,12 @@ AdgAutocomplete = (function() { regex = new RegExp(fuzzyFilter, 'i'); if (regex.test($optionContainer.text())) { visibleNumber++; - return this.show($optionContainer); + this.show($optionContainer); } else { - return this.hide($optionContainer); + this.hide($optionContainer); } }); - return this.announceOptionsNumber(filter, visibleNumber); + this.announceOptionsNumber(filter, visibleNumber); } announceOptionsNumber(filter = this.$filter.val(), number = this.$options.length) { @@ -336,7 +315,7 @@ AdgAutocomplete = (function() { total: this.$options.length, filter: `${filter}` }); - return this.$alertsContainer.append(`

${message}

`); + this.$alertsContainer.append(`

${message}

`); } fuzzifyFilter(filter) { @@ -356,7 +335,6 @@ AdgAutocomplete = (function() { uniqueIdCount = 1; config = { - debugMessage: false, hiddenCssClass: 'adg-visually-hidden', optionsContainer: 'fieldset', optionsContainerLabel: 'legend', @@ -367,10 +345,10 @@ AdgAutocomplete = (function() { return AdgAutocomplete; -}).call(this); +})(); $(document).ready(function() { - return $('[data-adg-autosuggest]').each(function() { - return new AdgAutocomplete(this); + $('[data-adg-autosuggest]').each(function() { + new AdgAutocomplete(this); }); }); From 64396c79d48256a9ef0766afc3045a6e8c19dee2 Mon Sep 17 00:00:00 2001 From: Thomas Jaggi Date: Sun, 26 Aug 2018 18:58:23 +0200 Subject: [PATCH 4/4] Added non-script default --- autocomplete.css | 17 +++++++++++++---- autocomplete.js | 1 + index.html | 3 ++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/autocomplete.css b/autocomplete.css index 00e06fa..ada4c7a 100644 --- a/autocomplete.css +++ b/autocomplete.css @@ -44,26 +44,35 @@ margin: 6px 0; } -input[type="text"] { +label, +input[type="text"], +.description { + display: none; +} + +.js input[type="text"] { + display: inline-block; width: 140px; } -label { +.js label { display: inline-block; width: 120px; vertical-align: top; } -.description { +.js .description { margin-left: 120px; } -fieldset { +.js fieldset { margin: -1px 0 0 120px; } + fieldset .control { margin: 0; } fieldset label { + display: inline-block; min-width: 144px; } diff --git a/autocomplete.js b/autocomplete.js index fe3b51f..c894299 100644 --- a/autocomplete.js +++ b/autocomplete.js @@ -141,6 +141,7 @@ AdgAutocomplete = (function() { this.$options = this.$optionsContainer.find('input[type="radio"]'); this.addAdgDataAttribute(this.labelOfInput(this.$options), 'option'); this.$options.addClass(this.config.hiddenCssClass); + this.hideOptions(); } initAlerts() { diff --git a/index.html b/index.html index 6a31d43..e0157a7 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ Accessible Autocomplete + @@ -15,7 +16,7 @@
-