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..ada4c7a --- /dev/null +++ b/autocomplete.css @@ -0,0 +1,78 @@ +.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; +} + +label, +input[type="text"], +.description { + display: none; +} + +.js input[type="text"] { + display: inline-block; + width: 140px; +} + +.js label { + display: inline-block; + width: 120px; + vertical-align: top; +} + +.js .description { + margin-left: 120px; +} + +.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 new file mode 100644 index 0000000..c894299 --- /dev/null +++ b/autocomplete.js @@ -0,0 +1,355 @@ +// 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.initFilter(); + this.initOptions(); + this.initAlerts(); + this.applyCheckedOptionToFilter(); + this.announceOptionsNumber(''); + this.attachEvents(); + } + + // 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 = '') { + $target.attr(this.adgDataAttributeName(name), value); + } + + removeAdgDataAttribute($target, name) { + $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'); + $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 = {}) { + 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'); + 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'); + this.$options.addClass(this.config.hiddenCssClass); + this.hideOptions(); + } + + initAlerts() { + this.$alertsContainer = $(``); + this.$optionsContainerLabel.after(this.$alertsContainer); + this.$filter.attr('aria-describedby', [this.$filter.attr('aria-describedby'), this.$alertsContainer.attr('id')].join(' ').trim()); + this.addAdgDataAttribute(this.$alertsContainer, 'alerts'); + } + + attachEvents() { + this.attachClickEventToFilter(); + this.attachChangeEventToFilter(); + this.attachEscapeKeyToFilter(); + this.attachEnterKeyToFilter(); + this.attachTabKeyToFilter(); + this.attachUpDownKeysToFilter(); + this.attachChangeEventToOptions(); + this.attachClickEventToOptions(); + } + + attachClickEventToFilter() { + this.$filter.click(() => { + if (this.$optionsContainer.is(':visible')) { + this.hideOptions(); + } else { + this.showOptions(); + } + }); + } + + attachEscapeKeyToFilter() { + this.$filter.keydown((e) => { + if (e.which === 27) { + if (this.$optionsContainer.is(':visible')) { + this.applyCheckedOptionToFilterAndResetOptions(); + e.preventDefault(); + } else if (this.$options.is(':checked')) { + this.$options.prop('checked', false); + this.applyCheckedOptionToFilterAndResetOptions(); + e.preventDefault(); + } + } + }); + } + + attachEnterKeyToFilter() { + this.$filter.keydown((e) => { + if (e.which === 13) { + if (this.$optionsContainer.is(':visible')) { + this.applyCheckedOptionToFilterAndResetOptions(); + e.preventDefault(); + } + } + }); + } + + attachTabKeyToFilter() { + this.$filter.keydown((e) => { + if (e.which === 9) { + if (this.$optionsContainer.is(':visible')) { + this.applyCheckedOptionToFilterAndResetOptions(); + } + } + }); + } + + attachUpDownKeysToFilter() { + 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(); + } + e.preventDefault(); // TODO: Test! + } + }); + } + + showOptions() { + this.show(this.$optionsContainer); + this.$filter.attr('aria-expanded', 'true'); + } + + hideOptions() { + this.hide(this.$optionsContainer); + 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]); + $upcomingOption.prop('checked', true).trigger('change'); + } + + attachChangeEventToOptions() { + this.$options.change((e) => { + this.applyCheckedOptionToFilter(); + this.$filter.focus().select(); + }); + } + + applyCheckedOptionToFilterAndResetOptions() { + this.applyCheckedOptionToFilter(); + this.hideOptions(); + this.filterOptions(); + } + + applyCheckedOptionToFilter() { + var $checkedOption, $checkedOptionLabel, $previouslyCheckedOptionLabel; + $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())); + this.addAdgDataAttribute($checkedOptionLabel, 'option-selected'); + } else { + this.$filter.val(''); + } + } + + attachClickEventToOptions() { + this.$options.click((e) => { + this.hideOptions(); + }); + } + + attachChangeEventToFilter() { + this.$filter.on('input propertychange paste', (e) => { + this.filterOptions(e.target.value); + 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++; + this.show($optionContainer); + } else { + this.hide($optionContainer); + } + }); + 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}` + }); + 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 = { + hiddenCssClass: 'adg-visually-hidden', + optionsContainer: 'fieldset', + optionsContainerLabel: 'legend', + alertsContainerId: 'alerts', + numberInTotalText: '[number] options in total', + numberFilteredText: '[number] of [total] options for [filter]' + }; + + return AdgAutocomplete; + +})(); + +$(document).ready(function() { + $('[data-adg-autosuggest]').each(function() { + 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..e0157a7 --- /dev/null +++ b/index.html @@ -0,0 +1,55 @@ + + + + + ++ +
+ + + ++ +
+ + + + +