Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

## [Unreleased]

### Added

- Navbar component snippet and classes.

### Changed

- All three default themes now have distinct status colors. Status UIs can now distinguish caution, error, info, and success states without token overrides.
- `dust` theme background lightened slightly to improve contrast ratios with the updated color palette.

### Breaking changes

- Tabs are now a formal tabset based on the `<button>`, not `<a>`. Tabs can now be used to display one panel of content at a time. Navigation responsibility belongs to the navbar.

## [0.2.0] - 2026-05-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Open `examples/playground.html` in a browser to see every component rendered tog

## Current Component Scope

Button, Input, Textarea, Checkbox, Radio Group, Select, Card, Alert, Badge, System Bar, Navigation Tabs, Toolbar, Query Row, Pagination, Empty State, Disclosure, Detail List, Status Label, Metrics, Meter, Data List, Data Table.
Button, Input, Textarea, Checkbox, Radio Group, Select, Card, Alert, Badge, System Bar, Navbar, Tabs, Toolbar, Query Row, Pagination, Empty State, Disclosure, Detail List, Status Label, Metrics, Meter, Data List, Data Table.

## Browser And Stability Notes

Expand Down
19 changes: 14 additions & 5 deletions docs/component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,21 @@ Use semantic HTML first. Add ARIA only when native semantics are not enough.
- Status values may use bracketed status text for terse terminal-style state.
- Keep system bar copy short enough to wrap cleanly on narrow screens.

## Navigation Tabs
## Navbar

- **Wrapper:** `.elum-tabs` on a `<nav>`
- **Tab link:** `.elum-tab` on an `<a>`
- **Current page:** `aria-current="page"`
- Use for page or section navigation. Do not use ARIA tab roles unless JavaScript provides the expected tab-panel keyboard behavior.
- **Wrapper:** `.elum-navbar` on a `<nav>`; sticks to the top of the viewport
- **Brand:** `.elum-navbar-brand` on the wordmark label
- **Links:** `.elum-navlinks` containing `.elum-navlink` anchors
- **Current page:** `aria-current="page"` on the active link
- Use for top-level page navigation. For switching views on one page, use tabs.

## Tabs

- **Tablist:** `.elum-tabs` with `role="tablist"` and an accessible name
- **Tab:** `.elum-tab` on a `<button role="tab">` with `aria-selected`, an `id`, and `aria-controls` for its panel
- **Panel:** `role="tabpanel"` with `aria-labelledby`; hide inactive panels with `hidden`
- **Roving focus:** active tab `tabindex="0"`, others `tabindex="-1"`
- Switches between views on one page. Requires JavaScript for panel switching and keyboard support; see `examples/playground.html`. For page navigation, use the navbar.

## Toolbar

Expand Down
110 changes: 86 additions & 24 deletions examples/playground.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,26 @@
</style>
</head>
<body>
<nav class="elum-navbar">
<div class="elum-navbar-brand">
Navigation
</div>

<section class="elum-navlinks">
<a class="elum-navlink" href="#" aria-current="page">home</a>
<a class="elum-navlink" href="#">devices</a>
<a class="elum-navlink" href="#">settings</a>
</section>
</nav>

<main class="elum-container elum-stack">
<header class="elum-card elum-card-labeled playground-header">
<div class="elum-card-header" data-inline="true">
<div class="elum-card-title">Component system · V0.<span style="text-transform: none">x</span></div>
<p class="elum-card-subtitle playground-title-status">
theme <span id="theme-status">[IRON]</span> components [22]
theme <span id="theme-status">[IRON]</span> components [23]
</p>
</div>
</div>
<div class="theme-controls" aria-label="Theme controls">
<button class="elum-button" id="theme-iron" type="button">Iron</button>
<button class="elum-button" id="theme-neon" type="button">Neon</button>
Expand Down Expand Up @@ -380,19 +392,21 @@ <h2 class="elum-card-title">Telemetry</h2>

<article class="elum-card elum-card-labeled elum-stack playground-tile playground-navigation-preview">
<header class="elum-card-header">
<h2 class="elum-card-title">Navigation Tabs</h2>
<h2 class="elum-card-title">Tabs</h2>
</header>
<p class="elum-card-subtitle">Page links with native current-page state.</p>
<p class="elum-card-subtitle">Switch between views on a page</p>

<nav class="elum-tabs" aria-label="Primary navigation example">
<a class="elum-tab" href="#" aria-current="page">Incidents</a>
<a class="elum-tab" href="#">Uptime</a>
</nav>
<div class="elum-tabs" role="tablist" aria-label="Service views">
<button class="elum-tab" id="tab-incidents" role="tab" aria-selected="true" aria-controls="panel-incidents" tabindex="0">Incidents</button>
<button class="elum-tab" id="tab-uptime" role="tab" aria-selected="false" aria-controls="panel-uptime" tabindex="-1">Uptime</button>
</div>

<nav class="elum-tabs" aria-label="Secondary navigation example">
<a class="elum-tab" href="#">Incidents</a>
<a class="elum-tab" href="#" aria-current="page">Uptime</a>
</nav>
<div id="panel-incidents" role="tabpanel" aria-labelledby="tab-incidents">
Incident content here.
</div>
<div id="panel-uptime" role="tabpanel" aria-labelledby="tab-uptime" hidden>
Uptime content here.
</div>
</article>

<article class="elum-card elum-card-labeled elum-stack playground-tile playground-details-preview">
Expand Down Expand Up @@ -543,28 +557,76 @@ <h2 class="elum-card-title">State Matrix</h2>
</main>

<script>
//Theme behavior
const root = document.documentElement;
const iron = document.getElementById("theme-iron");
const dust = document.getElementById("theme-dust");
const neon = document.getElementById("theme-neon");
const themeStatus = document.getElementById("theme-status");
const iron = document.getElementById("theme-iron")
const dust = document.getElementById("theme-dust")
const neon = document.getElementById("theme-neon")
const themeStatus = document.getElementById("theme-status")

function setTheme(theme, label) {
root.setAttribute("data-theme", theme);
themeStatus.textContent = label;
root.setAttribute("data-theme", theme)
themeStatus.textContent = label
}

iron.addEventListener("click", () => {
setTheme("iron", "[IRON]");
});
setTheme("iron", "[IRON]")
})

dust.addEventListener("click", () => {
setTheme("dust", "[DUST]");
});
setTheme("dust", "[DUST]")
})

neon.addEventListener("click", () => {
setTheme("neon", "[NEON]");
});
setTheme("neon", "[NEON]")
})

//Tab behavior — manual activation with roving tabindex (WAI-ARIA Tabs pattern)
document.querySelectorAll('.elum-tabs[role="tablist"]').forEach((tablist) => {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];

const setRoving = (tab) => {
tabs.forEach((t) => { t.tabIndex = t === tab ? 0 : -1; })
}

const activate = (tab) => {
setRoving(tab)
tabs.forEach((t) => {
const isActive = t === tab
t.setAttribute("aria-selected", String(isActive))
document.getElementById(t.getAttribute("aria-controls")).hidden = !isActive
})
}

tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activate(tab))
tab.addEventListener("keydown", (event) => {
const last = tabs.length - 1
let next;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = index === last ? 0 : index + 1
break
case "ArrowLeft":
case "ArrowUp":
next = index === 0 ? last : index - 1
break
case "Home":
next = 0
break
case "End":
next = last
break
default:
return
}
event.preventDefault()
setRoving(tabs[next])
tabs[next].focus()
});
});
})
</script>
</body>
</html>
65 changes: 65 additions & 0 deletions packages/core-css/src/components/navigation.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
.elum-navbar {
align-items: center;
justify-content: space-between;
display: flex;
overflow-x: auto;
overflow-y: hidden;
border-bottom: var(--elum-border-width) solid var(--elum-color-border);
padding: var(--elum-space-3) var(--elum-space-4);
margin-bottom: 1.5rem;
position: sticky;
top: 0;
background-color: var(--elum-color-bg);
z-index: 999;
}

