mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
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:
parent
f340d566b8
commit
369d1cd8f7
17 changed files with 2679 additions and 6 deletions
131
niveus/components/calendar.css
Normal file
131
niveus/components/calendar.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
69
niveus/components/picker.css
Normal file
69
niveus/components/picker.css
Normal 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;
|
||||
}
|
||||
109
niveus/components/slider.css
Normal file
109
niveus/components/slider.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
128
niveus/components/timepicker.css
Normal file
128
niveus/components/timepicker.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
631
niveus/niveus.js
631
niveus/niveus.js
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue