and a representing the content
1391 | // it means the required HTML structure is not met so the script will stop
1392 | if (!$summary || !$content) {
1393 | return
1394 | }
1395 |
1396 | // If the content doesn't have an ID, assign it one now
1397 | // which we'll need for the summary's aria-controls assignment
1398 | if (!$content.id) {
1399 | $content.id = 'details-content-' + generateUniqueID();
1400 | }
1401 |
1402 | // Add ARIA role="group" to details
1403 | $module.setAttribute('role', 'group');
1404 |
1405 | // Add role=button to summary
1406 | $summary.setAttribute('role', 'button');
1407 |
1408 | // Add aria-controls
1409 | $summary.setAttribute('aria-controls', $content.id);
1410 |
1411 | // Set tabIndex so the summary is keyboard accessible for non-native elements
1412 | //
1413 | // We have to use the camelcase `tabIndex` property as there is a bug in IE6/IE7 when we set the correct attribute lowercase:
1414 | // See http://web.archive.org/web/20170120194036/http://www.saliences.com/browserBugs/tabIndex.html for more information.
1415 | $summary.tabIndex = 0;
1416 |
1417 | // Detect initial open state
1418 | var openAttr = $module.getAttribute('open') !== null;
1419 | if (openAttr === true) {
1420 | $summary.setAttribute('aria-expanded', 'true');
1421 | $content.setAttribute('aria-hidden', 'false');
1422 | } else {
1423 | $summary.setAttribute('aria-expanded', 'false');
1424 | $content.setAttribute('aria-hidden', 'true');
1425 | $content.style.display = 'none';
1426 | }
1427 |
1428 | // Bind an event to handle summary elements
1429 | this.polyfillHandleInputs($summary, this.polyfillSetAttributes.bind(this));
1430 | };
1431 |
1432 | /**
1433 | * Define a statechange function that updates aria-expanded and style.display
1434 | * @param {object} summary element
1435 | */
1436 | Details.prototype.polyfillSetAttributes = function () {
1437 | var $module = this.$module;
1438 | var $summary = this.$summary;
1439 | var $content = this.$content;
1440 |
1441 | var expanded = $summary.getAttribute('aria-expanded') === 'true';
1442 | var hidden = $content.getAttribute('aria-hidden') === 'true';
1443 |
1444 | $summary.setAttribute('aria-expanded', (expanded ? 'false' : 'true'));
1445 | $content.setAttribute('aria-hidden', (hidden ? 'false' : 'true'));
1446 |
1447 | $content.style.display = (expanded ? 'none' : '');
1448 |
1449 | var hasOpenAttr = $module.getAttribute('open') !== null;
1450 | if (!hasOpenAttr) {
1451 | $module.setAttribute('open', 'open');
1452 | } else {
1453 | $module.removeAttribute('open');
1454 | }
1455 |
1456 | return true
1457 | };
1458 |
1459 | /**
1460 | * Handle cross-modal click events
1461 | * @param {object} node element
1462 | * @param {function} callback function
1463 | */
1464 | Details.prototype.polyfillHandleInputs = function (node, callback) {
1465 | node.addEventListener('keypress', function (event) {
1466 | var target = event.target;
1467 | // When the key gets pressed - check if it is enter or space
1468 | if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE$1) {
1469 | if (target.nodeName.toLowerCase() === 'summary') {
1470 | // Prevent space from scrolling the page
1471 | // and enter from submitting a form
1472 | event.preventDefault();
1473 | // Click to let the click event do all the necessary action
1474 | if (target.click) {
1475 | target.click();
1476 | } else {
1477 | // except Safari 5.1 and under don't support .click() here
1478 | callback(event);
1479 | }
1480 | }
1481 | }
1482 | });
1483 |
1484 | // Prevent keyup to prevent clicking twice in Firefox when using space key
1485 | node.addEventListener('keyup', function (event) {
1486 | var target = event.target;
1487 | if (event.keyCode === KEY_SPACE$1) {
1488 | if (target.nodeName.toLowerCase() === 'summary') {
1489 | event.preventDefault();
1490 | }
1491 | }
1492 | });
1493 |
1494 | node.addEventListener('click', callback);
1495 | };
1496 |
1497 | function CharacterCount ($module) {
1498 | this.$module = $module;
1499 | this.$textarea = $module.querySelector('.govuk-js-character-count');
1500 | if (this.$textarea) {
1501 | this.$countMessage = $module.querySelector('[id="' + this.$textarea.id + '-info"]');
1502 | }
1503 | }
1504 |
1505 | CharacterCount.prototype.defaults = {
1506 | characterCountAttribute: 'data-maxlength',
1507 | wordCountAttribute: 'data-maxwords'
1508 | };
1509 |
1510 | // Initialize component
1511 | CharacterCount.prototype.init = function () {
1512 | // Check for module
1513 | var $module = this.$module;
1514 | var $textarea = this.$textarea;
1515 | var $countMessage = this.$countMessage;
1516 |
1517 | if (!$textarea || !$countMessage) {
1518 | return
1519 | }
1520 |
1521 | // We move count message right after the field
1522 | // Kept for backwards compatibility
1523 | $textarea.insertAdjacentElement('afterend', $countMessage);
1524 |
1525 | // Read options set using dataset ('data-' values)
1526 | this.options = this.getDataset($module);
1527 |
1528 | // Determine the limit attribute (characters or words)
1529 | var countAttribute = this.defaults.characterCountAttribute;
1530 | if (this.options.maxwords) {
1531 | countAttribute = this.defaults.wordCountAttribute;
1532 | }
1533 |
1534 | // Save the element limit
1535 | this.maxLength = $module.getAttribute(countAttribute);
1536 |
1537 | // Check for limit
1538 | if (!this.maxLength) {
1539 | return
1540 | }
1541 |
1542 | // Remove hard limit if set
1543 | $module.removeAttribute('maxlength');
1544 |
1545 | // When the page is restored after navigating 'back' in some browsers the
1546 | // state of the character count is not restored until *after* the DOMContentLoaded
1547 | // event is fired, so we need to sync after the pageshow event in browsers
1548 | // that support it.
1549 | if ('onpageshow' in window) {
1550 | window.addEventListener('pageshow', this.sync.bind(this));
1551 | } else {
1552 | window.addEventListener('DOMContentLoaded', this.sync.bind(this));
1553 | }
1554 |
1555 | this.sync();
1556 | };
1557 |
1558 | CharacterCount.prototype.sync = function () {
1559 | this.bindChangeEvents();
1560 | this.updateCountMessage();
1561 | };
1562 |
1563 | // Read data attributes
1564 | CharacterCount.prototype.getDataset = function (element) {
1565 | var dataset = {};
1566 | var attributes = element.attributes;
1567 | if (attributes) {
1568 | for (var i = 0; i < attributes.length; i++) {
1569 | var attribute = attributes[i];
1570 | var match = attribute.name.match(/^data-(.+)/);
1571 | if (match) {
1572 | dataset[match[1]] = attribute.value;
1573 | }
1574 | }
1575 | }
1576 | return dataset
1577 | };
1578 |
1579 | // Counts characters or words in text
1580 | CharacterCount.prototype.count = function (text) {
1581 | var length;
1582 | if (this.options.maxwords) {
1583 | var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
1584 | length = tokens.length;
1585 | } else {
1586 | length = text.length;
1587 | }
1588 | return length
1589 | };
1590 |
1591 | // Bind input propertychange to the elements and update based on the change
1592 | CharacterCount.prototype.bindChangeEvents = function () {
1593 | var $textarea = this.$textarea;
1594 | $textarea.addEventListener('keyup', this.checkIfValueChanged.bind(this));
1595 |
1596 | // Bind focus/blur events to start/stop polling
1597 | $textarea.addEventListener('focus', this.handleFocus.bind(this));
1598 | $textarea.addEventListener('blur', this.handleBlur.bind(this));
1599 | };
1600 |
1601 | // Speech recognition software such as Dragon NaturallySpeaking will modify the
1602 | // fields by directly changing its `value`. These changes don't trigger events
1603 | // in JavaScript, so we need to poll to handle when and if they occur.
1604 | CharacterCount.prototype.checkIfValueChanged = function () {
1605 | if (!this.$textarea.oldValue) this.$textarea.oldValue = '';
1606 | if (this.$textarea.value !== this.$textarea.oldValue) {
1607 | this.$textarea.oldValue = this.$textarea.value;
1608 | this.updateCountMessage();
1609 | }
1610 | };
1611 |
1612 | // Update message box
1613 | CharacterCount.prototype.updateCountMessage = function () {
1614 | var countElement = this.$textarea;
1615 | var options = this.options;
1616 | var countMessage = this.$countMessage;
1617 |
1618 | // Determine the remaining number of characters/words
1619 | var currentLength = this.count(countElement.value);
1620 | var maxLength = this.maxLength;
1621 | var remainingNumber = maxLength - currentLength;
1622 |
1623 | // Set threshold if presented in options
1624 | var thresholdPercent = options.threshold ? options.threshold : 0;
1625 | var thresholdValue = maxLength * thresholdPercent / 100;
1626 | if (thresholdValue > currentLength) {
1627 | countMessage.classList.add('govuk-character-count__message--disabled');
1628 | // Ensure threshold is hidden for users of assistive technologies
1629 | countMessage.setAttribute('aria-hidden', true);
1630 | } else {
1631 | countMessage.classList.remove('govuk-character-count__message--disabled');
1632 | // Ensure threshold is visible for users of assistive technologies
1633 | countMessage.removeAttribute('aria-hidden');
1634 | }
1635 |
1636 | // Update styles
1637 | if (remainingNumber < 0) {
1638 | countElement.classList.add('govuk-textarea--error');
1639 | countMessage.classList.remove('govuk-hint');
1640 | countMessage.classList.add('govuk-error-message');
1641 | } else {
1642 | countElement.classList.remove('govuk-textarea--error');
1643 | countMessage.classList.remove('govuk-error-message');
1644 | countMessage.classList.add('govuk-hint');
1645 | }
1646 |
1647 | // Update message
1648 | var charVerb = 'remaining';
1649 | var charNoun = 'character';
1650 | var displayNumber = remainingNumber;
1651 | if (options.maxwords) {
1652 | charNoun = 'word';
1653 | }
1654 | charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's');
1655 |
1656 | charVerb = (remainingNumber < 0) ? 'too many' : 'remaining';
1657 | displayNumber = Math.abs(remainingNumber);
1658 |
1659 | countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb;
1660 | };
1661 |
1662 | CharacterCount.prototype.handleFocus = function () {
1663 | // Check if value changed on focus
1664 | this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000);
1665 | };
1666 |
1667 | CharacterCount.prototype.handleBlur = function () {
1668 | // Cancel value checking on blur
1669 | clearInterval(this.valueChecker);
1670 | };
1671 |
1672 | function Checkboxes ($module) {
1673 | this.$module = $module;
1674 | this.$inputs = $module.querySelectorAll('input[type="checkbox"]');
1675 | }
1676 |
1677 | /**
1678 | * Initialise Checkboxes
1679 | *
1680 | * Checkboxes can be associated with a 'conditionally revealed' content block –
1681 | * for example, a checkbox for 'Phone' could reveal an additional form field for
1682 | * the user to enter their phone number.
1683 | *
1684 | * These associations are made using a `data-aria-controls` attribute, which is
1685 | * promoted to an aria-controls attribute during initialisation.
1686 | *
1687 | * We also need to restore the state of any conditional reveals on the page (for
1688 | * example if the user has navigated back), and set up event handlers to keep
1689 | * the reveal in sync with the checkbox state.
1690 | */
1691 | Checkboxes.prototype.init = function () {
1692 | var $module = this.$module;
1693 | var $inputs = this.$inputs;
1694 |
1695 | nodeListForEach($inputs, function ($input) {
1696 | var target = $input.getAttribute('data-aria-controls');
1697 |
1698 | // Skip checkboxes without data-aria-controls attributes, or where the
1699 | // target element does not exist.
1700 | if (!target || !$module.querySelector('#' + target)) {
1701 | return
1702 | }
1703 |
1704 | // Promote the data-aria-controls attribute to a aria-controls attribute
1705 | // so that the relationship is exposed in the AOM
1706 | $input.setAttribute('aria-controls', target);
1707 | $input.removeAttribute('data-aria-controls');
1708 | });
1709 |
1710 | // When the page is restored after navigating 'back' in some browsers the
1711 | // state of form controls is not restored until *after* the DOMContentLoaded
1712 | // event is fired, so we need to sync after the pageshow event in browsers
1713 | // that support it.
1714 | if ('onpageshow' in window) {
1715 | window.addEventListener('pageshow', this.syncAllConditionalReveals.bind(this));
1716 | } else {
1717 | window.addEventListener('DOMContentLoaded', this.syncAllConditionalReveals.bind(this));
1718 | }
1719 |
1720 | // Although we've set up handlers to sync state on the pageshow or
1721 | // DOMContentLoaded event, init could be called after those events have fired,
1722 | // for example if they are added to the page dynamically, so sync now too.
1723 | this.syncAllConditionalReveals();
1724 |
1725 | $module.addEventListener('click', this.handleClick.bind(this));
1726 | };
1727 |
1728 | /**
1729 | * Sync the conditional reveal states for all inputs in this $module.
1730 | */
1731 | Checkboxes.prototype.syncAllConditionalReveals = function () {
1732 | nodeListForEach(this.$inputs, this.syncConditionalRevealWithInputState.bind(this));
1733 | };
1734 |
1735 | /**
1736 | * Sync conditional reveal with the input state
1737 | *
1738 | * Synchronise the visibility of the conditional reveal, and its accessible
1739 | * state, with the input's checked state.
1740 | *
1741 | * @param {HTMLInputElement} $input Checkbox input
1742 | */
1743 | Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) {
1744 | var $target = this.$module.querySelector('#' + $input.getAttribute('aria-controls'));
1745 |
1746 | if ($target && $target.classList.contains('govuk-checkboxes__conditional')) {
1747 | var inputIsChecked = $input.checked;
1748 |
1749 | $input.setAttribute('aria-expanded', inputIsChecked);
1750 | $target.classList.toggle('govuk-checkboxes__conditional--hidden', !inputIsChecked);
1751 | }
1752 | };
1753 |
1754 | /**
1755 | * Uncheck other checkboxes
1756 | *
1757 | * Find any other checkbox inputs with the same name value, and uncheck them.
1758 | * This is useful for when a “None of these" checkbox is checked.
1759 | */
1760 | Checkboxes.prototype.unCheckAllInputsExcept = function ($input) {
1761 | var allInputsWithSameName = document.querySelectorAll('input[type="checkbox"][name="' + $input.name + '"]');
1762 |
1763 | nodeListForEach(allInputsWithSameName, function ($inputWithSameName) {
1764 | var hasSameFormOwner = ($input.form === $inputWithSameName.form);
1765 | if (hasSameFormOwner && $inputWithSameName !== $input) {
1766 | $inputWithSameName.checked = false;
1767 | }
1768 | });
1769 |
1770 | this.syncAllConditionalReveals();
1771 | };
1772 |
1773 | /**
1774 | * Uncheck exclusive inputs
1775 | *
1776 | * Find any checkbox inputs with the same name value and the 'exclusive' behaviour,
1777 | * and uncheck them. This helps prevent someone checking both a regular checkbox and a
1778 | * "None of these" checkbox in the same fieldset.
1779 | */
1780 | Checkboxes.prototype.unCheckExclusiveInputs = function ($input) {
1781 | var allInputsWithSameNameAndExclusiveBehaviour = document.querySelectorAll(
1782 | 'input[data-behaviour="exclusive"][type="checkbox"][name="' + $input.name + '"]'
1783 | );
1784 |
1785 | nodeListForEach(allInputsWithSameNameAndExclusiveBehaviour, function ($exclusiveInput) {
1786 | var hasSameFormOwner = ($input.form === $exclusiveInput.form);
1787 | if (hasSameFormOwner) {
1788 | $exclusiveInput.checked = false;
1789 | }
1790 | });
1791 |
1792 | this.syncAllConditionalReveals();
1793 | };
1794 |
1795 | /**
1796 | * Click event handler
1797 | *
1798 | * Handle a click within the $module – if the click occurred on a checkbox, sync
1799 | * the state of any associated conditional reveal with the checkbox state.
1800 | *
1801 | * @param {MouseEvent} event Click event
1802 | */
1803 | Checkboxes.prototype.handleClick = function (event) {
1804 | var $target = event.target;
1805 |
1806 | // Ignore clicks on things that aren't checkbox inputs
1807 | if ($target.type !== 'checkbox') {
1808 | return
1809 | }
1810 |
1811 | // If the checkbox conditionally-reveals some content, sync the state
1812 | var hasAriaControls = $target.getAttribute('aria-controls');
1813 | if (hasAriaControls) {
1814 | this.syncConditionalRevealWithInputState($target);
1815 | }
1816 |
1817 | // No further behaviour needed for unchecking
1818 | if (!$target.checked) {
1819 | return
1820 | }
1821 |
1822 | // Handle 'exclusive' checkbox behaviour (ie "None of these")
1823 | var hasBehaviourExclusive = ($target.getAttribute('data-behaviour') === 'exclusive');
1824 | if (hasBehaviourExclusive) {
1825 | this.unCheckAllInputsExcept($target);
1826 | } else {
1827 | this.unCheckExclusiveInputs($target);
1828 | }
1829 | };
1830 |
1831 | (function(undefined) {
1832 |
1833 | // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
1834 | var detect = (
1835 | 'document' in this && "matches" in document.documentElement
1836 | );
1837 |
1838 | if (detect) return
1839 |
1840 | // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
1841 | Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
1842 | var element = this;
1843 | var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
1844 | var index = 0;
1845 |
1846 | while (elements[index] && elements[index] !== element) {
1847 | ++index;
1848 | }
1849 |
1850 | return !!elements[index];
1851 | };
1852 |
1853 | }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1854 |
1855 | (function(undefined) {
1856 |
1857 | // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
1858 | var detect = (
1859 | 'document' in this && "closest" in document.documentElement
1860 | );
1861 |
1862 | if (detect) return
1863 |
1864 | // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
1865 | Element.prototype.closest = function closest(selector) {
1866 | var node = this;
1867 |
1868 | while (node) {
1869 | if (node.matches(selector)) return node;
1870 | else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
1871 | }
1872 |
1873 | return null;
1874 | };
1875 |
1876 | }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1877 |
1878 | function ErrorSummary ($module) {
1879 | this.$module = $module;
1880 | }
1881 |
1882 | ErrorSummary.prototype.init = function () {
1883 | var $module = this.$module;
1884 | if (!$module) {
1885 | return
1886 | }
1887 | $module.focus();
1888 |
1889 | $module.addEventListener('click', this.handleClick.bind(this));
1890 | };
1891 |
1892 | /**
1893 | * Click event handler
1894 | *
1895 | * @param {MouseEvent} event - Click event
1896 | */
1897 | ErrorSummary.prototype.handleClick = function (event) {
1898 | var target = event.target;
1899 | if (this.focusTarget(target)) {
1900 | event.preventDefault();
1901 | }
1902 | };
1903 |
1904 | /**
1905 | * Focus the target element
1906 | *
1907 | * By default, the browser will scroll the target into view. Because our labels
1908 | * or legends appear above the input, this means the user will be presented with
1909 | * an input without any context, as the label or legend will be off the top of
1910 | * the screen.
1911 | *
1912 | * Manually handling the click event, scrolling the question into view and then
1913 | * focussing the element solves this.
1914 | *
1915 | * This also results in the label and/or legend being announced correctly in
1916 | * NVDA (as tested in 2018.3.2) - without this only the field type is announced
1917 | * (e.g. "Edit, has autocomplete").
1918 | *
1919 | * @param {HTMLElement} $target - Event target
1920 | * @returns {boolean} True if the target was able to be focussed
1921 | */
1922 | ErrorSummary.prototype.focusTarget = function ($target) {
1923 | // If the element that was clicked was not a link, return early
1924 | if ($target.tagName !== 'A' || $target.href === false) {
1925 | return false
1926 | }
1927 |
1928 | var inputId = this.getFragmentFromUrl($target.href);
1929 | var $input = document.getElementById(inputId);
1930 | if (!$input) {
1931 | return false
1932 | }
1933 |
1934 | var $legendOrLabel = this.getAssociatedLegendOrLabel($input);
1935 | if (!$legendOrLabel) {
1936 | return false
1937 | }
1938 |
1939 | // Scroll the legend or label into view *before* calling focus on the input to
1940 | // avoid extra scrolling in browsers that don't support `preventScroll` (which
1941 | // at time of writing is most of them...)
1942 | $legendOrLabel.scrollIntoView();
1943 | $input.focus({ preventScroll: true });
1944 |
1945 | return true
1946 | };
1947 |
1948 | /**
1949 | * Get fragment from URL
1950 | *
1951 | * Extract the fragment (everything after the hash) from a URL, but not including
1952 | * the hash.
1953 | *
1954 | * @param {string} url - URL
1955 | * @returns {string} Fragment from URL, without the hash
1956 | */
1957 | ErrorSummary.prototype.getFragmentFromUrl = function (url) {
1958 | if (url.indexOf('#') === -1) {
1959 | return false
1960 | }
1961 |
1962 | return url.split('#').pop()
1963 | };
1964 |
1965 | /**
1966 | * Get associated legend or label
1967 | *
1968 | * Returns the first element that exists from this list:
1969 | *
1970 | * - The `` associated with the closest `
` ancestor, as long
1971 | * as the top of it is no more than half a viewport height away from the
1972 | * bottom of the input
1973 | * - The first `` that is associated with the input using for="inputId"
1974 | * - The closest parent ``
1975 | *
1976 | * @param {HTMLElement} $input - The input
1977 | * @returns {HTMLElement} Associated legend or label, or null if no associated
1978 | * legend or label can be found
1979 | */
1980 | ErrorSummary.prototype.getAssociatedLegendOrLabel = function ($input) {
1981 | var $fieldset = $input.closest('fieldset');
1982 |
1983 | if ($fieldset) {
1984 | var legends = $fieldset.getElementsByTagName('legend');
1985 |
1986 | if (legends.length) {
1987 | var $candidateLegend = legends[0];
1988 |
1989 | // If the input type is radio or checkbox, always use the legend if there
1990 | // is one.
1991 | if ($input.type === 'checkbox' || $input.type === 'radio') {
1992 | return $candidateLegend
1993 | }
1994 |
1995 | // For other input types, only scroll to the fieldset’s legend (instead of
1996 | // the label associated with the input) if the input would end up in the
1997 | // top half of the screen.
1998 | //
1999 | // This should avoid situations where the input either ends up off the
2000 | // screen, or obscured by a software keyboard.
2001 | var legendTop = $candidateLegend.getBoundingClientRect().top;
2002 | var inputRect = $input.getBoundingClientRect();
2003 |
2004 | // If the browser doesn't support Element.getBoundingClientRect().height
2005 | // or window.innerHeight (like IE8), bail and just link to the label.
2006 | if (inputRect.height && window.innerHeight) {
2007 | var inputBottom = inputRect.top + inputRect.height;
2008 |
2009 | if (inputBottom - legendTop < window.innerHeight / 2) {
2010 | return $candidateLegend
2011 | }
2012 | }
2013 | }
2014 | }
2015 |
2016 | return document.querySelector("label[for='" + $input.getAttribute('id') + "']") ||
2017 | $input.closest('label')
2018 | };
2019 |
2020 | function NotificationBanner ($module) {
2021 | this.$module = $module;
2022 | }
2023 |
2024 | /**
2025 | * Initialise the component
2026 | */
2027 | NotificationBanner.prototype.init = function () {
2028 | var $module = this.$module;
2029 | // Check for module
2030 | if (!$module) {
2031 | return
2032 | }
2033 |
2034 | this.setFocus();
2035 | };
2036 |
2037 | /**
2038 | * Focus the element
2039 | *
2040 | * If `role="alert"` is set, focus the element to help some assistive technologies
2041 | * prioritise announcing it.
2042 | *
2043 | * You can turn off the auto-focus functionality by setting `data-disable-auto-focus="true"` in the
2044 | * component HTML. You might wish to do this based on user research findings, or to avoid a clash
2045 | * with another element which should be focused when the page loads.
2046 | */
2047 | NotificationBanner.prototype.setFocus = function () {
2048 | var $module = this.$module;
2049 |
2050 | if ($module.getAttribute('data-disable-auto-focus') === 'true') {
2051 | return
2052 | }
2053 |
2054 | if ($module.getAttribute('role') !== 'alert') {
2055 | return
2056 | }
2057 |
2058 | // Set tabindex to -1 to make the element focusable with JavaScript.
2059 | // Remove the tabindex on blur as the component doesn't need to be focusable after the page has
2060 | // loaded.
2061 | if (!$module.getAttribute('tabindex')) {
2062 | $module.setAttribute('tabindex', '-1');
2063 |
2064 | $module.addEventListener('blur', function () {
2065 | $module.removeAttribute('tabindex');
2066 | });
2067 | }
2068 |
2069 | $module.focus();
2070 | };
2071 |
2072 | function Header ($module) {
2073 | this.$module = $module;
2074 | this.$menuButton = $module && $module.querySelector('.govuk-js-header-toggle');
2075 | this.$menu = this.$menuButton && $module.querySelector(
2076 | '#' + this.$menuButton.getAttribute('aria-controls')
2077 | );
2078 | }
2079 |
2080 | /**
2081 | * Initialise header
2082 | *
2083 | * Check for the presence of the header, menu and menu button – if any are
2084 | * missing then there's nothing to do so return early.
2085 | */
2086 | Header.prototype.init = function () {
2087 | if (!this.$module || !this.$menuButton || !this.$menu) {
2088 | return
2089 | }
2090 |
2091 | this.syncState(this.$menu.classList.contains('govuk-header__navigation--open'));
2092 | this.$menuButton.addEventListener('click', this.handleMenuButtonClick.bind(this));
2093 | };
2094 |
2095 | /**
2096 | * Sync menu state
2097 | *
2098 | * Sync the menu button class and the accessible state of the menu and the menu
2099 | * button with the visible state of the menu
2100 | *
2101 | * @param {boolean} isVisible Whether the menu is currently visible
2102 | */
2103 | Header.prototype.syncState = function (isVisible) {
2104 | this.$menuButton.classList.toggle('govuk-header__menu-button--open', isVisible);
2105 | this.$menuButton.setAttribute('aria-expanded', isVisible);
2106 | };
2107 |
2108 | /**
2109 | * Handle menu button click
2110 | *
2111 | * When the menu button is clicked, change the visibility of the menu and then
2112 | * sync the accessibility state and menu button state
2113 | */
2114 | Header.prototype.handleMenuButtonClick = function () {
2115 | var isVisible = this.$menu.classList.toggle('govuk-header__navigation--open');
2116 | this.syncState(isVisible);
2117 | };
2118 |
2119 | function Radios ($module) {
2120 | this.$module = $module;
2121 | this.$inputs = $module.querySelectorAll('input[type="radio"]');
2122 | }
2123 |
2124 | /**
2125 | * Initialise Radios
2126 | *
2127 | * Radios can be associated with a 'conditionally revealed' content block – for
2128 | * example, a radio for 'Phone' could reveal an additional form field for the
2129 | * user to enter their phone number.
2130 | *
2131 | * These associations are made using a `data-aria-controls` attribute, which is
2132 | * promoted to an aria-controls attribute during initialisation.
2133 | *
2134 | * We also need to restore the state of any conditional reveals on the page (for
2135 | * example if the user has navigated back), and set up event handlers to keep
2136 | * the reveal in sync with the radio state.
2137 | */
2138 | Radios.prototype.init = function () {
2139 | var $module = this.$module;
2140 | var $inputs = this.$inputs;
2141 |
2142 | nodeListForEach($inputs, function ($input) {
2143 | var target = $input.getAttribute('data-aria-controls');
2144 |
2145 | // Skip radios without data-aria-controls attributes, or where the
2146 | // target element does not exist.
2147 | if (!target || !$module.querySelector('#' + target)) {
2148 | return
2149 | }
2150 |
2151 | // Promote the data-aria-controls attribute to a aria-controls attribute
2152 | // so that the relationship is exposed in the AOM
2153 | $input.setAttribute('aria-controls', target);
2154 | $input.removeAttribute('data-aria-controls');
2155 | });
2156 |
2157 | // When the page is restored after navigating 'back' in some browsers the
2158 | // state of form controls is not restored until *after* the DOMContentLoaded
2159 | // event is fired, so we need to sync after the pageshow event in browsers
2160 | // that support it.
2161 | if ('onpageshow' in window) {
2162 | window.addEventListener('pageshow', this.syncAllConditionalReveals.bind(this));
2163 | } else {
2164 | window.addEventListener('DOMContentLoaded', this.syncAllConditionalReveals.bind(this));
2165 | }
2166 |
2167 | // Although we've set up handlers to sync state on the pageshow or
2168 | // DOMContentLoaded event, init could be called after those events have fired,
2169 | // for example if they are added to the page dynamically, so sync now too.
2170 | this.syncAllConditionalReveals();
2171 |
2172 | // Handle events
2173 | $module.addEventListener('click', this.handleClick.bind(this));
2174 | };
2175 |
2176 | /**
2177 | * Sync the conditional reveal states for all inputs in this $module.
2178 | */
2179 | Radios.prototype.syncAllConditionalReveals = function () {
2180 | nodeListForEach(this.$inputs, this.syncConditionalRevealWithInputState.bind(this));
2181 | };
2182 |
2183 | /**
2184 | * Sync conditional reveal with the input state
2185 | *
2186 | * Synchronise the visibility of the conditional reveal, and its accessible
2187 | * state, with the input's checked state.
2188 | *
2189 | * @param {HTMLInputElement} $input Radio input
2190 | */
2191 | Radios.prototype.syncConditionalRevealWithInputState = function ($input) {
2192 | var $target = document.querySelector('#' + $input.getAttribute('aria-controls'));
2193 |
2194 | if ($target && $target.classList.contains('govuk-radios__conditional')) {
2195 | var inputIsChecked = $input.checked;
2196 |
2197 | $input.setAttribute('aria-expanded', inputIsChecked);
2198 | $target.classList.toggle('govuk-radios__conditional--hidden', !inputIsChecked);
2199 | }
2200 | };
2201 |
2202 | /**
2203 | * Click event handler
2204 | *
2205 | * Handle a click within the $module – if the click occurred on a radio, sync
2206 | * the state of the conditional reveal for all radio buttons in the same form
2207 | * with the same name (because checking one radio could have un-checked a radio
2208 | * in another $module)
2209 | *
2210 | * @param {MouseEvent} event Click event
2211 | */
2212 | Radios.prototype.handleClick = function (event) {
2213 | var $clickedInput = event.target;
2214 |
2215 | // Ignore clicks on things that aren't radio buttons
2216 | if ($clickedInput.type !== 'radio') {
2217 | return
2218 | }
2219 |
2220 | // We only need to consider radios with conditional reveals, which will have
2221 | // aria-controls attributes.
2222 | var $allInputs = document.querySelectorAll('input[type="radio"][aria-controls]');
2223 |
2224 | nodeListForEach($allInputs, function ($input) {
2225 | var hasSameFormOwner = ($input.form === $clickedInput.form);
2226 | var hasSameName = ($input.name === $clickedInput.name);
2227 |
2228 | if (hasSameName && hasSameFormOwner) {
2229 | this.syncConditionalRevealWithInputState($input);
2230 | }
2231 | }.bind(this));
2232 | };
2233 |
2234 | (function(undefined) {
2235 |
2236 | // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-library/master/polyfills/Element/prototype/nextElementSibling/detect.js
2237 | var detect = (
2238 | 'document' in this && "nextElementSibling" in document.documentElement
2239 | );
2240 |
2241 | if (detect) return
2242 |
2243 | // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-library/master/polyfills/Element/prototype/nextElementSibling/polyfill.js
2244 | Object.defineProperty(Element.prototype, "nextElementSibling", {
2245 | get: function(){
2246 | var el = this.nextSibling;
2247 | while (el && el.nodeType !== 1) { el = el.nextSibling; }
2248 | return el;
2249 | }
2250 | });
2251 |
2252 | }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2253 |
2254 | (function(undefined) {
2255 |
2256 | // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-library/master/polyfills/Element/prototype/previousElementSibling/detect.js
2257 | var detect = (
2258 | 'document' in this && "previousElementSibling" in document.documentElement
2259 | );
2260 |
2261 | if (detect) return
2262 |
2263 | // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-library/master/polyfills/Element/prototype/previousElementSibling/polyfill.js
2264 | Object.defineProperty(Element.prototype, 'previousElementSibling', {
2265 | get: function(){
2266 | var el = this.previousSibling;
2267 | while (el && el.nodeType !== 1) { el = el.previousSibling; }
2268 | return el;
2269 | }
2270 | });
2271 |
2272 | }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2273 |
2274 | function Tabs ($module) {
2275 | this.$module = $module;
2276 | this.$tabs = $module.querySelectorAll('.govuk-tabs__tab');
2277 |
2278 | this.keys = { left: 37, right: 39, up: 38, down: 40 };
2279 | this.jsHiddenClass = 'govuk-tabs__panel--hidden';
2280 | }
2281 |
2282 | Tabs.prototype.init = function () {
2283 | if (typeof window.matchMedia === 'function') {
2284 | this.setupResponsiveChecks();
2285 | } else {
2286 | this.setup();
2287 | }
2288 | };
2289 |
2290 | Tabs.prototype.setupResponsiveChecks = function () {
2291 | this.mql = window.matchMedia('(min-width: 40.0625em)');
2292 | this.mql.addListener(this.checkMode.bind(this));
2293 | this.checkMode();
2294 | };
2295 |
2296 | Tabs.prototype.checkMode = function () {
2297 | if (this.mql.matches) {
2298 | this.setup();
2299 | } else {
2300 | this.teardown();
2301 | }
2302 | };
2303 |
2304 | Tabs.prototype.setup = function () {
2305 | var $module = this.$module;
2306 | var $tabs = this.$tabs;
2307 | var $tabList = $module.querySelector('.govuk-tabs__list');
2308 | var $tabListItems = $module.querySelectorAll('.govuk-tabs__list-item');
2309 |
2310 | if (!$tabs || !$tabList || !$tabListItems) {
2311 | return
2312 | }
2313 |
2314 | $tabList.setAttribute('role', 'tablist');
2315 |
2316 | nodeListForEach($tabListItems, function ($item) {
2317 | $item.setAttribute('role', 'presentation');
2318 | });
2319 |
2320 | nodeListForEach($tabs, function ($tab) {
2321 | // Set HTML attributes
2322 | this.setAttributes($tab);
2323 |
2324 | // Save bounded functions to use when removing event listeners during teardown
2325 | $tab.boundTabClick = this.onTabClick.bind(this);
2326 | $tab.boundTabKeydown = this.onTabKeydown.bind(this);
2327 |
2328 | // Handle events
2329 | $tab.addEventListener('click', $tab.boundTabClick, true);
2330 | $tab.addEventListener('keydown', $tab.boundTabKeydown, true);
2331 |
2332 | // Remove old active panels
2333 | this.hideTab($tab);
2334 | }.bind(this));
2335 |
2336 | // Show either the active tab according to the URL's hash or the first tab
2337 | var $activeTab = this.getTab(window.location.hash) || this.$tabs[0];
2338 | this.showTab($activeTab);
2339 |
2340 | // Handle hashchange events
2341 | $module.boundOnHashChange = this.onHashChange.bind(this);
2342 | window.addEventListener('hashchange', $module.boundOnHashChange, true);
2343 | };
2344 |
2345 | Tabs.prototype.teardown = function () {
2346 | var $module = this.$module;
2347 | var $tabs = this.$tabs;
2348 | var $tabList = $module.querySelector('.govuk-tabs__list');
2349 | var $tabListItems = $module.querySelectorAll('.govuk-tabs__list-item');
2350 |
2351 | if (!$tabs || !$tabList || !$tabListItems) {
2352 | return
2353 | }
2354 |
2355 | $tabList.removeAttribute('role');
2356 |
2357 | nodeListForEach($tabListItems, function ($item) {
2358 | $item.removeAttribute('role', 'presentation');
2359 | });
2360 |
2361 | nodeListForEach($tabs, function ($tab) {
2362 | // Remove events
2363 | $tab.removeEventListener('click', $tab.boundTabClick, true);
2364 | $tab.removeEventListener('keydown', $tab.boundTabKeydown, true);
2365 |
2366 | // Unset HTML attributes
2367 | this.unsetAttributes($tab);
2368 | }.bind(this));
2369 |
2370 | // Remove hashchange event handler
2371 | window.removeEventListener('hashchange', $module.boundOnHashChange, true);
2372 | };
2373 |
2374 | Tabs.prototype.onHashChange = function (e) {
2375 | var hash = window.location.hash;
2376 | var $tabWithHash = this.getTab(hash);
2377 | if (!$tabWithHash) {
2378 | return
2379 | }
2380 |
2381 | // Prevent changing the hash
2382 | if (this.changingHash) {
2383 | this.changingHash = false;
2384 | return
2385 | }
2386 |
2387 | // Show either the active tab according to the URL's hash or the first tab
2388 | var $previousTab = this.getCurrentTab();
2389 |
2390 | this.hideTab($previousTab);
2391 | this.showTab($tabWithHash);
2392 | $tabWithHash.focus();
2393 | };
2394 |
2395 | Tabs.prototype.hideTab = function ($tab) {
2396 | this.unhighlightTab($tab);
2397 | this.hidePanel($tab);
2398 | };
2399 |
2400 | Tabs.prototype.showTab = function ($tab) {
2401 | this.highlightTab($tab);
2402 | this.showPanel($tab);
2403 | };
2404 |
2405 | Tabs.prototype.getTab = function (hash) {
2406 | return this.$module.querySelector('.govuk-tabs__tab[href="' + hash + '"]')
2407 | };
2408 |
2409 | Tabs.prototype.setAttributes = function ($tab) {
2410 | // set tab attributes
2411 | var panelId = this.getHref($tab).slice(1);
2412 | $tab.setAttribute('id', 'tab_' + panelId);
2413 | $tab.setAttribute('role', 'tab');
2414 | $tab.setAttribute('aria-controls', panelId);
2415 | $tab.setAttribute('aria-selected', 'false');
2416 | $tab.setAttribute('tabindex', '-1');
2417 |
2418 | // set panel attributes
2419 | var $panel = this.getPanel($tab);
2420 | $panel.setAttribute('role', 'tabpanel');
2421 | $panel.setAttribute('aria-labelledby', $tab.id);
2422 | $panel.classList.add(this.jsHiddenClass);
2423 | };
2424 |
2425 | Tabs.prototype.unsetAttributes = function ($tab) {
2426 | // unset tab attributes
2427 | $tab.removeAttribute('id');
2428 | $tab.removeAttribute('role');
2429 | $tab.removeAttribute('aria-controls');
2430 | $tab.removeAttribute('aria-selected');
2431 | $tab.removeAttribute('tabindex');
2432 |
2433 | // unset panel attributes
2434 | var $panel = this.getPanel($tab);
2435 | $panel.removeAttribute('role');
2436 | $panel.removeAttribute('aria-labelledby');
2437 | $panel.classList.remove(this.jsHiddenClass);
2438 | };
2439 |
2440 | Tabs.prototype.onTabClick = function (e) {
2441 | if (!e.target.classList.contains('govuk-tabs__tab')) {
2442 | // Allow events on child DOM elements to bubble up to tab parent
2443 | return false
2444 | }
2445 | e.preventDefault();
2446 | var $newTab = e.target;
2447 | var $currentTab = this.getCurrentTab();
2448 | this.hideTab($currentTab);
2449 | this.showTab($newTab);
2450 | this.createHistoryEntry($newTab);
2451 | };
2452 |
2453 | Tabs.prototype.createHistoryEntry = function ($tab) {
2454 | var $panel = this.getPanel($tab);
2455 |
2456 | // Save and restore the id
2457 | // so the page doesn't jump when a user clicks a tab (which changes the hash)
2458 | var id = $panel.id;
2459 | $panel.id = '';
2460 | this.changingHash = true;
2461 | window.location.hash = this.getHref($tab).slice(1);
2462 | $panel.id = id;
2463 | };
2464 |
2465 | Tabs.prototype.onTabKeydown = function (e) {
2466 | switch (e.keyCode) {
2467 | case this.keys.left:
2468 | case this.keys.up:
2469 | this.activatePreviousTab();
2470 | e.preventDefault();
2471 | break
2472 | case this.keys.right:
2473 | case this.keys.down:
2474 | this.activateNextTab();
2475 | e.preventDefault();
2476 | break
2477 | }
2478 | };
2479 |
2480 | Tabs.prototype.activateNextTab = function () {
2481 | var currentTab = this.getCurrentTab();
2482 | var nextTabListItem = currentTab.parentNode.nextElementSibling;
2483 | if (nextTabListItem) {
2484 | var nextTab = nextTabListItem.querySelector('.govuk-tabs__tab');
2485 | }
2486 | if (nextTab) {
2487 | this.hideTab(currentTab);
2488 | this.showTab(nextTab);
2489 | nextTab.focus();
2490 | this.createHistoryEntry(nextTab);
2491 | }
2492 | };
2493 |
2494 | Tabs.prototype.activatePreviousTab = function () {
2495 | var currentTab = this.getCurrentTab();
2496 | var previousTabListItem = currentTab.parentNode.previousElementSibling;
2497 | if (previousTabListItem) {
2498 | var previousTab = previousTabListItem.querySelector('.govuk-tabs__tab');
2499 | }
2500 | if (previousTab) {
2501 | this.hideTab(currentTab);
2502 | this.showTab(previousTab);
2503 | previousTab.focus();
2504 | this.createHistoryEntry(previousTab);
2505 | }
2506 | };
2507 |
2508 | Tabs.prototype.getPanel = function ($tab) {
2509 | var $panel = this.$module.querySelector(this.getHref($tab));
2510 | return $panel
2511 | };
2512 |
2513 | Tabs.prototype.showPanel = function ($tab) {
2514 | var $panel = this.getPanel($tab);
2515 | $panel.classList.remove(this.jsHiddenClass);
2516 | };
2517 |
2518 | Tabs.prototype.hidePanel = function (tab) {
2519 | var $panel = this.getPanel(tab);
2520 | $panel.classList.add(this.jsHiddenClass);
2521 | };
2522 |
2523 | Tabs.prototype.unhighlightTab = function ($tab) {
2524 | $tab.setAttribute('aria-selected', 'false');
2525 | $tab.parentNode.classList.remove('govuk-tabs__list-item--selected');
2526 | $tab.setAttribute('tabindex', '-1');
2527 | };
2528 |
2529 | Tabs.prototype.highlightTab = function ($tab) {
2530 | $tab.setAttribute('aria-selected', 'true');
2531 | $tab.parentNode.classList.add('govuk-tabs__list-item--selected');
2532 | $tab.setAttribute('tabindex', '0');
2533 | };
2534 |
2535 | Tabs.prototype.getCurrentTab = function () {
2536 | return this.$module.querySelector('.govuk-tabs__list-item--selected .govuk-tabs__tab')
2537 | };
2538 |
2539 | // this is because IE doesn't always return the actual value but a relative full path
2540 | // should be a utility function most prob
2541 | // http://labs.thesedays.com/blog/2010/01/08/getting-the-href-value-with-jquery-in-ie/
2542 | Tabs.prototype.getHref = function ($tab) {
2543 | var href = $tab.getAttribute('href');
2544 | var hash = href.slice(href.indexOf('#'), href.length);
2545 | return hash
2546 | };
2547 |
2548 | function initAll (options) {
2549 | // Set the options to an empty object by default if no options are passed.
2550 | options = typeof options !== 'undefined' ? options : {};
2551 |
2552 | // Allow the user to initialise GOV.UK Frontend in only certain sections of the page
2553 | // Defaults to the entire document if nothing is set.
2554 | var scope = typeof options.scope !== 'undefined' ? options.scope : document;
2555 |
2556 | var $buttons = scope.querySelectorAll('[data-module="govuk-button"]');
2557 | nodeListForEach($buttons, function ($button) {
2558 | new Button($button).init();
2559 | });
2560 |
2561 | var $accordions = scope.querySelectorAll('[data-module="govuk-accordion"]');
2562 | nodeListForEach($accordions, function ($accordion) {
2563 | new Accordion($accordion).init();
2564 | });
2565 |
2566 | var $details = scope.querySelectorAll('[data-module="govuk-details"]');
2567 | nodeListForEach($details, function ($detail) {
2568 | new Details($detail).init();
2569 | });
2570 |
2571 | var $characterCounts = scope.querySelectorAll('[data-module="govuk-character-count"]');
2572 | nodeListForEach($characterCounts, function ($characterCount) {
2573 | new CharacterCount($characterCount).init();
2574 | });
2575 |
2576 | var $checkboxes = scope.querySelectorAll('[data-module="govuk-checkboxes"]');
2577 | nodeListForEach($checkboxes, function ($checkbox) {
2578 | new Checkboxes($checkbox).init();
2579 | });
2580 |
2581 | // Find first error summary module to enhance.
2582 | var $errorSummary = scope.querySelector('[data-module="govuk-error-summary"]');
2583 | new ErrorSummary($errorSummary).init();
2584 |
2585 | // Find first header module to enhance.
2586 | var $toggleButton = scope.querySelector('[data-module="govuk-header"]');
2587 | new Header($toggleButton).init();
2588 |
2589 | var $notificationBanners = scope.querySelectorAll('[data-module="govuk-notification-banner"]');
2590 | nodeListForEach($notificationBanners, function ($notificationBanner) {
2591 | new NotificationBanner($notificationBanner).init();
2592 | });
2593 |
2594 | var $radios = scope.querySelectorAll('[data-module="govuk-radios"]');
2595 | nodeListForEach($radios, function ($radio) {
2596 | new Radios($radio).init();
2597 | });
2598 |
2599 | var $tabs = scope.querySelectorAll('[data-module="govuk-tabs"]');
2600 | nodeListForEach($tabs, function ($tabs) {
2601 | new Tabs($tabs).init();
2602 | });
2603 | }
2604 |
2605 | exports.initAll = initAll;
2606 | exports.Accordion = Accordion;
2607 | exports.Button = Button;
2608 | exports.Details = Details;
2609 | exports.CharacterCount = CharacterCount;
2610 | exports.Checkboxes = Checkboxes;
2611 | exports.ErrorSummary = ErrorSummary;
2612 | exports.Header = Header;
2613 | exports.Radios = Radios;
2614 | exports.Tabs = Tabs;
2615 |
2616 | })));
2617 |
--------------------------------------------------------------------------------