'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Card title. Supports markup, e.g. for links.'
+ media:
+ title: Media
+ type: [string, array, object]
+ description: 'Optional. Accepts an image, video, icon, map thumbnail, or another component.'
+ content:
+ title: 'Body content'
+ type: [string, array, object]
+ description: 'Card body content.'
+ header:
+ title: Header
+ type: [string, array, object]
+ description: 'Optional meta information or category content.'
+ footer:
+ title: Footer
+ type: [string, array, object]
+ description: 'Optional secondary actions or metadata.'
+ attributes:
+ title: 'Card attributes'
+ type: Drupal\Core\Template\Attribute
+ media_attributes:
+ title: 'Media attributes'
+ type: Drupal\Core\Template\Attribute
+ content_attributes:
+ title: 'Body attributes'
+ type: Drupal\Core\Template\Attribute
+ header_attributes:
+ title: 'Header attributes'
+ type: Drupal\Core\Template\Attribute
+ footer_attributes:
+ title: 'Footer attributes'
+ type: Drupal\Core\Template\Attribute
diff --git a/components/card_v2/card_v2.story.twig b/components/card_v2/card_v2.story.twig
new file mode 100644
index 000000000..e9101debe
--- /dev/null
+++ b/components/card_v2/card_v2.story.twig
@@ -0,0 +1,156 @@
+{% set title %}
+
Title card
+{% endset %}
+
+{% set linked_title %}
+
+{% endset %}
+
+{% set media %}
+
+{% endset %}
+
+{% set media_horizontal %}
+
+{% endset %}
+
+{% set content %}
+
Subtitle card
+
+ This card includes a longer description to show how body text wraps across multiple lines in the component preview.
+
Example link
+
+
+ Organisation Position
+
+
+ Custom action
+
+
+{% endset %}
+
+{% set header %}
+ Header of
card
+{% endset %}
+
+{% set footer %}
+ Footer of
card
+{% endset %}
+
+{% set simple_content %}
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse vel mauris vitae ipsum blandit condimentum ut eget quam.
+{% endset %}
+
+{% set date_media %}
+
+
+
+{% endset %}
+
+{% set section_title %}
+
Section card
+{% endset %}
+
+{% set section_content %}
+
+ A compact card with badges and supporting content for related resources.
+
+
+ Static badge
+ Clickable style badge
+ Another badge
+
+{% endset %}
+
+{% set badges_title %}
+
Card with badges
+{% endset %}
+
+{% set badges_content %}
+
+ This card contains various badges that can represent categories, filters, tags, or actions.
+
+
+{% endset %}
+
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'default',
+ title: linked_title,
+ media: media,
+ content: content,
+ header: header,
+ footer: footer,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'horizontal',
+ title: title,
+ media: media_horizontal,
+ content: content,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'default',
+ title: title,
+ content: simple_content,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'horizontal',
+ title: title,
+ media: date_media,
+ content: simple_content,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'default',
+ tag: 'section',
+ attributes: create_attribute({
+ class: ['position-relative', 'text-decoration-none']
+ }),
+ title: section_title,
+ media: media,
+ content: section_content,
+ footer: footer,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'default',
+ title: badges_title,
+ media: media,
+ content: badges_content,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:card_v2' with {
+ variant: 'default',
+ title: title,
+ media: media,
+ content: simple_content,
+ header: header,
+ footer: footer,
+ media_attributes: create_attribute().addClass(['bg-info-subtle']),
+ content_attributes: create_attribute().addClass(['bg-warning-subtle']),
+ header_attributes: create_attribute().addClass(['bg-primary-subtle']),
+ footer_attributes: create_attribute().addClass(['bg-success-subtle']),
+ } only %}
+
diff --git a/components/card_v2/card_v2.twig b/components/card_v2/card_v2.twig
new file mode 100644
index 000000000..8caefdd75
--- /dev/null
+++ b/components/card_v2/card_v2.twig
@@ -0,0 +1,43 @@
+{#
+/**
+ * @file
+ * Card V2 component.
+ */
+#}
+{% set _tag = tag|default('article') %}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _media_attributes = media_attributes|default(create_attribute()) %}
+{% set _content_attributes = content_attributes|default(create_attribute()) %}
+{% set _header_attributes = header_attributes|default(create_attribute()) %}
+{% set _footer_attributes = footer_attributes|default(create_attribute()) %}
+{% set _classes = ['card', 'v2'] %}
+
+{% if variant|default('default') == 'horizontal' %}
+ {% set _classes = _classes|merge(['horizontal']) %}
+{% endif %}
+
+<{{ _tag }}{{ _attributes.addClass(_classes) }}>
+ {% if media is not empty %}
+
+ {{ media }}
+
+ {% endif %}
+ {% if header is not empty %}
+
+ {{ header }}
+
+ {% endif %}
+
+ {% if title is not empty %}
+
+ {{ title }}
+
+ {% endif %}
+ {{ content }}
+
+ {% if footer is not empty %}
+
+ {{ footer }}
+
+ {% endif %}
+{{ _tag }}>
diff --git a/components/carousel/carousel.component.yml b/components/carousel/carousel.component.yml
new file mode 100644
index 000000000..1a3a6bf85
--- /dev/null
+++ b/components/carousel/carousel.component.yml
@@ -0,0 +1,112 @@
+name: Carousel
+description: 'A slideshow component for cycling through elements, like images or slides of text.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the carousel.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ carousel_id:
+ title: 'Carousel ID'
+ type: string
+ description: 'Unique ID used to connect carousel controls and indicators.'
+ slides:
+ title: Slides
+ type: array
+ description: 'Slides shown in the carousel.'
+ items:
+ type: object
+ properties:
+ image:
+ title: Image
+ type: object
+ description: 'Image attributes used to build the slide image.'
+ properties:
+ src:
+ type: string
+ alt:
+ type: string
+ title:
+ type: string
+ loading:
+ type: string
+ width:
+ type: string
+ height:
+ type: string
+ class:
+ type: [string, array, object]
+ caption_title:
+ title: 'Caption title'
+ type: [string, array, object]
+ caption:
+ title: Caption
+ type: [string, array, object]
+ caption_classes:
+ title: 'Caption classes'
+ type: string
+ interval:
+ title: Interval
+ type: integer
+ copyright:
+ title: Copyright
+ type: [string, array, object]
+ link:
+ title: Link
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: string
+ icon_position:
+ type: string
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to icons.'
+ active_item:
+ title: 'Active item'
+ type: integer
+ description: 'The 1-based index of the active slide. Defaults to 1.'
+ fade:
+ title: Fade
+ type: boolean
+ description: 'Animate slides with a fade transition.'
+ show_controls:
+ title: 'Show controls'
+ type: boolean
+ description: 'Show previous and next controls. Defaults to true.'
+ show_indicators:
+ title: 'Show indicators'
+ type: boolean
+ description: 'Show slide indicators. Defaults to false.'
+ autoplay:
+ title: Autoplay
+ type: boolean
+ description: 'Whether to initialise and animate the carousel at page load. Defaults to true.'
+ disable_touch:
+ title: 'Disable touch'
+ type: boolean
+ description: 'Disable touch swiping.'
+ dark_mode:
+ title: 'Dark mode'
+ type: boolean
+ description: 'Use dark carousel controls and indicators.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/carousel/carousel.story.twig b/components/carousel/carousel.story.twig
new file mode 100644
index 000000000..0bbb31982
--- /dev/null
+++ b/components/carousel/carousel.story.twig
@@ -0,0 +1,98 @@
+{% set slides = [
+ {
+ image: {
+ src: 'https://picsum.photos/seed/oe-carousel-sdc-1/1000/500',
+ alt: 'Modern building with glass facade'
+ },
+ caption_title: 'Research and innovation',
+ caption: 'Programmes and initiatives supporting sustainable growth across Europe.',
+ interval: 5000,
+ copyright: '© 2026 Example Organisation',
+ link: {
+ label: 'Read more',
+ path: 'https://example.com/research'
+ }
+ },
+ {
+ image: {
+ src: 'https://picsum.photos/seed/oe-carousel-sdc-2/1000/500',
+ alt: 'People walking through a public square'
+ },
+ caption_title: 'Digital services',
+ caption: 'Accessible public services designed around the needs of people and businesses.',
+ interval: 4000,
+ copyright: '© 2026 Example Organisation'
+ },
+ {
+ image: {
+ src: 'https://picsum.photos/seed/oe-carousel-sdc-3/1000/500',
+ alt: 'Coastal landscape at sunset'
+ },
+ caption_title: 'Climate action',
+ caption: 'Policies and projects that help communities adapt and reduce emissions.',
+ interval: 4000,
+ link: {
+ label: 'View priorities',
+ path: 'https://example.com/climate'
+ }
+ }
+] %}
+
+{% set rich_slides = [
+ {
+ image: {
+ src: 'https://picsum.photos/seed/oe-carousel-sdc-4/1000/500',
+ alt: 'Conference room with a large presentation screen',
+ title: 'Digital policy presentation',
+ loading: 'lazy',
+ width: '1000',
+ height: '500',
+ class: ['object-fit-cover']
+ },
+ caption_title: 'Interoperable services',
+ caption: 'Reusable building blocks that help administrations deliver reliable online services.',
+ interval: 3000,
+ link: {
+ label: 'Explore services',
+ path: 'https://example.com/services',
+ icon_position: 'after',
+ icon: {
+ name: 'chevron-right'
+ }
+ }
+ },
+ {
+ image: {
+ src: 'https://picsum.photos/seed/oe-carousel-sdc-5/1000/500',
+ alt: 'Solar panels in a green landscape'
+ },
+ caption_title: 'Sustainable investment',
+ caption: 'Funding opportunities for climate, infrastructure, and digital transformation.',
+ interval: 3000
+ }
+] %}
+
+
+ {% include 'oe_bootstrap_theme:carousel' with {
+ title: 'Featured priorities',
+ title_tag: 'h2',
+ carousel_id: 'carousel-sdc-preview',
+ fade: true,
+ show_controls: true,
+ show_indicators: true,
+ autoplay: false,
+ slides: slides,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:carousel' with {
+ title: 'Dark carousel with active item',
+ title_tag: 'h2',
+ carousel_id: 'carousel-sdc-dark-preview',
+ show_controls: true,
+ show_indicators: true,
+ autoplay: false,
+ dark_mode: true,
+ active_item: 2,
+ slides: rich_slides,
+ } only %}
+
diff --git a/components/carousel/carousel.twig b/components/carousel/carousel.twig
new file mode 100644
index 000000000..c72bd9889
--- /dev/null
+++ b/components/carousel/carousel.twig
@@ -0,0 +1,50 @@
+{#
+/**
+ * @file
+ * Carousel component.
+ */
+#}
+{% set icon_path = icon_path|default(bcl_icon_path()) %}
+{% set carousel_id = carousel_id|default('carousel'|clean_unique_id) %}
+{% set items = [] %}
+
+{% for slide in slides|default([]) %}
+ {% set image %}
+ {% if slide.image is defined and slide.image is not empty %}
+
+ {% endif %}
+ {% endset %}
+
+ {% set item = slide|merge({ image: image }) %}
+ {% if slide.link is defined and slide.link is not empty %}
+ {% set item = item|merge({
+ link: slide.link|merge({
+ icon_position: slide.link.icon_position|default('before'),
+ icon: slide.link.icon|default({})|merge({
+ name: slide.link.icon.name|default('link'),
+ path: slide.link.icon.path|default(icon_path),
+ }),
+ }),
+ }) %}
+ {% endif %}
+
+ {% set items = items|merge([item]) %}
+{% endfor %}
+
+{% include '@oe-bcl/carousel' with {
+ copyright_label: 'Image credit:'|t,
+ title: title,
+ title_tag: title_tag|default('h2'),
+ id: carousel_id,
+ autoplay: autoplay ?? true,
+ dark: dark_mode,
+ active_item: active_item,
+ items: items,
+ fade: fade ?? false,
+ with_controls: show_controls ?? true,
+ prev_label: 'Previous'|t,
+ next_label: 'Next'|t,
+ with_indicators: show_indicators ?? false,
+ disable_touch: disable_touch ?? false,
+ attributes: attributes,
+} only %}
diff --git a/components/columns/columns.component.yml b/components/columns/columns.component.yml
new file mode 100644
index 000000000..c707af3f0
--- /dev/null
+++ b/components/columns/columns.component.yml
@@ -0,0 +1,20 @@
+name: Columns
+description: 'Renders content organised in columns of equal width.'
+props:
+ type: object
+ properties:
+ items:
+ title: Items
+ type: array
+ description: 'The items to organize in columns. Accepts any render array.'
+ columns:
+ title: Columns
+ type: integer
+ description: 'The number of columns. Defaults to 4.'
+ tag:
+ title: Tag
+ type: string
+ description: 'HTML tag to use for the wrapper. Defaults to div.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/columns/columns.story.twig b/components/columns/columns.story.twig
new file mode 100644
index 000000000..c649e58c1
--- /dev/null
+++ b/components/columns/columns.story.twig
@@ -0,0 +1,47 @@
+{% set items = [
+ { '#plain_text': 'Column 1' },
+ { '#plain_text': 'Column 2' },
+ { '#plain_text': 'Column 3' },
+ { '#plain_text': 'Column 4' },
+ { '#plain_text': 'Column 5' },
+ { '#plain_text': 'Column 6' },
+] %}
+
+
+
+
+
+
Default columns
+ {% include 'oe_bootstrap_theme:columns' with {
+ items: items,
+ } only %}
+
+
+
+
Three columns
+ {% include 'oe_bootstrap_theme:columns' with {
+ items: items,
+ columns: 3,
+ } only %}
+
+
+
+
Section wrapper
+ {% include 'oe_bootstrap_theme:columns' with {
+ items: items,
+ columns: 6,
+ tag: 'section',
+ } only %}
+
+
diff --git a/components/columns/columns.twig b/components/columns/columns.twig
new file mode 100644
index 000000000..924d50994
--- /dev/null
+++ b/components/columns/columns.twig
@@ -0,0 +1,27 @@
+{#
+/**
+ * @file
+ * Columns component.
+ */
+#}
+{% set tag = tag|default('div') %}
+{% set columns = columns|default(4) %}
+{% set attributes = attributes|default(create_attribute()) %}
+{%
+ set wrapper_classes = [
+ 'columns',
+ ]
+%}
+{%
+ set responsive_grid_styles = [
+ '--columns-grid--column-count: ' ~ columns ~ ';',
+ ]
+%}
+
+<{{ tag }}{{ attributes.addClass(wrapper_classes).setAttribute('style', responsive_grid_styles|join()) }}>
+ {% for item in items|default([])|element_children %}
+
+ {{- item -}}
+
+ {% endfor %}
+{{ tag }}>
diff --git a/components/content_banner/content_banner.component.yml b/components/content_banner/content_banner.component.yml
new file mode 100644
index 000000000..f4148e221
--- /dev/null
+++ b/components/content_banner/content_banner.component.yml
@@ -0,0 +1,212 @@
+name: 'Content banner'
+description: 'Shows the content summary along with the title, image and badges.'
+props:
+ type: object
+ properties:
+ background:
+ title: Background
+ type: string
+ description: 'Sets a gray or white background. Defaults to white.'
+ default: white
+ enum:
+ - white
+ - gray
+ 'meta:enum':
+ white: 'White (default)'
+ gray: Gray
+ image:
+ title: Image
+ type: object
+ description: 'Image displayed in the content banner.'
+ properties:
+ src:
+ type: string
+ alt:
+ type: string
+ copyright:
+ title: Copyright
+ type: [string, array, object]
+ description: 'Optional copyright text displayed below the image.'
+ copyright_label:
+ title: 'Copyright label'
+ type: string
+ description: 'Accessible label shown before the copyright. Defaults to Image credit:.'
+ image_size:
+ title: 'Image size'
+ type: string
+ description: 'The size of the image. Defaults to lg.'
+ default: lg
+ enum:
+ - md
+ - lg
+ - xl
+ 'meta:enum':
+ md: Medium
+ lg: 'Large (default)'
+ xl: 'Extra large'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the content banner.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h1.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Optional link object for the title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: string
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ icon_position:
+ type: string
+ icon_display:
+ type: string
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ action_bar:
+ title: 'Action bar'
+ type: [string, array, object]
+ description: 'Action bar region, for example action buttons.'
+ content:
+ title: Content
+ type: [string, array, object]
+ description: 'Text with the summary of the content.'
+ subtitle:
+ title: Subtitle
+ type: object
+ description: 'Optional card subtitle.'
+ properties:
+ content:
+ type: [string, array, object]
+ tag:
+ type: string
+ classes:
+ type: string
+ text:
+ title: Text
+ type: object
+ description: 'Optional card text.'
+ properties:
+ content:
+ type: [string, array, object]
+ tag:
+ type: string
+ classes:
+ type: string
+ meta:
+ title: Meta
+ type: array
+ description: 'Miscellaneous information such as date or author.'
+ items:
+ type: [string, array, object]
+ badges:
+ title: Badges
+ type: array
+ description: 'List of badges.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ background:
+ type: string
+ url:
+ type: string
+ title:
+ type: string
+ dismissible:
+ type: boolean
+ assistive_text:
+ type: string
+ rounded_pill:
+ type: boolean
+ outline:
+ type: boolean
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ links:
+ title: Links
+ type: array
+ description: 'List of links.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: string
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ icon_position:
+ type: string
+ icon_display:
+ type: string
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ date:
+ title: 'Date block'
+ type: object
+ description: 'Date block displayed in place of an image.'
+ properties:
+ day:
+ type: [string, integer]
+ month:
+ type: string
+ date_time:
+ type: string
+ year:
+ type: [string, integer]
+ end_day:
+ type: [string, integer]
+ end_month:
+ type: string
+ end_year:
+ type: [string, integer]
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/content_banner/content_banner.story.twig b/components/content_banner/content_banner.story.twig
new file mode 100644
index 000000000..bbeab1736
--- /dev/null
+++ b/components/content_banner/content_banner.story.twig
@@ -0,0 +1,99 @@
+{% set image = {
+ src: 'https://picsum.photos/id/1015/1200/600',
+ alt: 'Mountain landscape reflected in a lake',
+} %}
+
+{% set badges = [
+ {
+ label: 'Primary badge',
+ rounded_pill: true,
+ outline: false,
+ },
+ {
+ label: 'Secondary badge',
+ rounded_pill: true,
+ },
+] %}
+
+{% set links = [
+ {
+ label: 'Read the latest updates',
+ path: '/example-1',
+ },
+ {
+ label: 'View related policies',
+ path: '/example-2',
+ },
+] %}
+
+{% set action_bar %}
+
+ {% include 'oe_bootstrap_theme:button' with {
+ label: 'Subscribe',
+ variant: 'primary',
+ type: 'button',
+ } only %}
+
+{% endset %}
+
+
+
+
White background with image
+ {% include 'oe_bootstrap_theme:content_banner' with {
+ background: 'white',
+ image: image,
+ copyright: '© 2026 Example Organisation',
+ copyright_label: 'Photo credit:',
+ title: 'Building a greener and more resilient Europe',
+ title_tag: 'h2',
+ subtitle: {
+ content: 'Climate and energy',
+ tag: 'div',
+ classes: 'fs-5',
+ },
+ text: {
+ content: 'Supporting practical action across regions and sectors.',
+ tag: 'p',
+ classes: 'mt-2',
+ },
+ meta: ['23 July 2026', 'Policy update'],
+ content: 'Discover the latest initiatives supporting clean energy, sustainable industry and stronger communities across Europe.',
+ badges: badges,
+ links: links,
+ action_bar: action_bar,
+ } only %}
+
+
+
+
Gray background with extra large image
+ {% include 'oe_bootstrap_theme:content_banner' with {
+ background: 'gray',
+ image: image,
+ image_size: 'xl',
+ title: 'A shared plan for sustainable growth',
+ content: 'Investment and cooperation are helping regions deliver practical solutions for citizens and businesses.',
+ } only %}
+
+
+
+
Gray background without image
+ {% include 'oe_bootstrap_theme:content_banner' with {
+ background: 'gray',
+ title: 'Key figures and programme highlights',
+ meta: ['Updated 23 July 2026', '6 min read'],
+ content: 'An overview of current priorities, milestones and expected results.',
+ badges: badges,
+ } only %}
+
+
+
+
White background with medium image
+ {% include 'oe_bootstrap_theme:content_banner' with {
+ background: 'white',
+ image: image,
+ image_size: 'md',
+ title: 'Regional projects making a difference',
+ content: 'Local projects are turning shared priorities into measurable improvements.',
+ } only %}
+
+
diff --git a/components/content_banner/content_banner.twig b/components/content_banner/content_banner.twig
new file mode 100644
index 000000000..d185041d8
--- /dev/null
+++ b/components/content_banner/content_banner.twig
@@ -0,0 +1,98 @@
+{#
+/**
+ * @file
+ * Content banner component.
+ */
+#}
+{%- set _content -%}
+ {%- set content_attributes = create_attribute().addClass(['mt-4', 'card-content']) -%}
+ {%- if meta is not empty -%}
+ {%- set content_attributes = create_attribute().addClass('card-content') -%}
+
+ {%- for meta_item in meta -%}
+ {%- if meta_item is not empty -%}
+ {{- meta_item -}}
+ {%- endif -%}
+ {%- endfor -%}
+
+ {%- endif -%}
+ {%- if content is not empty -%}
+
{{- content -}}
+ {%- endif -%}
+{%- endset -%}
+
+{%- set _badges = [] -%}
+{%- for badge in badges|default([]) -%}
+ {%- set _badge = {
+ outline: true,
+ }|merge(badge) -%}
+ {%- set _badge = _badge|merge({
+ attributes: create_attribute(_badge.attributes|default({})),
+ }) -%}
+ {%- set _badges = _badges|merge([_badge]) -%}
+{%- endfor -%}
+
+{%- set _links = [] -%}
+{%- for link in links|default([]) -%}
+ {%- set _link = link|merge({
+ attributes: create_attribute(link.attributes|default({})),
+ }) -%}
+ {%- if _link.icon is defined and _link.icon is not empty and _link.icon.path is not defined -%}
+ {%- set _link = _link|merge({
+ icon: _link.icon|merge({path: bcl_icon_path()}),
+ }) -%}
+ {%- endif -%}
+ {%- set _links = _links|merge([_link]) -%}
+{%- endfor -%}
+
+{%- set _title_link = title_link|default({}) -%}
+{%- if _title_link is not empty -%}
+ {%- set _title_link = _title_link|merge({
+ attributes: create_attribute(_title_link.attributes|default({})),
+ }) -%}
+ {%- if _title_link.icon is defined and _title_link.icon is not empty and _title_link.icon.path is not defined -%}
+ {%- set _title_link = _title_link|merge({
+ icon: _title_link.icon|merge({path: bcl_icon_path()}),
+ }) -%}
+ {%- endif -%}
+{%- endif -%}
+
+{%- set _image = {} -%}
+{%- if image is not empty -%}
+ {%- set _image = image|merge({
+ path: image.src|default(''),
+ alt: image.alt|default(''),
+ }) -%}
+{%- endif -%}
+
+{%- set _date = date|default({}) -%}
+{%- if _date is not empty -%}
+ {%- set _date = _date|merge({
+ attributes: create_attribute(_date.attributes|default({})),
+ }) -%}
+ {%- if _date.icon_path is not defined or _date.icon_path is empty -%}
+ {%- set _date = _date|merge({
+ icon_path: bcl_icon_path(),
+ }) -%}
+ {%- endif -%}
+{%- endif -%}
+
+{%- include '@oe-bcl/bcl-content-banner/bcl-content-banner.html.twig' with {
+ background: background|default('white'),
+ title: title,
+ title_tag: title_tag|default('h1'),
+ title_link: _title_link,
+ title_attributes: title_attributes,
+ subtitle: subtitle,
+ text: text,
+ content: _content,
+ image: _image,
+ date: _date,
+ copyright: copyright,
+ copyright_label: copyright_label|default('Image credit:'|t),
+ image_size: image_size|default('lg'),
+ badges: _badges,
+ links: _links,
+ action_bar: action_bar,
+ attributes: attributes,
+} only -%}
diff --git a/components/date_block/date_block.component.yml b/components/date_block/date_block.component.yml
new file mode 100644
index 000000000..8d6abb0c2
--- /dev/null
+++ b/components/date_block/date_block.component.yml
@@ -0,0 +1,40 @@
+name: 'Date block'
+description: 'Date block allows to present a date that can be placed in other components.'
+props:
+ type: object
+ properties:
+ day:
+ title: Day
+ type: [string, integer]
+ description: 'Day displayed in the date block.'
+ month:
+ title: Month
+ type: string
+ description: 'Month displayed in the date block.'
+ date_time:
+ title: Datetime
+ type: string
+ description: 'Machine-readable date added to the datetime attribute.'
+ year:
+ title: Year
+ type: [string, integer]
+ description: 'Year displayed in the date block.'
+ end_day:
+ title: 'End day'
+ type: [string, integer]
+ description: 'End day displayed for a date range.'
+ end_month:
+ title: 'End month'
+ type: string
+ description: 'End month displayed for a date range.'
+ end_year:
+ title: 'End year'
+ type: [string, integer]
+ description: 'End year displayed for a date range.'
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/date_block/date_block.story.twig b/components/date_block/date_block.story.twig
new file mode 100644
index 000000000..1e3c20b06
--- /dev/null
+++ b/components/date_block/date_block.story.twig
@@ -0,0 +1,57 @@
+
+
+
Explicit date values
+
+ {% include 'oe_bootstrap_theme:date_block' with {
+ day: 23,
+ month: 'Jul',
+ year: 2026,
+ date_time: '2026-07-23',
+ attributes: create_attribute().addClass('custom-date-block'),
+ } only %}
+
+
+
+
+
Same-month date range
+
+ {% include 'oe_bootstrap_theme:date_block' with {
+ day: 23,
+ month: 'Jan',
+ year: 2026,
+ date_time: '2026-01-23',
+ end_day: 30,
+ } only %}
+
+
+
+
+
Same-year date range
+
+ {% include 'oe_bootstrap_theme:date_block' with {
+ day: 23,
+ month: 'Jan',
+ year: 2026,
+ date_time: '2026-01-23',
+ end_day: 11,
+ end_month: 'Feb',
+ } only %}
+
+
+
+
+
Cross-year date range
+
+ {% include 'oe_bootstrap_theme:date_block' with {
+ day: 23,
+ month: 'Jan',
+ year: 2026,
+ date_time: '2026-01-23',
+ end_day: 11,
+ end_month: 'Feb',
+ end_year: 2027,
+ icon_path: bcl_icon_path(),
+ } only %}
+
+
+
diff --git a/components/date_block/date_block.twig b/components/date_block/date_block.twig
new file mode 100644
index 000000000..d58eebe64
--- /dev/null
+++ b/components/date_block/date_block.twig
@@ -0,0 +1,17 @@
+{#
+/**
+ * @file
+ * Date block component.
+ */
+#}
+{% include '@oe-bcl/date-block' with {
+ day: day,
+ month: month,
+ date_time: date_time,
+ year: year,
+ end_day: end_day,
+ end_month: end_month,
+ end_year: end_year,
+ icon_path: icon_path|default(bcl_icon_path()),
+ attributes: attributes,
+} only %}
diff --git a/components/description_list/description_list.component.yml b/components/description_list/description_list.component.yml
new file mode 100644
index 000000000..8ba4cd228
--- /dev/null
+++ b/components/description_list/description_list.component.yml
@@ -0,0 +1,81 @@
+name: 'Description list'
+description: 'Description lists are used to display and organise content that belongs to a category with a descriptions. Common use cases include glossary type lists or a list of speakers with their biographies.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the description list.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ items:
+ title: 'List items'
+ type: array
+ description: 'Each item is composed by one or more terms and one or more definitions. Each term entry can have an icon.'
+ items:
+ type: object
+ properties:
+ term:
+ title: 'Terms'
+ description: 'A string or an array of term objects.'
+ anyOf:
+ - type: string
+ - type: array
+ items:
+ type: object
+ properties:
+ label:
+ title: Label
+ type: [string, array, object]
+ description: 'Label text or render array.'
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ definition:
+ title: 'Definition'
+ description: 'A string or an array of definition objects.'
+ anyOf:
+ - type: string
+ - type: array
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ icon_path:
+ type: string
+ title: "Icons path"
+ description: "Path to icons"
+ orientation:
+ title: Orientation
+ type: string
+ description: 'Orientation of the list.'
+ enum:
+ - default
+ - horizontal
+ 'meta:enum':
+ default: Default
+ horizontal: Horizontal
+ bordered:
+ title: Bordered
+ type: boolean
+ description: 'Adds a border to the description list.'
+ col_classes:
+ title: 'Column classes'
+ type: string
+ description: 'Classes for the columns.'
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/description_list/description_list.story.twig b/components/description_list/description_list.story.twig
new file mode 100644
index 000000000..7da880ac0
--- /dev/null
+++ b/components/description_list/description_list.story.twig
@@ -0,0 +1,41 @@
+{% include 'oe_bootstrap_theme:description_list' with {
+ title: 'This is the title of this description list.',
+ items: [
+ {
+ term: [
+ {
+ label: 'Single label with icon',
+ icon: { name: 'geo-alt-fill' }
+ }
+ ],
+ definition: 'Description of first term text goes here.'
+ },
+ {
+ term: [
+ { label: 'First label without icon' },
+ { label: 'Second label', icon: { name: 'upload' } }
+ ],
+ definition: 'Description of second term text goes here.'
+ },
+ {
+ term: [
+ { label: [{ '#markup': 'A label with
some markup.' }] }
+ ],
+ definition: [
+ { label: 'First description of third term text goes here.' },
+ { label: 'Second description of third term text goes here.' }
+ ]
+ },
+ {
+ term: [
+ { label: 'Custom icon size', icon: { name: 'calendar-x', size: 'xl' } }
+ ],
+ definition: 'This is a bigger icon.'
+ },
+ {
+ term: 'A single term without icon.',
+ definition: 'Yet another definition text.'
+ }
+ ],
+ orientation: 'horizontal',
+} only %}
diff --git a/components/description_list/description_list.twig b/components/description_list/description_list.twig
new file mode 100644
index 000000000..0b664829b
--- /dev/null
+++ b/components/description_list/description_list.twig
@@ -0,0 +1,17 @@
+{#
+/**
+ * @file
+ * Description list component.
+ */
+#}
+{% include '@oe-bcl/description-list' with {
+ title: title,
+ title_link: title_link,
+ title_tag: title_tag|default('h2'),
+ title_attributes: title_attributes,
+ items: items|bcl_description_list_items(icon_path),
+ variant: orientation,
+ bordered: bordered,
+ col_classes: col_classes,
+ attributes: attributes,
+} only %}
diff --git a/components/dropdown/dropdown.component.yml b/components/dropdown/dropdown.component.yml
new file mode 100644
index 000000000..7c6c0420a
--- /dev/null
+++ b/components/dropdown/dropdown.component.yml
@@ -0,0 +1,168 @@
+name: Dropdown
+description: 'Toggle contextual overlays for displaying lists of links and actions.'
+props:
+ type: object
+ properties:
+ button_label:
+ title: 'Dropdown button label'
+ type: [string, array, object]
+ description: 'Label used by the default dropdown toggle.'
+ dropdown_id:
+ title: 'Dropdown ID'
+ type: string
+ description: 'ID connecting the toggle and menu. A unique ID is generated when omitted.'
+ trigger:
+ title: Trigger
+ type: object
+ description: 'Optional BCL button or link configuration for the dropdown toggle.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ size:
+ type: string
+ type:
+ type: string
+ outline:
+ type: boolean
+ disabled:
+ type: boolean
+ assistive_text:
+ type: string
+ standalone:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ attributes:
+ type: object
+ link:
+ title: 'Link trigger'
+ type: boolean
+ description: 'Render the dropdown toggle as a link instead of a button.'
+ direction:
+ title: Direction
+ type: string
+ description: 'Direction in which the menu opens.'
+ enum:
+ - dropup
+ - dropend
+ - dropstart
+ 'meta:enum':
+ dropup: Up
+ dropend: End
+ dropstart: Start
+ alignment:
+ title: Alignment
+ type: string
+ description: 'Dropdown menu alignment.'
+ enum:
+ - dropdown-menu-end
+ - dropdown-menu-sm-end
+ - dropdown-menu-md-end
+ - dropdown-menu-lg-end
+ - dropdown-menu-xl-end
+ - dropdown-menu-xxl-end
+ - dropdown-menu-start
+ - dropdown-menu-sm-start
+ - dropdown-menu-md-start
+ - dropdown-menu-lg-start
+ - dropdown-menu-xl-start
+ - dropdown-menu-xxl-start
+ 'meta:enum':
+ dropdown-menu-end: End
+ dropdown-menu-sm-end: 'End from sm'
+ dropdown-menu-md-end: 'End from md'
+ dropdown-menu-lg-end: 'End from lg'
+ dropdown-menu-xl-end: 'End from xl'
+ dropdown-menu-xxl-end: 'End from xxl'
+ dropdown-menu-start: Start
+ dropdown-menu-sm-start: 'Start from sm'
+ dropdown-menu-md-start: 'Start from md'
+ dropdown-menu-lg-start: 'Start from lg'
+ dropdown-menu-xl-start: 'Start from xl'
+ dropdown-menu-xxl-start: 'Start from xxl'
+ dark_mode:
+ title: 'Dark mode'
+ type: boolean
+ description: 'Use the dark dropdown menu style.'
+ remove_wrapper:
+ title: 'Remove wrapper'
+ type: boolean
+ description: 'Do not render the dropdown wrapper element.'
+ inside_navigation:
+ title: 'Inside navigation'
+ type: boolean
+ description: 'Render menu accessibility attributes for use inside navigation.'
+ items:
+ title: 'Dropdown items'
+ type: array
+ description: 'Links, buttons, and dividers displayed in the menu.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ button:
+ type: boolean
+ divider:
+ type: boolean
+ active:
+ type: boolean
+ disabled:
+ type: boolean
+ id:
+ type: string
+ title:
+ type: string
+ type:
+ type: string
+ variant:
+ type: string
+ size:
+ type: string
+ outline:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/dropdown/dropdown.story.twig b/components/dropdown/dropdown.story.twig
new file mode 100644
index 000000000..16f369e3b
--- /dev/null
+++ b/components/dropdown/dropdown.story.twig
@@ -0,0 +1,83 @@
+{% set items = [
+ {
+ label: 'A link',
+ path: '/example',
+ },
+ {
+ label: 'A button',
+ button: true,
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'A disabled button',
+ button: true,
+ disabled: true,
+ },
+ {
+ label: 'An active button',
+ button: true,
+ active: true,
+ },
+] %}
+
+
+
+
Default
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ button_label: 'Dropdown toggle',
+ items: items,
+ } only %}
+
+
+
+
Dark menu aligned to the end
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ button_label: 'Dark dropdown',
+ alignment: 'dropdown-menu-end',
+ dark_mode: true,
+ items: items,
+ } only %}
+
+
+
+
Dropup
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ button_label: 'Dropup toggle',
+ direction: 'dropup',
+ items: items,
+ } only %}
+
+
+
+
Dropend
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ button_label: 'Dropend toggle',
+ direction: 'dropend',
+ items: items,
+ } only %}
+
+
+
+
Dropstart
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ button_label: 'Dropstart toggle',
+ direction: 'dropstart',
+ items: items,
+ } only %}
+
+
+
+
Link trigger
+ {% include 'oe_bootstrap_theme:dropdown' with {
+ link: true,
+ trigger: {
+ label: 'Linked dropdown',
+ path: '/example',
+ icon: 'list',
+ },
+ items: items,
+ } only %}
+
+
diff --git a/components/dropdown/dropdown.twig b/components/dropdown/dropdown.twig
new file mode 100644
index 000000000..690b8fca1
--- /dev/null
+++ b/components/dropdown/dropdown.twig
@@ -0,0 +1,54 @@
+{#
+/**
+ * @file
+ * Dropdown component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _trigger = trigger|default({}) %}
+{% set _trigger = _trigger|merge({
+ label: _trigger.label|default(button_label|default('')),
+ attributes: create_attribute(_trigger.attributes|default({})),
+}) %}
+
+{% if _trigger.icon is defined and _trigger.icon is not empty %}
+ {% set _trigger_icon = _trigger.icon is iterable ? _trigger.icon : { name: _trigger.icon } %}
+ {% set _trigger = _trigger|merge({
+ icon: _trigger_icon|merge({
+ path: _trigger_icon.path|default(_icon_path),
+ attributes: create_attribute(_trigger_icon.attributes|default({})),
+ }),
+ }) %}
+{% endif %}
+
+{% set _items = [] %}
+{% for item in items|default([]) %}
+ {% set _item = item|merge({
+ attributes: create_attribute(item.attributes|default({})),
+ }) %}
+
+ {% if _item.icon is defined and _item.icon is not empty %}
+ {% set _item_icon = _item.icon is iterable ? _item.icon : { name: _item.icon } %}
+ {% set _item = _item|merge({
+ icon: _item_icon|merge({
+ path: _item_icon.path|default(_icon_path),
+ attributes: create_attribute(_item_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+
+ {% set _items = _items|merge([_item]) %}
+{% endfor %}
+
+{% include '@oe-bcl/dropdown' with {
+ id: dropdown_id|default('dropdown'|clean_unique_id),
+ trigger: _trigger,
+ link: link ?? false,
+ direction: direction,
+ alignment: alignment,
+ dark: dark_mode ?? false,
+ remove_wrapper: remove_wrapper ?? false,
+ inside_navigation: inside_navigation ?? false,
+ items: _items,
+ attributes: attributes,
+} only %}
diff --git a/components/fact_figures/fact_figures.component.yml b/components/fact_figures/fact_figures.component.yml
new file mode 100644
index 000000000..160270e52
--- /dev/null
+++ b/components/fact_figures/fact_figures.component.yml
@@ -0,0 +1,66 @@
+name: 'Fact and Figures'
+description: 'Fact and Figures pattern used for the paragraph of same type.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the facts and figures section.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ columns:
+ title: Columns
+ type: number
+ description: 'One, two or three columns layout for responsive. Defaults to 3.'
+ items:
+ title: Items
+ type: array
+ description: 'List of facts, with icon, title, subtitle and content.'
+ items:
+ type: object
+ properties:
+ icon:
+ title: Icon
+ description: 'Icon name or icon object.'
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ attributes:
+ type: object
+ title:
+ title: Title
+ type: [string, array, object]
+ subtitle:
+ title: Subtitle
+ type: [string, array, object]
+ description:
+ title: Description
+ type: [string, array, object]
+ link:
+ title: Link
+ type: object
+ description: 'Additional link to be displayed after facts.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to icons.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/fact_figures/fact_figures.story.twig b/components/fact_figures/fact_figures.story.twig
new file mode 100644
index 000000000..32b8dfadc
--- /dev/null
+++ b/components/fact_figures/fact_figures.story.twig
@@ -0,0 +1,80 @@
+{% set items = [
+ {
+ icon: 'sticky',
+ title: '1529 JIRA Ticket',
+ subtitle: 'Jira Tickets',
+ description: 'Nunc condimentum sapien ut nibh finibus suscipit vitae at justo. Morbi quis odio faucibus, commodo tortor id, elementum liber',
+ },
+ {
+ icon: 'inboxes',
+ title: '337 Features',
+ subtitle: 'Feature tickets',
+ description: 'Turpis varius congue venenatis, erat dui feugiat felis.',
+ },
+ {
+ icon: 'shield-check',
+ title: '107 Tests',
+ subtitle: 'Test tickets',
+ description: 'Cras vestibulum efficitur mi, quis porta tellus rutrum ut. Quisque at pulvinar sem.',
+ },
+ {
+ icon: 'file-earmark-code',
+ title: '5670 Variants',
+ subtitle: 'Test variants',
+ description: 'Aliquam lacinia diam eu sem malesuada, in interdum ante bibendum.',
+ },
+ {
+ icon: 'inbox',
+ title: '345 Dev Ticket',
+ subtitle: 'Jira ticket',
+ description: 'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis nec lectus tortor.',
+ },
+ {
+ icon: 'eye',
+ title: '43 Components',
+ subtitle: 'Figma components',
+ description: 'Sed efficitur bibendum rutrum. Nunc feugiat congue augue ac consectetur.',
+ },
+] %}
+
+
+
+
One column
+ {% include 'oe_bootstrap_theme:fact_figures' with {
+ title: 'Fact and figures block',
+ title_tag: 'h3',
+ columns: 1,
+ items: items,
+ link: {
+ label: 'Read more',
+ path: '/example',
+ },
+ } only %}
+
+
+
+
Two columns
+ {% include 'oe_bootstrap_theme:fact_figures' with {
+ title: 'Fact and figures block',
+ columns: 2,
+ items: items,
+ link: {
+ label: 'Read more',
+ path: '/example',
+ },
+ } only %}
+
+
+
+
Three columns
+ {% include 'oe_bootstrap_theme:fact_figures' with {
+ title: 'Fact and figures block',
+ columns: 3,
+ items: items,
+ link: {
+ label: 'Read more',
+ path: '/example',
+ },
+ } only %}
+
+
diff --git a/components/fact_figures/fact_figures.twig b/components/fact_figures/fact_figures.twig
new file mode 100644
index 000000000..3a0ad0abc
--- /dev/null
+++ b/components/fact_figures/fact_figures.twig
@@ -0,0 +1,30 @@
+{#
+/**
+ * @file
+ * Fact figures component.
+ */
+#}
+{% set processed_items = [] %}
+{% for item in items|default([]) %}
+ {% if item.icon is defined and item.icon is not empty and item.icon is not iterable %}
+ {% set item = item|merge({icon: {name: item.icon}}) %}
+ {% elseif item.icon is defined and item.icon is not empty %}
+ {% set item = item|merge({
+ icon: item.icon|merge({
+ attributes: create_attribute(item.icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+ {% set processed_items = processed_items|merge([item]) %}
+{% endfor %}
+
+{% include '@oe-bcl/bcl-fact-figures/fact-figures.html.twig' with {
+ title: title,
+ title_tag: title_tag|default('h2'),
+ items: processed_items,
+ responsive_columns: columns|default(3),
+ equal_height: true,
+ icon_path: icon_path|default(bcl_icon_path()),
+ link_more: link,
+ attributes: attributes,
+} only %}
diff --git a/components/featured_media/featured_media.component.yml b/components/featured_media/featured_media.component.yml
new file mode 100644
index 000000000..93c448dc4
--- /dev/null
+++ b/components/featured_media/featured_media.component.yml
@@ -0,0 +1,227 @@
+name: 'Featured media'
+description: 'Presents an image, embedded media or video, optionally alongside descriptive content.'
+props:
+ type: object
+ properties:
+ caption:
+ title: Caption
+ type: [string, array, object]
+ description: 'Caption displayed below the media.'
+ embedded_media:
+ title: 'Embedded media'
+ type: [string, array, object]
+ description: 'Embedded media markup or render array.'
+ image:
+ title: Image
+ type: [string, array, object]
+ description: 'Rendered image or an object containing image attributes.'
+ properties:
+ src:
+ type: string
+ alt:
+ type: string
+ name:
+ type: string
+ title:
+ type: string
+ responsive:
+ type: boolean
+ loading:
+ type: string
+ width:
+ type: [string, integer]
+ height:
+ type: [string, integer]
+ class:
+ type: [string, array, object]
+ poster_image:
+ title: 'Poster image'
+ type: string
+ description: 'Poster image URL for native video.'
+ sources:
+ title: 'Video sources'
+ type: array
+ description: 'Source files for native video.'
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ src:
+ type: string
+ tracks:
+ title: 'Video tracks'
+ type: array
+ description: 'Caption or subtitle tracks for native video.'
+ items:
+ type: object
+ properties:
+ kind:
+ type: string
+ label:
+ type: string
+ src:
+ type: string
+ src_lang:
+ type: string
+ ratio:
+ title: Ratio
+ type: string
+ description: 'Aspect ratio for embedded media. Defaults to 16x9.'
+ enum:
+ - 1x1
+ - 4x3
+ - 16x9
+ - 21x9
+ 'meta:enum':
+ 1x1: '1:1'
+ 4x3: '4:3'
+ 16x9: '16:9 (default)'
+ 21x9: '21:9'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title displayed above the featured media.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'Tag used for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Link applied to the title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ subtitle:
+ title: Subtitle
+ type: [string, array, object]
+ description: 'Subtitle displayed before the text.'
+ subtitle_tag:
+ title: 'Subtitle tag'
+ type: string
+ description: 'Tag used for the subtitle. Defaults to h3 with a title and h2 without one.'
+ text:
+ title: Text
+ type: [string, array, object]
+ description: 'Content displayed alongside the media.'
+ text_position:
+ title: 'Text position'
+ type: string
+ description: 'Position of the text on medium and larger viewports. Defaults to left.'
+ enum:
+ - left
+ - right
+ 'meta:enum':
+ left: 'Left (default)'
+ right: Right
+ link:
+ title: Link
+ type: object
+ description: 'Optional link displayed after the text.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ copyright:
+ title: Copyright
+ type: [string, array, object]
+ description: 'Optional copyright text displayed below the media.'
+ copyright_label:
+ title: 'Copyright label'
+ type: string
+ description: 'Visually hidden label for copyright text.'
+ content_classes:
+ title: 'Caption classes'
+ type: string
+ description: 'Classes applied to the caption. Defaults to bg-light p-3.'
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to icons used by links. Defaults to the BCL icon path.'
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ subtitle_attributes:
+ title: 'Subtitle attributes'
+ type: Drupal\Core\Template\Attribute
+ media_attributes:
+ title: 'Media attributes'
+ type: Drupal\Core\Template\Attribute
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/featured_media/featured_media.story.twig b/components/featured_media/featured_media.story.twig
new file mode 100644
index 000000000..812131f84
--- /dev/null
+++ b/components/featured_media/featured_media.story.twig
@@ -0,0 +1,67 @@
+{% set image = {
+ src: 'https://picsum.photos/1200/600/',
+ alt: 'A representative landscape',
+ title: 'Featured landscape',
+ loading: 'lazy',
+} %}
+
+
+
+
Image with caption
+ {% include 'oe_bootstrap_theme:featured_media' with {
+ image: image,
+ caption: 'Media description text goes here.',
+ copyright: '© 2026 Example Organisation',
+ } only %}
+
+
+ {% include 'oe_bootstrap_theme:featured_media' with {
+ image: image,
+ title: 'Featured image with text',
+ title_tag: 'h3',
+ title_link: {
+ path: '/example',
+ },
+ subtitle: 'Supporting subtitle',
+ text: 'This content appears to the left of the featured image and provides additional context.',
+ text_position: 'left',
+ link: {
+ label: 'Read more',
+ path: '/example',
+ standalone: true,
+ icon: 'arrow-right',
+ },
+ caption: 'Media description text goes here.',
+ } only %}
+
+ {% include 'oe_bootstrap_theme:featured_media' with {
+ embedded_media: {
+ '#type': 'html_tag',
+ '#tag': 'iframe',
+ '#attributes': {
+ src: 'https://www.youtube.com/embed/nWpgO1EPO_Y',
+ title: 'Featured video',
+ },
+ },
+ title: 'Embedded media with text',
+ subtitle: 'Media on the left',
+ text: 'This example uses a wider aspect ratio and positions its descriptive content on the right.',
+ text_position: 'right',
+ ratio: '21x9',
+ caption: 'Embedded media description.',
+ } only %}
+
+
+
Native video
+ {% include 'oe_bootstrap_theme:featured_media' with {
+ poster_image: 'https://picsum.photos/1200/675/',
+ sources: [
+ {
+ type: 'video/mp4',
+ src: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
+ },
+ ],
+ caption: 'Native video with a poster image.',
+ } only %}
+
+
diff --git a/components/featured_media/featured_media.twig b/components/featured_media/featured_media.twig
new file mode 100644
index 000000000..fa4890955
--- /dev/null
+++ b/components/featured_media/featured_media.twig
@@ -0,0 +1,124 @@
+{#
+/**
+ * @file
+ * Featured media component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _image = image|default('') %}
+{% set _link = link|default({}) %}
+{% set _title_link = title_link|default({}) %}
+
+{% if _image is not empty and _image.src is defined %}
+ {% set _image_attributes = create_attribute({
+ src: _image.src,
+ alt: _image.alt|default(''),
+ }).addClass('flex-end-100') %}
+ {% if _image.responsive ?? true %}
+ {% set _image_attributes = _image_attributes.addClass('img-fluid') %}
+ {% endif %}
+ {% if _image.class is defined %}
+ {% set _image_attributes = _image_attributes.addClass(_image.class) %}
+ {% endif %}
+ {% if _image.title|default(_image.name|default('')) is not empty %}
+ {% set _image_attributes = _image_attributes.setAttribute('title', _image.title|default(_image.name)) %}
+ {% endif %}
+ {% for attribute in ['loading', 'width', 'height'] %}
+ {% if attribute(_image, attribute) is defined and attribute(_image, attribute) is not empty %}
+ {% set _image_attributes = _image_attributes.setAttribute(attribute, attribute(_image, attribute)) %}
+ {% endif %}
+ {% endfor %}
+ {% set _image %}
+
+ {% endset %}
+{% endif %}
+
+{% if _link is not empty %}
+ {% if _link.attributes is defined %}
+ {% set _link = _link|merge({attributes: create_attribute(_link.attributes)}) %}
+ {% endif %}
+ {% if _link.icon is defined and _link.icon is not empty %}
+ {% set _link_icon = _link.icon is iterable ? _link.icon : {name: _link.icon} %}
+ {% if _link_icon.attributes is defined %}
+ {% set _link_icon = _link_icon|merge({attributes: create_attribute(_link_icon.attributes)}) %}
+ {% endif %}
+ {% set _link = _link|merge({
+ icon: _link_icon|merge({
+ path: _link_icon.path|default(_icon_path),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% if _title_link is not empty %}
+ {% if _title_link.attributes is defined %}
+ {% set _title_link = _title_link|merge({attributes: create_attribute(_title_link.attributes)}) %}
+ {% endif %}
+ {% if _title_link.icon is defined and _title_link.icon is not empty %}
+ {% set _title_link_icon = _title_link.icon is iterable ? _title_link.icon : {name: _title_link.icon} %}
+ {% if _title_link_icon.attributes is defined %}
+ {% set _title_link_icon = _title_link_icon|merge({attributes: create_attribute(_title_link_icon.attributes)}) %}
+ {% endif %}
+ {% set _title_link = _title_link|merge({
+ icon: _title_link_icon|merge({
+ path: _title_link_icon.path|default(_icon_path),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _media %}
+ {% include '@oe-bcl/featured-media' with {
+ embedded_media: embedded_media,
+ image: _image,
+ poster_image: poster_image,
+ sources: sources,
+ tracks: tracks,
+ content: caption,
+ content_classes: content_classes|default('bg-light p-3'),
+ ratio: ratio|default('16x9'),
+ copyright: copyright,
+ copyright_label: copyright_label|default(_image is not empty ? 'Image credit:'|t : 'Copyright:'|t),
+ attributes: media_attributes,
+ } only %}
+{% endset %}
+
+{% set _has_text = subtitle is not empty or text is not empty or _link is not empty %}
+
+ {% if _has_text %}
+ {% set _text_order = text_position|default('left') == 'right' ? 'order-2' : 'order-1' %}
+ {% set _media_order = text_position|default('left') == 'right' ? 'order-1' : 'order-2' %}
+
+ {% if title is not empty %}
+ {% include '@oe-bcl/heading' with {
+ title: title,
+ title_tag: title_tag|default('h2'),
+ title_link: _title_link,
+ attributes: title_attributes|default(create_attribute()).addClass('mb-4'),
+ } only %}
+ {% endif %}
+
+
+ {% if subtitle is not empty %}
+ {% set _subtitle_tag = subtitle_tag|default(title is not empty ? 'h3' : 'h2') %}
+ <{{ _subtitle_tag }}{{ subtitle_attributes|default(create_attribute()).addClass('text-secondary') }}>
+ {{- subtitle -}}
+ {{ _subtitle_tag }}>
+ {% endif %}
+ {{- text -}}
+ {% if _link is not empty %}
+ {% include '@oe-bcl/link' with _link only %}
+ {% endif %}
+
+
+ {{- _media -}}
+
+
+ {% else %}
+
+ {% endif %}
+
diff --git a/components/file/file.component.yml b/components/file/file.component.yml
new file mode 100644
index 000000000..51d47c6ca
--- /dev/null
+++ b/components/file/file.component.yml
@@ -0,0 +1,63 @@
+name: File
+description: 'Presents a file available for download, optionally with translations.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title displayed above the file.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'Tag used for the title. Defaults to h2.'
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ link_label:
+ title: 'Download link label'
+ type: string
+ description: 'Label used for the download links. Defaults to Download.'
+ file:
+ title: File
+ type: object
+ description: 'File presented for download.'
+ properties:
+ title:
+ type: string
+ url:
+ type: string
+ size:
+ type: [string, integer]
+ extension:
+ type: string
+ language_code:
+ type: string
+ meta:
+ type: string
+ translations:
+ title: Translations
+ type: array
+ description: 'List of translations of the file.'
+ items:
+ type: object
+ properties:
+ title:
+ type: string
+ url:
+ type: string
+ size:
+ type: [string, integer]
+ extension:
+ type: string
+ language_code:
+ type: string
+ meta:
+ type: string
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/file/file.story.twig b/components/file/file.story.twig
new file mode 100644
index 000000000..9f6e7e31f
--- /dev/null
+++ b/components/file/file.story.twig
@@ -0,0 +1,49 @@
+{% set file = {
+ title: 'Example document',
+ url: 'https://example.com/document.pdf',
+ size: 4444,
+ extension: 'pdf',
+ language_code: 'en',
+} %}
+
+
+
+
Default
+ {% include 'oe_bootstrap_theme:file' with {
+ title: 'Download the document',
+ file: file,
+ } only %}
+
+
+
+
With translations
+ {% include 'oe_bootstrap_theme:file' with {
+ title: 'Download the document',
+ file: file,
+ translations: [
+ {
+ title: 'Exemple de document',
+ url: 'https://example.com/document-fr.pdf',
+ size: 4321,
+ extension: 'pdf',
+ language_code: 'fr',
+ },
+ {
+ title: 'Пример документ',
+ url: 'https://example.com/document-bg.docx',
+ size: 40391,
+ extension: 'docx',
+ language_code: 'bg',
+ },
+ ],
+ } only %}
+
+
+
+
Custom link label, without title
+ {% include 'oe_bootstrap_theme:file' with {
+ link_label: 'Get the file',
+ file: file,
+ } only %}
+
+
diff --git a/components/file/file.twig b/components/file/file.twig
new file mode 100644
index 000000000..2baa4d9f4
--- /dev/null
+++ b/components/file/file.twig
@@ -0,0 +1,81 @@
+{#
+/**
+ * @file
+ * File component.
+ */
+#}
+{%- set _icon_path = icon_path|default(bcl_icon_path()) -%}
+{%- set _link_label = link_label|default('Download'|t) -%}
+
+{%- set _translations = {} -%}
+{%- if translations is not empty and translations is iterable -%}
+ {%- set _items = [] -%}
+ {%- for translation in translations -%}
+ {%- set _translation_meta = translation.meta|default('') -%}
+ {%- if _translation_meta is empty -%}
+ {%- set _translation_meta = '(' ~ translation.extension|upper ~ ')' -%}
+ {%- if translation.size -%}
+ {%- set _translation_meta = '(' ~ translation.size|format_size ~ ' - ' ~ translation.extension|upper ~ ')' -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- set _items = _items|merge([{
+ title: translation.title,
+ language: translation.language_code is not empty ? translation.language_code|to_native_language : '',
+ meta: _translation_meta,
+ download: {
+ label: _link_label,
+ path: translation.url,
+ },
+ }]) -%}
+ {%- endfor -%}
+ {%- set _translations = {
+ label: {
+ path: _icon_path,
+ label: 'Other languages (@count)'|t({'@count': _items|length}),
+ icon_position: 'after',
+ icon: {
+ name: 'caret-down-fill',
+ size: '2xs',
+ path: _icon_path,
+ },
+ },
+ items: _items,
+ } -%}
+{%- endif -%}
+
+{%- if file is not empty -%}
+ {%- set _meta = file.meta|default('') -%}
+ {%- if _meta is empty -%}
+ {%- set _meta = '(' ~ file.extension|upper ~ ')' -%}
+ {%- if file.size -%}
+ {%- set _meta = '(' ~ file.size|format_size ~ ' - ' ~ file.extension|upper ~ ')' -%}
+ {%- endif -%}
+ {%- endif -%}
+
+ {%- include '@oe-bcl/file' with {
+ title: title,
+ title_tag: title_tag|default('h2'),
+ title_attributes: title_attributes,
+ item_title: file.title,
+ language: file.language_code is not empty ? file.language_code|to_native_language : '',
+ meta: _meta,
+ icon: {
+ path: _icon_path,
+ name: file.extension|to_file_icon,
+ size: '2xl',
+ },
+ download: {
+ label: _link_label,
+ path: file.url,
+ icon: {
+ path: _icon_path,
+ name: 'download',
+ size: 'fluid',
+ },
+ },
+ translation: _translations,
+ icon_path: _icon_path,
+ attributes: attributes,
+ } only -%}
+
+{%- endif -%}
diff --git a/components/gallery/gallery.component.yml b/components/gallery/gallery.component.yml
new file mode 100644
index 000000000..f3a79c282
--- /dev/null
+++ b/components/gallery/gallery.component.yml
@@ -0,0 +1,203 @@
+name: Gallery
+description: 'A collection of images and videos displayed as thumbnails and in a modal carousel.'
+props:
+ type: object
+ properties:
+ gallery_id:
+ title: 'Gallery ID'
+ type: string
+ description: 'Unique ID used to connect the gallery, modal and carousel.'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the gallery.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Optional link applied to the gallery title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ 'meta:enum':
+ primary: Primary
+ secondary: Secondary
+ success: Success
+ danger: Danger
+ warning: Warning
+ info: Info
+ light: Light
+ dark: Dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ counter:
+ title: Counter
+ type: string
+ description: 'Counter label. The %d placeholder is replaced by the number of items.'
+ collapse_button_label:
+ title: 'Collapsed label'
+ type: string
+ description: 'Label used to reveal hidden thumbnails. Defaults to View the full gallery (%d).'
+ expand_button_label:
+ title: 'Expanded label'
+ type: string
+ description: 'Label used to hide expanded thumbnails. Defaults to View less.'
+ previous_label:
+ title: 'Previous label'
+ type: string
+ description: 'Accessible label for the previous carousel control. Defaults to Previous.'
+ next_label:
+ title: 'Next label'
+ type: string
+ description: 'Accessible label for the next carousel control. Defaults to Next.'
+ max_visible_thumbnails:
+ title: 'Maximum visible thumbnails'
+ type: integer
+ description: 'Number of thumbnails visible before the gallery is collapsed. Defaults to 5.'
+ items:
+ title: Items
+ type: array
+ description: 'Images and playable media displayed in the gallery.'
+ items:
+ type: object
+ properties:
+ thumbnail:
+ title: Thumbnail
+ type: [string, array, object]
+ media:
+ title: Media
+ type: [string, array, object]
+ caption_title:
+ title: 'Caption title'
+ type: [string, array, object]
+ caption:
+ title: Caption
+ type: [string, array, object]
+ caption_classes:
+ title: 'Caption classes'
+ type: string
+ copyright:
+ title: Copyright
+ type: [string, array, object]
+ copyright_label:
+ title: 'Copyright label'
+ type: string
+ interval:
+ title: Interval
+ type: integer
+ link:
+ title: Link
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ 'meta:enum':
+ primary: Primary
+ secondary: Secondary
+ success: Success
+ danger: Danger
+ warning: Warning
+ info: Info
+ light: Light
+ dark: Dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/gallery/gallery.story.twig b/components/gallery/gallery.story.twig
new file mode 100644
index 000000000..79ebeaf1c
--- /dev/null
+++ b/components/gallery/gallery.story.twig
@@ -0,0 +1,108 @@
+{% set gallery_items = [
+ {
+ media: {
+ '#markup': '
'
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Public architecture',
+ caption: 'A contemporary public building designed around an open civic space.',
+ copyright: '© 2026 Example Organisation'
+ },
+ {
+ media: {
+ '#markup': '
',
+ '#allowed_tags': ['video', 'source']
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Programme overview',
+ caption: 'A short introduction to the programme.',
+ copyright: '© 2026 Example Organisation'
+ },
+ {
+ media: {
+ '#markup': '
'
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Connected communities',
+ caption: 'People meeting in a shared urban space.',
+ link: {
+ label: 'Read the story',
+ path: 'https://example.com/community',
+ icon_position: 'after',
+ icon: {
+ name: 'chevron-right'
+ }
+ }
+ },
+ {
+ media: {
+ '#markup': '
VIDEO ',
+ '#allowed_tags': ['iframe']
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Safer electric vehicles',
+ caption: 'Harmonised test procedures for battery technology.'
+ },
+ {
+ media: {
+ '#markup': '
'
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Coastal regions',
+ caption: 'Communities adapting to environmental change.'
+ },
+ {
+ media: {
+ '#markup': '
'
+ },
+ thumbnail: {
+ '#markup': '
'
+ },
+ caption_title: 'Clean energy',
+ caption: 'Renewable energy infrastructure supporting the green transition.'
+ }
+] %}
+
+
+
+
Compact gallery
+ {% include 'oe_bootstrap_theme:gallery' with {
+ title: 'Featured media',
+ items: gallery_items|slice(0, 4),
+ } only %}
+
+
+
+
Mixed media gallery
+ {% include 'oe_bootstrap_theme:gallery' with {
+ gallery_id: 'gallery-story-mixed-media',
+ title: 'Projects across Europe',
+ title_tag: 'h4',
+ title_link: {
+ path: 'https://example.com/projects',
+ attributes: {
+ class: ['custom-gallery-title-link']
+ }
+ },
+ title_attributes: create_attribute().addClass('custom-gallery-title'),
+ counter: '%d media items',
+ collapse_button_label: 'Show all %d items',
+ expand_button_label: 'Show fewer items',
+ previous_label: 'Previous media',
+ next_label: 'Next media',
+ max_visible_thumbnails: 3,
+ items: gallery_items,
+ attributes: create_attribute().addClass('custom-gallery'),
+ } only %}
+
+
diff --git a/components/gallery/gallery.twig b/components/gallery/gallery.twig
new file mode 100644
index 000000000..c02a1ca0e
--- /dev/null
+++ b/components/gallery/gallery.twig
@@ -0,0 +1,69 @@
+{#
+/**
+ * @file
+ * Gallery component.
+ */
+#}
+{% extends '@oe-bcl/gallery' %}
+
+{% set id = gallery_id|default('gallery'|clean_unique_id) %}
+{% set title_tag = title_tag|default('h2') %}
+{% set toggle_collapsed = collapse_button_label|default('View the full gallery (%d)'|t) %}
+{% set toggle_expanded = expand_button_label|default('View less'|t) %}
+{% set next_label = next_label|default('Next'|t) %}
+{% set prev_label = previous_label|default('Previous'|t) %}
+{% set max_visible_thumbnails = max_visible_thumbnails|default(5) %}
+{% set icon_path = icon_path|default(bcl_icon_path()) %}
+
+{% set _title_link = title_link|default({}) %}
+{% if _title_link is not empty %}
+ {% set _title_link = _title_link|merge({
+ attributes: create_attribute(_title_link.attributes|default({})),
+ }) %}
+ {% if _title_link.icon is defined and _title_link.icon is not empty %}
+ {% set _title_link_icon = _title_link.icon|merge({
+ path: _title_link.icon.path|default(icon_path),
+ attributes: create_attribute(_title_link.icon.attributes|default({})),
+ }) %}
+ {% set _title_link = _title_link|merge({icon: _title_link_icon}) %}
+ {% endif %}
+{% endif %}
+{% set title_link = _title_link %}
+
+{% set _gallery_items = [] %}
+{% for item in items|default([]) %}
+ {% set _item = item %}
+ {% if _item.link is defined and _item.link is not empty %}
+ {% set _item_link = _item.link|merge({
+ attributes: create_attribute(_item.link.attributes|default({})),
+ }) %}
+ {% if _item_link.icon is defined and _item_link.icon is not empty %}
+ {% set _item_link_icon = _item_link.icon|merge({
+ path: _item_link.icon.path|default(icon_path),
+ attributes: create_attribute(_item_link.icon.attributes|default({})),
+ }) %}
+ {% set _item_link = _item_link|merge({icon: _item_link_icon}) %}
+ {% endif %}
+ {% set _item = _item|merge({link: _item_link}) %}
+ {% endif %}
+ {% set _gallery_items = _gallery_items|merge([_item]) %}
+{% endfor %}
+{% set items = bcl_gallery_items(_gallery_items) %}
+
+{% block carousel %}
+ {# Captions are not rendered for playable media in the carousel. #}
+ {% include '@oe-bcl/carousel' with {
+ id: _carousel_id,
+ with_controls: true,
+ with_indicators: false,
+ prev_label: _prev_label,
+ next_label: _next_label,
+ autoplay: false,
+ items: items|map(_item => _item|merge({
+ caption: _item.is_playable ? '' : _item.caption,
+ caption_title: _item.is_playable ? '' : _item.caption_title,
+ link: _item.is_playable ? {} : _item.link,
+ copyright_label: _item.copyright_label|default(_item.is_playable ? 'Copyright:'|t : 'Image credit:'|t),
+ })),
+ } only %}
+{% endblock %}
diff --git a/components/icon/icon.component.yml b/components/icon/icon.component.yml
new file mode 100644
index 000000000..9e315b453
--- /dev/null
+++ b/components/icon/icon.component.yml
@@ -0,0 +1,54 @@
+name: Icon
+description: 'Displays an icon from an SVG sprite.'
+props:
+ type: object
+ properties:
+ name:
+ title: Name
+ type: string
+ description: 'Name of the icon in the SVG sprite.'
+ size:
+ title: Size
+ type: string
+ description: 'Size of the icon. Defaults to s.'
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ 'meta:enum':
+ 2xs: '2XS'
+ xs: XS
+ s: 'S (default)'
+ m: M
+ l: L
+ xl: XL
+ 2xl: '2XL'
+ fluid: Fluid
+ transformation:
+ title: Transformation
+ type: string
+ description: 'Optional rotation or flip transformation.'
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ 'meta:enum':
+ rotate-90: 'Rotate 90 degrees'
+ rotate-180: 'Rotate 180 degrees'
+ rotate-270: 'Rotate 270 degrees'
+ flip-horizontal: 'Flip horizontally'
+ flip-vertical: 'Flip vertically'
+ icon_path:
+ title: 'Icon path'
+ type: string
+ description: 'Path to the SVG sprite. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/icon/icon.story.twig b/components/icon/icon.story.twig
new file mode 100644
index 000000000..df47a10a1
--- /dev/null
+++ b/components/icon/icon.story.twig
@@ -0,0 +1,73 @@
+{% set sizes = ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', 'fluid'] %}
+{% set transformations = [
+ { value: 'rotate-90', label: 'Rotate 90 degrees' },
+ { value: 'rotate-180', label: 'Rotate 180 degrees' },
+ { value: 'rotate-270', label: 'Rotate 270 degrees' },
+ { value: 'flip-horizontal', label: 'Flip horizontally' },
+ { value: 'flip-vertical', label: 'Flip vertically' },
+] %}
+
+
+
+
+
+
Sizes
+
+ {% for size in sizes %}
+
+
+ {% include 'oe_bootstrap_theme:icon' with {
+ name: 'eye',
+ size: size,
+ attributes: create_attribute().addClass('text-primary').setAttribute('aria-hidden', 'true'),
+ } only %}
+
+
{{ size }}
+
+ {% endfor %}
+
+
+
+
+
Transformations
+
+ {% for transformation in transformations %}
+
+
+ {% include 'oe_bootstrap_theme:icon' with {
+ name: 'arrow-right',
+ size: 'xl',
+ transformation: transformation.value,
+ attributes: create_attribute().addClass('text-secondary').setAttribute('aria-hidden', 'true'),
+ } only %}
+
+
{{ transformation.label }}
+
+ {% endfor %}
+
+
+
+
+
Custom attributes and path
+ {% include 'oe_bootstrap_theme:icon' with {
+ name: 'award',
+ size: '2xl',
+ icon_path: bcl_icon_path(),
+ attributes: create_attribute({
+ id: 'custom-icon',
+ class: ['text-success', 'custom-icon-class'],
+ role: 'img',
+ 'aria-label': 'Award',
+ }),
+ } only %}
+
+
diff --git a/components/icon/icon.twig b/components/icon/icon.twig
new file mode 100644
index 000000000..8da86f5db
--- /dev/null
+++ b/components/icon/icon.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Icon component.
+ */
+#}
+{% include '@oe-bcl/icon' with {
+ name: name,
+ path: icon_path|default(bcl_icon_path()),
+ size: size|default('s'),
+ transformation: transformation,
+ attributes: attributes,
+} only %}
diff --git a/components/inpage_navigation/inpage_navigation.component.yml b/components/inpage_navigation/inpage_navigation.component.yml
new file mode 100644
index 000000000..553785dbf
--- /dev/null
+++ b/components/inpage_navigation/inpage_navigation.component.yml
@@ -0,0 +1,177 @@
+name: 'Inpage navigation'
+description: 'Provides a sticky table of contents alongside page content and updates the active item while scrolling.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the navigation.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'HTML tag used for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Optional link applied to the title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ links:
+ title: Links
+ type: array
+ description: 'Links to headings in the page content.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ content:
+ title: Content
+ type: [string, array, object]
+ description: 'Page content displayed alongside the navigation.'
+ id:
+ title: ID
+ type: string
+ description: 'Navigation ID. A unique ID is generated when omitted.'
+ dropdown_id:
+ title: 'Dropdown ID'
+ type: string
+ description: 'Mobile dropdown trigger ID. Defaults to the navigation ID followed by -dropdown.'
+ dynamic_active:
+ title: 'Dynamic active'
+ type: boolean
+ description: 'Automatically controls the active item on mobile. Defaults to false.'
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: 'Navigation attributes'
+ type: Drupal\Core\Template\Attribute
diff --git a/components/inpage_navigation/inpage_navigation.story.twig b/components/inpage_navigation/inpage_navigation.story.twig
new file mode 100644
index 000000000..ce402dc61
--- /dev/null
+++ b/components/inpage_navigation/inpage_navigation.story.twig
@@ -0,0 +1,23 @@
+{% set default_links = [
+ { label: 'Overview', path: '#overview' },
+ { label: 'Eligibility', path: '#eligibility' },
+ { label: 'Apply', path: '#how-to-apply' },
+ { label: 'Support', path: '#support' },
+] %}
+{% set default_content %}
+
Overview
+
This funding programme supports projects that strengthen cooperation and create measurable benefits across Europe.
+
Eligibility
+
Public authorities, non-profit organisations, and research institutions established in eligible countries can participate.
+
How to apply
+
Prepare the required documents, complete the application form, and submit the proposal before the published deadline.
+
Support
+
Contact the programme helpdesk for guidance about eligibility, documentation, or the submission process.
+{% endset %}
+
+{% include 'oe_bootstrap_theme:inpage_navigation' with {
+ title: 'Contents',
+ links: default_links,
+ content: default_content,
+ dynamic_active: true,
+} only %}
diff --git a/components/inpage_navigation/inpage_navigation.twig b/components/inpage_navigation/inpage_navigation.twig
new file mode 100644
index 000000000..3c99fbd1d
--- /dev/null
+++ b/components/inpage_navigation/inpage_navigation.twig
@@ -0,0 +1,67 @@
+{#
+/**
+ * @file
+ * Inpage navigation component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _id = id|default(_attributes.id|default('bcl-inpage-navigation'|clean_unique_id)) %}
+{% set _dropdown_id = dropdown_id|default(_id ~ '-dropdown') %}
+{% set _title_link = title_link|default({}) %}
+
+{% if _title_link is not empty %}
+ {% set _title_link = _title_link|merge({
+ attributes: create_attribute(_title_link.attributes|default({})),
+ }) %}
+ {% if _title_link.icon is defined and _title_link.icon is not empty %}
+ {% set _title_icon = _title_link.icon is iterable ? _title_link.icon : { name: _title_link.icon } %}
+ {% set _title_link = _title_link|merge({
+ icon: _title_icon|merge({
+ path: _title_icon.path|default(_icon_path),
+ attributes: create_attribute(_title_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _links = [] %}
+{% for link in links|default([]) %}
+ {% set _link = link|merge({
+ attributes: create_attribute(link.attributes|default({})),
+ }) %}
+ {% if _link.icon is defined and _link.icon is not empty %}
+ {% set _link_icon = _link.icon is iterable ? _link.icon : { name: _link.icon } %}
+ {% set _link = _link|merge({
+ icon: _link_icon|merge({
+ path: _link_icon.path|default(_icon_path),
+ attributes: create_attribute(_link_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+ {% set _links = _links|merge([_link]) %}
+{% endfor %}
+
+{% if _links is not empty %}
+ {{ attach_library('oe_bootstrap_theme/inpage_navigation') }}
+{% endif %}
+
+
+
+
+ {{- content -}}
+
+
diff --git a/components/link/link.component.yml b/components/link/link.component.yml
new file mode 100644
index 000000000..f58936af6
--- /dev/null
+++ b/components/link/link.component.yml
@@ -0,0 +1,106 @@
+name: Link
+description: 'A link with optional color, icon, disabled and standalone styles.'
+props:
+ type: object
+ properties:
+ label:
+ title: Label
+ type: [string, array, object]
+ description: 'Link label text or render array.'
+ path:
+ title: Path
+ type: [string, object]
+ description: 'Link URL string or URL object. Defaults to #.'
+ variant:
+ title: Variant
+ type: string
+ description: 'Link color variant. Defaults to the standard link style.'
+ enum:
+ - ''
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ 'meta:enum':
+ '': Default
+ primary: Primary
+ secondary: Secondary
+ success: Success
+ danger: Danger
+ warning: Warning
+ info: Info
+ light: Light
+ dark: Dark
+ id:
+ title: ID
+ type: string
+ description: 'Link ID attribute.'
+ disabled:
+ title: Disabled
+ type: boolean
+ description: 'Disables keyboard and pointer interaction. Defaults to false.'
+ standalone:
+ title: Standalone
+ type: boolean
+ description: 'Applies standalone link styling. Defaults to false.'
+ icon:
+ title: Icon
+ type: [string, object]
+ description: 'Icon name as a string or an icon object.'
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ title: 'Icon position'
+ type: string
+ description: 'Position of the icon relative to the label. Defaults to after.'
+ enum:
+ - before
+ - after
+ 'meta:enum':
+ before: Before
+ after: 'After (default)'
+ icon_display:
+ title: 'Icon display'
+ type: string
+ description: 'Uses flexbox gap spacing between the icon and label.'
+ enum:
+ - flex
+ - inline-flex
+ 'meta:enum':
+ flex: Flex
+ inline-flex: 'Inline flex'
+ remove_icon_spacers:
+ title: 'Remove icon spacers'
+ type: boolean
+ description: 'Removes spacing between the icon and label. Defaults to false.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/link/link.story.twig b/components/link/link.story.twig
new file mode 100644
index 000000000..4a108bcfb
--- /dev/null
+++ b/components/link/link.story.twig
@@ -0,0 +1,115 @@
+{% set variants = [
+ 'primary',
+ 'secondary',
+ 'success',
+ 'danger',
+ 'warning',
+ 'info',
+ 'light',
+ 'dark',
+] %}
+
+
+
+
Variants
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Default',
+ path: '/example',
+ } only %}
+
+ {% for variant in variants %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: variant|capitalize,
+ path: '/example',
+ variant: variant,
+ } only %}
+
+ {% endfor %}
+
+
+
+
+
Icons
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Icon before',
+ path: '/example',
+ icon: 'arrow-left',
+ icon_position: 'before',
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Icon after',
+ path: '/example',
+ icon: 'arrow-right',
+ icon_position: 'after',
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Inline flex',
+ path: '/example',
+ icon: 'box-arrow-up-right',
+ icon_display: 'inline-flex',
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Flex',
+ path: '/example',
+ icon: 'download',
+ icon_position: 'before',
+ icon_display: 'flex',
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'No icon spacer',
+ path: '/example',
+ icon: 'arrow-right',
+ remove_icon_spacers: true,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ path: '/search',
+ icon: 'search',
+ attributes: create_attribute().setAttribute('aria-label', 'Search'),
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Transformed custom icon',
+ path: '/example',
+ icon: {
+ name: 'arrow-right',
+ path: bcl_icon_path(),
+ size: 'xl',
+ transformation: 'rotate-90',
+ attributes: {
+ class: ['text-success'],
+ 'aria-hidden': 'true',
+ },
+ },
+ } only %}
+
+
+
+
+
States and attributes
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: 'Disabled standalone link',
+ path: '/example',
+ disabled: true,
+ standalone: true,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:link' with {
+ label: [{ '#markup': 'Label with markup ' }],
+ path: '/example',
+ id: 'custom-link',
+ attributes: create_attribute()
+ .addClass('custom-link-class')
+ .setAttribute('data-link-attribute', 'custom-value'),
+ } only %}
+
+
+
diff --git a/components/link/link.twig b/components/link/link.twig
new file mode 100644
index 000000000..afbd9ca50
--- /dev/null
+++ b/components/link/link.twig
@@ -0,0 +1,30 @@
+{#
+/**
+ * @file
+ * Link component.
+ */
+#}
+{% set _icon = {} %}
+{% if icon is defined and icon %}
+ {% set _icon = icon is iterable ? icon : { name: icon } %}
+ {% if _icon.path is not defined or _icon.path is empty %}
+ {% set _icon = _icon|merge({ path: bcl_icon_path() }) %}
+ {% endif %}
+ {% set _icon = _icon|merge({
+ attributes: create_attribute(_icon.attributes|default({})),
+ }) %}
+{% endif %}
+
+{% include '@oe-bcl/link' with {
+ label: label,
+ path: path,
+ variant: variant,
+ id: id,
+ disabled: disabled,
+ standalone: standalone,
+ icon: _icon,
+ icon_position: icon_position,
+ icon_display: icon_display,
+ remove_icon_spacers: remove_icon_spacers,
+ attributes: attributes,
+} only %}
diff --git a/components/links_block/links_block.component.yml b/components/links_block/links_block.component.yml
new file mode 100644
index 000000000..c4ee357d5
--- /dev/null
+++ b/components/links_block/links_block.component.yml
@@ -0,0 +1,179 @@
+name: 'Links block'
+description: 'Displays a list of links with an optional title.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the links block.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'HTML tag used for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Optional link applied to the title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ background:
+ title: Background
+ type: string
+ description: 'Background style of the block. Defaults to gray.'
+ default: gray
+ enum:
+ - gray
+ - transparent
+ 'meta:enum':
+ gray: 'Gray (default)'
+ transparent: Transparent
+ orientation:
+ title: Orientation
+ type: string
+ description: 'Direction in which links are displayed. Defaults to vertical.'
+ default: vertical
+ enum:
+ - vertical
+ - horizontal
+ 'meta:enum':
+ vertical: 'Vertical (default)'
+ horizontal: Horizontal
+ links:
+ title: Links
+ type: array
+ description: 'Links displayed in the block.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/links_block/links_block.story.twig b/components/links_block/links_block.story.twig
new file mode 100644
index 000000000..2b132f1e7
--- /dev/null
+++ b/components/links_block/links_block.story.twig
@@ -0,0 +1,38 @@
+{% set resource_links = [
+ {
+ label: 'Funding opportunities',
+ path: '/example',
+ icon: 'arrow-right',
+ icon_position: 'before',
+ },
+ {
+ label: 'Calls for proposals',
+ path: '/example',
+ icon: 'arrow-right',
+ icon_position: 'before',
+ },
+ {
+ label: 'Programme guidance',
+ path: '/example',
+ icon: 'arrow-right',
+ icon_position: 'before',
+ },
+] %}
+
+
+ {% include 'oe_bootstrap_theme:links_block' with {
+ title: 'Useful resources',
+ links: resource_links,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:links_block' with {
+ title: 'Related topics',
+ background: 'transparent',
+ orientation: 'horizontal',
+ links: [
+ { label: 'Digital strategy', path: '/example' },
+ { label: 'Research and innovation', path: '/example' },
+ { label: 'Regional development', path: '/example' },
+ ],
+ } only %}
+
diff --git a/components/links_block/links_block.twig b/components/links_block/links_block.twig
new file mode 100644
index 000000000..b22c84189
--- /dev/null
+++ b/components/links_block/links_block.twig
@@ -0,0 +1,54 @@
+{#
+/**
+ * @file
+ * Links block component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _title_link = title_link|default({}) %}
+
+{% if _title_link is not empty %}
+ {% set _title_link = _title_link|merge({
+ attributes: create_attribute(_title_link.attributes|default({})),
+ }) %}
+ {% if _title_link.icon is defined and _title_link.icon is not empty %}
+ {% set _title_icon = _title_link.icon is iterable ? _title_link.icon : { name: _title_link.icon } %}
+ {% set _title_link = _title_link|merge({
+ icon: _title_icon|merge({
+ path: _title_icon.path|default(_icon_path),
+ attributes: create_attribute(_title_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _links = [] %}
+{% for link in links|default([]) %}
+ {% set _link = link|merge({
+ attributes: create_attribute(link.attributes|default({})),
+ }) %}
+ {% if _link.icon is defined and _link.icon is not empty %}
+ {% set _link_icon = _link.icon is iterable ? _link.icon : { name: _link.icon } %}
+ {% set _link = _link|merge({
+ icon: _link_icon|merge({
+ path: _link_icon.path|default(_icon_path),
+ attributes: create_attribute(_link_icon.attributes|default({})),
+ }),
+ }) %}
+ {% if _link.icon_display is not defined %}
+ {% set _link = _link|merge({ icon_display: 'flex' }) %}
+ {% endif %}
+ {% endif %}
+ {% set _links = _links|merge([_link]) %}
+{% endfor %}
+
+{% include '@oe-bcl/links-block' with {
+ title: title,
+ title_tag: title_tag,
+ title_link: _title_link,
+ title_attributes: title_attributes,
+ background: background,
+ variant: orientation,
+ links: _links,
+ attributes: attributes,
+} only %}
diff --git a/components/list_group/list_group.component.yml b/components/list_group/list_group.component.yml
new file mode 100644
index 000000000..c622407f2
--- /dev/null
+++ b/components/list_group/list_group.component.yml
@@ -0,0 +1,254 @@
+name: 'List group'
+description: 'Displays a flexible series of content as unordered, ordered, or actionable items.'
+props:
+ type: object
+ properties:
+ flush:
+ title: Flush
+ type: boolean
+ description: 'Removes outer borders and rounded corners. Defaults to false.'
+ horizontal:
+ title: Horizontal
+ type: string
+ description: 'Displays items horizontally, optionally from a responsive breakpoint.'
+ enum:
+ - horizontal
+ - horizontal-sm
+ - horizontal-md
+ - horizontal-lg
+ - horizontal-xl
+ - horizontal-xxl
+ 'meta:enum':
+ horizontal: 'All viewports'
+ horizontal-sm: 'Small and larger'
+ horizontal-md: 'Medium and larger'
+ horizontal-lg: 'Large and larger'
+ horizontal-xl: 'Extra large and larger'
+ horizontal-xxl: 'Extra extra large and larger'
+ id:
+ title: ID
+ type: string
+ description: 'List group ID attribute.'
+ type:
+ title: Type
+ type: string
+ description: 'Type of list group. Defaults to unordered.'
+ enum:
+ - unordered
+ - ordered
+ - actionable
+ 'meta:enum':
+ unordered: 'Unordered (default)'
+ ordered: Ordered
+ actionable: Actionable
+ items:
+ title: Items
+ type: array
+ description: 'List content, form inputs, links, or buttons.'
+ items:
+ type: object
+ properties:
+ html:
+ type: [string, array, object]
+ variant:
+ type: string
+ enum:
+ - ''
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ extra_classes:
+ type: string
+ badge:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ background:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ url:
+ type: string
+ title:
+ type: string
+ dismissible:
+ type: boolean
+ assistive_text:
+ type: string
+ rounded_pill:
+ type: boolean
+ outline:
+ type: boolean
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ input:
+ type: boolean
+ input_type:
+ type: string
+ label:
+ type: [string, array, object]
+ required:
+ type: boolean
+ checked:
+ type: boolean
+ hidden_label:
+ type: boolean
+ disabled:
+ type: boolean
+ readonly:
+ type: boolean
+ invalid:
+ type: boolean
+ valid:
+ type: boolean
+ invalid_feedback:
+ type: [string, array, object]
+ valid_feedback:
+ type: [string, array, object]
+ floating:
+ type: boolean
+ helper_text:
+ type: [string, array, object]
+ helper_text_id:
+ type: string
+ placeholder:
+ type: string
+ horizontal:
+ type: boolean
+ horizontal_class:
+ type: string
+ horizontal_label_class:
+ type: string
+ switch:
+ type: boolean
+ toggle:
+ type: boolean
+ toggle_variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ remove_wrapper:
+ type: boolean
+ path:
+ type: [string, object]
+ button:
+ type: boolean
+ id:
+ type: string
+ type:
+ type: string
+ enum:
+ - button
+ - submit
+ - reset
+ text_nowrap:
+ type: boolean
+ assistive_text:
+ type: string
+ standalone:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ icon_spacing:
+ type: string
+ enum:
+ - sm
+ - md
+ - lg
+ remove_icon_spacers:
+ type: boolean
+ spinner:
+ type: object
+ properties:
+ type:
+ type: string
+ enum:
+ - border
+ - grow
+ variant:
+ type: string
+ enum:
+ - ''
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ size:
+ type: string
+ enum:
+ - ''
+ - sm
+ - lg
+ assistive_text:
+ type: string
+ attributes:
+ type: object
+ show_spinner:
+ type: boolean
+ attributes:
+ type: object
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/list_group/list_group.story.twig b/components/list_group/list_group.story.twig
new file mode 100644
index 000000000..859cb8a85
--- /dev/null
+++ b/components/list_group/list_group.story.twig
@@ -0,0 +1,158 @@
+{% set variants = [
+ 'primary',
+ 'secondary',
+ 'success',
+ 'danger',
+ 'warning',
+ 'info',
+ 'light',
+ 'dark',
+] %}
+{% set variant_items = [] %}
+{% for variant in variants %}
+ {% set variant_items = variant_items|merge([{
+ html: variant|capitalize ~ ' item',
+ variant: variant,
+ }]) %}
+{% endfor %}
+{% set variant_items = variant_items|merge([
+ {
+ html: [{ '#markup': 'Item with
rendered markup ' }],
+ extra_classes: 'fw-medium',
+ },
+ {
+ html: 'Item with a badge',
+ badge: {
+ label: '10',
+ background: 'success',
+ rounded_pill: true,
+ attributes: { class: ['float-end'] },
+ },
+ },
+]) %}
+
+
+
+
Unordered items and variants
+ {% include 'oe_bootstrap_theme:list_group' with {
+ items: variant_items,
+ } only %}
+
+
+
+
Ordered and flush
+ {% include 'oe_bootstrap_theme:list_group' with {
+ type: 'ordered',
+ flush: true,
+ id: 'ordered-list-group',
+ attributes: create_attribute()
+ .addClass('custom-list-group')
+ .setAttribute('data-list-group', 'ordered'),
+ items: [
+ { html: 'First step' },
+ { html: 'Second step' },
+ { html: 'Third step' },
+ ],
+ } only %}
+
+
+
+
Horizontal
+ {% include 'oe_bootstrap_theme:list_group' with {
+ horizontal: 'horizontal',
+ items: [
+ { html: 'First item' },
+ { html: 'Second item' },
+ { html: 'Third item' },
+ ],
+ } only %}
+
+ Horizontal from medium
+ {% include 'oe_bootstrap_theme:list_group' with {
+ horizontal: 'horizontal-md',
+ items: [
+ { html: 'First item' },
+ { html: 'Second item' },
+ { html: 'Third item' },
+ ],
+ } only %}
+
+
+
+
Form inputs
+ {% include 'oe_bootstrap_theme:list_group' with {
+ items: [
+ {
+ input: true,
+ input_type: 'checkbox',
+ id: 'list-group-checkbox',
+ label: 'Checked switch',
+ checked: true,
+ required: true,
+ switch: true,
+ attributes: { class: ['custom-checkbox'] },
+ },
+ {
+ input: true,
+ input_type: 'text',
+ id: 'list-group-text',
+ label: 'Validated text input',
+ placeholder: 'Enter a value',
+ valid: true,
+ valid_feedback: 'The value is valid.',
+ helper_text: 'Supporting text for this field.',
+ helper_text_id: 'list-group-text-help',
+ },
+ {
+ input: true,
+ input_type: 'text',
+ id: 'list-group-invalid',
+ label: 'Invalid floating input',
+ placeholder: 'Enter a value',
+ floating: true,
+ invalid: true,
+ invalid_feedback: 'Enter a valid value.',
+ },
+ ],
+ } only %}
+
+
+
+
Actionable links and buttons
+ {% include 'oe_bootstrap_theme:list_group' with {
+ type: 'actionable',
+ items: [
+ {
+ label: 'Link with icon',
+ path: '/example',
+ variant: 'primary',
+ icon: 'arrow-right',
+ icon_position: 'before',
+ attributes: { class: ['custom-action-link'] },
+ },
+ {
+ label: 'Disabled standalone link',
+ path: '/example',
+ disabled: true,
+ standalone: true,
+ },
+ {
+ button: true,
+ label: 'Button with spinner',
+ type: 'button',
+ icon: {
+ name: 'check',
+ transformation: 'rotate-90',
+ attributes: { class: ['custom-button-icon'] },
+ },
+ show_spinner: true,
+ spinner: {
+ size: 'sm',
+ assistive_text: 'Loading',
+ },
+ attributes: { class: ['custom-action-button'] },
+ },
+ ],
+ } only %}
+
+
diff --git a/components/list_group/list_group.twig b/components/list_group/list_group.twig
new file mode 100644
index 000000000..61d9940ee
--- /dev/null
+++ b/components/list_group/list_group.twig
@@ -0,0 +1,51 @@
+{#
+/**
+ * @file
+ * List group component.
+ */
+#}
+{% set _icon_path = bcl_icon_path() %}
+{% set _items = [] %}
+
+{% for item in items|default([]) %}
+ {% set _item = item|merge({
+ attributes: create_attribute(item.attributes|default({})),
+ }) %}
+
+ {% if _item.badge is defined and _item.badge is not empty %}
+ {% set _badge = _item.badge|merge({
+ icon_path: _item.badge.icon_path|default(_icon_path),
+ attributes: create_attribute(_item.badge.attributes|default({})),
+ }) %}
+ {% set _item = _item|merge({ badge: _badge }) %}
+ {% endif %}
+
+ {% if _item.icon is defined and _item.icon is not empty %}
+ {% set _icon = _item.icon is iterable ? _item.icon : { name: _item.icon } %}
+ {% set _item = _item|merge({
+ icon: _icon|merge({
+ path: _icon.path|default(_icon_path),
+ attributes: create_attribute(_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+
+ {% if _item.spinner is defined and _item.spinner is not empty %}
+ {% set _item = _item|merge({
+ spinner: _item.spinner|merge({
+ attributes: create_attribute(_item.spinner.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+
+ {% set _items = _items|merge([_item]) %}
+{% endfor %}
+
+{% include '@oe-bcl/list-group' with {
+ flush: flush,
+ horizontal: horizontal,
+ id: id,
+ type: type,
+ items: _items,
+ attributes: attributes,
+} only %}
diff --git a/components/listing/listing.component.yml b/components/listing/listing.component.yml
new file mode 100644
index 000000000..3afea0fa0
--- /dev/null
+++ b/components/listing/listing.component.yml
@@ -0,0 +1,290 @@
+name: Listing
+description: 'Displays cards in default or highlighted one-, two-, or three-column listings.'
+props:
+ type: object
+ properties:
+ variant:
+ title: Variant
+ type: string
+ description: 'Visual style of the listing. Defaults to default.'
+ enum:
+ - default
+ - highlight
+ 'meta:enum':
+ default: 'Default (default)'
+ highlight: Highlight
+ columns:
+ title: Columns
+ type: integer
+ description: 'Number of columns. Defaults to 1.'
+ enum:
+ - 1
+ - 2
+ - 3
+ 'meta:enum':
+ 1: 'One (default)'
+ 2: Two
+ 3: Three
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the listing.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Optional link applied to the listing title. The title prop is used as its label.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ items:
+ title: Items
+ type: array
+ description: 'Cards displayed in the listing.'
+ items:
+ type: object
+ properties:
+ title:
+ type: [string, array, object]
+ title_tag:
+ type: string
+ title_link:
+ type: object
+ description: 'Optional link applied to the card title. The title prop is used as its label.'
+ properties:
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ url:
+ type: object
+ description: 'Drupal URL object optionally applied to the card title.'
+ subtitle:
+ type: [string, array, object]
+ text:
+ type: [string, array, object]
+ image:
+ type: object
+ properties:
+ src:
+ type: string
+ alt:
+ type: string
+ position:
+ type: string
+ enum:
+ - top
+ - bottom
+ - background
+ classes:
+ type: string
+ rounded:
+ type: string
+ image_copyright:
+ type: [string, array, object]
+ image_copyright_label:
+ type: string
+ description: 'Assistive label displayed before the image copyright. Defaults to "Image credit:".'
+ date:
+ type: object
+ properties:
+ day:
+ type: [string, integer]
+ month:
+ type: string
+ date_time:
+ type: string
+ year:
+ type: [string, integer]
+ end_day:
+ type: [string, integer]
+ end_month:
+ type: string
+ end_year:
+ type: [string, integer]
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ content:
+ type: [string, array, object]
+ header:
+ type: [string, array, object]
+ footer:
+ type: [string, array, object]
+ badges:
+ type: array
+ items:
+ type: [string, object]
+ properties:
+ label:
+ type: [string, array, object]
+ background:
+ type: string
+ url:
+ type: string
+ title:
+ type: string
+ dismissible:
+ type: boolean
+ assistive_text:
+ type: string
+ rounded_pill:
+ type: boolean
+ outline:
+ type: boolean
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ link:
+ title: Link
+ type: object
+ description: 'Optional call-to-action link displayed below the cards.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ alignment:
+ title: Alignment
+ type: string
+ description: 'Vertical alignment of card media and content.'
+ enum:
+ - start
+ - end
+ - center
+ - baseline
+ - stretch
+ 'meta:enum':
+ start: Start
+ end: End
+ center: Center
+ baseline: Baseline
+ stretch: Stretch
+ image_size:
+ title: 'Image size'
+ type: string
+ description: 'Width of card media in horizontal listings.'
+ enum:
+ - small
+ - large
+ - extra-large
+ 'meta:enum':
+ small: Small
+ large: Large
+ extra-large: 'Extra large'
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the icons SVG file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/listing/listing.story.twig b/components/listing/listing.story.twig
new file mode 100644
index 000000000..ad6c259b3
--- /dev/null
+++ b/components/listing/listing.story.twig
@@ -0,0 +1,261 @@
+{% set items = [
+ {
+ title: 'Digital policy and legislation',
+ title_link: {
+ path: '/digital-policy',
+ },
+ text: 'Current initiatives, legislation and practical guidance for Europe’s digital transformation.',
+ image: {
+ src: 'https://picsum.photos/seed/listing-policy/800/500',
+ alt: 'People working together in an office',
+ },
+ image_copyright: '© European Union, 2026',
+ content: {
+ '#markup': '
Policy · 24 July 2026 ',
+ },
+ badges: [
+ 'Digital',
+ {
+ label: 'Policy',
+ background: 'success',
+ },
+ ],
+ },
+ {
+ title: 'Funding opportunities',
+ title_link: {
+ path: '/funding',
+ },
+ text: 'Find open calls, programme information and resources for applicants and beneficiaries.',
+ image: {
+ src: 'https://picsum.photos/seed/listing-funding/800/500',
+ alt: 'Modern public building',
+ classes: 'mb-3',
+ },
+ content: {
+ '#markup': '
Funding · 18 July 2026 ',
+ },
+ },
+ {
+ title: 'Upcoming consultation',
+ title_link: {
+ path: '/consultation',
+ },
+ text: 'Contribute your views to an open consultation before the submission deadline.',
+ date: {
+ day: 12,
+ month: 'Sep',
+ year: 2026,
+ date_time: '2026-09-12',
+ },
+ content: {
+ '#markup': '
Consultation · Open ',
+ },
+ },
+] %}
+
+{% set image_items = items|slice(0, 2)|merge([
+ {
+ title: 'Research and innovation',
+ title_link: {
+ path: '/research',
+ },
+ text: 'Explore European research programmes, scientific results and opportunities for collaboration.',
+ image: {
+ src: 'https://picsum.photos/seed/listing-research/800/500',
+ alt: 'Research facility interior',
+ classes: 'mb-3',
+ },
+ content: {
+ '#markup': '
Research · 10 July 2026 ',
+ },
+ },
+]) %}
+
+{% set date_items = items|slice(2, 1)|merge([
+ {
+ title: 'Information session',
+ title_link: {
+ path: '/information-session',
+ },
+ text: 'Join an online session covering the programme, eligibility requirements and application process.',
+ date: {
+ day: 4,
+ month: 'Oct',
+ year: 2026,
+ date_time: '2026-10-04',
+ },
+ content: {
+ '#markup': '
Online event · Registration open ',
+ },
+ },
+ {
+ title: 'Application deadline',
+ title_link: {
+ path: '/application-deadline',
+ },
+ text: 'Submit completed applications and supporting documents before the final deadline.',
+ date: {
+ day: 18,
+ month: 'Nov',
+ year: 2026,
+ date_time: '2026-11-18',
+ },
+ content: {
+ '#markup': '
Funding · Final deadline ',
+ },
+ },
+]) %}
+
+{% set compact_items = [
+ {
+ title: 'Digital policy and legislation',
+ title_link: {
+ path: '/digital-policy',
+ },
+ text: 'Current initiatives, legislation and practical guidance for Europe’s digital transformation.',
+ content: {
+ '#markup': '
Policy · 24 July 2026 ',
+ },
+ badges: ['Digital', 'Policy'],
+ },
+ {
+ title: 'Funding opportunities',
+ title_link: {
+ path: '/funding',
+ },
+ text: 'Find open calls, programme information and resources for applicants and beneficiaries.',
+ content: {
+ '#markup': '
Funding · 18 July 2026 ',
+ },
+ },
+ {
+ title: 'Upcoming consultation',
+ title_link: {
+ path: '/consultation',
+ },
+ text: 'Contribute your views to an open consultation before the submission deadline.',
+ content: {
+ '#markup': '
Consultation · Open ',
+ },
+ },
+] %}
+
+{% set examples = [
+ {
+ heading: 'Default, one column with images',
+ variant: 'default',
+ columns: 1,
+ alignment: 'start',
+ image_size: 'large',
+ },
+ {
+ heading: 'Default, two columns',
+ variant: 'default',
+ columns: 2,
+ alignment: 'start',
+ items: compact_items,
+ },
+ {
+ heading: 'Default, three columns',
+ variant: 'default',
+ columns: 3,
+ alignment: 'start',
+ items: compact_items,
+ },
+ {
+ heading: 'Highlight, one column with images',
+ variant: 'highlight',
+ columns: 1,
+ alignment: 'baseline',
+ },
+ {
+ heading: 'Highlight, two columns with images',
+ variant: 'highlight',
+ columns: 2,
+ alignment: 'stretch',
+ },
+ {
+ heading: 'Highlight, three columns with images',
+ variant: 'highlight',
+ columns: 3,
+ alignment: 'center',
+ },
+ {
+ heading: 'Default date blocks, one column',
+ variant: 'default',
+ columns: 1,
+ alignment: 'start',
+ image_size: 'small',
+ items: date_items,
+ },
+ {
+ heading: 'Default date blocks, two columns',
+ variant: 'default',
+ columns: 2,
+ alignment: 'start',
+ image_size: 'small',
+ items: date_items,
+ },
+ {
+ heading: 'Default date blocks, three columns',
+ variant: 'default',
+ columns: 3,
+ alignment: 'start',
+ image_size: 'small',
+ items: date_items,
+ },
+ {
+ heading: 'Highlight date blocks, one column',
+ variant: 'highlight',
+ columns: 1,
+ alignment: 'start',
+ items: date_items,
+ },
+ {
+ heading: 'Highlight date blocks, two columns',
+ variant: 'highlight',
+ columns: 2,
+ alignment: 'start',
+ items: date_items,
+ },
+ {
+ heading: 'Highlight date blocks, three columns',
+ variant: 'highlight',
+ columns: 3,
+ alignment: 'start',
+ items: date_items,
+ },
+] %}
+
+
+ {% for example in examples %}
+
+
{{ example.heading }}
+ {% set _props = {
+ variant: example.variant,
+ columns: example.columns,
+ items: example.items|default(image_items),
+ alignment: example.alignment,
+ } %}
+ {% if example.image_size is defined %}
+ {% set _props = _props|merge({image_size: example.image_size}) %}
+ {% endif %}
+ {% if loop.first %}
+ {% set _props = _props|merge({
+ title: 'Latest updates',
+ title_tag: 'h4',
+ title_link: {
+ path: '/latest-updates',
+ },
+ link: {
+ label: 'View all updates',
+ path: '/updates',
+ icon_position: 'after',
+ },
+ }) %}
+ {% endif %}
+ {% include 'oe_bootstrap_theme:listing' with _props only %}
+
+ {% endfor %}
+
diff --git a/components/listing/listing.twig b/components/listing/listing.twig
new file mode 100644
index 000000000..d0ead8f60
--- /dev/null
+++ b/components/listing/listing.twig
@@ -0,0 +1,102 @@
+{#
+/**
+ * @file
+ * Listing component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _items = [] %}
+{% for item in items|default([]) %}
+ {% set _item = item|merge({
+ image_copyright_label: item.image_copyright_label|default('Image credit:'|t),
+ }) %}
+
+ {% if item.title_link is not empty %}
+ {% set _item_title_link = item.title_link|merge({
+ attributes: create_attribute(item.title_link.attributes|default({})),
+ }) %}
+ {% if item.title_link.icon is not empty %}
+ {% set _item_title_link = _item_title_link|merge({
+ icon: item.title_link.icon|merge({
+ path: item.title_link.icon.path|default(_icon_path),
+ attributes: create_attribute(item.title_link.icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+ {% set _item = _item|merge({title_link: _item_title_link}) %}
+ {% endif %}
+
+ {% if item.date is not empty %}
+ {% set _item = _item|merge({
+ date: item.date|merge({
+ icon_path: item.date.icon_path|default(_icon_path),
+ attributes: create_attribute(item.date.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+
+ {% if item.header is defined %}
+ {% set _item = _item|merge({card_header: item.header}) %}
+ {% endif %}
+ {% if item.footer is defined %}
+ {% set _item = _item|merge({card_footer: item.footer}) %}
+ {% endif %}
+
+ {% set _badges = [] %}
+ {% for badge in item.badges|default([]) %}
+ {% if badge is iterable %}
+ {% set badge = badge|merge({
+ icon_path: badge.icon_path|default(_icon_path),
+ attributes: create_attribute(badge.attributes|default({})),
+ }) %}
+ {% endif %}
+ {% set _badges = _badges|merge([badge]) %}
+ {% endfor %}
+ {% set _items = _items|merge([_item|merge({badges: _badges})]) %}
+{% endfor %}
+
+{% set _title_link = title_link|default({}) %}
+{% if _title_link is not empty %}
+ {% set _title_link = _title_link|merge({attributes: create_attribute(_title_link.attributes|default({}))}) %}
+ {% if _title_link.icon is not empty %}
+ {% set _title_link = _title_link|merge({
+ icon: _title_link.icon|merge({
+ path: _title_link.icon.path|default(_icon_path),
+ attributes: create_attribute(_title_link.icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _link = link|default({}) %}
+{% if _link is not empty %}
+ {% set _link = _link|merge({
+ attributes: create_attribute(_link.attributes|default({})),
+ }) %}
+ {% if _link.icon is not empty %}
+ {% set _link = _link|merge({
+ icon: _link.icon|merge({
+ path: _link.icon.path|default(_icon_path),
+ attributes: create_attribute(_link.icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _attributes = attributes|default(create_attribute()) %}
+{% if _attributes.class is empty %}
+ {% set _attributes = _attributes.addClass('text-break') %}
+{% endif %}
+
+{% include '@oe-bcl/listing' with {
+ title: title,
+ title_tag: title_tag|default('h2'),
+ title_link: _title_link,
+ title_attributes: title_attributes,
+ items: _items|bcl_card_list,
+ variant: variant|default('default') ~ '-' ~ columns|default(1) ~ '-col',
+ link: _link,
+ alignment: alignment,
+ image_size: image_size,
+ attributes: _attributes,
+} only %}
diff --git a/components/modal/modal.component.yml b/components/modal/modal.component.yml
new file mode 100644
index 000000000..148cd1322
--- /dev/null
+++ b/components/modal/modal.component.yml
@@ -0,0 +1,174 @@
+name: Modal
+description: 'Displays content in a dialog layered over the page, with an optional trigger button.'
+props:
+ type: object
+ properties:
+ modal_id:
+ title: 'Modal ID'
+ type: string
+ description: 'Unique ID connecting the modal and its trigger. A unique ID is generated when omitted.'
+ size:
+ title: Size
+ type: string
+ description: 'Size of the modal.'
+ enum:
+ - sm
+ - lg
+ - xl
+ - fullscreen
+ 'meta:enum':
+ sm: Small
+ lg: Large
+ xl: 'Extra large'
+ fullscreen: Fullscreen
+ fullscreen_responsive:
+ title: 'Responsive fullscreen'
+ type: string
+ description: 'Display the modal fullscreen below the selected breakpoint.'
+ enum:
+ - sm
+ - md
+ - lg
+ - xl
+ - xxl
+ 'meta:enum':
+ sm: Small
+ md: Medium
+ lg: Large
+ xl: 'Extra large'
+ xxl: 'Extra extra large'
+ static_backdrop:
+ title: 'Static backdrop'
+ type: boolean
+ description: 'Prevent the modal from closing when its backdrop is selected.'
+ vertically_centered:
+ title: 'Vertically centered'
+ type: boolean
+ description: 'Center the modal vertically in the viewport.'
+ scrollable:
+ title: Scrollable
+ type: boolean
+ description: 'Allow the modal body to scroll independently.'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title displayed in the modal header.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h5.'
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ show_close_button:
+ title: 'Show close button'
+ type: boolean
+ description: 'Display a close button in the modal header. Defaults to false.'
+ messages:
+ title: Messages
+ type: [string, array, object]
+ description: 'Content displayed between the modal header and body.'
+ body:
+ title: Body
+ type: [string, array, object]
+ description: 'Main modal content.'
+ footer:
+ title: Footer
+ type: [string, array, object]
+ description: 'Optional modal footer content.'
+ toggle:
+ title: 'Toggle button'
+ type: object
+ description: 'Optional button that opens the modal.'
+ properties:
+ label:
+ type: [string, array, object]
+ variant:
+ type: string
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ - link
+ size:
+ type: string
+ enum:
+ - sm
+ - md
+ - lg
+ type:
+ type: string
+ enum:
+ - button
+ - submit
+ - reset
+ outline:
+ type: boolean
+ disabled:
+ type: boolean
+ text_nowrap:
+ type: boolean
+ assistive_text:
+ type: string
+ id:
+ type: string
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_spacing:
+ type: string
+ enum:
+ - sm
+ - md
+ - lg
+ spinner:
+ type: object
+ properties:
+ type:
+ type: string
+ variant:
+ type: string
+ size:
+ type: string
+ assistive_text:
+ type: string
+ attributes:
+ type: object
+ show_spinner:
+ type: boolean
+ clean_class:
+ type: boolean
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
+ header_attributes:
+ title: 'Header attributes'
+ type: Drupal\Core\Template\Attribute
+ body_attributes:
+ title: 'Body attributes'
+ type: Drupal\Core\Template\Attribute
+ footer_attributes:
+ title: 'Footer attributes'
+ type: Drupal\Core\Template\Attribute
diff --git a/components/modal/modal.story.twig b/components/modal/modal.story.twig
new file mode 100644
index 000000000..2b0d01f4c
--- /dev/null
+++ b/components/modal/modal.story.twig
@@ -0,0 +1,109 @@
+{% set footer %}
+
Close
+
Save changes
+{% endset %}
+{% set messages %}
+
Review the information before continuing.
+{% endset %}
+
+
+
+
Default
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-default',
+ title: 'Default modal',
+ show_close_button: true,
+ body: 'This modal uses the default width and behavior.',
+ footer: footer,
+ toggle: {
+ label: 'Open default modal',
+ },
+ } only %}
+
+
+
+
Small and scrollable
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-small',
+ size: 'sm',
+ scrollable: true,
+ title: 'Small modal',
+ show_close_button: true,
+ body: 'A compact modal with an independently scrollable body.',
+ footer: footer,
+ toggle: {
+ label: 'Open small modal',
+ variant: 'secondary',
+ },
+ } only %}
+
+
+
+
Large and vertically centered
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-large',
+ size: 'lg',
+ vertically_centered: true,
+ title: 'Large centered modal',
+ title_tag: 'h4',
+ title_attributes: create_attribute().addClass('modal-title'),
+ show_close_button: true,
+ body: 'This larger dialog is centered vertically in the viewport.',
+ footer: footer,
+ toggle: {
+ label: 'Open large modal',
+ variant: 'success',
+ },
+ } only %}
+
+
+
+
Extra large with static backdrop
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-extra-large',
+ size: 'xl',
+ static_backdrop: true,
+ title: 'Extra large modal',
+ show_close_button: true,
+ messages: messages,
+ body: 'Selecting the backdrop does not close this modal.',
+ footer: footer,
+ toggle: {
+ label: 'Open extra large modal',
+ variant: 'info',
+ },
+ } only %}
+
+
+
+
Fullscreen
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-fullscreen',
+ size: 'fullscreen',
+ title: 'Fullscreen modal',
+ show_close_button: true,
+ body: 'This modal occupies the complete viewport.',
+ footer: footer,
+ toggle: {
+ label: 'Open fullscreen modal',
+ variant: 'dark',
+ },
+ } only %}
+
+
+
+
Responsive fullscreen
+ {% include 'oe_bootstrap_theme:modal' with {
+ modal_id: 'modal-responsive',
+ fullscreen_responsive: 'md',
+ title: 'Responsive fullscreen modal',
+ show_close_button: true,
+ body: 'This modal becomes fullscreen below the medium breakpoint.',
+ footer: footer,
+ toggle: {
+ label: 'Open responsive modal',
+ outline: true,
+ },
+ } only %}
+
+
diff --git a/components/modal/modal.twig b/components/modal/modal.twig
new file mode 100644
index 000000000..f535481b7
--- /dev/null
+++ b/components/modal/modal.twig
@@ -0,0 +1,54 @@
+{#
+/**
+ * @file
+ * Modal component.
+ */
+#}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _id = _attributes.id|default(modal_id|default('modal'|clean_unique_id)) %}
+{% set _header = '' %}
+{% set _title_tag = title_tag|default('h5') %}
+
+{% if title is not empty or show_close_button ?? false %}
+ {% set _header %}
+ {% if title is not empty %}
+ <{{ _title_tag }}{{ title_attributes|default(create_attribute()) }}>{{ title }}{{ _title_tag }}>
+ {% endif %}
+ {% if show_close_button ?? false %}
+ {% include 'oe_bootstrap_theme:button' with {
+ clean_class: true,
+ attributes: create_attribute()
+ .addClass('btn-close')
+ .setAttribute('data-bs-dismiss', 'modal')
+ .setAttribute('aria-label', 'Close'|t),
+ } only %}
+ {% endif %}
+ {% endset %}
+{% endif %}
+
+{% include '@oe-bcl/modal' with {
+ id: _id,
+ size: size,
+ fullscreen_responsive: fullscreen_responsive,
+ static_backdrop: static_backdrop ?? false,
+ vertically_centered: vertically_centered ?? false,
+ scrollable: scrollable ?? false,
+ header: _header,
+ messages: messages,
+ body: body,
+ footer: footer,
+ attributes: _attributes,
+ header_attributes: header_attributes|default(create_attribute()),
+ body_attributes: body_attributes|default(create_attribute()),
+ footer_attributes: footer_attributes|default(create_attribute()),
+} only %}
+
+{% if toggle is not empty %}
+ {% set _toggle = toggle|merge({
+ attributes: create_attribute(toggle.attributes|default({}))
+ .setAttribute('data-bs-toggle', 'modal')
+ .setAttribute('data-bs-target', '#' ~ _id)
+ .setAttribute('aria-controls', _id),
+ }) %}
+ {% include 'oe_bootstrap_theme:button' with _toggle only %}
+{% endif %}
diff --git a/components/navbar/navbar.component.yml b/components/navbar/navbar.component.yml
new file mode 100644
index 000000000..c260fa4b9
--- /dev/null
+++ b/components/navbar/navbar.component.yml
@@ -0,0 +1,56 @@
+name: Navbar
+description: 'Displays a responsive navigation bar with branding, navigation, and right-aligned content regions.'
+props:
+ type: object
+ properties:
+ collapse_id:
+ title: 'Collapse ID'
+ type: string
+ description: 'ID connecting the responsive toggle and collapsible content. A unique ID is generated when omitted.'
+ expanded_breakpoint:
+ title: 'Expanded breakpoint'
+ type: string
+ description: 'Breakpoint at which the navbar is expanded. Defaults to lg.'
+ enum:
+ - sm
+ - md
+ - lg
+ 'meta:enum':
+ sm: Small
+ md: Medium
+ lg: 'Large (default)'
+ color_scheme:
+ title: 'Color scheme'
+ type: string
+ description: 'Color scheme used by the navbar. Defaults to light.'
+ default: light
+ enum:
+ - light
+ - dark
+ 'meta:enum':
+ light: 'Light (default)'
+ dark: Dark
+ background:
+ title: Background
+ type: boolean
+ description: 'Adds a background matching the color scheme. Defaults to true.'
+ default: true
+ disable_collapse:
+ title: 'Disable collapse'
+ type: boolean
+ description: 'Keeps all navbar content visible and removes the responsive toggle. Defaults to true when no expanded breakpoint is provided.'
+ branding:
+ title: 'Branding region'
+ type: [string, array, object]
+ description: 'Branding content, such as a linked site name or logo.'
+ navigation:
+ title: 'Navigation region'
+ type: [string, array, object]
+ description: 'Primary navigation content.'
+ right:
+ title: 'Right region'
+ type: [string, array, object]
+ description: 'Content aligned to the end of the navbar, such as search or account actions.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/navbar/navbar.story.twig b/components/navbar/navbar.story.twig
new file mode 100644
index 000000000..d10a263e5
--- /dev/null
+++ b/components/navbar/navbar.story.twig
@@ -0,0 +1,52 @@
+{% set branding %}
+
Europa
+{% endset %}
+
+{% set navigation %}
+
+{% endset %}
+
+{% set right %}
+
Sign in
+{% endset %}
+
+
+
+
Light navbar
+ {% include 'oe_bootstrap_theme:navbar' with {
+ expanded_breakpoint: 'lg',
+ branding: branding,
+ navigation: navigation,
+ right: right,
+ } only %}
+
+
+
+
Dark navbar
+ {% include 'oe_bootstrap_theme:navbar' with {
+ collapse_id: 'dark-navbar',
+ expanded_breakpoint: 'md',
+ color_scheme: 'dark',
+ branding: branding,
+ navigation: navigation,
+ } only %}
+
+
+
+
Without background or collapse
+ {% include 'oe_bootstrap_theme:navbar' with {
+ expanded_breakpoint: 'sm',
+ background: false,
+ disable_collapse: true,
+ branding: branding,
+ navigation: navigation,
+ } only %}
+
+
diff --git a/components/navbar/navbar.twig b/components/navbar/navbar.twig
new file mode 100644
index 000000000..b68588a41
--- /dev/null
+++ b/components/navbar/navbar.twig
@@ -0,0 +1,25 @@
+{#
+/**
+ * @file
+ * Navbar component.
+ */
+#}
+{% extends '@oe-bcl/navbar' %}
+
+{% set color_set = color_scheme|default('light') %}
+{% set background = (background ?? true) ? color_set : '' %}
+{% set disable_collapse = disable_collapse ?? (expanded_breakpoint is empty) %}
+{% set expand = expanded_breakpoint|default('lg') %}
+{% set collapse_id = collapse_id|default('bcl-navbar'|clean_unique_id) %}
+
+{% block branding %}
+ {{- branding -}}
+{% endblock %}
+
+{% block navigation %}
+ {{- navigation -}}
+{% endblock %}
+
+{% block right %}
+ {{- right -}}
+{% endblock %}
diff --git a/components/navigation/navigation.component.yml b/components/navigation/navigation.component.yml
new file mode 100644
index 000000000..e282af1b9
--- /dev/null
+++ b/components/navigation/navigation.component.yml
@@ -0,0 +1,295 @@
+name: Navigation
+description: 'Renders links, buttons, dropdowns, and nested navigation in several Bootstrap navigation styles.'
+props:
+ type: object
+ properties:
+ variant:
+ title: Variant
+ type: string
+ description: 'Navigation presentation. Defaults to default.'
+ default: default
+ enum:
+ - default
+ - tabs
+ - pills
+ - navbar
+ 'meta:enum':
+ default: Default
+ tabs: Tabs
+ pills: Pills
+ navbar: Navbar
+ orientation:
+ title: Orientation
+ type: string
+ description: 'Navigation orientation. Defaults to horizontal.'
+ enum:
+ - horizontal
+ - vertical
+ 'meta:enum':
+ horizontal: Horizontal
+ vertical: Vertical
+ navigation_id:
+ title: 'Navigation ID'
+ type: string
+ description: 'ID added to the navigation wrapper.'
+ full_width:
+ title: 'Full width'
+ type: boolean
+ description: 'Distribute items across the available width.'
+ alignment:
+ title: Alignment
+ type: string
+ description: 'Horizontal alignment of navigation items.'
+ enum:
+ - start
+ - center
+ - end
+ 'meta:enum':
+ start: Start
+ center: Center
+ end: End
+ tabs_content:
+ title: 'Tab content'
+ type: boolean
+ description: 'Render tab panels from each item content value.'
+ items:
+ title: 'Navigation items'
+ type: array
+ description: 'Links, buttons, dropdowns, and nested navigation items.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ id:
+ type: string
+ title:
+ type: string
+ variant:
+ type: string
+ active:
+ type: boolean
+ disabled:
+ type: boolean
+ standalone:
+ type: boolean
+ button:
+ type: boolean
+ type:
+ type: string
+ text_nowrap:
+ type: boolean
+ assistive_text:
+ type: string
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_display:
+ type: string
+ enum:
+ - flex
+ - inline-flex
+ icon_spacing:
+ type: string
+ remove_icon_spacers:
+ type: boolean
+ spinner:
+ type: object
+ properties:
+ type:
+ type: string
+ variant:
+ type: string
+ size:
+ type: string
+ assistive_text:
+ type: string
+ attributes:
+ type: object
+ show_spinner:
+ type: boolean
+ content:
+ type: [string, array, object]
+ target:
+ type: string
+ dropdown:
+ type: boolean
+ link:
+ type: boolean
+ alignment:
+ type: string
+ dark_mode:
+ type: boolean
+ trigger:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ variant:
+ type: string
+ size:
+ type: string
+ type:
+ type: string
+ outline:
+ type: boolean
+ disabled:
+ type: boolean
+ assistive_text:
+ type: string
+ standalone:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ attributes:
+ type: object
+ items:
+ type: array
+ description: 'Items displayed in a dropdown navigation item.'
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ button:
+ type: boolean
+ divider:
+ type: boolean
+ active:
+ type: boolean
+ disabled:
+ type: boolean
+ id:
+ type: string
+ title:
+ type: string
+ type:
+ type: string
+ variant:
+ type: string
+ size:
+ type: string
+ outline:
+ type: boolean
+ standalone:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ attributes:
+ type: object
+ navigation:
+ type: object
+ description: 'Nested BCL navigation configuration.'
+ properties:
+ id:
+ type: string
+ variant:
+ type: string
+ enum:
+ - default
+ - tabs
+ - pills
+ - navbar
+ orientation:
+ type: string
+ enum:
+ - horizontal
+ - vertical
+ tabs_content:
+ type: boolean
+ full_width:
+ type: boolean
+ alignment:
+ type: string
+ attributes:
+ type: object
+ items:
+ type: array
+ items:
+ type: object
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: [string, object]
+ id:
+ type: string
+ active:
+ type: boolean
+ disabled:
+ type: boolean
+ button:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ attributes:
+ type: object
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/navigation/navigation.story.twig b/components/navigation/navigation.story.twig
new file mode 100644
index 000000000..b71f3e2ed
--- /dev/null
+++ b/components/navigation/navigation.story.twig
@@ -0,0 +1,141 @@
+{% set overview_content %}
+
Overview content displayed by the active tab.
+{% endset %}
+{% set details_content %}
+
Detailed information displayed by the second tab.
+{% endset %}
+
+
+
+
Default
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'default',
+ alignment: 'start',
+ items: [
+ { label: 'Overview', path: '#overview', active: true },
+ { label: 'Documents', path: '#documents' },
+ { label: 'Calendar', path: '#calendar', icon: 'calendar', icon_position: 'before' },
+ { label: 'Disabled', path: '#disabled', disabled: true },
+ ],
+ } only %}
+
+
+
+
Vertical pills with dropdown
+
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'pills',
+ orientation: 'vertical',
+ navigation_id: 'navigation-pills',
+ items: [
+ {
+ label: 'Profile',
+ path: '#profile',
+ active: true,
+ icon: 'person',
+ icon_position: 'before',
+ icon_display: 'flex',
+ },
+ {
+ label: 'Settings',
+ path: '#settings',
+ icon: 'gear',
+ icon_position: 'before',
+ icon_display: 'flex',
+ },
+ {
+ dropdown: true,
+ link: true,
+ id: 'navigation-dropdown',
+ trigger: {
+ label: 'More',
+ path: '#',
+ icon: 'list',
+ icon_position: 'before',
+ icon_display: 'flex',
+ attributes: { class: ['nav-link', 'align-items-center'] },
+ },
+ items: [
+ { label: 'Related information', path: '#related', icon: 'link', icon_position: 'before' },
+ { label: 'Download', path: '#download', icon: 'download', icon_position: 'before' },
+ ],
+ },
+ ],
+ } only %}
+
+
+
+
+
Tabs with content
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'tabs',
+ navigation_id: 'navigation-tabs',
+ tabs_content: true,
+ items: [
+ {
+ id: 'overview-tab',
+ label: 'Overview',
+ path: '#overview-panel',
+ target: 'overview-panel',
+ content: overview_content,
+ active: true,
+ },
+ {
+ id: 'details-tab',
+ label: 'Details',
+ path: '#details-panel',
+ target: 'details-panel',
+ content: details_content,
+ },
+ ],
+ } only %}
+
+
+
+
Navbar
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'navbar',
+ alignment: 'center',
+ items: [
+ { label: 'Home', path: '#home', active: true },
+ { label: 'Topics', path: '#topics' },
+ { label: 'Contact', path: '#contact' },
+ ],
+ } only %}
+
+
+
+
Full width, aligned to end
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'default',
+ full_width: true,
+ alignment: 'end',
+ items: [
+ { label: 'First item', path: '#first' },
+ { label: 'Second item', path: '#second' },
+ { label: 'Third item', path: '#third' },
+ ],
+ } only %}
+
+
+
+
Nested navigation
+ {% include 'oe_bootstrap_theme:navigation' with {
+ variant: 'default',
+ items: [
+ {
+ label: 'Parent section',
+ path: '#parent',
+ navigation: {
+ variant: 'pills',
+ orientation: 'vertical',
+ items: [
+ { label: 'Child page one', path: '#child-one' },
+ { label: 'Child page two', path: '#child-two' },
+ ],
+ },
+ },
+ ],
+ } only %}
+
+
diff --git a/components/navigation/navigation.twig b/components/navigation/navigation.twig
new file mode 100644
index 000000000..a4cd75b6c
--- /dev/null
+++ b/components/navigation/navigation.twig
@@ -0,0 +1,21 @@
+{#
+/**
+ * @file
+ * Navigation component.
+ */
+#}
+{% set _variant = variant|default('default') %}
+
+{% include '@oe-bcl/navigation' with {
+ id: navigation_id,
+ items: items|default([])|bcl_navigation_items(icon_path),
+ pills: _variant == 'pills',
+ tabs: _variant == 'tabs',
+ tabs_content: tabs_content ?? false,
+ vertical: orientation|default('horizontal') == 'vertical',
+ full_width: full_width ?? false,
+ alignment: alignment,
+ nav: _variant in ['pills', 'tabs'],
+ navbar: _variant == 'navbar',
+ attributes: attributes,
+} only %}
diff --git a/components/offcanvas/offcanvas.component.yml b/components/offcanvas/offcanvas.component.yml
new file mode 100644
index 000000000..e4dd2fd8e
--- /dev/null
+++ b/components/offcanvas/offcanvas.component.yml
@@ -0,0 +1,178 @@
+name: Offcanvas
+description: 'Displays content in a responsive panel with an optional button to show and hide it.'
+props:
+ type: object
+ properties:
+ variant:
+ title: Variant
+ type: string
+ description: 'The offcanvas presentation. Defaults to default.'
+ enum:
+ - default
+ - search
+ 'meta:enum':
+ default: Default
+ search: Search
+ offcanvas_id:
+ title: 'Offcanvas ID'
+ type: string
+ description: 'ID connecting the panel, close button, and toggle. A unique ID is generated when omitted.'
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Offcanvas title.'
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'HTML tag used for the title. Defaults to h2.'
+ title_link:
+ title: 'Title link'
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ variant:
+ type: string
+ id:
+ type: string
+ disabled:
+ type: boolean
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ icon_display:
+ type: string
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ placement:
+ title: Placement
+ type: string
+ description: 'Side from which the offcanvas opens.'
+ enum:
+ - start
+ - end
+ - top
+ - bottom
+ 'meta:enum':
+ start: Start
+ end: End
+ top: Top
+ bottom: Bottom
+ body:
+ title: Body
+ type: [string, array, object]
+ description: 'Offcanvas body content.'
+ body_scroll:
+ title: 'Body scroll'
+ type: boolean
+ description: 'Allows the page body to scroll while the offcanvas is open. Defaults to false.'
+ backdrop:
+ title: Backdrop
+ type: boolean
+ description: 'Displays a backdrop while the offcanvas is open. Defaults to false.'
+ with_close:
+ title: 'Close button'
+ type: boolean
+ description: 'Displays the close button. Defaults to true.'
+ close_aria_label:
+ title: 'Close button accessible label'
+ type: string
+ description: 'Accessible label for the close button. Defaults to Close.'
+ extra_classes_body:
+ title: 'Body classes'
+ type: string
+ description: 'Additional classes for the offcanvas body.'
+ extra_classes_header:
+ title: 'Header classes'
+ type: string
+ description: 'Additional classes for the offcanvas header.'
+ extra_classes_close:
+ title: 'Close button classes'
+ type: string
+ description: 'Additional classes for the close button.'
+ responsiveness:
+ title: Responsiveness
+ type: string
+ description: 'Breakpoint at which the offcanvas becomes static. Defaults to lg.'
+ enum:
+ - none
+ - sm
+ - md
+ - lg
+ - xl
+ - xxl
+ 'meta:enum':
+ none: 'Always offcanvas'
+ sm: Small
+ md: Medium
+ lg: 'Large (default)'
+ xl: 'Extra large'
+ xxl: 'Extra extra large'
+ toggle:
+ title: 'Toggle button'
+ type: object
+ description: 'Optional button that opens the offcanvas.'
+ properties:
+ label:
+ type: [string, array, object]
+ variant:
+ type: string
+ size:
+ type: string
+ type:
+ type: string
+ outline:
+ type: boolean
+ disabled:
+ type: boolean
+ text_nowrap:
+ type: boolean
+ assistive_text:
+ type: string
+ id:
+ type: string
+ icon:
+ anyOf:
+ - type: string
+ - type: object
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ icon_spacing:
+ type: string
+ clean_class:
+ type: boolean
+ remove_icon_spacers:
+ type: boolean
+ attributes:
+ type: object
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/offcanvas/offcanvas.story.twig b/components/offcanvas/offcanvas.story.twig
new file mode 100644
index 000000000..7b768225c
--- /dev/null
+++ b/components/offcanvas/offcanvas.story.twig
@@ -0,0 +1,152 @@
+{% set body %}
+
Use this panel for supporting navigation, filters, or focused actions without leaving the current page.
+
View related information
+{% endset %}
+
+{% set search_body %}
+
+{% endset %}
+
+
+
+
Start placement
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'default',
+ offcanvas_id: 'offcanvas-start-demo',
+ placement: 'start',
+ responsiveness: 'none',
+ title: 'Navigation and information',
+ body: body,
+ extra_classes_body: 'd-block',
+ toggle: {
+ label: 'Open start panel',
+ icon: 'filter',
+ icon_position: 'before',
+ },
+ } only %}
+
+
+
+
End placement with backdrop
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'default',
+ offcanvas_id: 'offcanvas-end-demo',
+ placement: 'end',
+ responsiveness: 'none',
+ backdrop: true,
+ title: 'Supporting actions',
+ body: body,
+ extra_classes_body: 'd-block',
+ toggle: { label: 'Open end panel', variant: 'secondary' },
+ } only %}
+
+
+
+
Search variant
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'search',
+ offcanvas_id: 'offcanvas-search-demo',
+ placement: 'top',
+ responsiveness: 'none',
+ title: 'Search this site',
+ body: search_body,
+ extra_classes_header: 'border-bottom',
+ extra_classes_body: 'py-4',
+ toggle: { label: 'Open search', icon: 'search' },
+ } only %}
+
+ Select Open search again, use the panel close button, or press Esc to close it.
+
+
+
+
+
Bottom placement
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'default',
+ offcanvas_id: 'offcanvas-bottom-demo',
+ placement: 'bottom',
+ responsiveness: 'none',
+ title: 'Additional options',
+ body: body,
+ extra_classes_body: 'd-block',
+ toggle: { label: 'Open bottom panel', outline: true },
+ } only %}
+
+
+
+
Responsive from extra-extra-large screens
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'default',
+ offcanvas_id: 'offcanvas-responsive-xxl-demo',
+ placement: 'end',
+ responsiveness: 'xxl',
+ title: 'Responsive filters',
+ body: body,
+ body_scroll: true,
+ extra_classes_body: 'd-block py-3',
+ toggle: {
+ label: 'Open responsive filters',
+ outline: true,
+ icon: 'funnel',
+ icon_position: 'before',
+ attributes: { class: ['d-xxl-none'] },
+ },
+ } only %}
+
+
+
+
Custom options
+ {% include 'oe_bootstrap_theme:offcanvas' with {
+ variant: 'default',
+ offcanvas_id: 'offcanvas-custom-demo',
+ placement: 'end',
+ responsiveness: 'none',
+ title: 'Linked panel title',
+ title_tag: 'h4',
+ title_link: {
+ path: '/example',
+ icon: {
+ name: 'arrow-right',
+ transformation: 'rotate-45',
+ },
+ icon_position: 'after',
+ },
+ title_attributes: create_attribute().addClass('text-success'),
+ body: body,
+ body_scroll: true,
+ backdrop: true,
+ close_aria_label: 'Close custom panel',
+ extra_classes_header: 'border-bottom bg-light',
+ extra_classes_body: 'd-block py-4',
+ extra_classes_close: 'me-1',
+ attributes: create_attribute().addClass('custom-offcanvas').setAttribute('data-demo', 'custom-options'),
+ toggle: {
+ label: 'Open custom panel',
+ variant: 'success',
+ size: 'lg',
+ assistive_text: 'Custom offcanvas example',
+ icon: {
+ name: 'arrow-left',
+ transformation: 'flip-horizontal',
+ },
+ icon_position: 'before',
+ icon_spacing: 'sm',
+ attributes: { 'data-demo-trigger': 'custom-options' },
+ },
+ } only %}
+
+
diff --git a/components/offcanvas/offcanvas.twig b/components/offcanvas/offcanvas.twig
new file mode 100644
index 000000000..7b37b30d8
--- /dev/null
+++ b/components/offcanvas/offcanvas.twig
@@ -0,0 +1,71 @@
+{#
+/**
+ * @file
+ * Offcanvas component.
+ */
+#}
+{% set _variant = variant|default('default') %}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _offcanvas_id = offcanvas_id|default(_attributes.id|default('offcanvas'|clean_unique_id)) %}
+{% set _title_attributes = title_attributes|default(create_attribute()) %}
+{% set _title_link = title_link|default({}) %}
+{% if _title_link is not empty %}
+ {% set _title_link = _title_link|merge({
+ attributes: create_attribute(_title_link.attributes|default({})),
+ }) %}
+ {% if _title_link.icon is defined and _title_link.icon is not empty %}
+ {% set _title_link_icon = _title_link.icon is iterable ? _title_link.icon : { name: _title_link.icon } %}
+ {% set _title_link = _title_link|merge({
+ icon: _title_link_icon|merge({
+ path: _title_link_icon.path|default(bcl_icon_path()),
+ attributes: create_attribute(_title_link_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+{% endif %}
+
+{% set _extra_classes_body = extra_classes_body|default('') %}
+{% set _extra_classes_header = extra_classes_header|default('') %}
+{% set _extra_classes_close = extra_classes_close|default('') %}
+{% set _responsiveness = responsiveness|default('lg') %}
+{% if _responsiveness == 'none' %}
+ {% set _responsiveness = '' %}
+{% endif %}
+{% if _variant == 'search' %}
+ {% set _attributes = _attributes.addClass('bcl-offcanvas') %}
+ {% set _extra_classes_body = ('d-block ' ~ _extra_classes_body)|trim %}
+ {% if _responsiveness is not empty %}
+ {% set _title_attributes = _title_attributes.addClass('mb-' ~ _responsiveness ~ '-4') %}
+ {% set _extra_classes_body = ('p-' ~ _responsiveness ~ '-0 ' ~ _extra_classes_body)|trim %}
+ {% set _extra_classes_header = ('p-' ~ _responsiveness ~ '-0 d-' ~ _responsiveness ~ '-block ' ~ _extra_classes_header)|trim %}
+ {% set _extra_classes_close = ('d-' ~ _responsiveness ~ '-none ' ~ _extra_classes_close)|trim %}
+ {% endif %}
+{% endif %}
+
+{% include '@oe-bcl/offcanvas' with {
+ title: title,
+ title_tag: title_tag|default('h2'),
+ title_link: _title_link,
+ title_attributes: _title_attributes,
+ placement: placement,
+ id: _offcanvas_id,
+ body: body,
+ with_body_scroll: body_scroll ?? false,
+ with_backdrop: backdrop ?? false,
+ with_close: with_close ?? true,
+ close_aria_label: close_aria_label|default('Close'|t),
+ extra_classes_body: _extra_classes_body,
+ extra_classes_header: _extra_classes_header,
+ extra_classes_close: _extra_classes_close,
+ responsiveness: _responsiveness,
+ attributes: _attributes,
+} only %}
+
+{% if toggle is not empty %}
+ {% set _toggle_attributes = create_attribute(toggle.attributes|default({}))
+ .setAttribute('data-bs-toggle', 'offcanvas')
+ .setAttribute('data-bs-target', '#' ~ _offcanvas_id)
+ .setAttribute('aria-controls', _offcanvas_id) %}
+ {% set _toggle = toggle|merge({ attributes: _toggle_attributes }) %}
+ {% include 'oe_bootstrap_theme:button' with _toggle only %}
+{% endif %}
diff --git a/components/pagination/pagination.component.yml b/components/pagination/pagination.component.yml
new file mode 100644
index 000000000..536b0fe8a
--- /dev/null
+++ b/components/pagination/pagination.component.yml
@@ -0,0 +1,276 @@
+name: Pagination
+description: 'Displays navigation for a series of related content split across multiple pages.'
+props:
+ type: object
+ properties:
+ variant:
+ title: Variant
+ type: string
+ description: 'Pagination presentation. Defaults to standard pagination.'
+ enum:
+ - default
+ - glossary
+ 'meta:enum':
+ default: Default
+ glossary: Glossary
+ size:
+ title: Size
+ type: string
+ description: 'Optional pagination size.'
+ enum:
+ - sm
+ - lg
+ 'meta:enum':
+ sm: Small
+ lg: Large
+ alignment:
+ title: Alignment
+ type: string
+ description: 'Optional pagination alignment.'
+ enum:
+ - center
+ - end
+ 'meta:enum':
+ center: Center
+ end: End
+ enable_prev_next_icons:
+ title: 'Enable previous and next icons'
+ type: boolean
+ description: 'Replaces previous and next labels with icons. Defaults to false.'
+ default: false
+ enable_first_last_icons:
+ title: 'Enable first and last icons'
+ type: boolean
+ description: 'Replaces first and last labels with icons. Defaults to true.'
+ default: true
+ ellipsis_before:
+ title: 'Ellipsis before'
+ type: boolean
+ description: 'Adds an ellipsis before the numbered items. Defaults to false.'
+ ellipsis_after:
+ title: 'Ellipsis after'
+ type: boolean
+ description: 'Adds an ellipsis after the numbered items. Defaults to false.'
+ first:
+ title: 'First link'
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ attributes:
+ type: object
+ prev:
+ title: 'Previous link'
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ attributes:
+ type: object
+ next:
+ title: 'Next link'
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ attributes:
+ type: object
+ last:
+ title: 'Last link'
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ attributes:
+ type: object
+ items:
+ title: Items
+ type: array
+ description: 'Numbered or glossary pagination items.'
+ items:
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: [string, object]
+ properties:
+ name:
+ type: string
+ path:
+ type: string
+ size:
+ type: string
+ enum:
+ - 2xs
+ - xs
+ - s
+ - m
+ - l
+ - xl
+ - 2xl
+ - fluid
+ transformation:
+ type: string
+ enum:
+ - rotate-90
+ - rotate-180
+ - rotate-270
+ - flip-horizontal
+ - flip-vertical
+ attributes:
+ type: object
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ list_attributes:
+ title: 'List attributes'
+ type: Drupal\Core\Template\Attribute
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/pagination/pagination.story.twig b/components/pagination/pagination.story.twig
new file mode 100644
index 000000000..1f28c120d
--- /dev/null
+++ b/components/pagination/pagination.story.twig
@@ -0,0 +1,55 @@
+{% set page_items = [
+ { path: '/example?page=0', label: '1' },
+ { path: '/example?page=1', label: '2', active: true },
+ { path: '/example?page=2', label: '3' },
+] %}
+
+
+
+
Default pagination
+ {% include 'oe_bootstrap_theme:pagination' with {
+ size: 'sm',
+ alignment: 'center',
+ enable_first_last_icons: false,
+ first: { path: '/example?page=0', label: 'First' },
+ prev: { path: '/example?page=0', label: 'Previous' },
+ next: { path: '/example?page=2', label: 'Next' },
+ last: { path: '/example?page=7', label: 'Last' },
+ items: page_items,
+ attributes: create_attribute().setAttribute('aria-label', 'Pagination'),
+ } only %}
+
+
+
+
Icons and ellipsis
+ {% include 'oe_bootstrap_theme:pagination' with {
+ alignment: 'end',
+ enable_prev_next_icons: true,
+ ellipsis_before: true,
+ ellipsis_after: true,
+ first: { path: '/example?page=0', label: 'First' },
+ prev: { path: '/example?page=3', label: 'Previous' },
+ next: { path: '/example?page=5', label: 'Next' },
+ last: { path: '/example?page=9', label: 'Last' },
+ items: [
+ { path: '/example?page=4', label: '5', active: true },
+ ],
+ attributes: create_attribute().setAttribute('aria-label', 'Pagination with skipped pages'),
+ } only %}
+
+
+
+
Glossary pagination
+ {% include 'oe_bootstrap_theme:pagination' with {
+ variant: 'glossary',
+ items: [
+ { path: '#a', label: 'A' },
+ { path: '#b', label: 'B', active: true },
+ { path: '#c', label: 'C' },
+ { path: '#d', label: 'D' },
+ { path: '#e', label: 'E' },
+ { path: '#numbers', label: '0-9' },
+ ],
+ } only %}
+
+
diff --git a/components/pagination/pagination.twig b/components/pagination/pagination.twig
new file mode 100644
index 000000000..f98619bd0
--- /dev/null
+++ b/components/pagination/pagination.twig
@@ -0,0 +1,85 @@
+{#
+/**
+ * @file
+ * Pagination component.
+ */
+#}
+{% set _variant = variant|default('default') %}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _list_attributes = list_attributes|default(create_attribute()).addClass('js-pager__items') %}
+
+{% if _variant == 'glossary' and not _attributes.hasAttribute('aria-label') and not _attributes.hasAttribute('aria-labelledby') %}
+ {% set _attributes = _attributes.setAttribute('aria-label', 'Glossary links'|t) %}
+{% endif %}
+
+{% set _controls = [
+ { item: first|default({}), label: 'First'|t },
+ { item: prev|default({}), label: 'Previous'|t },
+ { item: next|default({}), label: 'Next'|t },
+ { item: last|default({}), label: 'Last'|t },
+] %}
+{% set _processed_controls = [] %}
+{% for control in _controls %}
+ {% set _item = control.item %}
+ {% if _item is not empty %}
+ {% set _item_attributes = create_attribute(_item.attributes|default({})) %}
+ {% if not _item_attributes.hasAttribute('aria-label') %}
+ {% set _item_attributes = _item_attributes.setAttribute('aria-label', control.label) %}
+ {% endif %}
+ {% set _item = _item|merge({ attributes: _item_attributes }) %}
+ {% if _item.icon is defined and _item.icon is not empty %}
+ {% set _item_icon = _item.icon is iterable ? _item.icon : { name: _item.icon } %}
+ {% set _item_icon_path = _item_icon.path|default(_icon_path) %}
+ {% set _item = _item|merge({
+ icon_path: _item_icon_path,
+ icon: _item_icon|merge({
+ path: _item_icon_path,
+ size: _item_icon.size|default('fluid'),
+ attributes: create_attribute(_item_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+ {% endif %}
+ {% set _processed_controls = _processed_controls|merge([_item]) %}
+{% endfor %}
+
+{% set _items = [] %}
+{% for item in items|default([]) %}
+ {% set _item_attributes = create_attribute(item.attributes|default({})) %}
+ {% if not _item_attributes.hasAttribute('aria-label') and item.label is not empty %}
+ {% set _item_attributes = _item_attributes.setAttribute('aria-label', item.label) %}
+ {% endif %}
+ {% set _item = item|merge({ attributes: _item_attributes }) %}
+ {% if _item.icon is defined and _item.icon is not empty %}
+ {% set _item_icon = _item.icon is iterable ? _item.icon : { name: _item.icon } %}
+ {% set _item_icon_path = _item_icon.path|default(_icon_path) %}
+ {% set _item = _item|merge({
+ icon_path: _item_icon_path,
+ icon: _item_icon|merge({
+ path: _item_icon_path,
+ size: _item_icon.size|default('fluid'),
+ attributes: create_attribute(_item_icon.attributes|default({})),
+ }),
+ }) %}
+ {% endif %}
+ {% set _items = _items|merge([_item]) %}
+{% endfor %}
+
+{% include '@oe-bcl/pagination' with {
+ variant: _variant == 'default' ? '' : _variant,
+ size: size,
+ alignment: alignment,
+ enable_prev_next_icon: enable_prev_next_icons,
+ enable_first_last_icon: enable_first_last_icons,
+ ellipsis_before: ellipsis_before,
+ ellipsis_after: ellipsis_after,
+ first: _processed_controls[0],
+ prev: _processed_controls[1],
+ next: _processed_controls[2],
+ last: _processed_controls[3],
+ items: _items,
+ icon_path: _icon_path,
+ attributes: _attributes,
+ list_attributes: _list_attributes,
+} only %}
diff --git a/components/pagination_v2/pagination_v2.component.yml b/components/pagination_v2/pagination_v2.component.yml
new file mode 100644
index 000000000..895738346
--- /dev/null
+++ b/components/pagination_v2/pagination_v2.component.yml
@@ -0,0 +1,248 @@
+name: 'Pagination v2'
+description: 'Indicates a series of related content split across multiple pages, with responsive labels and optional ellipses.'
+props:
+ type: object
+ properties:
+ size:
+ title: Size
+ type: string
+ description: 'Size of the pagination.'
+ enum:
+ - sm
+ - lg
+ 'meta:enum':
+ sm: Small
+ lg: Large
+ alignment:
+ title: Alignment
+ type: string
+ description: 'Alignment of the pagination on medium screens and above. Defaults to center.'
+ enum:
+ - start
+ - center
+ - end
+ 'meta:enum':
+ start: Start
+ center: Center
+ end: End
+ aria_label:
+ title: 'ARIA label'
+ type: string
+ description: 'Accessible label for the navigation wrapper.'
+ enable_prev_next_icon:
+ title: 'Previous and next icons'
+ type: boolean
+ description: 'Show icons instead of text for previous and next links. Defaults to true.'
+ enable_first_last_icon:
+ title: 'First and last icons'
+ type: boolean
+ description: 'Show icons instead of text for first and last links. Defaults to false.'
+ ellipsis_before:
+ title: 'Ellipsis before'
+ type: boolean
+ description: 'Add an ellipsis before the page items.'
+ ellipsis_after:
+ title: 'Ellipsis after'
+ type: boolean
+ description: 'Add an ellipsis after the page items.'
+ current_mobile_label:
+ title: 'Current mobile label'
+ type: [string, array, object]
+ description: 'Replacement label for the active page on small screens.'
+ first:
+ title: 'First link'
+ type: object
+ description: 'Link to the first page.'
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ aria_label:
+ type: string
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ prev:
+ title: 'Previous link'
+ type: object
+ description: 'Link to the previous page.'
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ aria_label:
+ type: string
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ next:
+ title: 'Next link'
+ type: object
+ description: 'Link to the next page.'
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ aria_label:
+ type: string
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ last:
+ title: 'Last link'
+ type: object
+ description: 'Link to the last page.'
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ aria_label:
+ type: string
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ items:
+ title: 'Pagination items'
+ type: array
+ description: 'Page links displayed by the pagination.'
+ items:
+ type: object
+ properties:
+ path:
+ type: [string, object]
+ label:
+ type: [string, array, object]
+ aria_label:
+ type: string
+ disabled:
+ type: boolean
+ active:
+ type: boolean
+ icon:
+ type: object
+ properties:
+ name:
+ type: string
+ size:
+ type: string
+ path:
+ type: string
+ transformation:
+ type: string
+ attributes:
+ type: object
+ icon_position:
+ type: string
+ enum:
+ - before
+ - after
+ icon_path:
+ type: string
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to the SVG icon file. Defaults to the BCL icon path.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
+ list_attributes:
+ title: 'List attributes'
+ type: Drupal\Core\Template\Attribute
diff --git a/components/pagination_v2/pagination_v2.story.twig b/components/pagination_v2/pagination_v2.story.twig
new file mode 100644
index 000000000..672b9f6f8
--- /dev/null
+++ b/components/pagination_v2/pagination_v2.story.twig
@@ -0,0 +1,66 @@
+{% set pages = [
+ { path: '#page-1', label: '1' },
+ { label: '2', active: true },
+ { path: '#page-3', label: '3' },
+ { path: '#page-4', label: '4' },
+] %}
+
+
+
+
Default
+ {% include 'oe_bootstrap_theme:pagination_v2' with {
+ aria_label: 'Default pagination',
+ first: { path: '#first', label: 'First' },
+ prev: { path: '#previous', label: 'Previous' },
+ next: { path: '#next', label: 'Next' },
+ last: { path: '#last', label: 'Last' },
+ items: pages,
+ } only %}
+
+
+
+
Icons and ellipses
+ {% include 'oe_bootstrap_theme:pagination_v2' with {
+ aria_label: 'Pagination with icons and ellipses',
+ enable_first_last_icon: true,
+ ellipsis_before: true,
+ ellipsis_after: true,
+ current_mobile_label: 'Page 6 of 10',
+ first: { path: '#first' },
+ prev: { path: '#previous' },
+ next: { path: '#next' },
+ last: { path: '#last' },
+ items: [
+ { path: '#page-4', label: '4' },
+ { path: '#page-5', label: '5' },
+ { label: '6', active: true },
+ { path: '#page-7', label: '7' },
+ ],
+ } only %}
+
+
+
+
Small, aligned to start
+ {% include 'oe_bootstrap_theme:pagination_v2' with {
+ aria_label: 'Small pagination',
+ size: 'sm',
+ alignment: 'start',
+ prev: { path: '#previous', label: 'Previous' },
+ next: { path: '#next', label: 'Next' },
+ items: pages,
+ } only %}
+
+
+
+
Large, aligned to end
+ {% include 'oe_bootstrap_theme:pagination_v2' with {
+ aria_label: 'Large pagination',
+ size: 'lg',
+ alignment: 'end',
+ enable_prev_next_icon: false,
+ prev: { path: '#previous', label: 'Previous' },
+ next: { path: '#next', label: 'Next' },
+ items: pages,
+ } only %}
+
+
diff --git a/components/pagination_v2/pagination_v2.twig b/components/pagination_v2/pagination_v2.twig
new file mode 100644
index 000000000..d506dc994
--- /dev/null
+++ b/components/pagination_v2/pagination_v2.twig
@@ -0,0 +1,87 @@
+{#
+/**
+ * @file
+ * Pagination v2 component.
+ */
+#}
+{% set _icon_path = icon_path|default(bcl_icon_path()) %}
+{% set _attributes = attributes|default(create_attribute()) %}
+
+{% if aria_label is not empty %}
+ {% set _attributes = _attributes.setAttribute('aria-label', aria_label) %}
+{% endif %}
+
+{% set _controls = [] %}
+{% for entry in [
+ { item: first|default({}), aria_label: 'First'|t },
+ { item: prev|default({}), aria_label: 'Previous'|t },
+ { item: next|default({}), aria_label: 'Next'|t },
+ { item: last|default({}), aria_label: 'Last'|t },
+] %}
+ {% set _control = entry.item %}
+ {% if _control is not empty %}
+ {% set _control = _control|merge({
+ aria_label: _control.aria_label|default(entry.aria_label),
+ attributes: create_attribute(_control.attributes|default({})),
+ }) %}
+
+ {% if _control.icon is defined and _control.icon is not empty %}
+ {% set _control = _control|merge({
+ icon: _control.icon|merge({
+ attributes: create_attribute(_control.icon.attributes|default({})),
+ }),
+ icon_path: _control.icon_path|default(_control.icon.path|default(_icon_path)),
+ }) %}
+ {% endif %}
+ {% endif %}
+ {% set _controls = _controls|merge([_control]) %}
+{% endfor %}
+
+{% set _items = [] %}
+{% if ellipsis_before ?? false %}
+ {% set _items = _items|merge([{
+ item_type: 'ellipsis',
+ aria_label: 'Ellipsis'|t,
+ }]) %}
+{% endif %}
+
+{% for item in items|default([]) %}
+ {% set _item = item|merge({
+ aria_label: item.aria_label|default(item.label|default('')),
+ attributes: create_attribute(item.attributes|default({})),
+ }) %}
+
+ {% if _item.icon is defined and _item.icon is not empty %}
+ {% set _item = _item|merge({
+ icon: _item.icon|merge({
+ attributes: create_attribute(_item.icon.attributes|default({})),
+ }),
+ icon_path: _item.icon_path|default(_item.icon.path|default(_icon_path)),
+ }) %}
+ {% endif %}
+
+ {% set _items = _items|merge([_item]) %}
+{% endfor %}
+
+{% if ellipsis_after ?? false %}
+ {% set _items = _items|merge([{
+ item_type: 'ellipsis',
+ aria_label: 'Ellipsis'|t,
+ }]) %}
+{% endif %}
+
+{% include '@oe-bcl/bcl-pagination-v2/pagination.html.twig' with {
+ size: size,
+ alignment: alignment|default('center'),
+ enable_prev_next_icon: enable_prev_next_icon ?? true,
+ enable_first_last_icon: enable_first_last_icon ?? false,
+ current_mobile_label: current_mobile_label,
+ first: _controls[0],
+ prev: _controls[1],
+ next: _controls[2],
+ last: _controls[3],
+ items: _items,
+ icon_path: _icon_path,
+ attributes: _attributes,
+ list_attributes: list_attributes|default(create_attribute()),
+} only %}
diff --git a/components/progress/progress.component.yml b/components/progress/progress.component.yml
new file mode 100644
index 000000000..9a0e48571
--- /dev/null
+++ b/components/progress/progress.component.yml
@@ -0,0 +1,66 @@
+name: Progress
+description: 'Indicate the progress as a bar.'
+props:
+ type: object
+ properties:
+ label:
+ title: Label
+ type: [string, array, object]
+ description: 'Text before the progress bar.'
+ progress:
+ title: Progress
+ type: [integer, number]
+ description: 'The progress value for the bar.'
+ min:
+ title: Minimum
+ type: [integer, number, string]
+ description: 'Minimum value shown in the progress bar.'
+ max:
+ title: Maximum
+ type: [integer, number, string]
+ description: 'Maximum value shown in the progress bar.'
+ text:
+ title: Text
+ type: [string, array, object]
+ description: 'Text inside the progress bar.'
+ message:
+ title: Message
+ type: [string, array, object]
+ description: 'Optional description text below the progress bar.'
+ style:
+ title: 'Bar style'
+ type: string
+ description: 'Set the bar style.'
+ enum:
+ - primary
+ - secondary
+ - success
+ - danger
+ - warning
+ - info
+ - light
+ - dark
+ 'meta:enum':
+ primary: Primary
+ secondary: Secondary
+ success: Success
+ danger: Danger
+ warning: Warning
+ info: Info
+ light: Light
+ dark: Dark
+ striped:
+ title: Striped
+ type: boolean
+ description: 'Striped style for the progress bar. Defaults to false.'
+ animated:
+ title: Animated
+ type: boolean
+ description: 'Animation on striped bar. Defaults to false.'
+ hidden_label:
+ title: 'Hidden label'
+ type: boolean
+ description: 'Hides the text inside the progress bar. Defaults to false.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/progress/progress.story.twig b/components/progress/progress.story.twig
new file mode 100644
index 000000000..092329c31
--- /dev/null
+++ b/components/progress/progress.story.twig
@@ -0,0 +1,73 @@
+{% set examples = [
+ {
+ style: 'primary',
+ striped: true,
+ animated: true,
+ progress: 60,
+ label: 'Primary progress',
+ message: 'Progress bar description',
+ },
+ {
+ style: 'secondary',
+ progress: 45,
+ label: 'Secondary progress',
+ },
+ {
+ style: 'success',
+ progress: 75,
+ label: 'Success progress',
+ message: 'Most of the work is complete.',
+ },
+ {
+ style: 'danger',
+ progress: 25,
+ label: 'Danger progress',
+ },
+ {
+ style: 'warning',
+ progress: 50,
+ label: 'Warning progress',
+ },
+ {
+ style: 'info',
+ progress: 65,
+ label: 'Info progress',
+ },
+ {
+ style: 'light',
+ progress: 35,
+ label: 'Light progress',
+ wrapper_attributes: create_attribute().addClass(['bg-secondary', 'p-3', 'text-white']),
+ },
+ {
+ style: 'dark',
+ progress: 85,
+ label: 'Dark progress',
+ },
+ {
+ style: 'primary',
+ progress: 33.5,
+ label: 'Progress with hidden value',
+ hidden_label: true,
+ },
+] %}
+
+
+ {% for example in examples %}
+ {% set wrapper_attributes = example.wrapper_attributes|default(create_attribute()) %}
+
+ {% include 'oe_bootstrap_theme:progress' with {
+ style: example.style,
+ striped: example.striped|default(false),
+ animated: example.animated|default(false),
+ hidden_label: example.hidden_label|default(false),
+ progress: example.progress,
+ min: 0,
+ max: 100,
+ label: example.label,
+ text: example.progress ~ '%',
+ message: example.message|default(''),
+ } only %}
+
+ {% endfor %}
+
diff --git a/components/progress/progress.twig b/components/progress/progress.twig
new file mode 100644
index 000000000..5ecb15be5
--- /dev/null
+++ b/components/progress/progress.twig
@@ -0,0 +1,19 @@
+{#
+/**
+ * @file
+ * Progress component.
+ */
+#}
+{% include '@oe-bcl/progress' with {
+ progress: progress,
+ min: min,
+ max: max,
+ message: message,
+ striped: striped,
+ animated: animated,
+ hidden_label: hidden_label,
+ label: label,
+ variant: style,
+ bar_label: text,
+ attributes: attributes,
+} only %}
diff --git a/components/section/section.component.yml b/components/section/section.component.yml
new file mode 100644
index 000000000..d622a471d
--- /dev/null
+++ b/components/section/section.component.yml
@@ -0,0 +1,30 @@
+name: Section
+description: 'Renders content and heading inside section.'
+props:
+ type: object
+ properties:
+ content:
+ title: Content
+ type: [string, array, object]
+ description: 'Content of the section.'
+ heading:
+ title: Heading
+ type: [string, array, object]
+ description: 'Heading of the section.'
+ heading_tag:
+ title: 'Heading tag'
+ type: string
+ description: 'Tag of the heading. Defaults to h2.'
+ tag:
+ title: 'Wrapper tag'
+ type: string
+ description: 'Tag of the wrapper. Defaults to section.'
+ heading_attributes:
+ title: 'Heading attributes'
+ type: Drupal\Core\Template\Attribute
+ wrapper_attributes:
+ title: 'Wrapper attributes'
+ type: Drupal\Core\Template\Attribute
+ attributes:
+ title: 'Section attributes'
+ type: Drupal\Core\Template\Attribute
diff --git a/components/section/section.story.twig b/components/section/section.story.twig
new file mode 100644
index 000000000..2696b012e
--- /dev/null
+++ b/components/section/section.story.twig
@@ -0,0 +1,25 @@
+{% set content %}
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.
+{% endset %}
+
+
+ {% include 'oe_bootstrap_theme:section' with {
+ heading: 'This is the title of the section',
+ content: content,
+ } only %}
+
+ {% include 'oe_bootstrap_theme:section' with {
+ tag: 'article',
+ heading_tag: 'h3',
+ heading: 'Section with custom tags',
+ content: content,
+ attributes: create_attribute().addClass(['content-wrapper']).setAttribute('id', 'section-id'),
+ heading_attributes: create_attribute().addClass(['content-heading']),
+ wrapper_attributes: create_attribute().addClass(['content-body']),
+ } only %}
+
+ {% include 'oe_bootstrap_theme:section' with {
+ content: content,
+ } only %}
+
diff --git a/components/section/section.twig b/components/section/section.twig
new file mode 100644
index 000000000..bb6ffe337
--- /dev/null
+++ b/components/section/section.twig
@@ -0,0 +1,22 @@
+{#
+/**
+ * @file
+ * Section component.
+ */
+#}
+{% set _tag = tag|default('section') %}
+{% set _heading_tag = heading_tag|default('h2') %}
+{% set _attributes = attributes|default(create_attribute()) %}
+{% set _heading_attributes = heading_attributes|default(create_attribute()) %}
+{% set _wrapper_attributes = wrapper_attributes|default(create_attribute()) %}
+
+<{{ _tag }}{{ _attributes.addClass(['section']) }}>
+ {% if heading is not empty %}
+ <{{ _heading_tag }}{{ _heading_attributes.addClass(['section__title']) }}>
+ {{ heading }}
+ {{ _heading_tag }}>
+ {% endif %}
+
+ {{ content }}
+
+{{ _tag }}>
diff --git a/components/timeline/timeline.component.yml b/components/timeline/timeline.component.yml
new file mode 100644
index 000000000..a65e02127
--- /dev/null
+++ b/components/timeline/timeline.component.yml
@@ -0,0 +1,65 @@
+name: Timeline
+description: 'Timeline is a flexible and powerful component for displaying a series of content ordered in time.'
+props:
+ type: object
+ properties:
+ title:
+ title: Title
+ type: [string, array, object]
+ description: 'Title of the timeline block.'
+ title_link:
+ title: 'Title link'
+ type: object
+ description: 'Link applied to the timeline title.'
+ properties:
+ label:
+ type: [string, array, object]
+ path:
+ type: string
+ title_tag:
+ title: 'Title tag'
+ type: string
+ description: 'The tag to use for the title. Defaults to h2.'
+ title_attributes:
+ title: 'Title attributes'
+ type: Drupal\Core\Template\Attribute
+ expand_button_label:
+ title: 'Label expanded'
+ type: string
+ description: 'When all items are displayed, the label of the button to hide them.'
+ collapse_button_label:
+ title: 'Label collapsed'
+ type: string
+ description: 'When there are hidden items, the label of the button to expand them.'
+ hide_from:
+ title: 'Hide from'
+ type: integer
+ description: 'Indicate the number of items to display in the timeline, hide the others and add a toggle button to show them.'
+ hide_to:
+ title: 'Hide to'
+ type: integer
+ description: 'Indicate the number of bottom items to keep displayed when items are hidden. Defaults to 0.'
+ items:
+ title: Items
+ type: array
+ description: 'List of timeline items, with id, label, title and content.'
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ label:
+ type: [string, array, object]
+ title:
+ type: [string, array, object]
+ content:
+ type: [string, array, object]
+ attributes:
+ type: object
+ icon_path:
+ title: 'Icons path'
+ type: string
+ description: 'Path to icons.'
+ attributes:
+ title: Attributes
+ type: Drupal\Core\Template\Attribute
diff --git a/components/timeline/timeline.story.twig b/components/timeline/timeline.story.twig
new file mode 100644
index 000000000..a27cbdcf2
--- /dev/null
+++ b/components/timeline/timeline.story.twig
@@ -0,0 +1,67 @@
+{% set items = [
+ {
+ label: '13 September 2017',
+ title: 'State of the Union',
+ content: [
+ {
+ '#markup': '
President Juncker State of the Union speech ',
+ },
+ ],
+ },
+ {
+ label: '28-29 September 2017',
+ title: 'Digital Summit',
+ content: [
+ {
+ '#markup': '
Informal Digital Summit, Tallinn ',
+ },
+ ],
+ },
+ {
+ label: '14 November 2017',
+ content: 'Strengthening European identity through education and culture.',
+ },
+ {
+ label: '17 November 2017',
+ content: [
+ {
+ '#markup': '
Social Summit in Gothenburg, Sweden ',
+ },
+ ],
+ },
+ {
+ label: '6 December 2017',
+ content: [
+ {
+ '#markup': '
Economic and Monetary Union package of proposals ',
+ },
+ ],
+ },
+ {
+ label: '14-15 December 2017',
+ content: [
+ {
+ '#markup': '
EU Leaders meeting on migration, Brussels ',
+ },
+ ],
+ },
+] %}
+
+{% include 'oe_bootstrap_theme:timeline' with {
+ title: 'Timeline heading',
+ title_tag: 'h3',
+ hide_from: 3,
+ hide_to: 0,
+ collapse_button_label: 'Display %d more',
+ expand_button_label: 'Hide %d items',
+ items: items,
+} only %}
+
+{% include 'oe_bootstrap_theme:timeline' with {
+ title: 'Timeline with a linked title, no truncation',
+ title_tag: 'h3',
+ title_link: {
+ path: '/example',
+ },
+ items: items,
+} only %}
diff --git a/components/timeline/timeline.twig b/components/timeline/timeline.twig
new file mode 100644
index 000000000..a46fc8316
--- /dev/null
+++ b/components/timeline/timeline.twig
@@ -0,0 +1,28 @@
+{#
+/**
+ * @file
+ * Timeline component.
+ */
+#}
+{% set _items = [] %}
+{% for item in items|default([]) %}
+ {% set _items = _items|merge([item|merge({
+ attributes: create_attribute(item.attributes|default({})),
+ })]) %}
+{% endfor %}
+
+{% include '@oe-bcl/timeline' with {
+ toggle_collapsed: collapse_button_label|default('Show more %d items'|t),
+ toggle_expanded: expand_button_label|default('Hide %d items'|t),
+ hide: {
+ from: hide_from,
+ to: hide_to|default(0),
+ },
+ items: _items,
+ icon_path: icon_path|default(bcl_icon_path()),
+ attributes: attributes,
+ title: title,
+ title_link: title_link,
+ title_tag: title_tag,
+ title_attributes: title_attributes,
+} only %}
diff --git a/composer.json b/composer.json
index 93ec45136..da7966c06 100644
--- a/composer.json
+++ b/composer.json
@@ -10,6 +10,7 @@
"cweagans/composer-patches": "^1.7",
"drupal/core": "^10.3 || ^11",
"drupal/file_link": "^2.2",
+ "drupal/sdc_component_library": "^1.0",
"drupal/ui_patterns": "^1.10",
"drupal/ui_patterns_settings": "^2.3",
"openeuropa/ecl-twig-loader": "^4.0"
diff --git a/modules/oe_bootstrap_theme_helper/oe_bootstrap_theme_helper.services.yml b/modules/oe_bootstrap_theme_helper/oe_bootstrap_theme_helper.services.yml
index 081e0f002..e7f8be463 100644
--- a/modules/oe_bootstrap_theme_helper/oe_bootstrap_theme_helper.services.yml
+++ b/modules/oe_bootstrap_theme_helper/oe_bootstrap_theme_helper.services.yml
@@ -6,6 +6,6 @@ services:
- { name: twig.loader, priority: -100 }
oe_bootstrap_theme_helper.twig_extension:
class: Drupal\oe_bootstrap_theme_helper\TwigExtension\TwigExtension
- arguments: ['@language_manager', '@twig', '@renderer']
+ autowire: true
tags:
- { name: twig.extension }
diff --git a/modules/oe_bootstrap_theme_helper/src/TwigExtension/TwigExtension.php b/modules/oe_bootstrap_theme_helper/src/TwigExtension/TwigExtension.php
index 6985438ff..49484bfef 100644
--- a/modules/oe_bootstrap_theme_helper/src/TwigExtension/TwigExtension.php
+++ b/modules/oe_bootstrap_theme_helper/src/TwigExtension/TwigExtension.php
@@ -5,6 +5,8 @@
namespace Drupal\oe_bootstrap_theme_helper\TwigExtension;
use Drupal\Component\Utility\Html;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\Theme\ThemeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Render\BubbleableMetadata;
@@ -27,42 +29,13 @@
*/
class TwigExtension extends AbstractExtension {
- /**
- * The language manager.
- *
- * @var \Drupal\Core\Language\LanguageManagerInterface
- */
- protected $languageManager;
-
- /**
- * The renderer.
- *
- * @var \Drupal\Core\Render\RendererInterface
- */
- protected RendererInterface $renderer;
-
- /**
- * The Drupal Twig environment.
- *
- * @var \Drupal\Core\Template\TwigEnvironment
- */
- protected TwigEnvironment $twigEnvironment;
-
- /**
- * Constructs a new TwigExtension object.
- *
- * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager
- * The language manager.
- * @param \Drupal\Core\Template\TwigEnvironment $twigEnvironment
- * The Drupal Twig environment.
- * @param \Drupal\Core\Render\RendererInterface $renderer
- * The renderer.
- */
- public function __construct(LanguageManagerInterface $languageManager, TwigEnvironment $twigEnvironment, RendererInterface $renderer) {
- $this->languageManager = $languageManager;
- $this->twigEnvironment = $twigEnvironment;
- $this->renderer = $renderer;
- }
+ public function __construct(
+ protected readonly LanguageManagerInterface $languageManager,
+ protected readonly TwigEnvironment $twigEnvironment,
+ protected readonly RendererInterface $renderer,
+ protected readonly ThemeExtensionList $themeList,
+ protected readonly ThemeManagerInterface $themeManager,
+ ) {}
/**
* {@inheritdoc}
@@ -70,6 +43,8 @@ public function __construct(LanguageManagerInterface $languageManager, TwigEnvir
public function getFilters(): array {
return [
new TwigFilter('bcl_card_list', [$this, 'bclCardList']),
+ new TwigFilter('bcl_description_list_items', [$this, 'bclDescriptionListItems']),
+ new TwigFilter('bcl_navigation_items', [$this, 'bclNavigationItems']),
new TwigFilter('format_size', 'Drupal\Core\StringTranslation\ByteSizeMarkup::create'),
new TwigFilter('to_file_icon', [$this, 'toFileIcon']),
new TwigFilter('to_native_language', [
@@ -93,6 +68,8 @@ public function getFunctions(): array {
'needs_environment' => TRUE,
]),
new TwigFunction('bcl_gallery_items', [$this, 'bclGalleryItems']),
+ new TwigFunction('bcl_icon_path', [$this, 'getBclIconPath']),
+ new TwigFunction('html_unique_id', [Html::class, 'getUniqueId']),
];
}
@@ -115,6 +92,9 @@ public function bclCardList(array $items): array {
foreach ($items as $item) {
// Copy most of the fields.
$bcl_card = $item;
+ if (isset($item['attributes']) && is_array($item['attributes'])) {
+ $bcl_card['attributes'] = new Attribute($item['attributes']);
+ }
// Some fields need to be rewritten.
if (isset($item['title'])) {
$title = $item['title'];
@@ -146,16 +126,28 @@ public function bclCardList(array $items): array {
}
$bcl_card['badges'] = [];
foreach ($item['badges'] ?? [] as $badge) {
- $bcl_card['badges'][] = [
+ $bcl_badge = is_array($badge) ? $badge + ['background' => 'primary'] : [
'label' => $badge,
'background' => 'primary',
];
+ if (isset($bcl_badge['attributes']) && is_array($bcl_badge['attributes'])) {
+ $bcl_badge['attributes'] = new Attribute($bcl_badge['attributes']);
+ }
+ $bcl_card['badges'][] = $bcl_badge;
}
if (isset($item['image'])) {
- $bcl_card['image'] = [
- 'path' => $item['image']->getSource(),
- 'alt' => $item['image']->getAlt(),
- ];
+ if (is_array($item['image'])) {
+ $bcl_card['image'] = $item['image'] + [
+ 'path' => $item['image']['src'] ?? '',
+ 'alt' => '',
+ ];
+ }
+ else {
+ $bcl_card['image'] = [
+ 'path' => $item['image']->getSource(),
+ 'alt' => $item['image']->getAlt(),
+ ];
+ }
}
$bcl_cards[] = $bcl_card;
@@ -164,6 +156,124 @@ public function bclCardList(array $items): array {
return $bcl_cards;
}
+ /**
+ * Processes items for the description list component.
+ *
+ * @param array $items
+ * The description list items.
+ * @param string $icon_path
+ * Path to the icons SVG file. Defaults to the BCL icon path.
+ *
+ * @return array
+ * The processed items.
+ */
+ public function bclDescriptionListItems(array $items, ?string $icon_path = NULL): array {
+ if (!$icon_path) {
+ $icon_path = $this->getBclIconPath();
+ }
+
+ foreach ($items as &$item) {
+ if (empty($item['term']) || is_string($item['term'])) {
+ continue;
+ }
+ if (!is_array($item['term'])) {
+ throw new \InvalidArgumentException('Expected term to be a string or array.');
+ }
+ foreach ($item['term'] as &$term) {
+ if (!empty($term['icon'])) {
+ $term['icon'] += ['size' => 'xs', 'path' => $icon_path];
+ }
+ }
+ unset($term);
+ }
+ unset($item);
+
+ return $items;
+ }
+
+ /**
+ * Prepares recursive navigation items for BCL templates.
+ *
+ * @param array $items
+ * The navigation items.
+ * @param string|null $icon_path
+ * Path to the SVG icon file. Defaults to the BCL icon path.
+ *
+ * @return array
+ * The processed navigation items.
+ */
+ public function bclNavigationItems(array $items, ?string $icon_path = NULL): array {
+ $icon_path = $icon_path ?: $this->getBclIconPath();
+
+ foreach ($items as &$item) {
+ $item = $this->prepareNavigationElement($item, $icon_path);
+
+ if (isset($item['trigger']) && is_array($item['trigger'])) {
+ $item['trigger'] = $this->prepareNavigationElement($item['trigger'], $icon_path);
+ }
+
+ if (isset($item['items']) && is_array($item['items'])) {
+ foreach ($item['items'] as &$dropdown_item) {
+ $dropdown_item = $this->prepareNavigationElement($dropdown_item, $icon_path);
+ }
+ unset($dropdown_item);
+ }
+
+ if (isset($item['navigation']) && is_array($item['navigation'])) {
+ $variant = $item['navigation']['variant'] ?? 'default';
+ $item['navigation']['pills'] = $variant === 'pills';
+ $item['navigation']['tabs'] = $variant === 'tabs';
+ $item['navigation']['nav'] = in_array($variant, ['pills', 'tabs'], TRUE);
+ $item['navigation']['navbar'] = $variant === 'navbar';
+ $item['navigation']['vertical'] = ($item['navigation']['orientation'] ?? 'horizontal') === 'vertical';
+ if (isset($item['navigation']['attributes']) && is_array($item['navigation']['attributes'])) {
+ $item['navigation']['attributes'] = new Attribute($item['navigation']['attributes']);
+ }
+ if (isset($item['navigation']['items']) && is_array($item['navigation']['items'])) {
+ $item['navigation']['items'] = $this->bclNavigationItems($item['navigation']['items'], $icon_path);
+ }
+ }
+ }
+ unset($item);
+
+ return $items;
+ }
+
+ /**
+ * Prepares attributes and icons for a BCL navigation element.
+ *
+ * @param array $item
+ * A navigation, dropdown, link, or button item.
+ * @param string $icon_path
+ * Path to the SVG icon file.
+ *
+ * @return array
+ * The processed element.
+ */
+ protected function prepareNavigationElement(array $item, string $icon_path): array {
+ if (isset($item['attributes']) && is_array($item['attributes'])) {
+ $item['attributes'] = new Attribute($item['attributes']);
+ }
+
+ if (!empty($item['icon'])) {
+ $icon = is_array($item['icon']) ? $item['icon'] : ['name' => $item['icon']];
+ if (isset($icon['attributes']) && is_array($icon['attributes'])) {
+ $icon['attributes'] = new Attribute($icon['attributes']);
+ }
+ $item['icon'] = $icon + ['path' => $icon_path];
+ }
+
+ if (isset($item['spinner']['attributes']) && is_array($item['spinner']['attributes'])) {
+ $item['spinner']['attributes'] = new Attribute($item['spinner']['attributes']);
+ }
+
+ if (array_key_exists('dark_mode', $item)) {
+ $item['dark'] = $item['dark_mode'];
+ }
+
+ return $item;
+ }
+
/**
* Get file icon class given its extension.
*
@@ -341,6 +451,17 @@ public function bclGalleryItems(array $items): array {
return $items;
}
+ /**
+ * Returns the URL to the BCL default icons SVG file.
+ *
+ * @return string
+ * The relative URL to the BCL icons SVG file.
+ */
+ public function getBclIconPath(): string {
+ $theme = $this->themeList->get('oe_bootstrap_theme');
+ return base_path() . $theme->getPath() . '/assets/icons/bcl-default-icons.svg';
+ }
+
/**
* Returns the children of an element array, optionally sorted by weight.
*
diff --git a/modules/oe_bootstrap_theme_helper/tests/src/Kernel/TwigExtensionTest.php b/modules/oe_bootstrap_theme_helper/tests/src/Kernel/TwigExtensionTest.php
index c65370d14..104406419 100644
--- a/modules/oe_bootstrap_theme_helper/tests/src/Kernel/TwigExtensionTest.php
+++ b/modules/oe_bootstrap_theme_helper/tests/src/Kernel/TwigExtensionTest.php
@@ -458,6 +458,83 @@ public function testBclCardList(): void {
}
}
+ /**
+ * Tests BCL navigation items filter.
+ */
+ public function testBclNavigationItems(): void {
+ $extension = $this->container->get('oe_bootstrap_theme_helper.twig_extension');
+ $items = [
+ [
+ 'label' => 'Dropdown',
+ 'dark_mode' => TRUE,
+ 'attributes' => ['class' => ['dropdown-class']],
+ 'icon' => 'menu',
+ 'spinner' => [
+ 'attributes' => ['class' => ['spinner-class']],
+ ],
+ 'trigger' => [
+ 'label' => 'Open dropdown',
+ 'attributes' => ['class' => ['trigger-class']],
+ 'icon' => [
+ 'name' => 'chevron-down',
+ 'attributes' => ['class' => ['trigger-icon-class']],
+ ],
+ ],
+ 'items' => [
+ [
+ 'label' => 'Dropdown item',
+ 'attributes' => ['class' => ['dropdown-item-class']],
+ ],
+ ],
+ 'navigation' => [
+ 'variant' => 'tabs',
+ 'orientation' => 'vertical',
+ 'attributes' => ['class' => ['navigation-class']],
+ 'items' => [
+ [
+ 'label' => 'Nested item',
+ 'icon' => [
+ 'name' => 'arrow-right',
+ 'path' => '/existing-icons.svg',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $result = $extension->bclNavigationItems($items, '/custom-icons.svg');
+ $item = $result[0];
+
+ $this->assertInstanceOf(Attribute::class, $item['attributes']);
+ $this->assertSame('dropdown-class', (string) $item['attributes']['class']);
+ $this->assertSame([
+ 'name' => 'menu',
+ 'path' => '/custom-icons.svg',
+ ], $item['icon']);
+ $this->assertInstanceOf(Attribute::class, $item['spinner']['attributes']);
+ $this->assertSame('spinner-class', (string) $item['spinner']['attributes']['class']);
+ $this->assertTrue($item['dark']);
+
+ $this->assertInstanceOf(Attribute::class, $item['trigger']['attributes']);
+ $this->assertSame('trigger-class', (string) $item['trigger']['attributes']['class']);
+ $this->assertSame('/custom-icons.svg', $item['trigger']['icon']['path']);
+ $this->assertInstanceOf(Attribute::class, $item['trigger']['icon']['attributes']);
+ $this->assertSame('trigger-icon-class', (string) $item['trigger']['icon']['attributes']['class']);
+ $this->assertInstanceOf(Attribute::class, $item['items'][0]['attributes']);
+ $this->assertSame('dropdown-item-class', (string) $item['items'][0]['attributes']['class']);
+
+ $navigation = $item['navigation'];
+ $this->assertFalse($navigation['pills']);
+ $this->assertTrue($navigation['tabs']);
+ $this->assertTrue($navigation['nav']);
+ $this->assertFalse($navigation['navbar']);
+ $this->assertTrue($navigation['vertical']);
+ $this->assertInstanceOf(Attribute::class, $navigation['attributes']);
+ $this->assertSame('navigation-class', (string) $navigation['attributes']['class']);
+ $this->assertSame('/existing-icons.svg', $navigation['items'][0]['icon']['path']);
+ }
+
/**
* Provides data for testBclCardList().
*
@@ -491,6 +568,12 @@ public function bclCardListDataProvider(): array {
'meta 2',
],
],
+ [
+ 'image' => [
+ 'src' => 'https://picsum.photos/1000/500/',
+ 'alt' => 'Alternative text for card image',
+ ],
+ ],
],
'expected' => [
[
@@ -563,6 +646,17 @@ public function bclCardListDataProvider(): array {
],
],
],
+ [
+ 'title' => '',
+ 'subtitle' => [],
+ 'text' => [],
+ 'image' => [
+ 'src' => 'https://picsum.photos/1000/500/',
+ 'alt' => 'Alternative text for card image',
+ 'path' => 'https://picsum.photos/1000/500/',
+ ],
+ 'badges' => [],
+ ],
],
];
}
diff --git a/resources/sass/components/_patterns-preview.scss b/resources/sass/components/_patterns-preview.scss
index 71e4ada34..7b72f6443 100644
--- a/resources/sass/components/_patterns-preview.scss
+++ b/resources/sass/components/_patterns-preview.scss
@@ -4,3 +4,61 @@
display: none;
}
}
+
+.components-preview__components {
+ .bcl-content-banner,
+ .bcl-banner {
+ width: 100%;
+ left: auto;
+ margin: 0 0 1rem 0;
+ &:has(+.bcl-copyright) {
+ margin-bottom: 0;
+ + .bcl-copyright {
+ margin-bottom: 1rem;
+ }
+ }
+ }
+
+ .date-block-story__preview {
+ inline-size: 10rem;
+ max-inline-size: 100%;
+ }
+
+ &::after {
+ display: none;
+ }
+}
+.components-preview__main {
+ [data-component-id="oe_bootstrap_theme:card"] {
+ img {
+ max-inline-size: 100%;
+ }
+ }
+ .carousel img {
+ max-inline-size: 100%;
+ }
+}
+
+.components-preview {
+ background: transparent;
+ padding: 0;
+ .components-preview__wrapper {
+ gap: 0;
+ .components-preview__sidebar {
+ background-color: transparent;
+ box-shadow: none;
+ border-radius: 0;
+ }
+ .components-preview__components {
+ background-color: transparent;
+ border-radius: 0;
+ border: 0;
+ box-shadow: none;
+ .components-preview__preview {
+ background: transparent;
+ border-radius: 0;
+ --padding-base: 19px;
+ }
+ }
+ }
+}
diff --git a/tests/src/ComponentAssertion/BadgeComponentAssert.php b/tests/src/ComponentAssertion/BadgeComponentAssert.php
new file mode 100644
index 000000000..d615ad625
--- /dev/null
+++ b/tests/src/ComponentAssertion/BadgeComponentAssert.php
@@ -0,0 +1,50 @@
+ [
+ [$this, 'assertLabel'],
+ ],
+ 'url' => [
+ [$this, 'assertElementAttribute'],
+ '.badge',
+ 'href',
+ ],
+ 'title' => [
+ [$this, 'assertElementAttribute'],
+ '.badge',
+ 'title',
+ ],
+ 'assistive_text' => [
+ [$this, 'assertAssistiveText'],
+ ],
+ 'background' => [
+ [$this, 'assertBackground'],
+ ],
+ 'rounded_pill' => [
+ [$this, 'assertRoundedPill'],
+ ],
+ 'dismissible' => [
+ [$this, 'assertDismissible'],
+ ],
+ 'outline' => [
+ [$this, 'assertOutline'],
+ ],
+ ];
+ }
+
+}
diff --git a/tests/src/ComponentAssertion/ComponentAssertInterface.php b/tests/src/ComponentAssertion/ComponentAssertInterface.php
new file mode 100644
index 000000000..48b2c64a3
--- /dev/null
+++ b/tests/src/ComponentAssertion/ComponentAssertInterface.php
@@ -0,0 +1,32 @@
+container->get('theme_installer')->install(['oe_bootstrap_theme']);
+ $this->config('system.theme')->set('default', 'oe_bootstrap_theme')->save();
+ $this->container->set('theme.registry', NULL);
+
+ // Prevent NotFoundHttpException in PathProcessorFront.
+ $system_site_config = \Drupal::configFactory()->getEditable('system.site');
+ $system_site_config->set('page.front', '/')->save();
+ }
+
+ /**
+ * Tests rendering of SDC components.
+ *
+ * @param array $render
+ * A render array.
+ * @param array $assertions
+ * Test assertion expectations.
+ *
+ * @dataProvider componentRenderingProvider
+ */
+ public function testComponentRendering(array $render, array $assertions): void {
+ $form_state = new FormState();
+ $form_state->addBuildInfo('args', [$render]);
+ $form_state->setProgrammed();
+
+ $form = $this->container->get('form_builder')->buildForm($this, $form_state);
+ $this->assertComponentRendering($assertions, (string) $this->container->get('renderer')->renderRoot($form));
+ }
+
+ /**
+ * Data provider for component rendering tests.
+ *
+ * @return array
+ * A set of test data.
+ */
+ public static function componentRenderingProvider(): array {
+ $components_path = __DIR__ . '/fixtures/markup_rendering_components';
+ $test_cases = [];
+
+ foreach (self::$componentList as $component) {
+ foreach (Yaml::decode(file_get_contents("{$components_path}/{$component}.yml")) as $key => $test_case) {
+ // Ensure unique test case keys across components.
+ $suffix = 0;
+ do {
+ $candidate_key = $key . ($suffix ? sprintf(' (%s)', $suffix) : '');
+ $suffix++;
+ } while (isset($test_cases[$candidate_key]));
+ $test_cases[$candidate_key] = $test_case;
+ }
+ }
+
+ return $test_cases;
+ }
+
+ /**
+ * Runs various assertions on given HTML string via CSS selectors.
+ *
+ * Assertions array has to be provided in the following format:
+ *
+ * @code
+ * [
+ * 'count' => [
+ * "button[data-drupal-selector="button"]" => 1,
+ * "button svg" => 1,
+ * ],
+ * 'equals' => [
+ * "button label" => "Button label",
+ * ],
+ * 'contains' => [
+ * "button.btn-secondary" => "Button label",
+ * ],
+ * ]
+ * @endcode
+ *
+ * @param array $assertions
+ * Test assertions.
+ * @param string $html
+ * The rendered HTML markup.
+ */
+ protected function assertComponentRendering(array $assertions, string $html): void {
+ $assertions += array_fill_keys(['count', 'equals', 'contains', 'component'], []);
+
+ // Catch any unexpected assertion keys. Compare with an empty array to show
+ // only the unexpected keys in the exception message.
+ $this->assertEquals([], array_diff(array_keys($assertions), [
+ 'count',
+ 'equals',
+ 'contains',
+ 'component',
+ ]));
+
+ if (!empty($assertions['component'])) {
+ // Component assertion key should be the only one present.
+ $this->assertEquals(['component'], array_keys(array_filter($assertions)));
+ $this->assertComponentMarkup($assertions['component'], $html);
+ return;
+ }
+
+ $crawler = new Crawler($html);
+
+ // Assert presence of given strings.
+ foreach ($assertions['contains'] as $selector => $string) {
+ $element = $crawler->filter($selector);
+ $this->assertNotEmpty($element, sprintf('No elements found with selector "%s" in:%s%s', $selector, PHP_EOL, $html));
+ $this->assertStringContainsString($string, $element->html(), sprintf('Failed assertion for selector "%s".', $selector));
+ }
+
+ // Assert occurrences of given elements.
+ foreach ($assertions['count'] as $selector => $expected) {
+ $this->assertCount($expected, $crawler->filter($selector), sprintf('Wrong count for selector "%s" in:%s%s', $selector, PHP_EOL, $html));
+ }
+
+ // Assert that a given element content equals a given string.
+ foreach ($assertions['equals'] as $selector => $expected) {
+ $element = $crawler->filter($selector);
+ $this->assertNotEmpty($element, sprintf('No elements found with selector "%s" in:%s%s.', $selector, PHP_EOL, $html));
+ $this->assertSame($expected, trim($element->html()), sprintf('Failed assertion for selector "%s".', $selector));
+ }
+ }
+
+ /**
+ * Executes the component assertion class specified in the test data.
+ *
+ * @param array $assertions
+ * An associative array with 'class' and 'expected' keys.
+ * @param string $html
+ * The HTML markup.
+ */
+ protected function assertComponentMarkup(array $assertions, string $html): void {
+ $this->assertEquals([], array_diff(array_keys($assertions), [
+ 'class',
+ 'expected',
+ ]));
+
+ $crawler = new Crawler($html);
+ $component_html = $crawler->filter('body > form')->html();
+
+ /** @var \Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\ComponentAssertInterface $component_assert */
+ $component_assert = new $assertions['class']();
+ $component_assert->assertComponent($assertions['expected'], $component_html);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state, ?array $render_array = NULL): array {
+ // Drupal 10.6 SDC validates Attribute props as objects, while YAML
+ // fixtures can only provide arrays. Normalize them for test rendering.
+ // @todo Remove this when Drupal 10.6 support is dropped.
+ if (($render_array['#type'] ?? NULL) === 'component') {
+ foreach ([
+ 'attributes',
+ 'title_attributes',
+ 'subtitle_attributes',
+ 'media_attributes',
+ 'list_attributes',
+ 'content_attributes',
+ 'header_attributes',
+ 'body_attributes',
+ 'footer_attributes',
+ 'list_attributes',
+ 'wrapper_attributes',
+ 'heading_attributes',
+ ] as $attribute_prop) {
+ if (is_array($render_array['#props'][$attribute_prop] ?? NULL)) {
+ $render_array['#props'][$attribute_prop] = new Attribute($render_array['#props'][$attribute_prop]);
+ }
+ }
+ }
+
+ $form['test'] = $render_array;
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state): void {
+ $add_errors = function (array $element) use (&$add_errors, $form_state): void {
+ if (!empty($element['#set_validation_error'])) {
+ $label = !empty($element['#title']) ? $element['#title'] : implode('][', $element['#array_parents']);
+ $form_state->setError($element, t('Validation error on @label', ['@label' => $label]));
+ }
+
+ foreach (Element::children($element) as $key) {
+ $add_errors($element[$key]);
+ }
+ };
+
+ $add_errors($form['test']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state): void {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return 'oe_bootstrap_theme_component_rendering_test_form';
+ }
+
+}
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/accordion.yml b/tests/src/Kernel/fixtures/markup_rendering_components/accordion.yml
new file mode 100644
index 000000000..549d59421
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/accordion.yml
@@ -0,0 +1,143 @@
+accordion_with_bare_layout:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:accordion
+ '#props':
+ open_item: 1
+ bare_layout: true
+ items:
+ - title: 'Accordion Item #1'
+ content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ - title: 'Accordion Item #2'
+ content: 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ - title: 'Accordion Item #3'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+ assertions:
+ count:
+ 'div.accordion': 1
+ '.bcl-heading': 0
+ 'div.accordion.accordion-flush': 1
+ 'div.accordion-item': 3
+ 'div.accordion-item h2.accordion-header': 3
+ 'div.accordion-item button.accordion-button': 3
+ 'div.accordion-item div.accordion-collapse.collapse': 3
+ 'div.accordion-item div.accordion-collapse.collapse.show': 1
+ equals:
+ 'div.accordion-item:nth-child(1) button.accordion-button span': 'Accordion Item #1'
+ 'div.accordion-item:nth-child(2) button.accordion-button span': 'Accordion Item #2'
+ 'div.accordion-item:nth-child(3) button.accordion-button span': 'Accordion Item #3'
+ 'div.accordion-item:nth-child(1) div.accordion-body': 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ 'div.accordion-item:nth-child(2) div.accordion-body': 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ 'div.accordion-item:nth-child(3) div.accordion-body': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+accordion_without_flush_with_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:accordion
+ '#props':
+ title: 'The accordion title'
+ open_item: 1
+ bare_layout: false
+ items:
+ - title: 'Accordion Item #1'
+ content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ - title: 'Accordion Item #2'
+ content: 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ - title: 'Accordion Item #3'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+ assertions:
+ count:
+ 'div.accordion': 1
+ '.bcl-heading': 1
+ 'h2.bcl-heading': 1
+ 'div.accordion.accordion-flush': 0
+ 'div.accordion-item': 3
+ 'div.accordion-item h2.accordion-header': 3
+ 'div.accordion-item button.accordion-button': 3
+ 'div.accordion-item div.accordion-collapse.collapse': 3
+ 'div.accordion-item div.accordion-collapse.collapse.show': 1
+ equals:
+ 'h2.bcl-heading': 'The accordion title'
+ 'div.accordion-item:nth-child(1) button.accordion-button span': 'Accordion Item #1'
+ 'div.accordion-item:nth-child(2) button.accordion-button span': 'Accordion Item #2'
+ 'div.accordion-item:nth-child(3) button.accordion-button span': 'Accordion Item #3'
+ 'div.accordion-item:nth-child(1) div.accordion-body': 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ 'div.accordion-item:nth-child(2) div.accordion-body': 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ 'div.accordion-item:nth-child(3) div.accordion-body': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+accordion_with_title_tag:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:accordion
+ '#props':
+ title: 'The title tag is customisable'
+ title_tag: 'h5'
+ open_item: 1
+ items:
+ - title: 'Accordion Item #1'
+ content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ - title: 'Accordion Item #2'
+ content: 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ - title: 'Accordion Item #3'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+ assertions:
+ count:
+ 'div.accordion': 1
+ '.bcl-heading': 1
+ 'h5.bcl-heading': 1
+ 'div.accordion.accordion-flush': 0
+ 'div.accordion-item': 3
+ 'div.accordion-item h2.accordion-header': 3
+ 'div.accordion-item button.accordion-button': 3
+ 'div.accordion-item div.accordion-collapse.collapse': 3
+ 'div.accordion-item div.accordion-collapse.collapse.show': 1
+ equals:
+ 'h5.bcl-heading': 'The title tag is customisable'
+ 'div.accordion-item:nth-child(1) button.accordion-button span': 'Accordion Item #1'
+ 'div.accordion-item:nth-child(2) button.accordion-button span': 'Accordion Item #2'
+ 'div.accordion-item:nth-child(3) button.accordion-button span': 'Accordion Item #3'
+ 'div.accordion-item:nth-child(1) div.accordion-body': 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ 'div.accordion-item:nth-child(2) div.accordion-body': 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ 'div.accordion-item:nth-child(3) div.accordion-body': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+accordion_with_actionable_buttons:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:accordion
+ '#props':
+ title: 'The accordion title'
+ accordion_id: test-accordion
+ expand_button_label: 'Expand'
+ collapse_button_label: 'Collapse'
+ open_item: 1
+ bare_layout: false
+ items:
+ - title: 'Accordion Item #1'
+ content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ - title: 'Accordion Item #2'
+ content: 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ - title: 'Accordion Item #3'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+ assertions:
+ count:
+ 'div.accordion': 1
+ '.bcl-heading': 1
+ 'h2.bcl-heading': 1
+ 'div.accordion.accordion-flush': 0
+ 'div#accordion-test-accordion.accordion': 1
+ 'button[data-action="expand"]': 1
+ 'button[data-action="collapse"]': 1
+ 'button[data-action="expand"][data-target="accordion-test-accordion"]': 1
+ 'button[data-action="collapse"][data-target="accordion-test-accordion"]': 1
+ 'div.accordion-item': 3
+ 'div.accordion-item h2.accordion-header': 3
+ 'div.accordion-item button.accordion-button': 3
+ 'div.accordion-item div.accordion-collapse.collapse': 3
+ 'div.accordion-item div.accordion-collapse.collapse.show': 1
+ equals:
+ 'h2.bcl-heading': 'The accordion title'
+ 'button[data-action="expand"]': 'Expand'
+ 'button[data-action="collapse"]': 'Collapse'
+ 'div.accordion-item:nth-child(1) button.accordion-button span': 'Accordion Item #1'
+ 'div.accordion-item:nth-child(2) button.accordion-button span': 'Accordion Item #2'
+ 'div.accordion-item:nth-child(3) button.accordion-button span': 'Accordion Item #3'
+ 'div.accordion-item:nth-child(1) div.accordion-body': 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium'
+ 'div.accordion-item:nth-child(2) div.accordion-body': 'Advice on living, working or travelling in the EU, on visas and immigration for non-EU citizens, European culture. Plus some extra text to make it a little bit longer.'
+ 'div.accordion-item:nth-child(3) div.accordion-body': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/alert.yml b/tests/src/Kernel/fixtures/markup_rendering_components/alert.yml
new file mode 100644
index 000000000..fbcd2ac27
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/alert.yml
@@ -0,0 +1,65 @@
+alert_dismissible_animated:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:alert
+ '#props':
+ variant: danger
+ dismissible: true
+ animated_dismiss: true
+ heading: 'Well done!'
+ message: 'A simple alert. Check it out!'
+ icon: lock
+ assertions:
+ count:
+ 'div.alert.alert-danger.alert-dismissible.fade[role="alert"]': 1
+ 'div.h4.alert-heading': 1
+ 'button.btn-close': 1
+ equals:
+ 'div.h4.alert-heading': 'Well done!'
+ contains:
+ 'div.alert-content': 'A simple alert. Check it out!'
+ 'svg': '#lock'
+alert_dismissible_non_animated:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:alert
+ '#props':
+ variant: info
+ dismissible: true
+ animated_dismiss: false
+ heading: 'Well done!'
+ message: 'A simple alert. Check it out!'
+ icon: lock
+ assertions:
+ count:
+ 'div.alert.alert-info.alert-dismissible[role="alert"]': 1
+ 'div.fade': 0
+ 'div.h4.alert-heading': 1
+ 'button.btn-close': 1
+ equals:
+ 'div.h4.alert-heading': 'Well done!'
+ 'div.alert-content': '
Well done!
A simple alert. Check it out!'
+ contains:
+ 'svg': '#lock'
+alert_not_dismissible:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:alert
+ '#props':
+ variant: secondary
+ dismissible: false
+ animated_dismiss: false
+ heading: 'Well done!'
+ message: 'A simple alert. Check it out!'
+ icon: search
+ assertions:
+ count:
+ 'div.alert-dismissible': 0
+ 'button.btn-close': 0
+ 'div.alert-secondary': 1
+ 'div.h4.alert-heading': 1
+ equals:
+ 'div.h4.alert-heading': 'Well done!'
+ 'div.alert-content': '
Well done!
A simple alert. Check it out!'
+ contains:
+ 'svg': '#search'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/badge.yml b/tests/src/Kernel/fixtures/markup_rendering_components/badge.yml
new file mode 100644
index 000000000..fcfab0c52
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/badge.yml
@@ -0,0 +1,97 @@
+badge_with_all_values:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:badge
+ '#props':
+ background: danger
+ rounded_pill: true
+ dismissible: true
+ outline: true
+ label: 'A simple badge!'
+ assistive_text: 'Unread status'
+ url: 'https://example.com'
+ title: 'The badge title'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\BadgeComponentAssert
+ expected:
+ background: danger
+ rounded_pill: true
+ dismissible: true
+ outline: true
+ label: 'A simple badge!'
+ assistive_text: 'Unread status'
+ url: 'https://example.com'
+ title: 'The badge title'
+badge_no_settings_sent:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:badge
+ '#props':
+ label: 'A simple badge!'
+ url: 'https://example.com'
+ title: 'The badge title'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\BadgeComponentAssert
+ expected:
+ background: primary
+ rounded_pill: false
+ dismissible: false
+ outline: false
+ label: 'A simple badge!'
+ url: 'https://example.com'
+ title: 'The badge title'
+badge_dismissible_with_rounded_pill:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:badge
+ '#props':
+ dismissible: true
+ rounded_pill: true
+ background: secondary
+ label: 'A simple badge!'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\BadgeComponentAssert
+ expected:
+ dismissible: true
+ rounded_pill: true
+ background: secondary
+ label: 'A simple badge!'
+badge_non_dismissible_without_rounded_pill:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:badge
+ '#props':
+ dismissible: false
+ rounded_pill: false
+ background: danger
+ label: 'A simple badge!'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\BadgeComponentAssert
+ expected:
+ dismissible: false
+ rounded_pill: false
+ background: danger
+ label: 'A simple badge!'
+badge_with_url:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:badge
+ '#props':
+ dismissible: true
+ rounded_pill: true
+ background: secondary
+ label: 'A simple badge!'
+ url: 'https://example.com'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\BadgeComponentAssert
+ expected:
+ dismissible: true
+ rounded_pill: true
+ background: secondary
+ label: 'A simple badge!'
+ url: 'https://example.com'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/banner.yml b/tests/src/Kernel/fixtures/markup_rendering_components/banner.yml
new file mode 100644
index 000000000..c76c788b5
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/banner.yml
@@ -0,0 +1,202 @@
+banner_default_centered_with_call_to_action_full_width:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: default
+ hero: false
+ full_width: true
+ centered: true
+ title: 'EU Budget for the future'
+ title_tag: h4
+ description: 'Innovation, economy, environment and geopolitics'
+ call_to_action:
+ label: Subscribe
+ path: 'https://example.com'
+ assertions:
+ count:
+ '.bcl-banner__content h4.bcl-heading': 1
+ 'div.bg-lighter.text-dark.bcl-banner': 1
+ 'div.full-width': 1
+ 'div.text-center': 1
+ 'div.bcl-banner__content': 1
+ 'a': 1
+ 'div.hero': 0
+ 'div.bcl-banner__image': 0
+ contains:
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+ equals:
+ '.bcl-banner__content h4.bcl-heading': 'EU Budget for the future'
+ '.bcl-banner__content a[href="https://example.com"]': Subscribe
+banner_default_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: default
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ assertions:
+ count:
+ 'div.bg-lighter.text-dark.bcl-banner': 1
+ 'div.full-width': 0
+ 'div.text-center': 0
+ 'div.hero': 0
+ 'div.bcl-banner__image': 0
+ 'a': 0
+ contains:
+ '.bcl-banner__content div.bcl-heading': 'EU Budget for the future'
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+banner_primary_hero_aligned_left_full_width:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: primary
+ hero: true
+ full_width: true
+ centered: false
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ assertions:
+ count:
+ 'div.bg-primary.text-white.bcl-banner': 1
+ 'div.full-width': 1
+ 'div.text-center': 0
+ 'div.bcl-banner__content': 1
+ 'a': 0
+ 'div.hero': 1
+ 'div.bcl-banner__image': 0
+ contains:
+ '.bcl-banner__content div': 'EU Budget for the future'
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+banner_image_with_copyright_overlay:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: image
+ hero: false
+ full_width: false
+ centered: false
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ copyright: '© 2024 Example Organisation'
+ image: 'https://picsum.photos/1000/500/'
+ assertions:
+ count:
+ '.bcl-banner': 1
+ 'div.bcl-banner.bg-lighter.text-dark': 1
+ 'div.overlay': 1
+ 'div.shade': 0
+ '.bcl-copyright': 1
+ 'div.bcl-banner__image[style="background-image:url(https://picsum.photos/1000/500/)"]': 1
+ contains:
+ '.bcl-copyright': '© 2024 Example Organisation'
+banner_image_with_call_to_action_link_style_without_icon:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: image
+ full_width: true
+ centered: true
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ call_to_action:
+ label: Subscribe
+ path: 'https://example.com'
+ link_style: warning
+ image: 'https://picsum.photos/1000/500/'
+ assertions:
+ count:
+ 'div.bcl-banner.bg-lighter.text-dark': 1
+ 'div.full-width': 1
+ 'div.text-center': 1
+ 'a.link-warning': 1
+ 'a svg': 0
+ 'div.hero': 0
+ 'div.overlay': 1
+ 'div.shade': 0
+ 'div.bcl-banner__image[style="background-image:url(https://picsum.photos/1000/500/)"]': 1
+ contains:
+ '.bcl-banner__content div': 'EU Budget for the future'
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+ equals:
+ '.bcl-banner__content a[href="https://example.com"]': Subscribe
+banner_image_shade_centered_with_call_to_action_full_width:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: image_shade
+ hero: false
+ full_width: true
+ centered: true
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ call_to_action:
+ label: Subscribe
+ path: 'https://example.com'
+ link_style: warning
+ icon:
+ name: chevron-right
+ image: 'https://picsum.photos/1000/500/'
+ assertions:
+ count:
+ 'div.bcl-banner.bg-lighter': 1
+ 'div.bcl-banner.bg-lighter.text-dark': 0
+ 'div.full-width': 1
+ 'div.text-center': 1
+ 'a.link-warning': 1
+ 'div.hero': 0
+ 'div.overlay': 0
+ 'div.shade': 1
+ 'div.bcl-banner__image[style="background-image:url(https://picsum.photos/1000/500/)"]': 1
+ contains:
+ '.bcl-banner__content div': 'EU Budget for the future'
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+ '.bcl-banner__content a[href="https://example.com"]': Subscribe
+ 'svg': 'chevron-right'
+banner_image_shade_with_shade_disabled:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: image_shade
+ shade: false
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ image: 'https://picsum.photos/1000/500/'
+ assertions:
+ count:
+ 'div.bcl-banner.bg-lighter': 1
+ 'div.bcl-banner.bg-lighter.text-dark': 0
+ 'div.overlay': 1
+ 'div.shade': 0
+ 'div.bcl-banner__image[style="background-image:url(https://picsum.photos/1000/500/)"]': 1
+ contains:
+ '.bcl-banner__content div': 'EU Budget for the future'
+ '.bcl-banner__content p': 'Innovation, economy, environment and geopolitics'
+banner_with_custom_attributes_and_content_classes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:banner
+ '#props':
+ variant: default
+ attributes:
+ class:
+ - custom-banner-class
+ id: banner-id
+ data-banner-attribute: banner-attr-value
+ title: 'EU Budget for the future'
+ description: 'Innovation, economy, environment and geopolitics'
+ content_classes: 'custom-content-class another-content-class'
+ assertions:
+ count:
+ 'div#banner-id': 1
+ 'div.custom-banner-class.bcl-banner.bg-lighter.text-dark': 1
+ 'div[data-banner-attribute="banner-attr-value"]': 1
+ 'div.bcl-banner__content.custom-content-class.another-content-class': 1
+ contains:
+ '.bcl-banner__content': 'EU Budget for the future'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/blockquote.yml b/tests/src/Kernel/fixtures/markup_rendering_components/blockquote.yml
new file mode 100644
index 000000000..d6932d506
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/blockquote.yml
@@ -0,0 +1,69 @@
+blockquote:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:blockquote
+ '#props':
+ title: 'The blockquote title'
+ title_tag: h4
+ alignment: left
+ quote: 'A well-known quote, contained in a blockquote element.'
+ attribution: 'Someone famous in'
+ cite: 'Source title'
+ assertions:
+ count:
+ '.bcl-heading': 1
+ 'h4.bcl-heading': 1
+ 'figure.text-left': 1
+ 'blockquote.blockquote': 1
+ 'figcaption.blockquote-footer': 1
+ 'cite[title="Source title"]': 1
+ contains:
+ 'figcaption.blockquote-footer': 'Someone famous in'
+ equals:
+ 'h4.bcl-heading': 'The blockquote title'
+ 'blockquote.blockquote p': 'A well-known quote, contained in a blockquote element.'
+ 'figcaption.blockquote-footer': 'Someone famous in
Source title '
+ 'cite[title="Source title"]': 'Source title'
+blockquote_right_alignment_no_title_tag:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:blockquote
+ '#props':
+ title: 'The blockquote title'
+ alignment: end
+ quote: 'A well-known quote, contained in a blockquote element.'
+ attribution: 'Someone famous in'
+ cite: 'Source title'
+ assertions:
+ count:
+ '.bcl-heading': 1
+ 'h2.bcl-heading': 1
+ 'figure.text-end': 1
+ 'blockquote.blockquote': 1
+ 'figcaption.blockquote-footer': 1
+ 'cite[title="Source title"]': 1
+ contains:
+ 'figcaption.blockquote-footer': 'Someone famous in'
+ equals:
+ 'h2.bcl-heading': 'The blockquote title'
+ 'blockquote.blockquote p': 'A well-known quote, contained in a blockquote element.'
+ 'figcaption.blockquote-footer': 'Someone famous in
Source title '
+ 'cite[title="Source title"]': 'Source title'
+blockquote_with_no_cite_no_alignment_no_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:blockquote
+ '#props':
+ quote: 'A well-known quote, contained in a blockquote element.'
+ attribution: 'Someone famous in'
+ assertions:
+ count:
+ '.bcl-heading': 0
+ 'figure.text-left': 1
+ 'blockquote.blockquote': 1
+ 'figcaption.blockquote-footer': 1
+ 'cite': 0
+ contains:
+ 'figcaption.blockquote-footer': 'Someone famous in'
+ equals:
+ 'blockquote.blockquote p': 'A well-known quote, contained in a blockquote element.'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/breadcrumb.yml b/tests/src/Kernel/fixtures/markup_rendering_components/breadcrumb.yml
new file mode 100644
index 000000000..a5ccd1bfc
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/breadcrumb.yml
@@ -0,0 +1,60 @@
+breadcrumb:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:breadcrumb
+ '#props':
+ items:
+ - path: '/index'
+ label: 'Home'
+ id: breadcrumb-home-link
+ attributes:
+ class:
+ - custom-class
+ - path: '/example'
+ label: 'About the European Commission'
+ - path: 'https://example.com'
+ label: 'Link title'
+ assertions:
+ count:
+ 'nav[aria-label="breadcrumb"]': 1
+ 'ol.breadcrumb': 1
+ 'li.breadcrumb-item': 3
+ 'li.breadcrumb-item:nth-child(1) a.custom-class[href="/index"]#breadcrumb-home-link': 1
+ 'li.breadcrumb-item:nth-child(2) a[href="/example"]': 1
+ 'li.breadcrumb-item:nth-child(3) a[href="https://example.com"]': 1
+ 'li.breadcrumb-item:nth-child(3).active[aria-current="page"]': 1
+ 'li.breadcrumb-item:nth-child(2) a svg.d-md-none.bi.icon--s': 1
+ 'svg.d-md-none.ms-0.me-2.me-2-5.bi.icon--s': 1
+ equals:
+ 'li.breadcrumb-item:nth-child(1) a': 'Home'
+ 'li.breadcrumb-item:nth-child(3) a': 'Link title'
+ contains:
+ 'li.breadcrumb-item:nth-child(2) a': 'About the European Commission'
+ 'svg': '/icons/bcl-default-icons.svg#arrow-left'
+breadcrumb_current_page_without_link:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:breadcrumb
+ '#props':
+ items:
+ - path: '/index'
+ label: 'Home'
+ - path: '/example'
+ label: 'Library'
+ - label: 'Current page'
+ assertions:
+ count:
+ 'nav[aria-label="breadcrumb"]': 1
+ 'ol.breadcrumb': 1
+ 'li.breadcrumb-item': 3
+ 'li.breadcrumb-item:nth-child(1) a[href="/index"]': 1
+ 'li.breadcrumb-item:nth-child(2) a[href="/example"]': 1
+ 'li.breadcrumb-item:nth-child(3).active[aria-current="page"]': 1
+ 'li.breadcrumb-item:nth-child(3) a': 0
+ 'li.breadcrumb-item:nth-child(2) a svg.d-md-none.bi.icon--s': 1
+ equals:
+ 'li.breadcrumb-item:nth-child(1) a': 'Home'
+ contains:
+ 'li.breadcrumb-item:nth-child(2) a': 'Library'
+ 'li.breadcrumb-item:nth-child(3)': 'Current page'
+ 'svg': '/icons/bcl-default-icons.svg#arrow-left'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/button.yml b/tests/src/Kernel/fixtures/markup_rendering_components/button.yml
new file mode 100644
index 000000000..8d7a1896a
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/button.yml
@@ -0,0 +1,220 @@
+button_dark_with_icon_after:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: dark
+ icon_position: after
+ type: button
+ label: 'Button label'
+ icon: arrow-right
+ assertions:
+ count:
+ 'button.btn': 1
+ 'svg.icon--fluid': 1
+ 'button.btn-dark': 1
+ 'button svg': 1
+ contains:
+ 'button.btn-dark': 'Button label'
+ 'svg': '#arrow-right'
+button_without_icon:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ assertions:
+ count:
+ 'button.btn.btn-primary': 1
+ 'button svg': 0
+ equals:
+ 'button': 'Button label'
+button_secondary:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: secondary
+ type: button
+ label: 'Button label'
+ assertions:
+ count:
+ 'button.btn.btn-secondary': 1
+ equals:
+ 'button.btn-secondary': 'Button label'
+button_primary_small_with_outline_and_disabled:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ outline: true
+ disabled: true
+ size: sm
+ type: button
+ label: 'Button label'
+ assertions:
+ count:
+ 'button[disabled]': 1
+ 'button[aria-disabled="true"]': 1
+ 'button.btn.btn-outline-primary': 1
+ 'button.btn-sm': 1
+ equals:
+ 'button.btn-outline-primary': 'Button label'
+button_warning_large:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: warning
+ outline: false
+ disabled: false
+ size: lg
+ type: button
+ label: 'Button label'
+ assertions:
+ count:
+ 'button.btn.btn-warning': 1
+ 'button[disabled]': 0
+ 'button.btn-lg': 1
+ equals:
+ 'button.btn-warning': 'Button label'
+button_dark_with_icon_before_and_type_reset:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: dark
+ icon_position: before
+ type: reset
+ label: 'Button label'
+ icon: arrow-right
+ assertions:
+ count:
+ 'button.btn': 1
+ 'svg.icon--fluid': 1
+ 'button.btn-dark': 1
+ 'button svg': 1
+ 'button[type=reset]': 1
+ contains:
+ 'button.btn-dark': 'Button label'
+ 'svg': '#arrow-right'
+button_with_assistive_text_and_spinner:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: submit
+ label: 'Save'
+ assistive_text: 'Form action'
+ text_nowrap: true
+ show_spinner: true
+ spinner:
+ size: sm
+ assistive_text: 'Loading'
+ attributes:
+ class:
+ - custom-spinner-class
+ assertions:
+ count:
+ 'button.btn.btn-primary[type=submit]': 1
+ 'button.text-nowrap': 1
+ 'button.d-inline-flex.align-items-center.gap-2': 1
+ 'button > span.visually-hidden': 1
+ 'button > div.spinner-border.spinner-border-sm.custom-spinner-class[role="status"]': 1
+ 'button > div.spinner-border span.visually-hidden': 1
+ contains:
+ 'button.btn-primary': 'Save'
+ equals:
+ 'button > span.visually-hidden': 'Form action'
+ 'button > div.spinner-border span.visually-hidden': 'Loading'
+button_with_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ id: button-id
+ label: 'Button label'
+ assertions:
+ count:
+ 'button#button-id': 1
+ 'button.btn.btn-primary': 1
+button_disabled_via_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ attributes:
+ disabled: true
+ assertions:
+ count:
+ 'button[disabled]': 1
+ 'button[aria-disabled="true"]': 1
+button_with_icon_spacing:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ icon: arrow-right
+ icon_spacing: lg
+ assertions:
+ count:
+ 'button.gap-lg-2-5': 1
+ contains:
+ 'svg': '#arrow-right'
+button_with_icon_transformation:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ icon:
+ name: arrow-right
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-icon-class
+ assertions:
+ count:
+ 'svg.icon--rotate-90.custom-icon-class': 1
+button_clean_class:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ clean_class: true
+ assertions:
+ count:
+ 'button': 1
+ 'button.btn': 0
+ 'button.btn-primary': 0
+button_icon_without_spacer:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:button
+ '#props':
+ variant: primary
+ type: button
+ label: 'Button label'
+ icon: arrow-right
+ remove_icon_spacers: true
+ assertions:
+ count:
+ 'svg': 1
+ 'button.gap-2-5': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/card.yml b/tests/src/Kernel/fixtures/markup_rendering_components/card.yml
new file mode 100644
index 000000000..3145515da
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/card.yml
@@ -0,0 +1,270 @@
+card_horizontal_with_image:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ orientation: horizontal
+ title: 'Title card'
+ title_tag: 'h2'
+ subtitle: 'Subtitle card'
+ text: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ meta:
+ - 'DIGIT'
+ - 'Developer'
+ content:
+ '#type': html_tag
+ '#tag': button
+ '#value': 'Custom action'
+ '#attributes':
+ class:
+ - btn
+ - btn-primary
+ header: 'Header of card'
+ footer: 'Footer of card'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardPatternAssert
+ expected:
+ horizontal: true
+ title: 'Title card'
+ title_tag: 'h2'
+ subtitle: 'Subtitle card'
+ description: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ content:
+ - 'DIGIT'
+ - 'Developer'
+ - '
Custom action '
+ header: 'Header of card'
+ footer: 'Footer of card'
+card_vertical_with_title_link:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ orientation: vertical
+ title: 'Title card'
+ title_link:
+ path: 'https://example.com'
+ subtitle: 'Subtitle card'
+ text: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ content:
+ '#type': html_tag
+ '#tag': button
+ '#value': 'Custom action'
+ '#attributes':
+ class:
+ - btn
+ - btn-primary
+ header: 'Header of card'
+ footer: 'Footer of card'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardPatternAssert
+ expected:
+ horizontal: false
+ title: 'Title card'
+ title_tag: 'div'
+ url: 'https://example.com'
+ subtitle: 'Subtitle card'
+ description: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ content:
+ - '
Custom action '
+ header: 'Header of card'
+ footer: 'Footer of card'
+card_variant_search:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: search
+ title: 'Title card'
+ title_tag: 'h3'
+ text: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: ''
+ badges:
+ - label: 'Category 1'
+ - 'Category 2'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardPatternAssert
+ expected:
+ variant: search
+ title: 'Title card'
+ title_tag: 'h3'
+ description: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: ''
+ badges:
+ - label: 'Category 1'
+ rounded_pill: true
+ - 'Category 2'
+card_badge_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ title: 'Title card'
+ badges:
+ - label: 'Custom badge'
+ attributes:
+ class:
+ - custom-badge-class
+ assertions:
+ count:
+ 'span.badge.custom-badge-class': 1
+card_title_link_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ title: 'Title card'
+ title_link:
+ path: 'https://example.com'
+ attributes:
+ class:
+ - custom-link-class
+ assertions:
+ count:
+ '.card-title a.custom-link-class[href="https://example.com"]': 1
+card_date_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ title: 'Title card'
+ date:
+ year: '2021'
+ month: 'Jan'
+ day: '23'
+ attributes:
+ class:
+ - custom-date-class
+ assertions:
+ count:
+ 'time.custom-date-class': 1
+card_with_date_range:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ orientation: horizontal
+ title: 'Title card'
+ date:
+ year: "2021"
+ month: "Jan"
+ day: "23"
+ end_year: "2022"
+ end_month: "Feb"
+ end_day: "11"
+ date_time: '2021-01-23'
+ assertions:
+ count:
+ '.row .bcl-card-start-col': 1
+ '.row .col': 1
+ '.row .col-4': 0
+ '.row .col-8': 0
+ 'time[datetime="2021-01-23"]': 1
+ equals:
+ 'article.card .card-title': 'Title card'
+ 'time > div > div > div:nth-of-type(1) .date-month': 'Jan'
+ 'time > div > div > div:nth-of-type(1) .date-day': '23'
+ 'time > div > div > div:nth-of-type(3) .date-month': 'Feb'
+ 'time > div > div > div:nth-of-type(3) .date-day': '11'
+ 'time .year-start': '2021'
+ 'time .year-end': '2022'
+card_with_image_copyright:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ orientation: vertical
+ title: 'Title card'
+ text: 'Text card with longer text which is supported'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ image_copyright: '© 2024 Example Organisation'
+ assertions:
+ count:
+ 'article.card': 1
+ 'article.card img.card-img-top': 1
+ 'article.card .bcl-card__image-footer .bcl-copyright': 1
+ equals:
+ 'article.card .bcl-copyright': '
Image credit: © 2024 Example Organisation'
+card_title_link_full_options:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ title: 'Title card'
+ title_link:
+ label: 'Custom link label'
+ path: 'https://example.com'
+ variant: warning
+ id: 'card-title-link'
+ disabled: true
+ icon:
+ name: arrow-right
+ text: 'Text card with longer text which is supported'
+ assertions:
+ count:
+ '.card-title a.link-warning#card-title-link.disabled[href="https://example.com"][aria-disabled="true"]': 1
+ '.card-title a svg': 1
+ contains:
+ '.card-title a': 'Custom link label'
+ '.card-title a svg': '#arrow-right'
+card_with_image_position_bottom:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ title: 'Title card'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ position: bottom
+ classes: 'custom-bottom-class'
+ assertions:
+ count:
+ 'article.card img.card-img-bottom.custom-bottom-class': 1
+ 'article.card img.card-img-top': 0
+card_with_image_rounded:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card
+ '#props':
+ variant: default
+ orientation: horizontal
+ title: 'Title card'
+ image:
+ src: 'https://picsum.photos/500/500/'
+ alt: 'Alternative text for card image'
+ rounded: circle
+ assertions:
+ count:
+ 'article.card > div.row.g-0': 1
+ 'article.card img.card-img-top.rounded-circle': 1
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/card_layout.yml b/tests/src/Kernel/fixtures/markup_rendering_components/card_layout.yml
new file mode 100644
index 000000000..9ecd47391
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/card_layout.yml
@@ -0,0 +1,207 @@
+card_group:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_layout
+ '#props':
+ variant: group
+ equal_height: true
+ items:
+ - title: 'Card title 1'
+ title_tag: 'h2'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 1'
+ - title: 'Card title 2'
+ text: 'This card has supporting text below as a natural lead-in to additional content.'
+ card_footer: 'Footer of card 2'
+ - title: 'Card title 3'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 3'
+ - title: 'Card title 4'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 4'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:card_layout"].card-group': 1
+ 'article.card': 4
+ 'article.card.h-100': 0
+ 'div.card-title': 3
+ 'h2.card-title': 1
+ 'div.card-text': 4
+ equals:
+ 'article.card:nth-child(1) h2.card-title': 'Card title 1'
+ 'article.card:nth-child(2) div.card-title': 'Card title 2'
+ 'article.card:nth-child(3) div.card-title': 'Card title 3'
+ 'article.card:nth-child(4) div.card-title': 'Card title 4'
+ 'article.card:nth-child(1) div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'article.card:nth-child(2) div.card-text': 'This card has supporting text below as a natural lead-in to additional content.'
+ 'article.card:nth-child(3) div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'article.card:nth-child(4) div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'article.card:nth-child(1) div.card-footer': 'Footer of card 1'
+ 'article.card:nth-child(2) div.card-footer': 'Footer of card 2'
+ 'article.card:nth-child(3) div.card-footer': 'Footer of card 3'
+ 'article.card:nth-child(4) div.card-footer': 'Footer of card 4'
+card_grid_responsive_xxl_3_responsive_columns:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_layout
+ '#props':
+ variant: grid
+ responsiveness: xxl
+ responsive_columns: 3
+ gutter: 0
+ equal_height: false
+ items:
+ - title: 'Card title 1'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 1'
+ attributes:
+ class:
+ - custom-card-class
+ - title: 'Card title 2'
+ text: 'This card has supporting text below as a natural lead-in to additional content.'
+ card_footer: 'Footer of card 2'
+ - title: 'Card title 3'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 3'
+ - title: 'Card title 4'
+ text: 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ card_footer: 'Footer of card 4'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:card_layout"].row': 1
+ 'article.card': 4
+ 'div.card-title': 4
+ 'div.card-text': 4
+ 'article.card.h-100': 0
+ 'div.col:nth-child(1) article.card.custom-card-class': 1
+ 'div.g-0': 1
+ 'div.row-cols-xxl-3': 1
+ equals:
+ 'div.col:nth-child(1) article.card div.card-title': 'Card title 1'
+ 'div.col:nth-child(2) article.card div.card-title': 'Card title 2'
+ 'div.col:nth-child(3) article.card div.card-title': 'Card title 3'
+ 'div.col:nth-child(4) article.card div.card-title': 'Card title 4'
+ 'div.col:nth-child(1) article.card div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'div.col:nth-child(2) article.card div.card-text': 'This card has supporting text below as a natural lead-in to additional content.'
+ 'div.col:nth-child(3) article.card div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'div.col:nth-child(4) article.card div.card-text': 'This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.'
+ 'div.col:nth-child(1) article.card div.card-footer': 'Footer of card 1'
+ 'div.col:nth-child(2) article.card div.card-footer': 'Footer of card 2'
+ 'div.col:nth-child(3) article.card div.card-footer': 'Footer of card 3'
+ 'div.col:nth-child(4) article.card div.card-footer': 'Footer of card 4'
+card_grid_responsive_columns_2_md_equal_height:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_layout
+ '#props':
+ variant: grid
+ grid_columns: 2
+ responsiveness: md
+ gutter: 2
+ equal_height: true
+ responsive_columns: 2
+ items:
+ - title: 'Title card 1'
+ subtitle: 'Subtitle card 1'
+ text: 'Text card with longer text which is supported 1'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ classes: custom-image-class
+ rounded: '2'
+ image_copyright: '© 2026 Example Organisation'
+ badges:
+ - 'Category 1'
+ - label: 'Category 2'
+ background: success
+ content:
+ '#type': html_tag
+ '#tag': button
+ '#value': 'Custom action'
+ '#attributes':
+ class:
+ - btn
+ - btn-primary
+ card_header: 'Header of card 1'
+ card_footer: 'Footer of card 1'
+ attributes:
+ class:
+ - custom-class
+ - title: 'Title card 2'
+ subtitle: 'Subtitle card 2'
+ text: 'Text card with longer text which is supported 2'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ position: bottom
+ classes: custom-bottom-image
+ content: 'Custom test'
+ card_header: 'Header of card 2'
+ card_footer: 'Footer of card 2'
+ - title: 'Title card 3'
+ subtitle: 'Subtitle card 3'
+ text: 'Text card with longer text which is supported 3'
+ image:
+ src: 'https://picsum.photos/1000/500/'
+ alt: 'Alternative text for card image'
+ content: 'Some representative placeholder content for the first card.'
+ card_header: 'Header of card 3'
+ card_footer: 'Footer of card 3'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:card_layout"].row.row-cols-2.row-cols-md-2.g-2': 1
+ 'article.h-100.card': 3
+ 'div.col:nth-child(1) article.card.h-100.custom-class': 1
+ 'img.card-img-top': 2
+ 'img.card-img-top.rounded-2.custom-image-class': 1
+ 'img.card-img-bottom.custom-bottom-image': 1
+ 'span.badge.text-bg-primary': 1
+ 'span.badge.text-bg-success': 1
+ 'div.card-header': 3
+ 'div.card-body': 3
+ 'div.card-title': 3
+ 'h6.card-subtitle': 3
+ 'div.card-text': 3
+ 'div.card-footer': 3
+ contains:
+ 'div.col:nth-child(1) .bcl-copyright': '© 2026 Example Organisation'
+ 'div.col:nth-child(1) span.badge.text-bg-primary': 'Category 1'
+ 'div.col:nth-child(1) span.badge.text-bg-success': 'Category 2'
+ equals:
+ 'div.col:nth-child(1) div.card-header': 'Header of card 1'
+ 'div.col:nth-child(1) div.card-title': 'Title card 1'
+ 'div.col:nth-child(1) h6.card-subtitle': 'Subtitle card 1'
+ 'div.col:nth-child(1) div.card-text': 'Text card with longer text which is supported 1'
+ 'div.col:nth-child(1) div.card-footer': 'Footer of card 1'
+ 'div.col:nth-child(2) div.card-header': 'Header of card 2'
+ 'div.col:nth-child(2) div.card-title': 'Title card 2'
+ 'div.col:nth-child(2) h6.card-subtitle': 'Subtitle card 2'
+ 'div.col:nth-child(2) div.card-text': 'Text card with longer text which is supported 2'
+ 'div.col:nth-child(2) div.card-footer': 'Footer of card 2'
+ 'div.col:nth-child(3) div.card-header': 'Header of card 3'
+ 'div.col:nth-child(3) div.card-title': 'Title card 3'
+ 'div.col:nth-child(3) h6.card-subtitle': 'Subtitle card 3'
+ 'div.col:nth-child(3) div.card-text': 'Text card with longer text which is supported 3'
+ 'div.col:nth-child(3) div.card-footer': 'Footer of card 3'
+card_masonry:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_layout
+ '#props':
+ variant: masonry
+ grid_columns: 1
+ responsiveness: md
+ responsive_columns: 3
+ gutter: 3
+ items:
+ - title: 'Card title 1'
+ text: 'This is a longer card used in the masonry layout.'
+ - title: 'Card title 2'
+ text: 'Short card.'
+ - title: 'Card title 3'
+ text: 'This card has a medium amount of supporting text.'
+ assertions:
+ count:
+ "div[data-component-id='oe_bootstrap_theme:card_layout'][data-masonry='{\"percentPosition\": true }'].row.row-cols-1.row-cols-md-3.g-3": 1
+ 'div.col': 3
+ 'article.card': 3
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/card_v2.yml b/tests/src/Kernel/fixtures/markup_rendering_components/card_v2.yml
new file mode 100644
index 000000000..279111742
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/card_v2.yml
@@ -0,0 +1,207 @@
+card_v2_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: default
+ title:
+ '#markup': 'New title with
markup '
+ content:
+ '#markup': '
Body content card
'
+ footer:
+ '#markup': '
Footer content card
'
+ header:
+ '#markup': '
Header content card
'
+ media:
+ '#markup': '
'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: default
+ title: 'New title with
markup '
+ tag: article
+ media: '
'
+ content: '
New title with markup
Body content card
'
+ footer: '
Footer content card
'
+ header: '
Header content card
'
+ attributes:
+ class: 'card v2'
+ id: ~
+ media_attributes:
+ class: 'card-media'
+ id: ~
+ content_attributes:
+ class: 'card-body'
+ id: ~
+ header_attributes:
+ class: 'card-header'
+ id: ~
+ footer_attributes:
+ class: 'card-footer'
+ id: ~
+card_v2_default_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: default
+ title:
+ '#markup': 'New title with
markup '
+ content:
+ '#markup': '
Body content card
'
+ footer:
+ '#markup': '
Footer content card
'
+ header:
+ '#markup': '
Header content card
'
+ media:
+ '#markup': '
'
+ attributes:
+ class:
+ - card-class
+ id: card-id
+ data-card-attribute: card-attr-value
+ media_attributes:
+ class:
+ - media-class
+ id: media-id
+ data-media-attribute: media-attr-value
+ content_attributes:
+ class:
+ - content-class
+ id: content-id
+ data-content-attribute: content-attr-value
+ header_attributes:
+ class:
+ - header-class
+ id: header-id
+ data-header-attribute: header-attr-value
+ footer_attributes:
+ class:
+ - footer-class
+ id: footer-id
+ data-footer-attribute: footer-attr-value
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: default
+ title: 'New title with
markup '
+ tag: article
+ media: '
'
+ content: '
New title with markup
Body content card
'
+ footer: '
Footer content card
'
+ header: '
Header content card
'
+ attributes:
+ class: 'card-class card v2'
+ id: card-id
+ data-card-attribute: card-attr-value
+ media_attributes:
+ class: 'media-class card-media'
+ id: media-id
+ data-media-attribute: media-attr-value
+ content_attributes:
+ class: 'content-class card-body'
+ id: content-id
+ data-content-attribute: content-attr-value
+ header_attributes:
+ class: 'header-class card-header'
+ id: header-id
+ data-header-attribute: header-attr-value
+ footer_attributes:
+ class: 'footer-class card-footer'
+ id: footer-id
+ data-footer-attribute: footer-attr-value
+card_v2_horizontal:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: horizontal
+ tag: div
+ title:
+ '#markup': 'New title with
markup '
+ content:
+ '#markup': '
Body content card
'
+ footer:
+ '#markup': '
Footer content card
'
+ header:
+ '#markup': '
Header content card
'
+ media:
+ '#markup': '
'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: horizontal
+ title: 'New title with
markup '
+ tag: div
+ media: '
'
+ content: '
New title with markup
Body content card
'
+ footer: '
Footer content card
'
+ header: '
Header content card
'
+ attributes:
+ class: 'card v2 horizontal'
+ id: ~
+card_v2_no_values:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: default
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: default
+ tag: article
+ title: ~
+ media: ~
+ content: ''
+ footer: ~
+ header: ~
+ attributes:
+ class: 'card v2'
+card_v2_only_content:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: default
+ content: 'Just a simple content.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: default
+ tag: article
+ title: ~
+ media: ~
+ content: 'Just a simple content.'
+ footer: ~
+ header: ~
+ attributes:
+ class: 'card v2'
+card_v2_default_content_and_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:card_v2
+ '#props':
+ variant: default
+ title:
+ '#markup': 'New title with
markup '
+ content:
+ '#markup': '
Body content card
'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CardV2PatternAssert
+ expected:
+ variant: default
+ tag: article
+ title: 'New title with
markup '
+ media: ~
+ content: '
New title with markup
Body content card
'
+ footer: ~
+ header: ~
+ attributes:
+ class: 'card v2'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/carousel.yml b/tests/src/Kernel/fixtures/markup_rendering_components/carousel.yml
new file mode 100644
index 000000000..97b6f33ce
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/carousel.yml
@@ -0,0 +1,193 @@
+carousel_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ carousel_id: carousel-default
+ slides:
+ - caption: 'First slide caption'
+ caption_title: 'First slide title'
+ interval: 0
+ image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CarouselPatternAssert
+ expected:
+ settings:
+ fade: false
+ show_controls: true
+ show_indicators: false
+ autoplay: true
+ disable_touch: false
+ items:
+ - image: '
'
+ caption_title: 'First slide title'
+ caption: 'First slide caption'
+
+carousel_with_all_settings:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ title: 'Carousel title'
+ title_tag: 'h4'
+ carousel_id: carousel-all-settings
+ fade: true
+ show_controls: true
+ show_indicators: true
+ autoplay: false
+ disable_touch: true
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ caption_title: 'First slide title'
+ caption: 'First slide caption'
+ caption_classes: 'caption_classes-1'
+ interval: 1
+ copyright: '© 2024 Example Organisation'
+ link:
+ label: First link
+ path: https://link.one
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CarouselPatternAssert
+ expected:
+ title: 'Carousel title'
+ title_tag: 'h4'
+ settings:
+ fade: true
+ show_controls: true
+ show_indicators: true
+ autoplay: false
+ disable_touch: true
+ items:
+ - image: '
'
+ caption_title: 'First slide title'
+ caption: 'First slide caption'
+ caption_classes: 'caption_classes-1'
+ interval: 1
+ copyright: '© 2024 Example Organisation'
+ link:
+ label: First link
+ path: https://link.one
+carousel_settings_disabled:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ carousel_id: carousel-settings-disabled
+ fade: false
+ show_controls: false
+ show_indicators: false
+ autoplay: false
+ disable_touch: false
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\CarouselPatternAssert
+ expected:
+ settings:
+ fade: false
+ show_controls: false
+ show_indicators: false
+ autoplay: false
+ disable_touch: false
+ items:
+ - image: '
'
+carousel_with_fallback_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ show_controls: true
+ show_indicators: true
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ assertions:
+ count:
+ '.carousel[id^="carousel"]': 1
+ '.carousel-control-prev[data-bs-target^="#carousel"]': 1
+ '.carousel-control-next[data-bs-target^="#carousel"]': 1
+ '.carousel-indicators button[data-bs-target^="#carousel"]': 1
+carousel_with_user_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ carousel_id: custom-carousel-id
+ show_controls: true
+ show_indicators: true
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ - image:
+ src: 'https://images.site.example2'
+ alt: 'alt image 2'
+ assertions:
+ count:
+ '.carousel#custom-carousel-id': 1
+ '.carousel-control-prev[data-bs-target="#custom-carousel-id"]': 1
+ '.carousel-control-next[data-bs-target="#custom-carousel-id"]': 1
+ '.carousel-indicators button[data-bs-target="#custom-carousel-id"]': 2
+carousel_dark_mode_with_active_item:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ carousel_id: carousel-dark-active
+ dark_mode: true
+ active_item: 2
+ show_indicators: true
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ - image:
+ src: 'https://images.site.example2'
+ alt: 'alt image 2'
+ assertions:
+ count:
+ '.carousel.carousel-dark': 1
+ '.carousel-item': 2
+ '.carousel-item:nth-child(1).active': 0
+ '.carousel-item:nth-child(2).active': 1
+ '.carousel-indicators button:nth-child(2).active[aria-current]': 1
+carousel_with_image_attributes_and_link_icon:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:carousel
+ '#props':
+ carousel_id: carousel-image-attributes
+ slides:
+ - image:
+ src: 'https://images.site.example1'
+ alt: 'alt image 1'
+ title: 'Image title'
+ loading: lazy
+ width: '1000'
+ height: '500'
+ class:
+ - custom-image-class
+ caption_title: 'First slide title'
+ caption: 'First slide caption'
+ link:
+ label: First link
+ path: https://link.one
+ icon_position: after
+ icon:
+ name: chevron-right
+ assertions:
+ count:
+ '.carousel-item img[title="Image title"][loading="lazy"][width="1000"][height="500"].custom-image-class.d-block.w-100': 1
+ '.carousel-caption a svg.ms-2-5': 1
+ contains:
+ '.carousel-caption a svg': '#chevron-right'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/columns.yml b/tests/src/Kernel/fixtures/markup_rendering_components/columns.yml
new file mode 100644
index 000000000..4a42f3589
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/columns.yml
@@ -0,0 +1,79 @@
+columns_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:columns
+ '#props':
+ items:
+ - '#plain_text': Column 1
+ - '#plain_text': Column 2
+ - '#type': html_tag
+ '#tag': strong
+ '#value': 'Column 3'
+ - '#plain_text': Column 4
+ - '#plain_text': Column 5
+ - '#markup': '
Column 6 '
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:columns"].columns': 1
+ 'div[data-component-id="oe_bootstrap_theme:columns"][style="--columns-grid--column-count: 4;"]': 1
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item': 6
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item:nth-child(3) strong': 1
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item:nth-child(6) a[href="/example"]': 1
+ equals:
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item:nth-child(1)': 'Column 1'
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item:nth-child(3) strong': 'Column 3'
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item:nth-child(6) a[href="/example"]': 'Column 6'
+columns_three_cols:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:columns
+ '#props':
+ items:
+ - '#plain_text': Column 1
+ - '#plain_text': Column 2
+ - '#plain_text': Column 3
+ - '#plain_text': Column 4
+ - '#plain_text': Column 5
+ - '#plain_text': Column 6
+ columns: 3
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:columns"].columns': 1
+ 'div[data-component-id="oe_bootstrap_theme:columns"][style="--columns-grid--column-count: 3;"]': 1
+ 'div[data-component-id="oe_bootstrap_theme:columns"] > .columns__item': 6
+columns_section_element:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:columns
+ '#props':
+ items:
+ - '#plain_text': Column 1
+ - '#plain_text': Column 2
+ - '#plain_text': Column 3
+ - '#plain_text': Column 4
+ - '#plain_text': Column 5
+ - '#plain_text': Column 6
+ tag: section
+ columns: 6
+ assertions:
+ count:
+ 'section[data-component-id="oe_bootstrap_theme:columns"].columns': 1
+ 'section[data-component-id="oe_bootstrap_theme:columns"][style="--columns-grid--column-count: 6;"]': 1
+ 'section[data-component-id="oe_bootstrap_theme:columns"] > .columns__item': 6
+columns_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:columns
+ '#props':
+ attributes:
+ class:
+ - custom-columns-class
+ id: columns-id
+ data-columns-attribute: columns-attr-value
+ items:
+ - '#plain_text': Column 1
+ - '#plain_text': Column 2
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:columns"].columns.custom-columns-class#columns-id[data-columns-attribute="columns-attr-value"]': 1
+ '.columns__item': 2
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/content_banner.yml b/tests/src/Kernel/fixtures/markup_rendering_components/content_banner.yml
new file mode 100644
index 000000000..d045d4b84
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/content_banner.yml
@@ -0,0 +1,279 @@
+content_banner_white_with_all_content:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ background: white
+ meta:
+ - '26 May 2020'
+ - '1 comment'
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ title: 'This is the title of this page'
+ title_tag: h2
+ action_bar:
+ - '#markup': '
'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ outline: false
+ - label: 'Secondary badge'
+ rounded_pill: true
+ links:
+ - label: 'Link 1'
+ path: 'https://example-1.com'
+ - label: 'Link 2'
+ path: 'https://example-2.com'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: white
+ meta:
+ - '26 May 2020'
+ - '1 comment'
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ title: 'This is the title of this page'
+ title_tag: h2
+ action_bar: '
'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ outline: false
+ - label: 'Secondary badge'
+ rounded_pill: true
+ links:
+ - label: 'Link 1'
+ path: 'https://example-1.com'
+ - label: 'Link 2'
+ path: 'https://example-2.com'
+content_banner_white_with_image_and_copyright:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ background: white
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
+ copyright: '© 2024 Example Organisation'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: white
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ image_size: lg
+ title: 'This is the title of this page'
+ title_tag: h1
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
+ copyright: '© 2024 Example Organisation'
+content_banner_white_without_image:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ - label: 'Secondary badge'
+ rounded_pill: true
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: white
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ - label: 'Secondary badge'
+ rounded_pill: true
+content_banner_gray_with_extra_large_image:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ background: gray
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ image_size: xl
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: lighter
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ image_size: xl
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+content_banner_gray_without_image:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ background: gray
+ meta:
+ - '26 May 2020'
+ - '1 comment'
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ - label: 'Secondary badge'
+ rounded_pill: true
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: lighter
+ meta:
+ - '26 May 2020'
+ - '1 comment'
+ title: 'This is the title of this page'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse volutpat ultricies massa, a dapibus libero porta nec.'
+ badges:
+ - label: 'Primary badge'
+ rounded_pill: true
+ - label: 'Secondary badge'
+ rounded_pill: true
+content_banner_white_with_medium_image:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ image_size: md
+ title: 'Content banner with medium image'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\ContentBannerAssert
+ expected:
+ background: white
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ image_size: md
+ title: 'Content banner with medium image'
+content_banner_badge_outline_defaults:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ title: 'Content banner badge defaults'
+ badges:
+ - label: 'Default outlined badge'
+ - label: 'Explicit solid badge'
+ outline: false
+ assertions:
+ count:
+ '.badge.badge-outline-primary': 1
+ '.badge.text-bg-primary': 1
+ equals:
+ '.badge.badge-outline-primary': 'Default outlined badge'
+ '.badge.text-bg-primary': 'Explicit solid badge'
+content_banner_subtitle_text_and_copyright_label:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for content banner image'
+ copyright: '© 2026 Example Organisation'
+ copyright_label: 'Photo credit:'
+ title: 'Content banner inherited card content'
+ subtitle:
+ content: 'Content banner subtitle'
+ tag: h3
+ classes: custom-subtitle-class
+ text:
+ content: 'Content banner card text'
+ tag: div
+ classes: custom-text-class
+ assertions:
+ count:
+ 'h3.card-subtitle.custom-subtitle-class': 1
+ 'div.card-text.custom-text-class': 1
+ '.bcl-copyright .visually-hidden': 1
+ equals:
+ 'h3.card-subtitle': 'Content banner subtitle'
+ 'div.card-text': 'Content banner card text'
+ '.bcl-copyright': '
Photo credit: © 2026 Example Organisation'
+content_banner_custom_attributes_title_link_and_date:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ attributes:
+ id: custom-content-banner
+ class:
+ - custom-content-banner-class
+ data-content-banner: custom-value
+ title: 'Linked content banner title'
+ title_link:
+ path: 'https://example.com'
+ title_attributes:
+ class:
+ - custom-title-class
+ date:
+ day: 23
+ month: Jul
+ year: 2026
+ date_time: '2026-07-23'
+ content: 'Content banner with a date block.'
+ assertions:
+ count:
+ '#custom-content-banner.bcl-content-banner.custom-content-banner-class': 1
+ '#custom-content-banner[data-content-banner="custom-value"]': 1
+ 'h1.card-title.bcl-heading.custom-title-class a[href="https://example.com"]': 1
+ 'time.bcl-date-block[datetime="2026-07-23"]': 1
+ equals:
+ 'h1.card-title.bcl-heading a': 'Linked content banner title'
+ 'time .date-month': Jul
+ 'time .date-day': '23'
+ 'time .year-start': '2026'
+content_banner_badge_and_link_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:content_banner
+ '#props':
+ title: 'Content banner title'
+ badges:
+ - label: 'Custom badge'
+ attributes:
+ class:
+ - custom-badge-class
+ links:
+ - label: 'Custom link'
+ path: 'https://example.com'
+ attributes:
+ class:
+ - custom-link-class
+ assertions:
+ count:
+ '.badge.custom-badge-class': 1
+ 'a.custom-link-class[href="https://example.com"]': 1
+ contains:
+ '.badge.custom-badge-class': 'Custom badge'
+ 'a.custom-link-class': 'Custom link'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/date_block.yml b/tests/src/Kernel/fixtures/markup_rendering_components/date_block.yml
new file mode 100644
index 000000000..b3791f80d
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/date_block.yml
@@ -0,0 +1,66 @@
+date_block_explicit_values_and_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:date_block
+ '#props':
+ day: 23
+ month: Jul
+ date_time: '2026-07-23'
+ year: 2026
+ attributes:
+ id: custom-date-block
+ class:
+ - custom-date-block-class
+ data-date-block: custom-value
+ assertions:
+ count:
+ 'time#custom-date-block.bcl-date-block.custom-date-block-class': 1
+ 'time[data-date-block="custom-value"]': 1
+ 'time[datetime="2026-07-23"]': 1
+ 'time svg': 0
+ equals:
+ 'time .date-month': Jul
+ 'time .date-day': '23'
+ 'time .year-start': '2026'
+date_block_range_with_custom_icon_path:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:date_block
+ '#props':
+ day: 23
+ month: Jan
+ date_time: '2026-01-23'
+ year: 2026
+ end_day: 11
+ end_month: Feb
+ end_year: 2027
+ icon_path: '/custom/icons.svg'
+ assertions:
+ count:
+ 'time[datetime="2026-01-23"]': 1
+ 'time svg': 1
+ 'time .year-end': 1
+ contains:
+ 'time svg': '/custom/icons.svg#chevron-right'
+ equals:
+ 'time > div > div > div:nth-of-type(1) .date-month': Jan
+ 'time > div > div > div:nth-of-type(1) .date-day': '23'
+ 'time > div > div > div:nth-of-type(3) .date-month': Feb
+ 'time > div > div > div:nth-of-type(3) .date-day': '11'
+ 'time .year-start': '2026'
+ 'time .year-end': '2027'
+date_block_default_values:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:date_block
+ '#props': {}
+ assertions:
+ count:
+ 'time.bcl-date-block': 1
+ 'time[datetime]': 0
+ 'time svg': 0
+ 'time .year-end': 0
+ equals:
+ 'time .date-month': ''
+ 'time .date-day': ''
+ 'time .year-start': ''
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/description_list.yml b/tests/src/Kernel/fixtures/markup_rendering_components/description_list.yml
new file mode 100644
index 000000000..8733129af
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/description_list.yml
@@ -0,0 +1,106 @@
+description_list_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:description_list
+ '#props':
+ title: 'The description list title'
+ title_tag: 'h4'
+ orientation: default
+ items:
+ - term:
+ - label: 'Single label with icon'
+ icon:
+ name: 'geo-alt-fill'
+ definition: 'Description of first term text goes here.'
+ - term:
+ - label: 'First label without icon'
+ - label: 'Second label with icon'
+ icon:
+ name: 'calendar-event'
+ definition: 'Description of second term text goes here.'
+ - term:
+ - label:
+ - '#markup': 'A label with
some markup.'
+ definition:
+ - label:
+ - '#markup': '
First line description of third term text goes here.
'
+ - label:
+ - '#markup': 'Second line description of third term text goes here.'
+ - term:
+ - label: 'Custom icon size'
+ icon:
+ name: 'bicycle'
+ size: 'xl'
+ definition: 'This is a bigger icon.'
+ - term: 'A single term without icon.'
+ definition: 'Yet another definition text.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\DescriptionListAssert
+ expected:
+ items:
+ - term:
+ - label: 'Single label with icon'
+ icon: 'geo-alt-fill'
+ definition: 'Description of first term text goes here.'
+ - term:
+ - label: 'First label without icon'
+ - label: 'Second label with icon'
+ icon: 'calendar-event'
+ definition: 'Description of second term text goes here.'
+ - term:
+ - label:
+ - '#markup': 'A label with
some markup.'
+ definition:
+ - label: '
First line description of third term text goes here.
'
+ - label: 'Second line description of third term text goes here.'
+ - term:
+ - label: 'Custom icon size'
+ icon:
+ name: 'bicycle'
+ size: 'xl'
+ definition: 'This is a bigger icon.'
+ - term: 'A single term without icon.'
+ definition: 'Yet another definition text.'
+description_list_horizontal:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:description_list
+ '#props':
+ title: 'The description list title'
+ orientation: horizontal
+ items:
+ - term:
+ - label: 'Single label with icon'
+ icon:
+ name: 'geo-alt-fill'
+ definition: 'Description of first term text goes here.'
+ - term:
+ - label: 'First label without icon'
+ - label: 'Second label with icon'
+ icon:
+ name: 'calendar-event'
+ definition: 'Description of second term text goes here.'
+ - term:
+ - label: 'A single term without icon.'
+ definition:
+ - label: 'First definition for third term.'
+ - label: 'Second definition for third term.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\DescriptionListAssert
+ expected:
+ items:
+ - term:
+ - label: 'Single label with icon'
+ icon: 'geo-alt-fill'
+ definition: 'Description of first term text goes here.'
+ - term:
+ - label: 'First label without icon'
+ - label: 'Second label with icon'
+ icon: 'calendar-event'
+ definition: 'Description of second term text goes here.'
+ - term: 'A single term without icon.'
+ definition:
+ - label: 'First definition for third term.'
+ - label: 'Second definition for third term.'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/dropdown.yml b/tests/src/Kernel/fixtures/markup_rendering_components/dropdown.yml
new file mode 100644
index 000000000..2805aeff6
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/dropdown.yml
@@ -0,0 +1,123 @@
+dropdown_button_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:dropdown
+ '#props':
+ dropdown_id: bcl-dropdown
+ icon_path: /custom-icons.svg
+ trigger:
+ label: 'Dropdown toggle'
+ variant: secondary
+ attributes:
+ class:
+ - custom-trigger
+ icon:
+ name: list
+ attributes:
+ class:
+ - custom-trigger-icon
+ attributes:
+ class:
+ - custom-menu
+ items:
+ - label: 'A link'
+ path: '/example'
+ attributes:
+ class:
+ - custom-link
+ icon: link
+ - label: 'A button'
+ button: true
+ attributes:
+ class:
+ - custom-button
+ - divider: true
+ - label: 'A disabled button'
+ button: true
+ disabled: true
+ - label: 'An active button'
+ button: true
+ active: true
+ assertions:
+ count:
+ 'div.dropdown': 1
+ 'button#bcl-dropdown.btn-secondary.dropdown-toggle.custom-trigger[data-bs-toggle="dropdown"][autocomplete="off"]': 1
+ 'button#bcl-dropdown[aria-expanded="false"]': 1
+ 'ul.dropdown-menu.custom-menu[aria-labelledby="bcl-dropdown"]': 1
+ 'ul.dropdown-menu-dark': 0
+ 'li': 5
+ 'hr.dropdown-divider': 1
+ 'a.dropdown-item.custom-link[href="/example"]': 1
+ 'button.dropdown-item.custom-button': 1
+ 'button.dropdown-item.active': 1
+ 'button.dropdown-item[disabled][aria-disabled="true"]': 1
+ 'svg.custom-trigger-icon': 1
+ contains:
+ 'button#bcl-dropdown svg': '/custom-icons.svg#list'
+ 'a.dropdown-item': 'A link'
+ 'a.dropdown-item svg': '/custom-icons.svg#link'
+ equals:
+ 'button.dropdown-item.active': 'An active button'
+ 'button.dropdown-item[disabled]': 'A disabled button'
+ 'ul.dropdown-menu li:nth-child(2) button.dropdown-item': 'A button'
+dropdown_dark_aligned_dropup:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:dropdown
+ '#props':
+ dropdown_id: dark-dropdown
+ trigger:
+ label: 'Dark dropdown'
+ disabled: true
+ dark_mode: true
+ direction: dropup
+ alignment: dropdown-menu-lg-end
+ items:
+ - label: 'A link'
+ path: '/example'
+ assertions:
+ count:
+ 'div.dropdown.dropup': 1
+ 'button#dark-dropdown.dropdown-toggle[disabled][aria-disabled="true"]': 1
+ 'ul.dropdown-menu.dropdown-menu-dark.dropdown-menu-lg-end': 1
+ 'a.dropdown-item[href="/example"]': 1
+dropdown_link_without_wrapper:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:dropdown
+ '#props':
+ dropdown_id: link-dropdown
+ link: true
+ remove_wrapper: true
+ trigger:
+ label: 'Linked dropdown'
+ path: '/example'
+ attributes:
+ class:
+ - custom-link-trigger
+ items:
+ - label: 'A link'
+ path: '/target'
+ assertions:
+ count:
+ 'div.dropdown': 0
+ 'a#link-dropdown.dropdown-toggle.custom-link-trigger[href="/example"][role="button"]': 1
+ 'a#link-dropdown[autocomplete]': 0
+ 'ul.dropdown-menu[aria-labelledby="link-dropdown"]': 1
+ 'a.dropdown-item[href="/target"]': 1
+dropdown_inside_navigation_with_fallback_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:dropdown
+ '#props':
+ button_label: 'Navigation dropdown'
+ inside_navigation: true
+ items:
+ - label: 'A link'
+ path: '/example'
+ assertions:
+ count:
+ 'div.dropdown': 1
+ 'button[id^="dropdown"].dropdown-toggle[data-bs-toggle="dropdown"]': 1
+ 'button.dropdown-toggle[autocomplete]': 0
+ 'ul.dropdown-menu[aria-labelledby^="dropdown"][aria-hidden="true"][role="presentation"]': 1
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/fact_figures.yml b/tests/src/Kernel/fixtures/markup_rendering_components/fact_figures.yml
new file mode 100644
index 000000000..fd032d82a
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/fact_figures.yml
@@ -0,0 +1,162 @@
+fact_figures_default_1col:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:fact_figures
+ '#props':
+ columns: 1
+ link:
+ label: 'Read more'
+ path: '/example'
+ title: 'Fact and figures block'
+ title_tag: 'h3'
+ items:
+ - icon: 'sticky'
+ subtitle: 'Jira Tickets'
+ title: '1529 JIRA Ticket'
+ description: 'Nunc condimentum sapien ut nibh finibus suscipit vitae at justo. Morbi quis odio faucibus, commodo tortor id, elementum liber'
+ - icon: 'inboxes'
+ subtitle: 'Feature tickets'
+ title: '337 Features'
+ description: 'Turpis varius congue venenatis, erat dui feugiat felis.'
+ - icon: 'shield-check'
+ subtitle: 'Test tickets'
+ title: '107 Tests'
+ description: 'Cras vestibulum efficitur mi, quis porta tellus rutrum ut. Quisque at pulvinar sem.'
+ - icon: 'file-earmark-code'
+ subtitle: 'Test variants'
+ title: '5670 Variants'
+ description: 'Aliquam lacinia diam eu sem malesuada, in interdum ante bibendum.'
+ - icon: 'inbox'
+ subtitle: 'Jira ticket'
+ title: '345 Dev Ticket'
+ description: 'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis nec lectus tortor.'
+ - icon: 'eye'
+ subtitle: 'Figma components'
+ title: '43 Components'
+ description: 'Sed efficitur bibendum rutrum. Nunc feugiat congue augue ac consectetur.'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:fact_figures"].bcl-fact-figures.bcl-fact-figures--default': 1
+ 'h3.bcl-heading': 1
+ 'div.row.row-cols-md-1': 1
+ 'div.row.row-cols-sm-2': 0
+ 'svg.bi.icon--l': 6
+ 'svg use': 6
+ contains:
+ 'svg': '#sticky'
+ 'a[href="/example"]': 'Read more'
+ equals:
+ 'h3.bcl-heading': 'Fact and figures block'
+ 'div > div.col:nth-child(1) > div.fs-4': '1529 JIRA Ticket'
+ 'div > div.col:nth-child(2) > div.fs-4': '337 Features'
+ 'div > div.col:nth-child(3) > div.fs-4': '107 Tests'
+ 'div > div.col:nth-child(4) > div.fs-4': '5670 Variants'
+ 'div > div.col:nth-child(5) > div.fs-4': '345 Dev Ticket'
+ 'div > div.col:nth-child(6) > div.fs-4': '43 Components'
+ 'div > div.col:nth-child(1) > div.fs-5': 'Jira Tickets'
+ 'div > div.col:nth-child(2) > div.fs-5': 'Feature tickets'
+ 'div > div.col:nth-child(3) > div.fs-5': 'Test tickets'
+ 'div > div.col:nth-child(4) > div.fs-5': 'Test variants'
+ 'div > div.col:nth-child(5) > div.fs-5': 'Jira ticket'
+ 'div > div.col:nth-child(6) > div.fs-5': 'Figma components'
+ 'a[href="/example"]': 'Read more'
+fact_figures_default_2col:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:fact_figures
+ '#props':
+ columns: 2
+ title: 'Fact and figures block'
+ items:
+ - icon: 'sticky'
+ subtitle: 'Jira Tickets'
+ title: '1529 JIRA Ticket'
+ description: 'Nunc condimentum sapien ut nibh finibus suscipit vitae at justo. Morbi quis odio faucibus, commodo tortor id, elementum liber'
+ - icon: 'inboxes'
+ subtitle: 'Feature tickets'
+ title: '337 Features'
+ description: 'Turpis varius congue venenatis, erat dui feugiat felis.'
+ - icon: 'shield-check'
+ subtitle: 'Test tickets'
+ title: '107 Tests'
+ description: 'Cras vestibulum efficitur mi, quis porta tellus rutrum ut. Quisque at pulvinar sem.'
+ - icon: 'file-earmark-code'
+ subtitle: 'Test variants'
+ title: '5670 Variants'
+ description: 'Aliquam lacinia diam eu sem malesuada, in interdum ante bibendum.'
+ - icon: 'inbox'
+ subtitle: 'Jira ticket'
+ title: '345 Dev Ticket'
+ description: 'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis nec lectus tortor. '
+ - icon: 'eye'
+ subtitle: 'Figma components'
+ title: '43 Components'
+ description: 'Sed efficitur bibendum rutrum. Nunc feugiat congue augue ac consectetur.'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:fact_figures"].bcl-fact-figures.bcl-fact-figures--default': 1
+ 'h2.bcl-heading': 1
+ 'div.row.row-cols-md-2': 1
+ 'div.row.row-cols-sm-2': 0
+ 'svg.bi.icon--l': 6
+ 'div.border-top': 0
+ 'a': 0
+ equals:
+ 'h2.bcl-heading': 'Fact and figures block'
+ 'div > div.col:nth-child(1) > div.fs-4': '1529 JIRA Ticket'
+ 'div > div.col:nth-child(6) > div.fs-4': '43 Components'
+fact_figures_default_3col:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:fact_figures
+ '#props':
+ columns: 3
+ link:
+ label: 'Read more'
+ path: '/example'
+ title: 'Fact and figures block'
+ items:
+ - icon: 'sticky'
+ subtitle: 'Jira Tickets'
+ title: '1529 JIRA Ticket'
+ description: 'Nunc condimentum sapien ut nibh finibus suscipit vitae at justo. Morbi quis odio faucibus, commodo tortor id, elementum liber'
+ - icon: 'inboxes'
+ subtitle: 'Feature tickets'
+ title: '337 Features'
+ description: 'Turpis varius congue venenatis, erat dui feugiat felis.'
+ - icon: 'shield-check'
+ subtitle: 'Test tickets'
+ title: '107 Tests'
+ description: 'Cras vestibulum efficitur mi, quis porta tellus rutrum ut. Quisque at pulvinar sem.'
+ - icon: 'file-earmark-code'
+ subtitle: 'Test variants'
+ title: '5670 Variants'
+ description: 'Aliquam lacinia diam eu sem malesuada, in interdum ante bibendum.'
+ - icon:
+ name: 'inbox'
+ attributes:
+ class:
+ - custom-class
+ subtitle: 'Jira ticket'
+ title: '345 Dev Ticket'
+ description: 'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis nec lectus tortor. '
+ - icon: 'eye'
+ subtitle: 'Figma components'
+ title: '43 Components'
+ description: 'Sed efficitur bibendum rutrum. Nunc feugiat congue augue ac consectetur.'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:fact_figures"].bcl-fact-figures.bcl-fact-figures--default': 1
+ 'h2.bcl-heading': 1
+ 'div.row.row-cols-md-3': 1
+ 'div.row.row-cols-sm-2': 1
+ 'svg.bi.icon--l': 6
+ 'svg.custom-class.mb-2.icon--primary': 1
+ contains:
+ 'div > div.col:nth-child(5) > svg': '#inbox'
+ 'a[href="/example"]': 'Read more'
+ equals:
+ 'h2.bcl-heading': 'Fact and figures block'
+ 'div > div.col:nth-child(1) > div.fs-4': '1529 JIRA Ticket'
+ 'div > div.col:nth-child(6) > div.fs-4': '43 Components'
+ 'a[href="/example"]': 'Read more'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/featured_media.yml b/tests/src/Kernel/fixtures/markup_rendering_components/featured_media.yml
new file mode 100644
index 000000000..51f5715b3
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/featured_media.yml
@@ -0,0 +1,241 @@
+featured_media_image_without_text:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for featured image'
+ title: 'Featured image title'
+ loading: lazy
+ width: 1200
+ height: 600
+ class:
+ - custom-image
+ caption: 'Media description text goes here.'
+ copyright: '© 2026 Example Organisation'
+ attributes:
+ id: featured-media-image
+ class:
+ - custom-featured-media
+ media_attributes:
+ class:
+ - custom-media
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:featured_media"]#featured-media-image.bcl-featured-media.custom-featured-media': 1
+ 'div.bcl-featured-media > div.row': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-4': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-4 > figure.rounded.overflow-hidden.custom-media': 1
+ 'figure > img.img-fluid.flex-end-100.custom-image[src="https://picsum.photos/1200/600/"][alt="Alternative text for featured image"][title="Featured image title"][loading="lazy"][width="1200"][height="600"]': 1
+ 'figure > figcaption > div.bg-light.p-3': 1
+ 'figure > figcaption > div.bcl-copyright': 1
+ 'h2': 0
+ 'a': 0
+ 'iframe': 0
+ 'video': 0
+ equals:
+ 'figcaption > div.bg-light.p-3': 'Media description text goes here.'
+ '.bcl-copyright > span.visually-hidden': 'Image credit:'
+ contains:
+ '.bcl-copyright': '© 2026 Example Organisation'
+
+featured_media_image_with_text_and_link:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ image:
+ src: 'https://picsum.photos/1200/600/'
+ alt: 'Alternative text for featured image'
+ name: 'Image value object title'
+ responsive: false
+ title: 'Linked featured media title'
+ title_tag: h3
+ title_link:
+ label: 'Linked featured media title'
+ path: '/featured'
+ id: featured-title-link
+ disabled: true
+ icon:
+ name: link-45deg
+ transformation: flip-horizontal
+ attributes:
+ class:
+ - custom-title-icon
+ icon_position: before
+ icon_display: inline-flex
+ remove_icon_spacers: true
+ attributes:
+ class:
+ - custom-title-link
+ title_attributes:
+ id: featured-title
+ class:
+ - custom-title
+ subtitle: 'Featured media subtitle'
+ subtitle_tag: h4
+ subtitle_attributes:
+ id: featured-subtitle
+ class:
+ - custom-subtitle
+ text:
+ '#markup': '
Featured media description.
'
+ link:
+ label: 'Read more'
+ path: '/example'
+ variant: primary
+ id: featured-link
+ disabled: false
+ standalone: true
+ icon:
+ name: arrow-right
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-icon
+ icon_position: before
+ icon_display: inline-flex
+ remove_icon_spacers: false
+ attributes:
+ class:
+ - custom-link
+ caption: 'Custom caption.'
+ content_classes: 'custom-caption'
+ icon_path: '/custom-icons.svg'
+ assertions:
+ count:
+ 'div.bcl-featured-media > h3#featured-title.bcl-heading.mb-4.custom-title': 1
+ 'h3#featured-title > a#featured-title-link[href="/featured"][aria-disabled="true"][tabindex="-1"].disabled.standalone.custom-title-link': 1
+ 'a#featured-title-link.gap-2-5': 0
+ 'a#featured-title-link.d-inline-flex': 0
+ 'a#featured-title-link > svg.custom-title-icon.icon--flip-horizontal': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-1': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-2': 1
+ 'div.col-12.col-md-6.order-1 > h4#featured-subtitle.text-secondary.custom-subtitle': 1
+ 'div.col-12.col-md-6.order-1 p.featured-description': 1
+ 'a#featured-link[href="/example"].link-primary.standalone.d-inline-flex.gap-2-5.custom-link': 1
+ 'a#featured-link svg.bi.icon--s.icon--rotate-90.custom-icon': 1
+ 'figure > img.flex-end-100[title="Image value object title"]': 1
+ 'figure > img.img-fluid': 0
+ 'figure > figcaption > div.custom-caption': 1
+ 'figure .bcl-copyright': 0
+ equals:
+ 'h4#featured-subtitle': 'Featured media subtitle'
+ 'p.featured-description': 'Featured media description.'
+ 'figcaption > div.custom-caption': 'Custom caption.'
+ contains:
+ 'h3#featured-title > a': 'Linked featured media title'
+ 'a#featured-title-link svg': '/custom-icons.svg#link-45deg'
+ 'a#featured-link': 'Read more'
+ 'a#featured-link svg': '/custom-icons.svg#arrow-right'
+
+featured_media_embedded_right_custom_ratio:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ embedded_media:
+ '#type': html_tag
+ '#tag': iframe
+ '#attributes':
+ src: 'https://example.com/embed'
+ title: 'Embedded media'
+ ratio: 21x9
+ title: 'Embedded featured media'
+ subtitle: 'Supporting subtitle'
+ text: 'Text displayed on the right.'
+ text_position: right
+ caption: 'Embedded media caption.'
+ copyright: '© 2026 Example Organisation'
+ copyright_label: 'Media copyright:'
+ assertions:
+ count:
+ 'div.bcl-featured-media > h2.bcl-heading.mb-4': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-1 > figure': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-2': 1
+ 'div.col-12.col-md-6.order-2 > h3.text-secondary': 1
+ 'figure > div.ratio.ratio-21x9 > iframe[src="https://example.com/embed"][title="Embedded media"]': 1
+ 'figure > img': 0
+ 'figure > video': 0
+ equals:
+ 'h2.bcl-heading': 'Embedded featured media'
+ 'h3.text-secondary': 'Supporting subtitle'
+ 'figcaption > div.bg-light.p-3': 'Embedded media caption.'
+ '.bcl-copyright > span.visually-hidden': 'Media copyright:'
+ contains:
+ 'div.col-12.col-md-6.order-2': 'Text displayed on the right.'
+ '.bcl-copyright': '© 2026 Example Organisation'
+
+featured_media_embedded_defaults_without_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ embedded_media:
+ '#type': html_tag
+ '#tag': iframe
+ '#attributes':
+ src: 'https://example.com/default-ratio'
+ title: 'Default ratio'
+ subtitle: 'Subtitle without a title'
+ assertions:
+ count:
+ 'h2.bcl-heading': 0
+ 'h2.text-secondary': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-1': 1
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-6.order-2 > figure': 1
+ 'figure > div.ratio.ratio-16x9 > iframe[src="https://example.com/default-ratio"]': 1
+ 'figure > figcaption': 0
+ 'a': 0
+ equals:
+ 'h2.text-secondary': 'Subtitle without a title'
+
+featured_media_native_video:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ poster_image: 'https://picsum.photos/1200/675/'
+ sources:
+ - type: video/mp4
+ src: 'https://example.com/video.mp4'
+ - type: video/webm
+ src: 'https://example.com/video.webm'
+ tracks:
+ - kind: captions
+ label: English
+ src: 'https://example.com/captions-en.vtt'
+ src_lang: en
+ - kind: subtitles
+ label: Français
+ src: 'https://example.com/subtitles-fr.vtt'
+ src_lang: fr
+ caption: 'Native video caption.'
+ assertions:
+ count:
+ 'div.bcl-featured-media > div.row > div.col-12.col-md-4 > figure': 1
+ 'figure > video[controls][poster="https://picsum.photos/1200/675/"].w-100.d-block': 1
+ 'video > source': 2
+ 'video > source[type="video/mp4"][src="https://example.com/video.mp4"]': 1
+ 'video > source[type="video/webm"][src="https://example.com/video.webm"]': 1
+ 'video > track': 2
+ 'video > track[kind="captions"][label="English"][src="https://example.com/captions-en.vtt"][srclang="en"]': 1
+ 'video > track[kind="subtitles"][label="Français"][src="https://example.com/subtitles-fr.vtt"][srclang="fr"]': 1
+ 'figure > img': 0
+ 'figure iframe': 0
+ equals:
+ 'figcaption > div.bg-light.p-3': 'Native video caption.'
+
+featured_media_image_as_markup:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:featured_media
+ '#props':
+ image:
+ '#markup': '
'
+ assertions:
+ count:
+ 'figure > img[src="https://example.com/pre-rendered.jpg"][alt="Pre-rendered image"]': 1
+ 'figure > img.img-fluid': 0
+ 'figure > img.flex-end-100': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/file.yml b/tests/src/Kernel/fixtures/markup_rendering_components/file.yml
new file mode 100644
index 000000000..2ef9e8a3f
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/file.yml
@@ -0,0 +1,132 @@
+file_default_without_translations:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:file
+ '#props':
+ title: 'Download the document'
+ file:
+ title: 'Example document'
+ url: 'https://example.com/document.pdf'
+ size: 4444
+ extension: pdf
+ language_code: en
+ assertions:
+ count:
+ 'div.bcl-file-container.mt-4 > h2.mb-4.bcl-heading': 1
+ 'div.bcl-file-container > div.bcl-file.border.rounded': 1
+ '.pe-2.pb-2 > svg': 1
+ 'a[download][target="_blank"][href="https://example.com/document.pdf"]': 1
+ '.bcl-file .collapse': 0
+ equals:
+ 'h2.bcl-heading': 'Download the document'
+ 'p.fw-medium.fs-5': 'Example document'
+ contains:
+ '.bcl-file small.fw-medium': 'English'
+ '.bcl-file small.fw-medium span.fw-normal': '(4.34 KB - PDF)'
+ '.pe-2.pb-2 > svg': '/icons/bcl-default-icons.svg#file-pdf-fill'
+ 'a[download] svg': '/icons/bcl-default-icons.svg#download'
+
+file_with_translations_and_custom_link_label:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:file
+ '#props':
+ link_label: 'Get the file'
+ file:
+ title: 'Example document'
+ url: 'https://example.com/document.pdf'
+ extension: pdf
+ language_code: en
+ translations:
+ - title: 'Exemple de document'
+ url: 'https://example.com/document-fr.pdf'
+ size: 4321
+ extension: pdf
+ language_code: fr
+ - title: 'Пример документ'
+ url: 'https://example.com/document-bg.docx'
+ size: 40391
+ extension: docx
+ language_code: bg
+ meta: 'Draft'
+ assertions:
+ count:
+ 'h2': 0
+ '.bcl-file > .border-top': 1
+ '.bcl-file .collapse .row': 2
+ '.bcl-file .collapse a[href="https://example.com/document-fr.pdf"]': 1
+ '.bcl-file .collapse a[href="https://example.com/document-bg.docx"]': 1
+ equals:
+ '.collapse > div.py-3.border-bottom p.fw-bold': 'Exemple de document'
+ '.collapse > div.pt-3 p.fw-bold': 'Пример документ'
+ '.collapse > div.pt-3 small.fw-bold span.fw-normal': 'Draft'
+ contains:
+ 'a[href="https://example.com/document.pdf"]': 'Get the file'
+ '.text-end.text-md-center a': 'Other languages (2)'
+ '.collapse > div.py-3.border-bottom small.fw-bold': 'français'
+ '.collapse > div.py-3.border-bottom small.fw-bold span.fw-normal': '(4.22 KB - PDF)'
+ '.bcl-file .collapse a[href="https://example.com/document-fr.pdf"]': 'Get the file'
+
+file_custom_attributes_and_icon_path:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:file
+ '#props':
+ title: 'Custom attrs file'
+ title_attributes:
+ class:
+ - custom-title-class
+ attributes:
+ id: custom-file
+ class:
+ - custom-file-class
+ data-file: custom-value
+ icon_path: '/custom-icons.svg'
+ file:
+ title: 'Example document'
+ url: 'https://example.com/document.pdf'
+ size: 4444
+ extension: pdf
+ language_code: en
+ meta: 'Updated last week'
+ assertions:
+ count:
+ 'h2.custom-title-class.bcl-heading': 1
+ 'h2.mb-4': 0
+ '#custom-file.bcl-file.custom-file-class': 1
+ '#custom-file[data-file="custom-value"]': 1
+ equals:
+ '.bcl-file small.fw-medium span.fw-normal': 'Updated last week'
+ contains:
+ '.pe-2.pb-2 > svg': '/custom-icons.svg#file-pdf-fill'
+ 'a[download] svg': '/custom-icons.svg#download'
+
+file_without_language_code_or_size:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:file
+ '#props':
+ file:
+ title: 'No language file'
+ url: 'https://example.com/no-lang.txt'
+ extension: txt
+ assertions:
+ count:
+ 'h2': 0
+ '.bcl-file small.fw-medium': 1
+ equals:
+ '.bcl-file small.fw-medium': '
(TXT) '
+ contains:
+ '.pe-2.pb-2 > svg': '/icons/bcl-default-icons.svg#file-text-fill'
+
+file_without_file_renders_nothing:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:file
+ '#props':
+ title: 'Should not render body'
+ assertions:
+ count:
+ 'h2': 0
+ '.bcl-file-container': 0
+ '.bcl-file': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/gallery.yml b/tests/src/Kernel/fixtures/markup_rendering_components/gallery.yml
new file mode 100644
index 000000000..3558592b7
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/gallery.yml
@@ -0,0 +1,237 @@
+gallery_mixed_media:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:gallery
+ '#props':
+ items:
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ caption_title: 'First image'
+ caption: 'First image caption.'
+ - media:
+ '#markup': '
'
+ '#allowed_tags':
+ - video
+ - source
+ thumbnail:
+ '#markup': '
'
+ caption_title: 'Featured video'
+ caption: 'Video caption.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\GalleryPatternAssert
+ expected:
+ title: ~
+ items:
+ - thumbnail:
+ rendered: '
'
+ caption_title: 'First image'
+ caption: 'First image caption.'
+ media:
+ rendered: '
'
+ caption_title: 'First image'
+ caption: 'First image caption.'
+ - thumbnail:
+ rendered: '
'
+ caption_title: 'Featured video'
+ caption: 'Video caption.'
+ play_icon: true
+ media:
+ rendered: '
'
+ caption_title: ~
+ caption: ~
+
+gallery_default_labels_and_limit:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:gallery
+ '#props':
+ title: 'Default gallery title'
+ counter: '%d items'
+ items:
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ assertions:
+ count:
+ '.bcl-gallery': 1
+ '.bcl-gallery > .d-sm-flex h2.bcl-heading': 1
+ '.bcl-gallery__thumbnails > ul.bcl-gallery__grid > li': 5
+ '.bcl-gallery__thumbnails-collaspe > ul.bcl-gallery__grid > li': 1
+ '.bcl-gallery__collapse': 1
+ '.bcl-gallery__mobile-view-more': 1
+ '.modal .carousel-item': 6
+ equals:
+ '.bcl-gallery > .d-sm-flex .fw-bold': '6 items'
+ '.bcl-gallery__collapse .label-collapsed': 'View the full gallery (6)'
+ '.bcl-gallery__collapse .label-expanded': 'View less'
+ '.modal .carousel-control-prev .visually-hidden': 'Previous'
+ '.modal .carousel-control-next .visually-hidden': 'Next'
+
+gallery_with_all_parameters:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:gallery
+ '#props':
+ gallery_id: custom-gallery
+ title: 'Linked gallery title'
+ title_tag: h4
+ title_link:
+ path: 'https://example.com/gallery'
+ variant: danger
+ id: custom-gallery-title-link
+ disabled: true
+ icon:
+ name: images
+ size: l
+ transformation: flip-horizontal
+ attributes:
+ class:
+ - custom-title-icon
+ icon_position: before
+ icon_display: inline-flex
+ remove_icon_spacers: true
+ attributes:
+ class:
+ - custom-title-link
+ title_attributes:
+ id: custom-gallery-title
+ class:
+ - custom-title-class
+ counter: 'Media count: %d'
+ collapse_button_label: 'Show all %d media items'
+ expand_button_label: 'Show fewer media items'
+ previous_label: 'Previous media'
+ next_label: 'Next media'
+ max_visible_thumbnails: 2
+ icon_path: '/custom-icons.svg'
+ attributes:
+ id: custom-gallery-wrapper
+ class:
+ - custom-gallery-class
+ data-gallery-attribute: custom-value
+ items:
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ caption_title: 'Custom image'
+ caption: 'Custom image caption.'
+ copyright: '© 2026 Example Organisation'
+ copyright_label: 'Custom credit:'
+ - media:
+ '#markup': '
'
+ '#allowed_tags':
+ - iframe
+ thumbnail:
+ '#markup': '
'
+ caption_title: 'Embedded media'
+ caption: 'Embedded media caption.'
+ copyright: '© 2026 Video Organisation'
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ caption_title: 'Linked image'
+ caption: 'Linked image caption.'
+ caption_classes: custom-caption-class
+ interval: 3000
+ link:
+ label: 'Read more'
+ path: 'https://example.com/read-more'
+ variant: primary
+ id: custom-gallery-link
+ disabled: true
+ standalone: true
+ icon:
+ name: chevron-right
+ size: xs
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-link-icon
+ icon_position: after
+ icon_display: flex
+ remove_icon_spacers: false
+ attributes:
+ class:
+ - custom-gallery-link
+ assertions:
+ count:
+ '#custom-gallery-wrapper.bcl-gallery.custom-gallery-class[data-gallery-attribute="custom-value"]': 1
+ 'h4#custom-gallery-title.bcl-heading.custom-title-class': 1
+ 'h4#custom-gallery-title > a#custom-gallery-title-link.custom-title-link.link-danger.disabled.standalone[href="https://example.com/gallery"][aria-disabled="true"][tabindex="-1"]': 1
+ '#custom-gallery-title-link.d-inline-flex': 0
+ '#custom-gallery-title-link.gap-2-5': 0
+ 'h4#custom-gallery-title svg.custom-title-icon.icon--l.icon--flip-horizontal': 1
+ '.bcl-gallery__thumbnails > ul.bcl-gallery__grid > li': 2
+ '.bcl-gallery__thumbnails-collaspe > ul.bcl-gallery__grid > li': 1
+ '#modalCustom-gallery.modal': 1
+ '#carouselCustom-gallery.carousel': 1
+ '#carouselCustom-gallery .carousel-item': 3
+ '#carouselCustom-gallery .carousel-item:nth-child(2) .carousel-caption': 0
+ '#carouselCustom-gallery .carousel-item:nth-child(3)[data-bs-interval="3000"]': 1
+ '#custom-gallery-link.custom-gallery-link.link-primary.disabled.standalone.d-flex.gap-2-5[href="https://example.com/read-more"][aria-disabled="true"][tabindex="-1"]': 1
+ '#custom-gallery-link svg.custom-link-icon.icon--xs.icon--rotate-90': 1
+ equals:
+ '.bcl-gallery > .d-sm-flex .fw-bold': 'Media count: 3'
+ '.bcl-gallery__collapse .label-collapsed': 'Show all 3 media items'
+ '.bcl-gallery__collapse .label-expanded': 'Show fewer media items'
+ '#carouselCustom-gallery .carousel-control-prev .visually-hidden': 'Previous media'
+ '#carouselCustom-gallery .carousel-control-next .visually-hidden': 'Next media'
+ '#carouselCustom-gallery .carousel-item:nth-child(1) .bcl-copyright': '
Custom credit: © 2026 Example Organisation'
+ '#carouselCustom-gallery .carousel-item:nth-child(2) .bcl-copyright': '
Copyright: © 2026 Video Organisation'
+ contains:
+ '#custom-gallery-title > a': 'Linked gallery title'
+ 'h4#custom-gallery-title svg': '/custom-icons.svg#images'
+ '.bcl-gallery__item-play-icon svg': '/custom-icons.svg#play-fill'
+ '#custom-gallery-link svg': '/custom-icons.svg#chevron-right'
+
+gallery_with_fallback_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:gallery
+ '#props':
+ items:
+ - media:
+ '#markup': '
'
+ thumbnail:
+ '#markup': '
'
+ assertions:
+ count:
+ '.bcl-gallery .modal[id^="modalGallery"]': 1
+ '.bcl-gallery .carousel[id^="carouselGallery"]': 1
+ '.bcl-gallery__item[data-bs-target^="#carouselGallery"]': 1
+ '.bcl-gallery__item-overlay[data-bs-target^="#modalGallery"]': 1
+
+gallery_without_items:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:gallery
+ '#props': {}
+ assertions:
+ count:
+ '.bcl-gallery': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/icon.yml b/tests/src/Kernel/fixtures/markup_rendering_components/icon.yml
new file mode 100644
index 000000000..98aaac894
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/icon.yml
@@ -0,0 +1,89 @@
+icon_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props': {}
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\IconPatternAssert
+ expected:
+ name: ''
+ size: s
+
+icon_with_name_and_default_size:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props':
+ name: envelope
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\IconPatternAssert
+ expected:
+ name: envelope
+ size: s
+
+icon_with_smallest_size:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props':
+ name: eye
+ size: 2xs
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\IconPatternAssert
+ expected:
+ name: eye
+ size: 2xs
+
+icon_with_fluid_size:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props':
+ name: eye
+ size: fluid
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\IconPatternAssert
+ expected:
+ name: eye
+ size: fluid
+
+icon_with_transformation_and_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props':
+ name: arrow-right
+ size: xl
+ transformation: rotate-90
+ attributes:
+ id: custom-icon
+ class:
+ - custom-icon-class
+ data-icon-attribute: custom-value
+ role: img
+ aria-label: 'Next item'
+ assertions:
+ count:
+ 'svg#custom-icon.bi.icon--xl.icon--rotate-90.custom-icon-class[data-icon-attribute="custom-value"][role="img"][aria-label="Next item"]': 1
+ 'svg.icon--rotate-180': 0
+ contains:
+ 'svg#custom-icon': 'assets/icons/bcl-default-icons.svg#arrow-right'
+
+icon_with_custom_path:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:icon
+ '#props':
+ name: custom-symbol
+ size: m
+ transformation: flip-vertical
+ icon_path: '/custom/icons.svg'
+ assertions:
+ count:
+ 'svg.bi.icon--m.icon--flip-vertical': 1
+ contains:
+ 'svg': '/custom/icons.svg#custom-symbol'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/inpage_navigation.yml b/tests/src/Kernel/fixtures/markup_rendering_components/inpage_navigation.yml
new file mode 100644
index 000000000..4bf0e3678
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/inpage_navigation.yml
@@ -0,0 +1,173 @@
+inpage_navigation_default_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:inpage_navigation
+ '#props': {}
+ assertions:
+ count:
+ 'nav[data-component-id="oe_bootstrap_theme:inpage_navigation"]': 1
+ 'div.row > div.bcl-sidebar.col-md-3': 1
+ 'div.row > div.col-md-9': 1
+ 'nav.bcl-inpage-navigation.position-sticky[id^="bcl-inpage-navigation"]': 1
+ 'nav.dynamic-active': 0
+ 'button.dropdown-toggle.h2.d-md-none[id^="bcl-inpage-navigation"][aria-expanded="false"][data-bs-toggle="dropdown"]': 1
+ 'button.dropdown-toggle svg.icon--s': 1
+ 'h2.bcl-heading': 0
+ 'ul.nav.nav-pills.flex-column': 1
+ 'ul.nav-pills > li': 0
+ equals:
+ 'div.row > div.col-md-9': ''
+
+inpage_navigation_with_content_and_links:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:inpage_navigation
+ '#props':
+ title: 'Page content'
+ id: page-navigation
+ dropdown_id: page-navigation-dropdown
+ links:
+ - label:
+ '#markup': 'Heading
one '
+ path: '#heading-1'
+ active: true
+ attributes:
+ class:
+ - custom-navigation-link
+ data-link-attribute: first
+ - label: 'Heading two'
+ path: '#heading-2'
+ id: heading-two-link
+ variant: warning
+ standalone: true
+ - label: 'Unavailable heading'
+ path: '#heading-3'
+ disabled: true
+ content:
+ '#markup': '
Heading one First section content.
Heading two Second section content.
'
+ assertions:
+ count:
+ 'nav#page-navigation.bcl-inpage-navigation[aria-labelledby="page-navigation-dropdown"]': 1
+ 'button#page-navigation-dropdown.dropdown-toggle[type="button"]': 1
+ 'button#page-navigation-dropdown svg.icon--s': 1
+ 'nav#page-navigation h2.bcl-heading': 1
+ 'ul.nav-pills > li.nav-item': 3
+ 'a.nav-link.active.custom-navigation-link[href="#heading-1"][data-link-attribute="first"]': 1
+ 'a#heading-two-link.nav-link.link-warning.standalone[href="#heading-2"]': 1
+ 'a.nav-link.disabled[href="#heading-3"][aria-disabled="true"][tabindex="-1"]': 1
+ 'div.col-md-9 > h2#heading-1': 1
+ 'div.col-md-9 > h2#heading-2': 1
+ equals:
+ 'nav#page-navigation h2.bcl-heading': 'Page content'
+ 'a.custom-navigation-link strong': one
+ 'div.col-md-9 > h2#heading-2': 'Heading two'
+ contains:
+ 'button#page-navigation-dropdown svg': 'assets/icons/bcl-default-icons.svg#chevron-down'
+
+inpage_navigation_with_all_optional_parameters:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:inpage_navigation
+ '#props':
+ title:
+ '#markup': 'Related
information '
+ title_tag: h3
+ title_link:
+ path: '/overview'
+ variant: success
+ id: linked-navigation-title
+ disabled: true
+ icon:
+ name: arrow-right
+ size: l
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-title-icon
+ icon_position: after
+ icon_display: inline-flex
+ attributes:
+ class:
+ - custom-title-link
+ data-title-link-attribute: custom-value
+ title_attributes:
+ class:
+ - custom-title
+ data-title-attribute: custom-value
+ links:
+ - label: 'Active section'
+ path: '#active-section'
+ active: true
+ icon:
+ name: bookmark
+ attributes:
+ class:
+ - custom-link-icon
+ icon_position: before
+ icon_display: inline-flex
+ attributes:
+ class:
+ - custom-link
+ - label: 'Custom sprite section'
+ path: '#custom-section'
+ variant: danger
+ icon:
+ name: custom-symbol
+ path: '/other/icons.svg'
+ size: xl
+ transformation: flip-vertical
+ attributes:
+ class:
+ - custom-sprite-icon
+ remove_icon_spacers: true
+ content:
+ '#markup': '
Active section Active content.
Custom section Custom content.
'
+ dynamic_active: true
+ icon_path: '/custom/icons.svg'
+ attributes:
+ id: attributes-navigation
+ class:
+ - custom-navigation
+ aria-label: 'Related information'
+ data-navigation-attribute: custom-value
+ assertions:
+ count:
+ 'nav#attributes-navigation.bcl-inpage-navigation.position-sticky.dynamic-active.custom-navigation[aria-label="Related information"][data-navigation-attribute="custom-value"]': 1
+ 'nav#attributes-navigation[aria-labelledby]': 0
+ 'button#attributes-navigation-dropdown.dropdown-toggle': 1
+ 'nav h3.bcl-heading.custom-title[data-title-attribute="custom-value"]': 1
+ 'h3.bcl-heading > a#linked-navigation-title.link-success.standalone.disabled.d-inline-flex.gap-2-5.custom-title-link[href="/overview"][aria-disabled="true"][tabindex="-1"][data-title-link-attribute="custom-value"]': 1
+ 'h3.bcl-heading a > svg.icon--l.icon--rotate-90.custom-title-icon': 1
+ 'a.nav-link.active.custom-link.d-inline-flex.gap-2-5[href="#active-section"]': 1
+ 'a.custom-link > svg:first-child.custom-link-icon': 1
+ 'a.nav-link.link-danger[href="#custom-section"] > svg.icon--xl.icon--flip-vertical.custom-sprite-icon': 1
+ 'a[href="#custom-section"] > svg.ms-2-5': 0
+ 'a[href="#custom-section"] > svg.me-2-5': 0
+ equals:
+ 'h3.bcl-heading a em': information
+ contains:
+ 'button#attributes-navigation-dropdown svg': '/custom/icons.svg#chevron-down'
+ 'h3.bcl-heading a svg': '/custom/icons.svg#arrow-right'
+ 'a.custom-link svg': '/custom/icons.svg#bookmark'
+ 'a[href="#custom-section"] svg': '/other/icons.svg#custom-symbol'
+
+inpage_navigation_without_links:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:inpage_navigation
+ '#props':
+ title: 'Page content'
+ id: navigation-without-links
+ dropdown_id: navigation-without-links-dropdown
+ content:
+ '#markup': '
Content remains visible without navigation links.
'
+ assertions:
+ count:
+ 'nav#navigation-without-links': 1
+ 'button#navigation-without-links-dropdown': 1
+ 'nav h2.bcl-heading': 1
+ 'ul.nav-pills a.nav-link': 0
+ 'div.col-md-9 > div.content-example': 1
+ equals:
+ 'nav h2.bcl-heading': 'Page content'
+ 'div.content-example': 'Content remains visible without navigation links.'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/link.yml b/tests/src/Kernel/fixtures/markup_rendering_components/link.yml
new file mode 100644
index 000000000..2670c6746
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/link.yml
@@ -0,0 +1,158 @@
+link_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props': {}
+ assertions:
+ count:
+ 'a[href="#"]': 1
+ 'a.disabled': 0
+ 'a.standalone': 0
+ 'a[class*="link-"]': 0
+ 'a svg': 0
+ equals:
+ 'a': ''
+
+link_with_variant_id_and_render_array_label:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label:
+ '#markup': 'Read
more '
+ path: 'https://example.com?type=link#details'
+ variant: warning
+ id: variant-link
+ assertions:
+ count:
+ 'a#variant-link.link-warning[href="https://example.com?type=link#details"]': 1
+ 'a#variant-link strong': 1
+ equals:
+ 'a#variant-link strong': more
+
+link_disabled_standalone_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Disabled standalone link'
+ path: '/example'
+ disabled: true
+ standalone: true
+ attributes:
+ class:
+ - custom-link-class
+ data-link-attribute: custom-value
+ title: 'Custom link title'
+ assertions:
+ count:
+ 'a.disabled.standalone.custom-link-class[href="/example"][aria-disabled="true"][tabindex="-1"]': 1
+ 'a[data-link-attribute="custom-value"][title="Custom link title"]': 1
+ equals:
+ 'a': 'Disabled standalone link'
+
+link_with_string_icon_before:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Previous page'
+ path: '/previous'
+ icon: arrow-left
+ icon_position: before
+ assertions:
+ count:
+ 'a[href="/previous"] > svg:first-child.bi.icon--s.me-2-5': 1
+ 'a > svg.ms-2-5': 0
+ contains:
+ 'a > svg': 'assets/icons/bcl-default-icons.svg#arrow-left'
+
+link_with_icon_object_after:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Custom icon'
+ path: '/custom-icon'
+ icon:
+ name: custom-symbol
+ path: '/custom/icons.svg'
+ size: xl
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-icon-class
+ data-icon-attribute: custom-value
+ icon_position: after
+ assertions:
+ count:
+ 'a[href="/custom-icon"] > svg:last-child.bi.icon--xl.icon--rotate-90.ms-2-5.custom-icon-class[data-icon-attribute="custom-value"]': 1
+ 'a > svg.me-2-5': 0
+ contains:
+ 'a > svg': '/custom/icons.svg#custom-symbol'
+
+link_with_inline_flex_icon_display:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Inline flex link'
+ path: '/inline-flex'
+ icon: box-arrow-up-right
+ icon_display: inline-flex
+ assertions:
+ count:
+ 'a.d-inline-flex.gap-2-5': 1
+ 'a > svg:last-child': 1
+ 'a > svg.ms-2-5': 0
+ 'a > svg.me-2-5': 0
+
+link_with_flex_icon_display:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Flex link'
+ path: '/flex'
+ icon: download
+ icon_position: before
+ icon_display: flex
+ assertions:
+ count:
+ 'a.d-flex.gap-2-5': 1
+ 'a > svg.ms-2-5': 0
+ 'a > svg.me-2-5': 0
+
+link_with_icon_only:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ path: '/search'
+ icon: search
+ attributes:
+ aria-label: Search
+ assertions:
+ count:
+ 'a[href="/search"][aria-label="Search"] > svg': 1
+ 'a > svg.ms-2-5': 0
+ 'a > svg.me-2-5': 0
+ contains:
+ 'a > svg': 'assets/icons/bcl-default-icons.svg#search'
+
+link_without_icon_spacers:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:link
+ '#props':
+ label: 'Link without icon spacing'
+ path: '/no-spacing'
+ icon: arrow-right
+ icon_display: inline-flex
+ remove_icon_spacers: true
+ assertions:
+ count:
+ 'a > svg': 1
+ 'a.gap-2-5': 0
+ 'a > svg.ms-2-5': 0
+ 'a > svg.me-2-5': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/links_block.yml b/tests/src/Kernel/fixtures/markup_rendering_components/links_block.yml
new file mode 100644
index 000000000..5aad275d4
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/links_block.yml
@@ -0,0 +1,148 @@
+links_block_default_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:links_block
+ '#props': {}
+ assertions:
+ count:
+ '.bcl-links-block': 0
+
+links_block_vertical_gray:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:links_block
+ '#props':
+ title:
+ '#markup': 'Useful
resources '
+ links:
+ - label:
+ '#markup': 'Funding
opportunities '
+ path: 'https://example.com/funding'
+ - label: 'Programme guidance'
+ path: 'https://example.com/guidance'
+ - label: 'Contact support'
+ path: 'https://example.com/support'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:links_block"].bcl-links-block.bg-light.px-4.py-3.rounded-2': 1
+ 'h2.bcl-heading.pb-3.mb-3.border-bottom': 1
+ 'ul.ps-0.mb-0': 1
+ 'ul > li.list-unstyled': 3
+ 'ul > li.d-inline': 0
+ 'ul > li.me-4-5': 2
+ 'a.mb-3.d-inline-block.standalone': 3
+ 'a svg': 0
+ equals:
+ 'h2.bcl-heading strong': resources
+ 'li:nth-child(1) a em': opportunities
+ 'li:nth-child(2) a[href="https://example.com/guidance"]': 'Programme guidance'
+
+links_block_horizontal_transparent_with_optional_parameters:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:links_block
+ '#props':
+ title:
+ '#markup': 'Related
information '
+ title_tag: h3
+ title_link:
+ path: '/resources'
+ variant: success
+ id: resources-title-link
+ disabled: true
+ icon:
+ name: arrow-right
+ size: l
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-title-icon
+ icon_position: after
+ icon_display: inline-flex
+ attributes:
+ class:
+ - custom-title-link
+ data-title-link-attribute: custom-value
+ title_attributes:
+ class:
+ - custom-title
+ data-title-attribute: custom-value
+ background: transparent
+ orientation: horizontal
+ links:
+ - label:
+ '#markup': 'Disabled
link '
+ path: '/disabled'
+ variant: warning
+ id: disabled-link
+ disabled: true
+ icon: lock
+ icon_position: before
+ attributes:
+ class:
+ - custom-link
+ data-link-attribute: custom-value
+ - label: 'Custom icon'
+ path: '/custom-icon'
+ icon:
+ name: custom-symbol
+ path: '/other/icons.svg'
+ size: xl
+ transformation: flip-vertical
+ attributes:
+ class:
+ - custom-link-icon
+ icon_position: after
+ icon_display: inline-flex
+ - label: 'No icon spacing'
+ path: '/no-spacing'
+ icon: arrow-right
+ icon_display: inline-flex
+ remove_icon_spacers: true
+ icon_path: '/custom/icons.svg'
+ attributes:
+ id: custom-links-block
+ class:
+ - custom-links-block
+ data-block-attribute: custom-value
+ assertions:
+ count:
+ 'div#custom-links-block.bcl-links-block.custom-links-block[data-block-attribute="custom-value"]': 1
+ 'div#custom-links-block.bg-light': 0
+ 'h3.bcl-heading.pb-3.mb-3.border-bottom.custom-title[data-title-attribute="custom-value"]': 1
+ 'h3 > a#resources-title-link.link-success.standalone.disabled.d-inline-flex.gap-2-5.custom-title-link[href="/resources"][aria-disabled="true"][tabindex="-1"][data-title-link-attribute="custom-value"]': 1
+ 'h3 a > svg.icon--l.icon--rotate-90.custom-title-icon': 1
+ 'ul > li.list-unstyled.d-inline': 3
+ 'ul > li.me-4-5': 2
+ 'a#disabled-link.link-warning.disabled.d-flex.gap-2-5.custom-link[href="/disabled"][aria-disabled="true"][tabindex="-1"][data-link-attribute="custom-value"]': 1
+ 'a#disabled-link > svg:first-child': 1
+ 'a[href="/custom-icon"].d-inline-flex.gap-2-5 > svg:last-child.icon--xl.icon--flip-vertical.custom-link-icon': 1
+ 'a[href="/no-spacing"] > svg': 1
+ 'a[href="/no-spacing"].gap-2-5': 0
+ 'a[href="/no-spacing"] > svg.ms-2-5': 0
+ 'a[href="/no-spacing"] > svg.me-2-5': 0
+ equals:
+ 'h3 a em': information
+ 'a#disabled-link strong': link
+ contains:
+ 'h3 a svg': '/custom/icons.svg#arrow-right'
+ 'a#disabled-link svg': '/custom/icons.svg#lock'
+ 'a[href="/custom-icon"] svg': '/other/icons.svg#custom-symbol'
+ 'a[href="/no-spacing"] svg': '/custom/icons.svg#arrow-right'
+
+links_block_without_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:links_block
+ '#props':
+ links:
+ - label: 'Single link'
+ path: '/single-link'
+ assertions:
+ count:
+ 'div.bcl-links-block.bg-light': 1
+ 'div.bcl-links-block h2.bcl-heading': 0
+ 'ul > li.list-unstyled': 1
+ 'a[href="/single-link"]': 1
+ equals:
+ 'a[href="/single-link"]': 'Single link'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/list_group.yml b/tests/src/Kernel/fixtures/markup_rendering_components/list_group.yml
new file mode 100644
index 000000000..bed6b4fda
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/list_group.yml
@@ -0,0 +1,225 @@
+list_group_default_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:list_group
+ '#props': {}
+ assertions:
+ count:
+ 'ul[data-component-id="oe_bootstrap_theme:list_group"].list-group': 1
+ 'ul.list-group-flush': 0
+ 'ul.list-group-numbered': 0
+ 'ul[class*="list-group-horizontal"]': 0
+ 'ul > li': 0
+
+list_group_unordered_with_variants_and_badges:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:list_group
+ '#props':
+ items:
+ - html:
+ '#markup': 'Item with
rendered markup '
+ variant: danger
+ extra_classes: custom-item-class
+ - html: 'Item with a badge'
+ variant: secondary
+ badge:
+ label: '10'
+ background: success
+ rounded_pill: true
+ - html: 'Item with a linked badge'
+ badge:
+ label:
+ '#markup': 'New
items '
+ background: info
+ url: '/updates'
+ title: 'View updates'
+ dismissible: true
+ assistive_text: 'Unread: '
+ outline: true
+ attributes:
+ class:
+ - custom-badge-class
+ data-badge-attribute: custom-value
+ assertions:
+ count:
+ 'ul.list-group > li.list-group-item': 3
+ 'li.list-group-item-danger.custom-item-class strong': 1
+ 'li.list-group-item-secondary span.badge.text-bg-success.rounded-pill': 1
+ 'li:nth-child(3) a.badge.badge-outline-info.custom-badge-class[href="/updates"][title="View updates"][data-badge-attribute="custom-value"]': 1
+ 'li:nth-child(3) a.badge span.visually-hidden': 1
+ 'li:nth-child(3) a.badge svg.icon--xs': 1
+ equals:
+ 'li.list-group-item-danger strong': 'rendered markup'
+ 'li.list-group-item-secondary span.badge': '10'
+ 'li:nth-child(3) a.badge span.visually-hidden': 'Unread:'
+ contains:
+ 'li:nth-child(3) a.badge': 'New
items '
+ 'li:nth-child(3) a.badge svg': 'assets/icons/bcl-default-icons.svg#x-circle-fill'
+
+list_group_ordered_horizontal_flush_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:list_group
+ '#props':
+ type: ordered
+ flush: true
+ horizontal: horizontal-md
+ id: ordered-list-group
+ attributes:
+ class:
+ - custom-list-group
+ data-list-group-attribute: custom-value
+ items:
+ - html: 'First step'
+ - html: 'Second step'
+ - html: 'Third step'
+ assertions:
+ count:
+ 'ol#ordered-list-group.list-group.list-group-numbered.list-group-flush.list-group-horizontal-md.custom-list-group[data-list-group-attribute="custom-value"]': 1
+ 'ol#ordered-list-group > li.list-group-item': 3
+ 'ul.list-group': 0
+ equals:
+ 'ol > li:nth-child(1)': 'First step'
+ 'ol > li:nth-child(3)': 'Third step'
+
+list_group_actionable_links_and_buttons:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:list_group
+ '#props':
+ type: actionable
+ items:
+ - label:
+ '#markup': 'Linked
item '
+ path: '/linked-item'
+ variant: warning
+ icon: arrow-right
+ icon_position: before
+ icon_display: inline-flex
+ attributes:
+ class:
+ - custom-action-link
+ data-action-attribute: link
+ - label: 'Disabled standalone link'
+ path: '/disabled-item'
+ disabled: true
+ standalone: true
+ - button: true
+ label: 'Submit item'
+ type: submit
+ disabled: true
+ text_nowrap: true
+ assistive_text: 'Action: '
+ icon:
+ name: check
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-button-icon
+ icon_position: after
+ icon_spacing: lg
+ show_spinner: true
+ spinner:
+ type: grow
+ variant: success
+ size: sm
+ assistive_text: Loading
+ attributes:
+ class:
+ - custom-spinner
+ attributes:
+ class:
+ - custom-action-button
+ data-action-attribute: button
+ assertions:
+ count:
+ 'div.list-group[data-component-id="oe_bootstrap_theme:list_group"]': 1
+ 'div.list-group > a.list-group-item.list-group-item-action.link-warning.custom-action-link[href="/linked-item"][data-action-attribute="link"]': 1
+ 'a.custom-action-link.d-inline-flex.gap-2-5 > svg:first-child': 1
+ 'a.custom-action-link strong': 1
+ 'a[href="/disabled-item"].disabled.standalone[aria-disabled="true"][tabindex="-1"]': 1
+ 'button.list-group-item.list-group-item-action.custom-action-button[type="submit"][disabled][aria-disabled="true"][data-action-attribute="button"]': 1
+ 'button.text-nowrap.gap-lg-2-5': 1
+ 'button > svg.icon--fluid.icon--rotate-90.custom-button-icon': 1
+ 'button > div.spinner-grow.spinner-grow-sm.text-success.custom-spinner[role="status"]': 1
+ 'button > div.spinner-grow span.visually-hidden': 1
+ 'button > span.visually-hidden': 1
+ 'button.btn': 0
+ equals:
+ 'a.custom-action-link strong': item
+ 'button > div.spinner-grow span.visually-hidden': Loading
+ 'button > span.visually-hidden': 'Action:'
+ contains:
+ 'a.custom-action-link svg': 'assets/icons/bcl-default-icons.svg#arrow-right'
+ 'button > svg': 'assets/icons/bcl-default-icons.svg#check'
+
+list_group_with_form_inputs:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:list_group
+ '#props':
+ items:
+ - input: true
+ input_type: checkbox
+ id: list-group-checkbox
+ label: 'Checked switch'
+ required: true
+ checked: true
+ switch: true
+ attributes:
+ class:
+ - custom-checkbox
+ data-input-attribute: checkbox
+ - input: true
+ input_type: text
+ id: list-group-text
+ label: 'Horizontal text input'
+ hidden_label: true
+ disabled: true
+ readonly: true
+ valid: true
+ valid_feedback: 'The value is valid.'
+ helper_text: 'Supporting text.'
+ helper_text_id: list-group-text-help
+ placeholder: 'Enter a value'
+ horizontal: true
+ horizontal_class: col-sm-8
+ horizontal_label_class: col-sm-4
+ - input: true
+ input_type: text
+ id: list-group-invalid
+ label: 'Invalid floating input'
+ placeholder: 'Enter a value'
+ floating: true
+ invalid: true
+ invalid_feedback: 'Enter a valid value.'
+ - input: true
+ input_type: checkbox
+ id: list-group-toggle
+ label: 'Toggle input'
+ checked: true
+ toggle: true
+ toggle_variant: danger
+ remove_wrapper: true
+ assertions:
+ count:
+ 'ul.list-group > li.list-group-item': 4
+ 'input#list-group-checkbox.form-check-input.custom-checkbox[type="checkbox"][required][checked][data-input-attribute="checkbox"]': 1
+ 'input#list-group-checkbox + label.form-check-label': 1
+ 'input#list-group-text.form-control.is-valid[type="text"][disabled][readonly][placeholder="Enter a value"][aria-describedby="list-group-text-help"]': 1
+ 'label[for="list-group-text"].col-form-label.col-sm-4.visually-hidden': 1
+ 'li:nth-child(2) div.col-sm-8': 1
+ 'li:nth-child(2) div.valid-feedback': 1
+ 'li:nth-child(2) div.form-text#list-group-text-help': 1
+ 'li:nth-child(3) div.form-floating': 1
+ 'input#list-group-invalid.form-control.is-invalid': 1
+ 'li:nth-child(3) div.invalid-feedback': 1
+ 'input#list-group-toggle.btn-check[type="checkbox"][checked]': 1
+ 'label[for="list-group-toggle"].btn.btn-danger': 1
+ equals:
+ 'label[for="list-group-checkbox"]': 'Checked switch'
+ 'li:nth-child(2) div.valid-feedback': 'The value is valid.'
+ 'li:nth-child(2) div.form-text': 'Supporting text.'
+ 'li:nth-child(3) div.invalid-feedback': 'Enter a valid value.'
+ 'label[for="list-group-toggle"]': 'Toggle input'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/listing.yml b/tests/src/Kernel/fixtures/markup_rendering_components/listing.yml
new file mode 100644
index 000000000..a5e6fe68f
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/listing.yml
@@ -0,0 +1,279 @@
+listing_default_complete:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: default
+ columns: 1
+ title: 'Latest publications'
+ title_tag: h3
+ title_attributes:
+ class:
+ - custom-listing-title
+ data-title-attribute: custom-value
+ title_link:
+ path: '/publications'
+ variant: info
+ id: listing-title-link
+ standalone: true
+ icon:
+ name: arrow-up-right
+ size: xs
+ icon_position: after
+ icon_display: inline-flex
+ attributes:
+ data-title-link: custom-value
+ alignment: center
+ image_size: large
+ icon_path: '/custom/icons.svg'
+ attributes:
+ id: custom-listing
+ class:
+ - custom-listing-class
+ data-listing: custom-value
+ items:
+ - title: 'First publication'
+ title_tag: h4
+ title_link:
+ path: '/publication/first'
+ variant: danger
+ id: first-publication-link
+ icon:
+ name: file-text
+ size: sm
+ icon_position: before
+ remove_icon_spacers: true
+ attributes:
+ class:
+ - custom-card-link
+ subtitle: 'Research report'
+ text:
+ '#markup': 'A publication with
rendered text .'
+ image:
+ src: 'https://example.com/publication.jpg'
+ alt: 'Publication cover'
+ classes: custom-image-class
+ rounded: '2'
+ image_copyright: '© European Union, 2026'
+ image_copyright_label: 'Cover credit:'
+ content:
+ '#markup': '
Published 24 July 2026 '
+ header:
+ '#markup': ''
+ footer:
+ '#markup': ''
+ badges:
+ - 'Report'
+ - label: 'Open data'
+ background: success
+ url: '/open-data'
+ title: 'Open data badge'
+ dismissible: true
+ assistive_text: 'Data type:'
+ rounded_pill: true
+ outline: true
+ attributes:
+ class:
+ - custom-badge-class
+ - title:
+ '#markup': 'Consultation deadline'
+ text: 'The second item uses a date block instead of an image.'
+ date:
+ day: 12
+ month: Sep
+ year: 2026
+ end_day: 18
+ end_month: Sep
+ end_year: 2026
+ date_time: '2026-09-12'
+ attributes:
+ id: consultation-date
+ class:
+ - custom-date-class
+ link:
+ label: 'View all publications'
+ path: '/publications/all'
+ variant: success
+ id: listing-cta
+ disabled: true
+ standalone: true
+ icon_position: before
+ icon_display: inline-flex
+ remove_icon_spacers: true
+ icon:
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-cta-icon
+ attributes:
+ class:
+ - custom-cta-class
+ data-cta: custom-value
+ assertions:
+ count:
+ 'div#custom-listing[data-component-id="oe_bootstrap_theme:listing"].bcl-listing.bcl-listing--default-1-col.custom-listing-class[data-listing="custom-value"]': 1
+ 'div#custom-listing.text-break': 0
+ 'h3.bcl-heading': 1
+ 'h3 > a#listing-title-link.link-info.standalone.d-inline-flex.gap-2-5[href="/publications"][data-title-link="custom-value"]': 1
+ 'h3 > a#listing-title-link svg.icon--xs': 1
+ 'h3 > a#listing-title-link use[xlink\:href="/custom/icons.svg#arrow-up-right"]': 1
+ 'div.row.row-cols-1.g-4': 1
+ 'article.listing-item': 2
+ '.bcl-listing > .row > .col:first-child > article.listing-item .bcl-card-start-col.bcl-size-large.align-self-center': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .col-12.col-md.align-self-center': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .card-header .custom-card-header': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .card-footer .custom-card-footer': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item img[src="https://example.com/publication.jpg"][alt="Publication cover"].custom-image-class.rounded-2': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .bcl-copyright .visually-hidden': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item h4.card-title a#first-publication-link.custom-card-link.link-danger[href="/publication/first"]': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item h4.card-title a#first-publication-link svg.icon--sm': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .card-text strong': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item .custom-card-content': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item span.badge.text-bg-primary': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item a.badge.badge-outline-success.rounded-pill.custom-badge-class[href="/open-data"][title="Open data badge"]': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item a.badge .visually-hidden': 1
+ '.bcl-listing > .row > .col:first-child > article.listing-item a.badge svg.icon--xs': 1
+ 'time#consultation-date.custom-date-class[datetime="2026-09-12"]': 1
+ 'time#consultation-date use[xlink\:href="/custom/icons.svg#chevron-right"]': 1
+ 'a#listing-cta.link-success.disabled.custom-cta-class[data-cta="custom-value"][href="/publications/all"][aria-disabled="true"][tabindex="-1"]': 1
+ 'a#listing-cta svg.icon--rotate-90.custom-cta-icon': 1
+ 'a#listing-cta use[xlink\:href="/custom/icons.svg#chevron-right"]': 1
+ contains:
+ '.bcl-listing > .row > .col:first-child > article.listing-item .bcl-copyright': '© European Union, 2026'
+ 'a#listing-cta': 'View all publications'
+ 'h3 > a': 'Latest publications'
+ '.bcl-listing > .row > .col:first-child > article.listing-item a.badge': 'Open data'
+ '.bcl-listing > .row > .col:first-child > article.listing-item a.badge .visually-hidden': 'Data type:'
+ equals:
+ '.bcl-listing > .row > .col:first-child > article.listing-item .visually-hidden': 'Cover credit:'
+ '.bcl-listing > .row > .col:first-child > article.listing-item span.badge.text-bg-primary': 'Report'
+
+listing_default_two_columns:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: default
+ columns: 2
+ items:
+ - title: 'First card'
+ text: 'First card content.'
+ - title: 'Second card'
+ text: 'Second card content.'
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--default-2-col': 1
+ '.row.row-cols-1.row-cols-md-2.g-4': 1
+ 'article.listing-item': 2
+
+listing_default_three_columns:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: default
+ columns: 3
+ items:
+ - title: 'First card'
+ - title: 'Second card'
+ - title: 'Third card'
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--default-3-col': 1
+ '.row.row-cols-1.row-cols-md-2.row-cols-xl-3.g-4': 1
+ 'article.listing-item': 3
+
+listing_highlight_one_column:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: highlight
+ columns: 1
+ alignment: end
+ items:
+ - title: 'Highlighted card'
+ image:
+ src: 'https://example.com/highlight.jpg'
+ alt: 'Highlighted card image'
+ image_copyright: '© European Union, 2026'
+ text: 'Highlighted card content.'
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--highlight-1-col': 1
+ '.row.row-cols-1.g-4': 1
+ 'article.listing-item--highlight.bg-lighter.border-0': 1
+ 'article.listing-item--highlight img.card-img-top': 1
+ equals:
+ 'article.listing-item--highlight .bcl-copyright .visually-hidden': 'Image credit:'
+
+listing_highlight_two_columns:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: highlight
+ columns: 2
+ items:
+ - title: 'First highlighted card'
+ - title: 'Second highlighted card'
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--highlight-2-col': 1
+ '.row.row-cols-1.row-cols-md-2.g-4': 1
+ 'article.listing-item--highlight.bg-lighter.h-100.rounded-2': 2
+
+listing_highlight_three_columns:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: highlight
+ columns: 3
+ items:
+ - title: 'First highlighted card'
+ - title: 'Second highlighted card'
+ - title: 'Third highlighted card'
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--highlight-3-col': 1
+ '.row.row-cols-1.row-cols-md-3.g-4': 1
+ 'article.listing-item--highlight.bg-lighter.h-100.rounded-2': 3
+
+listing_optional_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: default
+ columns: 1
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--default-1-col.text-break': 1
+ '.bcl-listing .row.row-cols-1.g-4': 1
+ '.bcl-listing .bcl-heading': 0
+ '.bcl-listing article.card': 0
+ '.bcl-listing > a': 0
+
+listing_title_label_and_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:listing
+ '#props':
+ variant: default
+ columns: 1
+ title: 'Archive heading'
+ title_link:
+ label: 'Browse the archive'
+ path: '/archive-overview'
+ attributes:
+ class:
+ - custom-listing-attributes
+ assertions:
+ count:
+ '.bcl-listing.bcl-listing--default-1-col.custom-listing-attributes': 1
+ '.bcl-listing.text-break': 0
+ '.bcl-listing h2 > a[href="/archive-overview"]': 1
+ '.bcl-listing > a': 0
+ equals:
+ '.bcl-listing h2 > a[href="/archive-overview"]': 'Browse the archive'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/modal.yml b/tests/src/Kernel/fixtures/markup_rendering_components/modal.yml
new file mode 100644
index 000000000..0d1f45646
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/modal.yml
@@ -0,0 +1,157 @@
+modal_default_with_fallback_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:modal
+ '#props':
+ body: 'Default modal body.'
+ toggle:
+ label: 'Open modal'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:modal"]#modal.modal.fade[tabindex="-1"][aria-hidden="true"]': 1
+ 'div#modal > div.modal-dialog': 1
+ 'div#modal > div.modal-dialog.modal-sm': 0
+ 'div#modal > div.modal-dialog.modal-lg': 0
+ 'div#modal > div.modal-dialog.modal-xl': 0
+ 'div#modal > div.modal-dialog.modal-fullscreen': 0
+ 'div#modal > div.modal-dialog.modal-dialog-centered': 0
+ 'div#modal > div.modal-dialog.modal-dialog-scrollable': 0
+ 'div#modal[data-bs-backdrop]': 0
+ 'div#modal .modal-header': 0
+ 'div#modal .modal-footer': 0
+ 'button.btn-primary[data-bs-toggle="modal"][data-bs-target="#modal"][aria-controls="modal"]': 1
+ equals:
+ 'div#modal .modal-body': 'Default modal body.'
+ 'button[data-bs-toggle="modal"]': 'Open modal'
+
+modal_with_all_content_and_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:modal
+ '#props':
+ modal_id: custom-modal
+ size: sm
+ static_backdrop: true
+ vertically_centered: true
+ scrollable: true
+ title:
+ - '#markup': 'Custom
modal '
+ title_tag: h4
+ title_attributes:
+ class:
+ - custom-modal-title
+ data-modal-title: custom-value
+ show_close_button: true
+ messages:
+ - '#markup': '
Status message
'
+ body:
+ - '#type': html_tag
+ '#tag': p
+ '#value': 'Custom modal body.'
+ footer:
+ - '#type': html_tag
+ '#tag': button
+ '#attributes':
+ class:
+ - btn
+ - btn-secondary
+ data-bs-dismiss: modal
+ '#value': Cancel
+ attributes:
+ class:
+ - custom-modal
+ data-modal: custom-value
+ header_attributes:
+ id: custom-modal-header
+ class:
+ - custom-modal-header
+ body_attributes:
+ id: custom-modal-body
+ class:
+ - custom-modal-body
+ footer_attributes:
+ id: custom-modal-footer
+ class:
+ - custom-modal-footer
+ toggle:
+ id: custom-modal-toggle
+ label: 'Open custom modal'
+ variant: danger
+ size: lg
+ outline: true
+ assistive_text: 'Dialog:'
+ icon:
+ name: box-arrow-up-right
+ path: /custom-icons.svg
+ attributes:
+ class:
+ - custom-toggle-icon
+ icon_position: before
+ attributes:
+ class:
+ - custom-toggle
+ data-toggle: custom-value
+ assertions:
+ count:
+ 'div#custom-modal.modal.fade.custom-modal[data-modal="custom-value"][data-bs-backdrop="static"]': 1
+ 'div#custom-modal > div.modal-dialog.modal-sm.modal-dialog-centered.modal-dialog-scrollable': 1
+ 'div#custom-modal .modal-header#custom-modal-header.custom-modal-header': 1
+ 'div#custom-modal .modal-header h4.custom-modal-title[data-modal-title="custom-value"] em': 1
+ 'div#custom-modal .modal-header h5': 0
+ 'div#custom-modal .modal-header button.btn-close[data-bs-dismiss="modal"][aria-label="Close"]': 1
+ 'div#custom-modal .alert.alert-info': 1
+ 'div#custom-modal .modal-body#custom-modal-body.custom-modal-body > p': 1
+ 'div#custom-modal .modal-footer#custom-modal-footer.custom-modal-footer > button[data-bs-dismiss="modal"]': 1
+ 'button#custom-modal-toggle.btn-lg.btn-outline-danger.custom-toggle[data-toggle="custom-value"]': 1
+ 'button#custom-modal-toggle[data-bs-toggle="modal"][data-bs-target="#custom-modal"][aria-controls="custom-modal"]': 1
+ 'button#custom-modal-toggle svg.custom-toggle-icon': 1
+ 'button#custom-modal-toggle > span.visually-hidden': 1
+ contains:
+ 'button#custom-modal-toggle svg': '/custom-icons.svg#box-arrow-up-right'
+ equals:
+ 'div#custom-modal .modal-header h4': 'Custom
modal '
+ 'div#custom-modal .alert': 'Status message'
+ 'div#custom-modal .modal-body p': 'Custom modal body.'
+ 'div#custom-modal .modal-footer button': Cancel
+ 'button#custom-modal-toggle > span.visually-hidden': 'Dialog:'
+
+modal_attribute_id_takes_precedence:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:modal
+ '#props':
+ modal_id: ignored-modal-id
+ size: fullscreen
+ title: 'Fullscreen modal'
+ body: 'Fullscreen modal body.'
+ attributes:
+ id: attribute-modal-id
+ toggle:
+ label: 'Open fullscreen modal'
+ disabled: true
+ assertions:
+ count:
+ 'div#attribute-modal-id.modal': 1
+ 'div#ignored-modal-id': 0
+ 'div#attribute-modal-id > div.modal-dialog.modal-fullscreen': 1
+ 'div#attribute-modal-id .modal-header h5': 1
+ 'div#attribute-modal-id .modal-header .btn-close': 0
+ 'button[data-bs-target="#attribute-modal-id"][aria-controls="attribute-modal-id"][disabled][aria-disabled="true"]': 1
+
+modal_responsive_fullscreen_without_optional_regions:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:modal
+ '#props':
+ modal_id: responsive-modal
+ fullscreen_responsive: md
+ body: 'Responsive modal body.'
+ assertions:
+ count:
+ 'div#responsive-modal > div.modal-dialog.modal-fullscreen-md-down': 1
+ 'div#responsive-modal .modal-header': 0
+ 'div#responsive-modal .modal-body': 1
+ 'div#responsive-modal .modal-footer': 0
+ 'button[data-bs-toggle="modal"]': 0
+ equals:
+ 'div#responsive-modal .modal-body': 'Responsive modal body.'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/navbar.yml b/tests/src/Kernel/fixtures/markup_rendering_components/navbar.yml
new file mode 100644
index 000000000..e1708c537
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/navbar.yml
@@ -0,0 +1,133 @@
+navbar_default_props_omitted:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navbar
+ '#props': {}
+ assertions:
+ count:
+ 'nav[data-component-id="oe_bootstrap_theme:navbar"].navbar.navbar-expand-lg.navbar-light.bg-light': 1
+ 'nav > div.container': 1
+ 'button.navbar-toggler': 0
+ 'div.navbar-collapse': 0
+ equals:
+ 'nav > div.container': ''
+
+navbar_light_with_all_regions:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navbar
+ '#props':
+ expanded_breakpoint: lg
+ branding:
+ '#type': html_tag
+ '#tag': a
+ '#value': 'OpenEuropa Site'
+ '#attributes':
+ href: 'https://europa.eu/'
+ rel: home
+ class:
+ - navbar-brand
+ navigation:
+ - '#markup': '
Priorities '
+ - '#type': html_tag
+ '#tag': a
+ '#value': Funding
+ '#attributes':
+ href: '#funding'
+ class:
+ - nav-link
+ right:
+ '#type': html_tag
+ '#tag': form
+ '#attributes':
+ class:
+ - d-flex
+ action: /search
+ '#children': '
Search '
+ assertions:
+ count:
+ 'nav.navbar.navbar-expand-lg.navbar-light.bg-light': 1
+ 'nav.navbar-expand-sm': 0
+ 'nav.navbar-expand-md': 0
+ 'nav > .container > a.navbar-brand[href="https://europa.eu/"][rel="home"]': 1
+ 'button.navbar-toggler[type="button"][data-bs-toggle="collapse"][aria-expanded="false"][aria-label="Toggle navigation"]': 1
+ 'button.navbar-toggler[data-bs-target^="#bcl-navbar"][aria-controls^="bcl-navbar"]': 1
+ 'div.navbar-collapse[id^="bcl-navbar"]': 1
+ 'div.navbar-collapse > a.nav-link': 2
+ 'div.navbar-collapse > a.nav-link[href="#priorities"]': 1
+ 'div.navbar-collapse > a.nav-link[href="#funding"]': 1
+ 'div.navbar-collapse > form.d-flex[action="/search"]': 1
+ 'form.d-flex input#navbar-search[name="search"]': 1
+ equals:
+ 'a.navbar-brand': 'OpenEuropa Site'
+ 'a[href="#priorities"]': Priorities
+ 'a[href="#funding"]': Funding
+ 'form.d-flex label': Search
+
+navbar_dark_with_custom_id_and_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navbar
+ '#props':
+ collapse_id: custom-navbar-collapse
+ expanded_breakpoint: md
+ color_scheme: dark
+ branding:
+ '#markup': '
Digital Europe '
+ navigation:
+ '#type': html_tag
+ '#tag': a
+ '#value': 'About the programme'
+ '#attributes':
+ href: /about
+ class:
+ - nav-link
+ right:
+ '#type': html_tag
+ '#tag': button
+ '#value': Account
+ '#attributes':
+ class:
+ - btn
+ - btn-light
+ type: button
+ attributes:
+ id: custom-navbar
+ class:
+ - custom-navbar
+ aria-label: 'Primary navigation'
+ data-navbar-attribute: custom-value
+ assertions:
+ count:
+ 'nav#custom-navbar.navbar.navbar-expand-md.navbar-dark.bg-dark.custom-navbar[aria-label="Primary navigation"][data-navbar-attribute="custom-value"]': 1
+ 'nav.navbar-expand-lg': 0
+ 'button.navbar-toggler[data-bs-target="#custom-navbar-collapse"][aria-controls="custom-navbar-collapse"]': 1
+ 'div#custom-navbar-collapse.collapse.navbar-collapse': 1
+ 'a.navbar-brand[href="/home"] > strong': 1
+ 'div.navbar-collapse > a.nav-link[href="/about"]': 1
+ 'div.navbar-collapse > button.btn.btn-light[type="button"]': 1
+ equals:
+ 'a.navbar-brand strong': 'Digital Europe'
+ 'a.nav-link': 'About the programme'
+ 'button.btn-light': Account
+
+navbar_without_background_or_collapse:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navbar
+ '#props':
+ expanded_breakpoint: sm
+ background: false
+ disable_collapse: true
+ navigation:
+ '#markup': '
Direct link '
+ assertions:
+ count:
+ 'nav.navbar.navbar-expand-sm.navbar-light': 1
+ 'nav.bg-light': 0
+ 'nav.bg-dark': 0
+ 'button.navbar-toggler': 0
+ 'div.navbar-collapse': 0
+ 'nav > .container > a.nav-link[href="/direct-link"]': 1
+ equals:
+ 'a.nav-link': 'Direct link'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/navigation.yml b/tests/src/Kernel/fixtures/markup_rendering_components/navigation.yml
new file mode 100644
index 000000000..a2739e428
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/navigation.yml
@@ -0,0 +1,231 @@
+navigation_default_with_mixed_items:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navigation
+ '#props':
+ variant: default
+ icon_path: /custom-icons.svg
+ items:
+ - label:
+ - '#type': html_tag
+ '#tag': strong
+ '#value': Overview
+ path: /overview
+ attributes:
+ class:
+ - custom-overview-link
+ data-navigation-item: overview
+ - label: 'Action button'
+ button: true
+ active: true
+ show_spinner: true
+ spinner:
+ type: border
+ size: sm
+ assistive_text: Loading
+ attributes:
+ class:
+ - custom-navigation-spinner
+ attributes:
+ class:
+ - custom-action-button
+ - label: 'Disabled link'
+ path: /disabled
+ disabled: true
+ - label: Calendar
+ path: /calendar
+ icon:
+ name: calendar-event
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-navigation-icon
+ icon_position: before
+ assertions:
+ count:
+ 'ul[data-component-id="oe_bootstrap_theme:navigation"].nav': 1
+ 'nav[data-component-id="oe_bootstrap_theme:navigation"]': 0
+ 'ul.nav-pills': 0
+ 'ul.nav-tabs': 0
+ 'ul.navbar-nav': 0
+ 'ul.flex-column': 0
+ 'ul.nav-fill': 0
+ 'ul > li.nav-item': 4
+ 'li:nth-child(1) > a.nav-link.custom-overview-link[data-navigation-item="overview"] strong': 1
+ 'li:nth-child(2) > button.nav-link.custom-action-button.active': 1
+ 'li:nth-child(2) > button > div.spinner-border.spinner-border-sm.custom-navigation-spinner[role="status"]': 1
+ 'li:nth-child(2) > button > div.spinner-border span.visually-hidden': 1
+ 'li:nth-child(2) > button.btn': 0
+ 'li:nth-child(3) > a.nav-link.disabled[href="/disabled"][aria-disabled="true"][tabindex="-1"]': 1
+ 'li:nth-child(4) > a.nav-link[href="/calendar"] svg.icon--rotate-90.custom-navigation-icon': 1
+ contains:
+ 'li:nth-child(4) svg': '/custom-icons.svg#calendar-event'
+ 'li:nth-child(2) button': 'Action button'
+ equals:
+ 'li:nth-child(1) strong': Overview
+
+navigation_vertical_pills_dropdown_and_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navigation
+ '#props':
+ variant: pills
+ orientation: vertical
+ navigation_id: custom-navigation
+ full_width: true
+ alignment: end
+ attributes:
+ class:
+ - custom-navigation
+ data-navigation: custom-value
+ items:
+ - label: Profile
+ path: '#profile'
+ active: true
+ - label: Settings
+ button: true
+ - dropdown: true
+ link: true
+ id: navigation-dropdown
+ alignment: dropdown-menu-end
+ dark_mode: true
+ attributes:
+ class:
+ - custom-dropdown-menu
+ trigger:
+ label: More
+ path: '#more'
+ attributes:
+ class:
+ - custom-dropdown-trigger
+ items:
+ - label: 'Dropdown link'
+ path: /dropdown-link
+ attributes:
+ class:
+ - custom-dropdown-link
+ - label: 'Dropdown button'
+ button: true
+ active: true
+ - divider: true
+ - label: 'Disabled dropdown button'
+ button: true
+ disabled: true
+ assertions:
+ count:
+ 'nav#custom-navigation.nav.nav-pills.flex-column.nav-fill.justify-content-end.custom-navigation[data-navigation="custom-value"]': 1
+ 'nav#custom-navigation > a.nav-link.active[href="#profile"]': 1
+ 'nav#custom-navigation > button.nav-link': 1
+ 'nav#custom-navigation > a#navigation-dropdown.dropdown-toggle.custom-dropdown-trigger[href="#more"][data-bs-toggle="dropdown"]': 1
+ 'nav#custom-navigation > ul.dropdown-menu.dropdown-menu-end.dropdown-menu-dark.custom-dropdown-menu': 1
+ 'ul.dropdown-menu > li': 4
+ 'a.dropdown-item.custom-dropdown-link[href="/dropdown-link"]': 1
+ 'button.dropdown-item.active': 1
+ 'hr.dropdown-divider': 1
+ 'button.dropdown-item[disabled][aria-disabled="true"]': 1
+ equals:
+ 'nav#custom-navigation > button.nav-link': Settings
+ 'a.custom-dropdown-link': 'Dropdown link'
+
+navigation_tabs_with_content:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navigation
+ '#props':
+ variant: tabs
+ navigation_id: tab-navigation
+ tabs_content: true
+ items:
+ - id: overview-tab
+ label: Overview
+ path: '#overview-panel'
+ target: overview-panel
+ active: true
+ content:
+ - '#markup': '
Overview content
'
+ attributes:
+ data-tab: overview
+ - id: details-tab
+ label: Details
+ path: '#details-panel'
+ target: details-panel
+ content:
+ - '#type': html_tag
+ '#tag': strong
+ '#value': 'Details content'
+ assertions:
+ count:
+ 'nav#tab-navigation.nav.nav-tabs[role="tablist"]': 1
+ 'nav#tab-navigation > a.nav-link[role="tab"]': 2
+ 'nav#tab-navigation > a#overview-tab.nav-link.active[href="#overview-panel"][data-tab="overview"]': 1
+ 'div.tab-content': 1
+ 'div.tab-content > div#overview-panel.tab-pane.fade.show.active[role="tabpanel"][aria-labelledby="overview-tab"] p.overview-content': 1
+ 'div.tab-content > div#details-panel.tab-pane.fade[role="tabpanel"][aria-labelledby="details-tab"] strong': 1
+ equals:
+ 'div#overview-panel p': 'Overview content'
+ 'div#details-panel strong': 'Details content'
+
+navigation_navbar_with_attribute_id:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navigation
+ '#props':
+ variant: navbar
+ alignment: center
+ attributes:
+ id: navbar-navigation
+ class:
+ - custom-navbar-navigation
+ items:
+ - label: Home
+ path: /home
+ - label: Contact
+ path: /contact
+ assertions:
+ count:
+ 'ul#navbar-navigation.navbar-nav.justify-content-center.custom-navbar-navigation': 1
+ 'ul#navbar-navigation.nav': 0
+ 'ul#navbar-navigation > li.nav-item': 2
+ 'li:nth-child(1) > a.nav-link[href="/home"]': 1
+ 'li:nth-child(2) > a.nav-link[href="/contact"]': 1
+
+navigation_nested:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:navigation
+ '#props':
+ variant: default
+ icon_path: /nested-icons.svg
+ items:
+ - label: 'Parent section'
+ path: '#parent'
+ navigation:
+ id: child-navigation
+ variant: pills
+ orientation: vertical
+ attributes:
+ class:
+ - custom-child-navigation
+ data-level: child
+ items:
+ - label: 'Child link'
+ path: '#child-link'
+ attributes:
+ class:
+ - custom-child-link
+ - label: 'Child action'
+ button: true
+ icon: arrow-right
+ icon_position: after
+ assertions:
+ count:
+ 'ul[data-component-id="oe_bootstrap_theme:navigation"].nav > li.nav-item': 1
+ 'li.nav-item > a.nav-link[href="#parent"]': 1
+ 'li.nav-item > nav#child-navigation.nav.nav-pills.flex-column.custom-child-navigation[data-level="child"]': 1
+ 'nav#child-navigation > a.nav-link.custom-child-link[href="#child-link"]': 1
+ 'nav#child-navigation > button.nav-link': 1
+ 'nav#child-navigation > button.nav-link svg': 1
+ contains:
+ 'nav#child-navigation > button svg': '/nested-icons.svg#arrow-right'
+ equals:
+ 'nav#child-navigation > button': 'Child action
'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/offcanvas.yml b/tests/src/Kernel/fixtures/markup_rendering_components/offcanvas.yml
new file mode 100644
index 000000000..039ee1cac
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/offcanvas.yml
@@ -0,0 +1,216 @@
+offcanvas_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: default
+ offcanvas_id: default-offcanvas
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:offcanvas"]#default-offcanvas.offcanvas-lg[tabindex="-1"][data-bs-backdrop="false"]': 1
+ 'div.offcanvas-start': 0
+ 'div.offcanvas-end': 0
+ 'div.offcanvas-top': 0
+ 'div.offcanvas-bottom': 0
+ 'div.offcanvas-header': 1
+ 'div.offcanvas-body': 0
+ 'button.btn-close[data-bs-dismiss="offcanvas"][data-bs-target="#default-offcanvas"][aria-controls="default-offcanvas"][aria-label="Close"]': 1
+ 'button[data-bs-toggle="offcanvas"]': 0
+
+offcanvas_with_all_parameters:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: default
+ offcanvas_id: detailed-offcanvas
+ title:
+ '#markup': '
Linked offcanvas title '
+ title_tag: h3
+ title_link:
+ path: '/offcanvas-title'
+ variant: success
+ id: custom-title-link-id
+ disabled: true
+ icon:
+ name: link
+ path: /title-icons.svg
+ transformation: flip-horizontal
+ attributes:
+ class:
+ - custom-title-icon
+ icon_position: before
+ icon_display: inline-flex
+ attributes:
+ class:
+ - custom-title-link
+ title: 'Title link tooltip'
+ target: _blank
+ data-title-link: custom-value
+ title_attributes:
+ class:
+ - custom-title
+ data-title: custom-value
+ placement: end
+ body:
+ '#type': html_tag
+ '#tag': strong
+ '#value': 'Rendered body content'
+ body_scroll: true
+ backdrop: true
+ close_aria_label: 'Close detailed panel'
+ extra_classes_body: custom-body
+ extra_classes_header: custom-header
+ extra_classes_close: custom-close
+ responsiveness: xl
+ toggle:
+ label: 'Open detailed panel'
+ variant: secondary
+ size: lg
+ type: button
+ outline: true
+ disabled: true
+ text_nowrap: true
+ assistive_text: 'Panel action'
+ id: custom-toggle
+ icon:
+ name: filter
+ path: /custom-icons.svg
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-toggle-icon
+ data-icon: custom-value
+ icon_position: before
+ icon_spacing: lg
+ attributes:
+ class:
+ - custom-toggle
+ data-toggle: custom-value
+ attributes:
+ class:
+ - custom-offcanvas
+ aria-label: 'Detailed offcanvas'
+ data-offcanvas: custom-value
+ assertions:
+ count:
+ 'div#detailed-offcanvas.offcanvas-xl.offcanvas-end.custom-offcanvas[tabindex="-1"][aria-label="Detailed offcanvas"][data-offcanvas="custom-value"]': 1
+ 'div#detailed-offcanvas[data-bs-scroll="true"]': 1
+ 'div#detailed-offcanvas[data-bs-backdrop]': 0
+ 'div#detailed-offcanvas[aria-labelledby="detailed-offcanvas-title"]': 1
+ 'div.offcanvas-header.custom-header': 1
+ 'h3#detailed-offcanvas-title.offcanvas-title.bcl-heading.custom-title[data-title="custom-value"]': 1
+ 'h3 a#custom-title-link-id.custom-title-link.link-success.disabled[href="/offcanvas-title"][title="Title link tooltip"][target="_blank"][data-title-link="custom-value"][aria-disabled="true"][tabindex="-1"]': 1
+ 'h3 a#custom-title-link-id.d-inline-flex.gap-2-5 > svg.icon--flip-horizontal.custom-title-icon': 1
+ 'h3 a#custom-title-link-id > em': 1
+ 'div.offcanvas-body.custom-body > strong': 1
+ 'button.btn-close.custom-close[aria-label="Close detailed panel"]': 1
+ 'button#custom-toggle.btn-outline-secondary.btn-lg.custom-toggle[data-bs-toggle="offcanvas"][data-bs-target="#detailed-offcanvas"][aria-controls="detailed-offcanvas"][data-toggle="custom-value"][disabled][aria-disabled="true"]': 1
+ 'button#custom-toggle.text-nowrap': 1
+ 'button#custom-toggle.gap-lg-2-5': 1
+ 'button#custom-toggle > span.visually-hidden': 1
+ 'button#custom-toggle > svg.icon--rotate-90.custom-toggle-icon[data-icon="custom-value"]': 1
+ contains:
+ 'h3 a#custom-title-link-id svg': '/title-icons.svg#link'
+ 'button#custom-toggle svg': '/custom-icons.svg#filter'
+ equals:
+ 'h3 a em': 'Linked offcanvas title'
+ 'div.offcanvas-body strong': 'Rendered body content'
+ 'button#custom-toggle > span.visually-hidden': 'Panel action'
+
+offcanvas_without_close:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: default
+ offcanvas_id: offcanvas-without-close
+ placement: bottom
+ responsiveness: none
+ title: 'Persistent panel'
+ title_link:
+ path: '/persistent-panel'
+ icon: link
+ icon_position: before
+ remove_icon_spacers: true
+ body: 'Persistent panel content.'
+ with_close: false
+ assertions:
+ count:
+ 'div#offcanvas-without-close.offcanvas.offcanvas-bottom': 1
+ 'div#offcanvas-without-close.offcanvas-md': 0
+ 'div.offcanvas-header': 1
+ 'button.btn-close': 0
+ 'div.offcanvas-body': 1
+ 'h2.offcanvas-title > a[href="/persistent-panel"] > svg': 1
+ 'h2.offcanvas-title > a.gap-2-5': 0
+ 'h2.offcanvas-title > a > svg.me-2-5': 0
+ equals:
+ 'div.offcanvas-body': 'Persistent panel content.'
+ contains:
+ 'h2.offcanvas-title > a': 'Persistent panel'
+ 'h2.offcanvas-title > a svg': '#link'
+
+offcanvas_search_variant:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: search
+ offcanvas_id: search-offcanvas
+ placement: top
+ title: 'Search filters'
+ body: 'Filter controls'
+ extra_classes_body: custom-search-body
+ extra_classes_header: custom-search-header
+ extra_classes_close: custom-search-close
+ title_attributes:
+ class:
+ - custom-search-title
+ assertions:
+ count:
+ 'div#search-offcanvas.bcl-offcanvas.offcanvas-lg.offcanvas-top': 1
+ 'div.offcanvas-header.p-lg-0.d-lg-block.custom-search-header': 1
+ 'h2.offcanvas-title.mb-lg-4.custom-search-title': 1
+ 'div.offcanvas-body.p-lg-0.d-block.custom-search-body': 1
+ 'button.btn-close.d-lg-none.custom-search-close': 1
+ equals:
+ 'h2.offcanvas-title': 'Search filters'
+ 'div.offcanvas-body': 'Filter controls'
+
+offcanvas_id_from_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: default
+ placement: start
+ attributes:
+ id: attribute-offcanvas-id
+ toggle:
+ label: 'Open attribute ID panel'
+ assertions:
+ count:
+ 'div#attribute-offcanvas-id.offcanvas-lg.offcanvas-start': 1
+ 'button.btn-close[data-bs-target="#attribute-offcanvas-id"][aria-controls="attribute-offcanvas-id"]': 1
+ 'button[data-bs-toggle="offcanvas"][data-bs-target="#attribute-offcanvas-id"][aria-controls="attribute-offcanvas-id"]': 1
+ equals:
+ 'button[data-bs-toggle="offcanvas"]': 'Open attribute ID panel'
+
+offcanvas_with_generated_id_and_no_header_or_body:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:offcanvas
+ '#props':
+ variant: default
+ responsiveness: xxl
+ with_close: false
+ toggle:
+ label: 'Open generated ID panel'
+ assertions:
+ count:
+ 'div[id^="offcanvas"].offcanvas-xxl': 1
+ 'div.offcanvas-header': 0
+ 'div.offcanvas-body': 0
+ 'button[data-bs-toggle="offcanvas"][data-bs-target^="#offcanvas"][aria-controls^="offcanvas"]': 1
+ 'button.btn-close': 0
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/pagination.yml b/tests/src/Kernel/fixtures/markup_rendering_components/pagination.yml
new file mode 100644
index 000000000..1ab3f282a
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/pagination.yml
@@ -0,0 +1,179 @@
+pagination_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination
+ '#props':
+ variant: default
+ assertions:
+ count:
+ 'nav[data-component-id="oe_bootstrap_theme:pagination"][role="navigation"]': 1
+ 'nav > ul.pagination.js-pager__items': 1
+ 'ul.pagination--glossary': 0
+ 'ul.pagination-sm': 0
+ 'ul.pagination-lg': 0
+ 'ul[class*="justify-content-"]': 0
+ 'ul > li': 0
+
+pagination_text_controls_and_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination
+ '#props':
+ variant: default
+ size: sm
+ alignment: center
+ enable_first_last_icons: false
+ first:
+ path: '/pages/first'
+ label: First
+ attributes:
+ aria-label: 'Go to the first page'
+ class:
+ - custom-first-link
+ prev:
+ path: '/pages/previous'
+ label: Previous
+ next:
+ path: '/pages/next'
+ label: Next
+ disabled: true
+ last:
+ path: '/pages/last'
+ label: Last
+ items:
+ - path: '/pages/1'
+ label:
+ '#markup': '
1 '
+ active: true
+ attributes:
+ aria-label: 'Page 1'
+ class:
+ - custom-page-link
+ data-page-attribute: custom-value
+ - path: '/pages/2'
+ label: '2'
+ disabled: true
+ list_attributes:
+ id: custom-pagination-list
+ class:
+ - custom-pagination-list
+ data-list-attribute: custom-value
+ attributes:
+ id: custom-pagination
+ class:
+ - custom-pagination
+ aria-label: 'Search results pages'
+ data-pagination-attribute: custom-value
+ assertions:
+ count:
+ 'nav#custom-pagination.custom-pagination[role="navigation"][aria-label="Search results pages"][data-pagination-attribute="custom-value"]': 1
+ 'ul#custom-pagination-list.pagination.pagination-sm.justify-content-center.js-pager__items.custom-pagination-list[data-list-attribute="custom-value"]': 1
+ 'ul > li.page-item': 6
+ 'li.page-item.active > a.page-link.custom-page-link[href="/pages/1"][aria-current="page"][aria-label="Page 1"][data-page-attribute="custom-value"]': 1
+ 'li.page-item.disabled > a.page-link.disabled[href="/pages/2"][aria-disabled="true"][tabindex="-1"]': 1
+ 'li.page-item.disabled > a.page-link.disabled[href="/pages/next"]': 1
+ 'a.custom-first-link[href="/pages/first"][aria-label="Go to the first page"]': 1
+ 'a[href="/pages/previous"][aria-label="Previous"]': 1
+ 'a[href="/pages/last"][aria-label="Last"]': 1
+ 'a.page-link svg': 0
+ equals:
+ 'a[href="/pages/first"]': First
+ 'a[href="/pages/previous"]': Previous
+ 'a[href="/pages/1"] strong': '1'
+ 'a[href="/pages/2"]': '2'
+ 'a[href="/pages/next"]': Next
+ 'a[href="/pages/last"]': Last
+
+pagination_icons_and_ellipsis:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination
+ '#props':
+ variant: default
+ size: lg
+ alignment: end
+ enable_prev_next_icons: true
+ ellipsis_before: true
+ ellipsis_after: true
+ first:
+ path: '/pages/first'
+ label: First
+ prev:
+ path: '/pages/previous'
+ label: Previous
+ icon:
+ name: arrow-left
+ path: '/other/icons.svg'
+ size: xl
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-previous-icon
+ data-icon-attribute: custom-value
+ next:
+ path: '/pages/next'
+ label: Next
+ last:
+ path: '/pages/last'
+ label: Last
+ items:
+ - path: '/pages/5'
+ label: '5'
+ active: true
+ icon_path: '/custom/icons.svg'
+ assertions:
+ count:
+ 'ul.pagination.pagination-lg.justify-content-end.js-pager__items': 1
+ 'ul > li.page-item': 7
+ 'li:nth-child(3) > div.page-link > span[aria-hidden="true"]': 1
+ 'li:nth-child(5) > div.page-link > span[aria-hidden="true"]': 1
+ 'a[href="/pages/first"] > svg.icon--fluid': 1
+ 'a[href="/pages/previous"] > svg.icon--xl.icon--rotate-90.custom-previous-icon[data-icon-attribute="custom-value"]': 1
+ 'a[href="/pages/next"] > svg.icon--fluid': 1
+ 'a[href="/pages/last"] > svg.icon--fluid': 1
+ 'li:nth-child(4).active > a[href="/pages/5"][aria-current="page"]': 1
+ contains:
+ 'a[href="/pages/first"] svg': '/custom/icons.svg#chevron-double-left'
+ 'a[href="/pages/previous"] svg': '/other/icons.svg#arrow-left'
+ 'a[href="/pages/next"] svg': '/custom/icons.svg#chevron-right'
+ 'a[href="/pages/last"] svg': '/custom/icons.svg#chevron-double-right'
+
+pagination_glossary:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination
+ '#props':
+ variant: glossary
+ items:
+ - path: '#a'
+ label:
+ '#markup': '
A '
+ attributes:
+ aria-label: 'Letter A'
+ class:
+ - custom-letter-link
+ - path: '#b'
+ label: B
+ active: true
+ - path: '#c'
+ label: C
+ - label: '0-9'
+ attributes:
+ class:
+ - custom-glossary
+ data-glossary-attribute: custom-value
+ assertions:
+ count:
+ 'nav.bcl-glossary.custom-glossary[role="navigation"][aria-label="Glossary links"][data-glossary-attribute="custom-value"]': 1
+ 'ul.pagination.pagination--glossary.js-pager__items': 1
+ 'ul > li.page-item': 4
+ 'a.custom-letter-link[href="#a"][aria-label="Letter A"] > strong': 1
+ 'li.active > a[href="#b"][aria-current="page"][aria-label="B"]': 1
+ 'a[href="#c"][aria-label="C"]': 1
+ 'li:nth-child(4) > div.page-link[aria-label="0-9"]': 1
+ 'nav > ul > li > svg': 0
+ equals:
+ 'a[href="#a"] strong': A
+ 'a[href="#b"]': B
+ 'a[href="#c"]': C
+ 'li:nth-child(4) > div.page-link': '0-9'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/pagination_v2.yml b/tests/src/Kernel/fixtures/markup_rendering_components/pagination_v2.yml
new file mode 100644
index 000000000..582e3b352
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/pagination_v2.yml
@@ -0,0 +1,178 @@
+pagination_v2_defaults:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination_v2
+ '#props':
+ prev:
+ path: '#previous'
+ label: Previous
+ next:
+ path: '#next'
+ label: Next
+ items:
+ - path: '#page-1'
+ label: '1'
+ - label: '2'
+ active: true
+ - path: '#page-3'
+ label: '3'
+ assertions:
+ count:
+ 'nav[data-component-id="oe_bootstrap_theme:pagination_v2"].pager.pagination-v2': 1
+ 'nav[aria-label]': 0
+ 'nav > ul.pagination.pager__items.js-pager__items.justify-content-md-center': 1
+ 'nav > ul.pagination-sm': 0
+ 'nav > ul.pagination-lg': 0
+ 'nav > ul > li.page-item': 5
+ 'nav > ul > li.pager__item--prev > a.page-link[href="#previous"][aria-label="Previous"]': 1
+ 'nav > ul > li.pager__item--next > a.page-link[href="#next"][aria-label="Next"]': 1
+ 'nav > ul > li.pager__item--prev svg': 1
+ 'nav > ul > li.pager__item--next svg': 1
+ 'nav > ul > li.active > div.page-link[aria-label="2"][aria-current="page"][tabindex="0"]': 1
+ contains:
+ 'li.pager__item--prev svg': '#chevron-left'
+ 'li.pager__item--next svg': '#chevron-right'
+ equals:
+ 'li.active > div.page-link': '
2 '
+
+pagination_v2_text_controls_and_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination_v2
+ '#props':
+ size: sm
+ alignment: end
+ aria_label: 'Search results pages'
+ enable_prev_next_icon: false
+ enable_first_last_icon: false
+ attributes:
+ id: custom-pagination
+ class:
+ - custom-pagination
+ data-pagination: custom-value
+ list_attributes:
+ id: custom-pagination-list
+ class:
+ - custom-pagination-list
+ data-pagination-list: custom-list-value
+ first:
+ path: '#first'
+ label: First
+ attributes:
+ id: first-page-link
+ class:
+ - custom-first-link
+ prev:
+ path: '#previous'
+ label: Previous
+ aria_label: 'Go to previous page'
+ next:
+ path: '#next'
+ label: Next
+ last:
+ path: '#last'
+ label: Last
+ items:
+ - path: '#page-1'
+ label: '1'
+ attributes:
+ class:
+ - custom-page-link
+ data-page: '1'
+ - path: '#page-2'
+ label: '2'
+ disabled: true
+ - label:
+ - '#type': html_tag
+ '#tag': strong
+ '#value': '3'
+ aria_label: 'Page 3'
+ active: true
+ assertions:
+ count:
+ 'nav#custom-pagination.pager.pagination-v2.custom-pagination[data-pagination="custom-value"][aria-label="Search results pages"]': 1
+ 'nav > ul#custom-pagination-list.pagination.pagination-sm.justify-content-md-end.custom-pagination-list[data-pagination-list="custom-list-value"]': 1
+ 'nav > ul > li.page-item': 7
+ 'nav svg': 0
+ 'a#first-page-link.page-link.custom-first-link[href="#first"][aria-label="First"]': 1
+ 'a.page-link[href="#previous"][aria-label="Go to previous page"]': 1
+ 'a.page-link.custom-page-link[href="#page-1"][data-page="1"]': 1
+ 'li.disabled > a.page-link[href="#page-2"][aria-disabled="true"]': 1
+ 'li.active > div.page-link[aria-label="Page 3"] strong': 1
+ equals:
+ 'a#first-page-link': First
+ 'li.active > div.page-link strong': '3'
+
+pagination_v2_icons_ellipses_and_mobile_label:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination_v2
+ '#props':
+ size: lg
+ alignment: start
+ icon_path: /custom-icons.svg
+ enable_prev_next_icon: true
+ enable_first_last_icon: true
+ ellipsis_before: true
+ ellipsis_after: true
+ current_mobile_label: 'Page 6 of 10'
+ first:
+ path: '#first'
+ prev:
+ path: '#previous'
+ next:
+ path: '#next'
+ last:
+ path: '#last'
+ items:
+ - path: '#page-4'
+ label: '4'
+ - path: '#page-5'
+ label: '5'
+ - label: '6'
+ active: true
+ - path: '#page-7'
+ label: '7'
+ assertions:
+ count:
+ 'nav > ul.pagination-lg.justify-content-md-start': 1
+ 'nav > ul > li.page-item': 10
+ 'nav > ul > li.page-item svg': 4
+ 'nav > ul > li.page-item > .page-link[aria-label="Ellipsis"]': 2
+ 'li.active > div.page-link > span.d-none.d-md-inline-block': 1
+ 'li.active > div.page-link > span.d-inline-block.d-md-none': 1
+ contains:
+ 'li.pager__item--prev svg': '/custom-icons.svg#chevron-left'
+ 'li.pager__item--next svg': '/custom-icons.svg#chevron-right'
+ 'li.page-item:nth-child(2) svg': '/custom-icons.svg#chevron-double-left'
+ 'li.page-item:nth-child(9) svg': '/custom-icons.svg#chevron-double-right'
+ equals:
+ 'li.active > div.page-link > span.d-none.d-md-inline-block': '6'
+ 'li.active > div.page-link > span.d-inline-block.d-md-none': 'Page 6 of 10'
+ 'div.page-link[aria-label="Ellipsis"]': '
… '
+
+pagination_v2_custom_item_icon:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:pagination_v2
+ '#props':
+ enable_prev_next_icon: false
+ items:
+ - path: '#custom-icon'
+ label: 'Continue'
+ aria_label: 'Continue to another page'
+ icon_position: before
+ icon:
+ name: arrow-right
+ path: /item-icons.svg
+ transformation: rotate-90
+ attributes:
+ class:
+ - custom-item-icon
+ assertions:
+ count:
+ 'nav > ul > li.page-item': 1
+ 'a.page-link[href="#custom-icon"][aria-label="Continue to another page"] svg.icon--rotate-90.custom-item-icon': 1
+ contains:
+ 'a.page-link svg': '/item-icons.svg#arrow-right'
+ 'a.page-link': Continue
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/progress.yml b/tests/src/Kernel/fixtures/markup_rendering_components/progress.yml
new file mode 100644
index 000000000..18d398274
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/progress.yml
@@ -0,0 +1,98 @@
+progress_primary_striped_animated:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:progress
+ '#props':
+ style: primary
+ striped: true
+ animated: true
+ progress: 60
+ min: 10
+ max: 90
+ label: 'Progress label'
+ text: 'Bar label'
+ message: 'Progress bar description'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:progress"]': 1
+ 'div.progress-bar': 1
+ 'div.progress': 1
+ 'div[role="progressbar"]': 1
+ 'div[style="width: 60%"]': 1
+ 'div[aria-valuenow="60"]': 1
+ 'div[aria-valuemin="10"]': 1
+ 'div[aria-valuemax="90"]': 1
+ 'small.progress-bar-message': 1
+ 'div.text-bg-primary': 1
+ 'div.progress-bar-animated': 1
+ 'div.progress-bar-striped': 1
+ contains:
+ 'label.progress-bar-label': 'Progress label'
+ 'div.progress': 'Bar label'
+ 'small.progress-bar-message': 'Progress bar description'
+progress_light_non_striped_non_animated:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:progress
+ '#props':
+ style: light
+ striped: false
+ animated: false
+ hidden_label: true
+ progress: 60
+ min: 10.5
+ max: '90.5'
+ label: 'Progress label'
+ text: 'Bar label'
+ message: 'Progress bar description'
+ assertions:
+ count:
+ 'div[data-component-id="oe_bootstrap_theme:progress"]': 1
+ 'div.progress-bar': 1
+ 'div.progress': 1
+ 'div[role="progressbar"]': 1
+ 'div[style="width: 60%"]': 1
+ 'div[aria-valuenow="60"]': 1
+ 'div[aria-valuemin="10.5"]': 1
+ 'div[aria-valuemax="90.5"]': 1
+ 'small.progress-bar-message': 1
+ 'div.text-bg-light': 1
+ 'div.progress-bar-animated': 0
+ 'div.progress-bar-striped': 0
+ contains:
+ 'label.progress-bar-label': 'Progress label'
+ 'small.progress-bar-message': 'Progress bar description'
+ equals:
+ 'div.progress-bar': ''
+progress_defaults_without_text_or_style:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:progress
+ '#props':
+ progress: 33.5
+ label: 'Default progress'
+ assertions:
+ count:
+ 'div.progress-bar': 1
+ 'div[style="width: 33.5%"]': 1
+ 'div[aria-valuenow="33.5"]': 1
+ 'div[aria-valuemin="0"]': 1
+ 'div[aria-valuemax="100"]': 1
+ 'div.progress-bar[class*="text-bg-"]': 0
+ equals:
+ 'label.progress-bar-label': 'Default progress'
+ 'div.progress-bar': '33.5%'
+progress_string_min_number_max:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:progress
+ '#props':
+ progress: 50
+ min: '5.5'
+ max: 95.5
+ assertions:
+ count:
+ 'div[aria-valuemin="5.5"]': 1
+ 'div[aria-valuemax="95.5"]': 1
+ equals:
+ 'div.progress-bar': '50%'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/section.yml b/tests/src/Kernel/fixtures/markup_rendering_components/section.yml
new file mode 100644
index 000000000..f0c4e56dd
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/section.yml
@@ -0,0 +1,95 @@
+section_default:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:section
+ '#props':
+ heading: 'This is the heading of the section'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\SectionPatternAssert
+ expected:
+ tag: section
+ heading_tag: h2
+ heading: 'This is the heading of the section'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+section_with_custom_attributes:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:section
+ '#props':
+ attributes:
+ class:
+ - content-wrapper
+ id: section-id
+ data-attribute: some-value
+ wrapper_attributes:
+ class:
+ - content-body
+ id: wrapper-id
+ data-wrapper-attribute: wrapper-value
+ heading_attributes:
+ class:
+ - content-heading
+ id: heading-id
+ data-heading-attribute: heading-value
+ heading: 'This is the heading of the section'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\SectionPatternAssert
+ expected:
+ attributes:
+ class: 'content-wrapper section'
+ id: section-id
+ data-attribute: some-value
+ wrapper_attributes:
+ class: 'content-body section__body'
+ id: wrapper-id
+ data-wrapper-attribute: wrapper-value
+ heading_attributes:
+ class: 'content-heading section__title'
+ id: heading-id
+ data-heading-attribute: heading-value
+section_with_custom_tags:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:section
+ '#props':
+ tag: article
+ heading_tag: h3
+ heading: 'This is the heading of the section'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\SectionPatternAssert
+ expected:
+ tag: article
+ heading_tag: h3
+ heading: 'This is the heading of the section'
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+section_with_no_title:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:section
+ '#props':
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+ assertions:
+ component:
+ class: Drupal\Tests\oe_bootstrap_theme\PatternAssertion\SectionPatternAssert
+ expected:
+ heading: ~
+ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque.'
+section_with_no_content:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:section
+ '#props':
+ heading: 'This is the heading of the section'
+ assertions:
+ count:
+ '.section > .section__body': 1
+ equals:
+ '.section__body': ''
+ contains:
+ '.section__title': 'This is the heading of the section'
diff --git a/tests/src/Kernel/fixtures/markup_rendering_components/timeline.yml b/tests/src/Kernel/fixtures/markup_rendering_components/timeline.yml
new file mode 100644
index 000000000..f84e4272b
--- /dev/null
+++ b/tests/src/Kernel/fixtures/markup_rendering_components/timeline.yml
@@ -0,0 +1,125 @@
+timeline_items_hide:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:timeline
+ '#props':
+ hide_from: 7
+ hide_to: 0
+ title: 'Title example'
+ title_tag: 'h3'
+ title_link:
+ path: '/timeline'
+ title_attributes:
+ class:
+ - custom-title-class
+ items:
+ - id: timeline-item-1
+ label: '13 September 2017'
+ title: 'Item 1'
+ content:
+ '#markup': '
Economic and Monetary Union package of proposals '
+ attributes:
+ class:
+ - custom-class
+ - label: '28-29 September 2017'
+ title: 'Item 2'
+ content: 'Lorem Ipsum 2'
+ - content: 'Lorem Ipsum 3'
+ - content: 'Lorem Ipsum 4'
+ - content: 'Lorem Ipsum 5'
+ - label: '6 February 2018'
+ title: 'Item 6'
+ content: 'Lorem Ipsum 6'
+ assertions:
+ count:
+ 'h3.bcl-heading': 1
+ 'h3.bcl-heading.custom-title-class a[href="/timeline"]': 1
+ 'ol.bcl-timeline': 1
+ 'li#timeline-item-1.custom-class': 1
+ 'li > p.fs-5.fw-medium': 6
+ 'li a': 1
+ 'button': 0
+ '.collapse': 0
+ equals:
+ 'h3.bcl-heading a': 'Title example'
+ '.bcl-timeline li:nth-child(1) p.fs-5.fw-medium': '13 September 2017'
+ '.bcl-timeline li:nth-child(1) p.fs-6.fw-medium': 'Item 1'
+ '.bcl-timeline li:nth-child(1) div': '
Economic and Monetary Union package of proposals '
+ '.bcl-timeline li:nth-child(2) p.fs-5.fw-medium': '28-29 September 2017'
+ '.bcl-timeline li:nth-child(2) p.fs-6.fw-medium': 'Item 2'
+ '.bcl-timeline li:nth-child(2) div': 'Lorem Ipsum 2'
+ '.bcl-timeline li:nth-child(3) p.fs-5.fw-medium': ''
+ '.bcl-timeline li:nth-child(3) div': 'Lorem Ipsum 3'
+ '.bcl-timeline li:nth-child(4) p.fs-5.fw-medium': ''
+ '.bcl-timeline li:nth-child(4) div': 'Lorem Ipsum 4'
+ '.bcl-timeline li:nth-child(5) p.fs-5.fw-medium': ''
+ '.bcl-timeline li:nth-child(5) div': 'Lorem Ipsum 5'
+ '.bcl-timeline li:nth-child(6) p.fs-5.fw-medium': '6 February 2018'
+ '.bcl-timeline li:nth-child(6) p.fs-6.fw-medium': 'Item 6'
+ '.bcl-timeline li:nth-child(6) div': 'Lorem Ipsum 6'
+timeline_items_hide_from_zero:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:timeline
+ '#props':
+ hide_from: 0
+ items:
+ - title: 'Item 1'
+ content: 'Lorem Ipsum 1'
+ - title: 'Item 2'
+ content: 'Lorem Ipsum 2'
+ assertions:
+ count:
+ '.bcl-timeline': 1
+ '.bcl-timeline > li': 2
+ '.bcl-timeline > li.collapse': 0
+ '.bcl-timeline > li.bcl-timeline__item--toggle': 0
+timeline_items_show_more:
+ render:
+ '#type': component
+ '#component': oe_bootstrap_theme:timeline
+ '#props':
+ collapse_button_label: 'Display %d more'
+ expand_button_label: 'Stash %d items'
+ hide_from: 2
+ hide_to: 5
+ items:
+ - title: 'Item 1'
+ content: 'Lorem Ipsum 1'
+ - title: 'Item 2'
+ content: 'Lorem Ipsum 2'
+ - title: 'Item 3'
+ content: 'Lorem Ipsum 3'
+ - title: 'Item 4'
+ content: 'Lorem Ipsum 4'
+ - title: 'Item 5'
+ content: 'Lorem Ipsum 5'
+ - title: 'Item 6'
+ content: 'Lorem Ipsum 6'
+ assertions:
+ count:
+ '.bcl-timeline': 1
+ 'li': 7
+ '.label-collapsed': 1
+ '.label-expanded': 1
+ 'button svg': 1
+ '.bcl-timeline > li.collapse': 3
+ '.bcl-timeline > li:nth-child(6).bcl-timeline__item--toggle': 1
+ '.bcl-timeline > li:nth-child(7).collapse': 0
+ contains:
+ 'button svg': '#chevron-down'
+ equals:
+ '.label-expanded': 'Stash 3 items'
+ '.label-collapsed': 'Display 3 more'
+ '.bcl-timeline li:nth-child(1) p.fs-6.fw-medium': 'Item 1'
+ '.bcl-timeline li:nth-child(2) p.fs-6.fw-medium': 'Item 2'
+ '.bcl-timeline li:nth-child(3) p.fs-6.fw-medium': 'Item 3'
+ '.bcl-timeline li:nth-child(4) p.fs-6.fw-medium': 'Item 4'
+ '.bcl-timeline li:nth-child(5) p.fs-6.fw-medium': 'Item 5'
+ '.bcl-timeline li:nth-child(7) p.fs-6.fw-medium': 'Item 6'
+ '.bcl-timeline li:nth-child(1) div': 'Lorem Ipsum 1'
+ '.bcl-timeline li:nth-child(2) div': 'Lorem Ipsum 2'
+ '.bcl-timeline li:nth-child(3) div': 'Lorem Ipsum 3'
+ '.bcl-timeline li:nth-child(4) div': 'Lorem Ipsum 4'
+ '.bcl-timeline li:nth-child(5) div': 'Lorem Ipsum 5'
+ '.bcl-timeline li:nth-child(7) div': 'Lorem Ipsum 6'
diff --git a/tests/src/PatternAssertion/BadgePatternAssert.php b/tests/src/PatternAssertion/BadgePatternAssert.php
index 1fb1f1440..e1dd3515c 100644
--- a/tests/src/PatternAssertion/BadgePatternAssert.php
+++ b/tests/src/PatternAssertion/BadgePatternAssert.php
@@ -20,8 +20,7 @@ protected function getAssertions(string $variant): array {
[$this, 'assertSettings'],
],
'label' => [
- [$this, 'assertElementText'],
- '.badge',
+ [$this, 'assertLabel'],
],
'url' => [
[$this, 'assertElementAttribute'],
@@ -33,6 +32,9 @@ protected function getAssertions(string $variant): array {
'.badge',
'title',
],
+ 'assistive_text' => [
+ [$this, 'assertAssistiveText'],
+ ],
];
}
@@ -56,23 +58,101 @@ protected function assertBaseElements(string $html, string $variant): void {
protected function assertSettings(array $expected, Crawler $crawler): void {
$background = $expected['background'] ?? 'primary';
- if (!empty($expected['outline'])) {
+ $this->assertBackground($background, $crawler);
+ $this->assertOutline($expected['outline'] ?? FALSE, $crawler);
+ $this->assertRoundedPill($expected['rounded_pill'] ?? FALSE, $crawler);
+ $this->assertDismissible($expected['dismissible'] ?? FALSE, $crawler);
+ }
+
+ /**
+ * Asserts the badge label.
+ *
+ * @param string $expected
+ * The expected label.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertLabel(string $expected, Crawler $crawler): void {
+ $this->assertElementExists('.badge', $crawler);
+
+ $badge = $crawler->filter('.badge');
+ $actual = trim($badge->text());
+ $assistive_text = $badge->filter('.visually-hidden');
+ if ($assistive_text->count()) {
+ $actual = preg_replace('/^' . preg_quote(trim($assistive_text->text()), '/') . '/', '', $actual);
+ }
+
+ self::assertEquals($expected, trim($actual));
+ }
+
+ /**
+ * Asserts the badge background.
+ *
+ * @param string $expected
+ * The expected background.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertBackground(string $expected, Crawler $crawler): void {
+ $this->assertElementExists('.badge-outline-' . $expected . ', .text-bg-' . $expected, $crawler);
+ }
+
+ /**
+ * Asserts the badge outline.
+ *
+ * @param bool $expected
+ * Whether the badge should be outlined.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertOutline(bool $expected, Crawler $crawler): void {
+ $badge = $crawler->filter('.badge');
+ $classes = explode(' ', $badge->attr('class') ?? '');
+ $background = 'primary';
+ foreach ($classes as $class) {
+ if (str_starts_with($class, 'badge-outline-') || str_starts_with($class, 'text-bg-')) {
+ $background = preg_replace('/^(badge-outline|text-bg)-/', '', $class);
+ break;
+ }
+ }
+
+ if ($expected) {
$this->assertElementExists('.badge-outline-' . $background, $crawler);
- $this->assertElementNotExists('.bg-' . $background, $crawler);
+ $this->assertElementNotExists('.text-bg-' . $background, $crawler);
}
else {
$this->assertElementNotExists('.badge-outline-' . $background, $crawler);
$this->assertElementExists('.text-bg-' . $background, $crawler);
}
+ }
- if (!empty($expected['rounded_pill'])) {
+ /**
+ * Asserts the badge rounded pill class.
+ *
+ * @param bool $expected
+ * Whether the badge should be a rounded pill.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertRoundedPill(bool $expected, Crawler $crawler): void {
+ if ($expected) {
$this->assertElementExists('.rounded-pill', $crawler);
}
else {
$this->assertElementNotExists('.rounded-pill', $crawler);
}
+ }
- if (!empty($expected['dismissible'])) {
+ /**
+ * Asserts the badge dismiss icon.
+ *
+ * @param bool $expected
+ * Whether the badge should be dismissible.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertDismissible(bool $expected, Crawler $crawler): void {
+ if ($expected) {
$this->assertElementExists('.icon--close', $crawler);
}
else {
@@ -80,4 +160,16 @@ protected function assertSettings(array $expected, Crawler $crawler): void {
}
}
+ /**
+ * Asserts the visually hidden assistive text.
+ *
+ * @param string|null $expected
+ * The expected assistive text.
+ * @param \Symfony\Component\DomCrawler\Crawler $crawler
+ * The crawler.
+ */
+ protected function assertAssistiveText(?string $expected, Crawler $crawler): void {
+ $this->assertElementText($expected, '.badge .visually-hidden', $crawler);
+ }
+
}
diff --git a/tests/src/PatternAssertion/BasePatternAssert.php b/tests/src/PatternAssertion/BasePatternAssert.php
index 6ab650cf1..3d37aede5 100644
--- a/tests/src/PatternAssertion/BasePatternAssert.php
+++ b/tests/src/PatternAssertion/BasePatternAssert.php
@@ -4,14 +4,15 @@
namespace Drupal\Tests\oe_bootstrap_theme\PatternAssertion;
+use Drupal\Tests\oe_bootstrap_theme\ComponentAssertion\ComponentAssertInterface;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Exception;
use Symfony\Component\DomCrawler\Crawler;
/**
- * Base class for asserting patterns.
+ * Base class for asserting patterns and SDC components.
*/
-abstract class BasePatternAssert extends Assert implements PatternAssertInterface {
+abstract class BasePatternAssert extends Assert implements PatternAssertInterface, ComponentAssertInterface {
/**
* Method that returns the assertions to be run by a particular pattern.
@@ -83,6 +84,13 @@ public function assertPattern(array $expected, string $html): void {
}
}
+ /**
+ * {@inheritdoc}
+ */
+ public function assertComponent(array $expected, string $html): void {
+ $this->assertPattern($expected, $html);
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/tests/src/PatternAssertion/GalleryPatternAssert.php b/tests/src/PatternAssertion/GalleryPatternAssert.php
index f95e9eccd..5e82768f4 100644
--- a/tests/src/PatternAssertion/GalleryPatternAssert.php
+++ b/tests/src/PatternAssertion/GalleryPatternAssert.php
@@ -82,7 +82,7 @@ protected function assertItems(array $expected, Crawler $crawler): void {
$expected_carousel[] = [
'image' => $item['media']['rendered'],
'caption_title' => $item['media']['caption_title'] ?? NULL,
- 'caption' => $item['media']['caption_media'] ?? NULL,
+ 'caption' => $item['media']['caption'] ?? NULL,
];
}