Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve public JavaScript API for code initialisation #5338

Merged
merged 45 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5a05c23
Add `isSupported` to `all.mjs`
patrickpatrickpatrick Aug 19, 2024
d47f416
Add `isSupported` to `createAll`
patrickpatrickpatrick Aug 19, 2024
3cbc709
Add `errorCallback` to `createAll`
patrickpatrickpatrick Aug 21, 2024
b4483fa
Add `onError` to `initAll` config
patrickpatrickpatrick Aug 29, 2024
e82acf0
Update tests for `onError`
patrickpatrickpatrick Sep 11, 2024
985c0d7
Add `InitError` to throw for components already initialised
colinrotherham Dec 12, 2023
7ad92cf
Throw `InitError` when components are already initialised
colinrotherham Jan 8, 2024
3da21dd
Add tests for initialising components twice
colinrotherham Dec 12, 2023
ee7513d
Encapsulate check for components support in method of `GOVUKFrontendC…
romaricpascal Sep 16, 2024
fc50ec8
Rename `$module` to `$root` in common helpers and errors
romaricpascal Sep 20, 2024
b88cbfc
Rename `$module` to `$root` in Accordion
romaricpascal Sep 20, 2024
423514f
Rename `$module` to `$root` in Button
romaricpascal Sep 20, 2024
82df8d4
Rename `$module` to `$root` in Character Count
romaricpascal Sep 20, 2024
ac453bd
Rename `$module` to `$root` in Checkboxes
romaricpascal Sep 20, 2024
90849a0
Rename `$module` to `$root` in Error Summary
romaricpascal Sep 20, 2024
6330247
Rename `$module` to `$root` in Exit This Page
romaricpascal Sep 20, 2024
21b8cb3
Rename `$module` to `$root` in Header
romaricpascal Sep 20, 2024
cfe1093
Rename `$module` to `$root` in Notification Banner
romaricpascal Sep 20, 2024
8f32cdb
Rename `$module` to `$root` in Password Input
romaricpascal Sep 20, 2024
ff391c5
Rename `$module` to `$root` in Radios
romaricpascal Sep 20, 2024
530a41a
Rename `$module` to `$root` in Service Navigation
romaricpascal Sep 20, 2024
c7506f3
Rename `$module` to `$root` in Skip Link
romaricpascal Sep 20, 2024
0e60f4f
Rename `$module` to `$root` in Tabs
romaricpascal Sep 20, 2024
df501f2
Remove unnecessary variables in InitError tests
romaricpascal Sep 20, 2024
5e9c8da
Rename `$module` to `$root` in GOVUKFrontendComponent
romaricpascal Sep 20, 2024
7748282
Rename `$module` to `$root` in shared helpers
romaricpascal Sep 20, 2024
c77c714
Rename `$module` to `$root` in documentation
romaricpascal Sep 20, 2024
77d83e6
Update outdated example in JavaScript coding standards
romaricpascal Sep 20, 2024
817ea2e
`checkSupport` now `static`
patrickpatrickpatrick Sep 19, 2024
11d9e06
Remove `isSupported` static method for GOVUKFrontendComponent
romaricpascal Sep 23, 2024
bb472d2
Refactor `ElementError`, add `formatErrorMessage`
patrickpatrickpatrick Sep 18, 2024
c63b687
Change the components to use new `ElementError`
patrickpatrickpatrick Sep 25, 2024
c05794c
Export `GOVUKFrontendComponent` as `Component`
romaricpascal Sep 24, 2024
819c725
Add CHANGELOG entry
romaricpascal Sep 24, 2024
ed3044d
Add CHANGELOG entry for `isSupported`
romaricpascal Sep 24, 2024
dd8fd2e
Add CHANGELOG entry for `onError` option
romaricpascal Sep 24, 2024
b7eae7c
Add CHANGELOG entry for preventing double initialisation
romaricpascal Sep 24, 2024
d2b6576
Relax requirements to receive configuration with `createAll`
romaricpascal Oct 3, 2024
cd00ba0
Match argument type for `onError` to what `catch` provides
romaricpascal Oct 3, 2024
32e2324
Update `CharacterCount` to use `formatErrorMessage`
romaricpascal Sep 27, 2024
cba4d74
Refactor check for child components implementing a `moduleName` stati…
romaricpascal Sep 27, 2024
de6b95b
WIP - Refactor `InitError` to use `formatErrorMessage` or accept an a…
romaricpascal Sep 27, 2024
7cb7b62
Refactor `GOVUKFrontendComponent` `root` typing
patrickpatrickpatrick Oct 2, 2024
42c5276
Make changes to components due to `root` changes
patrickpatrickpatrick Oct 2, 2024
086734c
Use `get` for `$root` in `GOVUKFrontendComponent`
patrickpatrickpatrick Oct 10, 2024
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ You can safely delete the old image files, named `govuk-crest.png` and `govuk-cr

We introduced this change in [pull request #5376: Update the Royal Arms graphic in footer (v5.x)](https://github.com/alphagov/govuk-frontend/pull/5376).

#### Components can no longer be initialised twice on the same element

GOV.UK Frontend components now throw an error if they've already been initialised on the DOM Element they're receiving for initialisation.
This prevents components from being initialised more than once and therefore not working properly.

We introduced this change in [pull request #5272: Prevent multiple initialisations of a single component instance](https://github.com/alphagov/govuk-frontend/pull/5272)

#### Respond to initialisation errors when using `createAll` and `initAll`

We've added a new `onError` option for `createAll` and `initAll` that lets you respond to initialisation errors.
The functions will continue catching errors and initialising components further down the page if one component fails to initialise,
but this option will let you react to a component failing to initialise (for example, reporting to an error monitoring service).

We introduced this change in:

- [pull request #5252: Add `onError` to `createAll`](https://github.com/alphagov/govuk-frontend/pull/5252)
- [pull request #5276: Add `onError` to `initAll`](https://github.com/alphagov/govuk-frontend/pull/5276)

#### Check if GOV.UK Frontend is supported

We've added the `isSupported` function to let you check if GOV.UK Frontend is supported in the browser where your script is running.
GOV.UK Frontend components will check this automatically, but you may want to use the `isSupported` function to avoid running some code when GOV.UK Frontend is not supported.

We introduced this change in [pull request #5250: Add `isSupported` to `all.mjs`](https://github.com/alphagov/govuk-frontend/pull/5250)

#### Use our base component to build your own components

We've added a `Component` class to help you build your own components. It allows you to focus on your components' specific features by handling these shared behaviours across components:

- Checking that GOV.UK Frontend is supported
- Checking that the component is not already initialised on its root element
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved

We introduced this change in [pull request #5350: Export a base `Component` class](https://github.com/alphagov/govuk-frontend/pull/5350).
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved

### Fixes

We've made fixes to GOV.UK Frontend in the following pull requests:
Expand Down
22 changes: 11 additions & 11 deletions docs/contributing/coding-standards/component-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ First, make sure the component class has a constructor parameter for passing in

```mjs
export class Accordion {
constructor($module, config = {}) {
constructor($root, config = {}) {
// ...
}
}
Expand All @@ -36,7 +36,7 @@ There is no guarantee `config` will have any value at all, so we set the default
import { mergeConfigs } from '../../common/index.mjs'

export class Accordion {
constructor($module, config = {}) {
constructor($root, config = {}) {
this.config = mergeConfigs(
Accordion.defaults,
config
Expand Down Expand Up @@ -101,26 +101,26 @@ You can find `data-*` attributes in JavaScript by looking at an element's `datas

See ['Naming configuration options'](#naming-configuration-options) for exceptions to how names are transformed.

As we expect configuration-related `data-*` attributes to always be on the component's root element (the same element with the `data-module` attribute), we can access them all using `$module.dataset`.
As we expect configuration-related `data-*` attributes to always be on the component's root element (the same element with the `data-module` attribute), we can access them all using `$root.dataset`.

Using the `mergeConfigs` call discussed earlier in this document, update it to include `$module.dataset` as the highest priority.
Using the `mergeConfigs` call discussed earlier in this document, update it to include `$root.dataset` as the highest priority.

```mjs
import { mergeConfigs } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'

export class Accordion {
constructor($module, config = {}) {
constructor($root, config = {}) {
this.config = mergeConfigs(
Accordion.defaults,
config,
normaliseDataset(Accordion, $module.dataset)
normaliseDataset(Accordion, $root.dataset)
)
}
}
```

Here, we pass the value of `$module.dataset` through our `normaliseDataset` function. This is because attribute values in dataset are always interpreted as strings. `normaliseDataset` looks at the component's configuration schema and converts values into numbers or booleans where needed.
Here, we pass the value of `$root.dataset` through our `normaliseDataset` function. This is because attribute values in dataset are always interpreted as strings. `normaliseDataset` looks at the component's configuration schema and converts values into numbers or booleans where needed.

Now, in our HTML, we could pass configuration options by using the kebab-case version of the option's name.

Expand Down Expand Up @@ -164,11 +164,11 @@ import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { ConfigError } from '../../errors/index.mjs'

export class Accordion {
constructor($module, config = {}) {
constructor($root, config = {}) {
this.config = mergeConfigs(
Accordion.defaults,
config,
normaliseDataset(Accordion, $module.dataset)
normaliseDataset(Accordion, $root.dataset)
)

// Check that the configuration provided is valid
Expand Down Expand Up @@ -248,11 +248,11 @@ import { mergeConfigs, extractConfigByNamespace } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'

export class Accordion {
constructor($module, config = {}) {
constructor($root, config = {}) {
this.config = mergeConfigs(
Accordion.defaults,
config,
normaliseDataset(Accordion, $module.dataset)
normaliseDataset(Accordion, $root.dataset)
)

this.stateInfo = extractConfigByNamespace(Accordion, this.config, 'stateInfo');
Expand Down
19 changes: 7 additions & 12 deletions docs/contributing/coding-standards/js.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,22 @@ component
## Skeleton

```mjs
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Component name
*
* @preserve
*/
export class Example {
export class Example extends GOVUKFrontendComponent {
/**
* @param {Element | null} $module - HTML element to use for component
* @param {Element | null} $root - HTML element to use for component
*/
constructor($module) {
if (
!($module instanceof HTMLElement) ||
!document.body.classList.contains('govuk-frontend-supported')
) {
return this
}

this.$module = $module
constructor($root){
super($root)

// Code goes here
this.$module.addEventListener('click', () => {
this.$root.addEventListener('click', () => {
// ...
})
}
Expand Down
25 changes: 22 additions & 3 deletions packages/govuk-frontend/rollup.release.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,35 @@ import config from '@govuk-frontend/config'
import { babel } from '@rollup/plugin-babel'
import replace from '@rollup/plugin-replace'
import terser from '@rollup/plugin-terser'
import * as GOVUKFrontend from 'govuk-frontend/src/govuk/all.mjs'
import { defineConfig } from 'rollup'

// GOV.UK Frontend uses browser APIs at `import` time
// because of static properties. These APIs are not available
// in Node.js.
// We mock them the time of the `import` so we can read
// the name of GOV.UK Frontend's exports without errors
async function getGOVUKFrontendExportsNames() {
try {
global.HTMLElement = /** @type {any} */ (function () {})
global.HTMLAnchorElement = /** @type {any} */ (function () {})
return Object.keys(await import('govuk-frontend/src/govuk/all.mjs'))
} finally {
delete global.HTMLElement
delete global.HTMLAnchorElement
}
}

/**
* Rollup config for GitHub release
*
* ECMAScript (ES) module bundles for browser <script type="module">
* or using `import` for modern browsers and Node.js scripts
*
* @param {import('rollup').RollupOptions} input
* @returns {Promise<import('rollup').RollupOptions|import('rollup').RollupOptions[]>} rollup config
*/
export default defineConfig(({ i: input }) => ({

export default defineConfig(async ({ i: input }) => ({
input,

/**
Expand All @@ -37,7 +56,7 @@ export default defineConfig(({ i: input }) => ({
keep_fnames: true,
// Ensure all top-level exports skip mangling, for example
// non-function string constants like `export { version }`
reserved: Object.keys(GOVUKFrontend)
reserved: await getGOVUKFrontendExportsNames()
},

// Include sources content from source maps to inspect
Expand Down
2 changes: 2 additions & 0 deletions packages/govuk-frontend/src/govuk/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export { ServiceNavigation } from './components/service-navigation/service-navig
export { SkipLink } from './components/skip-link/skip-link.mjs'
export { Tabs } from './components/tabs/tabs.mjs'
export { initAll, createAll } from './init.mjs'
export { isSupported } from './common/index.mjs'
export { GOVUKFrontendComponent as Component } from './govuk-frontend-component.mjs'

/**
* @typedef {import('./init.mjs').Config} Config
Expand Down
17 changes: 16 additions & 1 deletion packages/govuk-frontend/src/govuk/all.puppeteer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,24 @@ describe('GOV.UK Frontend', () => {
expect(typeofCreateAll).toBe('function')
})

it('exports `isSupported` function', async () => {
const typeofIsSupported = await page.evaluate(
async (importPath, exportName) => {
const namespace = await import(importPath)
return typeof namespace[exportName]
},
scriptsPath.href,
'isSupported'
)

expect(typeofIsSupported).toBe('function')
})

it('exports Components', async () => {
const components = exported
.filter(
(method) => !['initAll', 'createAll', 'version'].includes(method)
(method) =>
!['initAll', 'createAll', 'version', 'isSupported'].includes(method)
)
.sort()

Expand All @@ -54,6 +68,7 @@ describe('GOV.UK Frontend', () => {
'Button',
'CharacterCount',
'Checkboxes',
'Component',
'ErrorSummary',
'ExitThisPage',
'Header',
Expand Down
43 changes: 41 additions & 2 deletions packages/govuk-frontend/src/govuk/common/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,28 @@ export function setFocus($element, options = {}) {
$element.focus()
}

/**
* Checks if component is already initialised
*
* @internal
* @param {Element} $root - HTML element to be checked
* @param {string} moduleName - name of component module
* @returns {boolean} Whether component is already initialised
*/
export function isInitialised($root, moduleName) {
return (
$root instanceof HTMLElement &&
$root.hasAttribute(`data-${moduleName}-init`)
)
}

/**
* Checks if GOV.UK Frontend is supported on this page
*
* Some browsers will load and run our JavaScript but GOV.UK Frontend
* won't be supported.
*
* @internal
* @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
* @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
* @returns {boolean} Whether GOV.UK Frontend is supported on this page
*/
export function isSupported($scope = document.body) {
Expand Down Expand Up @@ -266,6 +280,18 @@ function isObject(option) {
return !!option && typeof option === 'object' && !isArray(option)
}

/**
* Format error message
*
* @internal
* @param {ComponentWithModuleName} Component - Component that threw the error
* @param {string} message - Error message
* @returns {string} - Formatted error message
*/
export function formatErrorMessage(Component, message) {
return `${Component.moduleName}: ${message}`
}

/**
* Schema for component config
*
Expand Down Expand Up @@ -294,3 +320,16 @@ function isObject(option) {
* @typedef {keyof ObjectNested} NestedKey
* @typedef {{ [key: string]: string | boolean | number | ObjectNested | undefined }} ObjectNested
*/

/* eslint-disable jsdoc/valid-types --
* `{new(...args: any[] ): object}` is not recognised as valid
* https://github.com/gajus/eslint-plugin-jsdoc/issues/145#issuecomment-1308722878
* https://github.com/jsdoc-type-pratt-parser/jsdoc-type-pratt-parser/issues/131
**/

/**
* @typedef ComponentWithModuleName
* @property {string} moduleName - Name of the component
*/

/* eslint-enable jsdoc/valid-types */
Loading