You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
269 lines
8.2 KiB
269 lines
8.2 KiB
/*
|
|
* NavigationMenu recipe — top-level site navigation with mega-menus.
|
|
*
|
|
* [data-navigation-menu] → root bar
|
|
* [data-navigation-menu-list] → <ul> of items
|
|
* [data-navigation-menu-item] → wrapper for each trigger+content pair
|
|
* [data-navigation-menu-trigger] → in-bar button that opens Content
|
|
* [data-navigation-menu-content] → mega-menu panel (per Item)
|
|
* [data-navigation-menu-link] → one-shot anchor (no Content)
|
|
* [data-navigation-menu-indicator] → arrow / underline tracking the active trigger
|
|
*
|
|
* Sizes — sm | md | lg via `data-size` on the root.
|
|
*
|
|
* Distinct from dropdown-menu (floating click-driven) and menubar
|
|
* (roving tabindex bar). NavigationMenu is an INLINE list that
|
|
* persists in the page chrome; per-item Content panels render via
|
|
* Floating layer but are conceptually attached to the bar, not
|
|
* portaled away.
|
|
*/
|
|
|
|
[data-navigation-menu] {
|
|
--_navigation-menu-trigger-px: var(--space-3);
|
|
--_navigation-menu-list-gap: var(--space-1);
|
|
--_navigation-menu-font-size: var(--font-size-sm);
|
|
--_navigation-menu-radius: var(--radius-md);
|
|
|
|
position: relative;
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
font-family: var(--font-ui);
|
|
font-size: var(--_navigation-menu-font-size);
|
|
line-height: var(--leading-ui);
|
|
}
|
|
|
|
[data-navigation-menu][data-size='sm'] {
|
|
--_navigation-menu-trigger-px: var(--space-2);
|
|
--_navigation-menu-list-gap: 0;
|
|
--_navigation-menu-font-size: var(--font-size-xs);
|
|
--_navigation-menu-radius: var(--radius-sm);
|
|
}
|
|
|
|
[data-navigation-menu][data-size='lg'] {
|
|
--_navigation-menu-trigger-px: var(--space-4);
|
|
--_navigation-menu-list-gap: var(--space-2);
|
|
--_navigation-menu-font-size: var(--font-size-md);
|
|
--_navigation-menu-radius: var(--radius-lg);
|
|
}
|
|
|
|
/* ── List ─ */
|
|
[data-navigation-menu-list] {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: var(--_navigation-menu-list-gap);
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
[data-navigation-menu][data-orientation='vertical'] [data-navigation-menu-list] {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
/* ── Item ─
|
|
*
|
|
* `position: relative` makes the Item the positioning anchor for its
|
|
* Content panel — Content is `position: absolute` below so opening it
|
|
* doesn't push the bar. The Item only lays out its Trigger / Link
|
|
* inline.
|
|
*/
|
|
[data-navigation-menu-item] {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
/* ── Trigger (and Link share the same in-bar chrome) ─ */
|
|
[data-navigation-menu-trigger],
|
|
[data-navigation-menu-link] {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
padding-block: var(--space-2);
|
|
padding-inline: var(--_navigation-menu-trigger-px);
|
|
border: none;
|
|
border-radius: var(--_navigation-menu-radius);
|
|
background: transparent;
|
|
color: var(--color-content-primary);
|
|
font: inherit;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
outline: none;
|
|
transition:
|
|
background var(--duration-fast) var(--ease-default),
|
|
color var(--duration-fast) var(--ease-default);
|
|
}
|
|
|
|
[data-navigation-menu-trigger]:hover:not([data-disabled]),
|
|
[data-navigation-menu-link]:hover:not([data-disabled]) {
|
|
background: var(--color-surface-overlay);
|
|
}
|
|
|
|
[data-navigation-menu-trigger][data-state='open'],
|
|
[data-navigation-menu-trigger][data-state='open']:hover {
|
|
background: var(--color-primary-element);
|
|
color: var(--color-content-primary);
|
|
}
|
|
|
|
[data-navigation-menu-link][aria-current='page'],
|
|
[data-navigation-menu-link][data-active] {
|
|
background: var(--color-primary-element);
|
|
color: var(--color-content-primary);
|
|
}
|
|
|
|
[data-navigation-menu-trigger]:focus-visible,
|
|
[data-navigation-menu-link]:focus-visible {
|
|
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
[data-navigation-menu-trigger][data-disabled],
|
|
[data-navigation-menu-link][data-disabled] {
|
|
color: var(--color-content-muted);
|
|
pointer-events: none;
|
|
cursor: default;
|
|
}
|
|
|
|
/* Trigger chevron — points down by default, rotates when open. */
|
|
[data-navigation-menu-trigger]::after {
|
|
content: '';
|
|
display: inline-block;
|
|
inline-size: 0.5rem;
|
|
block-size: 0.5rem;
|
|
margin-inline-start: var(--space-1);
|
|
border-block-end: 1.5px solid currentColor;
|
|
border-inline-end: 1.5px solid currentColor;
|
|
transform: translateY(-1px) rotate(45deg);
|
|
opacity: 0.65;
|
|
transition: transform var(--duration-fast) var(--ease-default);
|
|
}
|
|
|
|
[data-navigation-menu-trigger][data-state='open']::after {
|
|
transform: translateY(2px) rotate(-135deg);
|
|
}
|
|
|
|
/* ── Content panel (mega-menu) ─
|
|
*
|
|
* Soma renders Content via the Floating layer below the trigger. The
|
|
* recipe paints the panel chrome (border, shadow, padding) and the
|
|
* directional slide animation per `data-motion` (from / to).
|
|
*/
|
|
[data-navigation-menu-content] {
|
|
/* Floats below the parent Item — taken out of the bar's flex flow so
|
|
* opening the mega-menu doesn't push siblings. `top: 100%` anchors
|
|
* it just below the trigger row; `inset-inline-start: 0` left-aligns
|
|
* to the Item, but the panel itself has a `min-inline-size` so it
|
|
* extends past the trigger if needed. */
|
|
position: absolute;
|
|
inset-block-start: calc(100% + var(--space-1));
|
|
inset-inline-start: 0;
|
|
z-index: 50;
|
|
|
|
min-inline-size: 14rem;
|
|
padding: var(--space-4);
|
|
border: 1px solid var(--color-border-default);
|
|
border-radius: var(--radius-lg);
|
|
background: var(--color-surface-overlay);
|
|
color: var(--color-content-primary);
|
|
box-shadow: var(--shadow-floating);
|
|
outline: none;
|
|
}
|
|
|
|
/* Vertical orientation flips the anchor — Content opens to the right
|
|
* of the trigger column instead of below. */
|
|
[data-navigation-menu][data-orientation='vertical'] [data-navigation-menu-content] {
|
|
inset-block-start: 0;
|
|
inset-inline-start: calc(100% + var(--space-1));
|
|
}
|
|
|
|
[data-navigation-menu-content][data-state='open'] {
|
|
animation: navigation-menu-enter var(--duration-fast) var(--ease-out);
|
|
}
|
|
|
|
[data-navigation-menu-content][data-state='closed'] {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes navigation-menu-enter {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-4px) scale(0.985);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
/* `data-motion` — soma writes 'from-start' / 'from-end' / 'to-start' /
|
|
* 'to-end' when the active item swaps to a sibling. The directional
|
|
* slide makes the swap feel like a horizontal scroll, not a flicker. */
|
|
[data-navigation-menu-content][data-motion='from-start'] {
|
|
animation: navigation-menu-from-start var(--duration-fast) var(--ease-out);
|
|
}
|
|
[data-navigation-menu-content][data-motion='from-end'] {
|
|
animation: navigation-menu-from-end var(--duration-fast) var(--ease-out);
|
|
}
|
|
[data-navigation-menu-content][data-motion='to-start'] {
|
|
animation: navigation-menu-to-start var(--duration-fast) var(--ease-default);
|
|
}
|
|
[data-navigation-menu-content][data-motion='to-end'] {
|
|
animation: navigation-menu-to-end var(--duration-fast) var(--ease-default);
|
|
}
|
|
|
|
@keyframes navigation-menu-from-start {
|
|
from { opacity: 0; transform: translateX(-12px); }
|
|
}
|
|
@keyframes navigation-menu-from-end {
|
|
from { opacity: 0; transform: translateX(12px); }
|
|
}
|
|
@keyframes navigation-menu-to-start {
|
|
to { opacity: 0; transform: translateX(-12px); }
|
|
}
|
|
@keyframes navigation-menu-to-end {
|
|
to { opacity: 0; transform: translateX(12px); }
|
|
}
|
|
|
|
/* ── Indicator (underline under active Trigger) ─
|
|
*
|
|
* Soma writes the active-trigger rect into CSS vars on the indicator:
|
|
* --navigation-menu-indicator-x / -y / -w / -h
|
|
* The recipe consumes them to position a 2px underline that slides
|
|
* between triggers. The indicator itself stays inside the navigation
|
|
* bar; it's NOT in the floating layer.
|
|
*/
|
|
[data-navigation-menu-indicator] {
|
|
position: absolute;
|
|
inset-inline-start: 0;
|
|
inset-block-end: 0;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
inline-size: var(--navigation-menu-indicator-w, 0px);
|
|
block-size: 2px;
|
|
transform: translateX(var(--navigation-menu-indicator-x, 0px));
|
|
background: var(--color-primary-solid);
|
|
border-radius: 1px;
|
|
opacity: 0;
|
|
transition:
|
|
transform var(--duration-fast) var(--ease-default),
|
|
inline-size var(--duration-fast) var(--ease-default),
|
|
opacity var(--duration-fast) var(--ease-default);
|
|
}
|
|
|
|
[data-navigation-menu-indicator][data-state='visible'] {
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
[data-navigation-menu-content],
|
|
[data-navigation-menu-indicator] {
|
|
animation: none !important;
|
|
transition: none !important;
|
|
}
|
|
}
|