Skip to content

Commit

Permalink
Merge pull request #5338 from alphagov/public-js-api
Browse files Browse the repository at this point in the history
Improve public JavaScript API for code initialisation
  • Loading branch information
romaricpascal authored Oct 10, 2024
2 parents 3edf2c0 + 086734c commit fe83aa4
Show file tree
Hide file tree
Showing 40 changed files with 1,172 additions and 569 deletions.
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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

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

### 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

0 comments on commit fe83aa4

Please sign in to comment.