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 @@ + + + + squares-in-squares + Created with Sketch. + + + + + + + + \ 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