Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,31 @@ scenario: |
{% from "govuk/components/details/macro.njk" import govukDetails %}
{% from "govuk/components/exit-this-page/macro.njk" import govukExitThisPage %}
{% from "govuk/components/inset-text/macro.njk" import govukInsetText %}
{% from "govuk/components/language-switcher/macro.njk" import govukLanguageSwitcher %}
{% from "govuk/components/service-navigation/macro.njk" import govukServiceNavigation %}
{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %}
{% from "govuk/components/footer/macro.njk" import govukFooter %}

{% set pageTitle = example.title %}
{% block pageTitle %}{{ pageTitle }} - GOV.UK{% endblock %}

{% set languageSwitcherHTML %}
{{ govukLanguageSwitcher({
classes: "govuk-language-switcher--no-top-margin",
items: [
{
text: "English",
href: "#",
current: true
},
{
text: "Cymraeg",
href: "#"
}
]
}) }}
{% endset %}

{% from "govuk/components/header/macro.njk" import govukHeader %}

{% block bodyStart %}
Expand Down Expand Up @@ -82,7 +100,11 @@ scenario: |
text: 'Companies you follow',
current: true
}
]
],
slots: {
end: languageSwitcherHTML,
endRightAligned: true
}
}) }}
{% endblock %}

Expand Down
1 change: 1 addition & 0 deletions packages/govuk-frontend/src/govuk/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { Checkboxes } from './components/checkboxes/checkboxes.mjs'
export { ErrorSummary } from './components/error-summary/error-summary.mjs'
export { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs'
export { FileUpload } from './components/file-upload/file-upload.mjs'
export { LanguageSwitcher } from './components/language-switcher/language-switcher.mjs'
export { NotificationBanner } from './components/notification-banner/notification-banner.mjs'
export { PasswordInput } from './components/password-input/password-input.mjs'
export { Radios } from './components/radios/radios.mjs'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,87 @@
color: base.govuk-functional-colour(text);
}

.govuk-language-switcher__list {
// Modifier to remove the top margin on small viewports. Intended for use when
// the switcher is in the service navigation. Kept as a modifier so the style is
// not dependent on another component.
.govuk-language-switcher--no-top-margin {
@media #{base.govuk-until-breakpoint(tablet)} {
margin-top: 0;
}
}

// Menu toggle button, shown on mobile when JavaScript is available. These
// styles deliberately mirror the service navigation menu toggle.
.govuk-language-switcher__toggle {
@include base.govuk-font($size: 19, $weight: bold);
display: inline-flex;
flex-wrap: wrap;
row-gap: base.govuk-spacing(1);
align-items: center;
margin: base.govuk-spacing(2) 0;
padding: 0;
border: 0;
color: base.govuk-functional-colour(link);
background: none;
word-break: break-all;
cursor: pointer;

&:focus {
@include base.govuk-focused-text;
}

&::after {
@include base.govuk-shape-arrow($direction: down, $base: 10px, $display: inline-block);
content: "";
margin-left: base.govuk-spacing(1);
}

&[aria-expanded="true"]::after {
@include base.govuk-shape-arrow($direction: up, $base: 10px, $display: inline-block);
}

&[hidden] {
display: none;
}
}

// Icon shown before the toggle button text
.govuk-language-switcher__toggle-icon {
flex-shrink: 0;
margin-right: base.govuk-spacing(1);
}

.govuk-language-switcher__list {
margin: 0;
padding: 0;
list-style-type: none;

@media #{base.govuk-from-breakpoint(tablet)} {
display: inline-flex;
flex-wrap: wrap;
row-gap: base.govuk-spacing(1);
}
}

.govuk-language-switcher__list-item {
display: flex;
align-items: center;
// On mobile, stack the language items vertically, matching the service
// navigation menu
@media #{base.govuk-until-breakpoint(tablet)} {
margin: base.govuk-spacing(2) 0;
}

@media #{base.govuk-from-breakpoint(tablet)} {
display: flex;
align-items: center;

&:not(:last-child) {
&::after {
content: "";
display: block;
height: 1em;
margin-right: base.govuk-spacing(2);
margin-left: base.govuk-spacing(2);
border-right: 1px solid;
border-right-color: base.govuk-functional-colour(secondary-text);
&:not(:last-child) {
&::after {
content: "";
display: block;
height: 1em;
margin-right: base.govuk-spacing(2);
margin-left: base.govuk-spacing(2);
border-right: 1px solid;
border-right-color: base.govuk-functional-colour(secondary-text);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { getBreakpoint } from '../../common/index.mjs'
import { Component } from '../../component.mjs'
import { ElementError } from '../../errors/index.mjs'

/**
* Language switcher component
*
* On mobile, collapses the list of languages into a menu behind a toggle
* button, mirroring the behaviour of the service navigation menu.
*
* @preserve
*/
export class LanguageSwitcher extends Component {
/** @private */
$menuButton

/** @private */
$menu

/**
* Remember the open/closed state of the menu so we can maintain it when the
* screen is resized.
*
* @private
*/
menuIsOpen = false

/**
* A global const for storing a matchMedia instance which we'll use to detect
* when a screen size change happens. We rely on it being null if the feature
* isn't available to initially apply hidden attributes
*
* @private
* @type {MediaQueryList | null}
*/
mql = null

/**
* @param {Element | null} $root - HTML element to use for the language switcher
*/
constructor($root) {
super($root)

const $menuButton = this.$root.querySelector(
'.govuk-js-language-switcher-toggle'
)

// The toggle button may be omitted when writing plain HTML, in which case
// the list of languages is always shown
if (!$menuButton) {
return this
}

const menuId = $menuButton.getAttribute('aria-controls')
if (!menuId) {
throw new ElementError({
component: LanguageSwitcher,
identifier:
'Menu button (`<button class="govuk-js-language-switcher-toggle">`) attribute (`aria-controls`)'
})
}

const $menu = document.getElementById(menuId)
if (!$menu) {
throw new ElementError({
component: LanguageSwitcher,
element: $menu,
identifier: `Language list (\`<ul id="${menuId}">\`)`
})
}

this.$menu = $menu
this.$menuButton = $menuButton

this.setupResponsiveChecks()

this.$menuButton.addEventListener('click', () =>
this.handleMenuButtonClick()
)
}

/**
* Setup viewport resize check
*
* @private
*/
setupResponsiveChecks() {
const breakpoint = getBreakpoint('tablet')

if (!breakpoint.value) {
throw new ElementError({
component: LanguageSwitcher,
identifier: `CSS custom property (\`${breakpoint.property}\`) on pseudo-class \`:root\``
})
}

// Media query list for GOV.UK Frontend desktop breakpoint
this.mql = window.matchMedia(`(min-width: ${breakpoint.value})`)

// MediaQueryList.addEventListener isn't supported by Safari < 14 so we need
// to be able to fall back to the deprecated MediaQueryList.addListener
if ('addEventListener' in this.mql) {
this.mql.addEventListener('change', () => this.checkMode())
} else {
// @ts-expect-error Property 'addListener' does not exist
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
this.mql.addListener(() => this.checkMode())
}

this.checkMode()
}

/**
* Sync menu state
*
* Uses the variable menuIsOpen to correctly set the accessible and
* visual states of the menu and the menu button.
* Additionally will force the menu to be visible and the menu button to be
* hidden if the matchMedia is triggered to desktop.
*
* @private
*/
checkMode() {
if (!this.mql || !this.$menu || !this.$menuButton) {
return
}

if (this.mql.matches) {
this.$menu.removeAttribute('hidden')
setAttributes(this.$menuButton, attributesForHidingButton)
} else {
removeAttributes(this.$menuButton, Object.keys(attributesForHidingButton))
this.$menuButton.setAttribute('aria-expanded', this.menuIsOpen.toString())

if (this.menuIsOpen) {
this.$menu.removeAttribute('hidden')
} else {
this.$menu.setAttribute('hidden', '')
}
}
}

/**
* Handle menu button click
*
* When the menu button is clicked, change the visibility of the menu and then
* sync the accessibility state and menu button state
*
* @private
*/
handleMenuButtonClick() {
this.menuIsOpen = !this.menuIsOpen
this.checkMode()
}

/**
* Name for the component used when initialising using data-module attributes.
*/
static moduleName = 'govuk-language-switcher'
}

/**
* Collection of attributes that needs setting on a `<button>`
* to fully hide it, both visually and from screen-readers,
* and prevent its activation while hidden
*/
const attributesForHidingButton = {
hidden: '',
// Fix button still appearing in VoiceOver's form control's menu despite being hidden
// https://bugs.webkit.org/show_bug.cgi?id=300899
'aria-hidden': 'true'
}

/**
* Sets a group of attributes on the given element
*
* @param {Element} $element - The element to set the attribute on
* @param {{[attributeName: string]: string}} attributes - The attributes to set
*/
function setAttributes($element, attributes) {
for (const attributeName in attributes) {
$element.setAttribute(attributeName, attributes[attributeName])
}
}

/**
* Removes a list of attributes from the given element
*
* @param {Element} $element - The element to remove the attributes from
* @param {string[]} attributeNames - The names of the attributes to remove
*/
function removeAttributes($element, attributeNames) {
for (const attributeName of attributeNames) {
$element.removeAttribute(attributeName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ params:
type: string
required: false
description: Plain text label identifying the navigation landmark to screen readers. Write it in the language of the current page. Defaults to "Language switcher".
- name: menuButtonText
type: string
required: false
description: The text of the button that opens the mobile menu. Write it in the language of the current page. Defaults to "Languages".
- name: menuButtonLabel
type: string
required: false
description: The screen reader label for the button that opens the mobile menu, when it is different to the text of the button. Defaults to the same value as `menuButtonText`.
- name: listId
type: string
required: false
description: The ID used to associate the mobile menu button with the list of languages it controls. Defaults to "language-switcher-list".
- name: collapseOnMobile
type: boolean
required: false
description: Setting this to `false` stops the list of languages from being collapsed into a menu on mobile, keeping it always visible. Defaults to `true` when there is more than one language.
- name: classes
type: string
required: false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
{% from "../../macros/attributes.njk" import govukAttributes %}

<nav class="govuk-language-switcher {%- if params.classes %} {{ params.classes }}{% endif %}" {{- govukAttributes(params.attributes) }} aria-label="{{ params.ariaLabel | default("Language switcher") }}">
<ul class="govuk-language-switcher__list">
{%- set menuButtonText = params.menuButtonText | default("Languages", true) -%}
{%- set listId = params.listId | default("language-switcher-list", true) -%}
{%- set collapseOnMobile = params.collapseOnMobile | default(params.items.length > 1) -%}

<nav class="govuk-language-switcher {%- if params.classes %} {{ params.classes }}{% endif %}" data-module="govuk-language-switcher" {{- govukAttributes(params.attributes) }} aria-label="{{ params.ariaLabel | default("Language switcher") }}">
{% if collapseOnMobile %}
<button type="button" class="govuk-language-switcher__toggle govuk-js-language-switcher-toggle" aria-controls="{{ listId }}" {%- if params.menuButtonLabel and params.menuButtonLabel != menuButtonText %} aria-label="{{ params.menuButtonLabel }}"{% endif %} hidden aria-hidden="true">
<svg class="govuk-language-switcher__toggle-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" focusable="false" aria-hidden="true">
<circle cx="9" cy="9" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<ellipse cx="9" cy="9" rx="3.5" ry="8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M1.5 6h15M1.5 12h15" fill="none" stroke="currentColor" stroke-width="1.5"/>
</svg>
{{ menuButtonText }}
</button>
{% endif %}
<ul class="govuk-language-switcher__list" id="{{ listId }}">
{% for item in params.items %}
{% set isCurrent = item.current or (not item.href) %}
{% if isCurrent %}
Expand Down
Loading
Loading