Add Glaze material, pickers, slider and design guidelines

Glaze is glass with a dial: --nv-glass-frost (0 clear .. 1 heavy) moves
backdrop blur, tint and saturation together, read where the surface is
drawn so any subtree or slider can change it. A masked rim and a specular
bloom follow the pointer via niveus.js; without it the light sits at its
default and the material still reads. Falls back to the elevated solid
under reduced transparency or without backdrop-filter.

New components: .nv-slider (binds to a token with data-nv-var),
.nv-calendar, .nv-timepicker (24-hour by default, optional 24H / AM/PM
switch, value always HH:MM), and .nv-picker with .nv-menu--listbox (search,
type-ahead, keys). All render without niveus.js except where noted.

GUIDELINES.md is the normative rulebook for people and AI agents.

check.py, build.py and build.py --check pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-28 09:24:24 +01:00
commit 369d1cd8f7
17 changed files with 2679 additions and 6 deletions

View file

@ -0,0 +1,131 @@
/* ==========================================================================
Niveus — Calendar
--------------------------------------------------------------------------
<div class="nv-calendar nv-surface" data-nv-calendar data-nv-value="2026-10-28">
<div class="nv-calendar__header">
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="-1" aria-label="Previous month">‹</button>
<h3 class="nv-calendar__title" aria-live="polite">October 2026</h3>
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="1" aria-label="Next month">›</button>
</div>
<div class="nv-calendar__grid" role="grid" aria-labelledby="…">
<div role="row"> <span class="nv-calendar__weekday" role="columnheader">Mo</span> … </div>
<div role="row">
<button class="nv-calendar__day" role="gridcell" data-date="2026-10-01">1</button> …
</div>
</div>
</div>
A calendar holds content, so it takes no material of its own: put it on
.nv-surface, or on .nv-material-glaze when it floats over something. The
grid and its states are plain markup and render without niveus.js; the
script's only jobs are to build the days for a month, step between months,
and keep the keyboard model (arrows, PageUp/PageDown, Home/End).
State lives in attributes the platform already has a meaning for:
aria-selected="true" the chosen day
aria-current="date" today
data-outside a day that belongs to the neighbouring month
disabled outside data-nv-min / data-nv-max
========================================================================== */
.nv-calendar {
--_cell: 2.5rem;
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
inline-size: fit-content;
max-inline-size: 100%;
padding: var(--nv-space-4);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
}
.nv-calendar__title {
margin: 0;
font: var(--nv-font-label);
font-size: var(--nv-font-size-md);
font-weight: var(--nv-font-weight-semibold);
}
.nv-calendar__grid {
display: grid;
grid-template-columns: repeat(7, var(--_cell));
gap: var(--nv-space-0-5) 0;
}
.nv-calendar__grid > [role="row"] {
display: contents;
}
.nv-calendar__weekday {
display: grid;
place-items: center;
block-size: var(--nv-space-8);
color: var(--nv-color-text-tertiary);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
}
.nv-calendar__day {
inline-size: var(--_cell);
block-size: var(--_cell);
padding: 0;
background-color: transparent;
border: 0;
border-radius: var(--nv-radius-full);
color: var(--nv-color-text-primary);
font: var(--nv-font-label);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: var(--nv-transition-colors), var(--nv-transition-depth);
}
@media (hover: hover) {
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
background-color: var(--nv-color-surface-hover);
}
}
.nv-calendar__day[data-outside] {
color: var(--nv-color-text-tertiary);
}
/* Today is ringed, not filled: filled means chosen. */
.nv-calendar__day[aria-current="date"] {
color: var(--nv-color-accent-text);
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent);
}
/* The chosen day is a small raised key, lit like the filled buttons. */
.nv-calendar__day[aria-selected="true"] {
background-color: var(--nv-color-accent);
background-image: var(--nv-sheen-filled);
color: var(--nv-color-on-accent);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
}
.nv-calendar__day:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
.nv-calendar__day[aria-selected="true"]:focus-visible {
box-shadow: var(--nv-bevel), var(--nv-focus-ring);
}
.nv-calendar__day:disabled {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}

View file

