From 5eb2a25d285e7b8c5e0260b13e654e002632dcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20L=C3=B3pez?= Date: Wed, 14 Jan 2026 18:14:31 +0000 Subject: [PATCH 1/5] feat: add functionality to handle data attributes on cancel-icon --- .../components/shepherd-cancel-icon.svelte | 17 + .../components/shepherd-cancel-icon.spec.js | 437 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 test/unit/components/shepherd-cancel-icon.spec.js diff --git a/shepherd.js/src/components/shepherd-cancel-icon.svelte b/shepherd.js/src/components/shepherd-cancel-icon.svelte index e2d387f97..9c4742df7 100644 --- a/shepherd.js/src/components/shepherd-cancel-icon.svelte +++ b/shepherd.js/src/components/shepherd-cancel-icon.svelte @@ -8,6 +8,22 @@ e.preventDefault(); step.cancel(); }; + + /** + * Convert dataAttributes array to an object of data-* attributes + */ + const dataAttrs = $derived(() => { + if (!cancelIcon.dataAttributes || !Array.isArray(cancelIcon.dataAttributes)) { + return {}; + } + + return cancelIcon.dataAttributes.reduce((acc, attr) => { + if (attr.id) { + acc[`data-${attr.id}`] = attr.value; + } + return acc; + }, {}); + }); diff --git a/test/unit/components/shepherd-cancel-icon.spec.js b/test/unit/components/shepherd-cancel-icon.spec.js new file mode 100644 index 000000000..adee0c10c --- /dev/null +++ b/test/unit/components/shepherd-cancel-icon.spec.js @@ -0,0 +1,437 @@ +import { vi } from 'vitest'; +import { cleanup, fireEvent, render } from '@testing-library/svelte'; +import ShepherdCancelIcon from '../../../shepherd.js/src/components/shepherd-cancel-icon.svelte'; +import { Tour } from '../../../shepherd.js/src/tour'; +import { Step } from '../../../shepherd.js/src/step'; + +describe('components/ShepherdCancelIcon', () => { + beforeEach(cleanup); + + describe('basic functionality', () => { + it('renders cancel icon with default aria-label', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toBeInTheDocument(); + expect(cancelIcon).toHaveAttribute('aria-label', 'Close Tour'); + expect(cancelIcon).toHaveAttribute('type', 'button'); + }); + + it('renders cancel icon with custom aria-label', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true, + label: 'Custom Close Label' + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + label: 'Custom Close Label' + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('aria-label', 'Custom Close Label'); + }); + + it('renders close symbol (×)', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const closeSymbol = container.querySelector('span[aria-hidden="true"]'); + expect(closeSymbol).toBeInTheDocument(); + expect(closeSymbol.textContent).toBe('×'); + }); + }); + + describe('click behavior', () => { + it('calls step.cancel() when clicked', async () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + const stepCancelSpy = vi.spyOn(step, 'cancel'); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + await fireEvent.click(cancelIcon); + + expect(stepCancelSpy).toHaveBeenCalledOnce(); + }); + + it('prevents default event behavior when clicked', async () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true + }); + const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault'); + + cancelIcon.dispatchEvent(clickEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + describe('dataAttributes functionality', () => { + it('applies single data attribute correctly', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [{ id: 'test', value: 'testValue' }] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-test', 'testValue'); + }); + + it('applies multiple data attributes correctly', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'foo', value: 'someData' }, + { id: 'bar', value: '1234' }, + { id: 'baz', value: 'anotherValue' } + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-foo', 'someData'); + expect(cancelIcon).toHaveAttribute('data-bar', '1234'); + expect(cancelIcon).toHaveAttribute('data-baz', 'anotherValue'); + }); + + it('handles data attributes with numeric values', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'count', value: 42 }, + { id: 'price', value: 99.99 } + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-count', '42'); + expect(cancelIcon).toHaveAttribute('data-price', '99.99'); + }); + + it('handles data attributes with boolean values', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'active', value: true }, + { id: 'disabled', value: false } + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-active', 'true'); + expect(cancelIcon).toHaveAttribute('data-disabled', 'false'); + }); + + it('handles empty dataAttributes array', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toBeInTheDocument(); + // Should not have any data-* attributes + const dataAttrs = Array.from(cancelIcon.attributes).filter((attr) => + attr.name.startsWith('data-') + ); + expect(dataAttrs).toHaveLength(0); + }); + + it('handles undefined dataAttributes', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toBeInTheDocument(); + }); + + it('handles null dataAttributes', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: null + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toBeInTheDocument(); + }); + + it('ignores data attributes without id', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'valid', value: 'validValue' }, + { value: 'noId' }, // Should be ignored + { id: '', value: 'emptyId' } // Should be ignored + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-valid', 'validValue'); + + // Check that only one data attribute exists + const dataAttrs = Array.from(cancelIcon.attributes).filter((attr) => + attr.name.startsWith('data-') + ); + expect(dataAttrs).toHaveLength(1); + }); + + it('handles data attributes with special characters in values', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'url', value: 'https://example.com/test?param=value' }, + { id: 'json', value: '{"key":"value"}' }, + { id: 'spaces', value: 'value with spaces' } + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute( + 'data-url', + 'https://example.com/test?param=value' + ); + expect(cancelIcon).toHaveAttribute('data-json', '{"key":"value"}'); + expect(cancelIcon).toHaveAttribute('data-spaces', 'value with spaces'); + }); + }); + + describe('integration with label and dataAttributes', () => { + it('works with both custom label and data attributes', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { + enabled: true, + label: 'close the tour', + dataAttributes: [ + { id: 'foo', value: 'someData' }, + { id: 'bar', value: '1234' } + ] + }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('aria-label', 'close the tour'); + expect(cancelIcon).toHaveAttribute('data-foo', 'someData'); + expect(cancelIcon).toHaveAttribute('data-bar', '1234'); + expect(cancelIcon).toHaveAttribute('type', 'button'); + expect(cancelIcon).toHaveClass('shepherd-cancel-icon'); + }); + }); + + describe('CSS classes and styling', () => { + it('has correct CSS class', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveClass('shepherd-cancel-icon'); + }); + + it('button has transparent background', () => { + const tour = new Tour(); + const step = new Step(tour, { + cancelIcon: { + enabled: true + } + }); + + const { container } = render(ShepherdCancelIcon, { + props: { + cancelIcon: { enabled: true }, + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + const styles = window.getComputedStyle(cancelIcon); + // Basic style checks - specific values might vary based on CSS + expect(cancelIcon.style.background || styles.background).toBeTruthy(); + }); + }); +}); From 0829ee1514d01f670d6763cf210c3fb69337a7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20L=C3=B3pez?= Date: Wed, 14 Jan 2026 18:15:10 +0000 Subject: [PATCH 2/5] test: add tests for cancel icon rendering with data attributes and title on header --- test/unit/components/shepherd-header.spec.js | 127 +++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/test/unit/components/shepherd-header.spec.js b/test/unit/components/shepherd-header.spec.js index 1cbd56c32..33c9ffd8e 100644 --- a/test/unit/components/shepherd-header.spec.js +++ b/test/unit/components/shepherd-header.spec.js @@ -88,4 +88,131 @@ describe('components/ShepherdHeader', () => { fireEvent.click(container.querySelector('.shepherd-cancel-icon')); expect(stepCancelSpy).toHaveBeenCalled(); }); + + it('cancel icon renders with data attributes', () => { + const step = { + options: { + cancelIcon: { + enabled: true, + dataAttributes: [ + { id: 'test', value: 'testValue' }, + { id: 'other', value: '1234' } + ] + } + } + }; + + const { container } = render(ShepherdHeader, { + props: { + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('data-test', 'testValue'); + expect(cancelIcon).toHaveAttribute('data-other', '1234'); + }); + + it('cancel icon renders with both label and data attributes', () => { + const step = { + options: { + cancelIcon: { + enabled: true, + label: 'close the tour', + dataAttributes: [ + { id: 'foo', value: 'someData' }, + { id: 'bar', value: '1234' } + ] + } + } + }; + + const { container } = render(ShepherdHeader, { + props: { + step + } + }); + + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + expect(cancelIcon).toHaveAttribute('aria-label', 'close the tour'); + expect(cancelIcon).toHaveAttribute('data-foo', 'someData'); + expect(cancelIcon).toHaveAttribute('data-bar', '1234'); + }); + + it('renders title when provided', () => { + const step = { + options: { + title: 'Test Title' + } + }; + + const { container } = render(ShepherdHeader, { + props: { + step, + labelId: 'test-label' + } + }); + + const title = container.querySelector('.shepherd-title'); + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent('Test Title'); + }); + + it('does not render title when not provided', () => { + const step = { + options: {} + }; + + const { container } = render(ShepherdHeader, { + props: { + step + } + }); + + const title = container.querySelector('.shepherd-title'); + expect(title).not.toBeInTheDocument(); + }); + + it('renders both title and cancel icon when both provided', () => { + const step = { + options: { + title: 'Test Title', + cancelIcon: { + enabled: true + } + } + }; + + const { container } = render(ShepherdHeader, { + props: { + step, + labelId: 'test-label' + } + }); + + const title = container.querySelector('.shepherd-title'); + const cancelIcon = container.querySelector('.shepherd-cancel-icon'); + + expect(title).toBeInTheDocument(); + expect(cancelIcon).toBeInTheDocument(); + }); + + it('header has correct CSS class', () => { + const step = { + options: { + title: 'Test Title' + } + }; + + const { container } = render(ShepherdHeader, { + props: { + step, + labelId: 'test-label' + } + }); + + const header = container.querySelector('.shepherd-header'); + expect(header).toBeInTheDocument(); + expect(header.tagName.toLowerCase()).toBe('header'); + }); }); From b4d652eed1ba02c76c2e359d77f33d2f910e6f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20L=C3=B3pez?= Date: Wed, 14 Jan 2026 18:15:42 +0000 Subject: [PATCH 3/5] feat: add support for data attributes in ShepherdButton component --- .../src/components/shepherd-button.svelte | 17 ++ test/unit/components/shepherd-button.spec.js | 146 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/shepherd.js/src/components/shepherd-button.svelte b/shepherd.js/src/components/shepherd-button.svelte index 4c2d52ee7..abb087fa6 100644 --- a/shepherd.js/src/components/shepherd-button.svelte +++ b/shepherd.js/src/components/shepherd-button.svelte @@ -18,6 +18,22 @@ const label = $derived(config.label ? getConfigOption(config.label) : null); const secondary = $derived(config.secondary); const text = $derived(config.text ? getConfigOption(config.text) : null); + + /** + * Convert dataAttributes array to an object of data-* attributes + */ + const dataAttrs = $derived(() => { + if (!config.dataAttributes || !Array.isArray(config.dataAttributes)) { + return {}; + } + + return config.dataAttributes.reduce((acc, attr) => { + if (attr.id) { + acc[`data-${attr.id}`] = attr.value; + } + return acc; + }, {}); + }); diff --git a/test/unit/components/shepherd-button.spec.js b/test/unit/components/shepherd-button.spec.js index b16746fc1..07c422cae 100644 --- a/test/unit/components/shepherd-button.spec.js +++ b/test/unit/components/shepherd-button.spec.js @@ -193,4 +193,150 @@ describe('component/ShepherdButton', () => { expect(buttonUpdated).toHaveTextContent('Test 2'); }); }); + + describe('dataAttributes', () => { + it('applies single data attribute correctly', () => { + const config = { + text: 'Click me', + dataAttributes: [{ id: 'test', value: 'testValue' }] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toHaveAttribute('data-test', 'testValue'); + }); + + it('applies multiple data attributes correctly', () => { + const config = { + text: 'Click me', + dataAttributes: [ + { id: 'foo', value: 'someData' }, + { id: 'bar', value: '1234' }, + { id: 'baz', value: 'anotherValue' } + ] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toHaveAttribute('data-foo', 'someData'); + expect(button).toHaveAttribute('data-bar', '1234'); + expect(button).toHaveAttribute('data-baz', 'anotherValue'); + }); + + it('handles data attributes with numeric values', () => { + const config = { + text: 'Click me', + dataAttributes: [ + { id: 'count', value: 42 }, + { id: 'price', value: 99.99 } + ] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toHaveAttribute('data-count', '42'); + expect(button).toHaveAttribute('data-price', '99.99'); + }); + + it('handles empty dataAttributes array', () => { + const config = { + text: 'Click me', + dataAttributes: [] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + const dataAttrs = Array.from(button.attributes).filter((attr) => + attr.name.startsWith('data-') + ); + expect(dataAttrs).toHaveLength(0); + }); + + it('handles undefined dataAttributes', () => { + const config = { + text: 'Click me' + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toBeInTheDocument(); + }); + + it('ignores data attributes without id', () => { + const config = { + text: 'Click me', + dataAttributes: [ + { id: 'valid', value: 'validValue' }, + { value: 'noId' }, + { id: '', value: 'emptyId' } + ] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toHaveAttribute('data-valid', 'validValue'); + + const dataAttrs = Array.from(button.attributes).filter((attr) => + attr.name.startsWith('data-') + ); + expect(dataAttrs).toHaveLength(1); + }); + + it('works with other button properties', () => { + const config = { + text: 'Next', + label: 'Go to next step', + classes: 'custom-class', + secondary: true, + dataAttributes: [ + { id: 'step', value: '2' }, + { id: 'action', value: 'next' } + ] + }; + + const { container } = render(ShepherdButton, { + props: { + config + } + }); + + const button = container.querySelector('.shepherd-button'); + expect(button).toHaveAttribute('aria-label', 'Go to next step'); + expect(button).toHaveClass('custom-class'); + expect(button).toHaveClass('shepherd-button-secondary'); + expect(button).toHaveAttribute('data-step', '2'); + expect(button).toHaveAttribute('data-action', 'next'); + expect(button).toHaveTextContent('Next'); + }); + }); }); From 42e92109406d83cba4f200c8e19be297ad27536c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20L=C3=B3pez?= Date: Wed, 14 Jan 2026 18:19:12 +0000 Subject: [PATCH 4/5] refactor: implement convertDataAttributes utility and refactor ButtonComponent/CancelIconComponent to use it --- .../src/components/shepherd-button.svelte | 19 +-- .../components/shepherd-cancel-icon.svelte | 20 +-- shepherd.js/src/utils/data-attributes.ts | 38 ++++++ test/unit/utils/data-attributes.spec.js | 123 ++++++++++++++++++ 4 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 shepherd.js/src/utils/data-attributes.ts create mode 100644 test/unit/utils/data-attributes.spec.js diff --git a/shepherd.js/src/components/shepherd-button.svelte b/shepherd.js/src/components/shepherd-button.svelte index abb087fa6..287aaebbf 100644 --- a/shepherd.js/src/components/shepherd-button.svelte +++ b/shepherd.js/src/components/shepherd-button.svelte @@ -1,5 +1,6 @@ diff --git a/shepherd.js/src/components/shepherd-cancel-icon.svelte b/shepherd.js/src/components/shepherd-cancel-icon.svelte index 9c4742df7..111c5f6ae 100644 --- a/shepherd.js/src/components/shepherd-cancel-icon.svelte +++ b/shepherd.js/src/components/shepherd-cancel-icon.svelte @@ -1,4 +1,6 @@ diff --git a/shepherd.js/src/utils/data-attributes.ts b/shepherd.js/src/utils/data-attributes.ts new file mode 100644 index 000000000..0a0fe3ca8 --- /dev/null +++ b/shepherd.js/src/utils/data-attributes.ts @@ -0,0 +1,38 @@ +/** + * Represents a single data attribute with an id and value + */ +export interface DataAttribute { + id: string; + value: string | number | boolean; +} + +/** + * Converts an array of data attributes to an object of data-* attributes + * suitable for spreading onto HTML elements. + * + * @param dataAttributes - Array of data attribute objects with id and value + * @returns Object with data-* attributes as keys + * + * @example + * ```typescript + * const attrs = convertDataAttributes([ + * { id: 'foo', value: 'bar' }, + * { id: 'count', value: 42 } + * ]); + * // Returns: { 'data-foo': 'bar', 'data-count': '42' } + * ``` + */ +export function convertDataAttributes( + dataAttributes?: DataAttribute[] | null +): Record { + if (!dataAttributes || !Array.isArray(dataAttributes)) { + return {}; + } + + return dataAttributes.reduce((acc, attr) => { + if (attr.id) { + acc[`data-${attr.id}`] = String(attr.value); + } + return acc; + }, {} as Record); +} diff --git a/test/unit/utils/data-attributes.spec.js b/test/unit/utils/data-attributes.spec.js new file mode 100644 index 000000000..c95ee5b92 --- /dev/null +++ b/test/unit/utils/data-attributes.spec.js @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { convertDataAttributes } from '../../../shepherd.js/src/utils/data-attributes.ts'; + +describe('utils/data-attributes', () => { + describe('convertDataAttributes', () => { + it('returns empty object when dataAttributes is undefined', () => { + const result = convertDataAttributes(undefined); + expect(result).toEqual({}); + }); + + it('returns empty object when dataAttributes is null', () => { + const result = convertDataAttributes(null); + expect(result).toEqual({}); + }); + + it('returns empty object when dataAttributes is empty array', () => { + const result = convertDataAttributes([]); + expect(result).toEqual({}); + }); + + it('converts single data attribute correctly', () => { + const result = convertDataAttributes([{ id: 'test', value: 'testValue' }]); + expect(result).toEqual({ 'data-test': 'testValue' }); + }); + + it('converts multiple data attributes correctly', () => { + const result = convertDataAttributes([ + { id: 'foo', value: 'someData' }, + { id: 'bar', value: '1234' }, + { id: 'baz', value: 'anotherValue' } + ]); + expect(result).toEqual({ + 'data-foo': 'someData', + 'data-bar': '1234', + 'data-baz': 'anotherValue' + }); + }); + + it('handles numeric values', () => { + const result = convertDataAttributes([ + { id: 'count', value: 42 }, + { id: 'price', value: 99.99 } + ]); + expect(result).toEqual({ + 'data-count': '42', + 'data-price': '99.99' + }); + }); + + it('handles boolean values', () => { + const result = convertDataAttributes([ + { id: 'active', value: true }, + { id: 'disabled', value: false } + ]); + expect(result).toEqual({ + 'data-active': 'true', + 'data-disabled': 'false' + }); + }); + + it('ignores attributes without id', () => { + const result = convertDataAttributes([ + { id: 'valid', value: 'validValue' }, + { value: 'noId' }, + { id: '', value: 'emptyId' } + ]); + expect(result).toEqual({ 'data-valid': 'validValue' }); + }); + + it('handles special characters in values', () => { + const result = convertDataAttributes([ + { id: 'url', value: 'https://example.com/test?param=value' }, + { id: 'json', value: '{"key":"value"}' }, + { id: 'spaces', value: 'value with spaces' } + ]); + expect(result).toEqual({ + 'data-url': 'https://example.com/test?param=value', + 'data-json': '{"key":"value"}', + 'data-spaces': 'value with spaces' + }); + }); + + it('handles mixed types of values', () => { + const result = convertDataAttributes([ + { id: 'string', value: 'text' }, + { id: 'number', value: 123 }, + { id: 'boolean', value: true }, + { id: 'float', value: 3.14 } + ]); + expect(result).toEqual({ + 'data-string': 'text', + 'data-number': '123', + 'data-boolean': 'true', + 'data-float': '3.14' + }); + }); + + it('handles zero as a value', () => { + const result = convertDataAttributes([{ id: 'count', value: 0 }]); + expect(result).toEqual({ 'data-count': '0' }); + }); + + it('handles empty string as a value', () => { + const result = convertDataAttributes([{ id: 'empty', value: '' }]); + expect(result).toEqual({ 'data-empty': '' }); + }); + + it('preserves kebab-case in attribute ids', () => { + const result = convertDataAttributes([ + { id: 'my-custom-attr', value: 'value' } + ]); + expect(result).toEqual({ 'data-my-custom-attr': 'value' }); + }); + + it('handles duplicate ids by keeping last value', () => { + const result = convertDataAttributes([ + { id: 'duplicate', value: 'first' }, + { id: 'duplicate', value: 'second' } + ]); + expect(result).toEqual({ 'data-duplicate': 'second' }); + }); + }); +}); From d415f491374b4790ecb87da9c51c507d678fba86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A1n=20L=C3=B3pez?= Date: Wed, 14 Jan 2026 21:35:10 +0000 Subject: [PATCH 5/5] fix: linting --- shepherd.js/src/utils/data-attributes.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/shepherd.js/src/utils/data-attributes.ts b/shepherd.js/src/utils/data-attributes.ts index 0a0fe3ca8..e83ca7feb 100644 --- a/shepherd.js/src/utils/data-attributes.ts +++ b/shepherd.js/src/utils/data-attributes.ts @@ -9,10 +9,10 @@ export interface DataAttribute { /** * Converts an array of data attributes to an object of data-* attributes * suitable for spreading onto HTML elements. - * + * * @param dataAttributes - Array of data attribute objects with id and value * @returns Object with data-* attributes as keys - * + * * @example * ```typescript * const attrs = convertDataAttributes([ @@ -29,10 +29,13 @@ export function convertDataAttributes( return {}; } - return dataAttributes.reduce((acc, attr) => { - if (attr.id) { - acc[`data-${attr.id}`] = String(attr.value); - } - return acc; - }, {} as Record); + return dataAttributes.reduce( + (acc, attr) => { + if (attr.id) { + acc[`data-${attr.id}`] = String(attr.value); + } + return acc; + }, + {} as Record + ); }