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 = "";