diff --git a/shepherd.js/src/components/shepherd-button.svelte b/shepherd.js/src/components/shepherd-button.svelte index 4c2d52ee7..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 e2d387f97..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..e83ca7feb --- /dev/null +++ b/shepherd.js/src/utils/data-attributes.ts @@ -0,0 +1,41 @@ +/** + * 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/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'); + }); + }); }); 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(); + }); + }); +}); 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'); + }); }); 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' }); + }); + }); +});