diff --git a/assets/icons/close.svg b/assets/icons/close.svg new file mode 100644 index 0000000..26a0e5e --- /dev/null +++ b/assets/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..4664f85 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/logo-text.svg b/assets/images/logo-text.svg new file mode 100644 index 0000000..dd78e01 --- /dev/null +++ b/assets/images/logo-text.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/header/header.component.yml b/src/components/header/header.component.yml new file mode 100644 index 0000000..5f1ee15 --- /dev/null +++ b/src/components/header/header.component.yml @@ -0,0 +1,23 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json + +name: Header +group: Components +status: stable +props: + type: object + properties: + header__show_search: + type: boolean + title: Show Search + description: Control visibility of the search form in the header. + data: true +slots: + header__branding: + title: Branding + description: Branding content (logo, site name, etc). + header__menu: + title: Menu + description: Navigation menu content. + header__search: + title: Search + description: Search form content. diff --git a/src/components/header/header.scss b/src/components/header/header.scss new file mode 100644 index 0000000..df4fb82 --- /dev/null +++ b/src/components/header/header.scss @@ -0,0 +1,66 @@ +@use '../../foundation/utility/rem-calc' as *; +@use '../../foundation/breakpoints/breakpoints' as *; + +.header { + display: flex; + width: 100%; + min-height: 5rem; + padding-inline: var(--spacing-xl) var(--spacing-lg); + justify-content: space-between; + align-items: center; + position: relative; + box-shadow: 0 4px 4px 0 rgba(197, 197, 197, 0.25); + + @include breakpoint('medium') { + justify-content: flex-start; + align-items: stretch; + padding-inline: 0; + } +} + +/* Branding */ +.header__branding { + display: flex; + align-items: center; + + .logo { + width: auto; + height: 2.5rem; + + @include breakpoint('medium') { + margin-left: var(--spacing-xl); + height: 3.5rem; + } + } + + .logo__link { + height: 100%; + display: flex; + } +} + +/* Main navigation */ +.header__menu { + @include breakpoint('medium') { + flex: 1; + display: flex; + justify-content: end; + margin-right: var(--spacing-xl); + } +} + +.main-nav__menu-list-wrapper--level-0 { + .header & { + top: 100%; + padding-right: var(--spacing-lg); + } +} + +/* Search form */ +.header__search { + display: none; + + @include breakpoint('medium') { + display: block; + } +} diff --git a/src/components/header/header.stories.js b/src/components/header/header.stories.js new file mode 100644 index 0000000..a032302 --- /dev/null +++ b/src/components/header/header.stories.js @@ -0,0 +1,51 @@ +// Markup. +import template from './header.twig'; + +// Data. +import { props as mainMenuProps } from '../navigation/main/main.component.yml'; +import { props as searchProps } from '../search/search.component.yml'; + +// JavaScript. +import '../navigation/base/menu-toggle/menu-toggle'; +import '../navigation/main/main'; +import '../search/search'; + +const mainMenuData = mainMenuProps.properties; +const searchData = searchProps.properties; + +/** + * Storybook Definition. + */ +export default { + title: 'Components/Header', + argTypes: { + mainMenu: { + name: 'Menu Items', + control: { type: 'object' }, + }, + searchPlaceholder: { + name: 'Search Placeholder', + type: 'string', + }, + showSearch: { + name: 'Show Search', + control: { type: 'boolean' }, + }, + }, + args: { + mainMenu: mainMenuData.items.data, + searchLabel: searchData.search__label.data, + searchPlaceholder: searchData.search__placeholder.data, + showSearch: true, + }, +}; + +export const Header = ({ mainMenu, searchPlaceholder, searchLabel, showSearch }) => + template({ + header__menu: mainMenu, + header__branding: 'Branding', + header__show_search: showSearch, + header__search__placeholder: searchPlaceholder, + header__search__label: searchLabel, + }); + diff --git a/src/components/header/header.twig b/src/components/header/header.twig new file mode 100644 index 0000000..830b355 --- /dev/null +++ b/src/components/header/header.twig @@ -0,0 +1,46 @@ +{# + # Header Component Template + # Available Variables: + # - header__base_class: (string) base BEM class, default 'header' + # - header__search__placeholder: (string) placeholder text for search input + # - header__search__label: (string) label for search toggle button + # - header__menu_label: (string) label for mobile menu toggle + # - additional_attributes: (array) HTML attributes for the header element + # - search_form: (string) rendered search form markup + # + # Available Blocks (Slots): + # - header__branding + # - header__menu + #} + +{% set header__base_class = 'header' %} + +
+ {# Branding #} +
+ {% block header__branding %} + {% include "@components/logo/logo.twig" with { + image_src: directory ? '/' ~ directory ~ '/assets/images/logo-text.svg' : 'logo-text.svg', + } %} + {% endblock %} +
+ {# Main navigation #} +
+ {% block header__menu %} + {% include "@components/navigation/main/main.twig" with { + items: header__menu, + } %} + {% endblock %} +
+ {# Search form #} + {% if header__show_search %} +
+ {% block header__search %} + {% include '@components/search/search.twig' with { + search__placeholder: header__search__placeholder, + search__label: header__search__label, + } %} + {% endblock %} +
+ {% endif %} +
diff --git a/src/components/logo/logo.twig b/src/components/logo/logo.twig index d7c567e..f38febe 100644 --- a/src/components/logo/logo.twig +++ b/src/components/logo/logo.twig @@ -20,8 +20,8 @@ {% block link__content %} {% include "@components/image/responsive-image.twig" with { output_image_tag: true, - image__src: directory ? '/' ~ directory ~ '/assets/images/logo.png' : 'logo.png', - image__alt: 'Logo', + image__src: image_src|default(directory ? '/' ~ directory ~ '/assets/images/logo.png' : 'logo.png'), + image__alt: image_alt|default('Logo'), responsive_image_blockname: 'logo', } %} {% endblock %} diff --git a/src/components/search/search.component.yml b/src/components/search/search.component.yml new file mode 100644 index 0000000..efe835d --- /dev/null +++ b/src/components/search/search.component.yml @@ -0,0 +1,23 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json + +name: HeaderSearch +group: Components +status: stable +props: + type: object + properties: + search__placeholder: + type: string + title: Search Placeholder + description: Placeholder text for search input. + data: Search + search__label: + type: string + title: Search Label + description: Label for the search toggle button. + data: Search +slots: + # Search form markup provided by parent + search_form: + title: Search Form + description: Rendered search form markup. diff --git a/src/components/search/search.js b/src/components/search/search.js new file mode 100644 index 0000000..eaa78cc --- /dev/null +++ b/src/components/search/search.js @@ -0,0 +1,69 @@ +// Search component behavior for header +Drupal.behaviors.headerSearch = { + attach(context) { + // Helper to close search form + const closeSearch = () => { + document.querySelectorAll('.header.search-active').forEach((header) => { + header.querySelectorAll('.search__toggle').forEach((t) => t.setAttribute('aria-expanded', 'false')); + header.querySelector('.search__form__wrapper')?.setAttribute('aria-hidden', 'true'); + header.classList.remove('search-active'); + }); + }; + + // Helper to handle search form location + const handleSearchLocation = () => { + const isMobile = window.innerWidth < 1040; + const searchForm = document.querySelector('.search__form'); + const desktopContainer = document.querySelector('.search__form__wrapper'); + const mobileContainer = document.querySelector('.header__menu > nav > div'); + + if (!searchForm || !mobileContainer || !desktopContainer) return; + + if (isMobile) { + if (searchForm.parentElement !== mobileContainer) { + mobileContainer.prepend(searchForm); + } + } else { + if (searchForm.parentElement !== desktopContainer) { + desktopContainer.prepend(searchForm); + } + } + }; + + // Initial calls + handleSearchLocation(); + + // Search Toggles + context.querySelectorAll('.search__toggle').forEach((toggle) => { + if (toggle.dataset.jsProcessed) return; + toggle.dataset.jsProcessed = true; + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + const header = toggle.closest('.header'); + const isOpening = !header.classList.contains('search-active'); + closeSearch(); + if (isOpening) { + header.classList.add('search-active'); + toggle.setAttribute('aria-expanded', 'true'); + header.querySelector('input[type="search"]')?.focus(); + header.querySelector('.search__form__wrapper')?.setAttribute('aria-hidden', 'false'); + } + }); + }); + + // Global listeners. + if (!window.headerSearchInitialized) { + window.headerSearchInitialized = true; + const mediaQuery = window.matchMedia('(min-width: 1040px)'); + const handleBreakpointChange = () => { + handleSearchLocation(); + closeSearch(); + }; + + mediaQuery.addEventListener('change', handleBreakpointChange); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeSearch(); + }); + } + } +}; diff --git a/src/components/search/search.scss b/src/components/search/search.scss new file mode 100644 index 0000000..374005b --- /dev/null +++ b/src/components/search/search.scss @@ -0,0 +1,174 @@ +@use '../../foundation/utility/rem-calc' as *; +@use '../../foundation/breakpoints/breakpoints' as *; + +.search { + display: block; + height: 100%; +} + +/* Search form */ +.search__form__wrapper { + position: absolute; + top: 100%; + left: 0; + width: 100%; + max-height: 0; + overflow: hidden; + visibility: hidden; + z-index: 1; + transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.4s; + + @include breakpoint('medium') { + top: 0; + right: 0; + left: auto; + height: 100%; + max-height: none; + max-width: 0; + transition: max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.4s; + } + + &[aria-hidden='false'] { + max-height: rem-calc(100); + visibility: visible; + + @include breakpoint('medium') { + max-width: rem-calc(560); + } + } +} + +.search__form { + display: flex; + width: 100%; + height: initial; + align-items: center; + padding-block: var(--spacing-lg) var(--spacing-xl); + padding-inline: 0; + background-color: var(--color-white); + + @include breakpoint('medium') { + padding-block: 0; + position: absolute; + right: 0; + width: auto; + height: 100%; + padding-block: var(--spacing-lg); + padding-inline: var(--spacing-xl) calc(var(--spacing-lg)); + background-color: var(--color-primary-darker); + } +} + +.search__form__input { + flex: 1; + border: none; + min-height: 2.25rem; + color: var(--color-text-body); + font-size: var(--font-size-body); + background-color: transparent; + margin-right: var(--spacing-lg); + padding-left: var(--spacing-lg); + border-bottom: 1px solid var(--color-primary-darker); + + @include breakpoint('medium') { + border-bottom: 1px solid var(--color-white); + color: var(--color-white); + } +} + +.search__form__input::placeholder { + opacity: 1; + color: var(--color-primary-dark); + + @include breakpoint('medium') { + color: var(--color-white); + } +} + +.search__form__input::-ms-input-placeholder { + color: var(--color-primary-dark); + + @include breakpoint('medium') { + color: var(--color-white); + } +} + +.search__form__actions { + display: flex; + align-items: center; + + button { + cursor: pointer; + border: none; + width: 2.25rem; + height: 2.25rem; + padding: var(--spacing-md); + background-color: transparent; + + @include breakpoint('medium') { + display: flex; + } + + &.search__toggle { + display: none; + + @include breakpoint('medium') { + display: flex; + } + } + + svg { + width: 100%; + height: 100%; + padding: 0; + user-select: none; + color: var(--color-primary-dark); + + @include breakpoint('medium') { + color: var(--color-white); + } + } + } +} + +/* Search toggle button */ +.search__toggle { + cursor: pointer; + font-weight: 700; + text-decoration: none; + text-transform: uppercase; + color: var(--color-primary-dark); + display: flex; + align-items: center; + gap: var(--spacing-lg); + height: 100%; + background-color: transparent; + border: none; + padding: 0 var(--spacing-md); + margin-inline: auto var(--spacing-md); + + @include breakpoint('medium') { + background-color: var(--color-primary-dark); + padding: 0 var(--spacing-xl); + color: var(--color-white); + margin-inline: 0; + } + + span { + display: none; + + @include breakpoint('medium') { + display: block; + } + } + + svg { + width: rem-calc(24); + height: rem-calc(24); + user-select: none; + + &.icon--close { + display: none; + } + } +} diff --git a/src/components/search/search.stories.js b/src/components/search/search.stories.js new file mode 100644 index 0000000..b290f33 --- /dev/null +++ b/src/components/search/search.stories.js @@ -0,0 +1,27 @@ +import template from './search.twig'; +import { props } from './search.component.yml'; + +import './search'; + +/** + * Storybook definition for Search component. + */ +export default { + title: 'Component/Search', + argTypes: { + header__search_placeholder: { + name: 'Placeholder', + control: { type: 'text' }, + }, + header__search_label: { + name: 'Search Button Label', + control: { type: 'text' }, + }, + }, + args: { + search__placeholder: props.properties.search__placeholder.data, + search__label: props.properties.search__label.data, + }, +}; + +export const Search = (args) => template(args); diff --git a/src/components/search/search.twig b/src/components/search/search.twig new file mode 100644 index 0000000..5f9ecea --- /dev/null +++ b/src/components/search/search.twig @@ -0,0 +1,45 @@ +{# + # Search Component Template + # Available Variables: + # - search__placeholder: (string) placeholder text for the search input + # - search__label: (string) label for the toggle button + #} + +{% set search__base_class = 'search' %} +{% set search__label = search__label|default('Search') %} +{% set search__placeholder = search__placeholder|default('Search') %} + +{% set search_form %} + +{% endset %} + +
+ + {{ search_form }} +