Skip to content

Commit

Permalink
Scroll to label or legend when linked from error summary
Browse files Browse the repository at this point in the history
By default, the browser will scroll the target into view. Because our labels or legends appear above the input, this means the user will be presented with an input without any context, as the label or legend will be off the top of the screen.

Manually handling the click event, focussing the element and scrolling the question into view solves this.
  • Loading branch information
36degrees committed Nov 5, 2018
1 parent f8b816f commit 852f017
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 4 deletions.
19 changes: 15 additions & 4 deletions app/views/examples/error-summary/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{% endblock %}

{% block content %}
<form action="/" method="post">
<form action="/" method="post" style="margin-bottom: 100vh;">

{{ govukErrorSummary({
"titleText": "There is a problem",
Expand Down Expand Up @@ -115,7 +115,8 @@
{{ govukDateInput({
fieldset: {
legend: {
text: 'Date Input'
text: 'Date Input',
classes: 'test-date-input-legend'
}
},
id: 'dateinput',
Expand Down Expand Up @@ -143,7 +144,8 @@
{{ govukDateInput({
fieldset: {
legend: {
text: 'Date Input'
text: 'Date Input',
classes: 'test-date-input2-legend'
}
},
id: 'dateinput2',
Expand Down Expand Up @@ -185,6 +187,9 @@
fieldset: {
legend: {
text: 'Radios'
},
attributes: {
id: 'test-radios'
}
},
name: "radios",
Expand All @@ -208,6 +213,9 @@
fieldset: {
legend: {
text: 'Checkboxes'
},
attributes: {
id: 'test-checkboxes'
}
},
name: "checkboxes",
Expand Down Expand Up @@ -275,7 +283,10 @@
"legend": {
"text": "Conditional?"
},
"classes": "govuk-radios--error"
"classes": "govuk-radios--error",
attributes: {
id: 'test-conditional-reveal'
}
},
"error": true,
"items": [
Expand Down
99 changes: 99 additions & 0 deletions src/components/error-summary/error-summary.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import '../../vendor/polyfills/Function/prototype/bind'
import '../../vendor/polyfills/Event' // addEventListener
import '../../vendor/polyfills/Element/prototype/closest'

function ErrorSummary ($module) {
this.$module = $module
Expand All @@ -12,6 +14,103 @@ ErrorSummary.prototype.init = function () {
window.addEventListener('load', function () {
$module.focus()
})

$module.addEventListener('click', this.handleClick.bind(this))
}

/**
* Click event handler
*
* @param {MouseEvent} event - Click event
*/
ErrorSummary.prototype.handleClick = function (event) {
var target = event.target
if (this.focusTarget(target)) {
event.preventDefault()
}
}

/**
* Focus the target element
*
* By default, the browser will scroll the target into view. Because our labels
* or legends appear above the input, this means the user will be presented with
* an input without any context, as the label or legend will be off the top of
* the screen.
*
* Manually handling the click event, focussing the element and scrolling the
* question into view solves this.
*
* @param {HTMLElement} $target - Event target
* @returns {boolean} True if the target was able to be focussed
*/
ErrorSummary.prototype.focusTarget = function ($target) {
// If the element that was clicked was not a link, return early
if ($target.tagName !== 'A' || $target.href === false) {
return false
}

var inputId = this.getFragmentFromUrl($target.href)
var $input = document.getElementById(inputId)
if (!$input) {
return false
}

var $legendOrLabel = this.getAssociatedLegendOrLabel($input)
if (!$legendOrLabel) {
return false
}

window.location.hash = inputId
$input.focus({ preventScroll: true })
$legendOrLabel.scrollIntoView()

return true
}

/**
* Get fragment from URL
*
* Extract the fragment (everything after the hash) from a URL, but not including
* the hash.
*
* @param {string} url - URL
* @returns {string} Fragment from URL, without the hash
*/
ErrorSummary.prototype.getFragmentFromUrl = function (url) {
if (url.indexOf('#') === -1) {
return false
}

return url.split('#').pop()
}

/**
* Get associated legend or label
*
* Returns the first element that exists from this list:
*
* - The `<legend>` associated with the closest `<fieldset>` ancestor
* - The first `<label>` that is associated with the input using for="inputId"
* - The closest parent `<label>`
*
* @param {HTMLElement} $input - The input
* @returns {HTMLElement} Associated legend or label, or null if no associated
* legend or label can be found
*/
ErrorSummary.prototype.getAssociatedLegendOrLabel = function ($input) {
var $fieldset = $input.closest('fieldset')

if ($fieldset) {
var legends = $fieldset.getElementsByTagName('legend')

if (legends.length) {
return legends[0]
}
}

return document.querySelector("label[for='" + $input.getAttribute('id') + "']") ||
$input.closest('label')
}

export default ErrorSummary
40 changes: 40 additions & 0 deletions src/components/error-summary/error-summary.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,44 @@ describe('Error Summary', () => {
const moduleName = await page.evaluate(() => document.activeElement.dataset.module)
expect(moduleName).toBe('error-summary')
})

let inputTypes = [
// [description, input id, selector for label or legend]
['an input', 'input', 'label[for="input"]'],
['a textarea', 'textarea', 'label[for="textarea"]'],
['a select', 'select', 'label[for="select"]'],
['a date input', 'dateinput-day', '.test-date-input-legend'],
['a specific field in a date input', 'dateinput2-year', '.test-date-input2-legend'],
['a file upload', 'file', 'label[for="file"]'],
['a group of radio buttons', 'radios', '#test-radios legend'],
['a group of checkboxes', 'checkboxes', '#test-checkboxes legend'],
['a single checkbox', 'single-checkbox', 'label[for="single-checkbox"]'],
['a conditionally revealed input', 'yes-input', '#test-conditional-reveal legend']
]

describe.each(inputTypes)('when linking to %s', async (_, inputId, legendOrLabelSelector) => {
beforeAll(async () => {
await page.goto(`${baseUrl}/examples/error-summary`, { waitUntil: 'load' })
await page.click(`.govuk-error-summary a[href="#${inputId}"]`)
})

it('focuses the target input', async () => {
const activeElement = await page.evaluate(() => document.activeElement.id)
expect(activeElement).toBe(inputId)
})

it('scrolls the label or legend to the top of the screen', async () => {
const labelOrLegendOffsetFromTop = await page.evaluate((selector) => {
let $elem = document.querySelector(selector)
return $elem ? $elem.getBoundingClientRect().top : null
}, legendOrLabelSelector)

expect(labelOrLegendOffsetFromTop).toEqual(0)
})

it('updates the hash in the URL', async () => {
const hash = await page.evaluate(() => window.location.hash)
expect(hash).toBe(`#${inputId}`)
})
})
})
24 changes: 24 additions & 0 deletions src/vendor/polyfills/Element/prototype/closest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import './matches'

(function(undefined) {

// Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
var detect = (
'document' in this && "closest" in document.documentElement
)

if (detect) return

// Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
Element.prototype.closest = function closest(selector) {
var node = this;

while (node) {
if (node.matches(selector)) return node;
else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
}

return null;
};

}).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
23 changes: 23 additions & 0 deletions src/vendor/polyfills/Element/prototype/matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
(function(undefined) {

// Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
var detect = (
'document' in this && "matches" in document.documentElement
)

if (detect) return

// Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
var element = this;
var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
var index = 0;

while (elements[index] && elements[index] !== element) {
++index;
}

return !!elements[index];
};

}).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});

0 comments on commit 852f017

Please sign in to comment.