Repository: swup/swup
Pull request #930 (2024-07-17 14:36:45)

Inherit trigger element attributes from parent
**Description**

- I recently had a situation where the markup of a page section was out of my control
- There was no way of defining a custom animation on the links within that section
- In such situations, it'd super helpful if custom attributes on links could be inherited from parent elements
- Applies to `data-swup-history` as well as `data-swup-animation`
- Also added some tests for `query` and `promise` utils

**Before**

```html
<ul>
  <li><a href="/1" data-swup-history="replace">Tab 1</a></li>
  <li><a href="/2" data-swup-history="replace">Tab 2</a></li>
  <li><a href="/3" data-swup-history="replace">Tab 3</a></li>
</ul>
```

**After**

```html
<ul data-swup-history="replace">
  <li><a href="/1">Tab 1</a></li>
  <li><a href="/2">Tab 2</a></li>
  <li><a href="/3">Tab 3</a></li>
</ul>
```

**Opt-out**

```html
<ul data-swup-history="replace">
  <li><a href="/1">Tab 1</a></li>
  <li><a href="/2">Tab 2</a></li>
  <li><a href="/3">Tab 3</a></li>
  <li><a href="/" data-swup-history="push">Return</a></li>
</ul>
```

**Checks**

- [x] The PR is submitted to the `master` branch
- [x] The code was linted before pushing (`npm run lint`)
- [x] All tests are passing (`npm run test`)
- [x] New or updated tests are included
- [ ] The documentation was updated as required


Patch:
diff --git a/src/modules/navigate.ts b/src/modules/navigate.ts
index a752f52..7793f89 100755
--- a/src/modules/navigate.ts
+++ b/src/modules/navigate.ts
@@ -2,6 +2,7 @@ import type Swup from '../Swup.js';
 import { FetchError, type FetchOptions, type PageData } from './fetchPage.js';
 import { type VisitInitOptions, type Visit, VisitState } from './Visit.js';
 import { createHistoryRecord, updateHistoryRecord, Location, classify } from '../helpers.js';
+import { getContextualAttr } from '../utils.js';
 
 export type HistoryAction = 'push' | 'replace';
 export type HistoryDirection = 'forwards' | 'backwards';
@@ -94,14 +95,14 @@ export async function performNavigation(
 	}
 
 	// Get history action from option or attribute on trigger element
