diff --git a/components/02-molecules/pagination/__snapshots__/pagination.test.js.snap b/components/02-molecules/pagination/__snapshots__/pagination.test.js.snap
new file mode 100644
index 00000000..dc34911d
--- /dev/null
+++ b/components/02-molecules/pagination/__snapshots__/pagination.test.js.snap
@@ -0,0 +1,923 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination Component does not render when items are empty 1`] = `
+
+`;
diff --git a/components/02-molecules/pagination/pagination.test.js b/components/02-molecules/pagination/pagination.test.js
new file mode 100644
index 00000000..524d80a6
--- /dev/null
+++ b/components/02-molecules/pagination/pagination.test.js
@@ -0,0 +1,119 @@
+const template = 'components/02-molecules/pagination/pagination.twig';
+
+describe('Pagination Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ theme: 'light',
+ items: {
+ previous: { text: 'Previous', href: '#previous' },
+ pages: {
+ 1: { href: '#1' },
+ 2: { href: '#2' },
+ 3: { href: '#3' },
+ },
+ next: { text: 'Next', href: '#next' },
+ },
+ current: '2',
+ });
+
+ expect(c.querySelectorAll('.ct-pagination')).toHaveLength(1);
+ const pages = c.querySelectorAll('.ct-pagination__item');
+ expect(pages).toHaveLength(5); // Previous, 1, 2 (current), 3, Next
+ expect(c.querySelector('.ct-pagination__item--active .ct-pagination__item__link').textContent.trim()).toEqual('2');
+ expect(c.querySelector('.ct-pagination__item--previous .ct-pagination__link').getAttribute('href')).toEqual('#previous');
+ expect(c.querySelector('.ct-pagination__item--next .ct-pagination__link').getAttribute('href')).toEqual('#next');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ theme: 'dark',
+ heading_id: 'pagination-heading',
+ items: {
+ first: { text: 'First', href: '#first' },
+ previous: { text: 'Previous', href: '#previous' },
+ pages: {
+ 1: { href: '#1' },
+ 2: { href: '#2' },
+ 3: { href: '#3' },
+ },
+ next: { text: 'Next', href: '#next' },
+ last: { text: 'Last', href: '#last' },
+ },
+ current: '2',
+ items_per_page_label: 'Items per page',
+ items_per_page_options: [
+ { type: 'option', label: '10', value: '10', selected: 'selected' },
+ { type: 'option', label: '20', value: '20' },
+ { type: 'option', label: '50', value: '50' },
+ ],
+ items_per_page_name: 'itemsPerPage',
+ items_per_page_id: 'items-per-page',
+ items_per_page_attributes: 'data-test="items-per-page"',
+ use_ellipsis: true,
+ attributes: 'data-test="pagination"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-pagination');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('pagination');
+
+ const heading = c.querySelector('#pagination-heading');
+ expect(heading).not.toBeNull();
+ expect(heading.textContent.trim()).toEqual('Pagination');
+
+ const itemsPerPage = c.querySelector('.ct-pagination__items_per_page');
+ expect(itemsPerPage).toBeTruthy();
+ expect(itemsPerPage.querySelector('select').getAttribute('name')).toEqual('itemsPerPage');
+ expect(itemsPerPage.querySelector('select').getAttribute('id')).toEqual('items-per-page');
+ expect(itemsPerPage.querySelector('.ct-field').getAttribute('data-test')).toEqual('items-per-page');
+ const options = itemsPerPage.querySelector('select').querySelectorAll('option');
+ expect(options).toHaveLength(3);
+
+ const ellipses = c.querySelectorAll('.ct-pagination__item--ellipsis');
+ expect(ellipses).toHaveLength(1);
+
+ const pages = c.querySelectorAll('.ct-pagination__item');
+ expect(pages).toHaveLength(8);
+ expect(c.querySelector('.ct-pagination__item--active .ct-pagination__item__link').textContent.trim()).toEqual('2');
+ expect(c.querySelector('.ct-pagination__item--first .ct-pagination__link').getAttribute('href')).toEqual('#first');
+ expect(c.querySelector('.ct-pagination__item--previous .ct-pagination__link').getAttribute('href')).toEqual('#previous');
+ expect(c.querySelector('.ct-pagination__item--next .ct-pagination__link').getAttribute('href')).toEqual('#next');
+ expect(c.querySelector('.ct-pagination__item--last .ct-pagination__link').getAttribute('href')).toEqual('#last');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when items are empty', async () => {
+ const c = await dom(template, {
+ items: [],
+ });
+
+ expect(c.querySelectorAll('.ct-pagination')).toHaveLength(0);
+ });
+
+ test('renders current page correctly', async () => {
+ const c = await dom(template, {
+ items: {
+ previous: { text: 'Previous', href: '#previous' },
+ pages: {
+ 1: { href: '#1' },
+ 2: { href: '#2' },
+ 3: { href: '#3' },
+ },
+ next: { text: 'Next', href: '#next' },
+ },
+ current: '2',
+ });
+
+ const currentPage = c.querySelector('.ct-pagination__item--active .ct-pagination__item__link');
+ expect(currentPage).toBeTruthy();
+ expect(currentPage.textContent.trim()).toEqual('2');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/promo-card/__snapshots__/promo-card.test.js.snap b/components/02-molecules/promo-card/__snapshots__/promo-card.test.js.snap
new file mode 100644
index 00000000..ba9034fe
--- /dev/null
+++ b/components/02-molecules/promo-card/__snapshots__/promo-card.test.js.snap
@@ -0,0 +1,528 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Promo Card Component does not render when title is empty 1`] = `
+
+`;
diff --git a/components/02-molecules/promo-card/promo-card.test.js b/components/02-molecules/promo-card/promo-card.test.js
new file mode 100644
index 00000000..04494d23
--- /dev/null
+++ b/components/02-molecules/promo-card/promo-card.test.js
@@ -0,0 +1,95 @@
+const template = 'components/02-molecules/promo-card/promo-card.twig';
+
+describe('Promo Card Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ title: 'Promo Card Title',
+ summary: 'This is the summary of the promo card.',
+ });
+
+ expect(c.querySelectorAll('.ct-promo-card')).toHaveLength(1);
+ expect(c.querySelector('.ct-promo-card__title').textContent.trim()).toEqual('Promo Card Title');
+ expect(c.querySelector('.ct-promo-card__summary').textContent.trim()).toEqual('This is the summary of the promo card.');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ image_over: 'Image over content',
+ content_middle: 'Middle content',
+ content_bottom: 'Bottom content',
+ image: { url: 'https://example.com/image.jpg', alt: 'Image description' },
+ subtitle: 'Subtitle text',
+ date: '2023-12-01',
+ date_iso: '2023-12-01T00:00:00Z',
+ title: 'Promo Card Title',
+ summary: 'This is the summary of the promo card.',
+ link: { text: 'Learn more', url: 'https://example.com', is_new_window: true, is_external: true },
+ tags: ['Tag1', 'Tag2'],
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-promo-card');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-promo-card--with-image')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-promo-card__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-promo-card__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-promo-card__title').textContent.trim()).toContain('Promo Card Title');
+ expect(c.querySelector('.ct-promo-card__summary').textContent.trim()).toEqual('This is the summary of the promo card.');
+
+ const image = c.querySelector('.ct-promo-card__image img');
+ expect(image).toBeTruthy();
+ expect(image.getAttribute('src')).toEqual('https://example.com/image.jpg');
+ expect(image.getAttribute('alt')).toEqual('Image description');
+
+ const subtitle = c.querySelector('.ct-promo-card__subtitle');
+ expect(subtitle).toBeTruthy();
+ expect(subtitle.textContent.trim()).toEqual('Subtitle text');
+
+ const date = c.querySelector('.ct-promo-card__date');
+ expect(date).toBeTruthy();
+
+ const link = c.querySelector('.ct-promo-card__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com');
+ expect(link.getAttribute('target')).toEqual('_blank');
+
+ const tags = c.querySelectorAll('.ct-promo-card__tags .ct-tag');
+ expect(tags).toHaveLength(2);
+ expect(tags[0].textContent.trim()).toEqual('Tag1');
+ expect(tags[1].textContent.trim()).toEqual('Tag2');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when title is empty', async () => {
+ const c = await dom(template, {
+ title: '',
+ summary: 'This is the summary of the promo card.',
+ });
+
+ expect(c.querySelectorAll('.ct-promo-card')).toHaveLength(0);
+ });
+
+ test('renders with link when provided', async () => {
+ const c = await dom(template, {
+ title: 'Promo Card Title',
+ summary: 'This is the summary of the promo card.',
+ link: { text: 'Learn more', url: 'https://example.com' },
+ });
+
+ const link = c.querySelector('.ct-promo-card__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/publication-card/__snapshots__/publication-card.test.js.snap b/components/02-molecules/publication-card/__snapshots__/publication-card.test.js.snap
new file mode 100644
index 00000000..9953b850
--- /dev/null
+++ b/components/02-molecules/publication-card/__snapshots__/publication-card.test.js.snap
@@ -0,0 +1,422 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Publication Card Component does not render when file is empty 1`] = `
+
+`;
+
+exports[`Publication Card Component renders file link with extension and size 1`] = `
+
+`;
+
+exports[`Publication Card Component renders with optional attributes 1`] = `
+
+`;
+
+exports[`Publication Card Component renders with required attributes 1`] = `
+
+`;
+
+exports[`Publication Card Component renders with summary when provided 1`] = `
+
+`;
diff --git a/components/02-molecules/publication-card/publication-card.test.js b/components/02-molecules/publication-card/publication-card.test.js
new file mode 100644
index 00000000..6542905d
--- /dev/null
+++ b/components/02-molecules/publication-card/publication-card.test.js
@@ -0,0 +1,111 @@
+const template = 'components/02-molecules/publication-card/publication-card.twig';
+
+describe('Publication Card Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ file: {
+ name: 'Sample File',
+ ext: 'PDF',
+ url: 'https://example.com/sample.pdf',
+ size: '2MB',
+ },
+ title: 'Publication Card Title',
+ });
+
+ expect(c.querySelectorAll('.ct-publication-card')).toHaveLength(1);
+ expect(c.querySelector('.ct-publication-card__title').textContent.trim()).toEqual('Publication Card Title');
+ expect(c.querySelector('.ct-publication-card__link').textContent.trim()).toContain('Sample File (PDF, 2MB)');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ image_over: 'Image over content',
+ content_middle: 'Middle content',
+ content_bottom: 'Bottom content',
+ image: { url: 'https://example.com/image.jpg', alt: 'Image description' },
+ title: 'Publication Card Title',
+ summary: 'This is the summary of the publication card.',
+ file: {
+ name: 'Sample File',
+ ext: 'PDF',
+ url: 'https://example.com/sample.pdf',
+ size: '2MB',
+ created: '2023-01-01',
+ changed: '2023-02-01',
+ icon: 'pdf-file',
+ },
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-publication-card');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-publication-card--with-image')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-publication-card__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-publication-card__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-publication-card__title').textContent.trim()).toEqual('Publication Card Title');
+ expect(c.querySelector('.ct-publication-card__summary').textContent.trim()).toEqual('This is the summary of the publication card.');
+
+ const image = c.querySelector('.ct-publication-card__image img');
+ expect(image).toBeTruthy();
+ expect(image.getAttribute('src')).toEqual('https://example.com/image.jpg');
+ expect(image.getAttribute('alt')).toEqual('Image description');
+
+ const fileLink = c.querySelector('.ct-publication-card__link');
+ expect(fileLink).toBeTruthy();
+ expect(fileLink.textContent.trim()).toContain('Sample File (PDF, 2MB)');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when file is empty', async () => {
+ const c = await dom(template, {
+ file: {},
+ });
+
+ expect(c.querySelectorAll('.ct-publication-card')).toHaveLength(0);
+ });
+
+ test('renders file link with extension and size', async () => {
+ const c = await dom(template, {
+ file: {
+ name: 'Sample File',
+ ext: 'PDF',
+ url: 'https://example.com/sample.pdf',
+ size: '2MB',
+ },
+ });
+
+ const fileLink = c.querySelector('.ct-publication-card__link');
+ expect(fileLink).toBeTruthy();
+ expect(fileLink.textContent.trim()).toContain('Sample File (PDF, 2MB)');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with summary when provided', async () => {
+ const c = await dom(template, {
+ file: {
+ name: 'Sample File',
+ ext: 'PDF',
+ url: 'https://example.com/sample.pdf',
+ size: '2MB',
+ },
+ summary: 'This is the summary of the publication card.',
+ });
+
+ const summary = c.querySelector('.ct-publication-card__summary');
+ expect(summary).toBeTruthy();
+ expect(summary.textContent.trim()).toEqual('This is the summary of the publication card.');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/publication-card/publication-card.twig b/components/02-molecules/publication-card/publication-card.twig
index df7cef6c..7b2a5dbd 100644
--- a/components/02-molecules/publication-card/publication-card.twig
+++ b/components/02-molecules/publication-card/publication-card.twig
@@ -30,7 +30,7 @@
{% set with_image = image.url is not empty %}
{% set with_image_class = with_image ? 'ct-publication-card--with-image' : '' %}
{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %}
-{% set modifier_class = '%s %s %s %s'|format(theme_class, with_image_class, with_link_class, modifier_class|default('')) %}
+{% set modifier_class = '%s %s %s'|format(theme_class, with_image_class, modifier_class|default('')) %}
{% if file is not empty %}
diff --git a/components/02-molecules/search/__snapshots__/search.test.js.snap b/components/02-molecules/search/__snapshots__/search.test.js.snap
new file mode 100644
index 00000000..6771762c
--- /dev/null
+++ b/components/02-molecules/search/__snapshots__/search.test.js.snap
@@ -0,0 +1,182 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Search Component renders with URL only 1`] = `
+
+`;
+
+exports[`Search Component renders with optional attributes 1`] = `
+
+`;
+
+exports[`Search Component renders with required attributes 1`] = `
+
+`;
diff --git a/components/02-molecules/search/search.test.js b/components/02-molecules/search/search.test.js
new file mode 100644
index 00000000..0cd737c3
--- /dev/null
+++ b/components/02-molecules/search/search.test.js
@@ -0,0 +1,56 @@
+const template = 'components/02-molecules/search/search.twig';
+
+describe('Search Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ text: 'Search',
+ url: 'https://example.com/search',
+ });
+
+ expect(c.querySelectorAll('.ct-search')).toHaveLength(1);
+ const link = c.querySelector('.ct-search__link');
+ expect(link).toBeTruthy();
+ expect(link.textContent.trim()).toContain('Search');
+ expect(link.getAttribute('href')).toEqual('https://example.com/search');
+ expect(link.querySelector('.ct-link__icon')).toBeTruthy();
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ text: 'Search',
+ url: 'https://example.com/search',
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-search');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ const link = c.querySelector('.ct-search__link');
+ expect(link).toBeTruthy();
+ expect(link.textContent.trim()).toContain('Search');
+ expect(link.getAttribute('href')).toEqual('https://example.com/search');
+ expect(link.querySelector('.ct-link__icon')).toBeTruthy();
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with URL only', async () => {
+ const c = await dom(template, {
+ text: 'Search',
+ url: 'https://example.com/search',
+ });
+
+ const link = c.querySelector('.ct-search__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com/search');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/service-card/__snapshots__/service-card.test.js.snap b/components/02-molecules/service-card/__snapshots__/service-card.test.js.snap
new file mode 100644
index 00000000..316b172e
--- /dev/null
+++ b/components/02-molecules/service-card/__snapshots__/service-card.test.js.snap
@@ -0,0 +1,388 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Service Card Component does not render when title is empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Service Card Component renders with content slots 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+ Service Card Title
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Service Card Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+ Service Card Title
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Service Card Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Service Card Title
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/service-card/service-card.test.js b/components/02-molecules/service-card/service-card.test.js
new file mode 100644
index 00000000..e929dd8a
--- /dev/null
+++ b/components/02-molecules/service-card/service-card.test.js
@@ -0,0 +1,89 @@
+const template = 'components/02-molecules/service-card/service-card.twig';
+
+describe('Service Card Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ title: 'Service Card Title',
+ links: [
+ { text: 'Link 1', url: 'https://example.com/link1' },
+ { text: 'Link 2', url: 'https://example.com/link2' },
+ ],
+ });
+
+ expect(c.querySelectorAll('.ct-service-card')).toHaveLength(1);
+ expect(c.querySelector('.ct-service-card__title').textContent.trim()).toEqual('Service Card Title');
+
+ const links = c.querySelectorAll('.ct-service-card__links .ct-link');
+ expect(links).toHaveLength(2);
+ expect(links[0].textContent.trim()).toEqual('Link 1');
+ expect(links[0].getAttribute('href')).toEqual('https://example.com/link1');
+ expect(links[1].textContent.trim()).toEqual('Link 2');
+ expect(links[1].getAttribute('href')).toEqual('https://example.com/link2');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ title: 'Service Card Title',
+ links: [
+ { text: 'Link 1', url: 'https://example.com/link1', is_new_window: true, is_external: true },
+ { text: 'Link 2', url: 'https://example.com/link2' },
+ ],
+ content_bottom: 'Bottom content',
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-service-card');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-service-card__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-service-card__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-service-card__title').textContent.trim()).toEqual('Service Card Title');
+
+ const links = c.querySelectorAll('.ct-service-card__links .ct-link');
+ expect(links).toHaveLength(2);
+ expect(links[0].textContent.trim()).toContain('Link 1');
+ expect(links[0].getAttribute('href')).toEqual('https://example.com/link1');
+ expect(links[0].getAttribute('target')).toEqual('_blank');
+ expect(links[1].textContent.trim()).toEqual('Link 2');
+ expect(links[1].getAttribute('href')).toEqual('https://example.com/link2');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when title is empty', async () => {
+ const c = await dom(template, {
+ title: '',
+ links: [
+ { text: 'Link 1', url: 'https://example.com/link1' },
+ { text: 'Link 2', url: 'https://example.com/link2' },
+ ],
+ });
+
+ expect(c.querySelectorAll('.ct-service-card')).toHaveLength(0);
+ });
+
+ test('renders with content slots', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ title: 'Service Card Title',
+ links: [
+ { text: 'Link 1', url: 'https://example.com/link1' },
+ { text: 'Link 2', url: 'https://example.com/link2' },
+ ],
+ content_bottom: 'Bottom content',
+ });
+
+ expect(c.querySelector('.ct-service-card__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-service-card__content-bottom').textContent.trim()).toEqual('Bottom content');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/single-filter/__snapshots__/single-filter.test.js.snap b/components/02-molecules/single-filter/__snapshots__/single-filter.test.js.snap
new file mode 100644
index 00000000..157a8d58
--- /dev/null
+++ b/components/02-molecules/single-filter/__snapshots__/single-filter.test.js.snap
@@ -0,0 +1,683 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Single Filter Component does not render when items are empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Single Filter Component renders with multiple selection enabled 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Single Filter Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Single Filter Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/single-filter/single-filter.test.js b/components/02-molecules/single-filter/single-filter.test.js
new file mode 100644
index 00000000..19f84920
--- /dev/null
+++ b/components/02-molecules/single-filter/single-filter.test.js
@@ -0,0 +1,100 @@
+const template = 'components/02-molecules/single-filter/single-filter.twig';
+
+describe('Single Filter Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ title: 'Filter results by:',
+ items: [
+ { text: 'Filter 1', is_selected: true },
+ { text: 'Filter 2' },
+ ],
+ submit_text: 'Apply filter',
+ });
+
+ expect(c.querySelectorAll('.ct-single-filter')).toHaveLength(1);
+ expect(c.querySelector('.ct-single-filter__title').textContent.trim()).toEqual('Filter results by:');
+
+ const items = c.querySelectorAll('.ct-single-filter__list .ct-chip');
+ expect(items).toHaveLength(2);
+ expect(items[0].textContent.trim()).toContain('Filter 1');
+ expect(items[0].querySelector('input').checked).toBe(true);
+ expect(items[1].textContent.trim()).toContain('Filter 2');
+ expect(items[1].querySelector('input').checked).toBe(false);
+
+ const submitButton = c.querySelector('.ct-single-filter__submit');
+ expect(submitButton).toBeTruthy();
+ expect(submitButton.textContent.trim()).toContain('Apply filter');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ title: 'Filter results by:',
+ items: [
+ { text: 'Filter 1', is_selected: true, attributes: 'data-test="true"' },
+ { text: 'Filter 2' },
+ ],
+ submit_text: 'Apply filter',
+ reset_text: 'Clear all',
+ content_bottom: 'Bottom content',
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-single-filter');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-single-filter__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-single-filter__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-single-filter__title').textContent.trim()).toEqual('Filter results by:');
+
+ const items = c.querySelectorAll('.ct-single-filter__list .ct-chip');
+ expect(items).toHaveLength(2);
+ expect(items[0].textContent.trim()).toContain('Filter 1');
+ expect(items[0].querySelector('input').checked).toBe(true);
+ expect(items[1].textContent.trim()).toContain('Filter 2');
+ expect(items[1].querySelector('input').checked).toBe(false);
+
+ const submitButton = c.querySelector('.ct-single-filter__submit[type="submit"]');
+ expect(submitButton).toBeTruthy();
+ expect(submitButton.textContent.trim()).toContain('Apply filter');
+
+ const resetButton = c.querySelector('.ct-single-filter__submit[type="reset"]');
+ expect(resetButton).toBeTruthy();
+ expect(resetButton.getAttribute('value').trim()).toContain('Clear all');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when items are empty', async () => {
+ const c = await dom(template, {
+ items: [],
+ });
+
+ expect(c.querySelectorAll('.ct-single-filter')).toHaveLength(0);
+ });
+
+ test('renders with multiple selection enabled', async () => {
+ const c = await dom(template, {
+ title: 'Filter results by:',
+ items: [
+ { text: 'Filter 1', is_selected: true, name: 'filter1' },
+ { text: 'Filter 2', name: 'filter2' },
+ ],
+ is_multiple: true,
+ });
+
+ const items = c.querySelectorAll('.ct-single-filter__list .ct-chip');
+ expect(items).toHaveLength(2);
+ expect(items[0].querySelector('input').getAttribute('name')).toEqual('filter1');
+ expect(items[1].querySelector('input').getAttribute('name')).toEqual('filter2');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/snippet/__snapshots__/snippet.test.js.snap b/components/02-molecules/snippet/__snapshots__/snippet.test.js.snap
new file mode 100644
index 00000000..b0718ab3
--- /dev/null
+++ b/components/02-molecules/snippet/__snapshots__/snippet.test.js.snap
@@ -0,0 +1,460 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Snippet Component does not render when title is empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Snippet Component renders with content slots 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+
+ Snippet Title
+
+
+
+
+
+
+
+ Middle content
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Snippet Component renders with link and tags 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Snippet Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Middle content
+
+
+
+
+
+
+
+
+
+ This is the summary of the snippet.
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Snippet Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Snippet Title
+
+
+
+
+
+
+
+
+
+
+ This is the summary of the snippet.
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/snippet/snippet.test.js b/components/02-molecules/snippet/snippet.test.js
new file mode 100644
index 00000000..bf7f4cd8
--- /dev/null
+++ b/components/02-molecules/snippet/snippet.test.js
@@ -0,0 +1,106 @@
+const template = 'components/02-molecules/snippet/snippet.twig';
+
+describe('Snippet Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ title: 'Snippet Title',
+ summary: 'This is the summary of the snippet.',
+ });
+
+ expect(c.querySelectorAll('.ct-snippet')).toHaveLength(1);
+ expect(c.querySelector('.ct-snippet__title').textContent.trim()).toEqual('Snippet Title');
+ expect(c.querySelector('.ct-snippet__summary').textContent.trim()).toEqual('This is the summary of the snippet.');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ title: 'Snippet Title',
+ summary: 'This is the summary of the snippet.',
+ content_middle: 'Middle content',
+ content_bottom: 'Bottom content',
+ link: {
+ text: 'Read more',
+ url: 'https://example.com/read-more',
+ is_new_window: true,
+ is_external: true,
+ },
+ tags: ['Tag1', 'Tag2'],
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-snippet');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-snippet__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-snippet__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-snippet__title').textContent.trim()).toContain('Snippet Title');
+ expect(c.querySelector('.ct-snippet__summary').textContent.trim()).toEqual('This is the summary of the snippet.');
+ expect(c.querySelector('.ct-snippet__content-middle').textContent.trim()).toEqual('Middle content');
+
+ const link = c.querySelector('.ct-snippet__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com/read-more');
+ expect(link.getAttribute('target')).toEqual('_blank');
+
+ const tags = c.querySelectorAll('.ct-snippet__tags .ct-tag');
+ expect(tags).toHaveLength(2);
+ expect(tags[0].textContent.trim()).toEqual('Tag1');
+ expect(tags[1].textContent.trim()).toEqual('Tag2');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when title is empty', async () => {
+ const c = await dom(template, {
+ title: '',
+ });
+
+ expect(c.querySelectorAll('.ct-snippet')).toHaveLength(0);
+ });
+
+ test('renders with link and tags', async () => {
+ const c = await dom(template, {
+ title: 'Snippet Title',
+ link: {
+ text: 'Read more',
+ url: 'https://example.com/read-more',
+ },
+ tags: ['Tag1', 'Tag2'],
+ });
+
+ const link = c.querySelector('.ct-snippet__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com/read-more');
+
+ const tags = c.querySelectorAll('.ct-snippet__tags .ct-tag');
+ expect(tags).toHaveLength(2);
+ expect(tags[0].textContent.trim()).toEqual('Tag1');
+ expect(tags[1].textContent.trim()).toEqual('Tag2');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with content slots', async () => {
+ const c = await dom(template, {
+ content_top: 'Top content',
+ title: 'Snippet Title',
+ content_middle: 'Middle content',
+ content_bottom: 'Bottom content',
+ });
+
+ expect(c.querySelector('.ct-snippet__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-snippet__content-bottom').textContent.trim()).toEqual('Bottom content');
+ expect(c.querySelector('.ct-snippet__title').textContent.trim()).toEqual('Snippet Title');
+ expect(c.querySelector('.ct-snippet__content-middle').textContent.trim()).toEqual('Middle content');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/social-links/__snapshots__/social-links.test.js.snap b/components/02-molecules/social-links/__snapshots__/social-links.test.js.snap
new file mode 100644
index 00000000..10bb496d
--- /dev/null
+++ b/components/02-molecules/social-links/__snapshots__/social-links.test.js.snap
@@ -0,0 +1,386 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Social Links Component does not render when items are empty 1`] = `
+
+
+
+
+
+`;
+
+exports[`Social Links Component renders with icon HTML 1`] = `
+
+`;
+
+exports[`Social Links Component renders with optional attributes 1`] = `
+
+`;
+
+exports[`Social Links Component renders with required attributes 1`] = `
+
+`;
diff --git a/components/02-molecules/social-links/social-links.test.js b/components/02-molecules/social-links/social-links.test.js
new file mode 100644
index 00000000..b22aa026
--- /dev/null
+++ b/components/02-molecules/social-links/social-links.test.js
@@ -0,0 +1,90 @@
+const template = 'components/02-molecules/social-links/social-links.twig';
+
+describe('Social Links Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ items: [
+ { title: 'Facebook', icon: 'facebook', url: 'https://facebook.com' },
+ { title: 'Twitter', icon: 'twitter', url: 'https://twitter.com' },
+ ],
+ });
+
+ expect(c.querySelectorAll('.ct-social-links')).toHaveLength(1);
+
+ const buttons = c.querySelectorAll('.ct-social-links__button');
+ expect(buttons).toHaveLength(2);
+
+ expect(buttons[0].getAttribute('title')).toEqual('Facebook');
+ expect(buttons[0].getAttribute('href')).toEqual('https://facebook.com');
+ expect(buttons[0].querySelector('svg')).not.toBeNull();
+
+ expect(buttons[1].getAttribute('title')).toEqual('Twitter');
+ expect(buttons[1].getAttribute('href')).toEqual('https://twitter.com');
+ expect(buttons[1].querySelector('svg')).not.toBeNull();
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ items: [
+ { title: 'Facebook', icon: 'facebook', url: 'https://facebook.com', icon_html: '
' },
+ { title: 'Twitter', icon: 'twitter', url: 'https://twitter.com', icon_html: '
' },
+ ],
+ with_border: true,
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-social-links');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-social-links--with-border')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ const buttons = c.querySelectorAll('.ct-social-links__button');
+ expect(buttons).toHaveLength(2);
+
+ expect(buttons[0].getAttribute('title')).toEqual('Facebook');
+ expect(buttons[0].getAttribute('href')).toEqual('https://facebook.com');
+ expect(buttons[0].innerHTML).toContain('
');
+
+ expect(buttons[1].getAttribute('title')).toEqual('Twitter');
+ expect(buttons[1].getAttribute('href')).toEqual('https://twitter.com');
+ expect(buttons[1].innerHTML).toContain('
');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when items are empty', async () => {
+ const c = await dom(template, {
+ items: [],
+ });
+
+ expect(c.querySelectorAll('.ct-social-links')).toHaveLength(0);
+ });
+
+ test('renders with icon HTML', async () => {
+ const c = await dom(template, {
+ items: [
+ { title: 'Facebook', icon_html: '
', url: 'https://facebook.com' },
+ { title: 'Twitter', icon_html: '
', url: 'https://twitter.com' },
+ ],
+ });
+
+ const buttons = c.querySelectorAll('.ct-social-links__button');
+ expect(buttons).toHaveLength(2);
+
+ expect(buttons[0].getAttribute('title')).toEqual('Facebook');
+ expect(buttons[0].getAttribute('href')).toEqual('https://facebook.com');
+ expect(buttons[0].innerHTML).toContain('
');
+
+ expect(buttons[1].getAttribute('title')).toEqual('Twitter');
+ expect(buttons[1].getAttribute('href')).toEqual('https://twitter.com');
+ expect(buttons[1].innerHTML).toContain('
');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/subject-card/__snapshots__/subject-card.test.js.snap b/components/02-molecules/subject-card/__snapshots__/subject-card.test.js.snap
new file mode 100644
index 00000000..cc3eda8f
--- /dev/null
+++ b/components/02-molecules/subject-card/__snapshots__/subject-card.test.js.snap
@@ -0,0 +1,264 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Subject Card Component does not render when title is empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Subject Card Component renders with content slots 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image overlay content
+
+
+
+
+
+
+
+
+
+
+ Subject Card Title
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Subject Card Component renders with link and image 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Subject Card Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ Image overlay content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Subject Card Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Subject Card Title
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/subject-card/subject-card.test.js b/components/02-molecules/subject-card/subject-card.test.js
new file mode 100644
index 00000000..a1b4ed07
--- /dev/null
+++ b/components/02-molecules/subject-card/subject-card.test.js
@@ -0,0 +1,100 @@
+const template = 'components/02-molecules/subject-card/subject-card.twig';
+
+describe('Subject Card Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ title: 'Subject Card Title',
+ });
+
+ expect(c.querySelectorAll('.ct-subject-card')).toHaveLength(1);
+ expect(c.querySelector('.ct-subject-card__title').textContent.trim()).toEqual('Subject Card Title');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ title: 'Subject Card Title',
+ image_over: 'Image overlay content',
+ image: {
+ url: 'https://example.com/image.jpg',
+ alt: 'Image Alt Text',
+ },
+ link: {
+ text: 'Read more',
+ url: 'https://example.com/read-more',
+ is_new_window: true,
+ is_external: true,
+ },
+ theme: 'dark',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-subject-card');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-subject-card--with-image')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ const image = c.querySelector('.ct-subject-card__image img');
+ expect(image).not.toBeNull();
+ expect(image.getAttribute('src')).toEqual('https://example.com/image.jpg');
+ expect(image.getAttribute('alt')).toEqual('Image Alt Text');
+
+ expect(c.querySelector('.ct-subject-card__image__over').textContent.trim()).toEqual('Image overlay content');
+
+ const link = c.querySelector('.ct-subject-card__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com/read-more');
+ expect(link.getAttribute('target')).toEqual('_blank');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when title is empty', async () => {
+ const c = await dom(template, {
+ title: '',
+ });
+
+ expect(c.querySelectorAll('.ct-subject-card')).toHaveLength(0);
+ });
+
+ test('renders with link and image', async () => {
+ const c = await dom(template, {
+ title: 'Subject Card Title',
+ link: {
+ text: 'Read more',
+ url: 'https://example.com/read-more',
+ },
+ image: {
+ url: 'https://example.com/image.jpg',
+ alt: 'Image Alt Text',
+ },
+ });
+
+ const link = c.querySelector('.ct-subject-card__title__link');
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toEqual('https://example.com/read-more');
+
+ const image = c.querySelector('.ct-subject-card__image img');
+ expect(image).not.toBeNull();
+ expect(image.getAttribute('src')).toEqual('https://example.com/image.jpg');
+ expect(image.getAttribute('alt')).toEqual('Image Alt Text');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with content slots', async () => {
+ const c = await dom(template, {
+ title: 'Subject Card Title',
+ image_over: 'Image overlay content',
+ });
+
+ expect(c.querySelector('.ct-subject-card__image__over').textContent.trim()).toEqual('Image overlay content');
+ expect(c.querySelector('.ct-subject-card__title').textContent.trim()).toEqual('Subject Card Title');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/tabs/__snapshots__/tabs.test.js.snap b/components/02-molecules/tabs/__snapshots__/tabs.test.js.snap
new file mode 100644
index 00000000..4100275f
--- /dev/null
+++ b/components/02-molecules/tabs/__snapshots__/tabs.test.js.snap
@@ -0,0 +1,401 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tabs Component does not render when panels are empty 1`] = `
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tabs Component renders with generated links from panels 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content for Tab 1
+
+
+
+
+
+
+ Content for Tab 2
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tabs Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content for Tab 1
+
+
+
+
+
+
+ Content for Tab 2
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tabs Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content for Tab 1
+
+
+
+
+
+
+ Content for Tab 2
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/tabs/tabs.test.js b/components/02-molecules/tabs/tabs.test.js
new file mode 100644
index 00000000..9dae7f85
--- /dev/null
+++ b/components/02-molecules/tabs/tabs.test.js
@@ -0,0 +1,73 @@
+const template = 'components/02-molecules/tabs/tabs.twig';
+
+describe('Tabs Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ panels: [
+ { title: 'Tab 1', content: 'Content for Tab 1', id: 'tab1', is_selected: true },
+ { title: 'Tab 2', content: 'Content for Tab 2', id: 'tab2', is_selected: false },
+ ],
+ });
+
+ expect(c.querySelectorAll('.ct-tabs')).toHaveLength(1);
+ expect(c.querySelectorAll('.ct-tabs__links a')).toHaveLength(2);
+ expect(c.querySelectorAll('.ct-tabs__panels__panel')).toHaveLength(2);
+
+ const selectedPanel = c.querySelector('.ct-tabs__panels__panel.selected');
+ expect(selectedPanel).not.toBeNull();
+ expect(selectedPanel.getAttribute('id')).toEqual('tab1');
+ expect(selectedPanel.textContent.trim()).toEqual('Content for Tab 1');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ panels: [
+ { title: 'Tab 1', content: 'Content for Tab 1', id: 'tab1', is_selected: true },
+ { title: 'Tab 2', content: 'Content for Tab 2', id: 'tab2', is_selected: false },
+ ],
+ theme: 'dark',
+ vertical_spacing: 'both',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ });
+
+ const element = c.querySelector('.ct-tabs');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-vertical-spacing--both')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when panels are empty', async () => {
+ const c = await dom(template, {
+ panels: [],
+ });
+
+ expect(c.querySelectorAll('.ct-tabs')).toHaveLength(0);
+ });
+
+ test('renders with generated links from panels', async () => {
+ const c = await dom(template, {
+ panels: [
+ { title: 'Tab 1', content: 'Content for Tab 1', id: 'tab1', is_selected: true },
+ { title: 'Tab 2', content: 'Content for Tab 2', id: 'tab2', is_selected: false },
+ ],
+ });
+
+ const links = c.querySelectorAll('.ct-tabs__links a');
+ expect(links).toHaveLength(2);
+
+ expect(links[0].getAttribute('href')).toEqual('#tab1-tab');
+ expect(links[0].getAttribute('aria-controls')).toEqual('tab1');
+
+ expect(links[1].getAttribute('href')).toEqual('#tab2-tab');
+ expect(links[1].getAttribute('aria-controls')).toEqual('tab2');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/tag-list/__snapshots__/tag-list.test.js.snap b/components/02-molecules/tag-list/__snapshots__/tag-list.test.js.snap
new file mode 100644
index 00000000..4bb3d31f
--- /dev/null
+++ b/components/02-molecules/tag-list/__snapshots__/tag-list.test.js.snap
@@ -0,0 +1,312 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tag List Component does not render when tags are empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Tag List Component renders with content slots 1`] = `
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 1
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tag List Component renders with optional attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+ Top content
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 1
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom content
+
+
+
+
+
+
+
+
+`;
+
+exports[`Tag List Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 1
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 2
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+ Tag 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/tag-list/tag-list.test.js b/components/02-molecules/tag-list/tag-list.test.js
new file mode 100644
index 00000000..a5cdad3d
--- /dev/null
+++ b/components/02-molecules/tag-list/tag-list.test.js
@@ -0,0 +1,62 @@
+const template = 'components/02-molecules/tag-list/tag-list.twig';
+
+describe('Tag List Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ tags: ['Tag 1', 'Tag 2', 'Tag 3'],
+ });
+
+ expect(c.querySelectorAll('.ct-tag-list')).toHaveLength(1);
+ expect(c.querySelectorAll('.ct-tag-list__content .ct-tag')).toHaveLength(3);
+ expect(c.querySelectorAll('.ct-tag-list__content .ct-tag')[0].textContent.trim()).toEqual('Tag 1');
+ expect(c.querySelectorAll('.ct-tag-list__content .ct-tag')[1].textContent.trim()).toEqual('Tag 2');
+ expect(c.querySelectorAll('.ct-tag-list__content .ct-tag')[2].textContent.trim()).toEqual('Tag 3');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ tags: ['Tag 1', 'Tag 2'],
+ theme: 'dark',
+ vertical_spacing: 'both',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ content_top: 'Top content',
+ content_bottom: 'Bottom content',
+ });
+
+ const element = c.querySelector('.ct-tag-list');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('ct-vertical-spacing--both')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+
+ expect(c.querySelector('.ct-tag-list__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-tag-list__content-bottom').textContent.trim()).toEqual('Bottom content');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when tags are empty', async () => {
+ const c = await dom(template, {
+ tags: [],
+ });
+
+ expect(c.querySelectorAll('.ct-tag-list')).toHaveLength(0);
+ });
+
+ test('renders with content slots', async () => {
+ const c = await dom(template, {
+ tags: ['Tag 1', 'Tag 2'],
+ content_top: 'Top content',
+ content_bottom: 'Bottom content',
+ });
+
+ expect(c.querySelector('.ct-tag-list__content-top').textContent.trim()).toEqual('Top content');
+ expect(c.querySelector('.ct-tag-list__content-bottom').textContent.trim()).toEqual('Bottom content');
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/tooltip/__snapshots__/tooltip.test.js.snap b/components/02-molecules/tooltip/__snapshots__/tooltip.test.js.snap
new file mode 100644
index 00000000..0467a3db
--- /dev/null
+++ b/components/02-molecules/tooltip/__snapshots__/tooltip.test.js.snap
@@ -0,0 +1,384 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tooltip Component does not render when content is empty 1`] = `
+
+
+
+
+
+`;
+
+exports[`Tooltip Component renders with icon and icon size 1`] = `
+
+`;
+
+exports[`Tooltip Component renders with optional attributes 1`] = `
+
+`;
+
+exports[`Tooltip Component renders with required attributes 1`] = `
+
+`;
diff --git a/components/02-molecules/tooltip/tooltip.test.js b/components/02-molecules/tooltip/tooltip.test.js
new file mode 100644
index 00000000..307fa4cd
--- /dev/null
+++ b/components/02-molecules/tooltip/tooltip.test.js
@@ -0,0 +1,57 @@
+const template = 'components/02-molecules/tooltip/tooltip.twig';
+
+describe('Tooltip Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ content: 'Tooltip content',
+ });
+
+ expect(c.querySelectorAll('.ct-tooltip')).toHaveLength(1);
+ expect(c.querySelector('.ct-tooltip__description__inner').textContent.trim()).toEqual('Tooltip content');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with optional attributes', async () => {
+ const c = await dom(template, {
+ content: 'Tooltip content',
+ theme: 'dark',
+ text: 'Tooltip text',
+ title: 'Tooltip title',
+ attributes: 'data-test="true"',
+ modifier_class: 'custom-class',
+ icon: 'call',
+ });
+
+ const element = c.querySelector('.ct-tooltip');
+ expect(element).not.toBeNull();
+ expect(element.classList.contains('ct-theme-dark')).toBe(true);
+ expect(element.classList.contains('custom-class')).toBe(true);
+ expect(element.getAttribute('data-test')).toEqual('true');
+ expect(c.querySelector('.ct-tooltip__button').getAttribute('aria-label')).toEqual('Tooltip title');
+ expect(c.querySelector('.ct-tooltip__button').getAttribute('title')).toEqual('Tooltip title');
+ expect(c.querySelector('.ct-tooltip__description__inner').textContent.trim()).toEqual('Tooltip content');
+ expect(c.querySelector('.ct-icon')).not.toBeNull();
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when content is empty', async () => {
+ const c = await dom(template, {
+ content: '',
+ });
+
+ expect(c.querySelectorAll('.ct-tooltip')).toHaveLength(0);
+ });
+
+ test('renders with icon and icon size', async () => {
+ const c = await dom(template, {
+ content: 'Tooltip content',
+ icon: 'call',
+ icon_size: 'large',
+ });
+
+ expect(c.querySelector('.ct-icon.ct-icon--size-large')).not.toBeNull();
+
+ assertUniqueCssClasses(c);
+ });
+});
diff --git a/components/02-molecules/video-player/__snapshots__/video-player.test.js.snap b/components/02-molecules/video-player/__snapshots__/video-player.test.js.snap
new file mode 100644
index 00000000..9c49cf4f
--- /dev/null
+++ b/components/02-molecules/video-player/__snapshots__/video-player.test.js.snap
@@ -0,0 +1,280 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Video Component does not render when sources, embedded_source, and raw_source are all empty 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`Video Component renders with oEmbed iframe source 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Video Component renders with raw source 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Video Component renders with required attributes 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Video Component renders with transcript link 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/components/02-molecules/video-player/video-player.test.js b/components/02-molecules/video-player/video-player.test.js
new file mode 100644
index 00000000..6fb3fbe9
--- /dev/null
+++ b/components/02-molecules/video-player/video-player.test.js
@@ -0,0 +1,81 @@
+const template = 'components/02-molecules/video-player/video-player.twig';
+
+describe('Video Component', () => {
+ test('renders with required attributes', async () => {
+ const c = await dom(template, {
+ sources: [
+ { url: 'video.mp4', type: 'video/mp4' },
+ ],
+ });
+
+ expect(c.querySelectorAll('.ct-video-player')).toHaveLength(1);
+ expect(c.querySelector('video')).not.toBeNull();
+ expect(c.querySelector('video source').getAttribute('src')).toEqual('video.mp4');
+ expect(c.querySelector('video source').getAttribute('type')).toEqual('video/mp4');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with oEmbed iframe source', async () => {
+ const c = await dom(template, {
+ embedded_source: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
+ title: 'Sample Video',
+ width: '560',
+ height: '315',
+ });
+
+ expect(c.querySelectorAll('.ct-video-player')).toHaveLength(1);
+ const iframe = c.querySelector('iframe');
+ expect(iframe).not.toBeNull();
+ expect(iframe.getAttribute('src')).toEqual('https://www.youtube.com/embed/dQw4w9WgXcQ');
+ expect(iframe.getAttribute('title')).toEqual('Sample Video');
+ expect(iframe.getAttribute('width')).toEqual('560');
+ expect(iframe.getAttribute('height')).toEqual('315');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with raw source', async () => {
+ const c = await dom(template, {
+ raw_source: '
',
+ });
+
+ expect(c.querySelectorAll('.ct-video-player')).toHaveLength(1);
+ const iframe = c.querySelector('iframe');
+ expect(iframe).not.toBeNull();
+ expect(iframe.getAttribute('src')).toEqual('https://www.example.com');
+ expect(iframe.getAttribute('width')).toEqual('560');
+ expect(iframe.getAttribute('height')).toEqual('315');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('renders with transcript link', async () => {
+ const c = await dom(template, {
+ sources: [
+ { url: 'video.mp4', type: 'video/mp4' },
+ ],
+ transcript_link: {
+ url: 'transcript.html',
+ text: 'View Transcript',
+ title: 'Transcript',
+ is_new_window: true,
+ is_external: false,
+ },
+ });
+
+ expect(c.querySelectorAll('.ct-video-player')).toHaveLength(1);
+ const transcriptLink = c.querySelector('.ct-video-player__links__transcript a');
+ expect(transcriptLink).not.toBeNull();
+ expect(transcriptLink.getAttribute('href')).toEqual('transcript.html');
+ expect(transcriptLink.textContent.trim()).toContain('View Transcript');
+
+ assertUniqueCssClasses(c);
+ });
+
+ test('does not render when sources, embedded_source, and raw_source are all empty', async () => {
+ const c = await dom(template, {});
+
+ expect(c.querySelectorAll('.ct-video-player')).toHaveLength(0);
+ });
+});