mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
Rework light theme, add format wheel and motion, fix Firefox paths
Theme: light is a dimmer brand-tinted stone with deeper shade, so it reads as the same material as dark; every neutral now carries --nv-brand in both appearances. Native controls (accent-color, caret-color) take the brand. Time picker: 24-hour by default; data-nv-format-toggle adds a third wheel of 24H / AM / PM. Swiping it rebuilds the hour wheel for the new format. The selection lens is a raised glass bar, not a flat band. Motion: drum effect on the wheels as a scroll-driven animation, with a script fallback for engines without it (Firefox); rubber-band at both ends of a wheel that springs back; calendar months slide locally instead of via a page-wide transition; a chosen day springs into place. Firefox: wheel notches arrive in lines, not pixels, which made the rubber-band invisible; deltaMode is now normalised. The selected-day pop no longer plays on page load. Also: hover only inside @media (hover: hover) on the slider, the Glaze inner glow is a token, and the focus note in GUIDELINES.md is corrected (popovers do not trap focus). SPDX identifier added to niveus.js. 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
369d1cd8f7
commit
5d435c4f49
12 changed files with 410 additions and 157 deletions
|
|
@ -146,6 +146,16 @@ freely.
|
|||
|
||||
### 4.1 Rules
|
||||
|
||||
0. **One dimensionality per interface.** Niveus is skeuomorphic *all the way
|
||||
through*. A raised key, a recessed well and a lit lens are physical objects;
|
||||
a flat colour block beside them reads as a different system. Every
|
||||
interactive or selected element MUST take its shape from the depth
|
||||
vocabulary — bevel, sheen, inset, ledge, outline, elevation — and MUST NOT be
|
||||
a flat fill. If a state seems to need a flat wash (a hover, a selection band,
|
||||
a pressed toggle), give it depth instead: press it *into* the surface, or
|
||||
lay a lit lens over it. Mixing flat and physical is the single most common
|
||||
way a Niveus interface starts to look unfinished.
|
||||
|
||||
1. **Solid by default.** Anything that *holds content* — windows, cards, dialogs,
|
||||
tables, forms — is solid. Translucency is reserved for what *floats over*
|
||||
content.
|
||||
|
|
@ -265,7 +275,17 @@ place to define a colour.
|
|||
attribute change (see 11).
|
||||
5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry
|
||||
meaning; they are not accents. Use `accent` for emphasis and brand.
|
||||
6. **Dark mode is designed, not generated.** When a new surface is added, look at
|
||||
6. **Every neutral carries the brand.** Surfaces, canvases, glass and shadows
|
||||
are mixed with `--nv-brand` in *both* appearances, so changing one value
|
||||
re-colours the whole interface and light and dark read as one material. A
|
||||
neutral that ignores the brand — a literal grey or a bright white field — is
|
||||
a defect. Light is a soft stone, not paper-white: a glaring field flattens
|
||||
the bevels meant to shape it.
|
||||
7. **No native colour survives.** Anything the browser draws itself (checkbox
|
||||
ticks, range and progress fills, the caret, selection, date glyphs) takes the
|
||||
brand through `accent-color`, `caret-color` and `::selection`. Do not leave a
|
||||
control in its default blue.
|
||||
8. **Dark mode is designed, not generated.** When a new surface is added, look at
|
||||
it in dark. If it only works on white, it is not finished.
|
||||
|
||||
---
|
||||
|
|
@ -320,7 +340,18 @@ have watched it reach.**
|
|||
away from the control that opened them.
|
||||
7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required
|
||||
for function.
|
||||
8. **Restraint wins ties.** When continuity and restraint conflict, animate less.
|
||||
8. **Edges give.** A scroller that reaches its end yields and springs back
|
||||
(rubber-band) instead of stopping dead, the way a phone's list does. Use the
|
||||
spring easing token, cap the stretch, and leave touch to the platform where
|
||||
it already does this. The wheels in the time picker are the reference.
|
||||
9. **Let scroll drive depth.** Where an effect is a function of scroll
|
||||
position — a drum's rows fading and tilting away from the centre — express it
|
||||
as a scroll-driven animation in CSS, not as script, so it tracks a finger
|
||||
exactly and costs no frame budget. Provide a fallback where the feature is missing — either the plain state, or the same curve from script (as the wheels do for Firefox) — and test input differences between engines: wheel notches arrive in pixels in Chromium and in *lines* in Firefox, so normalise `deltaMode` before using `deltaY`.
|
||||
10. **Local motion for local change.** Changing a month moves the month, not the
|
||||
page. Reserve page-wide view transitions for changes that are about the
|
||||
page: appearance, route, a selection travelling between regions.
|
||||
11. **Restraint wins ties.** When continuity and restraint conflict, animate less.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -368,8 +399,10 @@ Niveus provides the base; the author is responsible for the rest.
|
|||
visible focus order that follows reading order.
|
||||
4. **Labels.** Every field has a programmatic label. Errors pair
|
||||
`aria-invalid="true"` with `aria-describedby` pointing at the message.
|
||||
5. **Dialogs and popovers** trap or restore focus (native `<dialog>` and
|
||||
`popover` do; custom ones MUST).
|
||||
5. **Focus management.** A modal `<dialog>` opened with `showModal()` traps focus
|
||||
and restores it on close. A `popover` does **not** trap focus: move focus into
|
||||
it on open where it holds controls, and return it to the trigger on close, as
|
||||
the popup list does. Custom overlays MUST do both.
|
||||
6. **Live regions** (`aria-live`) for content that changes without focus —
|
||||
a calendar's month title is one.
|
||||
7. **Test the extremes.** Verify text on translucent materials at the
|
||||
|
|
@ -396,8 +429,8 @@ The complete list and API live in `REFERENCE.md`. These are the rules for
|
|||
month so it never changes height. The grid and states are plain markup;
|
||||
`niveus.js` only builds days, steps months and provides keyboard movement.
|
||||
- **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the
|
||||
default**; `data-nv-format-toggle` adds a 24H / AM/PM switch beneath, and
|
||||
AM/PM adds a third wheel. The value is always 24-hour (`HH:MM`), so a form
|
||||
default**; `data-nv-format-toggle` adds a third wheel of 24H / AM / PM: swiping it
|
||||
changes the format, and the hour wheel follows. The value is always 24-hour (`HH:MM`), so a form
|
||||
never needs to know which the person used. For a no-script fallback use
|
||||
`<input type="time" class="nv-input">`; the wheels are an enhancement, not
|
||||
the only path.
|
||||
|
|
|
|||
|
|
@ -494,6 +494,7 @@ to the state change simply happening rather than travelling:
|
|||
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
|
||||
| Anchor positioning | menus tied to their trigger | the menu centres itself |
|
||||
| `popover` | menus, light dismiss | needs the application's own toggle |
|
||||
| Scroll-driven animation | the time picker's drum effect | `niveus.js` applies the same curve from the scroll position (Firefox) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -76,9 +76,8 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
|
|||
| `.nv-calendar__grid` | day grid | `role="grid"`; built by `niveus.js` or written by hand. |
|
||||
| `.nv-calendar__weekday` | weekday column header | |
|
||||
| `.nv-calendar__day` | day cell | `aria-selected="true"` chosen · `aria-current="date"` today · `data-outside` neighbouring month · `disabled` out of range. |
|
||||
| `.nv-timepicker` | time picker, wheel picker | Scroll-snap wheels built from `data-nv-timepicker` plus `data-nv-value="HH:MM"` / `-hours` (`24` default, `12` adds an AM/PM wheel) / `-step` / `-name` / `-format-toggle` (a 24H / AM/PM switch under the wheels). The value is always 24-hour. Fires `nv:time`. Empty without the script; use `<input type="time" class="nv-input">` as the fallback. |
|
||||
| `.nv-timepicker` | time picker, wheel picker | Scroll-snap wheels built from `data-nv-timepicker` plus `data-nv-value="HH:MM"` / `-hours` (`24` default, `12` adds an AM/PM wheel) / `-step` / `-name` / `-format-toggle` (a third wheel of 24H / AM / PM; swipe it to change the format). The value is always 24-hour. Fires `nv:time`. Empty without the script; use `<input type="time" class="nv-input">` as the fallback. |
|
||||
| `.nv-timepicker__wheels` | wheel row, picker body | Holds the columns and the selection lens. |
|
||||
| `.nv-timepicker__format` | 24H / AM/PM switch, time format toggle | Button group under the wheels; added by `data-nv-format-toggle`. |
|
||||
| `.nv-timepicker__column` | wheel, picker column | One scrolling list (`role="listbox"`). |
|
||||
| `.nv-timepicker__option` | wheel row | `aria-selected="true"` is the row in the lens. |
|
||||
| `.nv-timepicker__separator` | colon between wheels | |
|
||||
|
|
@ -472,7 +471,7 @@ level. Components never opt in individually.
|
|||
subtree) · `--nv-glaze-blur-max` · `--nv-glaze-tint-min` ·
|
||||
`--nv-glaze-tint-max` · `--nv-glaze-rim` · `--nv-light-x` / `--nv-light-y` /
|
||||
`--nv-light-angle` (set by `niveus.js` on each glaze surface) ·
|
||||
`--nv-slider-pos` (set by `niveus.js`) · `--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
|
||||
`--nv-slider-pos` (set by `niveus.js`) · `--nv-pull` (set by `niveus.js` on a time-picker wheel pulled past its end) · `--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
|
||||
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
|
||||
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
|
||||
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
|
||||
|
|
@ -526,7 +525,7 @@ everything still renders — state changes simply arrive instead of travelling.
|
|||
| `data-nv-var-target` | `.nv-slider` | Selector of the element that receives the property. Default: `<html>`. |
|
||||
| `data-nv-calendar` | `.nv-calendar` | Marks a calendar for `niveus.js`. Also `data-nv-value`, `-min`, `-max` (`YYYY-MM-DD`), `-locale`, `-week-start` (0 = Sunday). |
|
||||
| `data-nv-calendar-step` | button in a calendar | `-1` or `1`: months to move. |
|
||||
| `data-nv-timepicker` | `.nv-timepicker` | Marks a time picker. Also `data-nv-value` (`HH:MM`), `-hours` (`24` default / `12`), `-step` (minutes), `-name` (hidden form field), `-format-toggle` (adds the 24H / AM/PM switch). |
|
||||
| `data-nv-timepicker` | `.nv-timepicker` | Marks a time picker. Also `data-nv-value` (`HH:MM`), `-hours` (`24` default / `12`), `-step` (minutes), `-name` (hidden form field), `-format-toggle` (adds the 24H / AM / PM wheel). |
|
||||
| `data-nv-picker-label` | child of a picker trigger | Receives the chosen option's text; default is the trigger itself. |
|
||||
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
|
||||
| `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
|
||||
|
|
|
|||
162
dist/niveus.css
vendored
162
dist/niveus.css
vendored
|
|
@ -811,6 +811,9 @@
|
|||
--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;
|
||||
/* The light a pane holds just inside its edge, so it reads as a thick lens
|
||||
rather than a flat film. */
|
||||
--nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular);
|
||||
|
||||
/* --- Sheen ------------------------------------------------------------------
|
||||
raised neutral controls: buttons, the selected tab, a titlebar
|
||||
|
|
@ -907,16 +910,18 @@
|
|||
:root,
|
||||
[data-nv-brand] {
|
||||
/* --- Canvas and surfaces -----------------------------------------------
|
||||
Paper stays paper: the two surfaces that hold content are left untinted so
|
||||
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
|
||||
of the brand, which is what stops an accent from looking stuck on. */
|
||||
--nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface: #fbfbfa;
|
||||
--nv-light-surface-elevated: #f8f8f6;
|
||||
--nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint));
|
||||
Every neutral carries a trace of the brand, content surfaces included, the
|
||||
way dark does: that is what stops an accent from looking stuck on, and what
|
||||
makes the two appearances read as one material. The paper is a soft
|
||||
grey-stone, not white — a bright field glares, and flattens the bevels that
|
||||
are meant to shape it. Text keeps its contrast: the primary is neutral. */
|
||||
--nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-scrim: rgb(28 24 18 / 0.34);
|
||||
|
||||
/* --- Text ---------------------------------------------------------------
|
||||
|
|
@ -925,7 +930,7 @@
|
|||
loss of legibility. */
|
||||
--nv-light-text-primary: var(--nv-palette-neutral-900);
|
||||
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
|
||||
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
|
||||
--nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%);
|
||||
--nv-light-text-disabled: var(--nv-palette-neutral-400);
|
||||
--nv-light-text-inverted: var(--nv-palette-neutral-0);
|
||||
|
||||
|
|
@ -1010,20 +1015,20 @@
|
|||
/* --- Depth and material -------------------------------------------------
|
||||
Three shadow colors build every elevation level: an ambient contact
|
||||
shadow, a soft penumbra, and a deeper umbra for high elevations. */
|
||||
--nv-light-shadow-ambient: rgb(40 32 20 / 0.08);
|
||||
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.1);
|
||||
--nv-light-shadow-umbra: rgb(40 32 20 / 0.16);
|
||||
--nv-light-shadow-inset: rgb(40 32 20 / 0.14);
|
||||
--nv-light-highlight: rgb(255 255 255 / 0.9);
|
||||
--nv-light-highlight-strong: rgb(255 255 255 / 1);
|
||||
--nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
|
||||
--nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
|
||||
--nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
|
||||
--nv-light-shadow-inset: rgb(32 28 22 / 0.2);
|
||||
--nv-light-highlight: rgb(255 255 255 / 0.7);
|
||||
--nv-light-highlight-strong: rgb(255 255 255 / 0.85);
|
||||
|
||||
/* Paper already has a border doing the contact work, so the outline only
|
||||
softens it; the bottom shade is faint for the same reason. */
|
||||
--nv-light-lowlight: rgb(40 32 20 / 0.09);
|
||||
--nv-light-outline: rgb(40 32 20 / 0.05);
|
||||
--nv-light-lowlight: rgb(32 28 22 / 0.14);
|
||||
--nv-light-outline: rgb(32 28 22 / 0.08);
|
||||
|
||||
--nv-light-sheen-top: rgb(255 255 255 / 0.9);
|
||||
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
|
||||
--nv-light-sheen-top: rgb(255 255 255 / 0.55);
|
||||
--nv-light-sheen-bottom: rgb(32 28 22 / 0.11);
|
||||
--nv-light-sheen-strong-top: rgb(255 255 255 / 0.32);
|
||||
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16);
|
||||
--nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
|
||||
|
|
@ -1039,16 +1044,16 @@
|
|||
--nv-light-elevation-tint-4: transparent;
|
||||
--nv-light-elevation-tint-5: transparent;
|
||||
|
||||
--nv-light-material-frosted: rgb(252 252 250 / 0.56);
|
||||
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
||||
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
||||
--nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-material-border: rgb(255 255 255 / 0.5);
|
||||
|
||||
/* 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);
|
||||
--nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
|
||||
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
|
||||
--nv-light-glaze-specular: rgb(255 255 255 / 0.4);
|
||||
|
||||
/* --- Window controls --------------------------------------------------------- */
|
||||
--nv-light-window-close: var(--nv-palette-window-close);
|
||||
|
|
@ -1369,6 +1374,14 @@
|
|||
border-radius: var(--nv-radius-xs);
|
||||
}
|
||||
|
||||
/* Whatever the browser draws itself — a bare checkbox, a progress bar, the
|
||||
text caret, a date field's glyphs — takes the brand, so no native colour
|
||||
survives next to the system's own. */
|
||||
:root {
|
||||
accent-color: var(--nv-color-accent);
|
||||
caret-color: var(--nv-color-accent);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--nv-color-selection);
|
||||
color: var(--nv-color-text-primary);
|
||||
|
|
@ -1840,7 +1853,7 @@
|
|||
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-glaze-glow),
|
||||
var(--nv-outline),
|
||||
var(--nv-elevation-4);
|
||||
backdrop-filter:
|
||||
|
|
@ -2772,7 +2785,7 @@
|
|||
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
.nv-slider::-moz-range-thumb {
|
||||
|
|
@ -2784,12 +2797,17 @@
|
|||
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);
|
||||
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
/* Hover only where there is a pointer to hover with; touch would leave the
|
||||
thumb enlarged after a tap. */
|
||||
@media (hover: hover) {
|
||||
.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::-webkit-slider-thumb { scale: 0.96; }
|
||||
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
|
||||
|
||||
.nv-slider:focus-visible::-webkit-slider-thumb {
|
||||
|
|
@ -3971,8 +3989,12 @@
|
|||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
/* Hover presses the day slightly into the surface; it is never a flat wash
|
||||
of colour, because the chosen day is a raised key and the two must read as
|
||||
the same material. */
|
||||
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
|
||||
background-color: var(--nv-color-surface-hover);
|
||||
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3983,7 +4005,7 @@
|
|||
/* 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);
|
||||
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent), var(--nv-elevation-inset-sm);
|
||||
}
|
||||
|
||||
/* The chosen day is a small raised key, lit like the filled buttons. */
|
||||
|
|
@ -4007,6 +4029,17 @@
|
|||
color: var(--nv-color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* A day pressed into place springs up to full size. Only when it was just
|
||||
chosen: a selection that is merely *there* on load stays still. */
|
||||
.nv-calendar__day[aria-selected="true"][data-nv-pop] {
|
||||
animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes nv-day-pop {
|
||||
from { scale: 0.72; }
|
||||
to { scale: 1; }
|
||||
}
|
||||
}
|
||||
@layer niveus.components {
|
||||
/* ==========================================================================
|
||||
|
|
@ -4026,9 +4059,9 @@
|
|||
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.
|
||||
third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
|
||||
becomes 1–12; swipe back to 24H and it returns to 0–23. 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">.
|
||||
|
|
@ -4042,7 +4075,7 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--nv-space-2);
|
||||
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3);
|
||||
padding: var(--nv-space-2) var(--nv-space-3);
|
||||
border-radius: var(--nv-radius-xl);
|
||||
color: var(--nv-color-text-primary);
|
||||
}
|
||||
|
|
@ -4053,8 +4086,10 @@
|
|||
gap: var(--nv-space-1);
|
||||
}
|
||||
|
||||
/* The lens. It belongs to the wheels, not the picker, so the switch beneath
|
||||
them never moves the centre. */
|
||||
/* The lens: a bar of tinted glass lying across the centre row. It is raised
|
||||
like the keys elsewhere in the system — lit on top, shaded below, with a
|
||||
contact line — not a flat band of colour, so it reads as a thing the wheels
|
||||
pass under. */
|
||||
.nv-timepicker__wheels::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
|
@ -4063,8 +4098,9 @@
|
|||
block-size: var(--_row);
|
||||
translate: 0 -50%;
|
||||
background-color: var(--nv-color-selection);
|
||||
background-image: var(--nv-sheen-raised);
|
||||
border-radius: var(--nv-radius-md);
|
||||
box-shadow: var(--nv-highlight-top);
|
||||
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
@ -4085,6 +4121,9 @@
|
|||
currentcolor 72%,
|
||||
transparent
|
||||
);
|
||||
/* Depth for the drum effect below: rows turn away from the viewer as they
|
||||
leave the centre. */
|
||||
perspective: 14rem;
|
||||
}
|
||||
|
||||
.nv-timepicker__column::-webkit-scrollbar {
|
||||
|
|
@ -4107,28 +4146,43 @@
|
|||
font-size: var(--nv-font-size-lg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
/* --nv-pull is how far the wheel has been dragged past its end. Letting go
|
||||
removes it, and the spring easing carries the rows back with a small
|
||||
overshoot — the way a phone's list gives at its limit and settles. */
|
||||
translate: 0 var(--nv-pull, 0);
|
||||
transition:
|
||||
var(--nv-transition-colors),
|
||||
translate var(--nv-duration-slower) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
.nv-timepicker__column[data-nv-pulling] .nv-timepicker__option {
|
||||
transition: var(--nv-transition-colors);
|
||||
}
|
||||
|
||||
/* A drum: each row shrinks, fades and tilts away in proportion to how far it
|
||||
is from the lens. Driven by the scroll position itself, so it needs no
|
||||
script and tracks a finger exactly. Where scroll-driven animation is missing
|
||||
the rows are simply flat, which is still correct. */
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.nv-timepicker__option {
|
||||
animation: nv-wheel-drum linear both;
|
||||
animation-timeline: view(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nv-wheel-drum {
|
||||
0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; }
|
||||
50% { scale: 1; opacity: 1; rotate: x 0deg; }
|
||||
100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; }
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,14 @@ textarea {
|
|||
border-radius: var(--nv-radius-xs);
|
||||
}
|
||||
|
||||
/* Whatever the browser draws itself — a bare checkbox, a progress bar, the
|
||||
text caret, a date field's glyphs — takes the brand, so no native colour
|
||||
survives next to the system's own. */
|
||||
:root {
|
||||
accent-color: var(--nv-color-accent);
|
||||
caret-color: var(--nv-color-accent);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--nv-color-selection);
|
||||
color: var(--nv-color-text-primary);
|
||||
|
|
|
|||
|
|
@ -93,8 +93,12 @@
|
|||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
/* Hover presses the day slightly into the surface; it is never a flat wash
|
||||
of colour, because the chosen day is a raised key and the two must read as
|
||||
the same material. */
|
||||
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
|
||||
background-color: var(--nv-color-surface-hover);
|
||||
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +109,7 @@
|
|||
/* 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);
|
||||
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent), var(--nv-elevation-inset-sm);
|
||||
}
|
||||
|
||||
/* The chosen day is a small raised key, lit like the filled buttons. */
|
||||
|
|
@ -129,3 +133,14 @@
|
|||
color: var(--nv-color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* A day pressed into place springs up to full size. Only when it was just
|
||||
chosen: a selection that is merely *there* on load stays still. */
|
||||
.nv-calendar__day[aria-selected="true"][data-nv-pop] {
|
||||
animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
@keyframes nv-day-pop {
|
||||
from { scale: 0.72; }
|
||||
to { scale: 1; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
.nv-slider::-moz-range-thumb {
|
||||
|
|
@ -87,12 +87,17 @@
|
|||
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);
|
||||
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
/* Hover only where there is a pointer to hover with; touch would leave the
|
||||
thumb enlarged after a tap. */
|
||||
@media (hover: hover) {
|
||||
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
|
||||
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@
|
|||
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-glaze-glow),
|
||||
var(--nv-outline),
|
||||
var(--nv-elevation-4);
|
||||
backdrop-filter:
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
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.
|
||||
third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
|
||||
becomes 1–12; swipe back to 24H and it returns to 0–23. 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">.
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--nv-space-2);
|
||||
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3);
|
||||
padding: var(--nv-space-2) var(--nv-space-3);
|
||||
border-radius: var(--nv-radius-xl);
|
||||
color: var(--nv-color-text-primary);
|
||||
}
|
||||
|
|
@ -42,8 +42,10 @@
|
|||
gap: var(--nv-space-1);
|
||||
}
|
||||
|
||||
/* The lens. It belongs to the wheels, not the picker, so the switch beneath
|
||||
them never moves the centre. */
|
||||
/* The lens: a bar of tinted glass lying across the centre row. It is raised
|
||||
like the keys elsewhere in the system — lit on top, shaded below, with a
|
||||
contact line — not a flat band of colour, so it reads as a thing the wheels
|
||||
pass under. */
|
||||
.nv-timepicker__wheels::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
|
@ -52,8 +54,9 @@
|
|||
block-size: var(--_row);
|
||||
translate: 0 -50%;
|
||||
background-color: var(--nv-color-selection);
|
||||
background-image: var(--nv-sheen-raised);
|
||||
border-radius: var(--nv-radius-md);
|
||||
box-shadow: var(--nv-highlight-top);
|
||||
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +77,9 @@
|
|||
currentcolor 72%,
|
||||
transparent
|
||||
);
|
||||
/* Depth for the drum effect below: rows turn away from the viewer as they
|
||||
leave the centre. */
|
||||
perspective: 14rem;
|
||||
}
|
||||
|
||||
.nv-timepicker__column::-webkit-scrollbar {
|
||||
|
|
@ -96,28 +102,43 @@
|
|||
font-size: var(--nv-font-size-lg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
/* --nv-pull is how far the wheel has been dragged past its end. Letting go
|
||||
removes it, and the spring easing carries the rows back with a small
|
||||
overshoot — the way a phone's list gives at its limit and settles. */
|
||||
translate: 0 var(--nv-pull, 0);
|
||||
transition:
|
||||
var(--nv-transition-colors),
|
||||
translate var(--nv-duration-slower) var(--nv-ease-spring);
|
||||
}
|
||||
|
||||
.nv-timepicker__column[data-nv-pulling] .nv-timepicker__option {
|
||||
transition: var(--nv-transition-colors);
|
||||
}
|
||||
|
||||
/* A drum: each row shrinks, fades and tilts away in proportion to how far it
|
||||
is from the lens. Driven by the scroll position itself, so it needs no
|
||||
script and tracks a finger exactly. Where scroll-driven animation is missing
|
||||
the rows are simply flat, which is still correct. */
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.nv-timepicker__option {
|
||||
animation: nv-wheel-drum linear both;
|
||||
animation-timeline: view(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nv-wheel-drum {
|
||||
0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; }
|
||||
50% { scale: 1; opacity: 1; rotate: x 0deg; }
|
||||
100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; }
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
190
niveus/niveus.js
190
niveus/niveus.js
|
|
@ -1,3 +1,4 @@
|
|||
/* SPDX-License-Identifier: Apache-2.0 */
|
||||
/* ==========================================================================
|
||||
Niveus — continuity helpers
|
||||
--------------------------------------------------------------------------
|
||||
|
|
@ -492,6 +493,8 @@
|
|||
grid.setAttribute("aria-labelledby", title.id);
|
||||
}
|
||||
|
||||
var pop = false;
|
||||
|
||||
function render() {
|
||||
if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" });
|
||||
grid.textContent = "";
|
||||
|
|
@ -525,6 +528,7 @@
|
|||
}
|
||||
var iso = isoDate(cursor);
|
||||
var day = document.createElement("button");
|
||||
if (pop && iso === selectedIso) day.dataset.nvPop = "";
|
||||
day.type = "button";
|
||||
day.className = "nv-calendar__day";
|
||||
day.setAttribute("role", "gridcell");
|
||||
|
|
@ -558,10 +562,36 @@
|
|||
var update = function () {
|
||||
view = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
render();
|
||||
pop = false;
|
||||
if (focus) focusActive();
|
||||
};
|
||||
if (monthChanged) transition(update, { direction: direction });
|
||||
else update();
|
||||
update();
|
||||
if (monthChanged) slide(direction);
|
||||
}
|
||||
|
||||
// The month slides in from the side it lies on, with the easing and
|
||||
// duration the rest of the system uses, so it collapses with reduced
|
||||
// motion like everything else. Only the grid moves; the page does not.
|
||||
function slide(direction) {
|
||||
if (prefersReducedMotion() || !grid.animate) return;
|
||||
var root = getComputedStyle(document.documentElement);
|
||||
var duration = parseFloat(root.getPropertyValue("--nv-duration-expand"));
|
||||
if (!duration) return;
|
||||
var easing = root.getPropertyValue("--nv-ease-emphasized").trim() || "ease-out";
|
||||
var shift = (direction === "forward" ? 1 : -1) * 28;
|
||||
grid.animate(
|
||||
[
|
||||
{ opacity: 0, translate: shift + "px 0", filter: "blur(3px)" },
|
||||
{ opacity: 1, translate: "0 0", filter: "blur(0)" },
|
||||
],
|
||||
{ duration: duration, easing: easing }
|
||||
);
|
||||
if (title) {
|
||||
title.animate(
|
||||
[{ opacity: 0, translate: shift / 2 + "px 0" }, { opacity: 1, translate: "0 0" }],
|
||||
{ duration: duration, easing: easing }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stepMonth(count) {
|
||||
|
|
@ -574,14 +604,18 @@
|
|||
function choose(button) {
|
||||
selected = parseDate(button.dataset.date);
|
||||
active = selected;
|
||||
pop = true;
|
||||
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");
|
||||
delete other.dataset.nvPop;
|
||||
});
|
||||
button.setAttribute("aria-selected", "true");
|
||||
button.dataset.nvPop = "";
|
||||
pop = false;
|
||||
}
|
||||
calendar.dispatchEvent(new CustomEvent("nv:date", {
|
||||
bubbles: true,
|
||||
|
|
@ -632,7 +666,8 @@
|
|||
* 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,
|
||||
* (adds a third wheel of 24H / AM / PM: swiping it changes the format). The
|
||||
* value is always 24-hour,
|
||||
* whatever the wheels show. Fires `nv:time` with { value } as the wheels
|
||||
* settle on a new row.
|
||||
*/
|
||||
|
|
@ -652,9 +687,10 @@
|
|||
|
||||
var wheels = document.createElement("div");
|
||||
wheels.className = "nv-timepicker__wheels";
|
||||
var hourColumn, minuteColumn, periodColumn;
|
||||
var hourColumn, minuteColumn, periodColumn, formatColumn;
|
||||
var field = null;
|
||||
var formatButtons = [];
|
||||
var modeTimer = 0;
|
||||
var withFormat = picker.hasAttribute("data-nv-format-toggle");
|
||||
var placed = false;
|
||||
var last = "";
|
||||
var frame = 0;
|
||||
|
|
@ -680,10 +716,22 @@
|
|||
var hi = indexOf(hourColumn);
|
||||
var mi = indexOf(minuteColumn);
|
||||
if (hi < 0 || mi < 0) return;
|
||||
var pm = periodColumn ? indexOf(periodColumn) === 1 : false;
|
||||
var fi = formatColumn ? indexOf(formatColumn) : -1;
|
||||
if (formatColumn && fi < 0) return;
|
||||
|
||||
// The format wheel disagrees with the hour wheel: a new format was
|
||||
// swiped to. Wait for it to settle, then rebuild the hours for it.
|
||||
if (formatColumn && (fi > 0) !== twelve) {
|
||||
global.clearTimeout(modeTimer);
|
||||
modeTimer = global.setTimeout(switchFormat, 160);
|
||||
return;
|
||||
}
|
||||
|
||||
var pm = formatColumn ? fi === 2 : periodColumn ? indexOf(periodColumn) === 1 : false;
|
||||
mark(hourColumn, hi);
|
||||
mark(minuteColumn, mi);
|
||||
if (periodColumn) mark(periodColumn, pm ? 1 : 0);
|
||||
if (formatColumn) mark(formatColumn, fi);
|
||||
else if (periodColumn) mark(periodColumn, pm ? 1 : 0);
|
||||
|
||||
var hour = twelve ? ((hi + 1) % 12) + (pm ? 12 : 0) : hi;
|
||||
var value = pad(hour) + ":" + minuteItems[mi];
|
||||
|
|
@ -708,10 +756,24 @@
|
|||
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);
|
||||
if (formatColumn) scrollTo(formatColumn, twelve ? (h24 >= 12 ? 2 : 1) : 0, false);
|
||||
else if (periodColumn) scrollTo(periodColumn, h24 >= 12 ? 1 : 0, false);
|
||||
read();
|
||||
}
|
||||
|
||||
// Going to 12-hour takes the half of the day that was swiped to; going back
|
||||
// to 24-hour keeps the moment, which the hour wheel has been tracking.
|
||||
function switchFormat() {
|
||||
var fi = indexOf(formatColumn);
|
||||
if (fi < 0) return;
|
||||
var wants = fi > 0;
|
||||
if (wants === twelve) { read(); return; }
|
||||
if (wants) h24 = (h24 % 12) + (fi === 2 ? 12 : 0);
|
||||
twelve = wants;
|
||||
picker.dataset.nvHours = twelve ? "12" : "24";
|
||||
build();
|
||||
}
|
||||
|
||||
function buildColumn(label, items) {
|
||||
var list = document.createElement("ul");
|
||||
list.className = "nv-timepicker__column";
|
||||
|
|
@ -726,9 +788,51 @@
|
|||
list.appendChild(option);
|
||||
});
|
||||
|
||||
// Past either end the wheel gives, as a phone's list does, then springs
|
||||
// back. Touch is left to the platform (iOS bounces, Android stretches);
|
||||
// overscroll-behavior keeps that inside the wheel. Mouse wheels and
|
||||
// trackpads, which have no such effect on a nested scroller, are handled
|
||||
// here. The distance has resistance: the further it is pulled, the less
|
||||
// each notch moves it.
|
||||
var pull = 0;
|
||||
var settle = 0;
|
||||
var limit = 36;
|
||||
|
||||
function setPull(value) {
|
||||
pull = value;
|
||||
list.style.setProperty("--nv-pull", value.toFixed(1) + "px");
|
||||
list.dataset.nvPulling = "";
|
||||
global.clearTimeout(settle);
|
||||
settle = global.setTimeout(function () {
|
||||
pull = 0;
|
||||
delete list.dataset.nvPulling;
|
||||
list.style.removeProperty("--nv-pull");
|
||||
}, 90);
|
||||
}
|
||||
|
||||
list.addEventListener("wheel", function (event) {
|
||||
if (prefersReducedMotion()) return;
|
||||
var atTop = list.scrollTop <= 0;
|
||||
var atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1;
|
||||
// Chromium reports a wheel notch in pixels (~100); Firefox reports it
|
||||
// in lines (~3). Both are brought to pixels, or the pull would be
|
||||
// invisible in one of them.
|
||||
var delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientHeight : 1);
|
||||
var pushing = (atTop && delta < 0) || (atBottom && delta > 0);
|
||||
if (!pushing && !pull) return;
|
||||
event.preventDefault();
|
||||
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
|
||||
setPull(Math.max(-limit, Math.min(limit, next)));
|
||||
}, { passive: false });
|
||||
|
||||
// A key pressed at the end nudges the wheel instead of doing nothing.
|
||||
list.nvNudge = function (direction) {
|
||||
if (!prefersReducedMotion()) setPull(direction * 12);
|
||||
};
|
||||
|
||||
list.addEventListener("scroll", function () {
|
||||
if (frame) return;
|
||||
frame = global.requestAnimationFrame(function () { frame = 0; read(); });
|
||||
frame = global.requestAnimationFrame(function () { frame = 0; read(); drum(list); });
|
||||
}, { passive: true });
|
||||
|
||||
list.addEventListener("click", function (event) {
|
||||
|
|
@ -750,11 +854,41 @@
|
|||
default: return;
|
||||
}
|
||||
event.preventDefault();
|
||||
scrollTo(list, Math.max(0, Math.min(end, to)), true);
|
||||
var clamped = Math.max(0, Math.min(end, to));
|
||||
if (clamped === index) list.nvNudge(to < index ? 1 : -1);
|
||||
scrollTo(list, clamped, true);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
// The drum effect is a CSS scroll-driven animation (see timepicker.css).
|
||||
// Where that does not exist — Firefox — the same curve is applied from the
|
||||
// scroll position here, so the wheels look the same in both. Only the few
|
||||
// rows near the lens are touched.
|
||||
var scrollDriven = global.CSS && global.CSS.supports && global.CSS.supports("animation-timeline", "view()");
|
||||
|
||||
function drum(list) {
|
||||
if (scrollDriven || prefersReducedMotion()) return;
|
||||
var first = list.firstElementChild;
|
||||
var row = first ? first.offsetHeight : 0;
|
||||
if (!row) return;
|
||||
var centre = list.scrollTop / row;
|
||||
var reach = list.clientHeight + row;
|
||||
for (var i = 0; i < list.children.length; i++) {
|
||||
var away = i - centre;
|
||||
var option = list.children[i];
|
||||
if (Math.abs(away) > 3) {
|
||||
option.style.opacity = "0.25";
|
||||
continue;
|
||||
}
|
||||
// 1 at the lens, 0 at the edge of the wheel; the CSS keyframes, spelt out.
|
||||
var near = 1 - Math.min(1, Math.abs(away) * row / reach * 2);
|
||||
option.style.scale = String(0.78 + 0.22 * near);
|
||||
option.style.opacity = String(0.25 + 0.75 * near);
|
||||
option.style.rotate = "x " + ((away < 0 ? 1 : -1) * 52 * (1 - near)).toFixed(1) + "deg";
|
||||
}
|
||||
}
|
||||
|
||||
function colon() {
|
||||
var mark = document.createElement("span");
|
||||
mark.className = "nv-timepicker__separator";
|
||||
|
|
@ -773,17 +907,19 @@
|
|||
wheels.textContent = "";
|
||||
hourColumn = buildColumn("Hours", hourItems);
|
||||
minuteColumn = buildColumn("Minutes", minuteItems);
|
||||
periodColumn = twelve ? buildColumn("AM or PM", ["AM", "PM"]) : null;
|
||||
formatColumn = withFormat ? buildColumn("Time format", ["24H", "AM", "PM"]) : null;
|
||||
periodColumn = twelve && !withFormat ? buildColumn("AM or PM", ["AM", "PM"]) : null;
|
||||
wheels.appendChild(hourColumn);
|
||||
wheels.appendChild(colon());
|
||||
wheels.appendChild(minuteColumn);
|
||||
if (periodColumn) wheels.appendChild(periodColumn);
|
||||
if (formatColumn) wheels.appendChild(formatColumn);
|
||||
else if (periodColumn) wheels.appendChild(periodColumn);
|
||||
|
||||
formatButtons.forEach(function (button) {
|
||||
button.setAttribute("aria-pressed", String((button.dataset.hours === "12") === twelve));
|
||||
});
|
||||
placed = false;
|
||||
place();
|
||||
[hourColumn, minuteColumn, formatColumn || periodColumn].forEach(function (list) {
|
||||
if (list) drum(list);
|
||||
});
|
||||
}
|
||||
|
||||
picker.textContent = "";
|
||||
|
|
@ -793,30 +929,6 @@
|
|||
}
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -32,16 +32,18 @@
|
|||
:root,
|
||||
[data-nv-brand] {
|
||||
/* --- Canvas and surfaces -----------------------------------------------
|
||||
Paper stays paper: the two surfaces that hold content are left untinted so
|
||||
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
|
||||
of the brand, which is what stops an accent from looking stuck on. */
|
||||
--nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface: #fbfbfa;
|
||||
--nv-light-surface-elevated: #f8f8f6;
|
||||
--nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint));
|
||||
Every neutral carries a trace of the brand, content surfaces included, the
|
||||
way dark does: that is what stops an accent from looking stuck on, and what
|
||||
makes the two appearances read as one material. The paper is a soft
|
||||
grey-stone, not white — a bright field glares, and flattens the bevels that
|
||||
are meant to shape it. Text keeps its contrast: the primary is neutral. */
|
||||
--nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-scrim: rgb(28 24 18 / 0.34);
|
||||
|
||||
/* --- Text ---------------------------------------------------------------
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
loss of legibility. */
|
||||
--nv-light-text-primary: var(--nv-palette-neutral-900);
|
||||
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
|
||||
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
|
||||
--nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%);
|
||||
--nv-light-text-disabled: var(--nv-palette-neutral-400);
|
||||
--nv-light-text-inverted: var(--nv-palette-neutral-0);
|
||||
|
||||
|
|
@ -135,20 +137,20 @@
|
|||
/* --- Depth and material -------------------------------------------------
|
||||
Three shadow colors build every elevation level: an ambient contact
|
||||
shadow, a soft penumbra, and a deeper umbra for high elevations. */
|
||||
--nv-light-shadow-ambient: rgb(40 32 20 / 0.08);
|
||||
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.1);
|
||||
--nv-light-shadow-umbra: rgb(40 32 20 / 0.16);
|
||||
--nv-light-shadow-inset: rgb(40 32 20 / 0.14);
|
||||
--nv-light-highlight: rgb(255 255 255 / 0.9);
|
||||
--nv-light-highlight-strong: rgb(255 255 255 / 1);
|
||||
--nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
|
||||
--nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
|
||||
--nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
|
||||
--nv-light-shadow-inset: rgb(32 28 22 / 0.2);
|
||||
--nv-light-highlight: rgb(255 255 255 / 0.7);
|
||||
--nv-light-highlight-strong: rgb(255 255 255 / 0.85);
|
||||
|
||||
/* Paper already has a border doing the contact work, so the outline only
|
||||
softens it; the bottom shade is faint for the same reason. */
|
||||
--nv-light-lowlight: rgb(40 32 20 / 0.09);
|
||||
--nv-light-outline: rgb(40 32 20 / 0.05);
|
||||
--nv-light-lowlight: rgb(32 28 22 / 0.14);
|
||||
--nv-light-outline: rgb(32 28 22 / 0.08);
|
||||
|
||||
--nv-light-sheen-top: rgb(255 255 255 / 0.9);
|
||||
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
|
||||
--nv-light-sheen-top: rgb(255 255 255 / 0.55);
|
||||
--nv-light-sheen-bottom: rgb(32 28 22 / 0.11);
|
||||
--nv-light-sheen-strong-top: rgb(255 255 255 / 0.32);
|
||||
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16);
|
||||
--nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
|
||||
|
|
@ -164,16 +166,16 @@
|
|||
--nv-light-elevation-tint-4: transparent;
|
||||
--nv-light-elevation-tint-5: transparent;
|
||||
|
||||
--nv-light-material-frosted: rgb(252 252 250 / 0.56);
|
||||
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
||||
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
||||
--nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint));
|
||||
--nv-light-material-border: rgb(255 255 255 / 0.5);
|
||||
|
||||
/* 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);
|
||||
--nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2));
|
||||
--nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
|
||||
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
|
||||
--nv-light-glaze-specular: rgb(255 255 255 / 0.4);
|
||||
|
||||
/* --- Window controls --------------------------------------------------------- */
|
||||
--nv-light-window-close: var(--nv-palette-window-close);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@
|
|||
--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;
|
||||
/* The light a pane holds just inside its edge, so it reads as a thick lens
|
||||
rather than a flat film. */
|
||||
--nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular);
|
||||
|
||||
/* --- Sheen ------------------------------------------------------------------
|
||||
raised neutral controls: buttons, the selected tab, a titlebar
|
||||
|
|
|
|||
Loading…
Reference in a new issue