-	const history = options.history || el?.getAttribute('data-swup-history') || undefined;
-	if (history && ['push', 'replace'].includes(history)) {
+	const history = options.history || getContextualAttr(el, 'data-swup-history');
+	if (typeof history === 'string' && ['push', 'replace'].includes(history)) {
 		visit.history.action = history as HistoryAction;
 	}
 
 	// Get custom animation name from option or attribute on trigger element
-	const animation = options.animation || el?.getAttribute('data-swup-animation') || undefined;
-	if (animation) {
+	const animation = options.animation || getContextualAttr(el, 'data-swup-animation');
+	if (typeof animation === 'string') {
 		visit.animation.name = animation;
 	}
 
diff --git a/src/utils/index.ts b/src/utils/index.ts
index e746d8c..2d0e77a 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -52,3 +52,17 @@ export function forceReflow(element?: HTMLElement): void {
 	element = element || document.body;
 	element?.getBoundingClientRect();
 }
+
+/**
+ * Read data attribute from closest element with that attribute.
+ *
+ * Returns `undefined` if no element is found or attribute is missing.
+ * Returns `true` if attribute is present without a value.
+ */
+export function getContextualAttr(
+	el: Element | undefined,
+	attr: string
+): string | boolean | undefined {
+	const target = el?.closest(`[${attr}]`);
+	return target?.hasAttribute(attr) ? target?.getAttribute(attr) || true : undefined;
+}


Test patch:
diff --git a/tests/fixtures/page-1.html b/tests/fixtures/page-1.html
index 30f7af3..3567f8c 100644
--- a/tests/fixtures/page-1.html
+++ b/tests/fixtures/page-1.html
@@ -51,8 +51,16 @@
             maxime molestias nisi perferendis perspiciatis quibusdam quos repellat,
             vel veniam vero voluptate voluptatem.</p>
             <p>
-                <a href="/page-2.html" data-swup-animation="custom">Custom animation</a>
+                <a href="/page-2.html" data-swup-animation="link">Custom animation</a>
             </p>
+            <ul data-swup-animation="list">
+                <li>
+                    <a href="/page-2.html">Custom animation</a>
+                </li>
+                <li>
+                    <a href="/page-3.html">Custom animation</a>
+                </li>
+            </ul>
         </div>
     </main>
     <script>
diff --git a/tests/functional/visit-object.spec.ts b/tests/functional/visit-object.spec.ts
index 5ec5142..388fb2b 100644
--- a/tests/functional/visit-object.spec.ts
+++ b/tests/functional/visit-object.spec.ts
@@ -99,12 +99,20 @@ test.describe('visit object', () => {
 
 	test('passes along custom animation', async ({ page }) => {
 		const link = page.locator('a[data-swup-animation]');
-		const animation = await link.getAttribute('data-swup-animation');
 		await page.evaluate(() => {
 			window._swup.hooks.on('visit:start', (visit) => window.data = visit.animation.name);
 		});
 		await link.click();
-		expect(await page.evaluate(() => window.data)).toEqual(animation);
+		expect(await page.evaluate(() => window.data)).toEqual('link');
+	});
+
+	test('passes along custom animation from parent', async ({ page }) => {
+		const link = page.locator('ul[data-swup-animation] li:first-child a');
+		await page.evaluate(() => {
+			window._swup.hooks.on('visit:start', (visit) => window.data = visit.animation.name);
+		});
+		await link.click();
+		expect(await page.evaluate(() => window.data)).toEqual('list');
 	});
 
 	test('allows disabling animations', async ({ page }) => {
diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts
new file mode 100644
index 0000000..cfc50ff
--- /dev/null
+++ b/tests/unit/utils.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { JSDOM } from 'jsdom';
+
+import { getContextualAttr, isPromise, query, queryAll } from '../../src/utils.js';
+
+const createDocument = (body: string): Document => {
+	const dom = new JSDOM(/*html*/ `<!DOCTYPE html><body>${body}</body>`);
+	return dom.window.document;
+};
+
+const stubGlobalDocument = (body: string): Document => {
+	vi.stubGlobal('document', createDocument(body));
+	return document;
+};
+
+afterEach(() => {
+	vi.unstubAllGlobals();
+});
+
+describe('query', () => {
+	it('should call querySelector on document', () => {
+		const doc = stubGlobalDocument(``);
+		const spy = vi.spyOn(doc, 'querySelector');
+		query('.selector');
+		expect(spy).toHaveBeenCalledWith('.selector');
+	});
+
+	it('should return queried element', () => {
+		stubGlobalDocument(`<div class="test">Test</div>`);
+		expect(query('.test')?.textContent).toBe('Test');
+	});
+
+	it('should accept a custom root element', () => {
+		const doc = stubGlobalDocument(/*html*/ `
+			<div class="sibling">Sibling</div>
+			<div class="nested">
+				<div class="child">Child</div>
+			</div>
+		`);
+
+		const root = doc.querySelector('.nested')!;
+		expect(query('.sibling')).toBe(doc.querySelector('.sibling'));
+		expect(query('.sibling', root)).toBe(null);
+		expect(query('.child', root)).toBe(doc.querySelector('.child'));
+	});
+});
+
+describe('queryAll', () => {
+	it('should call querySelectorAll on document', () => {
+		const doc = stubGlobalDocument(``);
+		const spy = vi.spyOn(doc, 'querySelectorAll');
+		queryAll('.selector');
+		expect(spy).toHaveBeenCalledWith('.selector');
+	});
+
+	it('returns an array', () => {
+		const els = queryAll('.lorem-ipsum');
+		expect(Array.isArray(els)).toBe(true);
+	});
+
+	it('should return queried elements', () => {
+		stubGlobalDocument(`<div class="test">Test 1</div><div class="test">Test 2</div>`);
+		expect(queryAll('.test').map(el => el.textContent)).toStrictEqual(['Test 1', 'Test 2']);
+	});
+
+	it('should accept a custom root element', () => {
+		const doc = stubGlobalDocument(/*html*/ `
+			<div class="sibling">Sibling</div>
+			<div class="nested">
+				<div class="child">Child</div>
+				<div class="child">Child</div>
+			</div>
+		`);
+
+		const root = doc.querySelector('.nested')!;
+		expect(queryAll('.sibling')).toStrictEqual([...doc.querySelectorAll('.sibling')]);
+		expect(queryAll('.sibling', root)).toStrictEqual([]);
+		expect(queryAll('.child', root)).toStrictEqual([...doc.querySelectorAll('.child')]);
+	});
+});
+
+describe('isPromise', () => {
+	it('accepts promises', () => {
+		expect(isPromise(Promise.resolve())).toBe(true);
+		expect(isPromise(new Promise(() => {}))).toBe(true);
+	});
+
+	it('accepts Promise-like objects', () => {
+		expect(isPromise({ then: () => {} })).toBe(true);
+		expect(isPromise({ then: () => {}, catch: () => {} })).toBe(true);
+	});
+
+	it('rejects non-promises', () => {
+		expect(isPromise(0)).toBe(false);
+		expect(isPromise('a')).toBe(false);
+		expect(isPromise([1, 2, 3])).toBe(false);
+		expect(isPromise({ b: 'c' })).toBe(false);
+	});
+});
+
+describe('getContextualAttr', () => {
+	it('gets attribute value from element', () => {
+		const doc = stubGlobalDocument(/*html*/ `<div attr="value"></div>`);
+		const el = doc.querySelector('div')!;
+		expect(getContextualAttr(el, 'attr')).toBe('value');
+	});
+
+	it('returns true for attrs without value', () => {
+		const doc = stubGlobalDocument(/*html*/ `<div disabled></div>`);
+		const el = doc.querySelector('div')!;
+		expect(getContextualAttr(el, 'disabled')).toBe(true);
+	});
+
+	it('gets attribute value from parent', () => {
+		const doc = stubGlobalDocument(/*html*/ `<section attr="value"><div></div></section>`);
+		const el = doc.querySelector('div')!;
+		expect(getContextualAttr(el, 'attr')).toBe('value');
+	});
+
+	it('prefers attribute value from element', () => {
+		const doc = stubGlobalDocument(/*html*/ `<section attr="parent"><div attr="self"></div></section>`);
+		const el = doc.querySelector('div')!;
+		expect(getContextualAttr(el, 'attr')).toBe('self');
+	});
+
+	it('returns null for missing attribute', () => {
+		const doc = stubGlobalDocument(/*html*/ `<div></div>`);
+		const el = doc.querySelector('div')!;
+		expect(getContextualAttr(el, 'attr')).toBe(undefined);
+	});
+
+	it('returns null for missing element', () => {
+		const doc = stubGlobalDocument(/*html*/ `<div></div>`);
+		const el = doc.querySelector('h1')!;
+		expect(getContextualAttr(el, 'attr')).toBe(undefined);
+	});
+});