diff --git a/src/components/dropdown/README.md b/src/components/dropdown/README.md new file mode 100644 index 0000000..c4b3859 --- /dev/null +++ b/src/components/dropdown/README.md @@ -0,0 +1,23 @@ +# Accessible Dropdown +Dropdowns allow "hanging" menus when a trigger element is activated. + +## Usage +to use this component: + +```ejs +<%- useComponent('dropdown', { menuId: 'example-dropdown', contents: `...` }) %> +``` + +## Parameters + +| Parameter | type | Description | +| ---------------------- | ----------------- | ------------------------------------------------------------ | +| `menuId` | string | The ID of the dropdown | +| `triggerContent` | HTML | The HTML of the dropdown trigger (button) | +| `contents` | HTML | The content of the dropdown | +| `desktopOnly` | boolean | If the dropdown is for desktop only | +| `mobileOnly` | boolean | If the dropdown is for mobile only | +| `triggerStyleClasses` | string / string[] | The class(es) assigned to the dropdown trigger element | +| `menuStyleClasses` | string / string[] | The class(es) assigned to the dropdown menu element | +| `dropdownExtraStyles` | Various | Extra CSS to be included in the components Shadow DOM | +| `dropdownExtraScripts` | Various | Extra JS scripts to be included in the components Shadow DOM | \ No newline at end of file diff --git a/src/components/dropdown/dropdown.css b/src/components/dropdown/dropdown.css new file mode 100644 index 0000000..b0613d8 --- /dev/null +++ b/src/components/dropdown/dropdown.css @@ -0,0 +1,367 @@ +/* ========================================================================== + Shadow DOM Styles (Component Wrapper & Mechanics) + ========================================================================== */ + +/* HOST (Replaces .has-dropdown) */ +:host { + /* POSITIONING - Relative to contain the absolute dropdown */ + position: relative; + + /* LAYOUT - Flex for trigger + dropdown */ + display: flex; + flex-direction: column; + align-items: stretch; + + /* WIDTH - Full width on mobile for easier tapping */ + width: 100%; + + /*font-family: var(--font-sans);*/ +} + +/* Reset dropdowns for mobile to be static lists */ +.dropdown-menu { + /* POSITIONING - static on mobile */ + position: static; + + /* WIDTH - Full width on mobile */ + width: 100%; + min-width: 0; /* Remove fixed width for mobile */ + + /* LIST STYLE - Remove bullets */ + list-style: none; + + /* SHADOW - Remove shadow for mobile since it's not floating */ + box-shadow: none; + + /* BORDER - Remove border for mobile since it's not floating */ + border: none; + border-left: 2px solid var(--surface-3); /* Visual guide for hierarchy */ + + /* OPACITY / VISIBILITY - Always visible on mobile */ + opacity: 1; + visibility: visible; + + /* ANIMATION - Disable mobile transform */ + transform: none; + + /* SPACING - Indent dropdown items on mobile for hierarchy */ + padding-left: var(--size-4); + + /* Z-INDEX - Auto for mobile since we're not overlapping content */ + z-index: auto; + + /*margin: 0; + box-sizing: border-box;*/ +} + +/* If the dropdown has the hidden attribute, hide it */ +.dropdown-menu[hidden] { + display: none !important; + opacity: 0; + visibility: hidden; +} + +.dropdown-menu:popover-open { + position: absolute; /* Actually works with anchor API */ + position-anchor: --dropdown-btn; + top: anchor(bottom); + left: anchor(start); + margin: 0; /* Reset native popover centering */ +} + +/* Because mobile doesn't support right aligned dropdowns turn into essentially noop */ +.dropdown-menu.align-right { + left: 0; + right: auto; + transform-origin: top left; +} + +/* -+- Dropdown Trigger Buttons (These can be used for any dropdown, not just nav) -+- */ + +.dropdown-trigger { + anchor-name: --dropdown-btn; +} + +.dropdown-trigger, +.dropdown-trigger-icon { + appearance: none; + + /* BACKGROUND */ + background: transparent; + + /* BORDER */ + border: none; + + /* WIDTH - Full width on mobile for easier tapping */ + min-width: 100%; + + /* FONT / TYPOGRAPHY */ + font-family: inherit; + font-size: inherit; + + /* COLOUR */ + color: var(--text-2); + + /* LAYOUT - Flex for icon + text */ + display: flex; + align-items: stretch; + + /* GAP - Space between icon and text */ + gap: var(--size-2); + + /*padding: 0; + margin: 0;*/ + cursor: pointer; +} + +/* Hover/Focus styles for dropdown triggers */ +.dropdown-trigger:hover, +.dropdown-trigger:focus, +.dropdown-trigger[aria-expanded="true"], +::slotted([slot="dropdown-trigger"]:hover), +::slotted([slot="dropdown-trigger"]:focus), +::slotted([slot="dropdown-trigger"][aria-expanded="true"]) { + background: var(--surface-elevated); + color: var(--text-1); +} + +/* Chevron Rotation when open */ +.dropdown-trigger[aria-expanded="true"] .fa-chevron-down, +.dropdown-trigger-icon[aria-expanded="true"] .fa-chevron-down, +::slotted([slot="dropdown-trigger"][aria-expanded="true"] .fa-chevron-down) { + transform: rotate(180deg); + transition: transform 0.2s var(--ease-1); + transform-origin: center center; +} + +/* For users who prefer reduced motion, disable the chevron rotation animation */ +@media (prefers-reduced-motion: reduce) { + .dropdown-trigger[aria-expanded="true"] .fa-chevron-down, + .dropdown-trigger-icon[aria-expanded="true"] .fa-chevron-down, + ::slotted([slot="dropdown-trigger"][aria-expanded="true"] .fa-chevron-down) { + transition: none; + } +} + +/* --- Desktop Component Overrides --- */ +@media (min-width: 820px) { + :host { + /* LAYOUT - Flex for trigger + dropdown */ + flex-direction: row; + align-items: center; + } + + .dropdown-menu { + /* POSITIONING - Absolute on desktop */ + position: absolute; + top: 100%; /* Push below parent */ + left: 0; + + /* WIDTH - Fit to content */ + width: max-content; + min-width: 14rem; + + /* BACKGROUND */ + background: var(--surface-filled); + + /* SHADOW - Gives depth */ + box-shadow: var(--shadow-3); + + /* BORDER */ + border: 1px solid var(--surface-3); + + /* CURVES / RADIUS - for a more elegant look */ + border-radius: var(--radius-2); + + /* SPACING */ + padding: var(--size-2); + + /* ANIMATION - Dropdown slide down */ + transform: translateY(-5px); + transition: all 0.2s var(--ease-2); + + /* Z-INDEX - Ensure it floats above other content */ + z-index: var(--layer-4); + } + + /* Right-Aligned Dropdown Modifier */ + .dropdown-menu.align-right { + left: auto; /* Unsets the default 'left: 0' */ + right: 0; /* Anchors to the right edge of the parent */ + transform-origin: top right; /* Makes animation start from the right corner */ + } + + .dropdown-trigger, + .dropdown-trigger-icon, + ::slotted([slot="dropdown-trigger"]) { + /* WIDTH - Fit to content on desktop */ + min-width: auto; + + /* LAYOUT */ + align-items: center; + } +} + +/* Ensure slotted Light DOM content fills the container appropriately */ +::slotted(ul) { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + width: 100%; +} + +::slotted([slot="dropdown-trigger"]) { + display: flex; + gap: var(--size-2); +} + +/* ========================================================================== + Light DOM Styles (Contents injected into the dropdown) + ========================================================================== */ + +/* For empty dropdowns (where we want to put a message like "No notifications"), we can use this class to style the message nicely */ +.dropdown-empty { + /* FONT / TYPOGRAPHY */ + font-style: italic; + text-align: center; + + /* SPACING */ + padding: var(--size-2); + + /* COLOUR - Use a muted color for the empty state message */ + color: var(--text-3); + + /* WIDTH - Full width to fill the dropdown */ + min-width: 100%; + + /* HEIGHT - Use native content height */ + min-height: auto; +} + +@media (min-width: 820px) { + .dropdown-empty { + /* WIDTH - Fit to content */ + width: max-content; + + /* OVERFLOW - Allow text to wrap if it's too long */ + overflow: wrap; + + /* HEIGHT - Ensure a minimum height for better aesthetics */ + min-height: 2.5rem; + } +} + +/* Targeting Light DOM lists slotted into the dropdown */ +ba-dropdown ul { + margin: 0; + padding: 0; + list-style: none; +} + +ba-dropdown li a, +ba-dropdown li button { + /* LAYOUT */ + display: block; + + /* WIDTH - Full width on mobile for easier tapping */ + width: 100%; + + /* FONT / TYPOGRAPHY */ + text-decoration: none; + + /* COLOUR */ + color: var(--text-2); + + /* SPACING */ + padding: var(--size-2); + + /* CURVES / RADIUS */ + border-radius: var(--radius-1); + + /*box-sizing: border-box; + appearance: none; + background: transparent; + border: none; + font: inherit; + text-align: left;*/ + cursor: pointer; +} + +ba-dropdown li a:hover, +ba-dropdown li a:focus, +ba-dropdown li button:hover, +ba-dropdown li button:focus { + background-color: var(--surface-elevated); + color: var(--text-1); +} + +@media (min-width: 820px) { + ba-dropdown li a, + ba-dropdown li button { + /* WIDTH - Fit to content on desktop */ + width: auto; + + display: flex; + align-items: center; + } + + ba-dropdown li a i, + ba-dropdown li button i { + /* GAP - Space between icon and text */ + margin-right: var(--size-2); + } +} + +/* Dividers */ +ba-dropdown .divider { + background-color: light-dark(var(--color-9), var(--color-1)); + block-size: var(--border-size-1); + margin-block: var(--size-2); + /*border: none;*/ +} + +/* Focus styling */ +:is(.dropdown-trigger, .dropdown-trigger-icon):focus-visible { + outline: 2px solid var(--brand, var(--indigo-6)); + outline-offset: 2px; + border-radius: var(--radius-1); + z-index: 10; +} + +/* Utilities */ +.desktop-only { + display: none; + visibility: hidden; +} + +@media (min-width: 820px) { + .desktop-only { + display: inherit; + visibility: visible; + } +} + +/* Ensure chevron wrappers remain perfectly centered inside the flex button */ +.dropdown-trigger .desktop-only, +.dropdown-trigger .mobile-only { + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; /* Ensure a consistent size for the chevron wrapper */ +} + +.dropdown-trigger[aria-expanded="true"] .desktop-only .fa-chevron-down, +.dropdown-trigger[aria-expanded="true"] .mobile-only .fa-chevron-down, +::slotted([slot="dropdown-trigger"][aria-expanded="true"] .desktop-only .fa-chevron-down), +::slotted([slot="dropdown-trigger"][aria-expanded="true"] .mobile-only .fa-chevron-down) { + transform: rotate(180deg); + transition: transform 0.2s var(--ease-1); + transform-origin: center center; +} + +/* Force the SVG to be a block/inline-block so the browser allows it to rotate */ +svg.fa-chevron-down { + display: inline-block; +} \ No newline at end of file diff --git a/src/components/dropdown/dropdown.ejs b/src/components/dropdown/dropdown.ejs new file mode 100644 index 0000000..7415294 --- /dev/null +++ b/src/components/dropdown/dropdown.ejs @@ -0,0 +1,123 @@ + + <%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the tabs data available in EJS to the JavaScript for the web component (easiest way was via a script tag) %> + + + + + + <% if(typeof slots !== 'undefined') { %> + <%- slots %> + <% } else { %> +
+ <%- triggerContent %> +
+ + + <% } %> +
\ No newline at end of file diff --git a/src/components/dropdown/dropdown.mjs b/src/components/dropdown/dropdown.mjs new file mode 100644 index 0000000..cd3a2dd --- /dev/null +++ b/src/components/dropdown/dropdown.mjs @@ -0,0 +1,211 @@ +import { ComposableElement } from '../ComposableElement.mjs'; + +/** + * Dropdown Web Component + * + * A simple dropdown component that toggles the visibility of its content when the "trigger" is clicked or activated with the keyboard. + */ +export class Dropdown extends ComposableElement { + constructor() { + super(); + + // Event handler bindings for toggle (popover) and keydown events + this._handleToggle = this._toggled.bind(this); + this._handleKeyDown = this._keyDown.bind(this) + } + + // ---------------------- + // Private Event Handlers + // ---------------------- + + /** Content toggled handler (popover content toggled) */ + _toggled(event) { + //event.preventDefault(); + + this.currentElemIndex = -1; + + const triggerElem = this.shadow.querySelector(`[aria-controls="${event.currentTarget.id}"]`); + const isOpen = event.newState === 'open'; + + triggerElem.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + event.currentTarget.toggleAttribute('hidden'); + } + + /** Keydown handler for the dropdown (arrow key navigation) */ + _keyDown(event) { + if(event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return; + } + + event.preventDefault(); + + const focusableItems = Array.from(this.querySelectorAll('li')); + + if (focusableItems.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + if(this.currentElemIndex === undefined || this.currentElemIndex == null || this.currentElemIndex >= focusableItems.length - 1) { + this.currentElemIndex = -1; + } + + this.currentElemIndex++; + + focusableItems[this.currentElemIndex].children[0].focus(); + } + else if(event.key === 'ArrowUp') { + if(this.currentElemIndex === undefined || this.currentElemIndex == null || this.currentElemIndex <= 0) { + this.currentElemIndex = focusableItems.length; + } + + this.currentElemIndex--; + + focusableItems[this.currentElemIndex].children[0].focus(); + } + } + + // ------------------------------- + // Web Component Lifecycle Methods + // ------------------------------- + + /** + * Does initial setup and adds event listeners for interactivity + * + * `connectedCallback` is a lifecycle method in web components that runs when the custom element is inserted into the document's Document Object Model (DOM). + * It can be invoked multiple times if the element is removed and then re-inserted into the DOM. + * + * Timing: It is called after the element's constructor() but before the element's children are necessarily connected or fully rendered. + * Purpose: It is the ideal place to set up tasks that should only occur when the element is actually present in the live document. Common uses include: + */ + connectedCallback() { + const internals = this.attachInternals(); + + this.shadow = this.shadowRoot; + if (!this.shadow) { + this.shadow = this.attachShadow({ mode: 'open' }); + + // Defer execution until the browser finishes parsing the children + setTimeout(() => { + // Recreate the template using the shadow DOM that is only available through JavaScript + this.createTemplateInJS(this.shadow); + }, 0); + } + + setTimeout(() => { + // Native Popover handles the click, enter, space, and dismiss logic. + // You only need event listeners here if you want to trigger + // custom analytics or highly specific behavior on open/close. + const dropdown = this.shadow.querySelector('[popover]'); + if (dropdown) { + // The index of the currently focused element within the dropdown menu, used mainly for keyboard navigation + this.currentElemIndex = -1; + + // Add event listeners to the dropdown trigger (button) + dropdown.addEventListener('toggle', this._handleToggle); + dropdown.addEventListener('keydown', this._handleKeyDown); + } + }, 0); + } + + /** + * Cleans up event listeners when the component is removed from the DOM + * + * `disconnectedCallback` is a lifecycle method in web components that runs when the custom element is removed from the document's DOM. + * It can be invoked multiple times if the element is removed and then re-inserted into the DOM. + * + * Timing: It is called after the element is removed from the DOM but before it is garbage collected. + * Purpose: It is the ideal place to clean up any resources or event listeners that were set up in `connectedCallback`. + * + * Common uses include: + * - Removing event listeners to prevent memory leaks + * - Clearing timers or intervals + * - Disconnecting from external data sources or APIs + */ + disconnectedCallback() { + const dropdown = this.shadow.querySelector('[popover]'); + if (dropdown) { + dropdown.removeEventListener('toggle', this._handleToggle); + dropdown.removeEventListener('keydown', this._handleKeyDown); + } + } + + /** + * Recreate the template in the shadow DOM through JavaScript instead of relying on the `shadowrootmode` attribute + * + * @param {ShadowRoot} shadow The shadow DOM to attach the template to + */ + createTemplateInJS(shadow) { + const config = this.initializeComponent('dropdown', shadow); + + const cssAnchorPolyfill = document.createElement('script'); + cssAnchorPolyfill.type = 'module'; + cssAnchorPolyfill.src = 'https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning.js'; + shadow.appendChild(cssAnchorPolyfill); + + // Create the trigger div for the dropdown component + const dropdownTriggerDiv = document.createElement('div'); + dropdownTriggerDiv.role = 'button'; + dropdownTriggerDiv.tabIndex = 0; + dropdownTriggerDiv.setAttribute('aria-expanded', 'false'); + dropdownTriggerDiv.setAttribute('aria-controls', config.menuId); + dropdownTriggerDiv.setAttribute('aria-haspopup', 'true'); + dropdownTriggerDiv.setAttribute('popovertarget', config.menuId); + dropdownTriggerDiv.classList.add('dropdown-trigger'); + config.triggerStyleClasses.forEach(cls => dropdownTriggerDiv.classList.add(cls)); + + // Create the slot for the dropdown trigger content + const dropdownTriggerSlotElem = document.createElement('slot'); + dropdownTriggerSlotElem.name = 'dropdown-trigger'; + + dropdownTriggerDiv.appendChild(dropdownTriggerSlotElem); + + // Define the SVG once to keep the HTML clean + const dropdownTriggerChevronSvg = ` + `; + + if((typeof config.desktopOnly === 'boolean' && config.desktopOnly) || (typeof config.mobileOnly === 'boolean' && config.mobileOnly)) { + const dropdownTriggerChevronWrapperElem = document.createElement('div'); + + if(typeof config.desktopOnly === 'boolean' && config.desktopOnly) { + dropdownTriggerChevronWrapperElem.classList.add('desktop-only'); + } + else if(typeof config.mobileOnly === 'boolean' && config.mobileOnly) { + dropdownTriggerChevronWrapperElem.classList.add('mobile-only'); + } + + dropdownTriggerChevronWrapperElem.innerHTML = dropdownTriggerChevronSvg; + + dropdownTriggerDiv.appendChild(dropdownTriggerChevronWrapperElem); + } + else { + dropdownTriggerDiv.innerHTML = dropdownTriggerDiv.innerHTML + dropdownTriggerChevronSvg; + } + + // Append the trigger div to the shadow DOM of the component + shadow.appendChild(dropdownTriggerDiv); + + // Create the list (`ul`) that will contain the dropdown content + const dropdownContentsDiv = document.createElement('div'); + dropdownContentsDiv.id = config.menuId; + dropdownContentsDiv.classList.add('dropdown-menu') + config.menuStyleClasses.forEach(cls => dropdownContentsDiv.classList.add(cls)); + dropdownContentsDiv.hidden = true; + dropdownContentsDiv.popover = true; + + const contentSlotElem = document.createElement('slot'); + contentSlotElem.name = 'dropdown-menu'; + + dropdownContentsDiv.appendChild(contentSlotElem); + + shadow.appendChild(dropdownContentsDiv); + } +} + +document.addEventListener('DOMContentLoaded', () => { + if (!customElements.get('ba-dropdown')) { + customElements.define('ba-dropdown', Dropdown); + } +}); \ No newline at end of file diff --git a/test-harness/tests/drawer.spec.ts-snapshots/drawer-closed-chromium-linux.png b/test-harness/tests/drawer.spec.ts-snapshots/drawer-closed-chromium-linux.png index 0dc9d0f..7cfe372 100644 Binary files a/test-harness/tests/drawer.spec.ts-snapshots/drawer-closed-chromium-linux.png and b/test-harness/tests/drawer.spec.ts-snapshots/drawer-closed-chromium-linux.png differ diff --git a/test-harness/tests/drawer.spec.ts-snapshots/drawer-open-chromium-linux.png b/test-harness/tests/drawer.spec.ts-snapshots/drawer-open-chromium-linux.png index c72a87d..f24b4ce 100644 Binary files a/test-harness/tests/drawer.spec.ts-snapshots/drawer-open-chromium-linux.png and b/test-harness/tests/drawer.spec.ts-snapshots/drawer-open-chromium-linux.png differ diff --git a/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-button-closed-chromium-linux.png b/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-button-closed-chromium-linux.png index 487d1cf..af8012b 100644 Binary files a/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-button-closed-chromium-linux.png and b/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-button-closed-chromium-linux.png differ diff --git a/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-container-open-chromium-linux.png b/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-container-open-chromium-linux.png index 487d1cf..af8012b 100644 Binary files a/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-container-open-chromium-linux.png and b/test-harness/tests/tooltip.spec.ts-snapshots/tooltip-container-open-chromium-linux.png differ