@ -142,7 +142,8 @@
cursor: not-allowed;
}
.nv-menu__item[aria-checked="true"]::before {
.nv-menu__item[aria-checked="true"]::before,
.nv-menu__item[aria-selected="true"]::before {
content: "";
inline-size: 0.28em;
block-size: 0.55em;

View file

@ -0,0 +1,69 @@
/* ==========================================================================
Niveus — Picker
--------------------------------------------------------------------------
<button class="nv-input nv-picker nv-menu-trigger" popovertarget="fruit"
style="--nv-anchor: --fruit">
<span data-nv-picker-label>Choose a fruit</span>
</button>
<div class="nv-menu nv-menu--listbox" popover id="fruit" role="listbox"
style="--nv-anchor: --fruit">
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…">
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
<button class="nv-menu__item" role="option" data-value="pear" aria-selected="true">Pear</button>
</div>
A popup list is a menu whose items are options: the same frosted surface,
anchoring and light dismiss, with a selection that stays. A picker is its
trigger: a field-shaped button that shows the current choice and a chevron
that turns over while the list is open.
niveus.js adds what a native <select> would give for free and a styled
popover does not: arrow-key movement, type-ahead, Enter or Space to choose,
filtering when a .nv-menu__search is present, and an `nv:select` event on the
trigger carrying { value, label }.
========================================================================== */
.nv-picker {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
text-align: start;
cursor: pointer;
}
.nv-picker[data-empty] {
color: var(--nv-color-text-tertiary);
}
.nv-picker::after {
content: "";
flex: none;
inline-size: 0.4em;
block-size: 0.4em;
translate: 0 -0.12em;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-text-tertiary);
border-block-end: 2px solid var(--nv-color-text-tertiary);
transition: rotate var(--nv-duration-quick) var(--nv-ease-standard);
}
.nv-picker:has(+ .nv-menu:popover-open)::after {
rotate: 225deg;
translate: 0 0.12em;
}
.nv-menu--listbox {
min-inline-size: anchor-size(inline);
}
.nv-menu__search {
inline-size: 100%;
margin-block-end: var(--nv-space-1);
}
/* display on .nv-menu__item would otherwise win over the hidden attribute. */
.nv-menu__item[hidden] {
display: none;
}

View file

@ -0,0 +1,109 @@
/* ==========================================================================
Niveus — Slider
--------------------------------------------------------------------------
<input class="nv-slider" type="range" min="0" max="100" value="50">
A native range input dressed as the rest of the controls are: the track is a
well cut into the surface, the fill is the accent seen inside it, and the
thumb is a knob lit from above. Keyboard, touch, step and form behaviour are
the browser's.
Firefox draws the fill itself (::-moz-range-progress). Chromium and Safari
do not expose one, so the fill is a gradient sized by --nv-slider-pos, which
niveus.js keeps in step with the value. Without the script the fill is
simply absent in those two engines and the thumb still tells the truth.
To bind a slider to a design token, add data-nv-var="--nv-glass-frost" (and
optionally data-nv-var-target="#region" for a subtree instead of :root).
========================================================================== */
.nv-slider {
--_track: 0.5rem;
--_thumb: 1.5rem;
--_pos: var(--nv-slider-pos, 50%);
display: block;
inline-size: 100%;
block-size: var(--_thumb);
margin: 0;
background: transparent;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.nv-slider:focus-visible {
outline: none;
}
/* --- Track ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-runnable-track {
block-size: var(--_track);
background:
linear-gradient(var(--nv-color-accent) 0 0) 0 / var(--_pos) 100% no-repeat,
var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
}
.nv-slider::-moz-range-track {
block-size: var(--_track);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
}
.nv-slider::-moz-range-progress {
block-size: var(--_track);
background-color: var(--nv-color-accent);
border-radius: var(--nv-radius-full);
}
/* --- Thumb ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
margin-block-start: calc((var(--_track) - var(--_thumb)) / 2);
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
appearance: none;
-webkit-appearance: none;
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
}
.nv-slider::-moz-range-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
box-sizing: border-box;
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
}
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
.nv-slider:focus-visible::-webkit-slider-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:focus-visible::-moz-range-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:disabled {
cursor: not-allowed;
opacity: 0.55;
}

View file

@ -61,6 +61,105 @@
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Glaze ------------------------------------------------------------------
Glass with a dial, and a rim that catches the light.
.nv-material-glaze the material
--nv-glass-frost: 0 … 1 clear glass … heavy frost (set anywhere)
Frost is read here, where the surface is drawn, not copied into a token
upstream: that is what lets a subtree (or a slider the visitor holds) change
it and have every surface inside follow. It moves three things together —
how far the backdrop is blurred, how much of the tint is laid over it, and
how much color the blur gives back — because a pane that changed only one
of them would stop looking like the same pane.
The rim is a 1.5px gradient border made with a masked ::before: bright on
the side facing the light, faint on the far side. ::after is the specular
bloom, a soft glow under the pointer. niveus.js sets --nv-light-x, -y and
-angle on the surface; without it both sit at their defaults (light from the
top left), so the material still reads as lit, just not as following you.
Zero specificity on position: a menu or dialog using it keeps the positioning
its own component gave it. */
:where(.nv-material-glaze) {
position: relative;
}
.nv-material-glaze {
--_frost: var(--nv-glass-frost);
isolation: isolate;
background-color: color-mix(
in oklab,
var(--nv-color-material-glaze)
calc(var(--nv-glaze-tint-min) + (var(--nv-glaze-tint-max) - var(--nv-glaze-tint-min)) * var(--_frost)),
transparent
);
border: var(--nv-border-width) solid transparent;
box-shadow:
var(--nv-highlight-top-strong),
inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular),
var(--nv-outline),
var(--nv-elevation-4);
backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
-webkit-backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
}
.nv-material-glaze::before,
.nv-material-glaze::after {
content: "";
position: absolute;
inset: calc(var(--nv-border-width) * -1);
border-radius: inherit;
pointer-events: none;
}
.nv-material-glaze::before {
padding: var(--nv-glaze-rim);
background: linear-gradient(
var(--nv-light-angle, 135deg),
var(--nv-color-glaze-rim-lit) 0%,
transparent 38%,
transparent 62%,
var(--nv-color-glaze-rim-far) 100%
);
mask:
linear-gradient(currentcolor 0 0) content-box,
linear-gradient(currentcolor 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.nv-material-glaze::after {
z-index: -1;
background: radial-gradient(
circle 16rem at var(--nv-light-x, 15%) var(--nv-light-y, 0%),
var(--nv-color-glaze-specular),
transparent 70%
);
}
/* Where translucency cannot be honoured, glaze falls back to the elevated
solid, as the other glass materials do. */
@media (prefers-reduced-transparency: reduce) {
.nv-material-glaze {
background-color: var(--nv-color-surface-elevated);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-material-glaze::after { display: none; }
}
@supports not (backdrop-filter: blur(1px)) {
.nv-material-glaze { background-color: var(--nv-color-surface-elevated); }
.nv-material-glaze::after { display: none; }
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,

View file

@ -0,0 +1,128 @@
/* ==========================================================================
Niveus — Time picker
--------------------------------------------------------------------------
<div class="nv-timepicker nv-surface" data-nv-timepicker
data-nv-value="09:30" data-nv-step="5" data-nv-format-toggle></div>
Wheels, as on a phone: each column scrolls and snaps, and whatever sits in
the lens across the middle is the value. Scrolling, snapping, momentum and
touch are the browser's; niveus.js builds the columns, reads which row is
centred, and exposes the value as data-nv-value and an `nv:time` event.
The lens is the selection colour laid over the centre row, so it follows the
brand. Rows fade towards the top and bottom with a mask rather than a
coloured gradient, which keeps the fade right on any surface — solid, or
Glaze with something moving behind it.
The format is 24-hour unless told otherwise. With data-nv-format-toggle a
24H / AM/PM switch sits under the wheels and adds an AM/PM wheel when chosen;
the value stays 24-hour either way, so a form never has to know which the
person used.
Without niveus.js the element is empty. For a no-script fallback, use a
native <input type="time" class="nv-input">.
========================================================================== */
.nv-timepicker {
--_row: 2.25rem;
--_rows: 5;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-timepicker__wheels {
position: relative;
display: flex;
gap: var(--nv-space-1);
}
/* The lens. It belongs to the wheels, not the picker, so the switch beneath
them never moves the centre. */
.nv-timepicker__wheels::before {
content: "";
position: absolute;
inset-inline: calc(var(--nv-space-2) * -1);
inset-block-start: 50%;
block-size: var(--_row);
translate: 0 -50%;
background-color: var(--nv-color-selection);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top);
pointer-events: none;
}
.nv-timepicker__column {
position: relative;
inline-size: 3.5rem;
block-size: calc(var(--_row) * var(--_rows));
margin: 0;
padding: calc(var(--_row) * (var(--_rows) - 1) / 2) 0;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
mask-image: linear-gradient(
transparent,
currentcolor 28%,
currentcolor 72%,
transparent
);
}
.nv-timepicker__column::-webkit-scrollbar {
display: none;
}
.nv-timepicker__column:focus-visible {
outline: none;
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-focus-ring);
}
.nv-timepicker__option {
display: grid;
place-items: center;
block-size: var(--_row);
scroll-snap-align: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: var(--nv-transition-colors);
}
.nv-timepicker__option[aria-selected="true"] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-semibold);
}
/* 24H / AM/PM. A button group under the wheels; the pressed one is the format
in use. 24-hour is the default, so it starts pressed. */
.nv-timepicker__format {
align-self: center;
}
/* Pressed reads as pushed in: the format in use is a key held down. */
.nv-timepicker__format .nv-button[aria-pressed="true"] {
background-color: var(--nv-color-accent-subtle);
background-image: none;
color: var(--nv-color-accent-text);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
}
.nv-timepicker__separator {
display: grid;
place-items: center;
align-self: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
}

View file

@ -48,10 +48,14 @@
@import url("components/surface.css") layer(niveus.components);
@import url("components/button.css") layer(niveus.components);
@import url("components/input.css") layer(niveus.components);
@import url("components/slider.css") layer(niveus.components);
@import url("components/card.css") layer(niveus.components);
@import url("components/dialog.css") layer(niveus.components);
@import url("components/navigation.css") layer(niveus.components);
@import url("components/menu.css") layer(niveus.components);
@import url("components/picker.css") layer(niveus.components);
@import url("components/calendar.css") layer(niveus.components);
@import url("components/timepicker.css") layer(niveus.components);
@import url("components/disclosure.css") layer(niveus.components);
@import url("components/scroller.css") layer(niveus.components);
@import url("components/bed.css") layer(niveus.components);

View file

@ -355,6 +355,628 @@
surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px");
}
/**
* The light on a .nv-material-glaze follows the pointer. One listener for
* the document and one write per frame, so a page of glass costs the same as
* a page of one pane. Each surface is told where the pointer is relative to
* itself (--nv-light-x/-y) and which way it lies (--nv-light-angle, the CSS
* gradient angle that puts the bright end of the rim on that side).
* Touch and reduced motion leave the default light from the top left.
*/
var lightFrame = 0;
var lightX = 0;
var lightY = 0;
function applyLight() {
lightFrame = 0;
var surfaces = document.querySelectorAll(".nv-material-glaze");
for (var i = 0; i < surfaces.length; i++) {
var surface = surfaces[i];
var box = surface.getBoundingClientRect();
if (box.bottom < 0 || box.top > global.innerHeight) continue;
var x = lightX - box.left;
var y = lightY - box.top;
var angle = Math.atan2(box.left + box.width / 2 - lightX, lightY - (box.top + box.height / 2));
surface.style.setProperty("--nv-light-x", x.toFixed(1) + "px");
surface.style.setProperty("--nv-light-y", y.toFixed(1) + "px");
surface.style.setProperty("--nv-light-angle", (angle * 180 / Math.PI).toFixed(1) + "deg");
}
}
function trackLight(event) {
if (event.pointerType && event.pointerType === "touch") return;
if (prefersReducedMotion()) return;
lightX = event.clientX;
lightY = event.clientY;
if (!lightFrame) lightFrame = global.requestAnimationFrame(applyLight);
}
/**
* A .nv-slider keeps its fill in step with its value, and, with data-nv-var,
* writes that value into a CSS custom property: on :root by default, or on
* the element data-nv-var-target selects. The property is the source of
* truth, so the slider starts where the stylesheet already is.
*/
function initSlider(slider) {
if (slider.dataset.nvSliderWired) return;
slider.dataset.nvSliderWired = "";
var name = slider.dataset.nvVar;
var target = document.documentElement;
if (name && slider.dataset.nvVarTarget) {
target = document.querySelector(slider.dataset.nvVarTarget) || target;
}
function fill() {
var min = parseFloat(slider.min || "0");
var max = parseFloat(slider.max || "100");
var fraction = max > min ? (parseFloat(slider.value) - min) / (max - min) : 0;
// The fill ends under the middle of the thumb, which travels one thumb
// width less than the track.
slider.style.setProperty(
"--nv-slider-pos",
"calc(" + (fraction * 100).toFixed(2) + "% + " + (0.5 - fraction).toFixed(3) + " * var(--_thumb))"
);
}
if (name) {
var current = parseFloat(getComputedStyle(target).getPropertyValue(name));
if (!isNaN(current)) slider.value = current;
}
fill();
slider.addEventListener("input", function () {
fill();
if (name) target.style.setProperty(name, slider.value);
});
}
/* ------------------------------------------------------------------------
Calendar
------------------------------------------------------------------------ */
function pad(n) {
return (n < 10 ? "0" : "") + n;
}
function isoDate(date) {
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
}
function parseDate(text) {
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text || "");
return match ? new Date(+match[1], +match[2] - 1, +match[3]) : null;
}
function weekStart(locale) {
try {
var info = new Intl.Locale(locale || navigator.language);
var week = info.getWeekInfo ? info.getWeekInfo() : info.weekInfo;
if (week && week.firstDay) return week.firstDay % 7;
} catch (error) { /* fall through to Monday */ }
return 1;
}
/**
* Builds and drives one .nv-calendar. Configuration is data-*: nv-value
* (YYYY-MM-DD), nv-min, nv-max, nv-locale, nv-week-start (0 = Sunday).
* Choosing a day sets data-nv-value and fires `nv:date` with
* { value, date }. Stepping months travels as a view transition.
*/
function initCalendar(calendar) {
if (calendar.dataset.nvCalendarWired) return;
calendar.dataset.nvCalendarWired = "";
var locale = calendar.dataset.nvLocale || document.documentElement.lang || undefined;
var now = new Date();
var today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var selected = parseDate(calendar.dataset.nvValue);
var min = parseDate(calendar.dataset.nvMin);
var max = parseDate(calendar.dataset.nvMax);
var first = calendar.dataset.nvWeekStart !== undefined
? parseInt(calendar.dataset.nvWeekStart, 10) % 7
: weekStart(locale);
var active = selected || today;
var view = new Date(active.getFullYear(), active.getMonth(), 1);
var title = calendar.querySelector(".nv-calendar__title");
var grid = calendar.querySelector(".nv-calendar__grid");
if (!grid) {
grid = document.createElement("div");
grid.className = "nv-calendar__grid";
grid.setAttribute("role", "grid");
calendar.appendChild(grid);
}
if (title) {
if (!title.id) title.id = "nv-calendar-" + Math.random().toString(36).slice(2, 8);
grid.setAttribute("aria-labelledby", title.id);
}
function render() {
if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" });
grid.textContent = "";
var head = document.createElement("div");
head.setAttribute("role", "row");
for (var d = 0; d < 7; d++) {
var name = document.createElement("span");
name.className = "nv-calendar__weekday";
name.setAttribute("role", "columnheader");
// 2023-01-01 was a Sunday.
name.textContent = new Date(2023, 0, 1 + ((first + d) % 7))
.toLocaleDateString(locale, { weekday: "short" });
head.appendChild(name);
}
grid.appendChild(head);
var offset = (view.getDay() - first + 7) % 7;
var cursor = new Date(view.getFullYear(), view.getMonth(), 1 - offset);
var activeIso = isoDate(active);
var selectedIso = selected ? isoDate(selected) : "";
var todayIso = isoDate(today);
var row;
// Six weeks every month, so the calendar never changes height.
for (var i = 0; i < 42; i++) {
if (i % 7 === 0) {
row = document.createElement("div");
row.setAttribute("role", "row");
grid.appendChild(row);
}
var iso = isoDate(cursor);
var day = document.createElement("button");
day.type = "button";
day.className = "nv-calendar__day";
day.setAttribute("role", "gridcell");
day.dataset.date = iso;
day.textContent = cursor.getDate();
day.setAttribute("aria-label", cursor.toLocaleDateString(locale, { dateStyle: "full" }));
day.tabIndex = iso === activeIso ? 0 : -1;
if (cursor.getMonth() !== view.getMonth()) day.dataset.outside = "";
if (iso === todayIso) day.setAttribute("aria-current", "date");
if (iso === selectedIso) day.setAttribute("aria-selected", "true");
if ((min && cursor < min) || (max && cursor > max)) day.disabled = true;
row.appendChild(day);
cursor.setDate(cursor.getDate() + 1);
}
// The active day may sit outside this month's grid after a step.
if (!grid.querySelector('[tabindex="0"]')) {
var anchor = grid.querySelector(".nv-calendar__day:not([data-outside]):not(:disabled)");
if (anchor) anchor.tabIndex = 0;
}
}
function focusActive() {
var button = grid.querySelector('[data-date="' + isoDate(active) + '"]');
if (button) button.focus();
}
function show(date, direction, focus) {
var monthChanged = date.getFullYear() !== view.getFullYear() || date.getMonth() !== view.getMonth();
active = date;
var update = function () {
view = new Date(date.getFullYear(), date.getMonth(), 1);
render();
if (focus) focusActive();
};
if (monthChanged) transition(update, { direction: direction });
else update();
}
function stepMonth(count) {
var target = new Date(view.getFullYear(), view.getMonth() + count, 1);
var last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
target.setDate(Math.min(active.getDate(), last));
show(target, count > 0 ? "forward" : "backward", false);
}
function choose(button) {
selected = parseDate(button.dataset.date);
active = selected;
calendar.dataset.nvValue = button.dataset.date;
if (button.dataset.outside !== undefined) {
show(selected, selected > view ? "forward" : "backward", true);
} else {
grid.querySelectorAll('[aria-selected="true"]').forEach(function (other) {
other.removeAttribute("aria-selected");
});
button.setAttribute("aria-selected", "true");
}
calendar.dispatchEvent(new CustomEvent("nv:date", {
bubbles: true,
detail: { value: button.dataset.date, date: selected },
}));
}
calendar.addEventListener("click", function (event) {
var step = event.target.closest("[data-nv-calendar-step]");
if (step && calendar.contains(step)) {
stepMonth(parseInt(step.dataset.nvCalendarStep, 10));
return;
}
var day = event.target.closest(".nv-calendar__day");
if (day && !day.disabled) choose(day);
});
grid.addEventListener("keydown", function (event) {
var day = event.target.closest(".nv-calendar__day");
if (!day) return;
var rtl = getComputedStyle(calendar).direction === "rtl";
var from = parseDate(day.dataset.date);
var to = new Date(from.getFullYear(), from.getMonth(), from.getDate());
switch (event.key) {
case "ArrowLeft": to.setDate(to.getDate() + (rtl ? 1 : -1)); break;
case "ArrowRight": to.setDate(to.getDate() + (rtl ? -1 : 1)); break;
case "ArrowUp": to.setDate(to.getDate() - 7); break;
case "ArrowDown": to.setDate(to.getDate() + 7); break;
case "Home": to.setDate(to.getDate() - ((to.getDay() - first + 7) % 7)); break;
case "End": to.setDate(to.getDate() + 6 - ((to.getDay() - first + 7) % 7)); break;
case "PageUp": to.setMonth(to.getMonth() - (event.shiftKey ? 12 : 1)); break;
case "PageDown": to.setMonth(to.getMonth() + (event.shiftKey ? 12 : 1)); break;
default: return;
}
event.preventDefault();
if ((min && to < min) || (max && to > max)) return;
show(to, to > from ? "forward" : "backward", true);
});
render();
}
/* ------------------------------------------------------------------------
Time picker
------------------------------------------------------------------------ */
/**
* Builds and drives one .nv-timepicker from data-*: nv-value (HH:MM, 24h),
* nv-hours ("24", the default, or "12" for an AM/PM wheel), nv-step (minute
* interval), nv-name (adds a hidden input for forms) and nv-format-toggle
* (adds a 24H / AM/PM switch under the wheels). The value is always 24-hour,
* whatever the wheels show. Fires `nv:time` with { value } as the wheels
* settle on a new row.
*/
function initTimePicker(picker) {
if (picker.dataset.nvTimeWired) return;
picker.dataset.nvTimeWired = "";
var twelve = picker.dataset.nvHours === "12";
var step = Math.max(1, parseInt(picker.dataset.nvStep || "1", 10));
var now = new Date();
var match = /^(\d{1,2}):(\d{2})$/.exec(picker.dataset.nvValue || "");
var h24 = match ? parseInt(match[1], 10) % 24 : now.getHours();
var minute = match ? parseInt(match[2], 10) % 60 : now.getMinutes();
var minuteItems = [];
for (var m = 0; m < 60; m += step) minuteItems.push(pad(m));
var wheels = document.createElement("div");
wheels.className = "nv-timepicker__wheels";
var hourColumn, minuteColumn, periodColumn;
var field = null;
var formatButtons = [];
var placed = false;
var last = "";
var frame = 0;
function indexOf(column) {
var row = column.firstElementChild ? column.firstElementChild.offsetHeight : 0;
if (!row) return -1;
return Math.max(0, Math.min(column.children.length - 1, Math.round(column.scrollTop / row)));
}
function scrollTo(column, index, smooth) {
var row = column.firstElementChild.offsetHeight;
column.scrollTo({ top: index * row, behavior: smooth && !prefersReducedMotion() ? "smooth" : "auto" });
}
function mark(column, index) {
for (var i = 0; i < column.children.length; i++) {
column.children[i].setAttribute("aria-selected", i === index ? "true" : "false");
}
}
function read() {
var hi = indexOf(hourColumn);
var mi = indexOf(minuteColumn);
if (hi < 0 || mi < 0) return;
var pm = periodColumn ? indexOf(periodColumn) === 1 : false;
mark(hourColumn, hi);
mark(minuteColumn, mi);
if (periodColumn) mark(periodColumn, pm ? 1 : 0);
var hour = twelve ? ((hi + 1) % 12) + (pm ? 12 : 0) : hi;
var value = pad(hour) + ":" + minuteItems[mi];
if (value === last) return;
last = value;
// place() replays these when the picker is shown again, so they must
// follow the wheels rather than stay at what it was built with.
h24 = hour;
minute = parseInt(minuteItems[mi], 10);
picker.dataset.nvValue = value;
if (field) field.value = value;
picker.dispatchEvent(new CustomEvent("nv:time", { bubbles: true, detail: { value: value } }));
}
// Puts the wheels where the value says. Needs layout, so it runs when the
// picker becomes visible, not when it is built (it may sit in a closed
// popover).
function place() {
if (!hourColumn.firstElementChild || !hourColumn.firstElementChild.offsetHeight) return;
placed = true;
var hi = twelve ? ((h24 % 12) || 12) - 1 : h24;
var mi = Math.round(minute / step) % minuteItems.length;
scrollTo(hourColumn, hi, false);
scrollTo(minuteColumn, mi, false);
if (periodColumn) scrollTo(periodColumn, h24 >= 12 ? 1 : 0, false);
read();
}
function buildColumn(label, items) {
var list = document.createElement("ul");
list.className = "nv-timepicker__column";
list.setAttribute("role", "listbox");
list.setAttribute("aria-label", label);
list.tabIndex = 0;
items.forEach(function (text) {
var option = document.createElement("li");
option.className = "nv-timepicker__option";
option.setAttribute("role", "option");
option.textContent = text;
list.appendChild(option);
});
list.addEventListener("scroll", function () {
if (frame) return;
frame = global.requestAnimationFrame(function () { frame = 0; read(); });
}, { passive: true });
list.addEventListener("click", function (event) {
var option = event.target.closest(".nv-timepicker__option");
if (option) scrollTo(list, Array.prototype.indexOf.call(list.children, option), true);
});
list.addEventListener("keydown", function (event) {
var index = indexOf(list);
var end = list.children.length - 1;
var to;
switch (event.key) {
case "ArrowUp": to = index - 1; break;
case "ArrowDown": to = index + 1; break;
case "PageUp": to = index - 5; break;
case "PageDown": to = index + 5; break;
case "Home": to = 0; break;
case "End": to = end; break;
default: return;
}
event.preventDefault();
scrollTo(list, Math.max(0, Math.min(end, to)), true);
});
return list;
}
function colon() {
var mark = document.createElement("span");
mark.className = "nv-timepicker__separator";
mark.setAttribute("aria-hidden", "true");
mark.textContent = ":";
return mark;
}
// The wheels depend on the format, so a change of format rebuilds them and
// lands on the same moment in the new layout.
function build() {
var hourItems = [];
var count = twelve ? 12 : 24;
for (var h = 0; h < count; h++) hourItems.push(twelve ? String(h + 1) : pad(h));
wheels.textContent = "";
hourColumn = buildColumn("Hours", hourItems);
minuteColumn = buildColumn("Minutes", minuteItems);
periodColumn = twelve ? buildColumn("AM or PM", ["AM", "PM"]) : null;
wheels.appendChild(hourColumn);
wheels.appendChild(colon());
wheels.appendChild(minuteColumn);
if (periodColumn) wheels.appendChild(periodColumn);
formatButtons.forEach(function (button) {
button.setAttribute("aria-pressed", String((button.dataset.hours === "12") === twelve));
});
placed = false;
place();
}
picker.textContent = "";
picker.setAttribute("role", "group");
if (!picker.hasAttribute("aria-label") && !picker.hasAttribute("aria-labelledby")) {
picker.setAttribute("aria-label", "Time");
}
picker.appendChild(wheels);
if (picker.hasAttribute("data-nv-format-toggle")) {
var format = document.createElement("div");
format.className = "nv-timepicker__format nv-button-group";
format.setAttribute("role", "group");
format.setAttribute("aria-label", "Time format");
[["24", "24H"], ["12", "AM/PM"]].forEach(function (option) {
var button = document.createElement("button");
button.type = "button";
button.className = "nv-button nv-button--sm";
button.dataset.hours = option[0];
button.textContent = option[1];
button.addEventListener("click", function () {
var wants = option[0] === "12";
if (wants === twelve) return;
twelve = wants;
picker.dataset.nvHours = option[0];
build();
});
formatButtons.push(button);
format.appendChild(button);
});
picker.appendChild(format);
}
if (picker.dataset.nvName) {
field = document.createElement("input");
field.type = "hidden";
field.name = picker.dataset.nvName;
picker.appendChild(field);
}
build();
if (typeof global.IntersectionObserver === "function") {
// A hidden element loses its scroll offsets, so the wheels are put back
// each time the picker reappears — and only then, never while it is on
// screen and being turned.
var seen = new global.IntersectionObserver(function (entries) {
var visible = entries.some(function (entry) { return entry.isIntersecting; });
if (!visible) placed = false;
else if (!placed) place();
});
seen.observe(picker);
}
}
/* ------------------------------------------------------------------------
Popup list
------------------------------------------------------------------------ */
/**
* Gives a .nv-menu--listbox the behaviour of a native select: arrows,
* Home/End, type-ahead, Enter/Space to choose, and filtering when it holds a
* .nv-menu__search. The trigger is the element whose popovertarget is the
* list's id; it receives `nv:select` with { value, label } and keeps the
* choice in data-nv-value.
*/
function initListbox(list) {
if (list.dataset.nvListWired || !list.id) return;
list.dataset.nvListWired = "";
var trigger = document.querySelector('[popovertarget="' + list.id + '"]');
var label = trigger && (trigger.querySelector("[data-nv-picker-label]") || trigger);
var search = list.querySelector(".nv-menu__search");
var buffer = "";
var bufferTimer = 0;
function options() {
return Array.prototype.filter.call(list.querySelectorAll('[role="option"]'), function (option) {
return !option.hidden && !option.disabled && option.getAttribute("aria-disabled") !== "true";
});
}
function choose(option) {
list.querySelectorAll('[role="option"]').forEach(function (other) {
other.setAttribute("aria-selected", other === option ? "true" : "false");
});
var text = option.dataset.label || option.textContent.trim();
var value = option.dataset.value !== undefined ? option.dataset.value : text;
if (trigger) {
if (label) label.textContent = text;
trigger.removeAttribute("data-empty");
trigger.dataset.nvValue = value;
trigger.dispatchEvent(new CustomEvent("nv:select", {
bubbles: true,
detail: { value: value, label: text },
}));
}
list.hidePopover();
if (trigger) trigger.focus();
}
function focusAt(items, index) {
if (!items.length) return;
items[(index + items.length) % items.length].focus();
}
function filter() {
var query = search.value.trim().toLowerCase();
list.querySelectorAll('[role="option"]').forEach(function (option) {
option.hidden = query !== "" && option.textContent.toLowerCase().indexOf(query) === -1;
});
}
list.setAttribute("role", "listbox");
if (trigger) {
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
trigger.addEventListener("keydown", function (event) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
list.showPopover();
}
});
}
list.addEventListener("toggle", function (event) {
var open = event.newState === "open";
if (trigger) trigger.setAttribute("aria-expanded", open ? "true" : "false");
if (!open) return;
if (search) {
search.value = "";
filter();
search.focus();
} else {
var items = options();
var current = list.querySelector('[aria-selected="true"]');
(current && !current.hidden ? current : items[0] || list).focus();
}
});
list.addEventListener("click", function (event) {
var option = event.target.closest('[role="option"]');
if (option && list.contains(option) && !option.disabled) choose(option);
});
if (search) search.addEventListener("input", filter);
list.addEventListener("keydown", function (event) {
var items = options();
var index = items.indexOf(document.activeElement);
var typing = event.target === search;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(items, typing ? 0 : index + 1);
return;
case "ArrowUp":
event.preventDefault();
if (typing) return;
if (index === 0 && search) search.focus();
else focusAt(items, index - 1);
return;
case "Home":
if (typing) return;
event.preventDefault();
focusAt(items, 0);
return;
case "End":
if (typing) return;
event.preventDefault();
focusAt(items, items.length - 1);
return;
case "Enter":
if (typing && items.length) {
event.preventDefault();
choose(items[0]);
}
return;
}
// Type-ahead, for a list with nothing to type into.
if (typing || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) return;
buffer += event.key.toLowerCase();
global.clearTimeout(bufferTimer);
bufferTimer = global.setTimeout(function () { buffer = ""; }, 600);
var hit = items.filter(function (option) {
return option.textContent.trim().toLowerCase().indexOf(buffer) === 0;
})[0];
if (hit) hit.focus();
});
}
function wire(scope) {
scope = scope || document;
@ -372,6 +994,10 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
scope.querySelectorAll(".nv-slider").forEach(initSlider);
scope.querySelectorAll("[data-nv-calendar]").forEach(initCalendar);
scope.querySelectorAll("[data-nv-timepicker]").forEach(initTimePicker);
scope.querySelectorAll(".nv-menu--listbox").forEach(initListbox);
initReveal(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
@ -399,12 +1025,17 @@
openDialog: openDialog,
selectTab: selectTab,
initScroller: initScroller,
initSlider: initSlider,
initCalendar: initCalendar,
initTimePicker: initTimePicker,
initListbox: initListbox,
liftLabels: liftLabels,
wire: wire,
prefersReducedMotion: prefersReducedMotion,
};
document.addEventListener("pointermove", trackSpotlight, { passive: true });
document.addEventListener("pointermove", trackLight, { passive: true });
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });

View file

@ -144,6 +144,11 @@
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-border: rgb(255 255 255 / 0.14);
--nv-dark-material-glaze: color-mix(in oklab, rgb(30 30 38 / 1), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-glaze-rim-lit: rgb(255 255 255 / 0.55);
--nv-dark-glaze-rim-far: rgb(255 255 255 / 0.14);
--nv-dark-glaze-specular: rgb(255 255 255 / 0.18);
/* --- Window controls --------------------------------------------------------- */
--nv-dark-window-close: var(--nv-palette-window-close);
--nv-dark-window-minimize: var(--nv-palette-window-minimize);

View file

@ -168,6 +168,13 @@
--nv-light-material-glass: rgb(252 252 250 / 0.4);
--nv-light-material-border: rgb(255 255 255 / 0.72);
/* Glaze: the tint it is made of, the rim that catches light on the side
facing the pointer, the rim on the far side, and the specular bloom. */
--nv-light-material-glaze: rgb(255 255 255 / 1);
--nv-light-glaze-rim-lit: rgb(255 255 255 / 1);
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.5);
--nv-light-glaze-specular: rgb(255 255 255 / 0.6);
/* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close);
--nv-light-window-minimize: var(--nv-palette-window-minimize);

View file

@ -249,6 +249,10 @@
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
--nv-color-material-glaze: light-dark(var(--nv-light-material-glaze), var(--nv-dark-material-glaze));
--nv-color-glaze-rim-lit: light-dark(var(--nv-light-glaze-rim-lit), var(--nv-dark-glaze-rim-lit));
--nv-color-glaze-rim-far: light-dark(var(--nv-light-glaze-rim-far), var(--nv-dark-glaze-rim-far));
--nv-color-glaze-specular: light-dark(var(--nv-light-glaze-specular), var(--nv-dark-glaze-specular));
/* --- Window controls ------------------------------------------------------- */
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));

View file

@ -12,6 +12,10 @@
over content: bars, docks, launchers, notifications, menus.
glass more translucent, lightly blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
glaze glass with a dial. How frosted it is belongs to whoever is
looking at it, so it is one number (--nv-glass-frost) rather
than a fixed recipe, and it carries a rim that catches the
light and follows the pointer.
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
and everything that holds content are solid; translucency is reserved for
@ -39,6 +43,18 @@
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
/* --- Frost ------------------------------------------------------------------
The one input the glaze material derives from: 0 is clear glass, 1 is
nearly opaque frost. Set it on :root for the whole site, on a subtree to
change just that region, or bind a slider to it with data-nv-var and let
the person looking choose. The glaze recipe reads it where it is used,
so a subtree override always reaches the surfaces inside it. */
--nv-glass-frost: 0.5;
--nv-glaze-blur-max: 56px; /* blur at frost 1 */
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
--nv-glaze-rim: 1.5px;
/* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar
filled saturated fills: primary and danger buttons, checked marks