mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
Add Glaze material, pickers, slider and design guidelines
Glaze is glass with a dial: --nv-glass-frost (0 clear .. 1 heavy) moves backdrop blur, tint and saturation together, read where the surface is drawn so any subtree or slider can change it. A masked rim and a specular bloom follow the pointer via niveus.js; without it the light sits at its default and the material still reads. Falls back to the elevated solid under reduced transparency or without backdrop-filter. New components: .nv-slider (binds to a token with data-nv-var), .nv-calendar, .nv-timepicker (24-hour by default, optional 24H / AM/PM switch, value always HH:MM), and .nv-picker with .nv-menu--listbox (search, type-ahead, keys). All render without niveus.js except where noted. GUIDELINES.md is the normative rulebook for people and AI agents. check.py, build.py and build.py --check pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f340d566b8
commit
369d1cd8f7
17 changed files with 2679 additions and 6 deletions
689
GUIDELINES.md
Normal file
689
GUIDELINES.md
Normal file
|
|
@ -0,0 +1,689 @@
|
||||||
|
# Niveus Design Guidelines
|
||||||
|
|
||||||
|
**Status:** normative · **Version:** 1.0 · **Applies to:** every person and every
|
||||||
|
AI agent that designs with, builds on, or changes Niveus.
|
||||||
|
|
||||||
|
This document says what Niveus is, what it decides, and what it forbids. It is
|
||||||
|
written to be followed, not admired. Where it conflicts with a habit, a
|
||||||
|
preference, or a model's prior, this document wins.
|
||||||
|
|
||||||
|
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT** and **MAY** are
|
||||||
|
used as in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). A **MUST** is
|
||||||
|
enforced by a tool or is a defect when broken. A **SHOULD** needs a stated
|
||||||
|
reason to break. A **MAY** is a freedom, not a recommendation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
1. [How to use this document](#1-how-to-use-this-document)
|
||||||
|
2. [Identity](#2-identity)
|
||||||
|
3. [Principles and their order](#3-principles-and-their-order)
|
||||||
|
4. [Materials](#4-materials)
|
||||||
|
5. [Glaze](#5-glaze)
|
||||||
|
6. [Elevation and light](#6-elevation-and-light)
|
||||||
|
7. [Color and appearance](#7-color-and-appearance)
|
||||||
|
8. [Type, space and shape](#8-type-space-and-shape)
|
||||||
|
9. [Motion and continuity](#9-motion-and-continuity)
|
||||||
|
10. [Interaction and state](#10-interaction-and-state)
|
||||||
|
11. [Accessibility](#11-accessibility)
|
||||||
|
12. [Components](#12-components)
|
||||||
|
13. [Customization contract](#13-customization-contract)
|
||||||
|
14. [Authoring rules](#14-authoring-rules)
|
||||||
|
15. [Protocol for AI agents](#15-protocol-for-ai-agents)
|
||||||
|
16. [Changing the system](#16-changing-the-system)
|
||||||
|
17. [Anti-patterns](#17-anti-patterns)
|
||||||
|
18. [Compliance checklist](#18-compliance-checklist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. How to use this document
|
||||||
|
|
||||||
|
### 1.1 Sources of truth, in order of authority
|
||||||
|
|
||||||
|
When two sources disagree, the higher one is right and the lower one has a bug.
|
||||||
|
|
||||||
|
| # | Source | Answers |
|
||||||
|
| - | ------ | ------- |
|
||||||
|
| 1 | `tools/check.py`, `tools/build.py --check` | What is *allowed*. Mechanical, not negotiable. |
|
||||||
|
| 2 | **This document** | What is *intended*, and why. |
|
||||||
|
| 3 | [`REFERENCE.md`](REFERENCE.md) | What exists: every class, token, attribute, function, event. |
|
||||||
|
| 4 | [`README.md`](README.md) | How to install and orient. |
|
||||||
|
| 5 | The CSS and JS | How it is currently built. |
|
||||||
|
|
||||||
|
`REFERENCE.md` is the complete API. If a class, token or attribute is not in it,
|
||||||
|
**it does not exist** and MUST NOT be used, guessed, or invented.
|
||||||
|
|
||||||
|
### 1.2 Who this is for
|
||||||
|
|
||||||
|
- **Designers and engineers** use it to decide, and to review each other.
|
||||||
|
- **AI agents** use it as an operating contract. Section 15 is written for them
|
||||||
|
directly; sections 2–14 apply to them as to anyone.
|
||||||
|
- **Product authors** who ship Niveus to end users use section 13 to decide what
|
||||||
|
their users may customize.
|
||||||
|
|
||||||
|
### 1.3 What this document is not
|
||||||
|
|
||||||
|
It is not a style preference list. Every rule here exists because breaking it
|
||||||
|
produces a specific, nameable failure: an unreadable surface, a broken theme, a
|
||||||
|
control that behaves differently in two places, a port that has drifted. Rules
|
||||||
|
state their reason so they can be applied to cases the document did not foresee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Identity
|
||||||
|
|
||||||
|
Niveus is a **design-system layer**, not a component library bolted onto an
|
||||||
|
application. Applications consume it; they do not redefine it.
|
||||||
|
|
||||||
|
**It is:** tactile (controls are bevelled keys with light falling across them),
|
||||||
|
adaptive (one brand colour drives light and dark), and continuous (state
|
||||||
|
changes can be followed). Fields are wells cut into a surface. Windows are
|
||||||
|
solid. Glass is kept for what floats.
|
||||||
|
|
||||||
|
**It is not:** a flat system, a material-for-its-own-sake system, or a
|
||||||
|
glassmorphism kit. Skeuomorphism here is **functional**: a surface imitates a
|
||||||
|
physical property only where that property answers a question the user is
|
||||||
|
already asking.
|
||||||
|
|
||||||
|
> Niveus is not about making interfaces look physical. It is about making
|
||||||
|
> digital surfaces feel understandable.
|
||||||
|
|
||||||
|
The questions a surface must answer are: **What is this? Is it above or below?
|
||||||
|
Where did it come from? Did my action register?** An effect that answers none
|
||||||
|
of them is decoration, and decoration makes an interface tiring.
|
||||||
|
|
||||||
|
### 2.1 Naming
|
||||||
|
|
||||||
|
Names in Niveus describe a thing's role, never another vendor's product. A
|
||||||
|
feature MUST NOT be named after a third party's trademark, and documentation
|
||||||
|
MUST NOT present a Niveus feature as a clone of one. (Niveus's adjustable-frost
|
||||||
|
glass is called **Glaze** for this reason.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Principles and their order
|
||||||
|
|
||||||
|
| Principle | Rule | What it costs |
|
||||||
|
| --------- | ---- | ------------- |
|
||||||
|
| **Clarity** | Every element has a purpose. Decoration never competes with content. | Fewer flourishes. |
|
||||||
|
| **Soft physicality** | Depth comes from shadow, highlight and inset, not ornament. | No gratuitous textures. |
|
||||||
|
| **Material** | Surfaces are made of something: solid, elevated, inset, frosted, glass, glaze. | Every surface must pick one. |
|
||||||
|
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. | Saying no to good-looking things. |
|
||||||
|
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. | No local variations. |
|
||||||
|
| **Adaptability** | Nothing is defined in terms of one appearance or one colour. | Every value is a role. |
|
||||||
|
| **Continuity** | Nothing pops into a state the user could have watched it reach. | Motion must be earned and bounded. |
|
||||||
|
| **Customizability** | Where the author allows it, the person using the product can tune it. | A bounded, documented set of dials. |
|
||||||
|
|
||||||
|
### 3.1 Precedence
|
||||||
|
|
||||||
|
When principles conflict, resolve in this order:
|
||||||
|
|
||||||
|
1. **Accessibility** (section 11) overrides everything. A beautiful surface that
|
||||||
|
cannot be read or operated is broken.
|
||||||
|
2. **Clarity** overrides material and motion.
|
||||||
|
3. **Restraint** overrides every effect. A quiet interface with one thing moving
|
||||||
|
reads clearly; a busy one does not.
|
||||||
|
4. **Consistency** overrides local preference.
|
||||||
|
5. **Customizability** operates *inside* the limits above, never through them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Materials
|
||||||
|
|
||||||
|
A material answers *what light does when it hits a surface*. Elevation answers
|
||||||
|
*how far the surface is from the canvas*. They are independent and combine
|
||||||
|
freely.
|
||||||
|
|
||||||
|
| Material | Class | Use for | MUST NOT be used for |
|
||||||
|
| -------- | ----- | ------- | -------------------- |
|
||||||
|
| Solid | `.nv-material-solid` | Content. The default. | — |
|
||||||
|
| Elevated | `.nv-material-elevated` | Surfaces that must be read before their context. | — |
|
||||||
|
| Inset | `.nv-material-inset` | Fields, wells, tracks: anything recessed. | Anything that is pressed *toward* the user. |
|
||||||
|
| Frosted | `.nv-material-frosted` | Chrome that content passes under: bars, docks, menus, toolbars. | Containers of body copy or forms. |
|
||||||
|
| Glass | `.nv-material-glass` | Contextual layers where the content behind must stay legible. | Surfaces with no content behind them. |
|
||||||
|
| Glaze | `.nv-material-glaze` | Floating, interactive panes where the visitor's control over frost is a feature. See section 5. | Whole-page backgrounds; anything the user must read at length. |
|
||||||
|
|
||||||
|
### 4.1 Rules
|
||||||
|
|
||||||
|
1. **Solid by default.** Anything that *holds content* — windows, cards, dialogs,
|
||||||
|
tables, forms — is solid. Translucency is reserved for what *floats over*
|
||||||
|
content.
|
||||||
|
2. **Translucency means "this floats".** Used everywhere, it stops meaning
|
||||||
|
anything, tints whatever is behind it, and forces the GPU to composite.
|
||||||
|
Spend it.
|
||||||
|
3. **Raised things are bevelled and sheened.** Lit along the top edge, shaded
|
||||||
|
along the bottom, with a contact outline. Compose with
|
||||||
|
`box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2)` and
|
||||||
|
`background-image: var(--nv-sheen-raised)` over a role colour.
|
||||||
|
4. **Never invent a material.** If none fits, the right answer is a different
|
||||||
|
composition, or a proposal under section 16 — not a local `backdrop-filter`.
|
||||||
|
5. **Translucent materials MUST degrade.** `prefers-reduced-transparency` and
|
||||||
|
missing `backdrop-filter` fall back to the solid equivalent. A new
|
||||||
|
translucent surface without that fallback is incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Glaze
|
||||||
|
|
||||||
|
Glaze is glass with a dial. It exists to make Niveus feel *alive* — a surface
|
||||||
|
that catches light and answers to the person looking at it — without giving up
|
||||||
|
legibility.
|
||||||
|
|
||||||
|
### 5.1 Anatomy
|
||||||
|
|
||||||
|
| Part | Mechanism | Controlled by |
|
||||||
|
| ---- | --------- | ------------- |
|
||||||
|
| **Frost** | Backdrop blur, tint opacity and saturation, moved together | `--nv-glass-frost`, `0` (clear) … `1` (heavy frost); range set by `--nv-glaze-blur-max`, `--nv-glaze-tint-min`, `--nv-glaze-tint-max` |
|
||||||
|
| **Rim** | A 1.5px masked gradient border, bright on the side facing the light | `--nv-glaze-rim` (width), `--nv-color-glaze-rim-lit` / `-far` |
|
||||||
|
| **Specular** | A soft bloom under the pointer | `--nv-color-glaze-specular` |
|
||||||
|
| **Light** | Pointer position and angle written per surface | `niveus.js` → `--nv-light-x/-y/-angle` |
|
||||||
|
|
||||||
|
Frost moves blur, tint and saturation **together** on purpose. A pane that
|
||||||
|
changed only one of them would stop looking like the same pane.
|
||||||
|
|
||||||
|
### 5.2 Rules
|
||||||
|
|
||||||
|
1. **Frost is read where the surface is drawn.** Set `--nv-glass-frost` on
|
||||||
|
`:root` for the site, or on any subtree to change just that region. Never
|
||||||
|
copy a frost value into another token; that breaks subtree overrides.
|
||||||
|
2. **Something colourful MUST be behind it.** Glaze over a flat canvas looks like
|
||||||
|
a grey film. Use it over imagery, gradients, or an `.nv-bed`. If nothing is
|
||||||
|
behind it, use `elevated`.
|
||||||
|
3. **Never place Glaze behind work.** Forms, tables and long reading belong on
|
||||||
|
solid surfaces. Glaze is for what the user passes *through*: pickers,
|
||||||
|
popovers, docks, notifications, hover cards, command surfaces.
|
||||||
|
4. **Text on Glaze MUST meet contrast at every frost the person can choose.**
|
||||||
|
Clear glass over a busy backdrop is the worst case. A pane carrying body text
|
||||||
|
SHOULD bound its slider (`min="0.2"` or higher) so the lowest setting is
|
||||||
|
still legible. Verify against the busiest content that can sit behind it.
|
||||||
|
5. **Do not stack Glaze.** Glaze over Glaze compounds blur and tint into mud.
|
||||||
|
One translucent layer between content and the eye.
|
||||||
|
6. **Do not animate the blur radius.** Blur is expensive; change frost by
|
||||||
|
discrete steps or on user input, not as ambient motion.
|
||||||
|
7. **No JavaScript, no problem.** Without `niveus.js` the light sits at its
|
||||||
|
default (top left) and the material is still correct. Light tracking MUST
|
||||||
|
NOT be required for legibility, and is skipped for touch and under reduced
|
||||||
|
motion.
|
||||||
|
8. **Position.** Glaze carries zero-specificity `position: relative`; a menu or
|
||||||
|
dialog using it keeps the positioning its own component gave it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Elevation and light
|
||||||
|
|
||||||
|
Six levels, each describing one relationship between a surface and the canvas.
|
||||||
|
A shadow that is on neither scale is a shadow that means nothing.
|
||||||
|
|
||||||
|
| Level | Class | Relationship | Typical use |
|
||||||
|
| ----- | ----- | ------------ | ----------- |
|
||||||
|
| 0 | `.nv-elevation-0` | flush | painted on the canvas |
|
||||||
|
| 1 | `.nv-elevation-1` | resting | cards, rows, panels |
|
||||||
|
| 2 | `.nv-elevation-2` | raised | anything that reacts |
|
||||||
|
| 3 | `.nv-elevation-3` | floating | popovers, menus |
|
||||||
|
| 4 | `.nv-elevation-4` | lifted | dialogs, sheets |
|
||||||
|
| 5 | `.nv-elevation-5` | overlay | topmost; used sparingly |
|
||||||
|
|
||||||
|
Inset depth uses `.nv-elevation-inset-{sm,md,lg}`.
|
||||||
|
|
||||||
|
1. **Light comes from above.** Highlights sit on top edges, shade on bottom
|
||||||
|
edges. A highlight on a lower edge is a bug.
|
||||||
|
2. **Dark is not light inverted.** Surfaces get *lighter* as they rise, shadows
|
||||||
|
deepen, and the top highlight thins. Elevation therefore carries a tint, not
|
||||||
|
only a shadow.
|
||||||
|
3. **Elevation tracks the user's mental model, not the visual taste.** Raise
|
||||||
|
what the user can act on or must notice; do not raise to decorate.
|
||||||
|
4. **A recess catches light on its lower lip** (`--nv-ledge`). Wells are cut into
|
||||||
|
the surface, not painted darker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Color and appearance
|
||||||
|
|
||||||
|
### 7.1 The model
|
||||||
|
|
||||||
|
The whole colour system has **one input**: `--nv-brand`. Every role, in both
|
||||||
|
appearances, is derived from it with relative colour syntax. There is no second
|
||||||
|
place to define a colour.
|
||||||
|
|
||||||
|
- A component names a **role** (`--nv-color-*`); the appearance decides what the
|
||||||
|
role is made of, through `light-dark()`.
|
||||||
|
- `--nv-palette-*` is raw and does not adapt. **MUST NOT** be referenced from
|
||||||
|
application code.
|
||||||
|
- Appearance is `data-theme="light" | "dark"`, or absent to follow the OS.
|
||||||
|
- A subtree may rebrand with `data-nv-brand`.
|
||||||
|
|
||||||
|
### 7.2 Rules
|
||||||
|
|
||||||
|
1. **No literal colours** in components or examples (the sole exception is the
|
||||||
|
value of `--nv-brand`). `tools/check.py` fails on any.
|
||||||
|
2. **Every role exists in both appearances.** Adding `--nv-light-x` without
|
||||||
|
`--nv-dark-x` fails the parity check.
|
||||||
|
3. **Contrast is measured, not assumed.** The derived label on any filled colour
|
||||||
|
clears 4.5:1 for any brand. New roles that carry text MUST do the same.
|
||||||
|
4. **State is never colour alone.** Pair colour with a shape, icon, text or
|
||||||
|
attribute change (see 11).
|
||||||
|
5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry
|
||||||
|
meaning; they are not accents. Use `accent` for emphasis and brand.
|
||||||
|
6. **Dark mode is designed, not generated.** When a new surface is added, look at
|
||||||
|
it in dark. If it only works on white, it is not finished.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Type, space and shape
|
||||||
|
|
||||||
|
- **Type.** Instrument Sans for the interface, JetBrains Mono for code and
|
||||||
|
engraved labels, shipped in `fonts/`. Use the role classes — `.nv-display`,
|
||||||
|
`.nv-heading`, `.nv-title`, `.nv-body`, `.nv-label`, `.nv-caption`,
|
||||||
|
`.nv-overline` — not raw sizes. Sizes are in `rem` so the user's own font
|
||||||
|
size is respected. Tabular figures (`font-variant-numeric: tabular-nums`) for
|
||||||
|
any number that changes or aligns.
|
||||||
|
- **Space.** A 4px rhythm, expressed as `--nv-space-*`. Values are tokens; a
|
||||||
|
one-off `13px` is a defect.
|
||||||
|
- **Shape.** `--nv-radius-*`. Nested radii MUST be concentric: use
|
||||||
|
`--nv-radius-nested-*`, or the outer radius minus the gap. Pills
|
||||||
|
(`--nv-radius-full`) are for pills, avatars and switches.
|
||||||
|
- **Logical properties.** Use `inline`/`block` (`padding-inline`,
|
||||||
|
`inset-block-start`), never `left`/`right`, so the system works in RTL.
|
||||||
|
Where a property has no logical form (`translate`), flip it explicitly for
|
||||||
|
RTL.
|
||||||
|
- **Application-specific values** that describe *your content* — a 15rem sidebar,
|
||||||
|
an aspect ratio, a grid template — are yours. A value that restates the design
|
||||||
|
language is the system's, and hard-coding it is how a system quietly stops
|
||||||
|
being one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Motion and continuity
|
||||||
|
|
||||||
|
Every interaction MUST read as a continuation of the state before it. The goal is
|
||||||
|
not to animate everything. It is that **nothing pops into a state the user could
|
||||||
|
have watched it reach.**
|
||||||
|
|
||||||
|
### 9.1 Rules
|
||||||
|
|
||||||
|
1. **Motion explains.** It shows where something came from, where it went, or
|
||||||
|
that an action registered. Motion with no such job is removed.
|
||||||
|
2. **Durations and easings are tokens.** `--nv-duration-*`, `--nv-ease-*`,
|
||||||
|
`--nv-transition-*`. A literal `200ms` is a defect, and it bypasses reduced
|
||||||
|
motion.
|
||||||
|
3. **Reduced motion collapses at the token level.** Components never opt in
|
||||||
|
individually. `prefers-reduced-motion` skips view transitions entirely; the
|
||||||
|
change still happens, it just arrives.
|
||||||
|
4. **Selection travels.** When emphasis moves between elements, the marker
|
||||||
|
itself moves (shared `view-transition-name`). When a panel replaces another,
|
||||||
|
it slides in the direction of travel.
|
||||||
|
5. **View transition names are unique per document.** A duplicate makes the
|
||||||
|
browser skip the transition. Give each selection group its own
|
||||||
|
`--nv-selection-name`.
|
||||||
|
6. **Origin matters.** Menus scale out of their trigger's edge; dialogs expand
|
||||||
|
away from the control that opened them.
|
||||||
|
7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required
|
||||||
|
for function.
|
||||||
|
8. **Restraint wins ties.** When continuity and restraint conflict, animate less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Interaction and state
|
||||||
|
|
||||||
|
1. **One treatment per state, written once per component.** Hover lift, press
|
||||||
|
sink and focus ring are defined once; a new variant MUST NOT redefine them.
|
||||||
|
Variants are made by reassigning private `--_*` properties.
|
||||||
|
2. **Hover is guarded.** Hover styles live in `@media (hover: hover)`. Touch
|
||||||
|
devices never see a stuck hover.
|
||||||
|
3. **Pressed means pressed.** Raised controls sink on press. A control that does
|
||||||
|
not respond to a press does not look pressable.
|
||||||
|
4. **Targets are at least 44px**, including controls drawn smaller (the system
|
||||||
|
extends the pointer target).
|
||||||
|
5. **Every state has a visible form:** default, hover, focus-visible, active,
|
||||||
|
selected, disabled, invalid, loading. A component missing one is unfinished.
|
||||||
|
6. **Disabled is not hidden.** A disabled control keeps its place, dims, and
|
||||||
|
uses `not-allowed`. Prefer explaining *why* over disabling silently.
|
||||||
|
7. **Selection state lives in attributes with a platform meaning:**
|
||||||
|
`aria-selected`, `aria-current`, `aria-pressed`, `aria-checked`,
|
||||||
|
`aria-invalid`, `disabled`, `data-outside`. Not in ad-hoc classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Accessibility
|
||||||
|
|
||||||
|
Accessibility is a precondition, not a feature. Target **WCAG 2.2 level AA**.
|
||||||
|
Niveus provides the base; the author is responsible for the rest.
|
||||||
|
|
||||||
|
**Provided by the system:**
|
||||||
|
|
||||||
|
- One focus treatment: a contrast ring under an accent ring, visible on any
|
||||||
|
surface including accent-filled controls.
|
||||||
|
- A minimum 44px pointer target.
|
||||||
|
- `prefers-reduced-motion` and `prefers-reduced-transparency` honoured at the
|
||||||
|
token level.
|
||||||
|
- Filled colours with a derived on-colour that clears 4.5:1.
|
||||||
|
|
||||||
|
**Required of the author:**
|
||||||
|
|
||||||
|
1. **Icon-only buttons MUST have `aria-label`.** The system cannot name them.
|
||||||
|
2. **Use native elements** — `<button>`, `<dialog>`, `<details>`, `<input>` —
|
||||||
|
before ARIA. ARIA repairs; it does not replace.
|
||||||
|
3. **Keyboard parity.** Everything pointer-operable is keyboard-operable, with a
|
||||||
|
visible focus order that follows reading order.
|
||||||
|
4. **Labels.** Every field has a programmatic label. Errors pair
|
||||||
|
`aria-invalid="true"` with `aria-describedby` pointing at the message.
|
||||||
|
5. **Dialogs and popovers** trap or restore focus (native `<dialog>` and
|
||||||
|
`popover` do; custom ones MUST).
|
||||||
|
6. **Live regions** (`aria-live`) for content that changes without focus —
|
||||||
|
a calendar's month title is one.
|
||||||
|
7. **Test the extremes.** Verify text on translucent materials at the
|
||||||
|
lowest-contrast backdrop and the lowest frost; verify at 200% text size and
|
||||||
|
in both appearances.
|
||||||
|
8. **Do not rely on colour, motion or transparency to carry meaning.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Components
|
||||||
|
|
||||||
|
The complete list and API live in `REFERENCE.md`. These are the rules for
|
||||||
|
*choosing and composing*.
|
||||||
|
|
||||||
|
1. **Compose before you create.** Most needs are an existing component plus a
|
||||||
|
modifier, or a pattern — a composition that needs no new CSS.
|
||||||
|
2. **Native first.** A native `<select>` (`.nv-select`) is the default choice for
|
||||||
|
a short list. Use the popup list (`.nv-picker` + `.nv-menu--listbox`) when you
|
||||||
|
need search, rich options, or anchored positioning; it re-implements keys,
|
||||||
|
type-ahead and filtering, so it must earn its place.
|
||||||
|
3. **Pickers.**
|
||||||
|
- **Calendar** (`.nv-calendar`): holds content, so it takes no material of its
|
||||||
|
own — put it on `.nv-surface`, or on Glaze when it floats. Six weeks every
|
||||||
|
month so it never changes height. The grid and states are plain markup;
|
||||||
|
`niveus.js` only builds days, steps months and provides keyboard movement.
|
||||||
|
- **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the
|
||||||
|
default**; `data-nv-format-toggle` adds a 24H / AM/PM switch beneath, and
|
||||||
|
AM/PM adds a third wheel. The value is always 24-hour (`HH:MM`), so a form
|
||||||
|
never needs to know which the person used. For a no-script fallback use
|
||||||
|
`<input type="time" class="nv-input">`; the wheels are an enhancement, not
|
||||||
|
the only path.
|
||||||
|
- **Popup list** (`.nv-menu--listbox`): needs an `id`, a trigger with that
|
||||||
|
`popovertarget`, and one shared `--nv-anchor` name per pair.
|
||||||
|
- **Slider** (`.nv-slider`): a native range input; bind it to a token with
|
||||||
|
`data-nv-var`.
|
||||||
|
4. **Overlays.** Menus and popovers anchor to their trigger. A menu and trigger
|
||||||
|
MUST name the same `--nv-anchor`, unique per pair. Light dismiss, Escape and
|
||||||
|
top-layer stacking come from `popover`; do not script them.
|
||||||
|
5. **A component earns its place** when it can be stated as a reusable rule.
|
||||||
|
"Selection emphasis travels" is a rule. "This menu is 14px from its button"
|
||||||
|
is not.
|
||||||
|
6. **Variants reassign private properties** (`--_*`); they do not restate
|
||||||
|
interaction.
|
||||||
|
|
||||||
|
### 12.1 Not yet in the system
|
||||||
|
|
||||||
|
The following are known gaps. Until they ship, compose from existing parts, and
|
||||||
|
do not fake them with unlisted classes: toast/snackbar, tooltip, drawer, alert
|
||||||
|
banner, progress and spinner, chips, segmented control, pagination, command
|
||||||
|
palette, context menu, date-range selection, a `data-nv-style` personality
|
||||||
|
switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Customization contract
|
||||||
|
|
||||||
|
Niveus lets the **author** decide what the **person using the product** may
|
||||||
|
tune. This is a first-class goal, bounded by sections 3.1 and 11.
|
||||||
|
|
||||||
|
| Dial | Mechanism | Safe to expose to end users? |
|
||||||
|
| ---- | --------- | ---------------------------- |
|
||||||
|
| Appearance | `data-theme`, `data-nv-theme` buttons | **Yes.** Always offer system, light, dark. |
|
||||||
|
| Brand colour | `--nv-brand`, `data-nv-brand` | **Yes**, within the contrast guarantee (7.2.3). |
|
||||||
|
| Frost | `--nv-glass-frost` via `.nv-slider[data-nv-var]` | **Yes**, with a floor where text sits on Glaze (5.2.4). |
|
||||||
|
| Motion | the OS setting | **Respect, do not override.** Never add a control that forces motion on. |
|
||||||
|
| Transparency | the OS setting | **Respect, do not override.** |
|
||||||
|
| Density, radius, type scale | tokens | Possible; expose only as named presets, not free values. |
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
|
||||||
|
1. **Customization changes tokens, never component CSS.** If a dial needs a new
|
||||||
|
selector, it is a new feature and goes through section 16.
|
||||||
|
2. **Free-form values are a hazard.** Expose steps or bounded ranges; never raw
|
||||||
|
colour or size inputs that can break contrast or layout.
|
||||||
|
3. **Persist deliberately.** If a choice is saved, it is the author's
|
||||||
|
responsibility (the system stores nothing), and the page MUST render
|
||||||
|
correctly before the stored value is read.
|
||||||
|
4. **The default is the product.** Most people never open settings. The default
|
||||||
|
configuration must be correct, accessible and good-looking on its own.
|
||||||
|
5. **Per-subtree overrides are the unit.** Tokens like `--nv-glass-frost` and
|
||||||
|
`data-nv-brand` apply to a region, which lets one product mix a calm
|
||||||
|
content area with a lively floating layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Authoring rules
|
||||||
|
|
||||||
|
### 14.1 CSS
|
||||||
|
|
||||||
|
1. **Reference the system, never restate it.**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* No */
|
||||||
|
.thing { background: #fff; border-radius: 12px; padding: 10px 16px; }
|
||||||
|
|
||||||
|
/* Yes */
|
||||||
|
.thing {
|
||||||
|
background: var(--nv-color-surface-elevated);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
padding: var(--nv-space-2-5) var(--nv-space-4);
|
||||||
|
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Everything ships in the `niveus` cascade layer.** Application styles are
|
||||||
|
unlayered and win by default. Overriding Niveus MUST NOT require `!important`
|
||||||
|
or selector escalation. If it does, the structure is wrong.
|
||||||
|
3. **Naming.** `nv-` prefix; BEM `__element` and `--modifier`; `--_name` for
|
||||||
|
component-private properties; `--nv-` for public tokens.
|
||||||
|
4. **Layer order is fixed:** tokens → theme → base → continuity → components →
|
||||||
|
utilities. Do not rely on file load order.
|
||||||
|
5. **A header comment explains *why*,** not what. Match the density and idiom of
|
||||||
|
the file you are in.
|
||||||
|
6. **Register new files** in `niveus/niveus.css` with `layer(niveus.components)`,
|
||||||
|
then rebuild. **Never hand-edit `dist/`.**
|
||||||
|
7. **`niveus/tokens` and `niveus/themes` use a deliberately narrow CSS subset**
|
||||||
|
(`rgb`, `oklch`, `color-mix in oklab`, `light-dark`, `var`, `calc`, `clamp`,
|
||||||
|
`min`, `max`) so ports can evaluate them. Component CSS is not so limited.
|
||||||
|
|
||||||
|
### 14.2 JavaScript
|
||||||
|
|
||||||
|
1. **Optional and dependency-free.** Everything works without it; it adds
|
||||||
|
continuity and the keyboard models CSS cannot provide.
|
||||||
|
2. **Declarative first.** Behaviour is attached through `data-nv-*` attributes and
|
||||||
|
wired by `nv.wire(scope)`. Call `nv.wire` again after injecting markup.
|
||||||
|
3. **Events are namespaced `nv:`** and documented in `REFERENCE.md`.
|
||||||
|
4. **Respect reduced motion** via `nv.prefersReducedMotion()`.
|
||||||
|
5. **One listener, one write per frame** for pointer-driven effects.
|
||||||
|
6. **ES5-compatible style** in `niveus.js`, to match the file.
|
||||||
|
|
||||||
|
### 14.3 Documentation
|
||||||
|
|
||||||
|
Every new class, token, attribute, function or event is added to `REFERENCE.md`
|
||||||
|
**in the same change.** `check.py` fails otherwise, and a documented thing that
|
||||||
|
no longer exists is worse than a missing one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Protocol for AI agents
|
||||||
|
|
||||||
|
This section is an operating contract. If you are a model generating or changing
|
||||||
|
Niveus code, follow it literally.
|
||||||
|
|
||||||
|
### 15.1 Before you write
|
||||||
|
|
||||||
|
1. **Read `REFERENCE.md` in full.** It is the entire API. Do not rely on memory
|
||||||
|
of other design systems.
|
||||||
|
2. **Identify the task type:** *consume* (build an interface with Niveus) or
|
||||||
|
*change* (modify Niveus itself). Section 15.3 applies to the first, 15.4 to
|
||||||
|
the second.
|
||||||
|
3. **State your assumptions** about which components, materials and appearance
|
||||||
|
apply, when the request is ambiguous. Ask only when the answer would change
|
||||||
|
what you build and cannot be inferred.
|
||||||
|
|
||||||
|
### 15.2 Hard rules (never break)
|
||||||
|
|
||||||
|
- **MUST NOT** invent a class, token, attribute, function or event. If it is not
|
||||||
|
in `REFERENCE.md`, it does not exist.
|
||||||
|
- **MUST NOT** write a literal colour, shadow geometry, duration or easing. Use
|
||||||
|
tokens.
|
||||||
|
- **MUST NOT** reference `--nv-palette-*`.
|
||||||
|
- **MUST NOT** use `!important`, inline `style` for visual values, or `left/right`
|
||||||
|
where a logical property exists.
|
||||||
|
- **MUST NOT** put Glaze, frosted or glass behind content the user works in.
|
||||||
|
- **MUST NOT** name anything after another vendor's trademark.
|
||||||
|
- **MUST NOT** hand-edit `dist/`.
|
||||||
|
- **MUST NOT** weaken a check, delete a rule, or widen an allowlist to make a
|
||||||
|
change pass. If a change cannot be made without breaking a check, the change
|
||||||
|
is probably not the problem the system has. Report it.
|
||||||
|
- **MUST** keep interaction states complete, and both appearances correct.
|
||||||
|
- **MUST** leave the page legible when `niveus.js` fails to load.
|
||||||
|
|
||||||
|
### 15.3 Consuming Niveus
|
||||||
|
|
||||||
|
1. Choose the **material** by what the surface does (section 4), then the
|
||||||
|
**elevation** by its relationship to the canvas (section 6).
|
||||||
|
2. Compose existing components; reach for a pattern before new CSS.
|
||||||
|
3. Use role classes for type, tokens for space and shape.
|
||||||
|
4. Give icon-only buttons `aria-label`; label every field.
|
||||||
|
5. Include `data-theme` handling only if the product offers a toggle; otherwise
|
||||||
|
follow the OS.
|
||||||
|
6. Application CSS goes in unlayered styles and uses only tokens for anything
|
||||||
|
that is *about the design language*.
|
||||||
|
|
||||||
|
### 15.4 Changing Niveus
|
||||||
|
|
||||||
|
1. Apply the three questions of section 16 **before** writing code, and say the
|
||||||
|
answers.
|
||||||
|
2. Make the smallest change that satisfies a rule, not the most complete one.
|
||||||
|
3. Colours: add `--nv-light-*` **and** `--nv-dark-*` in `themes/`, then the role
|
||||||
|
in `tokens/color.css`. Both, always.
|
||||||
|
4. Add the class/token/attribute to `REFERENCE.md` in the same change.
|
||||||
|
5. Register new component files in `niveus/niveus.css`.
|
||||||
|
6. **Run the gates, in order, after every file, not only at the end:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/build.py # regenerate dist/
|
||||||
|
python3 tools/check.py # parity, references, hard-coded colours, docs
|
||||||
|
python3 tools/build.py --check # dist/ matches sources
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Look at it.** Render both appearances in a real browser at a realistic size
|
||||||
|
and inspect the result. Passing checks prove the rules; they do not prove it
|
||||||
|
looks right or works. Exercise interactive components, not just their markup.
|
||||||
|
8. **Report faithfully.** State what you verified, what you could not, and what
|
||||||
|
failed. A test that failed because of the test harness is still reported.
|
||||||
|
|
||||||
|
### 15.5 Behaviour
|
||||||
|
|
||||||
|
- **Prefer the smaller diff.** Do not refactor, rename or restyle beyond the
|
||||||
|
task.
|
||||||
|
- **Prefer doing to asking,** except for actions that are hard to reverse or
|
||||||
|
outward-facing (publishing, deleting, force-pushing), which need explicit
|
||||||
|
authorization.
|
||||||
|
- **Surface conflicts** between a request and this document. Do not silently
|
||||||
|
pick a side: say which rule is in tension, and recommend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Changing the system
|
||||||
|
|
||||||
|
### 16.1 The three questions
|
||||||
|
|
||||||
|
Ask in order, and stop at the first *yes* to question 1.
|
||||||
|
|
||||||
|
1. **Can it be expressed with what already exists?** Most new requirements are
|
||||||
|
an existing component with a modifier, or a composition that needs no new
|
||||||
|
CSS.
|
||||||
|
2. **Is it a rule, or is it this once?** A component earns its place when it can
|
||||||
|
be stated as a reusable principle.
|
||||||
|
3. **Does it hold in both appearances, at every elevation, and under a
|
||||||
|
pointer?** If it only works on white, or only when nothing is behind it, it
|
||||||
|
is not finished.
|
||||||
|
|
||||||
|
### 16.2 Tokens
|
||||||
|
|
||||||
|
If a value appears three times and means the same thing each time, it is a
|
||||||
|
token. If it appears three times and means something different each time, three
|
||||||
|
different things were given the same number by accident.
|
||||||
|
|
||||||
|
### 16.3 Definition of done
|
||||||
|
|
||||||
|
A change is done when:
|
||||||
|
|
||||||
|
- [ ] `build.py`, `check.py` and `build.py --check` all pass.
|
||||||
|
- [ ] It is documented in `REFERENCE.md`; `README.md` and this document are
|
||||||
|
updated if a principle or the component list changed.
|
||||||
|
- [ ] It renders correctly in **light and dark**, with and without `niveus.js`.
|
||||||
|
- [ ] Every interaction state exists and is reachable by keyboard.
|
||||||
|
- [ ] Translucent surfaces have a solid fallback.
|
||||||
|
- [ ] It respects reduced motion and reduced transparency.
|
||||||
|
- [ ] It was seen in a browser, and interactive parts were exercised.
|
||||||
|
- [ ] It adds no unlisted class, literal value or `!important`.
|
||||||
|
|
||||||
|
### 16.4 Deprecation
|
||||||
|
|
||||||
|
Do not delete a documented name silently. Mark it deprecated in `REFERENCE.md`
|
||||||
|
for at least one release, state its replacement, then remove it from the CSS and
|
||||||
|
the reference together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Anti-patterns
|
||||||
|
|
||||||
|
| Anti-pattern | Why it fails | Instead |
|
||||||
|
| ------------ | ------------ | ------- |
|
||||||
|
| Glass on everything | Translucency stops meaning "floats"; cost and tint everywhere | Solid by default; glass for chrome |
|
||||||
|
| Glaze over a flat background | Reads as a grey film; the effect is invisible | Put it over a bed, imagery or gradient |
|
||||||
|
| Body copy on clear glass | Contrast collapses on busy backdrops | Solid surface, or floor the frost |
|
||||||
|
| Stacked translucent layers | Blur and tint compound | One translucent layer |
|
||||||
|
| Raw hex, `px` shadows, literal `ms` | Restates the language; bypasses theme and reduced motion | Tokens |
|
||||||
|
| A new shadow "just here" | Off-scale shadow means nothing | Pick an elevation |
|
||||||
|
| `!important` or selector escalation | The layer system already gives you precedence | Unlayered application CSS |
|
||||||
|
| Animating everything | Busy interfaces read as unclear | Animate what explains |
|
||||||
|
| Colour as the only state signal | Fails colour-blind and low-vision users | Add shape, text or attribute |
|
||||||
|
| Re-defining hover/press/focus per variant | Variants drift from the system | Reassign `--_*` properties |
|
||||||
|
| Icon button with no `aria-label` | No accessible name | Add it |
|
||||||
|
| Inventing a class that "should" exist | Documented API is the contract | Check `REFERENCE.md`; propose it |
|
||||||
|
| Hand-editing `dist/` | Next build erases it; check fails | Edit sources, rebuild |
|
||||||
|
| Weakening a check to pass | Hides a real defect | Fix the cause or report it |
|
||||||
|
| Naming a feature after another vendor's mark | Legal and brand risk | Name it for its role |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Compliance checklist
|
||||||
|
|
||||||
|
Use this for review, human or automated. Every line is a yes/no.
|
||||||
|
|
||||||
|
**Material and depth**
|
||||||
|
- [ ] Each surface has exactly one material, chosen by what it does.
|
||||||
|
- [ ] No translucent surface holds body copy or forms.
|
||||||
|
- [ ] Translucent surfaces have colour behind them and a solid fallback.
|
||||||
|
- [ ] Every shadow is on the elevation scale.
|
||||||
|
|
||||||
|
**Colour and theme**
|
||||||
|
- [ ] No literal colours; roles only; no `--nv-palette-*`.
|
||||||
|
- [ ] Looks correct in light and dark, and with a different `--nv-brand`.
|
||||||
|
- [ ] Text contrast ≥ 4.5:1 (3:1 for large text and UI components), including
|
||||||
|
on translucent materials at their worst-case backdrop.
|
||||||
|
|
||||||
|
**Interaction and motion**
|
||||||
|
- [ ] All states exist; hover is behind `@media (hover: hover)`.
|
||||||
|
- [ ] Targets ≥ 44px; focus visible; full keyboard operation.
|
||||||
|
- [ ] Durations and easings are tokens; reduced motion honoured.
|
||||||
|
- [ ] Nothing depends on `niveus.js` to be readable or operable.
|
||||||
|
|
||||||
|
**Code and docs**
|
||||||
|
- [ ] `nv-` naming, BEM, `--_` privates, logical properties.
|
||||||
|
- [ ] No `!important`; application CSS is unlayered.
|
||||||
|
- [ ] `REFERENCE.md` updated; `dist/` rebuilt, not hand-edited.
|
||||||
|
- [ ] `build.py`, `check.py`, `build.py --check` pass.
|
||||||
|
- [ ] Seen in a browser; interactive parts exercised.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Niveus Design Guidelines 1.0. Maintained with the system: change the rules and
|
||||||
|
the code in the same commit, or neither.*
|
||||||
14
README.md
14
README.md
|
|
@ -31,6 +31,10 @@ Open `index.html` for the full specimen: materials, the elevation scale, every
|
||||||
semantic color, the type scale, and each component in both appearances.
|
semantic color, the type scale, and each component in both appearances.
|
||||||
`examples/` holds four complete products built with it.
|
`examples/` holds four complete products built with it.
|
||||||
|
|
||||||
|
**[GUIDELINES.md](GUIDELINES.md) is the rulebook** — what Niveus is, what it
|
||||||
|
decides, what it forbids, and a protocol for AI agents. Read it before you
|
||||||
|
design with the system or change it.
|
||||||
|
|
||||||
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
|
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
|
||||||
attribute and function, each listed beside the name the rest of the industry
|
attribute and function, each listed beside the name the rest of the industry
|
||||||
uses for it. It is written to be read by a person *or* handed to a model on its
|
uses for it. It is written to be read by a person *or* handed to a model on its
|
||||||
|
|
@ -85,7 +89,8 @@ niveus/
|
||||||
├── base.css elements, before any component
|
├── base.css elements, before any component
|
||||||
├── continuity.css how one state becomes the next
|
├── continuity.css how one state becomes the next
|
||||||
├── components/ surface, button, input, card, dialog, navigation,
|
├── components/ surface, button, input, card, dialog, navigation,
|
||||||
│ menu, disclosure, scroller, bed, badge, table
|
│ menu, picker, calendar, timepicker, slider,
|
||||||
|
│ disclosure, scroller, bed, badge, table
|
||||||
├── utilities.css the short list
|
├── utilities.css the short list
|
||||||
├── niveus.css the entry point
|
├── niveus.css the entry point
|
||||||
└── niveus.js optional — continuity for changes CSS cannot animate
|
└── niveus.js optional — continuity for changes CSS cannot animate
|
||||||
|
|
@ -136,6 +141,7 @@ answers how far that surface is from the canvas. They are independent.
|
||||||
| `inset` | fields, wells, tracks — anything recessed |
|
| `inset` | fields, wells, tracks — anything recessed |
|
||||||
| `frosted` | chrome that content passes under: bars, toolbars |
|
| `frosted` | chrome that content passes under: bars, toolbars |
|
||||||
| `glass` | contextual layers where content behind must stay legible |
|
| `glass` | contextual layers where content behind must stay legible |
|
||||||
|
| `glaze` | glass the person looking can tune: `--nv-glass-frost` (0 clear … 1 frosted), with a rim that follows the pointer |
|
||||||
|
|
||||||
| Elevation | Relationship |
|
| Elevation | Relationship |
|
||||||
| --------- | ------------------------------- |
|
| --------- | ------------------------------- |
|
||||||
|
|
@ -352,7 +358,7 @@ interface tiring.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
|
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
|
||||||
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
|
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
|
||||||
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass. |
|
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass, glaze. |
|
||||||
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
|
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
|
||||||
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
|
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
|
||||||
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
|
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
|
||||||
|
|
@ -432,6 +438,10 @@ probably not the problem the system has.
|
||||||
| `.nv-steps` | markers set into a rail; `data-state="done"` lights the way, `--vertical` |
|
| `.nv-steps` | markers set into a rail; `data-state="done"` lights the way, `--vertical` |
|
||||||
| `.nv-deck` | one surface in front of others of its kind, with `__layer` |
|
| `.nv-deck` | one surface in front of others of its kind, with `__layer` |
|
||||||
| `.nv-skeleton` | a waiting well with a slow sweep of light; `--text` `--circle` |
|
| `.nv-skeleton` | a waiting well with a slow sweep of light; `--text` `--circle` |
|
||||||
|
| `.nv-slider` | native range input as an inset track and a knob; `data-nv-var` binds it to a token |
|
||||||
|
| `.nv-calendar` | month grid with keyboard model; chosen day is a small raised key |
|
||||||
|
| `.nv-timepicker` | scroll-snap wheels, 24-hour by default, optional 24H / AM/PM switch |
|
||||||
|
| `.nv-picker` | field-shaped trigger for a popup list (`.nv-menu--listbox`) with search and type-ahead |
|
||||||
| `.nv-surface` | materials and elevation as utilities |
|
| `.nv-surface` | materials and elevation as utilities |
|
||||||
|
|
||||||
Variants are defined by reassigning private `--_*` properties. Interaction —
|
Variants are defined by reassigning private `--_*` properties. Interaction —
|
||||||
|
|
|
||||||
49
REFERENCE.md
49
REFERENCE.md
|
|
@ -63,8 +63,29 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
|
||||||
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
|
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
|
||||||
| `.nv-radio` | radio button | Native input, custom mark. |
|
| `.nv-radio` | radio button | Native input, custom mark. |
|
||||||
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
|
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
|
||||||
|
| `.nv-slider` | range slider, track bar | `input[type=range]`. Inset track, accent fill, knob thumb. Add `data-nv-var="--token"` to write its value into a CSS property. |
|
||||||
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
|
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
|
||||||
|
|
||||||
|
## Pickers
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-calendar` | calendar, date picker | Month grid on whatever surface you give it (`.nv-surface`, or `.nv-material-glaze` when floating). Configure with `data-nv-calendar` plus `data-nv-value` / `-min` / `-max` / `-locale` / `-week-start`. Fires `nv:date`. |
|
||||||
|
| `.nv-calendar__header` | calendar toolbar | Title and month-step buttons (`data-nv-calendar-step="-1"` / `"1"`). |
|
||||||
|
| `.nv-calendar__title` | month and year heading | Filled by `niveus.js`. |
|
||||||
|
| `.nv-calendar__grid` | day grid | `role="grid"`; built by `niveus.js` or written by hand. |
|
||||||
|
| `.nv-calendar__weekday` | weekday column header | |
|
||||||
|
| `.nv-calendar__day` | day cell | `aria-selected="true"` chosen · `aria-current="date"` today · `data-outside` neighbouring month · `disabled` out of range. |
|
||||||
|
| `.nv-timepicker` | time picker, wheel picker | Scroll-snap wheels built from `data-nv-timepicker` plus `data-nv-value="HH:MM"` / `-hours` (`24` default, `12` adds an AM/PM wheel) / `-step` / `-name` / `-format-toggle` (a 24H / AM/PM switch under the wheels). The value is always 24-hour. Fires `nv:time`. Empty without the script; use `<input type="time" class="nv-input">` as the fallback. |
|
||||||
|
| `.nv-timepicker__wheels` | wheel row, picker body | Holds the columns and the selection lens. |
|
||||||
|
| `.nv-timepicker__format` | 24H / AM/PM switch, time format toggle | Button group under the wheels; added by `data-nv-format-toggle`. |
|
||||||
|
| `.nv-timepicker__column` | wheel, picker column | One scrolling list (`role="listbox"`). |
|
||||||
|
| `.nv-timepicker__option` | wheel row | `aria-selected="true"` is the row in the lens. |
|
||||||
|
| `.nv-timepicker__separator` | colon between wheels | |
|
||||||
|
| `.nv-picker` | select trigger, dropdown button | Goes on the trigger beside `.nv-input` and `.nv-menu-trigger`; chevron turns while its list is open. `data-empty` shows placeholder colour. |
|
||||||
|
| `.nv-menu--listbox` | popup list, listbox, combobox list | A `.nv-menu` whose `.nv-menu__item`s are `role="option"`. At least as wide as its trigger. `niveus.js` adds keys, type-ahead and filtering; trigger fires `nv:select`. |
|
||||||
|
| `.nv-menu__search` | list filter, combobox input | An `.nv-input` as the first child of a listbox; typing filters the options. |
|
||||||
|
|
||||||
## Card
|
## Card
|
||||||
|
|
||||||
| Class | Known as | Does |
|
| Class | Known as | Does |
|
||||||
|
|
@ -297,6 +318,7 @@ color before they are read, so they never take the brand.
|
||||||
| `.nv-material-inset` | recessed surface | Fields, wells, tracks. |
|
| `.nv-material-inset` | recessed surface | Fields, wells, tracks. |
|
||||||
| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that floats over content: bars, docks, launchers, notifications, menus. Moderate blur, bevelled rim. |
|
| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that floats over content: bars, docks, launchers, notifications, menus. Moderate blur, bevelled rim. |
|
||||||
| `.nv-material-glass` | glass | Contextual layers where content behind must stay legible. |
|
| `.nv-material-glass` | glass | Contextual layers where content behind must stay legible. |
|
||||||
|
| `.nv-material-glaze` | glaze, adjustable frosted glass | Glass with a dial. Frost comes from `--nv-glass-frost` (0 clear … 1 heavy), read where the surface is drawn, so a subtree or a slider can change it. Rim catches light and follows the pointer with `niveus.js`. |
|
||||||
| `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised |
|
| `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised |
|
||||||
| `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay |
|
| `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay |
|
||||||
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
|
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
|
||||||
|
|
@ -305,7 +327,7 @@ color before they are read, so they never take the brand.
|
||||||
|
|
||||||
Material and elevation are independent: combine freely.
|
Material and elevation are independent: combine freely.
|
||||||
|
|
||||||
Niveus is solid, with glass used sparingly: anything that holds content
|
Niveus is solid by default, with glass used sparingly: anything that holds content
|
||||||
(windows, cards, dialogs) is solid, and only chrome floating over it is
|
(windows, cards, dialogs) is solid, and only chrome floating over it is
|
||||||
frosted. Raised things are bevelled — lit along the top edge, shaded along the
|
frosted. Raised things are bevelled — lit along the top edge, shaded along the
|
||||||
bottom, with a contact outline — and carry a sheen across their face. When
|
bottom, with a contact outline — and carry a sheen across their face. When
|
||||||
|
|
@ -446,7 +468,11 @@ level. Components never opt in individually.
|
||||||
|
|
||||||
### Other
|
### Other
|
||||||
|
|
||||||
`--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
|
`--nv-glass-frost` (0–1, the glaze material's one input; set on `:root` or any
|
||||||
|
subtree) · `--nv-glaze-blur-max` · `--nv-glaze-tint-min` ·
|
||||||
|
`--nv-glaze-tint-max` · `--nv-glaze-rim` · `--nv-light-x` / `--nv-light-y` /
|
||||||
|
`--nv-light-angle` (set by `niveus.js` on each glaze surface) ·
|
||||||
|
`--nv-slider-pos` (set by `niveus.js`) · `--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
|
||||||
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
|
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
|
||||||
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
|
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
|
||||||
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
|
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
|
||||||
|
|
@ -468,11 +494,24 @@ everything still renders — state changes simply arrive instead of travelling.
|
||||||
| `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. |
|
||||||
| `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. |
|
| `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. |
|
||||||
|
| `nv.initSlider` | `(input) => void` | Keeps one `.nv-slider`'s fill in step with its value, and applies `data-nv-var`. |
|
||||||
|
| `nv.initCalendar` | `(el) => void` | Builds and drives one `[data-nv-calendar]`. |
|
||||||
|
| `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.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` so a travelling marker passes behind them, and returns them. `nv.transition` does this for its `scope` and removes the names afterwards. |
|
||||||
| `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` | |
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
| Event | On | Detail |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `nv:date` | `.nv-calendar` | `{ value: "YYYY-MM-DD", date }` |
|
||||||
|
| `nv:time` | `.nv-timepicker` | `{ value: "HH:MM" }` (24-hour) |
|
||||||
|
| `nv:select` | picker trigger | `{ value, label }` |
|
||||||
|
| `nv:theme` | `<html>` | the new appearance |
|
||||||
|
|
||||||
### Data attributes
|
### Data attributes
|
||||||
|
|
||||||
| Attribute | On | Does |
|
| Attribute | On | Does |
|
||||||
|
|
@ -483,6 +522,12 @@ everything still renders — state changes simply arrive instead of travelling.
|
||||||
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
|
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
|
||||||
| `data-nv-close` | button | Closes its dialog or popover. |
|
| `data-nv-close` | button | Closes its dialog or popover. |
|
||||||
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
|
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
|
||||||
|
| `data-nv-var` | `.nv-slider` | Name of the custom property (e.g. `--nv-glass-frost`) the slider writes its value to. It starts at that property's current value. |
|
||||||
|
| `data-nv-var-target` | `.nv-slider` | Selector of the element that receives the property. Default: `<html>`. |
|
||||||
|
| `data-nv-calendar` | `.nv-calendar` | Marks a calendar for `niveus.js`. Also `data-nv-value`, `-min`, `-max` (`YYYY-MM-DD`), `-locale`, `-week-start` (0 = Sunday). |
|
||||||
|
| `data-nv-calendar-step` | button in a calendar | `-1` or `1`: months to move. |
|
||||||
|
| `data-nv-timepicker` | `.nv-timepicker` | Marks a time picker. Also `data-nv-value` (`HH:MM`), `-hours` (`24` default / `12`), `-step` (minutes), `-name` (hidden form field), `-format-toggle` (adds the 24H / AM/PM switch). |
|
||||||
|
| `data-nv-picker-label` | child of a picker trigger | Receives the chosen option's text; default is the trigger itself. |
|
||||||
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
|
| `data-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 transition by `nv.transition`. |
|
||||||
|
|
|
||||||
579
dist/niveus.css
vendored
579
dist/niveus.css
vendored
|
|
@ -324,6 +324,10 @@
|
||||||
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
|
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
|
||||||
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
|
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
|
||||||
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
|
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
|
||||||
|
--nv-color-material-glaze: light-dark(var(--nv-light-material-glaze), var(--nv-dark-material-glaze));
|
||||||
|
--nv-color-glaze-rim-lit: light-dark(var(--nv-light-glaze-rim-lit), var(--nv-dark-glaze-rim-lit));
|
||||||
|
--nv-color-glaze-rim-far: light-dark(var(--nv-light-glaze-rim-far), var(--nv-dark-glaze-rim-far));
|
||||||
|
--nv-color-glaze-specular: light-dark(var(--nv-light-glaze-specular), var(--nv-dark-glaze-specular));
|
||||||
|
|
||||||
/* --- Window controls ------------------------------------------------------- */
|
/* --- Window controls ------------------------------------------------------- */
|
||||||
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
|
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
|
||||||
|
|
@ -765,6 +769,10 @@
|
||||||
over content: bars, docks, launchers, notifications, menus.
|
over content: bars, docks, launchers, notifications, menus.
|
||||||
glass more translucent, lightly blurred. Contextual layers where the
|
glass more translucent, lightly blurred. Contextual layers where the
|
||||||
content behind should stay legible: overlays, hover cards.
|
content behind should stay legible: overlays, hover cards.
|
||||||
|
glaze glass with a dial. How frosted it is belongs to whoever is
|
||||||
|
looking at it, so it is one number (--nv-glass-frost) rather
|
||||||
|
than a fixed recipe, and it carries a rim that catches the
|
||||||
|
light and follows the pointer.
|
||||||
|
|
||||||
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
|
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
|
||||||
and everything that holds content are solid; translucency is reserved for
|
and everything that holds content are solid; translucency is reserved for
|
||||||
|
|
@ -792,6 +800,18 @@
|
||||||
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
|
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
|
||||||
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
|
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
|
||||||
|
|
||||||
|
/* --- Frost ------------------------------------------------------------------
|
||||||
|
The one input the glaze material derives from: 0 is clear glass, 1 is
|
||||||
|
nearly opaque frost. Set it on :root for the whole site, on a subtree to
|
||||||
|
change just that region, or bind a slider to it with data-nv-var and let
|
||||||
|
the person looking choose. The glaze recipe reads it where it is used,
|
||||||
|
so a subtree override always reaches the surfaces inside it. */
|
||||||
|
--nv-glass-frost: 0.5;
|
||||||
|
--nv-glaze-blur-max: 56px; /* blur at frost 1 */
|
||||||
|
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
|
||||||
|
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
|
||||||
|
--nv-glaze-rim: 1.5px;
|
||||||
|
|
||||||
/* --- Sheen ------------------------------------------------------------------
|
/* --- Sheen ------------------------------------------------------------------
|
||||||
raised neutral controls: buttons, the selected tab, a titlebar
|
raised neutral controls: buttons, the selected tab, a titlebar
|
||||||
filled saturated fills: primary and danger buttons, checked marks
|
filled saturated fills: primary and danger buttons, checked marks
|
||||||
|
|
@ -1023,6 +1043,13 @@
|
||||||
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
||||||
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
||||||
|
|
||||||
|
/* Glaze: the tint it is made of, the rim that catches light on the side
|
||||||
|
facing the pointer, the rim on the far side, and the specular bloom. */
|
||||||
|
--nv-light-material-glaze: rgb(255 255 255 / 1);
|
||||||
|
--nv-light-glaze-rim-lit: rgb(255 255 255 / 1);
|
||||||
|
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.5);
|
||||||
|
--nv-light-glaze-specular: rgb(255 255 255 / 0.6);
|
||||||
|
|
||||||
/* --- Window controls --------------------------------------------------------- */
|
/* --- Window controls --------------------------------------------------------- */
|
||||||
--nv-light-window-close: var(--nv-palette-window-close);
|
--nv-light-window-close: var(--nv-palette-window-close);
|
||||||
--nv-light-window-minimize: var(--nv-palette-window-minimize);
|
--nv-light-window-minimize: var(--nv-palette-window-minimize);
|
||||||
|
|
@ -1184,6 +1211,11 @@
|
||||||
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
|
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
|
||||||
--nv-dark-material-border: rgb(255 255 255 / 0.14);
|
--nv-dark-material-border: rgb(255 255 255 / 0.14);
|
||||||
|
|
||||||
|
--nv-dark-material-glaze: color-mix(in oklab, rgb(30 30 38 / 1), var(--nv-brand) var(--nv-brand-tint));
|
||||||
|
--nv-dark-glaze-rim-lit: rgb(255 255 255 / 0.55);
|
||||||
|
--nv-dark-glaze-rim-far: rgb(255 255 255 / 0.14);
|
||||||
|
--nv-dark-glaze-specular: rgb(255 255 255 / 0.18);
|
||||||
|
|
||||||
/* --- Window controls --------------------------------------------------------- */
|
/* --- Window controls --------------------------------------------------------- */
|
||||||
--nv-dark-window-close: var(--nv-palette-window-close);
|
--nv-dark-window-close: var(--nv-palette-window-close);
|
||||||
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
|
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
|
||||||
|
|
@ -1770,6 +1802,105 @@
|
||||||
-webkit-backdrop-filter: var(--nv-backdrop-glass);
|
-webkit-backdrop-filter: var(--nv-backdrop-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Glaze ------------------------------------------------------------------
|
||||||
|
Glass with a dial, and a rim that catches the light.
|
||||||
|
|
||||||
|
.nv-material-glaze the material
|
||||||
|
--nv-glass-frost: 0 … 1 clear glass … heavy frost (set anywhere)
|
||||||
|
|
||||||
|
Frost is read here, where the surface is drawn, not copied into a token
|
||||||
|
upstream: that is what lets a subtree (or a slider the visitor holds) change
|
||||||
|
it and have every surface inside follow. It moves three things together —
|
||||||
|
how far the backdrop is blurred, how much of the tint is laid over it, and
|
||||||
|
how much color the blur gives back — because a pane that changed only one
|
||||||
|
of them would stop looking like the same pane.
|
||||||
|
|
||||||
|
The rim is a 1.5px gradient border made with a masked ::before: bright on
|
||||||
|
the side facing the light, faint on the far side. ::after is the specular
|
||||||
|
bloom, a soft glow under the pointer. niveus.js sets --nv-light-x, -y and
|
||||||
|
-angle on the surface; without it both sit at their defaults (light from the
|
||||||
|
top left), so the material still reads as lit, just not as following you.
|
||||||
|
|
||||||
|
Zero specificity on position: a menu or dialog using it keeps the positioning
|
||||||
|
its own component gave it. */
|
||||||
|
|
||||||
|
:where(.nv-material-glaze) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze {
|
||||||
|
--_frost: var(--nv-glass-frost);
|
||||||
|
isolation: isolate;
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--nv-color-material-glaze)
|
||||||
|
calc(var(--nv-glaze-tint-min) + (var(--nv-glaze-tint-max) - var(--nv-glaze-tint-min)) * var(--_frost)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border: var(--nv-border-width) solid transparent;
|
||||||
|
box-shadow:
|
||||||
|
var(--nv-highlight-top-strong),
|
||||||
|
inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular),
|
||||||
|
var(--nv-outline),
|
||||||
|
var(--nv-elevation-4);
|
||||||
|
backdrop-filter:
|
||||||
|
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
|
||||||
|
saturate(calc(140% + var(--_frost) * 60%));
|
||||||
|
-webkit-backdrop-filter:
|
||||||
|
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
|
||||||
|
saturate(calc(140% + var(--_frost) * 60%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::before,
|
||||||
|
.nv-material-glaze::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--nv-border-width) * -1);
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::before {
|
||||||
|
padding: var(--nv-glaze-rim);
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--nv-light-angle, 135deg),
|
||||||
|
var(--nv-color-glaze-rim-lit) 0%,
|
||||||
|
transparent 38%,
|
||||||
|
transparent 62%,
|
||||||
|
var(--nv-color-glaze-rim-far) 100%
|
||||||
|
);
|
||||||
|
mask:
|
||||||
|
linear-gradient(currentcolor 0 0) content-box,
|
||||||
|
linear-gradient(currentcolor 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::after {
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle 16rem at var(--nv-light-x, 15%) var(--nv-light-y, 0%),
|
||||||
|
var(--nv-color-glaze-specular),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where translucency cannot be honoured, glaze falls back to the elevated
|
||||||
|
solid, as the other glass materials do. */
|
||||||
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
.nv-material-glaze {
|
||||||
|
background-color: var(--nv-color-surface-elevated);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.nv-material-glaze::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.nv-material-glaze { background-color: var(--nv-color-surface-elevated); }
|
||||||
|
.nv-material-glaze::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Elevation -------------------------------------------------------------
|
/* --- Elevation -------------------------------------------------------------
|
||||||
Elevation utilities carry three things at once, because a lifted surface is
|
Elevation utilities carry three things at once, because a lifted surface is
|
||||||
never only a shadow: the shadow itself, the highlight along its top edge,
|
never only a shadow: the shadow itself, the highlight along its top edge,
|
||||||
|
|
@ -2563,6 +2694,117 @@
|
||||||
translate: calc(var(--_travel) * -1) -50%;
|
translate: calc(var(--_travel) * -1) -50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@layer niveus.components {
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Slider
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<input class="nv-slider" type="range" min="0" max="100" value="50">
|
||||||
|
|
||||||
|
A native range input dressed as the rest of the controls are: the track is a
|
||||||
|
well cut into the surface, the fill is the accent seen inside it, and the
|
||||||
|
thumb is a knob lit from above. Keyboard, touch, step and form behaviour are
|
||||||
|
the browser's.
|
||||||
|
|
||||||
|
Firefox draws the fill itself (::-moz-range-progress). Chromium and Safari
|
||||||
|
do not expose one, so the fill is a gradient sized by --nv-slider-pos, which
|
||||||
|
niveus.js keeps in step with the value. Without the script the fill is
|
||||||
|
simply absent in those two engines and the thumb still tells the truth.
|
||||||
|
|
||||||
|
To bind a slider to a design token, add data-nv-var="--nv-glass-frost" (and
|
||||||
|
optionally data-nv-var-target="#region" for a subtree instead of :root).
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-slider {
|
||||||
|
--_track: 0.5rem;
|
||||||
|
--_thumb: 1.5rem;
|
||||||
|
--_pos: var(--nv-slider-pos, 50%);
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Track ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.nv-slider::-webkit-slider-runnable-track {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--nv-color-accent) 0 0) 0 / var(--_pos) 100% no-repeat,
|
||||||
|
var(--nv-color-surface-inset);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-track {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background-color: var(--nv-color-surface-inset);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-progress {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background-color: var(--nv-color-accent);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Thumb ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.nv-slider::-webkit-slider-thumb {
|
||||||
|
inline-size: var(--_thumb);
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
margin-block-start: calc((var(--_track) - var(--_thumb)) / 2);
|
||||||
|
background-color: var(--nv-color-knob);
|
||||||
|
background-image: var(--nv-sheen-knob);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-thumb {
|
||||||
|
inline-size: var(--_thumb);
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--nv-color-knob);
|
||||||
|
background-image: var(--nv-sheen-knob);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||||
|
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
|
||||||
|
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
|
||||||
|
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
|
||||||
|
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
|
||||||
|
|
||||||
|
.nv-slider:focus-visible::-webkit-slider-thumb {
|
||||||
|
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:focus-visible::-moz-range-thumb {
|
||||||
|
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
@layer niveus.components {
|
@layer niveus.components {
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Niveus — Card
|
Niveus — Card
|
||||||
|
|
@ -3528,7 +3770,8 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nv-menu__item[aria-checked="true"]::before {
|
.nv-menu__item[aria-checked="true"]::before,
|
||||||
|
.nv-menu__item[aria-selected="true"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
inline-size: 0.28em;
|
inline-size: 0.28em;
|
||||||
block-size: 0.55em;
|
block-size: 0.55em;
|
||||||
|
|
@ -3561,6 +3804,340 @@
|
||||||
color: var(--nv-color-text-tertiary);
|
color: var(--nv-color-text-tertiary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@layer niveus.components {
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Picker
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<button class="nv-input nv-picker nv-menu-trigger" popovertarget="fruit"
|
||||||
|
style="--nv-anchor: --fruit">
|
||||||
|
<span data-nv-picker-label>Choose a fruit</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nv-menu nv-menu--listbox" popover id="fruit" role="listbox"
|
||||||
|
style="--nv-anchor: --fruit">
|
||||||
|
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…">
|
||||||
|
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="pear" aria-selected="true">Pear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
A popup list is a menu whose items are options: the same frosted surface,
|
||||||
|
anchoring and light dismiss, with a selection that stays. A picker is its
|
||||||
|
trigger: a field-shaped button that shows the current choice and a chevron
|
||||||
|
that turns over while the list is open.
|
||||||
|
|
||||||
|
niveus.js adds what a native <select> would give for free and a styled
|
||||||
|
popover does not: arrow-key movement, type-ahead, Enter or Space to choose,
|
||||||
|
filtering when a .nv-menu__search is present, and an `nv:select` event on the
|
||||||
|
trigger carrying { value, label }.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker[data-empty] {
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker::after {
|
||||||
|
content: "";
|
||||||
|
flex: none;
|
||||||
|
inline-size: 0.4em;
|
||||||
|
block-size: 0.4em;
|
||||||
|
translate: 0 -0.12em;
|
||||||
|
rotate: 45deg;
|
||||||
|
border-inline-end: 2px solid var(--nv-color-text-tertiary);
|
||||||
|
border-block-end: 2px solid var(--nv-color-text-tertiary);
|
||||||
|
transition: rotate var(--nv-duration-quick) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker:has(+ .nv-menu:popover-open)::after {
|
||||||
|
rotate: 225deg;
|
||||||
|
translate: 0 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-menu--listbox {
|
||||||
|
min-inline-size: anchor-size(inline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-menu__search {
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-block-end: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* display on .nv-menu__item would otherwise win over the hidden attribute. */
|
||||||
|
.nv-menu__item[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer niveus.components {
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Calendar
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-calendar nv-surface" data-nv-calendar data-nv-value="2026-10-28">
|
||||||
|
<div class="nv-calendar__header">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon"
|
||||||
|
data-nv-calendar-step="-1" aria-label="Previous month">‹</button>
|
||||||
|
<h3 class="nv-calendar__title" aria-live="polite">October 2026</h3>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon"
|
||||||
|
data-nv-calendar-step="1" aria-label="Next month">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="nv-calendar__grid" role="grid" aria-labelledby="…">
|
||||||
|
<div role="row"> <span class="nv-calendar__weekday" role="columnheader">Mo</span> … </div>
|
||||||
|
<div role="row">
|
||||||
|
<button class="nv-calendar__day" role="gridcell" data-date="2026-10-01">1</button> …
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
A calendar holds content, so it takes no material of its own: put it on
|
||||||
|
.nv-surface, or on .nv-material-glaze when it floats over something. The
|
||||||
|
grid and its states are plain markup and render without niveus.js; the
|
||||||
|
script's only jobs are to build the days for a month, step between months,
|
||||||
|
and keep the keyboard model (arrows, PageUp/PageDown, Home/End).
|
||||||
|
|
||||||
|
State lives in attributes the platform already has a meaning for:
|
||||||
|
aria-selected="true" the chosen day
|
||||||
|
aria-current="date" today
|
||||||
|
data-outside a day that belongs to the neighbouring month
|
||||||
|
disabled outside data-nv-min / data-nv-max
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-calendar {
|
||||||
|
--_cell: 2.5rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
inline-size: fit-content;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
padding: var(--nv-space-4);
|
||||||
|
border-radius: var(--nv-radius-xl);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__title {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-md);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, var(--_cell));
|
||||||
|
gap: var(--nv-space-0-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__grid > [role="row"] {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__weekday {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
block-size: var(--nv-space-8);
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-caption);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
letter-spacing: var(--nv-letter-spacing-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day {
|
||||||
|
inline-size: var(--_cell);
|
||||||
|
block-size: var(--_cell);
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--nv-transition-colors), var(--nv-transition-depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
|
||||||
|
background-color: var(--nv-color-surface-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day[data-outside] {
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today is ringed, not filled: filled means chosen. */
|
||||||
|
.nv-calendar__day[aria-current="date"] {
|
||||||
|
color: var(--nv-color-accent-text);
|
||||||
|
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chosen day is a small raised key, lit like the filled buttons. */
|
||||||
|
.nv-calendar__day[aria-selected="true"] {
|
||||||
|
background-color: var(--nv-color-accent);
|
||||||
|
background-image: var(--nv-sheen-filled);
|
||||||
|
color: var(--nv-color-on-accent);
|
||||||
|
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day[aria-selected="true"]:focus-visible {
|
||||||
|
box-shadow: var(--nv-bevel), var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day:disabled {
|
||||||
|
color: var(--nv-color-text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer niveus.components {
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Time picker
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-timepicker nv-surface" data-nv-timepicker
|
||||||
|
data-nv-value="09:30" data-nv-step="5" data-nv-format-toggle></div>
|
||||||
|
|
||||||
|
Wheels, as on a phone: each column scrolls and snaps, and whatever sits in
|
||||||
|
the lens across the middle is the value. Scrolling, snapping, momentum and
|
||||||
|
touch are the browser's; niveus.js builds the columns, reads which row is
|
||||||
|
centred, and exposes the value as data-nv-value and an `nv:time` event.
|
||||||
|
|
||||||
|
The lens is the selection colour laid over the centre row, so it follows the
|
||||||
|
brand. Rows fade towards the top and bottom with a mask rather than a
|
||||||
|
coloured gradient, which keeps the fade right on any surface — solid, or
|
||||||
|
Glaze with something moving behind it.
|
||||||
|
|
||||||
|
The format is 24-hour unless told otherwise. With data-nv-format-toggle a
|
||||||
|
24H / AM/PM switch sits under the wheels and adds an AM/PM wheel when chosen;
|
||||||
|
the value stays 24-hour either way, so a form never has to know which the
|
||||||
|
person used.
|
||||||
|
|
||||||
|
Without niveus.js the element is empty. For a no-script fallback, use a
|
||||||
|
native <input type="time" class="nv-input">.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-timepicker {
|
||||||
|
--_row: 2.25rem;
|
||||||
|
--_rows: 5;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3);
|
||||||
|
border-radius: var(--nv-radius-xl);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__wheels {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lens. It belongs to the wheels, not the picker, so the switch beneath
|
||||||
|
them never moves the centre. */
|
||||||
|
.nv-timepicker__wheels::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: calc(var(--nv-space-2) * -1);
|
||||||
|
inset-block-start: 50%;
|
||||||
|
block-size: var(--_row);
|
||||||
|
translate: 0 -50%;
|
||||||
|
background-color: var(--nv-color-selection);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
box-shadow: var(--nv-highlight-top);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column {
|
||||||
|
position: relative;
|
||||||
|
inline-size: 3.5rem;
|
||||||
|
block-size: calc(var(--_row) * var(--_rows));
|
||||||
|
margin: 0;
|
||||||
|
padding: calc(var(--_row) * (var(--_rows) - 1) / 2) 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
transparent,
|
||||||
|
currentcolor 28%,
|
||||||
|
currentcolor 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
box-shadow: var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__option {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
block-size: var(--_row);
|
||||||
|
scroll-snap-align: center;
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--nv-transition-colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__option[aria-selected="true"] {
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 24H / AM/PM. A button group under the wheels; the pressed one is the format
|
||||||
|
in use. 24-hour is the default, so it starts pressed. */
|
||||||
|
.nv-timepicker__format {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed reads as pushed in: the format in use is a key held down. */
|
||||||
|
.nv-timepicker__format .nv-button[aria-pressed="true"] {
|
||||||
|
background-color: var(--nv-color-accent-subtle);
|
||||||
|
background-image: none;
|
||||||
|
color: var(--nv-color-accent-text);
|
||||||
|
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__separator {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-self: center;
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@layer niveus.components {
|
@layer niveus.components {
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Niveus — Disclosure
|
Niveus — Disclosure
|
||||||
|
|
|
||||||
148
index.html
148
index.html
|
|
@ -67,6 +67,72 @@
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glaze specimen: color behind the pane, so the frost has something to
|
||||||
|
act on. The orbs and the line of type are only there to be blurred. */
|
||||||
|
.glaze-demo {
|
||||||
|
position: relative;
|
||||||
|
margin-block-start: var(--nv-space-6);
|
||||||
|
min-block-size: 22rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glaze-demo__scene {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glaze-demo__orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
inline-size: var(--_size);
|
||||||
|
block-size: var(--_size);
|
||||||
|
inset-inline-start: var(--_x);
|
||||||
|
inset-block-start: var(--_y);
|
||||||
|
background: var(--_fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glaze-demo__type {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
inset-block-end: var(--nv-space-4);
|
||||||
|
text-align: center;
|
||||||
|
font: var(--nv-font-display);
|
||||||
|
font-size: clamp(2.5rem, 9vw, 5.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glaze-demo__panel {
|
||||||
|
position: relative;
|
||||||
|
inline-size: min(26rem, 100%);
|
||||||
|
padding: var(--nv-space-6);
|
||||||
|
border-radius: var(--nv-radius-xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-demo {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--nv-space-6);
|
||||||
|
margin-block-start: var(--nv-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-demo__column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
inline-size: min(16rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.specimen__name {
|
.specimen__name {
|
||||||
font: var(--nv-font-label);
|
font: var(--nv-font-label);
|
||||||
}
|
}
|
||||||
|
|
@ -297,6 +363,84 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-bed glaze-demo" id="glaze-bed">
|
||||||
|
<div class="glaze-demo__scene" aria-hidden="true">
|
||||||
|
<span class="glaze-demo__orb" style="--_size: 11rem; --_x: 8%; --_y: 8%; --_fill: var(--nv-color-accent)"></span>
|
||||||
|
<span class="glaze-demo__orb" style="--_size: 8rem; --_x: 68%; --_y: 14%; --_fill: var(--nv-color-danger)"></span>
|
||||||
|
<span class="glaze-demo__orb" style="--_size: 9rem; --_x: 52%; --_y: 58%; --_fill: var(--nv-color-success)"></span>
|
||||||
|
<span class="glaze-demo__type">Niveus glaze</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-material-glaze glaze-demo__panel">
|
||||||
|
<div>
|
||||||
|
<h3 class="nv-heading">Glaze</h3>
|
||||||
|
<p class="nv-prose">
|
||||||
|
One dial controls how frosted this pane is. The rim brightens on
|
||||||
|
the side facing your pointer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" for="frost">Frost</label>
|
||||||
|
<input class="nv-slider" id="frost" type="range" min="0" max="1" step="0.01"
|
||||||
|
data-nv-var="--nv-glass-frost" data-nv-var-target="#glaze-bed">
|
||||||
|
<span class="nv-field__hint">Clear glass on the left, heavy frost on the right.</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-cluster nv-cluster--tight">
|
||||||
|
<button class="nv-button nv-button--primary">Continue</button>
|
||||||
|
<button class="nv-button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================= -->
|
||||||
|
<section id="pickers">
|
||||||
|
<div class="section__head">
|
||||||
|
<h2 class="nv-heading">Pickers</h2>
|
||||||
|
<p class="nv-prose">
|
||||||
|
A calendar, wheels for time, and a popup list. They hold content, so
|
||||||
|
they sit on solid surfaces; here the first two float on Glaze
|
||||||
|
over a bed, which is where a date field's popover would be.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-bed picker-demo" id="picker-bed">
|
||||||
|
<div class="nv-calendar nv-material-glaze" data-nv-calendar data-nv-value="2026-10-28"
|
||||||
|
data-nv-min="2026-10-05">
|
||||||
|
<div class="nv-calendar__header">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon" data-nv-calendar-step="-1"
|
||||||
|
aria-label="Previous month">‹</button>
|
||||||
|
<h3 class="nv-calendar__title" aria-live="polite"></h3>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon" data-nv-calendar-step="1"
|
||||||
|
aria-label="Next month">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="nv-calendar__grid" role="grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="picker-demo__column">
|
||||||
|
<div class="nv-timepicker nv-material-glaze" data-nv-timepicker data-nv-value="09:30"
|
||||||
|
data-nv-step="5" data-nv-format-toggle aria-label="Start time"></div>
|
||||||
|
<output class="nv-caption nv-code" id="picker-output">09:30</output>
|
||||||
|
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" id="fruit-label">Fruit</label>
|
||||||
|
<button class="nv-input nv-picker nv-menu-trigger" data-empty popovertarget="fruit"
|
||||||
|
aria-labelledby="fruit-label" style="--nv-anchor: --fruit">
|
||||||
|
<span data-nv-picker-label>Choose a fruit</span>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu nv-menu--listbox" popover id="fruit" style="--nv-anchor: --fruit">
|
||||||
|
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…" aria-label="Search fruit">
|
||||||
|
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="apricot">Apricot</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="banana">Banana</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="cherry">Cherry</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="mango">Mango</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="pear">Pear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ================================================================= -->
|
<!-- ================================================================= -->
|
||||||
|
|
@ -1315,6 +1459,10 @@
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
document.addEventListener("nv:time", function (event) {
|
||||||
|
var out = document.getElementById("picker-output");
|
||||||
|
if (out) out.textContent = event.detail.value;
|
||||||
|
});
|
||||||
/* --------------------------------------------------------------------
|
/* --------------------------------------------------------------------
|
||||||
Page behaviour. Note what is *not* here: no style values, no colors,
|
Page behaviour. Note what is *not* here: no style values, no colors,
|
||||||
no durations. Continuity comes from nv.transition(); this file only
|
no durations. Continuity comes from nv.transition(); this file only
|
||||||
|
|
|
||||||
131
niveus/components/calendar.css
Normal file
131
niveus/components/calendar.css
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Calendar
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-calendar nv-surface" data-nv-calendar data-nv-value="2026-10-28">
|
||||||
|
<div class="nv-calendar__header">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon"
|
||||||
|
data-nv-calendar-step="-1" aria-label="Previous month">‹</button>
|
||||||
|
<h3 class="nv-calendar__title" aria-live="polite">October 2026</h3>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon"
|
||||||
|
data-nv-calendar-step="1" aria-label="Next month">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="nv-calendar__grid" role="grid" aria-labelledby="…">
|
||||||
|
<div role="row"> <span class="nv-calendar__weekday" role="columnheader">Mo</span> … </div>
|
||||||
|
<div role="row">
|
||||||
|
<button class="nv-calendar__day" role="gridcell" data-date="2026-10-01">1</button> …
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
A calendar holds content, so it takes no material of its own: put it on
|
||||||
|
.nv-surface, or on .nv-material-glaze when it floats over something. The
|
||||||
|
grid and its states are plain markup and render without niveus.js; the
|
||||||
|
script's only jobs are to build the days for a month, step between months,
|
||||||
|
and keep the keyboard model (arrows, PageUp/PageDown, Home/End).
|
||||||
|
|
||||||
|
State lives in attributes the platform already has a meaning for:
|
||||||
|
aria-selected="true" the chosen day
|
||||||
|
aria-current="date" today
|
||||||
|
data-outside a day that belongs to the neighbouring month
|
||||||
|
disabled outside data-nv-min / data-nv-max
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-calendar {
|
||||||
|
--_cell: 2.5rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
inline-size: fit-content;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
padding: var(--nv-space-4);
|
||||||
|
border-radius: var(--nv-radius-xl);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__title {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-md);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, var(--_cell));
|
||||||
|
gap: var(--nv-space-0-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__grid > [role="row"] {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__weekday {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
block-size: var(--nv-space-8);
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-caption);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
letter-spacing: var(--nv-letter-spacing-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day {
|
||||||
|
inline-size: var(--_cell);
|
||||||
|
block-size: var(--_cell);
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--nv-transition-colors), var(--nv-transition-depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
|
||||||
|
background-color: var(--nv-color-surface-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day[data-outside] {
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today is ringed, not filled: filled means chosen. */
|
||||||
|
.nv-calendar__day[aria-current="date"] {
|
||||||
|
color: var(--nv-color-accent-text);
|
||||||
|
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chosen day is a small raised key, lit like the filled buttons. */
|
||||||
|
.nv-calendar__day[aria-selected="true"] {
|
||||||
|
background-color: var(--nv-color-accent);
|
||||||
|
background-image: var(--nv-sheen-filled);
|
||||||
|
color: var(--nv-color-on-accent);
|
||||||
|
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day[aria-selected="true"]:focus-visible {
|
||||||
|
box-shadow: var(--nv-bevel), var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-calendar__day:disabled {
|
||||||
|
color: var(--nv-color-text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
@ -142,7 +142,8 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nv-menu__item[aria-checked="true"]::before {
|
.nv-menu__item[aria-checked="true"]::before,
|
||||||
|
.nv-menu__item[aria-selected="true"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
inline-size: 0.28em;
|
inline-size: 0.28em;
|
||||||
block-size: 0.55em;
|
block-size: 0.55em;
|
||||||
|
|
|
||||||
69
niveus/components/picker.css
Normal file
69
niveus/components/picker.css
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Picker
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<button class="nv-input nv-picker nv-menu-trigger" popovertarget="fruit"
|
||||||
|
style="--nv-anchor: --fruit">
|
||||||
|
<span data-nv-picker-label>Choose a fruit</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nv-menu nv-menu--listbox" popover id="fruit" role="listbox"
|
||||||
|
style="--nv-anchor: --fruit">
|
||||||
|
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…">
|
||||||
|
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
|
||||||
|
<button class="nv-menu__item" role="option" data-value="pear" aria-selected="true">Pear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
A popup list is a menu whose items are options: the same frosted surface,
|
||||||
|
anchoring and light dismiss, with a selection that stays. A picker is its
|
||||||
|
trigger: a field-shaped button that shows the current choice and a chevron
|
||||||
|
that turns over while the list is open.
|
||||||
|
|
||||||
|
niveus.js adds what a native <select> would give for free and a styled
|
||||||
|
popover does not: arrow-key movement, type-ahead, Enter or Space to choose,
|
||||||
|
filtering when a .nv-menu__search is present, and an `nv:select` event on the
|
||||||
|
trigger carrying { value, label }.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker[data-empty] {
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker::after {
|
||||||
|
content: "";
|
||||||
|
flex: none;
|
||||||
|
inline-size: 0.4em;
|
||||||
|
block-size: 0.4em;
|
||||||
|
translate: 0 -0.12em;
|
||||||
|
rotate: 45deg;
|
||||||
|
border-inline-end: 2px solid var(--nv-color-text-tertiary);
|
||||||
|
border-block-end: 2px solid var(--nv-color-text-tertiary);
|
||||||
|
transition: rotate var(--nv-duration-quick) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-picker:has(+ .nv-menu:popover-open)::after {
|
||||||
|
rotate: 225deg;
|
||||||
|
translate: 0 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-menu--listbox {
|
||||||
|
min-inline-size: anchor-size(inline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-menu__search {
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-block-end: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* display on .nv-menu__item would otherwise win over the hidden attribute. */
|
||||||
|
.nv-menu__item[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
109
niveus/components/slider.css
Normal file
109
niveus/components/slider.css
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Slider
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<input class="nv-slider" type="range" min="0" max="100" value="50">
|
||||||
|
|
||||||
|
A native range input dressed as the rest of the controls are: the track is a
|
||||||
|
well cut into the surface, the fill is the accent seen inside it, and the
|
||||||
|
thumb is a knob lit from above. Keyboard, touch, step and form behaviour are
|
||||||
|
the browser's.
|
||||||
|
|
||||||
|
Firefox draws the fill itself (::-moz-range-progress). Chromium and Safari
|
||||||
|
do not expose one, so the fill is a gradient sized by --nv-slider-pos, which
|
||||||
|
niveus.js keeps in step with the value. Without the script the fill is
|
||||||
|
simply absent in those two engines and the thumb still tells the truth.
|
||||||
|
|
||||||
|
To bind a slider to a design token, add data-nv-var="--nv-glass-frost" (and
|
||||||
|
optionally data-nv-var-target="#region" for a subtree instead of :root).
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-slider {
|
||||||
|
--_track: 0.5rem;
|
||||||
|
--_thumb: 1.5rem;
|
||||||
|
--_pos: var(--nv-slider-pos, 50%);
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Track ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.nv-slider::-webkit-slider-runnable-track {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--nv-color-accent) 0 0) 0 / var(--_pos) 100% no-repeat,
|
||||||
|
var(--nv-color-surface-inset);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-track {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background-color: var(--nv-color-surface-inset);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-progress {
|
||||||
|
block-size: var(--_track);
|
||||||
|
background-color: var(--nv-color-accent);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Thumb ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.nv-slider::-webkit-slider-thumb {
|
||||||
|
inline-size: var(--_thumb);
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
margin-block-start: calc((var(--_track) - var(--_thumb)) / 2);
|
||||||
|
background-color: var(--nv-color-knob);
|
||||||
|
background-image: var(--nv-sheen-knob);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider::-moz-range-thumb {
|
||||||
|
inline-size: var(--_thumb);
|
||||||
|
block-size: var(--_thumb);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--nv-color-knob);
|
||||||
|
background-image: var(--nv-sheen-knob);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
box-shadow: var(--nv-outline), var(--nv-elevation-2);
|
||||||
|
transition: scale var(--nv-duration-fast) var(--nv-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
|
||||||
|
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
|
||||||
|
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
|
||||||
|
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
|
||||||
|
|
||||||
|
.nv-slider:focus-visible::-webkit-slider-thumb {
|
||||||
|
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:focus-visible::-moz-range-thumb {
|
||||||
|
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-slider:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,105 @@
|
||||||
-webkit-backdrop-filter: var(--nv-backdrop-glass);
|
-webkit-backdrop-filter: var(--nv-backdrop-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Glaze ------------------------------------------------------------------
|
||||||
|
Glass with a dial, and a rim that catches the light.
|
||||||
|
|
||||||
|
.nv-material-glaze the material
|
||||||
|
--nv-glass-frost: 0 … 1 clear glass … heavy frost (set anywhere)
|
||||||
|
|
||||||
|
Frost is read here, where the surface is drawn, not copied into a token
|
||||||
|
upstream: that is what lets a subtree (or a slider the visitor holds) change
|
||||||
|
it and have every surface inside follow. It moves three things together —
|
||||||
|
how far the backdrop is blurred, how much of the tint is laid over it, and
|
||||||
|
how much color the blur gives back — because a pane that changed only one
|
||||||
|
of them would stop looking like the same pane.
|
||||||
|
|
||||||
|
The rim is a 1.5px gradient border made with a masked ::before: bright on
|
||||||
|
the side facing the light, faint on the far side. ::after is the specular
|
||||||
|
bloom, a soft glow under the pointer. niveus.js sets --nv-light-x, -y and
|
||||||
|
-angle on the surface; without it both sit at their defaults (light from the
|
||||||
|
top left), so the material still reads as lit, just not as following you.
|
||||||
|
|
||||||
|
Zero specificity on position: a menu or dialog using it keeps the positioning
|
||||||
|
its own component gave it. */
|
||||||
|
|
||||||
|
:where(.nv-material-glaze) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze {
|
||||||
|
--_frost: var(--nv-glass-frost);
|
||||||
|
isolation: isolate;
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--nv-color-material-glaze)
|
||||||
|
calc(var(--nv-glaze-tint-min) + (var(--nv-glaze-tint-max) - var(--nv-glaze-tint-min)) * var(--_frost)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border: var(--nv-border-width) solid transparent;
|
||||||
|
box-shadow:
|
||||||
|
var(--nv-highlight-top-strong),
|
||||||
|
inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular),
|
||||||
|
var(--nv-outline),
|
||||||
|
var(--nv-elevation-4);
|
||||||
|
backdrop-filter:
|
||||||
|
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
|
||||||
|
saturate(calc(140% + var(--_frost) * 60%));
|
||||||
|
-webkit-backdrop-filter:
|
||||||
|
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
|
||||||
|
saturate(calc(140% + var(--_frost) * 60%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::before,
|
||||||
|
.nv-material-glaze::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--nv-border-width) * -1);
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::before {
|
||||||
|
padding: var(--nv-glaze-rim);
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--nv-light-angle, 135deg),
|
||||||
|
var(--nv-color-glaze-rim-lit) 0%,
|
||||||
|
transparent 38%,
|
||||||
|
transparent 62%,
|
||||||
|
var(--nv-color-glaze-rim-far) 100%
|
||||||
|
);
|
||||||
|
mask:
|
||||||
|
linear-gradient(currentcolor 0 0) content-box,
|
||||||
|
linear-gradient(currentcolor 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-material-glaze::after {
|
||||||
|
z-index: -1;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle 16rem at var(--nv-light-x, 15%) var(--nv-light-y, 0%),
|
||||||
|
var(--nv-color-glaze-specular),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where translucency cannot be honoured, glaze falls back to the elevated
|
||||||
|
solid, as the other glass materials do. */
|
||||||
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
.nv-material-glaze {
|
||||||
|
background-color: var(--nv-color-surface-elevated);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.nv-material-glaze::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.nv-material-glaze { background-color: var(--nv-color-surface-elevated); }
|
||||||
|
.nv-material-glaze::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Elevation -------------------------------------------------------------
|
/* --- Elevation -------------------------------------------------------------
|
||||||
Elevation utilities carry three things at once, because a lifted surface is
|
Elevation utilities carry three things at once, because a lifted surface is
|
||||||
never only a shadow: the shadow itself, the highlight along its top edge,
|
never only a shadow: the shadow itself, the highlight along its top edge,
|
||||||
|
|
|
||||||
128
niveus/components/timepicker.css
Normal file
128
niveus/components/timepicker.css
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Time picker
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-timepicker nv-surface" data-nv-timepicker
|
||||||
|
data-nv-value="09:30" data-nv-step="5" data-nv-format-toggle></div>
|
||||||
|
|
||||||
|
Wheels, as on a phone: each column scrolls and snaps, and whatever sits in
|
||||||
|
the lens across the middle is the value. Scrolling, snapping, momentum and
|
||||||
|
touch are the browser's; niveus.js builds the columns, reads which row is
|
||||||
|
centred, and exposes the value as data-nv-value and an `nv:time` event.
|
||||||
|
|
||||||
|
The lens is the selection colour laid over the centre row, so it follows the
|
||||||
|
brand. Rows fade towards the top and bottom with a mask rather than a
|
||||||
|
coloured gradient, which keeps the fade right on any surface — solid, or
|
||||||
|
Glaze with something moving behind it.
|
||||||
|
|
||||||
|
The format is 24-hour unless told otherwise. With data-nv-format-toggle a
|
||||||
|
24H / AM/PM switch sits under the wheels and adds an AM/PM wheel when chosen;
|
||||||
|
the value stays 24-hour either way, so a form never has to know which the
|
||||||
|
person used.
|
||||||
|
|
||||||
|
Without niveus.js the element is empty. For a no-script fallback, use a
|
||||||
|
native <input type="time" class="nv-input">.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.nv-timepicker {
|
||||||
|
--_row: 2.25rem;
|
||||||
|
--_rows: 5;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3);
|
||||||
|
border-radius: var(--nv-radius-xl);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__wheels {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lens. It belongs to the wheels, not the picker, so the switch beneath
|
||||||
|
them never moves the centre. */
|
||||||
|
.nv-timepicker__wheels::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: calc(var(--nv-space-2) * -1);
|
||||||
|
inset-block-start: 50%;
|
||||||
|
block-size: var(--_row);
|
||||||
|
translate: 0 -50%;
|
||||||
|
background-color: var(--nv-color-selection);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
box-shadow: var(--nv-highlight-top);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column {
|
||||||
|
position: relative;
|
||||||
|
inline-size: 3.5rem;
|
||||||
|
block-size: calc(var(--_row) * var(--_rows));
|
||||||
|
margin: 0;
|
||||||
|
padding: calc(var(--_row) * (var(--_rows) - 1) / 2) 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
transparent,
|
||||||
|
currentcolor 28%,
|
||||||
|
currentcolor 72%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__column:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
box-shadow: var(--nv-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__option {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
block-size: var(--_row);
|
||||||
|
scroll-snap-align: center;
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--nv-transition-colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__option[aria-selected="true"] {
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 24H / AM/PM. A button group under the wheels; the pressed one is the format
|
||||||
|
in use. 24-hour is the default, so it starts pressed. */
|
||||||
|
.nv-timepicker__format {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed reads as pushed in: the format in use is a key held down. */
|
||||||
|
.nv-timepicker__format .nv-button[aria-pressed="true"] {
|
||||||
|
background-color: var(--nv-color-accent-subtle);
|
||||||
|
background-image: none;
|
||||||
|
color: var(--nv-color-accent-text);
|
||||||
|
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-timepicker__separator {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-self: center;
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font: var(--nv-font-label);
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
}
|
||||||
|
|
@ -48,10 +48,14 @@
|
||||||
@import url("components/surface.css") layer(niveus.components);
|
@import url("components/surface.css") layer(niveus.components);
|
||||||
@import url("components/button.css") layer(niveus.components);
|
@import url("components/button.css") layer(niveus.components);
|
||||||
@import url("components/input.css") layer(niveus.components);
|
@import url("components/input.css") layer(niveus.components);
|
||||||
|
@import url("components/slider.css") layer(niveus.components);
|
||||||
@import url("components/card.css") layer(niveus.components);
|
@import url("components/card.css") layer(niveus.components);
|
||||||
@import url("components/dialog.css") layer(niveus.components);
|
@import url("components/dialog.css") layer(niveus.components);
|
||||||
@import url("components/navigation.css") layer(niveus.components);
|
@import url("components/navigation.css") layer(niveus.components);
|
||||||
@import url("components/menu.css") layer(niveus.components);
|
@import url("components/menu.css") layer(niveus.components);
|
||||||
|
@import url("components/picker.css") layer(niveus.components);
|
||||||
|
@import url("components/calendar.css") layer(niveus.components);
|
||||||
|
@import url("components/timepicker.css") layer(niveus.components);
|
||||||
@import url("components/disclosure.css") layer(niveus.components);
|
@import url("components/disclosure.css") layer(niveus.components);
|
||||||
@import url("components/scroller.css") layer(niveus.components);
|
@import url("components/scroller.css") layer(niveus.components);
|
||||||
@import url("components/bed.css") layer(niveus.components);
|
@import url("components/bed.css") layer(niveus.components);
|
||||||
|
|
|
||||||
631
niveus/niveus.js
631
niveus/niveus.js
|
|
@ -355,6 +355,628 @@
|
||||||
surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px");
|
surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The light on a .nv-material-glaze follows the pointer. One listener for
|
||||||
|
* the document and one write per frame, so a page of glass costs the same as
|
||||||
|
* a page of one pane. Each surface is told where the pointer is relative to
|
||||||
|
* itself (--nv-light-x/-y) and which way it lies (--nv-light-angle, the CSS
|
||||||
|
* gradient angle that puts the bright end of the rim on that side).
|
||||||
|
* Touch and reduced motion leave the default light from the top left.
|
||||||
|
*/
|
||||||
|
var lightFrame = 0;
|
||||||
|
var lightX = 0;
|
||||||
|
var lightY = 0;
|
||||||
|
|
||||||
|
function applyLight() {
|
||||||
|
lightFrame = 0;
|
||||||
|
var surfaces = document.querySelectorAll(".nv-material-glaze");
|
||||||
|
for (var i = 0; i < surfaces.length; i++) {
|
||||||
|
var surface = surfaces[i];
|
||||||
|
var box = surface.getBoundingClientRect();
|
||||||
|
if (box.bottom < 0 || box.top > global.innerHeight) continue;
|
||||||
|
var x = lightX - box.left;
|
||||||
|
var y = lightY - box.top;
|
||||||
|
var angle = Math.atan2(box.left + box.width / 2 - lightX, lightY - (box.top + box.height / 2));
|
||||||
|
surface.style.setProperty("--nv-light-x", x.toFixed(1) + "px");
|
||||||
|
surface.style.setProperty("--nv-light-y", y.toFixed(1) + "px");
|
||||||
|
surface.style.setProperty("--nv-light-angle", (angle * 180 / Math.PI).toFixed(1) + "deg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackLight(event) {
|
||||||
|
if (event.pointerType && event.pointerType === "touch") return;
|
||||||
|
if (prefersReducedMotion()) return;
|
||||||
|
lightX = event.clientX;
|
||||||
|
lightY = event.clientY;
|
||||||
|
if (!lightFrame) lightFrame = global.requestAnimationFrame(applyLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A .nv-slider keeps its fill in step with its value, and, with data-nv-var,
|
||||||
|
* writes that value into a CSS custom property: on :root by default, or on
|
||||||
|
* the element data-nv-var-target selects. The property is the source of
|
||||||
|
* truth, so the slider starts where the stylesheet already is.
|
||||||
|
*/
|
||||||
|
function initSlider(slider) {
|
||||||
|
if (slider.dataset.nvSliderWired) return;
|
||||||
|
slider.dataset.nvSliderWired = "";
|
||||||
|
|
||||||
|
var name = slider.dataset.nvVar;
|
||||||
|
var target = document.documentElement;
|
||||||
|
if (name && slider.dataset.nvVarTarget) {
|
||||||
|
target = document.querySelector(slider.dataset.nvVarTarget) || target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill() {
|
||||||
|
var min = parseFloat(slider.min || "0");
|
||||||
|
var max = parseFloat(slider.max || "100");
|
||||||
|
var fraction = max > min ? (parseFloat(slider.value) - min) / (max - min) : 0;
|
||||||
|
// The fill ends under the middle of the thumb, which travels one thumb
|
||||||
|
// width less than the track.
|
||||||
|
slider.style.setProperty(
|
||||||
|
"--nv-slider-pos",
|
||||||
|
"calc(" + (fraction * 100).toFixed(2) + "% + " + (0.5 - fraction).toFixed(3) + " * var(--_thumb))"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
var current = parseFloat(getComputedStyle(target).getPropertyValue(name));
|
||||||
|
if (!isNaN(current)) slider.value = current;
|
||||||
|
}
|
||||||
|
fill();
|
||||||
|
|
||||||
|
slider.addEventListener("input", function () {
|
||||||
|
fill();
|
||||||
|
if (name) target.style.setProperty(name, slider.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Calendar
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function pad(n) {
|
||||||
|
return (n < 10 ? "0" : "") + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(date) {
|
||||||
|
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(text) {
|
||||||
|
var match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text || "");
|
||||||
|
return match ? new Date(+match[1], +match[2] - 1, +match[3]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekStart(locale) {
|
||||||
|
try {
|
||||||
|
var info = new Intl.Locale(locale || navigator.language);
|
||||||
|
var week = info.getWeekInfo ? info.getWeekInfo() : info.weekInfo;
|
||||||
|
if (week && week.firstDay) return week.firstDay % 7;
|
||||||
|
} catch (error) { /* fall through to Monday */ }
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and drives one .nv-calendar. Configuration is data-*: nv-value
|
||||||
|
* (YYYY-MM-DD), nv-min, nv-max, nv-locale, nv-week-start (0 = Sunday).
|
||||||
|
* Choosing a day sets data-nv-value and fires `nv:date` with
|
||||||
|
* { value, date }. Stepping months travels as a view transition.
|
||||||
|
*/
|
||||||
|
function initCalendar(calendar) {
|
||||||
|
if (calendar.dataset.nvCalendarWired) return;
|
||||||
|
calendar.dataset.nvCalendarWired = "";
|
||||||
|
|
||||||
|
var locale = calendar.dataset.nvLocale || document.documentElement.lang || undefined;
|
||||||
|
var now = new Date();
|
||||||
|
var today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
var selected = parseDate(calendar.dataset.nvValue);
|
||||||
|
var min = parseDate(calendar.dataset.nvMin);
|
||||||
|
var max = parseDate(calendar.dataset.nvMax);
|
||||||
|
var first = calendar.dataset.nvWeekStart !== undefined
|
||||||
|
? parseInt(calendar.dataset.nvWeekStart, 10) % 7
|
||||||
|
: weekStart(locale);
|
||||||
|
var active = selected || today;
|
||||||
|
var view = new Date(active.getFullYear(), active.getMonth(), 1);
|
||||||
|
|
||||||
|
var title = calendar.querySelector(".nv-calendar__title");
|
||||||
|
var grid = calendar.querySelector(".nv-calendar__grid");
|
||||||
|
if (!grid) {
|
||||||
|
grid = document.createElement("div");
|
||||||
|
grid.className = "nv-calendar__grid";
|
||||||
|
grid.setAttribute("role", "grid");
|
||||||
|
calendar.appendChild(grid);
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
if (!title.id) title.id = "nv-calendar-" + Math.random().toString(36).slice(2, 8);
|
||||||
|
grid.setAttribute("aria-labelledby", title.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" });
|
||||||
|
grid.textContent = "";
|
||||||
|
|
||||||
|
var head = document.createElement("div");
|
||||||
|
head.setAttribute("role", "row");
|
||||||
|
for (var d = 0; d < 7; d++) {
|
||||||
|
var name = document.createElement("span");
|
||||||
|
name.className = "nv-calendar__weekday";
|
||||||
|
name.setAttribute("role", "columnheader");
|
||||||
|
// 2023-01-01 was a Sunday.
|
||||||
|
name.textContent = new Date(2023, 0, 1 + ((first + d) % 7))
|
||||||
|
.toLocaleDateString(locale, { weekday: "short" });
|
||||||
|
head.appendChild(name);
|
||||||
|
}
|
||||||
|
grid.appendChild(head);
|
||||||
|
|
||||||
|
var offset = (view.getDay() - first + 7) % 7;
|
||||||
|
var cursor = new Date(view.getFullYear(), view.getMonth(), 1 - offset);
|
||||||
|
var activeIso = isoDate(active);
|
||||||
|
var selectedIso = selected ? isoDate(selected) : "";
|
||||||
|
var todayIso = isoDate(today);
|
||||||
|
var row;
|
||||||
|
|
||||||
|
// Six weeks every month, so the calendar never changes height.
|
||||||
|
for (var i = 0; i < 42; i++) {
|
||||||
|
if (i % 7 === 0) {
|
||||||
|
row = document.createElement("div");
|
||||||
|
row.setAttribute("role", "row");
|
||||||
|
grid.appendChild(row);
|
||||||
|
}
|
||||||
|
var iso = isoDate(cursor);
|
||||||
|
var day = document.createElement("button");
|
||||||
|
day.type = "button";
|
||||||
|
day.className = "nv-calendar__day";
|
||||||
|
day.setAttribute("role", "gridcell");
|
||||||
|
day.dataset.date = iso;
|
||||||
|
day.textContent = cursor.getDate();
|
||||||
|
day.setAttribute("aria-label", cursor.toLocaleDateString(locale, { dateStyle: "full" }));
|
||||||
|
day.tabIndex = iso === activeIso ? 0 : -1;
|
||||||
|
if (cursor.getMonth() !== view.getMonth()) day.dataset.outside = "";
|
||||||
|
if (iso === todayIso) day.setAttribute("aria-current", "date");
|
||||||
|
if (iso === selectedIso) day.setAttribute("aria-selected", "true");
|
||||||
|
if ((min && cursor < min) || (max && cursor > max)) day.disabled = true;
|
||||||
|
row.appendChild(day);
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active day may sit outside this month's grid after a step.
|
||||||
|
if (!grid.querySelector('[tabindex="0"]')) {
|
||||||
|
var anchor = grid.querySelector(".nv-calendar__day:not([data-outside]):not(:disabled)");
|
||||||
|
if (anchor) anchor.tabIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusActive() {
|
||||||
|
var button = grid.querySelector('[data-date="' + isoDate(active) + '"]');
|
||||||
|
if (button) button.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(date, direction, focus) {
|
||||||
|
var monthChanged = date.getFullYear() !== view.getFullYear() || date.getMonth() !== view.getMonth();
|
||||||
|
active = date;
|
||||||
|
var update = function () {
|
||||||
|
view = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
render();
|
||||||
|
if (focus) focusActive();
|
||||||
|
};
|
||||||
|
if (monthChanged) transition(update, { direction: direction });
|
||||||
|
else update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepMonth(count) {
|
||||||
|
var target = new Date(view.getFullYear(), view.getMonth() + count, 1);
|
||||||
|
var last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
||||||
|
target.setDate(Math.min(active.getDate(), last));
|
||||||
|
show(target, count > 0 ? "forward" : "backward", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(button) {
|
||||||
|
selected = parseDate(button.dataset.date);
|
||||||
|
active = selected;
|
||||||
|
calendar.dataset.nvValue = button.dataset.date;
|
||||||
|
if (button.dataset.outside !== undefined) {
|
||||||
|
show(selected, selected > view ? "forward" : "backward", true);
|
||||||
|
} else {
|
||||||
|
grid.querySelectorAll('[aria-selected="true"]').forEach(function (other) {
|
||||||
|
other.removeAttribute("aria-selected");
|
||||||
|
});
|
||||||
|
button.setAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
calendar.dispatchEvent(new CustomEvent("nv:date", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { value: button.dataset.date, date: selected },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.addEventListener("click", function (event) {
|
||||||
|
var step = event.target.closest("[data-nv-calendar-step]");
|
||||||
|
if (step && calendar.contains(step)) {
|
||||||
|
stepMonth(parseInt(step.dataset.nvCalendarStep, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var day = event.target.closest(".nv-calendar__day");
|
||||||
|
if (day && !day.disabled) choose(day);
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener("keydown", function (event) {
|
||||||
|
var day = event.target.closest(".nv-calendar__day");
|
||||||
|
if (!day) return;
|
||||||
|
var rtl = getComputedStyle(calendar).direction === "rtl";
|
||||||
|
var from = parseDate(day.dataset.date);
|
||||||
|
var to = new Date(from.getFullYear(), from.getMonth(), from.getDate());
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowLeft": to.setDate(to.getDate() + (rtl ? 1 : -1)); break;
|
||||||
|
case "ArrowRight": to.setDate(to.getDate() + (rtl ? -1 : 1)); break;
|
||||||
|
case "ArrowUp": to.setDate(to.getDate() - 7); break;
|
||||||
|
case "ArrowDown": to.setDate(to.getDate() + 7); break;
|
||||||
|
case "Home": to.setDate(to.getDate() - ((to.getDay() - first + 7) % 7)); break;
|
||||||
|
case "End": to.setDate(to.getDate() + 6 - ((to.getDay() - first + 7) % 7)); break;
|
||||||
|
case "PageUp": to.setMonth(to.getMonth() - (event.shiftKey ? 12 : 1)); break;
|
||||||
|
case "PageDown": to.setMonth(to.getMonth() + (event.shiftKey ? 12 : 1)); break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if ((min && to < min) || (max && to > max)) return;
|
||||||
|
show(to, to > from ? "forward" : "backward", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Time picker
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and drives one .nv-timepicker from data-*: nv-value (HH:MM, 24h),
|
||||||
|
* nv-hours ("24", the default, or "12" for an AM/PM wheel), nv-step (minute
|
||||||
|
* interval), nv-name (adds a hidden input for forms) and nv-format-toggle
|
||||||
|
* (adds a 24H / AM/PM switch under the wheels). The value is always 24-hour,
|
||||||
|
* whatever the wheels show. Fires `nv:time` with { value } as the wheels
|
||||||
|
* settle on a new row.
|
||||||
|
*/
|
||||||
|
function initTimePicker(picker) {
|
||||||
|
if (picker.dataset.nvTimeWired) return;
|
||||||
|
picker.dataset.nvTimeWired = "";
|
||||||
|
|
||||||
|
var twelve = picker.dataset.nvHours === "12";
|
||||||
|
var step = Math.max(1, parseInt(picker.dataset.nvStep || "1", 10));
|
||||||
|
var now = new Date();
|
||||||
|
var match = /^(\d{1,2}):(\d{2})$/.exec(picker.dataset.nvValue || "");
|
||||||
|
var h24 = match ? parseInt(match[1], 10) % 24 : now.getHours();
|
||||||
|
var minute = match ? parseInt(match[2], 10) % 60 : now.getMinutes();
|
||||||
|
|
||||||
|
var minuteItems = [];
|
||||||
|
for (var m = 0; m < 60; m += step) minuteItems.push(pad(m));
|
||||||
|
|
||||||
|
var wheels = document.createElement("div");
|
||||||
|
wheels.className = "nv-timepicker__wheels";
|
||||||
|
var hourColumn, minuteColumn, periodColumn;
|
||||||
|
var field = null;
|
||||||
|
var formatButtons = [];
|
||||||
|
var placed = false;
|
||||||
|
var last = "";
|
||||||
|
var frame = 0;
|
||||||
|
|
||||||
|
function indexOf(column) {
|
||||||
|
var row = column.firstElementChild ? column.firstElementChild.offsetHeight : 0;
|
||||||
|
if (!row) return -1;
|
||||||
|
return Math.max(0, Math.min(column.children.length - 1, Math.round(column.scrollTop / row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTo(column, index, smooth) {
|
||||||
|
var row = column.firstElementChild.offsetHeight;
|
||||||
|
column.scrollTo({ top: index * row, behavior: smooth && !prefersReducedMotion() ? "smooth" : "auto" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mark(column, index) {
|
||||||
|
for (var i = 0; i < column.children.length; i++) {
|
||||||
|
column.children[i].setAttribute("aria-selected", i === index ? "true" : "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
var hi = indexOf(hourColumn);
|
||||||
|
var mi = indexOf(minuteColumn);
|
||||||
|
if (hi < 0 || mi < 0) return;
|
||||||
|
var pm = periodColumn ? indexOf(periodColumn) === 1 : false;
|
||||||
|
mark(hourColumn, hi);
|
||||||
|
mark(minuteColumn, mi);
|
||||||
|
if (periodColumn) mark(periodColumn, pm ? 1 : 0);
|
||||||
|
|
||||||
|
var hour = twelve ? ((hi + 1) % 12) + (pm ? 12 : 0) : hi;
|
||||||
|
var value = pad(hour) + ":" + minuteItems[mi];
|
||||||
|
if (value === last) return;
|
||||||
|
last = value;
|
||||||
|
// place() replays these when the picker is shown again, so they must
|
||||||
|
// follow the wheels rather than stay at what it was built with.
|
||||||
|
h24 = hour;
|
||||||
|
minute = parseInt(minuteItems[mi], 10);
|
||||||
|
picker.dataset.nvValue = value;
|
||||||
|
if (field) field.value = value;
|
||||||
|
picker.dispatchEvent(new CustomEvent("nv:time", { bubbles: true, detail: { value: value } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puts the wheels where the value says. Needs layout, so it runs when the
|
||||||
|
// picker becomes visible, not when it is built (it may sit in a closed
|
||||||
|
// popover).
|
||||||
|
function place() {
|
||||||
|
if (!hourColumn.firstElementChild || !hourColumn.firstElementChild.offsetHeight) return;
|
||||||
|
placed = true;
|
||||||
|
var hi = twelve ? ((h24 % 12) || 12) - 1 : h24;
|
||||||
|
var mi = Math.round(minute / step) % minuteItems.length;
|
||||||
|
scrollTo(hourColumn, hi, false);
|
||||||
|
scrollTo(minuteColumn, mi, false);
|
||||||
|
if (periodColumn) scrollTo(periodColumn, h24 >= 12 ? 1 : 0, false);
|
||||||
|
read();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumn(label, items) {
|
||||||
|
var list = document.createElement("ul");
|
||||||
|
list.className = "nv-timepicker__column";
|
||||||
|
list.setAttribute("role", "listbox");
|
||||||
|
list.setAttribute("aria-label", label);
|
||||||
|
list.tabIndex = 0;
|
||||||
|
items.forEach(function (text) {
|
||||||
|
var option = document.createElement("li");
|
||||||
|
option.className = "nv-timepicker__option";
|
||||||
|
option.setAttribute("role", "option");
|
||||||
|
option.textContent = text;
|
||||||
|
list.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener("scroll", function () {
|
||||||
|
if (frame) return;
|
||||||
|
frame = global.requestAnimationFrame(function () { frame = 0; read(); });
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
list.addEventListener("click", function (event) {
|
||||||
|
var option = event.target.closest(".nv-timepicker__option");
|
||||||
|
if (option) scrollTo(list, Array.prototype.indexOf.call(list.children, option), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener("keydown", function (event) {
|
||||||
|
var index = indexOf(list);
|
||||||
|
var end = list.children.length - 1;
|
||||||
|
var to;
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowUp": to = index - 1; break;
|
||||||
|
case "ArrowDown": to = index + 1; break;
|
||||||
|
case "PageUp": to = index - 5; break;
|
||||||
|
case "PageDown": to = index + 5; break;
|
||||||
|
case "Home": to = 0; break;
|
||||||
|
case "End": to = end; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
scrollTo(list, Math.max(0, Math.min(end, to)), true);
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colon() {
|
||||||
|
var mark = document.createElement("span");
|
||||||
|
mark.className = "nv-timepicker__separator";
|
||||||
|
mark.setAttribute("aria-hidden", "true");
|
||||||
|
mark.textContent = ":";
|
||||||
|
return mark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wheels depend on the format, so a change of format rebuilds them and
|
||||||
|
// lands on the same moment in the new layout.
|
||||||
|
function build() {
|
||||||
|
var hourItems = [];
|
||||||
|
var count = twelve ? 12 : 24;
|
||||||
|
for (var h = 0; h < count; h++) hourItems.push(twelve ? String(h + 1) : pad(h));
|
||||||
|
|
||||||
|
wheels.textContent = "";
|
||||||
|
hourColumn = buildColumn("Hours", hourItems);
|
||||||
|
minuteColumn = buildColumn("Minutes", minuteItems);
|
||||||
|
periodColumn = twelve ? buildColumn("AM or PM", ["AM", "PM"]) : null;
|
||||||
|
wheels.appendChild(hourColumn);
|
||||||
|
wheels.appendChild(colon());
|
||||||
|
wheels.appendChild(minuteColumn);
|
||||||
|
if (periodColumn) wheels.appendChild(periodColumn);
|
||||||
|
|
||||||
|
formatButtons.forEach(function (button) {
|
||||||
|
button.setAttribute("aria-pressed", String((button.dataset.hours === "12") === twelve));
|
||||||
|
});
|
||||||
|
placed = false;
|
||||||
|
place();
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.textContent = "";
|
||||||
|
picker.setAttribute("role", "group");
|
||||||
|
if (!picker.hasAttribute("aria-label") && !picker.hasAttribute("aria-labelledby")) {
|
||||||
|
picker.setAttribute("aria-label", "Time");
|
||||||
|
}
|
||||||
|
picker.appendChild(wheels);
|
||||||
|
|
||||||
|
if (picker.hasAttribute("data-nv-format-toggle")) {
|
||||||
|
var format = document.createElement("div");
|
||||||
|
format.className = "nv-timepicker__format nv-button-group";
|
||||||
|
format.setAttribute("role", "group");
|
||||||
|
format.setAttribute("aria-label", "Time format");
|
||||||
|
[["24", "24H"], ["12", "AM/PM"]].forEach(function (option) {
|
||||||
|
var button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "nv-button nv-button--sm";
|
||||||
|
button.dataset.hours = option[0];
|
||||||
|
button.textContent = option[1];
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
var wants = option[0] === "12";
|
||||||
|
if (wants === twelve) return;
|
||||||
|
twelve = wants;
|
||||||
|
picker.dataset.nvHours = option[0];
|
||||||
|
build();
|
||||||
|
});
|
||||||
|
formatButtons.push(button);
|
||||||
|
format.appendChild(button);
|
||||||
|
});
|
||||||
|
picker.appendChild(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picker.dataset.nvName) {
|
||||||
|
field = document.createElement("input");
|
||||||
|
field.type = "hidden";
|
||||||
|
field.name = picker.dataset.nvName;
|
||||||
|
picker.appendChild(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
|
|
||||||
|
if (typeof global.IntersectionObserver === "function") {
|
||||||
|
// A hidden element loses its scroll offsets, so the wheels are put back
|
||||||
|
// each time the picker reappears — and only then, never while it is on
|
||||||
|
// screen and being turned.
|
||||||
|
var seen = new global.IntersectionObserver(function (entries) {
|
||||||
|
var visible = entries.some(function (entry) { return entry.isIntersecting; });
|
||||||
|
if (!visible) placed = false;
|
||||||
|
else if (!placed) place();
|
||||||
|
});
|
||||||
|
seen.observe(picker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Popup list
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a .nv-menu--listbox the behaviour of a native select: arrows,
|
||||||
|
* Home/End, type-ahead, Enter/Space to choose, and filtering when it holds a
|
||||||
|
* .nv-menu__search. The trigger is the element whose popovertarget is the
|
||||||
|
* list's id; it receives `nv:select` with { value, label } and keeps the
|
||||||
|
* choice in data-nv-value.
|
||||||
|
*/
|
||||||
|
function initListbox(list) {
|
||||||
|
if (list.dataset.nvListWired || !list.id) return;
|
||||||
|
list.dataset.nvListWired = "";
|
||||||
|
|
||||||
|
var trigger = document.querySelector('[popovertarget="' + list.id + '"]');
|
||||||
|
var label = trigger && (trigger.querySelector("[data-nv-picker-label]") || trigger);
|
||||||
|
var search = list.querySelector(".nv-menu__search");
|
||||||
|
var buffer = "";
|
||||||
|
var bufferTimer = 0;
|
||||||
|
|
||||||
|
function options() {
|
||||||
|
return Array.prototype.filter.call(list.querySelectorAll('[role="option"]'), function (option) {
|
||||||
|
return !option.hidden && !option.disabled && option.getAttribute("aria-disabled") !== "true";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(option) {
|
||||||
|
list.querySelectorAll('[role="option"]').forEach(function (other) {
|
||||||
|
other.setAttribute("aria-selected", other === option ? "true" : "false");
|
||||||
|
});
|
||||||
|
var text = option.dataset.label || option.textContent.trim();
|
||||||
|
var value = option.dataset.value !== undefined ? option.dataset.value : text;
|
||||||
|
if (trigger) {
|
||||||
|
if (label) label.textContent = text;
|
||||||
|
trigger.removeAttribute("data-empty");
|
||||||
|
trigger.dataset.nvValue = value;
|
||||||
|
trigger.dispatchEvent(new CustomEvent("nv:select", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { value: value, label: text },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
list.hidePopover();
|
||||||
|
if (trigger) trigger.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAt(items, index) {
|
||||||
|
if (!items.length) return;
|
||||||
|
items[(index + items.length) % items.length].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter() {
|
||||||
|
var query = search.value.trim().toLowerCase();
|
||||||
|
list.querySelectorAll('[role="option"]').forEach(function (option) {
|
||||||
|
option.hidden = query !== "" && option.textContent.toLowerCase().indexOf(query) === -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.setAttribute("role", "listbox");
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute("aria-haspopup", "listbox");
|
||||||
|
trigger.setAttribute("aria-expanded", "false");
|
||||||
|
trigger.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
list.showPopover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener("toggle", function (event) {
|
||||||
|
var open = event.newState === "open";
|
||||||
|
if (trigger) trigger.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (!open) return;
|
||||||
|
if (search) {
|
||||||
|
search.value = "";
|
||||||
|
filter();
|
||||||
|
search.focus();
|
||||||
|
} else {
|
||||||
|
var items = options();
|
||||||
|
var current = list.querySelector('[aria-selected="true"]');
|
||||||
|
(current && !current.hidden ? current : items[0] || list).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener("click", function (event) {
|
||||||
|
var option = event.target.closest('[role="option"]');
|
||||||
|
if (option && list.contains(option) && !option.disabled) choose(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (search) search.addEventListener("input", filter);
|
||||||
|
|
||||||
|
list.addEventListener("keydown", function (event) {
|
||||||
|
var items = options();
|
||||||
|
var index = items.indexOf(document.activeElement);
|
||||||
|
var typing = event.target === search;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
focusAt(items, typing ? 0 : index + 1);
|
||||||
|
return;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
if (typing) return;
|
||||||
|
if (index === 0 && search) search.focus();
|
||||||
|
else focusAt(items, index - 1);
|
||||||
|
return;
|
||||||
|
case "Home":
|
||||||
|
if (typing) return;
|
||||||
|
event.preventDefault();
|
||||||
|
focusAt(items, 0);
|
||||||
|
return;
|
||||||
|
case "End":
|
||||||
|
if (typing) return;
|
||||||
|
event.preventDefault();
|
||||||
|
focusAt(items, items.length - 1);
|
||||||
|
return;
|
||||||
|
case "Enter":
|
||||||
|
if (typing && items.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(items[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-ahead, for a list with nothing to type into.
|
||||||
|
if (typing || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) return;
|
||||||
|
buffer += event.key.toLowerCase();
|
||||||
|
global.clearTimeout(bufferTimer);
|
||||||
|
bufferTimer = global.setTimeout(function () { buffer = ""; }, 600);
|
||||||
|
var hit = items.filter(function (option) {
|
||||||
|
return option.textContent.trim().toLowerCase().indexOf(buffer) === 0;
|
||||||
|
})[0];
|
||||||
|
if (hit) hit.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wire(scope) {
|
function wire(scope) {
|
||||||
scope = scope || document;
|
scope = scope || document;
|
||||||
|
|
||||||
|
|
@ -372,6 +994,10 @@
|
||||||
|
|
||||||
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
|
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
|
||||||
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
|
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
|
||||||
|
scope.querySelectorAll(".nv-slider").forEach(initSlider);
|
||||||
|
scope.querySelectorAll("[data-nv-calendar]").forEach(initCalendar);
|
||||||
|
scope.querySelectorAll("[data-nv-timepicker]").forEach(initTimePicker);
|
||||||
|
scope.querySelectorAll(".nv-menu--listbox").forEach(initListbox);
|
||||||
initReveal(scope);
|
initReveal(scope);
|
||||||
|
|
||||||
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
|
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
|
||||||
|
|
@ -399,12 +1025,17 @@
|
||||||
openDialog: openDialog,
|
openDialog: openDialog,
|
||||||
selectTab: selectTab,
|
selectTab: selectTab,
|
||||||
initScroller: initScroller,
|
initScroller: initScroller,
|
||||||
|
initSlider: initSlider,
|
||||||
|
initCalendar: initCalendar,
|
||||||
|
initTimePicker: initTimePicker,
|
||||||
|
initListbox: initListbox,
|
||||||
liftLabels: liftLabels,
|
liftLabels: liftLabels,
|
||||||
wire: wire,
|
wire: wire,
|
||||||
prefersReducedMotion: prefersReducedMotion,
|
prefersReducedMotion: prefersReducedMotion,
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("pointermove", trackSpotlight, { passive: true });
|
document.addEventListener("pointermove", trackSpotlight, { passive: true });
|
||||||
|
document.addEventListener("pointermove", trackLight, { passive: true });
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", function () { wire(); });
|
document.addEventListener("DOMContentLoaded", function () { wire(); });
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,11 @@
|
||||||
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
|
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
|
||||||
--nv-dark-material-border: rgb(255 255 255 / 0.14);
|
--nv-dark-material-border: rgb(255 255 255 / 0.14);
|
||||||
|
|
||||||
|
--nv-dark-material-glaze: color-mix(in oklab, rgb(30 30 38 / 1), var(--nv-brand) var(--nv-brand-tint));
|
||||||
|
--nv-dark-glaze-rim-lit: rgb(255 255 255 / 0.55);
|
||||||
|
--nv-dark-glaze-rim-far: rgb(255 255 255 / 0.14);
|
||||||
|
--nv-dark-glaze-specular: rgb(255 255 255 / 0.18);
|
||||||
|
|
||||||
/* --- Window controls --------------------------------------------------------- */
|
/* --- Window controls --------------------------------------------------------- */
|
||||||
--nv-dark-window-close: var(--nv-palette-window-close);
|
--nv-dark-window-close: var(--nv-palette-window-close);
|
||||||
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
|
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,13 @@
|
||||||
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
--nv-light-material-glass: rgb(252 252 250 / 0.4);
|
||||||
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
--nv-light-material-border: rgb(255 255 255 / 0.72);
|
||||||
|
|
||||||
|
/* Glaze: the tint it is made of, the rim that catches light on the side
|
||||||
|
facing the pointer, the rim on the far side, and the specular bloom. */
|
||||||
|
--nv-light-material-glaze: rgb(255 255 255 / 1);
|
||||||
|
--nv-light-glaze-rim-lit: rgb(255 255 255 / 1);
|
||||||
|
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.5);
|
||||||
|
--nv-light-glaze-specular: rgb(255 255 255 / 0.6);
|
||||||
|
|
||||||
/* --- Window controls --------------------------------------------------------- */
|
/* --- Window controls --------------------------------------------------------- */
|
||||||
--nv-light-window-close: var(--nv-palette-window-close);
|
--nv-light-window-close: var(--nv-palette-window-close);
|
||||||
--nv-light-window-minimize: var(--nv-palette-window-minimize);
|
--nv-light-window-minimize: var(--nv-palette-window-minimize);
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,10 @@
|
||||||
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
|
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
|
||||||
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
|
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
|
||||||
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
|
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
|
||||||
|
--nv-color-material-glaze: light-dark(var(--nv-light-material-glaze), var(--nv-dark-material-glaze));
|
||||||
|
--nv-color-glaze-rim-lit: light-dark(var(--nv-light-glaze-rim-lit), var(--nv-dark-glaze-rim-lit));
|
||||||
|
--nv-color-glaze-rim-far: light-dark(var(--nv-light-glaze-rim-far), var(--nv-dark-glaze-rim-far));
|
||||||
|
--nv-color-glaze-specular: light-dark(var(--nv-light-glaze-specular), var(--nv-dark-glaze-specular));
|
||||||
|
|
||||||
/* --- Window controls ------------------------------------------------------- */
|
/* --- Window controls ------------------------------------------------------- */
|
||||||
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
|
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
over content: bars, docks, launchers, notifications, menus.
|
over content: bars, docks, launchers, notifications, menus.
|
||||||
glass more translucent, lightly blurred. Contextual layers where the
|
glass more translucent, lightly blurred. Contextual layers where the
|
||||||
content behind should stay legible: overlays, hover cards.
|
content behind should stay legible: overlays, hover cards.
|
||||||
|
glaze glass with a dial. How frosted it is belongs to whoever is
|
||||||
|
looking at it, so it is one number (--nv-glass-frost) rather
|
||||||
|
than a fixed recipe, and it carries a rim that catches the
|
||||||
|
light and follows the pointer.
|
||||||
|
|
||||||
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
|
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
|
||||||
and everything that holds content are solid; translucency is reserved for
|
and everything that holds content are solid; translucency is reserved for
|
||||||
|
|
@ -39,6 +43,18 @@
|
||||||
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
|
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
|
||||||
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
|
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
|
||||||
|
|
||||||
|
/* --- Frost ------------------------------------------------------------------
|
||||||
|
The one input the glaze material derives from: 0 is clear glass, 1 is
|
||||||
|
nearly opaque frost. Set it on :root for the whole site, on a subtree to
|
||||||
|
change just that region, or bind a slider to it with data-nv-var and let
|
||||||
|
the person looking choose. The glaze recipe reads it where it is used,
|
||||||
|
so a subtree override always reaches the surfaces inside it. */
|
||||||
|
--nv-glass-frost: 0.5;
|
||||||
|
--nv-glaze-blur-max: 56px; /* blur at frost 1 */
|
||||||
|
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
|
||||||
|
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
|
||||||
|
--nv-glaze-rim: 1.5px;
|
||||||
|
|
||||||
/* --- Sheen ------------------------------------------------------------------
|
/* --- Sheen ------------------------------------------------------------------
|
||||||
raised neutral controls: buttons, the selected tab, a titlebar
|
raised neutral controls: buttons, the selected tab, a titlebar
|
||||||
filled saturated fills: primary and danger buttons, checked marks
|
filled saturated fills: primary and danger buttons, checked marks
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue