Adding a dropdown component
All checks were successful
Build, Test, and Publish (to Private NPM Registry) UI Components Library / publish (push) Successful in 1m15s
23
src/components/dropdown/README.md
Normal file
|
|
@ -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 |
|
||||||
367
src/components/dropdown/dropdown.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
123
src/components/dropdown/dropdown.ejs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<ba-dropdown>
|
||||||
|
<%# 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) %>
|
||||||
|
<!-- JSON config data so that `createTemplateInJS` can recreate the tabs DOM structure accurately if needed -->
|
||||||
|
<script type="application/json" data-dropdown-config>
|
||||||
|
<%- JSON.stringify({
|
||||||
|
dropdownExtraStyles: typeof dropdownExtraStyles !== 'undefined' ? (Array.isArray(dropdownExtraStyles) ? dropdownExtraStyles : [dropdownExtraStyles]) : [],
|
||||||
|
dropdownExtraScripts: typeof dropdownExtraScripts !== 'undefined' ? (Array.isArray(dropdownExtraScripts) ? dropdownExtraScripts : [dropdownExtraScripts]) : [],
|
||||||
|
desktopOnly: typeof desktopOnly !== 'undefined' ? desktopOnly : false,
|
||||||
|
mobileOnly: typeof mobileOnly !== 'undefined' ? mobileOnly : false,
|
||||||
|
triggerStyleClasses: typeof triggerStyleClasses !== 'undefined' ? (Array.isArray(triggerStyleClasses) ? triggerStyleClasses : [triggerStyleClasses]) : [],
|
||||||
|
triggerContent: typeof triggerContent !== 'undefined' ? triggerContent : '',
|
||||||
|
menuId: typeof menuId !== 'undefined' ? menuId : 'dropdown-menu',
|
||||||
|
menuStyleClasses: typeof menuStyleClasses !== 'undefined' ? (Array.isArray(menuStyleClasses) ? menuStyleClasses : [menuStyleClasses]) : [],
|
||||||
|
contents: typeof contents !== 'undefined' ? contents : false
|
||||||
|
}) %>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template shadowrootmode="open">
|
||||||
|
<!-- Styles -->
|
||||||
|
<!-- Component Specific Styling -->
|
||||||
|
<!-- Component Styling (Part of Component Library CSS) -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
|
||||||
|
<!-- END: Component Specific Styling -->
|
||||||
|
<!-- Additional Styles -->
|
||||||
|
<% if (typeof dropdownExtraStyles !== 'undefined') { %>
|
||||||
|
<% const dropdownStyles = Array.isArray(dropdownExtraStyles) ? dropdownExtraStyles : [dropdownExtraStyles]; %>
|
||||||
|
|
||||||
|
<% for (let style of dropdownStyles) { %>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
<!-- END: Additional Styles -->
|
||||||
|
<!-- END: Styles -->
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<!-- -- Fonts, Icons, and other assets -- -->
|
||||||
|
<!-- CSS Anchor Positioning Polyfill -->
|
||||||
|
<script type="module" src="https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning.js"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<%# <script src="https://kit.fontawesome.com/c754d91c7e.js" crossorigin="anonymous"></script> %>
|
||||||
|
<!-- -- End: Fonts, Icons, and other assets -- -->
|
||||||
|
|
||||||
|
<% if (typeof dropdownExtraScripts !== 'undefined') { %>
|
||||||
|
<% const dropdownScripts = Array.isArray(dropdownExtraScripts) ? dropdownExtraScripts : [dropdownExtraScripts]; %>
|
||||||
|
<% for (let script of dropdownScripts) { %>
|
||||||
|
<% if(typeof script === 'object') { %>
|
||||||
|
<script type="<%= typeof script.module === 'boolean' && script.module ? 'module' : 'application/javascript' %>" src="<%= script.script %>"></script>
|
||||||
|
<% } else if(typeof script === 'string' && (script.startsWith('http') || script.startsWith('https'))) { %>
|
||||||
|
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="<%= script %>"></script>
|
||||||
|
<% } else if(typeof script === 'string') { %>
|
||||||
|
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="/js/<%= script.endsWith('.mjs') ? script : script + '.js' %>"></script>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
<!-- END: Scripts -->
|
||||||
|
|
||||||
|
<div role="button" class="dropdown-trigger<%= typeof triggerStyleClasses !== 'undefined' ? ' ' + triggerStyleClasses.join(' ') : '' %>" tabindex="0" aria-expanded="false" aria-controls="<%= typeof menuId !== 'undefined' ? menuId : 'dropdown-menu' %>" aria-haspopup="true" popovertarget="<%= typeof menuId !== 'undefined' ? menuId : 'dropdown-menu' %>">
|
||||||
|
<slot name="dropdown-trigger"></slot>
|
||||||
|
|
||||||
|
<%
|
||||||
|
// Define the SVG once to keep the HTML clean
|
||||||
|
const chevronSvg = `
|
||||||
|
<svg class="fa-chevron-down" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor">
|
||||||
|
<path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/>
|
||||||
|
</svg>`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% if((typeof desktopOnly === 'boolean' && desktopOnly) || (typeof mobileOnly === 'boolean' && mobileOnly)) { %>
|
||||||
|
<div class="<%= typeof desktopOnly === 'boolean' && desktopOnly ? 'desktop-only' : '' %><%= typeof mobileOnly === 'boolean' && mobileOnly ? 'mobile-only' : '' %>">
|
||||||
|
<%# <i class="fas fa-chevron-down" aria-hidden="true"></i> %>
|
||||||
|
<%- chevronSvg %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<%# <i class="fas fa-chevron-down" aria-hidden="true"></i> %>
|
||||||
|
<%- chevronSvg %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div id="<%= typeof menuId !== 'undefined' ? menuId : 'dropdown-menu' %>" class="dropdown-menu<%= typeof menuStyleClasses !== 'undefined' ? ' ' + menuStyleClasses.join(' ') : '' %>" hidden popover>
|
||||||
|
<slot name="dropdown-menu"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<% if(typeof slots !== 'undefined') { %>
|
||||||
|
<%- slots %>
|
||||||
|
<% } else { %>
|
||||||
|
<div slot="dropdown-trigger">
|
||||||
|
<%- triggerContent %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul slot="dropdown-menu" role="menu">
|
||||||
|
<% if(typeof contents === 'object' && Array.isArray(contents)) { %>
|
||||||
|
<% contents.forEach((item, index) => { %>
|
||||||
|
<li role="menuitem">
|
||||||
|
<% if(typeof item === 'object' && item !== null) { %>
|
||||||
|
<a href="<%= item.link %>">
|
||||||
|
<% if(typeof item.icon !== 'undefined') { %>
|
||||||
|
<i class="<%= item.icon %>" aria-hidden="true"></i>
|
||||||
|
<% } %>
|
||||||
|
<span><%= item.text %></span>
|
||||||
|
</a>
|
||||||
|
<% } else if(typeof item === 'string') { %>
|
||||||
|
<span><%= item %></span>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
<% } else if(typeof contents === 'object') { %>
|
||||||
|
<li role="menuitem">
|
||||||
|
<a href="<%= contents.link %>">
|
||||||
|
<% if(typeof contents.icon !== 'undefined') { %>
|
||||||
|
<i class="<%= contents.icon %>" aria-hidden="true"></i>
|
||||||
|
<% } %>
|
||||||
|
<span><%= contents.text %></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<% } else { %>
|
||||||
|
<li role="menuitem">
|
||||||
|
<span>No content provided for dropdown.</span>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</ba-dropdown>
|
||||||
211
src/components/dropdown/dropdown.mjs
Normal file
|
|
@ -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 = `
|
||||||
|
<svg class="fa-chevron-down" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor">
|
||||||
|
<path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 143 B After Width: | Height: | Size: 141 B |
|
Before Width: | Height: | Size: 143 B After Width: | Height: | Size: 141 B |