diff --git a/GUIDELINES.md b/GUIDELINES.md
index 942b4dc..703d6da 100644
--- a/GUIDELINES.md
+++ b/GUIDELINES.md
@@ -350,7 +350,7 @@ have watched it reach.**
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.
+ page: a route change, never appearance or selection — see 14.4.1.
11. **Restraint wins ties.** When continuity and restraint conflict, animate less.
---
@@ -537,12 +537,15 @@ tune. This is a first-class goal, bounded by sections 3.1 and 11.
Each of these was a real defect, found by driving Firefox with real input.
-1. **Never wrap an appearance change in a page-wide view transition.** The
- snapshot cannot blur what sits behind frosted glass, so for its whole
- duration every frosted bar, menu and Glaze pane goes clear and shows the page
- sharply through itself. Let surfaces transition their own colours. Selection
- markers still use a short view transition; keep it brief, and prefer a
- per-element animation for anything inside frosted chrome.
+1. **Never wrap a change in a page-wide view transition.** The snapshot cannot
+ blur what sits behind frosted glass, so for its whole duration every frosted
+ bar, menu and Glaze pane goes clear and shows the page sharply through
+ itself. This applied to theme changes, brand changes *and* selection markers,
+ and was only noticed on the third. Let surfaces transition their own colours;
+ move a selection marker by animating the marker itself (measure, change,
+ animate the difference); animate arriving content on its own. `nv.transition`
+ is built this way; do not reintroduce `document.startViewTransition` around
+ anything on a page that has glass.
2. **Do not use padding to make a scroll container's last item reachable.**
Firefox leaves the container's bottom padding out of its scrollable area, so
the final rows were unreachable and a short list could not scroll at all. Use
@@ -567,10 +570,23 @@ Each of these was a real defect, found by driving Firefox with real input.
8. **Same-page links travel.** `scroll-behavior: smooth` under
`prefers-reduced-motion: no-preference`; a jump to a section is the textbook
change a user could have watched.
-9. **Know your test rig.** A headless browser without a GPU does not render
+9. **Own a gesture completely or not at all.** A control that is half native
+ scrolling and half script behaves differently in every engine, in a desktop
+ browser's phone mode, and on a real phone. The wheels take every pointer —
+ mouse, pen and touch — with `touch-action: none` and run one code path, so
+ what was tested is what runs everywhere.
+10. **An effect must not look like a value.** A rubber-band stretch of a whole
+ row makes the neighbouring row sit under the lens and read as a different
+ selection. Cap give at a fraction of a row (about 40%).
+11. **One animation-frame slot per element, never per component.** A slot shared
+ between a picker's wheels dropped the last scroll event of one wheel and
+ left its rows and value a step behind.
+12. **Know your test rig.** A headless browser without a GPU does not render
`backdrop-filter`, reports no hover capability (so hover-only controls such
as scroller arrows disappear), and may not deliver scroll events reliably.
- Treat those as limits of the rig, not of the system, and confirm them on a
+ It also cannot pan on synthesized touch (not even the page itself), so any
+ behaviour that depends on native touch scrolling cannot be verified there.
+ Treat these as limits of the rig, not of the system, and confirm them on a
real display.
### 14.3 Documentation
diff --git a/README.md b/README.md
index c7af6f4..164196f 100644
--- a/README.md
+++ b/README.md
@@ -186,22 +186,24 @@ could have watched it reach.
| Change | How it stays continuous |
| ------ | ----------------------- |
-| Selection moves between items | The marker itself travels. It is a pseudo-element with a shared `view-transition-name`, so the browser interpolates its old box into its new one. |
-| Switching tabs | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
+| Selection moves between items | The marker itself travels. It is a pseudo-element; `niveus.js` measures it before and after the change and animates the new marker from the old box to its own. |
+| Switching tabs | The incoming panel slides in from the side the user is moving towards, and the marker travels with it. |
| Content expanding | `interpolate-size` lets `block-size: auto` animate, so content grows from the row that revealed it. Nothing is measured. |
| Menus and dialogs | A menu is anchored to its trigger and scales out of its edge; a dialog expands away from the control that opened it. |
-| Theme change | The whole surface cross-fades. Surfaces also transition their own colors, so it glides even with no script. |
+| Theme or brand change | Surfaces transition their own colors, so it glides even with no script. Nothing snapshots the page, so frosted glass stays live while it happens. |
| Hover, press, focus, select | All have a duration. Marks grow from their centre, the switch thumb travels its track. |
-Most of this is plain CSS. The three cases CSS cannot do alone — a selection
-moving *between two elements*, one panel replacing another, and the whole page
-changing appearance — need a view transition, which needs one line of script:
+Most of this is plain CSS. The two cases CSS cannot do alone — a selection
+moving *between two elements*, and one panel replacing another — need one line
+of script. It deliberately does **not** use a view transition: a page-wide
+snapshot cannot blur what sits behind frosted glass, so every frosted surface
+went clear for the length of the change.
```js
-nv.transition(() => { // or document.startViewTransition directly
+nv.transition(() => {
previous.removeAttribute("aria-current");
next.setAttribute("aria-current", "page");
-});
+}, { scope: group }); // the group whose marker should travel
```
`niveus.js` is optional and dependency-free. Without it every component still
@@ -214,27 +216,13 @@ place once as it scrolls into view, and `.nv-reveal-group` brings its children
in one after another, `--nv-stagger` apart. Only the script marks anything as
waiting, so without it — and under reduced motion — nothing is ever hidden.
-### One rule worth knowing
+### Nothing to name, nothing to keep unique
-A `view-transition-name` must be unique among all rendered elements, or the
-browser skips the transition entirely. Each selection group therefore needs its
-own marker name:
-
-```html
-
-
-```
-
-Tab strips default to `nv-tabs-selection` and everything else to
-`nv-selection`, which is enough for one of each per page.
-
-### Motion under a transition
-
-Transition layers always paint above the page, so a travelling marker cannot
-stay behind the labels it passes the way it does at rest. Rather than let an
-opaque marker wipe them, Niveus thins it while it is in flight: it stays
-visible the whole way — it can still be followed — but reads as having lifted
-off the surface to travel.
+Because the marker is animated directly, several selection groups can change at
+once and none of them needs a name. (`--nv-selection-name` only matters to
+application code that starts its own view transition; if you do, remember that
+a `view-transition-name` must be unique among all rendered elements, and that
+the snapshot flattens frosted glass for as long as it runs.)
`prefers-reduced-motion` collapses every duration at the token level and skips
view transitions entirely. The change still happens; it just arrives.
@@ -490,7 +478,7 @@ to the state change simply happening rather than travelling:
| Feature | Used for | Without it |
| ------- | -------- | ---------- |
-| View transitions | travelling selection, tab panels, theme cross-fade | the change applies instantly |
+| Web Animations (`pseudoElement`) | travelling selection marker, tab panels | the change applies instantly |
| `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 |
diff --git a/REFERENCE.md b/REFERENCE.md
index 89da370..8d897db 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -488,7 +488,7 @@ everything still renders — state changes simply arrive instead of travelling.
| Function | Signature | Does |
| --- | --- | --- |
-| `nv.transition` | `(update, { direction, kind, scope }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade; `scope` is the selection group (or list of groups) whose marker should travel — pass the group whose `aria-current`/`aria-selected` the update moves. Without `scope` nothing is lifted and the change cross-fades in place. Falls back to running `update` directly under reduced motion or without API support. |
+| `nv.transition` | `(update, { direction, kind, scope, content }) => Promise` | Runs `update` so the change can be followed, **without snapshotting the page**: a snapshot flattens frosted glass for its duration. `scope` is the selection group (or list of groups) whose marker should travel — the marker is measured before and after `update` and animated between the two boxes. `content` is an element (or list) that arrives from the side `direction` (`"forward"`/`"backward"`) names, or rises into place. `kind: "theme"` and reduced motion run `update` directly: surfaces transition their own colours. |
| `nv.setTheme` | `("light" \| "dark" \| "system") => Promise` | Cross-fades the whole surface into the new appearance and fires `nv:theme` on ``. |
| `nv.syncThemeControls` | `(theme) => void` | Sets `aria-pressed` on every `[data-nv-theme]`. |
| `nv.openDialog` | `(dialog \| id, trigger?) => dialog` | `showModal()`, plus a transform origin so the dialog expands out of its trigger. |
@@ -498,7 +498,7 @@ everything still renders — state changes simply arrive instead of travelling.
| `nv.initTimePicker` | `(el) => void` | Builds and drives one `[data-nv-timepicker]`. |
| `nv.initListbox` | `(list) => void` | Wires one `.nv-menu--listbox` (needs an `id` and a trigger with that `popovertarget`). |
| `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. |
-| `nv.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope` so a travelling marker passes behind them, and returns them. `nv.transition` does this for its `scope` and removes the names afterwards. |
+| `nv.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope`. Only for code that starts its own view transition; `nv.transition` no longer needs it. |
| `nv.wire` | `(scope = document) => void` | Applies all of the below, and starts watching `.nv-reveal` / `.nv-reveal-group`. Runs once on load; call again after injecting markup. |
| `nv.prefersReducedMotion` | `() => boolean` | |
@@ -530,20 +530,16 @@ everything still renders — state changes simply arrive instead of travelling.
| `data-nv-tristate` | `input[type=checkbox]` | Click cycles unchecked → checked → partial. `="partial"` starts partial. Exposes `data-nv-state` (`off`/`on`/`partial`). |
| `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. |
-| `data-nv-direction` | `` | Set during a transition by `nv.transition`. |
-| `data-nv-transition` | `` | `"theme"` during an appearance change. |
+| `data-nv-direction` | `` | Set during a view transition that application code starts itself. |
+| `data-nv-transition` | `` | `"theme"` during a view transition the application starts for an appearance change. |
| `data-nv-reveal` | revealed element | `pending` while waiting to scroll into view, `shown` while arriving; removed once it has. Set by `niveus.js`. |
-| `data-nv-travelling` | selection group | Set by `nv.transition` on each `scope` for the length of the change; names its marker. |
+| `data-nv-travelling` | selection group | Names the marker for a view transition the application starts itself. |
---
## Rules that bite
-1. **`view-transition-name` must be unique per document.** A duplicate makes
- the browser skip the transition entirely. Markers are only named while their
- group is in `scope`, so this only bites when one transition moves two groups
- at once: give each its own `--nv-selection-name`. `.nv-nav-tabs` defaults
- to `nv-tabs-selection` and everything else to `nv-selection`.
+1. **Do not start your own view transition around a page that has glass.** The snapshot cannot blur what sits behind frosted surfaces, so they go clear for its duration. Use `nv.transition`, which never snapshots the page. (If you must, `view-transition-name` has to be unique per document, or the browser skips the transition; give each selection group its own `--nv-selection-name`.)
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
and the value must be unique per pair.
3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies
diff --git a/dist/niveus.css b/dist/niveus.css
index b85cacc..3d24f9e 100644
--- a/dist/niveus.css
+++ b/dist/niveus.css
@@ -3349,6 +3349,32 @@
position: static;
}
+ /* On a phone the bar keeps to one row. Wrapping put the controls on a second
+ line that sat over the links, and with glass behind it that read as a
+ collision. Instead the list gives way: it takes the room that is left and
+ scrolls sideways inside the bar, and everything else keeps its size. */
+ @media (max-width: 40rem) {
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) {
+ flex-wrap: nowrap;
+ gap: var(--nv-space-2);
+ padding-inline: var(--nv-space-3);
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > * {
+ flex-shrink: 0;
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__list {
+ flex: 1 1 0;
+ flex-shrink: 1;
+ overflow-x: auto;
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__spacer {
+ display: none;
+ }
+ }
+
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
@@ -4136,7 +4162,11 @@
list-style: none;
cursor: grab;
user-select: none;
- touch-action: pan-y;
+ /* The wheel owns vertical gestures, whatever the pointer: niveus.js drags it
+ itself so a finger, a mouse and a desktop browser's phone mode behave
+ alike. Without the script the wheel still scrolls natively with a wheel
+ or keys; set touch-action back to pan-y if you ship it without. */
+ touch-action: none;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
@@ -4471,6 +4501,19 @@
--nv-button-offset: 0 -50%;
}
+ /* The arrows float over the cards, so they are glass: frosted, with the content
+ passing blurred beneath them, rather than an opaque key hiding what it sits
+ on. This is the system's rule for anything that floats over content. */
+ .nv-scroller-frame [data-nv-scroll] {
+ --_bg: var(--nv-material-frosted-bg);
+ --_bg-hover: var(--nv-material-frosted-bg);
+ --_bg-active: var(--nv-material-frosted-bg);
+ --_border: var(--nv-material-frosted-border);
+ --_sheen: none;
+ backdrop-filter: var(--nv-backdrop-frosted);
+ -webkit-backdrop-filter: var(--nv-backdrop-frosted);
+ }
+
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],
diff --git a/examples/ai-chat/index.html b/examples/ai-chat/index.html
index fe59588..e5e7ca6 100644
--- a/examples/ai-chat/index.html
+++ b/examples/ai-chat/index.html
@@ -404,7 +404,7 @@
.forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
render(item.dataset.thread);
- }, { scope: list });
+ }, { scope: list, content: transcript });
scroller.scrollTop = 0;
});
});
diff --git a/examples/it-shop/index.html b/examples/it-shop/index.html
index f3b9c2b..77710c8 100644
--- a/examples/it-shop/index.html
+++ b/examples/it-shop/index.html
@@ -491,9 +491,13 @@
const count = document.getElementById("basket-count");
document.querySelectorAll("[data-add]").forEach((button) => {
button.addEventListener("click", () => {
- nv.transition(() => {
- count.textContent = String(Number(count.textContent) + 1);
- });
+ count.textContent = String(Number(count.textContent) + 1);
+ if (!nv.prefersReducedMotion()) {
+ count.animate([{ scale: 1.4 }, { scale: 1 }], {
+ duration: 280,
+ easing: getComputedStyle(document.documentElement).getPropertyValue("--nv-ease-spring"),
+ });
+ }
button.textContent = "Added";
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
});
diff --git a/index.html b/index.html
index db92b1d..7d681f2 100644
--- a/index.html
+++ b/index.html
@@ -1486,21 +1486,19 @@
const brandInput = document.getElementById("brand-input");
const brandValue = document.getElementById("brand-value");
- function setBrand(value, continuous) {
- const apply = () => {
- document.documentElement.style.setProperty("--nv-brand", value);
- brandInput.value = value;
- brandValue.textContent = value;
- };
- if (continuous) nv.transition(apply, { kind: "theme" });
- else apply();
+ /* No page-wide transition here: surfaces fade their own colours, and a
+ snapshot of the page would flatten every frosted surface while it ran. */
+ function setBrand(value) {
+ document.documentElement.style.setProperty("--nv-brand", value);
+ brandInput.value = value;
+ brandValue.textContent = value;
}
- brandInput.addEventListener("input", (event) => setBrand(event.target.value, false));
+ brandInput.addEventListener("input", (event) => setBrand(event.target.value));
document.getElementById("brand-presets").addEventListener("click", (event) => {
const preset = event.target.closest("[data-brand]");
- if (preset) setBrand(preset.dataset.brand, true);
+ if (preset) setBrand(preset.dataset.brand);
});
/* A selection group whose emphasis travels: one marker, moved inside a
diff --git a/niveus/components/navigation.css b/niveus/components/navigation.css
index 267ed61..d2369db 100644
--- a/niveus/components/navigation.css
+++ b/niveus/components/navigation.css
@@ -56,6 +56,32 @@
position: static;
}
+/* On a phone the bar keeps to one row. Wrapping put the controls on a second
+ line that sat over the links, and with glass behind it that read as a
+ collision. Instead the list gives way: it takes the room that is left and
+ scrolls sideways inside the bar, and everything else keeps its size. */
+@media (max-width: 40rem) {
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) {
+ flex-wrap: nowrap;
+ gap: var(--nv-space-2);
+ padding-inline: var(--nv-space-3);
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > * {
+ flex-shrink: 0;
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__list {
+ flex: 1 1 0;
+ flex-shrink: 1;
+ overflow-x: auto;
+ }
+
+ .nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__spacer {
+ display: none;
+ }
+}
+
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
diff --git a/niveus/components/scroller.css b/niveus/components/scroller.css
index 0bbb8f4..ff5f8b7 100644
--- a/niveus/components/scroller.css
+++ b/niveus/components/scroller.css
@@ -102,6 +102,19 @@
--nv-button-offset: 0 -50%;
}
+/* The arrows float over the cards, so they are glass: frosted, with the content
+ passing blurred beneath them, rather than an opaque key hiding what it sits
+ on. This is the system's rule for anything that floats over content. */
+.nv-scroller-frame [data-nv-scroll] {
+ --_bg: var(--nv-material-frosted-bg);
+ --_bg-hover: var(--nv-material-frosted-bg);
+ --_bg-active: var(--nv-material-frosted-bg);
+ --_border: var(--nv-material-frosted-border);
+ --_sheen: none;
+ backdrop-filter: var(--nv-backdrop-frosted);
+ -webkit-backdrop-filter: var(--nv-backdrop-frosted);
+}
+
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],
diff --git a/niveus/components/timepicker.css b/niveus/components/timepicker.css
index 1affb1a..c628cf3 100644
--- a/niveus/components/timepicker.css
+++ b/niveus/components/timepicker.css
@@ -69,7 +69,11 @@
list-style: none;
cursor: grab;
user-select: none;
- touch-action: pan-y;
+ /* The wheel owns vertical gestures, whatever the pointer: niveus.js drags it
+ itself so a finger, a mouse and a desktop browser's phone mode behave
+ alike. Without the script the wheel still scrolls natively with a wheel
+ or keys; set touch-action back to pan-y if you ship it without. */
+ touch-action: none;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
diff --git a/niveus/niveus.js b/niveus/niveus.js
index 816c772..6d07192 100644
--- a/niveus/niveus.js
+++ b/niveus/niveus.js
@@ -67,31 +67,85 @@
function transition(update, options) {
options = options || {};
- if (!canTransition()) {
+ // Deliberately not a view transition. A page-wide snapshot cannot blur what
+ // sits behind frosted glass, so for the length of the change every frosted
+ // bar, menu and Glaze pane went clear and showed the page sharply through
+ // itself — on every theme, brand and selection change. Nothing here
+ // snapshots the page: surfaces transition their own colours, a selection
+ // marker is moved by animating the marker itself, and arriving content
+ // animates on its own. The glass stays live throughout.
+ var groups = [].concat(options.scope || []).filter(Boolean);
+ var contents = [].concat(options.content || []).filter(Boolean);
+
+ if (options.kind === "theme" || prefersReducedMotion() || typeof Element.prototype.animate !== "function") {
return Promise.resolve(update());
}
- if (options.direction) root.dataset.nvDirection = options.direction;
- if (options.kind) root.dataset.nvTransition = options.kind;
+ var before = groups.map(markerOf);
+ update();
- var groups = [].concat(options.scope || []).filter(Boolean);
- var lifted = [];
- groups.forEach(function (group) {
- group.setAttribute("data-nv-travelling", "");
- lifted = lifted.concat(liftLabels(group));
+ var motion = [];
+ groups.forEach(function (group, index) {
+ var from = before[index];
+ var to = markerOf(group);
+ if (!from || !to || from.item === to.item) return;
+ motion.push(travelMarker(from.rect, to));
+ });
+ contents.forEach(function (content) {
+ motion.push(arriveContent(content, options.direction));
});
- var view = document.startViewTransition(update);
+ return Promise.all(motion).then(function () {});
+ }
- function clear() {
- delete root.dataset.nvDirection;
- delete root.dataset.nvTransition;
- groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); });
- lifted.forEach(function (item) { item.style.viewTransitionName = ""; });
- }
- view.finished.then(clear, clear);
+ var MARKED = '.nv-nav__item[aria-current], .nv-nav__item--active, .nv-nav__item[aria-selected="true"]';
- return view.finished;
+ /** The item carrying a group's selection marker, and where it is now. */
+ function markerOf(group) {
+ var item = group.matches(MARKED) ? group : group.querySelector(MARKED);
+ return item ? { item: item, rect: item.getBoundingClientRect() } : null;
+ }
+
+ function tokenMs(name) {
+ return parseFloat(getComputedStyle(root).getPropertyValue(name)) || 0;
+ }
+
+ function tokenEase(name) {
+ return getComputedStyle(root).getPropertyValue(name).trim() || "ease-out";
+ }
+
+ /**
+ * FLIP: the marker is a ::before filling its item, so the new marker is
+ * drawn where the old one was and animated to where it belongs. It paints
+ * behind the labels exactly as it does at rest, so none of them are washed as
+ * it passes.
+ */
+ function travelMarker(from, to) {
+ var rect = to.rect;
+ var duration = tokenMs("--nv-duration-travel");
+ if (!duration || !rect.width || !rect.height) return Promise.resolve();
+ var start = {
+ transformOrigin: "0 0",
+ translate: (from.left - rect.left) + "px " + (from.top - rect.top) + "px",
+ scale: (from.width / rect.width) + " " + (from.height / rect.height),
+ };
+ var end = { transformOrigin: "0 0", translate: "0 0", scale: "1 1" };
+ return to.item.animate([start, end], {
+ duration: duration,
+ easing: tokenEase("--nv-ease-emphasized"),
+ pseudoElement: "::before",
+ }).finished.catch(function () {});
+ }
+
+ /** New content arrives from the side it lies on, or rises into place. */
+ function arriveContent(element, direction) {
+ var duration = tokenMs("--nv-duration-expand");
+ if (!duration) return Promise.resolve();
+ var shift = direction === "backward" ? -28 : direction === "forward" ? 28 : 0;
+ return element.animate([
+ { opacity: 0, translate: shift + "px " + (shift ? 0 : 10) + "px" },
+ { opacity: 1, translate: "0 0" },
+ ], { duration: duration, easing: tokenEase("--nv-ease-emphasized") }).finished.catch(function () {});
}
/** "light" | "dark" | "system" — system means: follow the environment. */
@@ -168,16 +222,10 @@
}
if (next < 0 || next === current) return Promise.resolve();
- // The outgoing and incoming panels carry one name between them — the old
- // one in the old state, the new one in the new — so the browser treats
- // them as one surface whose content changes. Named only for the length of
- // the change, so any number of tab sets can live on one page.
var panelOf = function (candidate) {
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
};
- var outgoing = panelOf(tabs[current]);
var incoming = panelOf(tab);
- if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
var done = transition(function () {
tabs.forEach(function (candidate, index) {
@@ -188,12 +236,7 @@
var panel = panelOf(candidate);
if (panel) panel.hidden = !selected;
});
- if (outgoing) outgoing.style.viewTransitionName = "";
- if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel";
- }, { direction: next > current ? "forward" : "backward", scope: tablist });
-
- var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; };
- done.then(settle, settle);
+ }, { direction: next > current ? "forward" : "backward", scope: tablist, content: incoming });
tab.focus();
return done;
@@ -695,7 +738,6 @@
var withFormat = picker.hasAttribute("data-nv-format-toggle");
var placed = false;
var last = "";
- var frame = 0;
function indexOf(column) {
var row = column.firstElementChild ? column.firstElementChild.offsetHeight : 0;
@@ -725,7 +767,7 @@
// 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);
+ modeTimer = global.setTimeout(switchFormat, 120);
return;
}
@@ -773,7 +815,7 @@
if (wants) h24 = (h24 % 12) + (fi === 2 ? 12 : 0);
twelve = wants;
picker.dataset.nvHours = twelve ? "12" : "24";
- build();
+ build(true);
}
function buildColumn(label, items) {
@@ -791,14 +833,18 @@
});
// 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
+ // back — for a wheel or trackpad here, and for a finger or mouse drag
+ // below. The distance has resistance: the further it is pulled, the less
// each notch moves it.
var pull = 0;
var settle = 0;
- var limit = 36;
+ // A phone's list gives a fraction of a row, not a whole one: any more and
+ // the row under the lens looks like its neighbour.
+ function limitOf() {
+ var first = list.firstElementChild;
+ return Math.max(8, Math.round((first ? first.offsetHeight : 36) * 0.4));
+ }
+ var colFrame = 0;
function clearPull() {
pull = 0;
@@ -816,15 +862,17 @@
if (!hold) settle = global.setTimeout(clearPull, 90);
}
- // Dragging with a mouse or pen. Touch scrolls natively; a desktop
- // pointer has no such thing, so without this a wheel cannot be grabbed.
- // Snapping is off while the pointer is down so the list follows it, the
- // end gives with resistance, and on release the list coasts to the row
- // the motion was heading for.
+ // Dragging, for every kind of pointer. A mouse has no native drag-scroll,
+ // and touch is taken over too (touch-action: none on the wheel) so that a
+ // phone, a desktop browser's phone mode and a mouse all run the same code:
+ // the same inertia, the same give at the ends, nothing left to each
+ // engine's own panning. Snapping is off while the pointer is down so the
+ // list follows it, and on release the list coasts to the row the motion
+ // was heading for.
var drag = null;
list.addEventListener("pointerdown", function (event) {
- if (event.pointerType === "touch" || event.button !== 0) return;
+ if (event.button !== 0) return;
drag = {
id: event.pointerId, y: event.clientY, top: list.scrollTop,
lastY: event.clientY, lastT: event.timeStamp, velocity: 0, moved: false,
@@ -854,7 +902,7 @@
list.scrollTop = target < 0 ? 0 : max;
var over = target < 0 ? -target : target - max;
if (!prefersReducedMotion()) {
- setPull((target < 0 ? 1 : -1) * Math.min(limit, over * 0.35), true);
+ setPull((target < 0 ? 1 : -1) * Math.min(limitOf(), over * 0.35), true);
}
} else {
list.scrollTop = target;
@@ -893,18 +941,30 @@
if (!pushing && !pull) return;
if (drag && drag.moved) return;
event.preventDefault();
+ var limit = limitOf();
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);
+ if (!prefersReducedMotion()) setPull(direction * Math.round(limitOf() * 0.4));
};
+ // The format changes the moment the format wheel comes to rest, not a
+ // fixed wait after it stops being touched.
+ list.addEventListener("scrollend", function () {
+ if (list !== formatColumn) return;
+ global.clearTimeout(modeTimer);
+ switchFormat();
+ });
+
+ // One frame slot per wheel: with a slot shared across the picker, a scroll
+ // on one wheel could swallow the last scroll event of another and leave
+ // its rows and its value one step behind.
list.addEventListener("scroll", function () {
- if (frame) return;
- frame = global.requestAnimationFrame(function () { frame = 0; read(); drum(list); });
+ if (colFrame) return;
+ colFrame = global.requestAnimationFrame(function () { colFrame = 0; read(); drum(list); });
}, { passive: true });
list.addEventListener("click", function (event) {
@@ -971,7 +1031,7 @@
// 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() {
+ function build(animate) {
var hourItems = [];
var count = twelve ? 12 : 24;
for (var h = 0; h < count; h++) hourItems.push(twelve ? String(h + 1) : pad(h));
@@ -992,6 +1052,19 @@
[hourColumn, minuteColumn, formatColumn || periodColumn].forEach(function (list) {
if (list) drum(list);
});
+
+ // A new format is a new set of hours, so they arrive rather than swap:
+ // the wheel settles into place from slightly smaller and blurred, and
+ // the lens answers with a small press.
+ var duration = tokenMs("--nv-duration-expand");
+ if (animate && duration && !prefersReducedMotion() && hourColumn.animate) {
+ var easing = tokenEase("--nv-ease-spring");
+ hourColumn.animate([
+ { opacity: 0, scale: 0.86, filter: "blur(4px)" },
+ { opacity: 1, scale: 1, filter: "blur(0)" },
+ ], { duration: duration * 1.4, easing: easing });
+ wheels.animate([{ scale: "0.94 0.78" }, { scale: "1 1" }], { duration: duration, easing: easing, pseudoElement: "::before" });
+ }
}
picker.textContent = "";