diff --git a/assets/img/logotype-white-full.svg b/assets/img/logotype-white-full.svg
new file mode 100755
index 000000000..1d9ed0ea7
--- /dev/null
+++ b/assets/img/logotype-white-full.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/assets/img/logotype-white.svg b/assets/img/logotype-white.svg
new file mode 100644
index 000000000..c41975019
--- /dev/null
+++ b/assets/img/logotype-white.svg
@@ -0,0 +1,20 @@
+
diff --git a/assets/img/squares-in-squares.svg b/assets/img/squares-in-squares.svg
new file mode 100644
index 000000000..c6d71599b
--- /dev/null
+++ b/assets/img/squares-in-squares.svg
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/component-design-tokens.json b/component-design-tokens.json
index d27aeb4b7..539da3513 100644
--- a/component-design-tokens.json
+++ b/component-design-tokens.json
@@ -863,27 +863,396 @@
"name": "Footer",
"slug": "footer",
"tokens": [
+ "base",
"space",
+ "outer-space",
"border-width",
"color--surface",
- "color--surface-contrast"
+ "color--surface-contrast",
+ "color--surface-border",
+ "color--surface-alt",
+ "color--surface-contrast-muted",
+ "color--primary",
+ "color--primary-contrast",
+ "color--primary-border",
+ "color--primary-alt",
+ "color--secondary",
+ "color--secondary-contrast",
+ "color--secondary-border",
+ "color--secondary-alt",
+ "color--background",
+ "color--background-contrast",
+ "color--background-border",
+ "color--background-alt",
+ "color--background-contrast-muted",
+ "font-size-90",
+ "font-size-200",
+ "font-size-300"
],
"componentSettings": [
+ {
+ "id": "colors",
+ "label": "Colors",
+ "description": "Footer color scheme.",
+ "settings": [
+ {
+ "label": "Color Scheme",
+ "description": "Controls the footer background, text contrast, border color, and alternate subfooter background.",
+ "token": "color--surface",
+ "includeStateColors": false
+ }
+ ]
+ },
{
"id": "branding",
"label": "Branding",
- "description": "Component-local branding adjustments.",
+ "description": "Branding adjustments.",
+ "settings": [
+ {
+ "variable": "--logotype-height-multiplier",
+ "label": "Logotype Height Multiplier",
+ "description": "Scales the main footer logotype height locally without changing the global base size.",
+ "type": "range",
+ "default": "6",
+ "min": 2,
+ "max": 12,
+ "step": 0.25,
+ "unit": ""
+ },
+ {
+ "variable": "--subfooter-logotype-height-multiplier",
+ "label": "Subfooter Logotype Height Multiplier",
+ "description": "Scales the subfooter logotype height locally without changing the global base size.",
+ "type": "range",
+ "default": "6",
+ "min": 2,
+ "max": 12,
+ "step": 0.25,
+ "unit": ""
+ }
+ ]
+ },
+ {
+ "id": "layout",
+ "label": "Layout",
+ "description": "Footer spacing, columns, and alignment.",
"settings": [
{
- "variable": "--home-link-height-multiplier",
- "label": "Home Link Height Multiplier",
- "description": "Scales the footer home link height locally without changing global sizing.",
+ "variable": "--outer-padding-inset-multiplier",
+ "label": "Padding Multiplier",
+ "description": "Scales footer outer padding.",
"type": "range",
"default": "1",
- "min": 0.5,
- "max": 3,
+ "min": 0,
+ "max": 8,
+ "step": 0.125,
+ "unit": ""
+ },
+ {
+ "variable": "--gap-multiplier",
+ "label": "Gap Multiplier",
+ "description": "Scales footer gaps.",
+ "type": "range",
+ "default": "1",
+ "min": 1,
+ "max": 2.5,
+ "step": 0.125,
+ "unit": ""
+ },
+ {
+ "variable": "--subfooter-border-width",
+ "label": "Subfooter Top Border",
+ "description": "Toggles the subfooter top border between no border and the component border width token.",
+ "type": "select",
+ "default": "0",
+ "options": [
+ {
+ "value": "0",
+ "label": "Off"
+ },
+ {
+ "value": "var(--c-footer--subfooter-border-width-token)",
+ "label": "On"
+ }
+ ]
+ },
+ {
+ "variable": "--columns-count",
+ "label": "Number of Columns",
+ "description": "How many columns to display in the footer.",
+ "type": "select",
+ "default": "3",
+ "options": [
+ {
+ "value": "1",
+ "label": "1"
+ },
+ {
+ "value": "2",
+ "label": "2"
+ },
+ {
+ "value": "3",
+ "label": "3"
+ },
+ {
+ "value": "4",
+ "label": "4"
+ }
+ ]
+ },
+ {
+ "variable": "--logotype-justify-content",
+ "label": "Logotype Alignment",
+ "description": "How to align the footer logotype.",
+ "type": "select",
+ "default": "flex-start",
+ "options": [
+ {
+ "value": "flex-start",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "flex-end",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--text-align",
+ "label": "Text Alignment",
+ "description": "How to align text in the footer columns.",
+ "type": "select",
+ "default": "left",
+ "options": [
+ {
+ "value": "left",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--prefooter-text-align",
+ "label": "Text Alignment [Prefooter/Below Logotype]",
+ "description": "How to align text in the prefooter.",
+ "type": "select",
+ "default": "left",
+ "options": [
+ {
+ "value": "left",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--subfooter-alignment",
+ "label": "Subfooter Alignment",
+ "description": "How to distribute the subfooter logo and links group.",
+ "type": "select",
+ "default": "center",
+ "options": [
+ {
+ "value": "flex-start",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "flex-end",
+ "label": "Right"
+ },
+ {
+ "value": "space-between",
+ "label": "Space Between"
+ },
+ {
+ "value": "space-around",
+ "label": "Space Around"
+ },
+ {
+ "value": "space-evenly",
+ "label": "Space Evenly"
+ }
+ ]
+ },
+ {
+ "variable": "--subfooter-flex-direction",
+ "label": "Subfooter Direction",
+ "description": "Controls whether the subfooter wrapper displays the logo before or after the links.",
+ "type": "select",
+ "default": "row",
+ "options": [
+ {
+ "value": "row",
+ "label": "Default"
+ },
+ {
+ "value": "row-reverse",
+ "label": "Reversed"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "background",
+ "label": "Background Image",
+ "description": "Background image controls.",
+ "settings": [
+ {
+ "variable": "--background-opacity",
+ "label": "Background Opacity",
+ "description": "Controls the opacity of the footer background image layer.",
+ "type": "range",
+ "default": "1",
+ "min": 0,
+ "max": 1,
"step": 0.05,
"unit": ""
+ },
+ {
+ "variable": "--subfooter-background-opacity",
+ "label": "Subfooter Background Opacity",
+ "description": "Controls the opacity of the subfooter background layer.",
+ "type": "range",
+ "default": "100",
+ "min": 0,
+ "max": 100,
+ "step": 1,
+ "unit": "%"
+ },
+ {
+ "variable": "--background-rotation",
+ "label": "Background Rotation",
+ "description": "Rotates the footer background image.",
+ "type": "range",
+ "default": "0",
+ "min": -7,
+ "max": 7,
+ "step": 0.5,
+ "unit": "deg"
+ },
+ {
+ "variable": "--background-position",
+ "label": "Background Position",
+ "description": "Controls the CSS background-position value.",
+ "type": "select",
+ "default": "center",
+ "options": [
+ {
+ "value": "left top",
+ "label": "Left Top"
+ },
+ {
+ "value": "center top",
+ "label": "Center Top"
+ },
+ {
+ "value": "right top",
+ "label": "Right Top"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right bottom",
+ "label": "Right Bottom"
+ },
+ {
+ "value": "center bottom",
+ "label": "Center Bottom"
+ },
+ {
+ "value": "left bottom",
+ "label": "Left Bottom"
+ }
+ ]
+ },
+ {
+ "variable": "--background-repeat",
+ "label": "Background Repeat",
+ "description": "Controls the CSS background-repeat value.",
+ "type": "select",
+ "default": "no-repeat",
+ "options": [
+ {
+ "value": "no-repeat",
+ "label": "No Repeat"
+ },
+ {
+ "value": "repeat",
+ "label": "Repeat"
+ },
+ {
+ "value": "repeat-x",
+ "label": "Repeat X"
+ },
+ {
+ "value": "repeat-y",
+ "label": "Repeat Y"
+ }
+ ]
+ },
+ {
+ "variable": "--background-size",
+ "label": "Background Size",
+ "description": "Controls the CSS background-size value.",
+ "type": "select",
+ "default": "cover",
+ "options": [
+ {
+ "value": "cover",
+ "label": "Cover"
+ },
+ {
+ "value": "contain",
+ "label": "Contain"
+ },
+ {
+ "value": "auto",
+ "label": "Auto"
+ }
+ ]
+ },
+ {
+ "variable": "--background-blend-mode",
+ "label": "Background Blend Mode",
+ "description": "Controls the CSS background-blend-mode.",
+ "type": "select",
+ "default": "normal",
+ "options": [
+ {
+ "value": "normal",
+ "label": "Normal"
+ },
+ {
+ "value": "overlay",
+ "label": "Overlay (mixes background image with background color)"
+ }
+ ]
}
]
}
@@ -1367,7 +1736,36 @@
"apiVersion": 1,
"name": "Logotype",
"slug": "logotype",
- "tokens": [],
+ "tokens": [
+ "color--surface-contrast",
+ "color--primary"
+ ],
+ "componentSettings": [
+ {
+ "id": "appearance",
+ "label": "Appearance",
+ "description": "Visual rendering options for logotype masking.",
+ "settings": [
+ {
+ "variable": "--mask-enabled",
+ "label": "Masking",
+ "description": "Enables or disables logotype masking.",
+ "type": "select",
+ "default": "1",
+ "options": [
+ {
+ "value": "0",
+ "label": "Off"
+ },
+ {
+ "value": "1",
+ "label": "On"
+ }
+ ]
+ }
+ ]
+ }
+ ],
"description": "The Logotype component provides a reusable pattern for logotype in the interface.",
"tags": [
"logotype",
diff --git a/index.php b/index.php
index 04bead313..cffe1d147 100644
--- a/index.php
+++ b/index.php
@@ -14,6 +14,13 @@
error_reporting(0);
}
+if (!function_exists('__')) {
+ function __($text, $domain = 'default')
+ {
+ return $text;
+ }
+}
+
define('BASEPATH', dirname(__FILE__) . '/');
require_once BASEPATH . 'config.php';
require_once __DIR__ . '/vendor/autoload.php';
diff --git a/source/components/footer/component.json b/source/components/footer/component.json
index 5d09ab056..6b9199c01 100644
--- a/source/components/footer/component.json
+++ b/source/components/footer/component.json
@@ -3,23 +3,397 @@
"apiVersion": 1,
"name": "Footer",
"slug": "footer",
- "tokens": ["space", "border-width", "color--surface", "color--surface-contrast"],
+ "tokens": [
+ "base",
+ "space",
+ "outer-space",
+ "border-width",
+ "color--surface",
+ "color--surface-contrast",
+ "color--surface-border",
+ "color--surface-alt",
+ "color--surface-contrast-muted",
+ "color--primary",
+ "color--primary-contrast",
+ "color--primary-border",
+ "color--primary-alt",
+ "color--secondary",
+ "color--secondary-contrast",
+ "color--secondary-border",
+ "color--secondary-alt",
+ "color--background",
+ "color--background-contrast",
+ "color--background-border",
+ "color--background-alt",
+ "color--background-contrast-muted",
+ "font-size-90",
+ "font-size-200",
+ "font-size-300"
+ ],
"componentSettings": [
+ {
+ "id": "colors",
+ "label": "Colors",
+ "description": "Footer color scheme.",
+ "settings": [
+ {
+ "label": "Color Scheme",
+ "description": "Controls the footer background, text contrast, border color, and alternate subfooter background.",
+ "token": "color--surface",
+ "includeStateColors": false
+ }
+ ]
+ },
{
"id": "branding",
"label": "Branding",
- "description": "Component-local branding adjustments.",
+ "description": "Branding adjustments.",
+ "settings": [
+ {
+ "variable": "--logotype-height-multiplier",
+ "label": "Logotype Height Multiplier",
+ "description": "Scales the main footer logotype height locally without changing the global base size.",
+ "type": "range",
+ "default": "6",
+ "min": 2,
+ "max": 12,
+ "step": 0.25,
+ "unit": ""
+ },
+ {
+ "variable": "--subfooter-logotype-height-multiplier",
+ "label": "Subfooter Logotype Height Multiplier",
+ "description": "Scales the subfooter logotype height locally without changing the global base size.",
+ "type": "range",
+ "default": "6",
+ "min": 2,
+ "max": 12,
+ "step": 0.25,
+ "unit": ""
+ }
+ ]
+ },
+ {
+ "id": "layout",
+ "label": "Layout",
+ "description": "Footer spacing, columns, and alignment.",
"settings": [
{
- "variable": "--home-link-height-multiplier",
- "label": "Home Link Height Multiplier",
- "description": "Scales the footer home link height locally without changing global sizing.",
+ "variable": "--outer-padding-inset-multiplier",
+ "label": "Padding Multiplier",
+ "description": "Scales footer outer padding.",
"type": "range",
"default": "1",
- "min": 0.5,
- "max": 3,
+ "min": 0,
+ "max": 8,
+ "step": 0.125,
+ "unit": ""
+ },
+ {
+ "variable": "--gap-multiplier",
+ "label": "Gap Multiplier",
+ "description": "Scales footer gaps.",
+ "type": "range",
+ "default": "1",
+ "min": 1,
+ "max": 2.5,
+ "step": 0.125,
+ "unit": ""
+ },
+ {
+ "variable": "--subfooter-border-width",
+ "label": "Subfooter Top Border",
+ "description": "Toggles the subfooter top border between no border and the component border width token.",
+ "type": "select",
+ "default": "0",
+ "options": [
+ {
+ "value": "0",
+ "label": "Off"
+ },
+ {
+ "value": "var(--c-footer--subfooter-border-width-token)",
+ "label": "On"
+ }
+ ]
+ },
+ {
+ "variable": "--columns-count",
+ "label": "Number of Columns",
+ "description": "How many columns to display in the footer.",
+ "type": "select",
+ "default": "3",
+ "options": [
+ {
+ "value": "1",
+ "label": "1"
+ },
+ {
+ "value": "2",
+ "label": "2"
+ },
+ {
+ "value": "3",
+ "label": "3"
+ },
+ {
+ "value": "4",
+ "label": "4"
+ }
+ ]
+ },
+ {
+ "variable": "--logotype-justify-content",
+ "label": "Logotype Alignment",
+ "description": "How to align the footer logotype.",
+ "type": "select",
+ "default": "flex-start",
+ "options": [
+ {
+ "value": "flex-start",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "flex-end",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--text-align",
+ "label": "Text Alignment",
+ "description": "How to align text in the footer columns.",
+ "type": "select",
+ "default": "left",
+ "options": [
+ {
+ "value": "left",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--prefooter-text-align",
+ "label": "Text Alignment [Prefooter/Below Logotype]",
+ "description": "How to align text in the prefooter.",
+ "type": "select",
+ "default": "left",
+ "options": [
+ {
+ "value": "left",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right",
+ "label": "Right"
+ }
+ ]
+ },
+ {
+ "variable": "--subfooter-alignment",
+ "label": "Subfooter Alignment",
+ "description": "How to distribute the subfooter logo and links group.",
+ "type": "select",
+ "default": "center",
+ "options": [
+ {
+ "value": "flex-start",
+ "label": "Left"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "flex-end",
+ "label": "Right"
+ },
+ {
+ "value": "space-between",
+ "label": "Space Between"
+ },
+ {
+ "value": "space-around",
+ "label": "Space Around"
+ },
+ {
+ "value": "space-evenly",
+ "label": "Space Evenly"
+ }
+ ]
+ },
+ {
+ "variable": "--subfooter-flex-direction",
+ "label": "Subfooter Direction",
+ "description": "Controls whether the subfooter wrapper displays the logo before or after the links.",
+ "type": "select",
+ "default": "row",
+ "options": [
+ {
+ "value": "row",
+ "label": "Default"
+ },
+ {
+ "value": "row-reverse",
+ "label": "Reversed"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "background",
+ "label": "Background Image",
+ "description": "Background image controls.",
+ "settings": [
+ {
+ "variable": "--background-opacity",
+ "label": "Background Opacity",
+ "description": "Controls the opacity of the footer background image layer.",
+ "type": "range",
+ "default": "1",
+ "min": 0,
+ "max": 1,
"step": 0.05,
"unit": ""
+ },
+ {
+ "variable": "--subfooter-background-opacity",
+ "label": "Subfooter Background Opacity",
+ "description": "Controls the opacity of the subfooter background layer.",
+ "type": "range",
+ "default": "100",
+ "min": 0,
+ "max": 100,
+ "step": 1,
+ "unit": "%"
+ },
+ {
+ "variable": "--background-rotation",
+ "label": "Background Rotation",
+ "description": "Rotates the footer background image.",
+ "type": "range",
+ "default": "0",
+ "min": -7,
+ "max": 7,
+ "step": 0.5,
+ "unit": "deg"
+ },
+ {
+ "variable": "--background-position",
+ "label": "Background Position",
+ "description": "Controls the CSS background-position value.",
+ "type": "select",
+ "default": "center",
+ "options": [
+ {
+ "value": "left top",
+ "label": "Left Top"
+ },
+ {
+ "value": "center top",
+ "label": "Center Top"
+ },
+ {
+ "value": "right top",
+ "label": "Right Top"
+ },
+ {
+ "value": "center",
+ "label": "Center"
+ },
+ {
+ "value": "right bottom",
+ "label": "Right Bottom"
+ },
+ {
+ "value": "center bottom",
+ "label": "Center Bottom"
+ },
+ {
+ "value": "left bottom",
+ "label": "Left Bottom"
+ }
+ ]
+ },
+ {
+ "variable": "--background-repeat",
+ "label": "Background Repeat",
+ "description": "Controls the CSS background-repeat value.",
+ "type": "select",
+ "default": "no-repeat",
+ "options": [
+ {
+ "value": "no-repeat",
+ "label": "No Repeat"
+ },
+ {
+ "value": "repeat",
+ "label": "Repeat"
+ },
+ {
+ "value": "repeat-x",
+ "label": "Repeat X"
+ },
+ {
+ "value": "repeat-y",
+ "label": "Repeat Y"
+ }
+ ]
+ },
+ {
+ "variable": "--background-size",
+ "label": "Background Size",
+ "description": "Controls the CSS background-size value.",
+ "type": "select",
+ "default": "cover",
+ "options": [
+ {
+ "value": "cover",
+ "label": "Cover"
+ },
+ {
+ "value": "contain",
+ "label": "Contain"
+ },
+ {
+ "value": "auto",
+ "label": "Auto"
+ }
+ ]
+ },
+ {
+ "variable": "--background-blend-mode",
+ "label": "Background Blend Mode",
+ "description": "Controls the CSS background-blend-mode.",
+ "type": "select",
+ "default": "normal",
+ "options": [
+ {
+ "value": "normal",
+ "label": "Normal"
+ },
+ {
+ "value": "overlay",
+ "label": "Overlay (mixes background image with background color)"
+ }
+ ]
}
]
}
diff --git a/source/components/footer/examples/examples.json b/source/components/footer/examples/examples.json
index 1e371cf23..49bbb532f 100644
--- a/source/components/footer/examples/examples.json
+++ b/source/components/footer/examples/examples.json
@@ -1,19 +1,23 @@
{
- "footer": {
- "grid": "",
- "heading": "Footer",
- "text": "Renders a footer in default mode with a logotype and grouped navigation links."
- },
- "slot-only": {
- "heading": "Footer with slot content",
- "text": "Renders the footer in slot-only mode, where arbitrary content is placed inside the component tags instead of using the built-in links structure."
- },
- "with-subfooter": {
- "heading": "Footer with subfooter",
- "text": "Renders the footer with a subfooter bar containing a list of contact items, demonstrating the subfooter section with horizontal layout."
- },
- "with-prefooter": {
- "heading": "Footer with prefooter and postfooter",
- "text": "Renders the footer in slot-only mode with prefooter content placed above and postfooter content placed below the main slot area."
- }
+ "footer": {
+ "grid": "",
+ "heading": "Footer",
+ "text": "Renders a footer in default mode with a logotype and grouped navigation links."
+ },
+ "lorem-columns-with-subfooter": {
+ "heading": "Footer with three columns and subfooter",
+ "text": "Renders a slot-only footer with a three-column content area and a horizontal subfooter link list, using lorem ipsum placeholder content."
+ },
+ "slot-only": {
+ "heading": "Footer with slot content",
+ "text": "Renders the footer in slot-only mode, where arbitrary content is placed inside the component tags instead of using the built-in links structure."
+ },
+ "with-subfooter": {
+ "heading": "Footer with subfooter",
+ "text": "Renders the footer with a subfooter bar containing a list of contact items, demonstrating the subfooter section with horizontal layout."
+ },
+ "with-prefooter": {
+ "heading": "Footer with prefooter and postfooter",
+ "text": "Renders the footer in slot-only mode with prefooter content placed above and postfooter content placed below the main slot area."
+ }
}
diff --git a/source/components/footer/examples/lorem-columns-with-subfooter.blade.php b/source/components/footer/examples/lorem-columns-with-subfooter.blade.php
new file mode 100644
index 000000000..c686c264b
--- /dev/null
+++ b/source/components/footer/examples/lorem-columns-with-subfooter.blade.php
@@ -0,0 +1,120 @@
+
+@footer([
+ 'slotOnly' => true,
+ 'id' => 'site-footer-abc',
+ 'logotypeHref' => '/',
+ 'classList' => ['site-footer', 's-footer'],
+ 'logotype' => '/assets/img/logotype-white-full.svg',
+ 'subfooterLogotype' => '/assets/img/logotype-white-full.svg',
+ 'prefooter' => 'This prefooter text appears above the footer columns and uses the footer prefooter alignment token.',
+ 'subfooter' => [
+ 'flexDirection' => 'row',
+ 'alignment' => 'center',
+ 'content' => [
+ [
+ 'title' => 'Tis is a title',
+ 'content' => 'Lorem ipsum',
+ 'link' => '#lorem-ipsum',
+ ],
+ [
+ 'title' => '',
+ 'content' => 'Dolor sit amet',
+ 'link' => '#dolor-sit-amet',
+ ],
+ [
+ 'title' => '',
+ 'content' => 'Consectetur adipiscing',
+ 'link' => '#consectetur-adipiscing',
+ ],
+ ],
+ ],
+])
+
+@endfooter
diff --git a/source/components/footer/style.scss b/source/components/footer/style.scss
index 4c27f71e0..56794d373 100644
--- a/source/components/footer/style.scss
+++ b/source/components/footer/style.scss
@@ -1,3 +1,5 @@
+@use "../../sass/mixin/inset-multiplier" as insetMultiplier;
+@use "../../sass/mixin/inherit";
@use "../../sass/mixin/mq";
@use "../../sass/mixin/tokens";
@@ -5,47 +7,237 @@
$_: "c-footer";
/* Design tokens */
+@include insetMultiplier.property();
@include tokens.create($_, getComponentTokens($_));
/* Component */
.#{$_} {
- --#{$_}--logotype-height: calc(var(--c-footer-height-logotype, 6) * var(--base));
- --#{$_}--home-link-height: calc(var(--#{$_}--home-link-height-multiplier, 1) * 60px);
- --#{$_}--header-border-color: var(--c-footer-header-border-color, rgba(0, 0, 0, 0.55));
- --#{$_}--subfooter-logotype-height: calc(var(--c-footer-subfooter-height-logotype, 6) * var(--base));
- --#{$_}--subfooter-color-background: var(--c-footer-subfooter-color-background, var(--color--white-100, #fff));
- --#{$_}--subfooter-color-text: var(--c-footer-subfooter-color-text, var(--color--black-100, #000));
- --#{$_}--subfooter-alignment: var(--c-footer-subfooter-alignment, center);
- --#{$_}--subfooter-flex-direction: var(--c-footer-subfooter-flex-direction, row);
- --#{$_}--subfooter-color-separator: var(--c-footer-subfooter-color-separator, #a3a3a3);
- background-color: tokens.getRawValue($_, "color--surface");
- color: tokens.getRawValue($_, "color--surface-contrast");
-
- a,
- a:hover,
- a:visited,
- a:active {
- color: tokens.getRawValue($_, "color--surface-contrast");
+ @include insetMultiplier.consume($_, var(--#{$_}--outer-padding-inset-multiplier, var(--inherit-inset-multiplier, 1)));
+
+ --#{$_}--background-color: #{tokens.getRawValue($_, "color--surface")};
+ --#{$_}--color: #{tokens.getRawValue($_, "color--surface-contrast")};
+ --#{$_}--color-muted: color-mix(in srgb, var(--#{$_}--color) 55%, var(--#{$_}--background-color));
+ --#{$_}--border-color: #{tokens.getRawValue($_, "color--surface-border")};
+ --#{$_}--subfooter-background-color: #{tokens.getRawValue($_, "color--surface-alt")};
+ --#{$_}--subfooter-background-opacity: 100%;
+ --#{$_}--subfooter-background: color-mix(in srgb, var(--#{$_}--subfooter-background-color) var(--#{$_}--subfooter-background-opacity), transparent);
+ --#{$_}--subfooter-color: #{tokens.getRawValue($_, "color--surface-contrast")};
+ --#{$_}--subfooter-color-muted: var(--#{$_}--color-muted);
+ --#{$_}--subfooter-separator-color: #{tokens.getRawValue($_, "color--surface-border")};
+ --#{$_}--list-marker-color: color-mix(in srgb, var(--#{$_}--color) 30%, var(--#{$_}--border-color));
+ --#{$_}--subfooter-separator-dot-color: color-mix(in srgb, var(--#{$_}--subfooter-color) 30%, var(--#{$_}--subfooter-separator-color));
+ --#{$_}--logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ --#{$_}--subfooter-logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ --#{$_}--subfooter-border-width: 0;
+ --#{$_}--subfooter-border-width-token: #{tokens.getCalculatedValue($_, "border-width")};
+ --#{$_}--background-image: none;
+ --#{$_}--background-position: center;
+ --#{$_}--background-repeat: no-repeat;
+ --#{$_}--background-size: cover;
+ --#{$_}--background-blend-mode: normal;
+ --#{$_}--background-opacity: 1;
+ --#{$_}--background-rotation: 0deg;
+ --#{$_}--main-padding-y: calc(#{tokens.getCalculatedValue($_, "outer-space", 3)} * var(--#{$_}--inset-multiplier));
+ --#{$_}--main-padding-x: var(--#{$_}--inset-padding-x);
+ --#{$_}--gap: calc(#{tokens.getCalculatedValue($_, "space", 3)} * var(--#{$_}--gap-multiplier, 1));
+ --#{$_}--columns-count: 3;
+ --#{$_}--text-align: left;
+ --#{$_}--prefooter-text-align: left;
+ --#{$_}--logotype-justify-content: flex-start;
+ --#{$_}--logotype-height: calc(var(--#{$_}--logotype-height-multiplier, 6) * #{tokens.getRawValue($_, "base")});
+ --#{$_}--subfooter-logotype-height: calc(var(--#{$_}--subfooter-logotype-height-multiplier, 6) * #{tokens.getRawValue($_, "base")});
+ --#{$_}--subfooter-justify-content-value: var(--#{$_}--subfooter-alignment, center);
+ --#{$_}--subfooter-flex-direction-value: var(--#{$_}--subfooter-flex-direction, row);
+ @include inherit.publish("color-background", var(--#{$_}--background-color));
+ @include inherit.publish("color-contrast", var(--#{$_}--color));
+ @include inherit.publish("color-contrast-muted", var(--#{$_}--color-muted));
+ @include inherit.publish("color-primary", var(--#{$_}--logotype-hover-color));
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ color: var(--#{$_}--color);
+ background-color: var(--#{$_}--background-color);
+ display: grid;
+ grid-template-areas:
+ "main"
+ "subfooter";
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto auto;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: -50%;
+ z-index: 0;
+ pointer-events: none;
+ opacity: var(--#{$_}--background-opacity);
+ transform: rotate(clamp(-9deg, var(--#{$_}--background-rotation), 9deg));
+ background-color: var(--#{$_}--background-color);
+ background-image: var(--#{$_}--background-image);
+ background-position: var(--#{$_}--background-position);
+ background-repeat: var(--#{$_}--background-repeat);
+ background-size: var(--#{$_}--background-size);
+ background-blend-mode: var(--#{$_}--background-blend-mode);
+ }
+
+ >* {
+ position: relative;
+ z-index: 1;
+ }
+
+ &--primary {
+ --#{$_}--background-color: #{tokens.getRawValue($_, "color--primary")};
+ --#{$_}--color: #{tokens.getRawValue($_, "color--primary-contrast")};
+ --#{$_}--color-muted: color-mix(in srgb, var(--#{$_}--color) 55%, var(--#{$_}--background-color));
+ --#{$_}--border-color: #{tokens.getRawValue($_, "color--primary-border")};
+ --#{$_}--subfooter-background-color: #{tokens.getRawValue($_, "color--primary-alt")};
+ --#{$_}--subfooter-color: #{tokens.getRawValue($_, "color--primary-contrast")};
+ --#{$_}--subfooter-separator-color: #{tokens.getRawValue($_, "color--primary-border")};
+ --#{$_}--logotype-hover-color: #{tokens.getRawValue($_, "color--secondary")};
+ --#{$_}--subfooter-logotype-hover-color: #{tokens.getRawValue($_, "color--secondary")};
+ }
+
+ &--secondary {
+ --#{$_}--background-color: #{tokens.getRawValue($_, "color--secondary")};
+ --#{$_}--color: #{tokens.getRawValue($_, "color--secondary-contrast")};
+ --#{$_}--color-muted: color-mix(in srgb, var(--#{$_}--color) 55%, var(--#{$_}--background-color));
+ --#{$_}--border-color: #{tokens.getRawValue($_, "color--secondary-border")};
+ --#{$_}--subfooter-background-color: #{tokens.getRawValue($_, "color--secondary-alt")};
+ --#{$_}--subfooter-color: #{tokens.getRawValue($_, "color--secondary-contrast")};
+ --#{$_}--subfooter-separator-color: #{tokens.getRawValue($_, "color--secondary-border")};
+ --#{$_}--logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ --#{$_}--subfooter-logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ }
+
+ &--background {
+ --#{$_}--background-color: #{tokens.getRawValue($_, "color--background")};
+ --#{$_}--color: #{tokens.getRawValue($_, "color--background-contrast")};
+ --#{$_}--color-muted: color-mix(in srgb, var(--#{$_}--color) 55%, var(--#{$_}--background-color));
+ --#{$_}--border-color: #{tokens.getRawValue($_, "color--background-border")};
+ --#{$_}--subfooter-background-color: #{tokens.getRawValue($_, "color--background-alt")};
+ --#{$_}--subfooter-color: #{tokens.getRawValue($_, "color--background-contrast")};
+ --#{$_}--subfooter-separator-color: #{tokens.getRawValue($_, "color--background-border")};
+ --#{$_}--logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ --#{$_}--subfooter-logotype-hover-color: #{tokens.getRawValue($_, "color--primary")};
+ }
+
+ &--logotype-align__left,
+ &--logotype-left {
+ --#{$_}--logotype-justify-content: flex-start;
+ }
+
+ &--logotype-align__center,
+ &--logotype-center {
+ --#{$_}--logotype-justify-content: center;
+ }
+
+ &--logotype-align__right,
+ &--logotype-right {
+ --#{$_}--logotype-justify-content: flex-end;
+ }
+
+ &--text-align__left,
+ &--text-left {
+ --#{$_}--text-align: left;
+ }
+
+ &--text-align__center,
+ &--text-center {
+ --#{$_}--text-align: center;
+ }
+
+ &--text-align__right,
+ &--text-right {
+ --#{$_}--text-align: right;
+ }
+
+ &--prefooter-text-align__left,
+ &--prefooter-text-left {
+ --#{$_}--prefooter-text-align: left;
+ }
+
+ &--prefooter-text-align__center,
+ &--prefooter-text-center {
+ --#{$_}--prefooter-text-align: center;
+ }
+
+ &--prefooter-text-align__right,
+ &--prefooter-text-right {
+ --#{$_}--prefooter-text-align: right;
+ }
+
+ &--columns-1 {
+ --#{$_}--columns-count: 1;
+ }
+
+ &--columns-2 {
+ --#{$_}--columns-count: 2;
+ }
+
+ &--columns-3 {
+ --#{$_}--columns-count: 3;
+ }
+
+ &--columns-4 {
+ --#{$_}--columns-count: 4;
}
&__main-wrapper {
- padding: tokens.getCalculatedValue($_, "space", 3) 0;
+ display: grid;
+ grid-area: main;
+ grid-template-areas:
+ "header"
+ "prefooter"
+ "content"
+ "areas"
+ "body"
+ "postfooter";
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--#{$_}--gap);
+ padding: var(--#{$_}--main-padding-y) var(--#{$_}--main-padding-x);
+ }
+
+ &__main-wrapper>.o-container>.o-grid,
+ &__content-wrapper>.o-container>.o-grid,
+ &__areas-wrapper>.o-container>.o-grid {
+ display: grid;
+ grid-template-columns: repeat(var(--#{$_}--columns-count), minmax(0, 1fr));
+ gap: var(--#{$_}--gap);
+
+ >[class*="o-grid-"] {
+ flex: initial;
+ max-width: none;
+ width: auto;
+ }
}
&__body {
+ grid-area: body;
width: 100%;
max-width: var(--container-width);
margin-left: auto;
margin-right: auto;
- padding: tokens.getCalculatedValue($_, "space", 5) 0;
- display: flex;
- flex-wrap: nowrap;
+ padding: #{tokens.getCalculatedValue($_, "space", 5)} 0;
+ display: grid;
+ grid-template-areas: "home nav";
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: var(--#{$_}--gap);
+ align-items: start;
+ text-align: var(--#{$_}--text-align);
+ }
+
+ &__header-wrapper {
+ grid-area: header;
+
+ .o-grid-12 {
+ display: flex;
+ justify-content: var(--#{$_}--logotype-justify-content);
+ }
}
&__home-link {
- margin-right: 5%;
+ grid-area: home;
line-height: 0;
- height: var(--#{$_}--home-link-height);
img,
svg {
@@ -53,30 +245,29 @@ $_: "c-footer";
}
}
+ &__nav {
+ grid-area: nav;
+ }
+
&__links {
margin-bottom: tokens.getCalculatedValue($_, "space", 3);
- & h4 {
+ h4 {
font-weight: var(--h4-font-weight);
- font-size: 18px;
+ font-size: tokens.getRawValue($_, "font-size-200");
}
- & a {
- color: #707070;
+ a,
+ div {
+ color: var(--#{$_}--color-muted);
text-decoration: none;
display: inline-block;
}
}
- &__links div {
- color: #707070;
- text-decoration: none;
- display: inline-block;
- }
-
&__link-divider {
display: inline-block;
- background-color: #565656;
+ background-color: var(--#{$_}--border-color);
border-radius: 50%;
height: tokens.getCalculatedValue($_, "space", 0.5);
width: tokens.getCalculatedValue($_, "space", 0.5);
@@ -86,41 +277,66 @@ $_: "c-footer";
}
&__widget-area {
- gap: tokens.getCalculatedValue($_, "space", 2);
+ gap: calc(var(--#{$_}--gap) * 0.25);
+ text-align: var(--#{$_}--text-align);
+ max-width: #{tokens.getCalculatedValue($_, "space", 70)};
+ margin-left: auto;
+ margin-right: auto;
+
+ ul {
+ display: grid;
+ gap: tokens.getCalculatedValue($_, "space", 0.5);
+ justify-content: var(--#{$_}--text-align);
+ list-style: none;
+ padding-left: 0;
+ }
.footer-title {
margin-bottom: tokens.getCalculatedValue($_, "space", 2);
}
li {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: tokens.getCalculatedValue($_, "space");
+ align-items: baseline;
padding-left: 0;
+ text-align: left;
+
+ &::before {
+ content: "";
+ width: tokens.getCalculatedValue($_, "base", 0.75);
+ height: tokens.getCalculatedValue($_, "base", 0.75);
+ border-radius: 50%;
+ background: var(--#{$_}--list-marker-color);
+ transform: translateY(-0.1em);
+ }
}
h2 {
- --inherit-font-size: var(--font-size-300);
+ --inherit-font-size: #{tokens.getRawValue($_, "font-size-300")};
}
}
- // Logo
-
&__logotype {
height: var(--#{$_}--logotype-height);
+ }
- @include mq.mq("xs") {
- height: clamp(#{tokens.getCalculatedValue($_, "space", 3)}, calc(#{var(--#{$_}--logotype-height)} * 0.75), #{var(--#{$_}--logotype-height)});
- }
+ &__prefooter-wrapper {
+ grid-area: prefooter;
+ text-align: var(--#{$_}--prefooter-text-align);
+ }
- @include mq.mq("md") {
- height: clamp(#{tokens.getCalculatedValue($_, "space", 3)}, calc(#{var(--#{$_}--logotype-height)} * 0.75), #{var(--#{$_}--logotype-height)});
- }
+ &__content-wrapper {
+ grid-area: content;
+ }
- @include mq.mq("lg") {
- height: var(--#{$_}--logotype-height);
- }
+ &__areas-wrapper {
+ grid-area: areas;
}
- &__header-wrapper {
- margin-bottom: tokens.getCalculatedValue($_, "space", 3);
+ &__postfooter-wrapper {
+ grid-area: postfooter;
}
&__prefooter-wrapper,
@@ -134,63 +350,85 @@ $_: "c-footer";
}
}
+ &__header-wrapper {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
&--header-border {
.#{$_}__header-wrapper {
- border-bottom: tokens.getCalculatedValue($_, "border-width") solid var(--#{$_}--header-border-color);
+ border-bottom: tokens.getCalculatedValue($_, "border-width") solid var(--#{$_}--border-color);
padding-bottom: tokens.getCalculatedValue($_, "space", 3);
}
}
&__subfooter {
- color: var(--#{$_}--subfooter-color-text);
- background: var(--#{$_}--subfooter-color-background);
+ display: flex;
+ flex-direction: column;
+ grid-area: subfooter;
+ color: var(--#{$_}--subfooter-color);
+ background: var(--#{$_}--subfooter-background);
+ border-top: var(--#{$_}--subfooter-border-width) solid var(--#{$_}--subfooter-separator-color);
padding: tokens.getCalculatedValue($_, "space", 3) 0;
- font-size: .9rem;
+ font-size: tokens.getRawValue($_, "font-size-90");
- a,
- a:hover,
- a:visited,
- a:active {
- color: var(--#{$_}--subfooter-color-text);
+ .o-container {
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ [data-component="link"] {
+ @include inherit.publish("color-contrast", var(--#{$_}--subfooter-color));
+ @include inherit.publish("color-contrast-muted", var(--#{$_}--subfooter-color-muted));
+ @include inherit.publish("color-primary", var(--#{$_}--subfooter-logotype-hover-color));
+ }
+
+ [data-component="logotype"] {
+ @include inherit.publish("color-contrast", var(--#{$_}--subfooter-color));
+ @include inherit.publish("color-contrast-muted", var(--#{$_}--subfooter-color-muted));
+ @include inherit.publish("color-primary", var(--#{$_}--subfooter-logotype-hover-color));
}
&__wrapper {
display: flex;
+ flex-direction: var(--#{$_}--subfooter-flex-direction-value);
flex-wrap: wrap;
- justify-content: var(--#{$_}--subfooter-alignment);
- align-items: var(--#{$_}--subfooter-alignment);
- flex-direction: var(--#{$_}--subfooter-flex-direction);
+ gap: var(--#{$_}--gap);
+ justify-content: var(--#{$_}--subfooter-justify-content-value);
+ align-items: center;
+ overflow: hidden;
}
&__list {
display: flex;
+ flex-direction: row;
flex-wrap: wrap;
- justify-content: var(--#{$_}--subfooter-alignment);
- align-items: var(--#{$_}--subfooter-alignment);
- flex-direction: var(--#{$_}--subfooter-flex-direction);
- margin: tokens.getCalculatedValue($_, "space", -1) tokens.getCalculatedValue($_, "space", -5);
- margin-left: 0;
+ gap: 0;
+ justify-content: center;
+ align-items: center;
+ margin: #{tokens.getCalculatedValue($_, "space", -1)} #{tokens.getCalculatedValue($_, "space", -5)};
padding: 0;
list-style: none;
overflow: hidden;
- flex: 1;
+ flex: 0 1 auto;
li {
position: relative;
margin: 0;
- padding: tokens.getCalculatedValue($_, "space") tokens.getCalculatedValue($_, "space", 5);
- padding-left: 0;
+ padding: #{tokens.getCalculatedValue($_, "space")} #{tokens.getCalculatedValue($_, "space", 5)} #{tokens.getCalculatedValue($_, "space")} 0;
line-height: 1;
&:before {
content: "";
border-radius: 50%;
- background: var(--#{$_}--subfooter-color-separator);
+ background: var(--#{$_}--subfooter-separator-dot-color);
display: none;
position: absolute;
- width: 6px;
- height: 6px;
- left: -23px;
+ width: #{tokens.getCalculatedValue($_, "base", 0.75)};
+ height: #{tokens.getCalculatedValue($_, "base", 0.75)};
+ left: #{tokens.getCalculatedValue($_, "base", -2.875)};
+ right: auto;
top: 50%;
transform: translateY(-50%);
}
@@ -201,120 +439,83 @@ $_: "c-footer";
}
}
- // Logo
-
&__logotype {
height: var(--#{$_}--subfooter-logotype-height);
}
- // Fix for separator dots showing on new lines
+ &__logotype-wrapper {
+ flex: 0 0 auto;
+ }
+
+ &--align-flex-start {
+ --#{$_}--subfooter-justify-content-value: var(--#{$_}--subfooter-alignment, flex-start);
+ }
+
+ &--align-center {
+ --#{$_}--subfooter-justify-content-value: var(--#{$_}--subfooter-alignment, center);
+ }
&--align-flex-end {
- .#{$_}__subfooter__list {
- margin: tokens.getCalculatedValue($_, "space", -1) tokens.getCalculatedValue($_, "space", -5);
- margin-right: 0;
-
- li {
- padding: tokens.getCalculatedValue($_, "space") tokens.getCalculatedValue($_, "space", 5);
- padding-right: 0;
-
- &:before {
- display: none;
- }
-
- &:not(:last-child):before {
- display: block;
- left: auto;
- right: -23px;
- }
- }
- }
+ --#{$_}--subfooter-justify-content-value: var(--#{$_}--subfooter-alignment, flex-end);
}
&--horizontal {
- .#{$_}__subfooter__wrapper {
- align-items: center;
- }
-
- .#{$_}__subfooter__logotype-wrapper {
- margin-right: tokens.getCalculatedValue($_, "space", 7);
- margin-bottom: 0;
- }
+ --#{$_}--subfooter-flex-direction-value: var(--#{$_}--subfooter-flex-direction, row);
}
&--vertical {
- .#{$_}__subfooter__list {
-
- li:not(:first-child):before,
- li:not(:last-child):before {
- display: none;
- }
- }
+ --#{$_}--subfooter-flex-direction-value: column;
- .#{$_}__subfooter__logotype-wrapper {
- margin-bottom: tokens.getCalculatedValue($_, "space", 3);
- margin-right: 0;
+ .#{$_}__subfooter__list li:not(:first-child):before {
+ display: none;
}
}
}
-
- //Todo: whole subfooter list implementation can/should be refactored and simplified
- .#{$_}__subfooter__wrapper {
- overflow: hidden; //Quick fix until refactor
- }
}
@include mq.mq("xs", "md") {
.#{$_} {
+ --#{$_}--text-align: center;
+
+ &__main-wrapper>.o-container>.o-grid,
+ &__content-wrapper>.o-container>.o-grid,
+ &__areas-wrapper>.o-container>.o-grid {
+ grid-template-columns: 1fr;
+ }
&__body {
- padding: tokens.getCalculatedValue($_, "space", 5) tokens.getCalculatedValue($_, "space", 4);
- display: block;
- text-align: center;
+ grid-template-areas:
+ "home"
+ "nav";
+ grid-template-columns: minmax(0, 1fr);
+ padding: #{tokens.getCalculatedValue($_, "space", 5)} #{tokens.getCalculatedValue($_, "space", 4)};
}
&__home-link {
- display: block;
- margin-bottom: tokens.getCalculatedValue($_, "space", 5);
+ justify-self: center;
}
- &__link-divider {
+ &__subfooter__list li:not(:first-child):before {
display: none;
}
- &__links a {
- display: block;
- margin-bottom: tokens.getCalculatedValue($_, "space", 2);
+ &__logotype,
+ &__subfooter__logotype {
+ height: clamp(#{tokens.getCalculatedValue($_, "space", 3)}, calc(var(--#{$_}--logotype-height) * 0.75), var(--#{$_}--logotype-height));
}
- &__subfooter {
-
- &__list,
- &__wrapper {
- flex-direction: column;
- justify-content: center;
- align-items: center;
-
- >li:not(:first-child):before {
- display: none;
- }
- }
-
- &--align-flex-end .#{$_}__subfooter__list li:not(:last-child):before {
- display: none;
- }
+ &__subfooter__logotype {
+ height: clamp(#{tokens.getCalculatedValue($_, "space", 3)}, calc(var(--#{$_}--subfooter-logotype-height) * 0.75), var(--#{$_}--subfooter-logotype-height));
+ }
- &__logotype {
- height: clamp(#{tokens.getCalculatedValue($_, "space", 3)}, calc(#{var(--#{$_}--subfooter-logotype-height)} * 0.75), #{var(--#{$_}--subfooter-logotype-height)});
- }
+ &__link-divider {
+ display: none;
+ }
- &--horizontal {
- .#{$_}__subfooter__logotype-wrapper {
- margin-bottom: tokens.getCalculatedValue($_, "space", 3);
- margin-right: 0;
- }
- }
+ &__links a {
+ display: block;
+ margin-bottom: tokens.getCalculatedValue($_, "space", 2);
}
}
}
\ No newline at end of file
diff --git a/source/components/footer/tests/footer.spec.ts b/source/components/footer/tests/footer.spec.ts
new file mode 100644
index 000000000..9c3fbb7c9
--- /dev/null
+++ b/source/components/footer/tests/footer.spec.ts
@@ -0,0 +1,407 @@
+import { expect, type Locator, type Page, test } from '@playwright/test';
+
+/**
+ * Footer component - Design panel integration tests.
+ *
+ * These tests verify that every Footer component setting exposed in the
+ * Design Builder changes the rendered footer or its computed component values.
+ */
+
+const FOOTER_PAGE = '/components/footer';
+const FAB_TRIGGER_LABEL = 'Open component customizer';
+const COMPONENT_SELECT = '#db-component-select';
+const FAB_PANEL_OPEN_CLASS = 'c-fab__panel--open';
+const CUSTOMIZER_PANEL = '.db-builder-customizer';
+const FOOTER_TARGET = '#site-footer-abc';
+const FOOTER_COMPONENT = '[data-component="footer"]';
+
+const COLOR_SCHEME_LABEL = 'Color Scheme';
+const LOGOTYPE_HEIGHT_LABEL = 'Logotype Height Multiplier';
+const SUBFOOTER_LOGOTYPE_HEIGHT_LABEL = 'Subfooter Logotype Height Multiplier';
+const OUTER_PADDING_LABEL = 'Padding Multiplier';
+const GAP_LABEL = 'Gap Multiplier';
+const SUBFOOTER_BORDER_LABEL = 'Subfooter Top Border';
+const COLUMNS_LABEL = 'Number of Columns';
+const LOGOTYPE_ALIGNMENT_LABEL = 'Logotype Alignment';
+const TEXT_ALIGNMENT_LABEL = 'Text Alignment';
+const PREFOOTER_TEXT_ALIGNMENT_LABEL = 'Text Alignment [Prefooter/Below Logotype]';
+const SUBFOOTER_JUSTIFY_LABEL = 'Subfooter Alignment';
+const SUBFOOTER_DIRECTION_LABEL = 'Subfooter Direction';
+const BACKGROUND_OPACITY_LABEL = 'Background Opacity';
+const SUBFOOTER_BACKGROUND_OPACITY_LABEL = 'Subfooter Background Opacity';
+const BACKGROUND_ROTATION_LABEL = 'Background Rotation';
+const BACKGROUND_POSITION_LABEL = 'Background Position';
+const BACKGROUND_REPEAT_LABEL = 'Background Repeat';
+const BACKGROUND_SIZE_LABEL = 'Background Size';
+const BACKGROUND_BLEND_MODE_LABEL = 'Background Blend Mode';
+
+const EXPECTED_VISIBLE_CONTROLS = [
+ COLOR_SCHEME_LABEL,
+ LOGOTYPE_HEIGHT_LABEL,
+ SUBFOOTER_LOGOTYPE_HEIGHT_LABEL,
+ OUTER_PADDING_LABEL,
+ GAP_LABEL,
+ SUBFOOTER_BORDER_LABEL,
+ COLUMNS_LABEL,
+ LOGOTYPE_ALIGNMENT_LABEL,
+ TEXT_ALIGNMENT_LABEL,
+ PREFOOTER_TEXT_ALIGNMENT_LABEL,
+ SUBFOOTER_JUSTIFY_LABEL,
+ SUBFOOTER_DIRECTION_LABEL,
+ BACKGROUND_OPACITY_LABEL,
+ SUBFOOTER_BACKGROUND_OPACITY_LABEL,
+ BACKGROUND_ROTATION_LABEL,
+ BACKGROUND_POSITION_LABEL,
+ BACKGROUND_REPEAT_LABEL,
+ BACKGROUND_SIZE_LABEL,
+ BACKGROUND_BLEND_MODE_LABEL,
+];
+
+async function setRangeValue(rangeLocator: Locator, value: string): Promise {
+ await rangeLocator.evaluate((input: HTMLInputElement, val: string) => {
+ input.value = val;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }, value);
+}
+
+async function expandCategory(categoryHeader: Locator): Promise {
+ const category = categoryHeader.locator('..');
+ const isCollapsed = await category.evaluate((el) => el.classList.contains('db-category-collapsed'));
+ if (isCollapsed) {
+ await categoryHeader.click();
+ }
+}
+
+async function expandAllCategories(page: Page): Promise {
+ const headers = page.locator('.db-category-header');
+ const count = await headers.count();
+
+ for (let index = 0; index < count; index += 1) {
+ await expandCategory(headers.nth(index));
+ }
+}
+
+async function getComputedPx(locator: Locator, property: string): Promise {
+ return locator.evaluate((el, prop) => Number.parseFloat(window.getComputedStyle(el).getPropertyValue(prop)), property);
+}
+
+async function getComputedCss(locator: Locator, property: string): Promise {
+ return locator.evaluate((el, prop) => window.getComputedStyle(el).getPropertyValue(prop).trim(), property);
+}
+
+async function getPseudoCss(locator: Locator, pseudo: string, property: string): Promise {
+ return locator.evaluate((el, { pseudoSelector, cssProperty }) => window.getComputedStyle(el, pseudoSelector).getPropertyValue(cssProperty).trim(), {
+ pseudoSelector: pseudo,
+ cssProperty: property,
+ });
+}
+
+async function resolveCssVariableColor(locator: Locator, variableName: string): Promise {
+ return locator.evaluate((el, customPropertyName) => {
+ const probe = document.createElement('span');
+ probe.style.color = `var(${customPropertyName})`;
+ probe.style.display = 'none';
+ el.appendChild(probe);
+ const resolvedColor = window.getComputedStyle(probe).color.trim();
+ probe.remove();
+ return resolvedColor;
+ }, variableName);
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+async function getControlRow(page: Page, label: string): Promise {
+ const exactLabel = page.locator('.db-control-row-label-text').filter({ hasText: new RegExp(`^${escapeRegExp(label)}$`) });
+ const row = page.locator('db-control-row').filter({ has: exactLabel }).first();
+ await expect(row).toBeVisible();
+ return row;
+}
+
+async function setRangeControl(page: Page, label: string, value: string): Promise {
+ const row = await getControlRow(page, label);
+ const input = row.locator('input[type="range"]');
+ await expect(input).toBeVisible();
+ await setRangeValue(input, value);
+}
+
+async function selectControlOption(page: Page, label: string, optionValue: string): Promise {
+ const row = await getControlRow(page, label);
+ const select = row.locator('select').first();
+ await expect(select).toBeVisible();
+ await select.selectOption(optionValue);
+}
+
+async function chooseNextSwatch(page: Page, label: string): Promise {
+ const row = await getControlRow(page, label);
+ const swatchOption = row.locator('.db-swatch-select-option[aria-selected="false"]').first();
+ await expect(swatchOption).toBeVisible();
+ await swatchOption.click();
+}
+
+test.describe('Footer - design panel', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(FOOTER_PAGE);
+ await expect(page.locator(FOOTER_TARGET)).toBeVisible();
+
+ await page.getByRole('button', { name: FAB_TRIGGER_LABEL }).click();
+ await expect(page.locator(`.${FAB_PANEL_OPEN_CLASS}`)).toBeVisible();
+ await expect(page.locator(CUSTOMIZER_PANEL)).toBeVisible();
+ await page.locator(COMPONENT_SELECT).selectOption({ value: 'footer' });
+ });
+
+ test('opens the design panel when the FAB button is clicked', async ({ page }) => {
+ await expect(page.locator(CUSTOMIZER_PANEL)).toBeVisible();
+ });
+
+ test('design panel shows footer component settings', async ({ page }) => {
+ await expect(page.locator('.db-category-header').filter({ hasText: 'Layout' })).toBeVisible();
+ await expect(page.locator('.db-category-header').filter({ hasText: 'Background Image' })).toBeVisible();
+ });
+
+ test('footer panel exposes the full expected control inventory', async ({ page }) => {
+ await expandAllCategories(page);
+
+ const labels = await page.locator('db-control-row .db-control-row-label-text').evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() ?? '').filter(Boolean));
+
+ expect(labels.length).toBe(EXPECTED_VISIBLE_CONTROLS.length);
+ expect(labels.sort()).toEqual([...EXPECTED_VISIBLE_CONTROLS].sort());
+ });
+
+ test('changing Color Scheme updates the footer background color', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Colors' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ const initialBackground = await getComputedCss(footer, 'background-color');
+ await chooseNextSwatch(page, COLOR_SCHEME_LABEL);
+ const updatedBackground = await getComputedCss(footer, 'background-color');
+
+ expect(updatedBackground).not.toBe(initialBackground);
+ });
+
+ test('changing Logotype Height Multiplier updates the footer logotype height', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Branding' }));
+
+ const logotype = page.locator(`${FOOTER_TARGET} .c-footer__logotype`).first();
+ const initialHeight = await getComputedPx(logotype, 'height');
+ await setRangeControl(page, LOGOTYPE_HEIGHT_LABEL, '12');
+ const updatedHeight = await getComputedPx(logotype, 'height');
+
+ expect(updatedHeight).toBeGreaterThan(initialHeight);
+ });
+
+ test('changing Subfooter Logotype Height Multiplier updates the subfooter logotype height', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Branding' }));
+
+ const logotype = page.locator(`${FOOTER_TARGET} .c-footer__subfooter__logotype`).first();
+ const initialHeight = await getComputedPx(logotype, 'height');
+ await setRangeControl(page, SUBFOOTER_LOGOTYPE_HEIGHT_LABEL, '12');
+ const updatedHeight = await getComputedPx(logotype, 'height');
+
+ expect(updatedHeight).toBeGreaterThan(initialHeight);
+ });
+
+ test('changing Outer Padding Inset Multiplier updates main footer padding', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const mainWrapper = page.locator(`${FOOTER_TARGET} .c-footer__main-wrapper`).first();
+ const initialPadding = await getComputedPx(mainWrapper, 'padding-left');
+ await setRangeControl(page, OUTER_PADDING_LABEL, '8');
+ const updatedPadding = await getComputedPx(mainWrapper, 'padding-left');
+
+ expect(updatedPadding).toBeGreaterThan(initialPadding);
+ });
+
+ test('changing Gap Multiplier updates footer grid gap', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const mainWrapper = page.locator(`${FOOTER_TARGET} .c-footer__main-wrapper`).first();
+ const initialGap = await getComputedPx(mainWrapper, 'gap');
+ await setRangeControl(page, GAP_LABEL, '2.5');
+ const updatedGap = await getComputedPx(mainWrapper, 'gap');
+
+ expect(updatedGap).toBeGreaterThan(initialGap);
+ });
+
+ test('changing Subfooter Top Border toggles the subfooter border width', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const subfooter = page.locator(`${FOOTER_TARGET} .c-footer__subfooter`).first();
+ const initialBorderWidth = await getComputedPx(subfooter, 'border-top-width');
+ await selectControlOption(page, SUBFOOTER_BORDER_LABEL, 'var(--c-footer--subfooter-border-width-token)');
+ const updatedBorderWidth = await getComputedPx(subfooter, 'border-top-width');
+
+ expect(initialBorderWidth).toBe(0);
+ expect(updatedBorderWidth).toBeGreaterThan(initialBorderWidth);
+ });
+
+ test('changing Number of Columns updates the footer content grid columns', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const grid = page.locator(`${FOOTER_TARGET} .c-footer__content-wrapper > .o-container > .o-grid`).first();
+ await selectControlOption(page, COLUMNS_LABEL, '4');
+ const columns = await getComputedCss(grid, 'grid-template-columns');
+
+ expect(columns.split(' ').length).toBe(4);
+ });
+
+ test('changing Logotype Alignment updates the logotype wrapper alignment', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const headerGrid = page.locator(`${FOOTER_TARGET} .c-footer__header-wrapper .o-grid-12`).first();
+ await selectControlOption(page, LOGOTYPE_ALIGNMENT_LABEL, 'flex-end');
+ await expect(headerGrid).toHaveCSS('justify-content', 'flex-end');
+ });
+
+ test('hovering the footer logotype applies the resolved hover mask color', async ({ page }) => {
+ const logotypeWrapper = page.locator(`${FOOTER_TARGET} .c-footer__header-wrapper .c-link`).first();
+ const logotype = page.locator(`${FOOTER_TARGET} .c-footer__logotype`).first();
+ const initialMaskColor = await getPseudoCss(logotype, '::before', 'background-color');
+ const expectedHoverMaskColor = await resolveCssVariableColor(logotype, '--c-logotype--mask-color-hover');
+
+ await logotypeWrapper.scrollIntoViewIfNeeded();
+ await logotypeWrapper.hover({ force: true });
+
+ await expect.poll(async () => getPseudoCss(logotype, '::before', 'background-color')).toBe(expectedHoverMaskColor);
+ expect(expectedHoverMaskColor).not.toBe(initialMaskColor);
+ });
+
+ test('changing Text Alignment updates footer widget text alignment', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const widget = page.locator(`${FOOTER_TARGET} .c-footer__widget-area`).first();
+ await selectControlOption(page, TEXT_ALIGNMENT_LABEL, 'center');
+ await expect(widget).toHaveCSS('text-align', 'center');
+ });
+
+ test('hovering footer links applies the resolved link hover color', async ({ page }) => {
+ const componentLink = page.locator(`${FOOTER_TARGET} .c-footer__widget-area .c-link`).first();
+ const plainLink = page.locator(`${FOOTER_TARGET} .c-footer__widget-area a[href="#lorem-link-5"]`).first();
+
+ const componentExpectedHoverColor = await resolveCssVariableColor(componentLink, '--c-link--state-link-color-mix');
+ await componentLink.hover();
+ await expect(componentLink).toHaveCSS('color', componentExpectedHoverColor);
+
+ const plainExpectedHoverColor = await resolveCssVariableColor(plainLink, '--c-link--state-link-color-mix');
+ await plainLink.hover();
+ await expect(plainLink).toHaveCSS('color', plainExpectedHoverColor);
+ expect(plainExpectedHoverColor).toBeTruthy();
+ });
+
+ test('changing Prefooter Text Alignment updates prefooter text alignment', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const prefooter = page.locator(`${FOOTER_TARGET} .c-footer__prefooter-wrapper`).first();
+ await selectControlOption(page, PREFOOTER_TEXT_ALIGNMENT_LABEL, 'right');
+ await expect(prefooter).toHaveCSS('text-align', 'right');
+ });
+
+ test('changing Subfooter Justify Content updates wrapper distribution', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const wrapper = page.locator(`${FOOTER_TARGET} .c-footer__subfooter__wrapper`).first();
+ await selectControlOption(page, SUBFOOTER_JUSTIFY_LABEL, 'space-between');
+ await expect(wrapper).toHaveCSS('justify-content', 'space-between');
+ });
+
+ test('changing Subfooter Direction reverses the wrapper direction', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const wrapper = page.locator(`${FOOTER_TARGET} .c-footer__subfooter__wrapper`).first();
+ await selectControlOption(page, SUBFOOTER_DIRECTION_LABEL, 'row-reverse');
+ await expect(wrapper).toHaveCSS('flex-direction', 'row-reverse');
+ });
+
+ test('changing Background Opacity updates the footer background layer opacity', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ await setRangeControl(page, BACKGROUND_OPACITY_LABEL, '0.25');
+ const opacity = await getPseudoCss(footer, '::before', 'opacity');
+
+ expect(opacity).toBe('0.25');
+ });
+
+ test('changing Subfooter Background Opacity updates the subfooter background color', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const subfooter = page.locator(`${FOOTER_TARGET} .c-footer__subfooter`).first();
+ const initialBackground = await getComputedCss(subfooter, 'background-color');
+ await setRangeControl(page, SUBFOOTER_BACKGROUND_OPACITY_LABEL, '30');
+ const updatedBackground = await getComputedCss(subfooter, 'background-color');
+
+ expect(updatedBackground).not.toBe(initialBackground);
+ });
+
+ test('changing Background Rotation updates the footer background transform', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ const initialTransform = await getPseudoCss(footer, '::before', 'transform');
+ await setRangeControl(page, BACKGROUND_ROTATION_LABEL, '7');
+ const updatedTransform = await getPseudoCss(footer, '::before', 'transform');
+
+ expect(updatedTransform).not.toBe(initialTransform);
+ });
+
+ test('changing Background Position updates the footer background layer position', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ await selectControlOption(page, BACKGROUND_POSITION_LABEL, 'left top');
+ const position = await getPseudoCss(footer, '::before', 'background-position');
+
+ expect(position).toBe('0% 0%');
+ });
+
+ test('changing Background Repeat updates the footer background layer repeat mode', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ await selectControlOption(page, BACKGROUND_REPEAT_LABEL, 'repeat-x');
+ const repeat = await getPseudoCss(footer, '::before', 'background-repeat');
+
+ expect(repeat).toBe('repeat-x');
+ });
+
+ test('changing Background Size updates the footer background layer size', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ await selectControlOption(page, BACKGROUND_SIZE_LABEL, 'contain');
+ const size = await getPseudoCss(footer, '::before', 'background-size');
+
+ expect(size).toBe('contain');
+ });
+
+ test('changing Background Blend Mode updates the footer background layer blend mode', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Background Image' }));
+
+ const footer = page.locator(FOOTER_TARGET);
+ await selectControlOption(page, BACKGROUND_BLEND_MODE_LABEL, 'overlay');
+ const blendMode = await getPseudoCss(footer, '::before', 'background-blend-mode');
+
+ expect(blendMode).toBe('overlay');
+ });
+
+ test('resetting the footer component restores the original gap', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Layout' }));
+
+ const mainWrapper = page.locator(`${FOOTER_TARGET} .c-footer__main-wrapper`).first();
+ const initialGap = await getComputedPx(mainWrapper, 'gap');
+ await setRangeControl(page, GAP_LABEL, '2.5');
+ expect(await getComputedPx(mainWrapper, 'gap')).toBeGreaterThan(initialGap);
+
+ page.once('dialog', (dialog) => dialog.accept());
+ await page.locator('[data-action="reset-component"]').click();
+
+ const restoredGap = await getComputedPx(mainWrapper, 'gap');
+ expect(restoredGap).toBeCloseTo(initialGap, 1);
+ });
+
+ test('footer page has a footer component target for design settings', async ({ page }) => {
+ await expect(page.locator(FOOTER_COMPONENT).first()).toBeVisible();
+ await expect(page.locator(FOOTER_TARGET)).toBeVisible();
+ });
+});
diff --git a/source/components/link/style.scss b/source/components/link/style.scss
index 19877b517..593102466 100644
--- a/source/components/link/style.scss
+++ b/source/components/link/style.scss
@@ -1,9 +1,8 @@
+@use "../../sass/mixin/inherit";
@use "../../sass/mixin/tokens";
-@property --inherit-color-contrast {
- syntax: "*";
- inherits: true;
-}
+@include inherit.property("color-contrast");
+@include inherit.property("color-primary");
$_: "c-link";
@@ -11,17 +10,17 @@ $_: "c-link";
a,
.#{$_} {
- --#{$_}-mixable-color: #{tokens.getRawValue($_, "color--background-contrast", "color-contrast")};
- --#{$_}-mixable-color-saturated: #{tokens.getRawValue($_, "color--primary")};
- --#{$_}-link-color-mix: color-mix(in srgb, var(--#{$_}-mixable-color) var(--#{$_}--color-mix-amount, 90%), var(--#{$_}-mixable-color-saturated));
- --#{$_}-state-link-color-mix: color-mix(in srgb, var(--#{$_}-mixable-color) var(--#{$_}--state-color-mix-amount, 50%), var(--#{$_}-mixable-color-saturated));
+ @include inherit.consume($_, "color-contrast", tokens.getRawValue($_, "color--background-contrast"), "mixable-color");
+ @include inherit.consume($_, "color-primary", tokens.getRawValue($_, "color--primary"), "mixable-color-saturated");
+ --#{$_}--link-color-mix: color-mix(in srgb, var(--#{$_}--mixable-color) var(--#{$_}--color-mix-amount, 90%), var(--#{$_}--mixable-color-saturated));
+ --#{$_}--state-link-color-mix: color-mix(in srgb, var(--#{$_}--mixable-color) var(--#{$_}--state-color-mix-amount, 50%), var(--#{$_}--mixable-color-saturated));
}
a:not([class^="c-"]):not([class*=" c-"]),
.#{$_} {
text-decoration-style: solid;
transition: 100ms color;
- color: var(--#{$_}-link-color-mix);
+ color: var(--#{$_}--link-color-mix);
i {
text-decoration: none;
@@ -39,12 +38,12 @@ a:not([class^="c-"]):not([class*=" c-"]),
&:active,
&:hover {
- color: var(--#{$_}-state-link-color-mix);
+ color: var(--#{$_}--state-link-color-mix);
}
&:visited,
&:visited:hover,
&:visited:active {
- color: var(--#{$_}-state-link-color-mix);
+ color: var(--#{$_}--state-link-color-mix);
}
}
\ No newline at end of file
diff --git a/source/components/logotype/component.json b/source/components/logotype/component.json
index 748d0f070..7839eeb15 100644
--- a/source/components/logotype/component.json
+++ b/source/components/logotype/component.json
@@ -3,7 +3,33 @@
"apiVersion": 1,
"name": "Logotype",
"slug": "logotype",
- "tokens": [],
+ "tokens": ["color--surface-contrast", "color--primary"],
+ "componentSettings": [
+ {
+ "id": "appearance",
+ "label": "Appearance",
+ "description": "Visual rendering options for logotype masking.",
+ "settings": [
+ {
+ "variable": "--mask-enabled",
+ "label": "Masking",
+ "description": "Enables or disables logotype masking.",
+ "type": "select",
+ "default": "1",
+ "options": [
+ {
+ "value": "0",
+ "label": "Off"
+ },
+ {
+ "value": "1",
+ "label": "On"
+ }
+ ]
+ }
+ ]
+ }
+ ],
"description": "The Logotype component provides a reusable pattern for logotype in the interface.",
"tags": ["logotype", "component"],
"similarComponents": ["logotypegrid", "footer"],
diff --git a/source/components/logotype/examples/examples.json b/source/components/logotype/examples/examples.json
index 99646b6e0..fe9caa399 100644
--- a/source/components/logotype/examples/examples.json
+++ b/source/components/logotype/examples/examples.json
@@ -18,5 +18,9 @@
"linked": {
"heading": "Logotype in a link",
"text": "Renders a logotype wrapped in an anchor tag, activating the hover highlight effect."
+ },
+ "masked": {
+ "heading": "Logotype masked",
+ "text": "Renders a masked logotype using CSS mask properties."
}
}
diff --git a/source/components/logotype/examples/masked.blade.php b/source/components/logotype/examples/masked.blade.php
new file mode 100644
index 000000000..5558031d8
--- /dev/null
+++ b/source/components/logotype/examples/masked.blade.php
@@ -0,0 +1,9 @@
+
+ @logotype([
+ 'src' => '/assets/img/logotype.svg',
+ 'alt' => 'Masked logotype',
+ 'maskable' => true,
+ 'style' => 'height: 100px;',
+ ])
+ @endlogotype
+
diff --git a/source/components/logotype/style.scss b/source/components/logotype/style.scss
index 66cfbb374..e4285a726 100644
--- a/source/components/logotype/style.scss
+++ b/source/components/logotype/style.scss
@@ -1,17 +1,27 @@
+@use "../../sass/mixin/inherit";
@use "../../sass/mixin/tokens";
$_: "c-logotype";
@include tokens.create($_, getComponentTokens($_));
+@include inherit.property("color-contrast");
+@include inherit.property("color-contrast-muted");
+@include inherit.property("color-primary");
.#{$_} {
+ --#{$_}--mask-color: var(--inherit-color-contrast, currentColor);
+ --#{$_}--mask-color-hover: var(--inherit-color-contrast-muted, var(--#{$_}--mask-color));
+ --#{$_}--mask-enabled: 1;
+ --#{$_}--mask-current-color: var(--#{$_}--mask-color);
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0;
margin-inline-end: 0;
+ display: inline-block;
position: relative;
- img {
+ .#{$_}__image {
+ display: block;
height: inherit;
}
@@ -26,4 +36,40 @@ $_: "c-logotype";
&.#{$_}--align-right {
text-align: right;
}
+
+ &[data-logotype-maskable],
+ &.#{$_}--maskable {
+ --#{$_}--mask-opacity: calc(var(--#{$_}--mask-enabled) * 1);
+ --#{$_}--image-opacity: calc(1 - var(--#{$_}--mask-enabled));
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-color: var(--#{$_}--mask-current-color);
+ opacity: var(--#{$_}--mask-opacity);
+ transition: opacity 0.3s ease, background-color 0.3s ease;
+ pointer-events: none;
+ -webkit-mask-image: var(--#{$_}--mask-image, none);
+ mask-image: var(--#{$_}--mask-image, none);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: var(--#{$_}--mask-position, center);
+ mask-position: var(--#{$_}--mask-position, center);
+ -webkit-mask-size: var(--#{$_}--mask-size, contain);
+ mask-size: var(--#{$_}--mask-size, contain);
+ }
+
+ .#{$_}__image {
+ opacity: var(--#{$_}--image-opacity);
+ transition: opacity 0.3s ease;
+ }
+
+ &:hover,
+ a:hover &,
+ .c-link:hover &,
+ [data-component="link"]:hover & {
+ --#{$_}--mask-current-color: var(--#{$_}--mask-color-hover, var(--#{$_}--mask-color));
+ }
+ }
}
\ No newline at end of file
diff --git a/source/components/logotype/tests/logotype.spec.ts b/source/components/logotype/tests/logotype.spec.ts
new file mode 100644
index 000000000..876de2e40
--- /dev/null
+++ b/source/components/logotype/tests/logotype.spec.ts
@@ -0,0 +1,159 @@
+import { expect, type Locator, type Page, test } from '@playwright/test';
+
+/**
+ * Logotype component – Design panel (component customizer) integration tests.
+ *
+ * These tests verify that the Logotype component settings exposed in the
+ * Design Builder change the rendered logotype or its computed CSS values.
+ *
+ * The design-builder target on the logotype page is the header logotype:
+ * `[data-component="logotype"]` (#main-logo), which is already maskable.
+ *
+ * Prerequisites:
+ * - The styleguide PHP server must be accessible at the configured base URL.
+ * - The project must have been built (`npm run build`) so the
+ * design-builder JS bundle is served.
+ */
+
+const LOGOTYPE_PAGE = '/components/logotype';
+const FAB_TRIGGER_LABEL = 'Open component customizer';
+const COMPONENT_SELECT = '#db-component-select';
+const FAB_PANEL_OPEN_CLASS = 'c-fab__panel--open';
+const CUSTOMIZER_PANEL = '.db-builder-customizer';
+const LOGOTYPE_TARGET = '[data-component="logotype"]';
+
+const MASKING_LABEL = 'Masking';
+
+const EXPECTED_VISIBLE_CONTROLS = [MASKING_LABEL];
+
+/**
+ * Expands a Design Builder category panel if it is currently collapsed.
+ */
+async function expandCategory(categoryHeader: Locator): Promise {
+ const category = categoryHeader.locator('..');
+ const isCollapsed = await category.evaluate((el) => el.classList.contains('db-category-collapsed'));
+ if (isCollapsed) {
+ await categoryHeader.click();
+ }
+}
+
+/**
+ * Expands all customizer categories so all controls are visible and testable.
+ */
+async function expandAllCategories(page: Page): Promise {
+ const headers = page.locator('.db-category-header');
+ const count = await headers.count();
+
+ for (let index = 0; index < count; index += 1) {
+ await expandCategory(headers.nth(index));
+ }
+}
+
+/**
+ * Returns the computed value of a CSS property on a DOM element.
+ */
+async function getComputedCss(locator: Locator, property: string): Promise {
+ return locator.evaluate((el, prop) => window.getComputedStyle(el).getPropertyValue(prop).trim(), property);
+}
+
+/**
+ * Returns the computed opacity of a pseudo-element (::before or ::after).
+ */
+async function getPseudoOpacity(locator: Locator, pseudo: string): Promise {
+ return locator.evaluate((el, pseudoSelector) => window.getComputedStyle(el, pseudoSelector).getPropertyValue('opacity').trim(), pseudo);
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Finds a design-builder control row by its label text.
+ */
+async function getControlRow(page: Page, label: string): Promise {
+ const exactLabel = page.locator('.db-control-row-label-text').filter({ hasText: new RegExp(`^${escapeRegExp(label)}$`) });
+ const row = page.locator('db-control-row').filter({ has: exactLabel }).first();
+ await expect(row).toBeVisible();
+ return row;
+}
+
+/**
+ * Selects an option in a select-type control row.
+ */
+async function selectControlOption(page: Page, label: string, optionValue: string): Promise {
+ const row = await getControlRow(page, label);
+ const select = row.locator('select').first();
+ await expect(select).toBeVisible();
+ await select.selectOption(optionValue);
+}
+
+test.describe('Logotype – design panel', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(LOGOTYPE_PAGE);
+ await expect(page.locator(LOGOTYPE_TARGET)).toBeVisible();
+
+ await page.getByRole('button', { name: FAB_TRIGGER_LABEL }).click();
+ await expect(page.locator(`.${FAB_PANEL_OPEN_CLASS}`)).toBeVisible();
+ await expect(page.locator(CUSTOMIZER_PANEL)).toBeVisible();
+ await page.locator(COMPONENT_SELECT).selectOption({ value: 'logotype' });
+ });
+
+ test('opens the design panel when the FAB button is clicked', async ({ page }) => {
+ await expect(page.locator(CUSTOMIZER_PANEL)).toBeVisible();
+ });
+
+ test('design panel shows logotype component settings', async ({ page }) => {
+ await expect(page.locator('.db-category-header').filter({ hasText: 'Appearance' })).toBeVisible();
+ });
+
+ test('logotype panel exposes the full expected control inventory', async ({ page }) => {
+ await expandAllCategories(page);
+
+ const labels = await page.locator('db-control-row .db-control-row-label-text').evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() ?? '').filter(Boolean));
+
+ for (const expectedLabel of EXPECTED_VISIBLE_CONTROLS) {
+ expect(labels).toContain(expectedLabel);
+ }
+ });
+
+ test('Masking defaults to On – image is hidden and ::before is visible', async ({ page }) => {
+ const logotype = page.locator(LOGOTYPE_TARGET).first();
+ const imageOpacity = await getComputedCss(logotype.locator('.c-logotype__image'), 'opacity');
+ const beforeOpacity = await getPseudoOpacity(logotype, '::before');
+
+ expect(imageOpacity).toBe('0');
+ expect(beforeOpacity).toBe('1');
+ });
+
+ test('setting Masking to Off reveals the image and hides the ::before mask', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Appearance' }));
+
+ const logotype = page.locator(LOGOTYPE_TARGET).first();
+
+ const initialImageOpacity = await getComputedCss(logotype.locator('.c-logotype__image'), 'opacity');
+ const initialBeforeOpacity = await getPseudoOpacity(logotype, '::before');
+ expect(initialImageOpacity).toBe('0');
+ expect(initialBeforeOpacity).toBe('1');
+
+ await selectControlOption(page, MASKING_LABEL, '0');
+
+ const updatedImageOpacity = await getComputedCss(logotype.locator('.c-logotype__image'), 'opacity');
+ const updatedBeforeOpacity = await getPseudoOpacity(logotype, '::before');
+ expect(updatedImageOpacity).toBe('1');
+ expect(updatedBeforeOpacity).toBe('0');
+ });
+
+ test('setting Masking back to On re-applies the mask', async ({ page }) => {
+ await expandCategory(page.locator('.db-category-header').filter({ hasText: 'Appearance' }));
+
+ await selectControlOption(page, MASKING_LABEL, '0');
+ await selectControlOption(page, MASKING_LABEL, '1');
+
+ const logotype = page.locator(LOGOTYPE_TARGET).first();
+ const imageOpacity = await getComputedCss(logotype.locator('.c-logotype__image'), 'opacity');
+ const beforeOpacity = await getPseudoOpacity(logotype, '::before');
+
+ expect(imageOpacity).toBe('0');
+ expect(beforeOpacity).toBe('1');
+ });
+});
diff --git a/source/design-builder/shared/control-elements/controls/RangeControl.test.ts b/source/design-builder/shared/control-elements/controls/RangeControl.test.ts
index 9499ab449..e4375f9b3 100644
--- a/source/design-builder/shared/control-elements/controls/RangeControl.test.ts
+++ b/source/design-builder/shared/control-elements/controls/RangeControl.test.ts
@@ -107,4 +107,20 @@ describe('RangeControl', () => {
expect(input.step).toBe('2');
expect(input.value).toBe('160');
});
+
+ it('clamps out-of-range values to the configured bounds', () => {
+ const rangeControl = document.createElement('range-control');
+ rangeControl.setAttribute('min', '-9');
+ rangeControl.setAttribute('max', '9');
+ rangeControl.setAttribute('step', '0.5');
+ rangeControl.setAttribute('value', '360');
+ rangeControl.setAttribute('unit', 'deg');
+ document.body.appendChild(rangeControl);
+
+ const input = rangeControl.querySelector('input[type="range"]') as HTMLInputElement;
+ const output = rangeControl.querySelector('output') as HTMLOutputElement;
+
+ expect(input.value).toBe('9');
+ expect(output.textContent).toBe('9 deg');
+ });
});
diff --git a/source/design-builder/shared/control-elements/controls/RangeControl.ts b/source/design-builder/shared/control-elements/controls/RangeControl.ts
index 8bc2c5645..11e7880f0 100644
--- a/source/design-builder/shared/control-elements/controls/RangeControl.ts
+++ b/source/design-builder/shared/control-elements/controls/RangeControl.ts
@@ -25,12 +25,32 @@ class RangeControl extends HTMLElement {
this.render();
}
+ private getClampedValue(value: string, min: string, max: string): string {
+ if (value === '') {
+ return value;
+ }
+
+ const numericValue = Number.parseFloat(value);
+ const numericMin = Number.parseFloat(min);
+ const numericMax = Number.parseFloat(max);
+
+ if (Number.isNaN(numericValue)) {
+ return value;
+ }
+
+ const lowerBound = Number.isNaN(numericMin) ? numericValue : numericMin;
+ const upperBound = Number.isNaN(numericMax) ? numericValue : numericMax;
+ const clampedValue = Math.min(Math.max(numericValue, lowerBound), upperBound);
+
+ return String(clampedValue);
+ }
+
render() {
const isDisabled = this.hasAttribute('locked');
const min = this.getAttribute('min') || '0';
const max = this.getAttribute('max') || '100';
const step = this.getAttribute('step') || '1';
- const value = this.getAttribute('value') || '';
+ const value = this.getClampedValue(this.getAttribute('value') || '', min, max);
const unit = this.getAttribute('unit') || '';
const markup = () =>
@@ -43,7 +63,8 @@ class RangeControl extends HTMLElement {
}
_onInput(e: Event) {
- const value = (e.target as HTMLInputElement).value;
+ const input = e.target as HTMLInputElement;
+ const value = this.getClampedValue(input.value, input.min, input.max);
this.dispatchEvent(
new CustomEvent('change', {
diff --git a/views/layout/master.blade.php b/views/layout/master.blade.php
index 33f1ca378..70b7bcb29 100644
--- a/views/layout/master.blade.php
+++ b/views/layout/master.blade.php
@@ -45,25 +45,6 @@
-
-
-
@@ -173,7 +154,11 @@
'id' => 'main-logo',
'src' => '/assets/img/logotype.svg',
'alt' => 'Go to homepage',
- 'classList' => ['c-header__logotype']
+ 'maskable' => true,
+ 'classList' => ['c-header__logotype'],
+ 'attributeList' => [
+ 'style' => '--c-header--logotype-height: calc(var(--base) * 6);'
+ ],
])
@endlogotype
@endlink
diff --git a/views/layout/partials/doc-nav.blade.php b/views/layout/partials/doc-nav.blade.php
index 51d240227..0afa1a667 100644
--- a/views/layout/partials/doc-nav.blade.php
+++ b/views/layout/partials/doc-nav.blade.php
@@ -15,7 +15,11 @@
@logotype([
'src'=> '/assets/img/logotype.svg',
'alt' => 'Go to homepage',
- 'classList' => ['c-header__logotype']
+ 'maskable' => true,
+ 'classList' => ['c-header__logotype'],
+ 'attributeList' => [
+ 'style' => '--c-header--logotype-height: calc(var(--base) * 6);'
+ ]
])
@endlogotype
@endlink