.elum-navbar-brand {
align-items: center;
color: var(--elum-navbar-brand-color, var(--elum-color-accent));
display: inline-flex;
font-size: var(--elum-navbar-brand-size, var(--elum-text-sm));
font-weight: 700;
letter-spacing: 0.08em;
line-height: var(--elum-lh-tight);
padding: 0 var(--elum-space-2);
text-transform: uppercase;
}

.elum-navlinks {
align-items: center;
display: flex;
}

.elum-navlink {
display: inline-flex;
align-items: center;
color: var(--elum-navlink-color, var(--elum-color-accent));
text-decoration: none;
font-size: var(--elum-navlink-size, var(--elum-text-sm));
font-weight: 700;
letter-spacing: 0.08em;
line-height: var(--elum-lh-tight);
text-transform: uppercase;
}

.elum-navlink:not(:last-child)::after {
display: inline;
color: var(--elum-color-muted);
content: "|";
margin: 0 var(--elum-space-1);
font-weight: 500;
line-height: 0;
}

.elum-navlink:hover {
color: var(--elum-color-fg);
}

.elum-navlink:focus-visible {
outline: var(--elum-color-focus) solid var(--elum-border-width);
outline-offset: calc(var(--elum-border-width) * 2);
}

.elum-navlink[aria-current="page"] {
color: var(--elum-color-fg);
}
6 changes: 5 additions & 1 deletion packages/core-css/src/components/tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
}

.elum-tab {
appearance: none;
background: transparent;
border: 0;
border-bottom: var(--elum-border-width) solid var(--elum-color-border);
color: var(--elum-color-muted);
cursor: pointer;
display: inline-flex;
padding: var(--elum-space-1) var(--elum-space-2);
text-decoration: none;
Expand All @@ -29,7 +33,7 @@
outline-offset: calc(var(--elum-border-width) * -1);
}

.elum-tab[aria-current="page"] {
.elum-tab[aria-selected="true"] {
background: var(--elum-color-bg);
border: var(--elum-border-width) solid var(--elum-color-border);
border-bottom: 0;
Expand Down
1 change: 1 addition & 0 deletions packages/core-css/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
@import "./components/tabs.css";
@import "./components/toolbar.css";
@import "./components/query.css";
@import "./components/navigation.css";
20 changes: 16 additions & 4 deletions packages/core-patterns/snippets/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
<section class="elum-stack" aria-label="Project ElumKit snippets">
<nav class="elum-navbar">
<div class="elum-navbar-brand">Navigation</div>
<section class="elum-navlinks">
<a class="elum-navlink" href="#" aria-current="page">home</a>
<a class="elum-navlink" href="#">devices</a>
<a class="elum-navlink" href="#">settings</a>
</section>
</nav>

<article class="elum-card elum-card-labeled elum-stack">
<header class="elum-card-header">
<h2 class="elum-card-title">Card Title</h2>
Expand Down Expand Up @@ -180,10 +189,13 @@ <h2 class="elum-card-title">Card Title</h2>
</div>
</dl>

<nav class="elum-tabs" aria-label="Primary navigation">
<a class="elum-tab" href="#" aria-current="page">Incidents</a>
<a class="elum-tab" href="#">Uptime</a>
</nav>
<div class="elum-tabs" role="tablist" aria-label="Service views">
<button class="elum-tab" id="tab-incidents" role="tab" aria-selected="true" aria-controls="panel-incidents" tabindex="0">Incidents</button>
<button class="elum-tab" id="tab-uptime" role="tab" aria-selected="false" aria-controls="panel-uptime" tabindex="-1">Uptime</button>
</div>

<div id="panel-incidents" role="tabpanel" aria-labelledby="tab-incidents">Incident content here.</div>
<div id="panel-uptime" role="tabpanel" aria-labelledby="tab-uptime" hidden>Uptime content here.</div>

<div class="elum-toolbar" role="group" aria-label="Page actions">
<div class="elum-toolbar-group">
Expand Down
21 changes: 19 additions & 2 deletions tests/core-css-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const DATA_CSS = readFileSync("packages/core-css/src/components/data.css", "utf8
const TABS_CSS = readFileSync("packages/core-css/src/components/tabs.css", "utf8");
const TOOLBAR_CSS = readFileSync("packages/core-css/src/components/toolbar.css", "utf8");
const QUERY_CSS = readFileSync("packages/core-css/src/components/query.css", "utf8");
const NAVIGATION_CSS = readFileSync("packages/core-css/src/components/navigation.css", "utf8");
const INDEX_CSS = readFileSync("packages/core-css/src/index.css", "utf8");
const INDEX_CSS_PATH = "packages/core-css/src/index.css";
const PUBLIC_CSS = [
Expand All @@ -28,6 +29,7 @@ const PUBLIC_CSS = [
TABS_CSS,
TOOLBAR_CSS,
QUERY_CSS,
NAVIGATION_CSS,
].join("\n");

const THEMES = ["dust", "iron", "neon"];
Expand Down Expand Up @@ -72,7 +74,7 @@ function contrastRatio(firstColor, secondColor) {
}

test("core CSS entrypoint imports the public bundles", () => {
for (const bundle of ["tokens", "base", "button", "form", "card", "feedback", "telemetry", "data", "tabs", "toolbar", "query"]) {
for (const bundle of ["tokens", "base", "button", "form", "card", "feedback", "telemetry", "data", "tabs", "toolbar", "query", "navigation"]) {
assert.match(INDEX_CSS, new RegExp(`@import ".+${bundle}\\.css";`));
}
});
Expand Down Expand Up @@ -124,6 +126,10 @@ test("component CSS exposes current public class surfaces", () => {
"elum-badge",
"elum-status-label",
"elum-system-bar",
"elum-navbar",
"elum-navbar-brand",
"elum-navlinks",
"elum-navlink",
"elum-tabs",
"elum-tab",
"elum-toolbar",
Expand Down Expand Up @@ -209,5 +215,16 @@ test("table and row APIs expose responsive state hooks", () => {
test("tabs scroll horizontally and show focus", () => {
assert.match(TABS_CSS, /\.elum-tabs\s*{[^}]*overflow-x:\s*auto;/s);
assert.match(TABS_CSS, /\.elum-tab:focus-visible\s*{[^}]*outline:/s);
assert.doesNotMatch(TABS_CSS, /\.elum-tab\[aria-current="page"\]\s*{[^}]*box-shadow:/s);
assert.doesNotMatch(TABS_CSS, /\.elum-tab\[aria-selected="true"\]\s*{[^}]*box-shadow:/s);
});

test("tab control resets native button styling", () => {
assert.match(TABS_CSS, /\.elum-tab\s*{[^}]*appearance:\s*none;/s);
assert.match(TABS_CSS, /\.elum-tab\s*{[^}]*background:\s*transparent;/s);
});

test("navbar sticks to the top and navlinks show focus", () => {
assert.match(NAVIGATION_CSS, /\.elum-navbar\s*{[^}]*position:\s*sticky;/s);
assert.match(NAVIGATION_CSS, /\.elum-navlink:focus-visible\s*{[^}]*outline:/s);
assert.match(NAVIGATION_CSS, /\.elum-navlink\[aria-current="page"\]/);
});
3 changes: 2 additions & 1 deletion tests/docs-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ test("component docs cover the public component set", () => {
"Badge",
"Status Label",
"System Bar",
"Navigation Tabs",
"Navbar",
"Tabs",
"Toolbar",
"Query Row",
"Pagination",
Expand Down
Loading