Feat: Build out server shell
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selector {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 180ms var(--easing-standard),
|
||||
color 180ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.selectorOpen {
|
||||
.meta,
|
||||
.icon {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
.value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta,
|
||||
.icon {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.selector:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
|
||||
}
|
||||
|
||||
.value {
|
||||
@include text-title;
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-title);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 180ms var(--easing-standard), color 180ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.iconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
min-width: min(18rem, calc(100vw - (var(--space-4) * 2)));
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-muted);
|
||||
box-shadow: 0 16px 32px color-mix(in srgb, black 18%, transparent);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.menuSection {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.menuSectionLabel {
|
||||
@include text-caption;
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding-inline: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.menuDivider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 160ms var(--easing-standard),
|
||||
border-color 160ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.menuItemActive {
|
||||
border-color: var(--color-accent-soft);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.menuItemCopy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.menuItemValue {
|
||||
@include text-label;
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.menuItemMeta {
|
||||
@include text-caption;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submenuItem {
|
||||
min-width: 0;
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 160ms var(--easing-standard),
|
||||
border-color 160ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.submenuItem:hover {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.submenuItemActive {
|
||||
border-color: var(--color-accent-soft);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.submenuIndicator {
|
||||
width: 0.35rem;
|
||||
height: 0.35rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
@include respond-down(mobile) {
|
||||
.menu {
|
||||
min-width: min(16rem, calc(100vw - (var(--space-4) * 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { ChevronDown } from "../../../lib/icons";
|
||||
import { activeDepartment, departmentItems, type DepartmentItem } from "../data/shell.data";
|
||||
import styles from "./DepartmentSelector.module.scss";
|
||||
|
||||
const defaultDepartment = departmentItems.find((item) => item.id === activeDepartment.id) ?? departmentItems[0];
|
||||
const defaultTeamName = departmentItems
|
||||
.find((item) => item.id === activeDepartment.id)
|
||||
?.teams.find((teamName) => teamName === activeDepartment.teamName)
|
||||
?? defaultDepartment?.teams[0]
|
||||
?? "";
|
||||
|
||||
export const DepartmentSelector = (): JSX.Element => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment);
|
||||
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName);
|
||||
|
||||
let rootRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
if (!isOpen()) return;
|
||||
if (!rootRef) return;
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.contains(target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
|
||||
});
|
||||
|
||||
const selectDepartment = (item: DepartmentItem): void => {
|
||||
setSelectedDepartment(item);
|
||||
setSelectedTeamName(item.teams[0] ?? "");
|
||||
};
|
||||
|
||||
const selectTeam = (teamName: string): void => {
|
||||
setSelectedTeamName(teamName);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={styles.root} ref={rootRef}>
|
||||
<button
|
||||
classList={{ [styles.selector]: true, [styles.selectorOpen]: isOpen() }}
|
||||
type="button"
|
||||
aria-label="Select department"
|
||||
title="Select department"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen()}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<strong class={styles.value}>{selectedDepartment().name}</strong>
|
||||
<span class={styles.meta}>{selectedTeamName()} team</span>
|
||||
|
||||
<ChevronDown classList={{ [styles.icon]: true, [styles.iconOpen]: isOpen() }} size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{isOpen() ? (
|
||||
<div class={styles.menu} role="menu" aria-label="Department selector menu">
|
||||
<div class={styles.menuSection}>
|
||||
<span class={styles.menuSectionLabel}>Departments</span>
|
||||
|
||||
<For each={departmentItems}>
|
||||
{(item): JSX.Element => (
|
||||
<button
|
||||
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={item.id === selectedDepartment().id}
|
||||
onClick={() => selectDepartment(item)}
|
||||
>
|
||||
<div class={styles.menuItemCopy}>
|
||||
<strong class={styles.menuItemValue}>{item.name}</strong>
|
||||
<span class={styles.menuItemMeta}>{item.teams.length} teams</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={styles.menuDivider} aria-hidden="true" />
|
||||
|
||||
<div class={styles.menuSection}>
|
||||
<span class={styles.menuSectionLabel}>Teams in {selectedDepartment().name}</span>
|
||||
|
||||
<For each={selectedDepartment().teams}>
|
||||
{(teamName): JSX.Element => (
|
||||
<button
|
||||
classList={{ [styles.submenuItem]: true, [styles.submenuItemActive]: teamName === selectedTeamName() }}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={teamName === selectedTeamName()}
|
||||
onClick={() => selectTeam(teamName)}
|
||||
>
|
||||
<span class={styles.submenuIndicator} aria-hidden="true" />
|
||||
<div class={styles.menuItemCopy}>
|
||||
<strong class={styles.menuItemValue}>{teamName}</strong>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user