From c476cb5cb315cd0704c07bfc64beaa984c647d9c Mon Sep 17 00:00:00 2001 From: Philipp Daun Date: Fri, 18 Oct 2024 22:54:39 +0200 Subject: [PATCH] Add ability to update additional body attributes --- README.md | 12 ++++++ src/attributes.ts | 29 ++++++++++++++ src/index.ts | 15 ++++++-- tests/unit/attributes.test.ts | 72 +++++++++++++++++++++++++++++++++++ tests/unit/plugin.test.ts | 15 ++++++-- 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100755 src/attributes.ts create mode 100644 tests/unit/attributes.test.ts diff --git a/README.md b/README.md index 883dd06..9087c7f 100755 --- a/README.md +++ b/README.md @@ -43,3 +43,15 @@ e.g. `page-`. It will then only update those classes and leave all other classes prefix: 'page-' } ``` + +### attributes + +Update additional attributes of the body element. Useful if you have defined the `lang` or `dir` +attributes or work with `data-*` attributes on the body element. Accepts an array of strings or +regular expression instances. + +```javascript +{ + attributes: ['lang', 'dir', /^data-/] +} +``` diff --git a/src/attributes.ts b/src/attributes.ts new file mode 100755 index 0000000..0885c74 --- /dev/null +++ b/src/attributes.ts @@ -0,0 +1,29 @@ +export function updateAttributes( + target: Element, + source: Element, + filters: (string | RegExp)[] = [] +): void { + const keep = new Set(); + + for (const { name, value } of getAttributes(source, filters)) { + target.setAttribute(name, value); + keep.add(name); + } + + for (const { name } of getAttributes(target, filters)) { + if (!keep.has(name)) { + target.removeAttribute(name); + } + } +} + +function getAttributes(el: Element, filters: (string | RegExp)[] = []): Attr[] { + const all = Array.from(el.attributes); + if (!filters.length) return all; + + return all.filter(({ name }) => + filters.some((pattern) => + pattern instanceof RegExp ? pattern.test(name) : name === pattern + ) + ); +} diff --git a/src/index.ts b/src/index.ts index 7287d49..84cd625 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ import { Handler } from 'swup'; import Plugin from '@swup/plugin'; import { updateClassNames } from './classes.js'; +import { updateAttributes } from './attributes.js'; type Options = { /** If set, only classes beginning with this string will be added/removed. */ prefix: string; + /** Additional body attributes to update, besides the classname. */ + attributes: (string | RegExp)[]; }; export default class SwupBodyClassPlugin extends Plugin { @@ -13,7 +16,8 @@ export default class SwupBodyClassPlugin extends Plugin { requires = { swup: '>=4.6' }; defaults: Options = { - prefix: '' + prefix: '', + attributes: [] }; options: Options; @@ -23,11 +27,14 @@ export default class SwupBodyClassPlugin extends Plugin { } mount() { - this.on('content:replace', this.updateBodyClass); + this.on('content:replace', this.update); } - protected updateBodyClass: Handler<'content:replace'> = (visit) => { - const { prefix } = this.options; + protected update: Handler<'content:replace'> = (visit) => { + const { prefix, attributes } = this.options; updateClassNames(document.body, visit.to.document!.body, { prefix }); + if (attributes?.length) { + updateAttributes(document.body, visit.to.document!.body, attributes); + } }; } diff --git a/tests/unit/attributes.test.ts b/tests/unit/attributes.test.ts new file mode 100644 index 0000000..af6e4cb --- /dev/null +++ b/tests/unit/attributes.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { updateAttributes } from '../../src/attributes.js'; + +function createElement(html: string): Element { + const el = document.createElement('div'); + el.innerHTML = html; + return el.firstElementChild!; +}; + +const mergeAttributes = (currentEl: string, incomingEl: string, ...args): string => { + const current = createElement(currentEl); + const incoming = createElement(incomingEl); + updateAttributes(current, incoming, ...args); + return current.outerHTML; +}; + +describe('updateAttributes', () => { + describe('attributes', () => { + it('adds attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('updates attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('removes attributes', () => { + expect(mergeAttributes('
', '
')).toBe('
'); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + + it('allows filtering attributes', () => { + expect(mergeAttributes('
', '
', [''])).toBe('
'); + expect(mergeAttributes('
', '
', ['b'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a'])).toBe('
'); + expect(mergeAttributes('
', '
', ['b'])).toBe('
'); + expect(mergeAttributes('
', '
', ['a', 'b'])).toBe('
'); + expect(mergeAttributes('
', '
', [/^ab/])).toBe('
'); + }); + + it('handles boolean attributes', () => { + expect(mergeAttributes('
', '')).toBe(''); + expect(mergeAttributes('
', '
')).toBe('
'); + }); + }); + + describe('types', () => { + const el = document.createElement('div'); + + it('returns nothing', () => { + expect(updateAttributes(el, el)).toBeUndefined(); + }); + + it('only accepts elements', () => { + try { + updateAttributes(el, el); + // @ts-expect-error + updateAttributes('', el); + // @ts-expect-error + updateAttributes(el, ''); + // @ts-expect-error + updateAttributes(el, 1); + // @ts-expect-error + updateAttributes(el, []); + // @ts-expect-error + updateAttributes(el); + } catch (error) {} + }); + }); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index b5d4fbf..c237ba6 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -31,14 +31,12 @@ describe('SwupBodyClassPlugin', () => { it('merges user options', async () => { const plugin = new SwupBodyClassPlugin({ prefix: 'pre-' }); - expect(plugin.options).toMatchObject({ prefix: 'pre-' }); + expect(plugin.options).toMatchObject({ prefix: 'pre-', attributes: [] }); }); it('updates body classname from content:replace hook handler', async () => { const classes = await import('../../src/classes.js'); const spy = vitest.spyOn(classes, 'updateClassNames'); - // classes.updateClassNames = vitest.fn() - // .mockImplementation(() => ({ removed: [], added: [] })); await swup.hooks.call('content:replace', visit, page); @@ -47,4 +45,15 @@ describe('SwupBodyClassPlugin', () => { prefix: plugin.options.prefix }); }); + + it('updates attributes from content:replace hook handler', async () => { + const attributes = await import('../../src/attributes.js'); + const spy = vitest.spyOn(attributes, 'updateAttributes'); + + plugin.options.attributes = ['lang', /^aria-/]; + await swup.hooks.call('content:replace', visit, page); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith(document.body, visit.to.document!.body, ['lang', /^aria-/]); + }); });