mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
Stop snapshotting the page; own the wheel gesture; one-row phone nav
Selection markers, tab panels, brand changes and theme changes no longer use a view transition. The page snapshot could not blur what sits behind frosted glass, so every frosted surface went clear for the length of the change. nv.transition now measures the marker before and after, and animates the marker itself (FLIP); arriving content animates on its own; colours fade on their own surfaces. Verified in Firefox: no view transition is ever started. Time picker: wheels take every pointer type (mouse, pen, touch) with touch-action: none, so a phone, a browser's phone mode and a desktop behave alike. The rubber-band is capped at about 40% of a row (a whole row made the neighbour look selected). Each wheel has its own frame slot, so one wheel can no longer swallow another's last scroll event. Switching 24H / AM / PM happens when the wheel settles, and the hours animate into the new layout. Carousel arrows are frosted glass, as anything floating over content should be. At phone width the top bar keeps to one row and its link list scrolls. GUIDELINES.md records the new cross-engine rules. 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
65d81481bd
commit
2e956cec42
11 changed files with 273 additions and 112 deletions
|
|
@ -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`.
|
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
|
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. 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.
|
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.
|
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
|
1. **Never wrap a change in a page-wide view transition.** The snapshot cannot
|
||||||
snapshot cannot blur what sits behind frosted glass, so for its whole
|
blur what sits behind frosted glass, so for its whole duration every frosted
|
||||||
duration every frosted bar, menu and Glaze pane goes clear and shows the page
|
bar, menu and Glaze pane goes clear and shows the page sharply through
|
||||||
sharply through itself. Let surfaces transition their own colours. Selection
|
itself. This applied to theme changes, brand changes *and* selection markers,
|
||||||
markers still use a short view transition; keep it brief, and prefer a
|
and was only noticed on the third. Let surfaces transition their own colours;
|
||||||
per-element animation for anything inside frosted chrome.
|
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.**
|
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
|
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
|
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
|
8. **Same-page links travel.** `scroll-behavior: smooth` under
|
||||||
`prefers-reduced-motion: no-preference`; a jump to a section is the textbook
|
`prefers-reduced-motion: no-preference`; a jump to a section is the textbook
|
||||||
change a user could have watched.
|
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
|
`backdrop-filter`, reports no hover capability (so hover-only controls such
|
||||||
as scroller arrows disappear), and may not deliver scroll events reliably.
|
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.
|
real display.
|
||||||
|
|
||||||
### 14.3 Documentation
|
### 14.3 Documentation
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -186,22 +186,24 @@ could have watched it reach.
|
||||||
|
|
||||||
| Change | How it stays continuous |
|
| 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. |
|
| 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 | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
Most of this is plain CSS. The two cases CSS cannot do alone — a selection
|
||||||
moving *between two elements*, one panel replacing another, and the whole page
|
moving *between two elements*, and one panel replacing another — need one line
|
||||||
changing appearance — need a view transition, which needs one line of script:
|
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
|
```js
|
||||||
nv.transition(() => { // or document.startViewTransition directly
|
nv.transition(() => {
|
||||||
previous.removeAttribute("aria-current");
|
previous.removeAttribute("aria-current");
|
||||||
next.setAttribute("aria-current", "page");
|
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
|
`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
|
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.
|
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
|
Because the marker is animated directly, several selection groups can change at
|
||||||
browser skips the transition entirely. Each selection group therefore needs its
|
once and none of them needs a name. (`--nv-selection-name` only matters to
|
||||||
own marker name:
|
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
|
||||||
```html
|
the snapshot flattens frosted glass for as long as it runs.)
|
||||||
<nav class="nv-nav" style="--nv-selection-name: nv-sel-sidebar"> … </nav>
|
|
||||||
<nav class="nv-nav-tabs" style="--nv-selection-name: nv-sel-tabs"> … </nav>
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`prefers-reduced-motion` collapses every duration at the token level and skips
|
`prefers-reduced-motion` collapses every duration at the token level and skips
|
||||||
view transitions entirely. The change still happens; it just arrives.
|
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 |
|
| 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) |
|
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
|
||||||
| Anchor positioning | menus tied to their trigger | the menu centres itself |
|
| Anchor positioning | menus tied to their trigger | the menu centres itself |
|
||||||
| `popover` | menus, light dismiss | needs the application's own toggle |
|
| `popover` | menus, light dismiss | needs the application's own toggle |
|
||||||
|
|
|
||||||
16
REFERENCE.md
16
REFERENCE.md
|
|
@ -488,7 +488,7 @@ everything still renders — state changes simply arrive instead of travelling.
|
||||||
|
|
||||||
| Function | Signature | Does |
|
| 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 `<html>`. |
|
| `nv.setTheme` | `("light" \| "dark" \| "system") => Promise` | Cross-fades the whole surface into the new appearance and fires `nv:theme` on `<html>`. |
|
||||||
| `nv.syncThemeControls` | `(theme) => void` | Sets `aria-pressed` on every `[data-nv-theme]`. |
|
| `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. |
|
| `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.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.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.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.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` | |
|
| `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-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-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-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
|
||||||
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
|
| `data-nv-direction` | `<html>` | Set during a view transition that application code starts itself. |
|
||||||
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. |
|
| `data-nv-transition` | `<html>` | `"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-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
|
## Rules that bite
|
||||||
|
|
||||||
1. **`view-transition-name` must be unique per document.** A duplicate makes
|
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`.)
|
||||||
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`.
|
|
||||||
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
|
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
|
||||||
and the value must be unique per pair.
|
and the value must be unique per pair.
|
||||||
3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies
|
3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies
|
||||||
|
|
|
||||||
45
dist/niveus.css
vendored
45
dist/niveus.css
vendored
|
|
@ -3349,6 +3349,32 @@
|
||||||
position: static;
|
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,
|
/* A navigation group used inside content rather than as page chrome: no bar,
|
||||||
no border, no blur — just the items and their travelling selection. */
|
no border, no blur — just the items and their travelling selection. */
|
||||||
.nv-nav--inline {
|
.nv-nav--inline {
|
||||||
|
|
@ -4136,7 +4162,11 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
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;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
|
|
@ -4471,6 +4501,19 @@
|
||||||
--nv-button-offset: 0 -50%;
|
--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
|
/* Belt and braces: the frame knows these controls float, so their centring
|
||||||
survives even if the markup forgets --float. */
|
survives even if the markup forgets --float. */
|
||||||
.nv-scroller-frame [data-nv-scroll],
|
.nv-scroller-frame [data-nv-scroll],
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@
|
||||||
.forEach((el) => el.removeAttribute("aria-current"));
|
.forEach((el) => el.removeAttribute("aria-current"));
|
||||||
item.setAttribute("aria-current", "page");
|
item.setAttribute("aria-current", "page");
|
||||||
render(item.dataset.thread);
|
render(item.dataset.thread);
|
||||||
}, { scope: list });
|
}, { scope: list, content: transcript });
|
||||||
scroller.scrollTop = 0;
|
scroller.scrollTop = 0;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -491,9 +491,13 @@
|
||||||
const count = document.getElementById("basket-count");
|
const count = document.getElementById("basket-count");
|
||||||
document.querySelectorAll("[data-add]").forEach((button) => {
|
document.querySelectorAll("[data-add]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
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";
|
button.textContent = "Added";
|
||||||
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
|
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
index.html
12
index.html
|
|
@ -1486,21 +1486,19 @@
|
||||||
const brandInput = document.getElementById("brand-input");
|
const brandInput = document.getElementById("brand-input");
|
||||||
const brandValue = document.getElementById("brand-value");
|
const brandValue = document.getElementById("brand-value");
|
||||||
|
|
||||||
function setBrand(value, continuous) {
|
/* No page-wide transition here: surfaces fade their own colours, and a
|
||||||
const apply = () => {
|
snapshot of the page would flatten every frosted surface while it ran. */
|
||||||
|
function setBrand(value) {
|
||||||
document.documentElement.style.setProperty("--nv-brand", value);
|
document.documentElement.style.setProperty("--nv-brand", value);
|
||||||
brandInput.value = value;
|
brandInput.value = value;
|
||||||
brandValue.textContent = value;
|
brandValue.textContent = value;
|
||||||
};
|
|
||||||
if (continuous) nv.transition(apply, { kind: "theme" });
|
|
||||||
else apply();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
brandInput.addEventListener("input", (event) => setBrand(event.target.value, false));
|
brandInput.addEventListener("input", (event) => setBrand(event.target.value));
|
||||||
|
|
||||||
document.getElementById("brand-presets").addEventListener("click", (event) => {
|
document.getElementById("brand-presets").addEventListener("click", (event) => {
|
||||||
const preset = event.target.closest("[data-brand]");
|
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
|
/* A selection group whose emphasis travels: one marker, moved inside a
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,32 @@
|
||||||
position: static;
|
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,
|
/* A navigation group used inside content rather than as page chrome: no bar,
|
||||||
no border, no blur — just the items and their travelling selection. */
|
no border, no blur — just the items and their travelling selection. */
|
||||||
.nv-nav--inline {
|
.nv-nav--inline {
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,19 @@
|
||||||
--nv-button-offset: 0 -50%;
|
--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
|
/* Belt and braces: the frame knows these controls float, so their centring
|
||||||
survives even if the markup forgets --float. */
|
survives even if the markup forgets --float. */
|
||||||
.nv-scroller-frame [data-nv-scroll],
|
.nv-scroller-frame [data-nv-scroll],
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,11 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
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;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
|
|
|
||||||
169
niveus/niveus.js
169
niveus/niveus.js
|
|
@ -67,31 +67,85 @@
|
||||||
function transition(update, options) {
|
function transition(update, options) {
|
||||||
options = 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());
|
return Promise.resolve(update());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.direction) root.dataset.nvDirection = options.direction;
|
var before = groups.map(markerOf);
|
||||||
if (options.kind) root.dataset.nvTransition = options.kind;
|
update();
|
||||||
|
|
||||||
var groups = [].concat(options.scope || []).filter(Boolean);
|
var motion = [];
|
||||||
var lifted = [];
|
groups.forEach(function (group, index) {
|
||||||
groups.forEach(function (group) {
|
var from = before[index];
|
||||||
group.setAttribute("data-nv-travelling", "");
|
var to = markerOf(group);
|
||||||
lifted = lifted.concat(liftLabels(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);
|
|
||||||
|
|
||||||
return view.finished;
|
var MARKED = '.nv-nav__item[aria-current], .nv-nav__item--active, .nv-nav__item[aria-selected="true"]';
|
||||||
|
|
||||||
|
/** 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. */
|
/** "light" | "dark" | "system" — system means: follow the environment. */
|
||||||
|
|
@ -168,16 +222,10 @@
|
||||||
}
|
}
|
||||||
if (next < 0 || next === current) return Promise.resolve();
|
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) {
|
var panelOf = function (candidate) {
|
||||||
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
|
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
|
||||||
};
|
};
|
||||||
var outgoing = panelOf(tabs[current]);
|
|
||||||
var incoming = panelOf(tab);
|
var incoming = panelOf(tab);
|
||||||
if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
|
|
||||||
|
|
||||||
var done = transition(function () {
|
var done = transition(function () {
|
||||||
tabs.forEach(function (candidate, index) {
|
tabs.forEach(function (candidate, index) {
|
||||||
|
|
@ -188,12 +236,7 @@
|
||||||
var panel = panelOf(candidate);
|
var panel = panelOf(candidate);
|
||||||
if (panel) panel.hidden = !selected;
|
if (panel) panel.hidden = !selected;
|
||||||
});
|
});
|
||||||
if (outgoing) outgoing.style.viewTransitionName = "";
|
}, { direction: next > current ? "forward" : "backward", scope: tablist, content: incoming });
|
||||||
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);
|
|
||||||
|
|
||||||
tab.focus();
|
tab.focus();
|
||||||
return done;
|
return done;
|
||||||
|
|
@ -695,7 +738,6 @@
|
||||||
var withFormat = picker.hasAttribute("data-nv-format-toggle");
|
var withFormat = picker.hasAttribute("data-nv-format-toggle");
|
||||||
var placed = false;
|
var placed = false;
|
||||||
var last = "";
|
var last = "";
|
||||||
var frame = 0;
|
|
||||||
|
|
||||||
function indexOf(column) {
|
function indexOf(column) {
|
||||||
var row = column.firstElementChild ? column.firstElementChild.offsetHeight : 0;
|
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.
|
// swiped to. Wait for it to settle, then rebuild the hours for it.
|
||||||
if (formatColumn && (fi > 0) !== twelve) {
|
if (formatColumn && (fi > 0) !== twelve) {
|
||||||
global.clearTimeout(modeTimer);
|
global.clearTimeout(modeTimer);
|
||||||
modeTimer = global.setTimeout(switchFormat, 160);
|
modeTimer = global.setTimeout(switchFormat, 120);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -773,7 +815,7 @@
|
||||||
if (wants) h24 = (h24 % 12) + (fi === 2 ? 12 : 0);
|
if (wants) h24 = (h24 % 12) + (fi === 2 ? 12 : 0);
|
||||||
twelve = wants;
|
twelve = wants;
|
||||||
picker.dataset.nvHours = twelve ? "12" : "24";
|
picker.dataset.nvHours = twelve ? "12" : "24";
|
||||||
build();
|
build(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumn(label, items) {
|
function buildColumn(label, items) {
|
||||||
|
|
@ -791,14 +833,18 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Past either end the wheel gives, as a phone's list does, then springs
|
// 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);
|
// back — for a wheel or trackpad here, and for a finger or mouse drag
|
||||||
// overscroll-behavior keeps that inside the wheel. Mouse wheels and
|
// below. The distance has resistance: the further it is pulled, the less
|
||||||
// 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.
|
// each notch moves it.
|
||||||
var pull = 0;
|
var pull = 0;
|
||||||
var settle = 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() {
|
function clearPull() {
|
||||||
pull = 0;
|
pull = 0;
|
||||||
|
|
@ -816,15 +862,17 @@
|
||||||
if (!hold) settle = global.setTimeout(clearPull, 90);
|
if (!hold) settle = global.setTimeout(clearPull, 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dragging with a mouse or pen. Touch scrolls natively; a desktop
|
// Dragging, for every kind of pointer. A mouse has no native drag-scroll,
|
||||||
// pointer has no such thing, so without this a wheel cannot be grabbed.
|
// and touch is taken over too (touch-action: none on the wheel) so that a
|
||||||
// Snapping is off while the pointer is down so the list follows it, the
|
// phone, a desktop browser's phone mode and a mouse all run the same code:
|
||||||
// end gives with resistance, and on release the list coasts to the row
|
// the same inertia, the same give at the ends, nothing left to each
|
||||||
// the motion was heading for.
|
// 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;
|
var drag = null;
|
||||||
|
|
||||||
list.addEventListener("pointerdown", function (event) {
|
list.addEventListener("pointerdown", function (event) {
|
||||||
if (event.pointerType === "touch" || event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
drag = {
|
drag = {
|
||||||
id: event.pointerId, y: event.clientY, top: list.scrollTop,
|
id: event.pointerId, y: event.clientY, top: list.scrollTop,
|
||||||
lastY: event.clientY, lastT: event.timeStamp, velocity: 0, moved: false,
|
lastY: event.clientY, lastT: event.timeStamp, velocity: 0, moved: false,
|
||||||
|
|
@ -854,7 +902,7 @@
|
||||||
list.scrollTop = target < 0 ? 0 : max;
|
list.scrollTop = target < 0 ? 0 : max;
|
||||||
var over = target < 0 ? -target : target - max;
|
var over = target < 0 ? -target : target - max;
|
||||||
if (!prefersReducedMotion()) {
|
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 {
|
} else {
|
||||||
list.scrollTop = target;
|
list.scrollTop = target;
|
||||||
|
|
@ -893,18 +941,30 @@
|
||||||
if (!pushing && !pull) return;
|
if (!pushing && !pull) return;
|
||||||
if (drag && drag.moved) return;
|
if (drag && drag.moved) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
var limit = limitOf();
|
||||||
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
|
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
|
||||||
setPull(Math.max(-limit, Math.min(limit, next)));
|
setPull(Math.max(-limit, Math.min(limit, next)));
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
// A key pressed at the end nudges the wheel instead of doing nothing.
|
// A key pressed at the end nudges the wheel instead of doing nothing.
|
||||||
list.nvNudge = function (direction) {
|
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 () {
|
list.addEventListener("scroll", function () {
|
||||||
if (frame) return;
|
if (colFrame) return;
|
||||||
frame = global.requestAnimationFrame(function () { frame = 0; read(); drum(list); });
|
colFrame = global.requestAnimationFrame(function () { colFrame = 0; read(); drum(list); });
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
list.addEventListener("click", function (event) {
|
list.addEventListener("click", function (event) {
|
||||||
|
|
@ -971,7 +1031,7 @@
|
||||||
|
|
||||||
// The wheels depend on the format, so a change of format rebuilds them and
|
// The wheels depend on the format, so a change of format rebuilds them and
|
||||||
// lands on the same moment in the new layout.
|
// lands on the same moment in the new layout.
|
||||||
function build() {
|
function build(animate) {
|
||||||
var hourItems = [];
|
var hourItems = [];
|
||||||
var count = twelve ? 12 : 24;
|
var count = twelve ? 12 : 24;
|
||||||
for (var h = 0; h < count; h++) hourItems.push(twelve ? String(h + 1) : pad(h));
|
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) {
|
[hourColumn, minuteColumn, formatColumn || periodColumn].forEach(function (list) {
|
||||||
if (list) drum(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 = "";
|
picker.textContent = "";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue