UNPKG

@sandlada/mdc

Version:

@sandlada/mdc is an open source component library that follows the Material Design 3 design specifications.

663 lines (605 loc) 32.9 kB
import{css as e,unsafeCSS as t}from"../../node_modules/@lit/reactive-element/css-tag.js";import"../../node_modules/lit/index.js";import{defineVars as n}from"../../node_modules/@sandlada/jss/dist/define-vars.js";import{defineTokenRefsRecord as r}from"../../node_modules/@sandlada/jss/dist/define-token-refs.js";import{Duration as i}from"../../node_modules/@sandlada/mdk/build/domain/duration.js";import{Easing as a}from"../../node_modules/@sandlada/mdk/build/domain/easing.js";import{Shape as o}from"../../node_modules/@sandlada/mdk/build/domain/shape.js";import"../../node_modules/@sandlada/mdk/build/index.js";import{SliderDefinition as s}from"../../component-definitions/slider.definition.js"; /** * @license * Copyright 2026 Kai-Orion & Sandlada * SPDX-License-Identifier: MIT * * @fileoverview * Styles for `mdc-expressive-slider` — Material Design 3 Expressive slider. * * Locked to the Figma prototype (node-id `58008:10353`): * * | Size | Track h | Handle h | Rounded end | * | ------------- | ------- | -------- | ----------- | * | extra-small | 16 | 44 | 16 | * | small | 24 | 44 | 8 | * | medium | 40 | 52 | 12 | * | large | 56 | 68 | 16 | * | extra-large | 96 | 108 | 28 | * * Three behavioral types — `standard`, `centered`, `range` — render through * shared CSS via `data-active` / `data-position` attributes. Two directions — * `horizontal` (default), `vertical` — swap flex direction and per-track * `inline-size` / `block-size`. * * ── Edge inset ──────────────────────────────────────────────────────── * The visible track is inset by `--_edge-inset` (= `handleWidth / 2`) on * each side so the handle's interaction center is offset from the slider * edge at extremes and the edge-tick / edge-stop-indicator dots all line * up with the visible track edges. See expressive-slider.ts for the * per-segment inline-style math that uses this inset. * * Centered mode mirrors the same inset on each side of the 50% midpoint * (`--_center-inset`) so the active overlay has a center gap and rounded * ends on BOTH sides (per MD3E / Compose Material3 spec). * * @link https://m3.material.io/components/sliders/specs */ const c=r(s,{expandShapes:!1,useBaseFallback:!0,prefix:`--mdc-expressive-slider`}),l=t(n(c,!0).join(``)),u=t(i.Short2.ToCSSVariable()),d=t(a.Emphasized.ToCSSVariable()),f=[e` :host { ${l}; /* Per-size internal aliases. */ --_track-height: 16px; --_active-leading-shape: 16px; --_active-trailing-shape: 2px; --_inactive-leading-shape: 2px; --_inactive-trailing-shape: 16px; --_handle-size: 44px; --_handle-width: 4px; --_center-dot-size: 4px; --_stop-indicator-size: 4px; /* Visible gap between handle edge and track edge. */ --_thumb-track-gap: 4px; /* Label (value indicator) — MD3E spec: 48x44 pill, 16/12 padding, Full shape, 6px gap to slider. Hardcoded here because the public token indirection loses the fallback in the current vite/oxc transform pipeline; the public tokens are still in slider.definition.ts for consumers. */ --_label-container-width: 48px; --_label-container-height: 44px; --_label-container-padding-inline: 16px; --_label-container-padding-block: 12px; --_label-bottom-space: 6px; /* Edge inset: the visible track is inset by handleWidth/2 on each side. Derives from --_handle-width so each size preset gets its own inset for free. */ --_edge-inset: calc(var(--_handle-width) / 2); /* Centered-mode mirror: a symmetric inset on each side of 50% so the active overlay has a center gap. */ --_center-inset: var(--_edge-inset); /* Fractions set at render time by the component. */ --_start-fraction: 0; --_end-fraction: 0; --_tick-count: 0; /* Range-mode input clip points (published from the inline style so the input pointer-event split matches the handle split; defaults to fixed 50% when no override is set). */ --_clip-to-start: 50%; --_clip-to-end: 50%; /* Active-tick clip math (see .tickmarks::after). The base formula uses --_with-tick-marks-container-size for the inset; we use --_edge-inset so the active ticks align with the visible track edges. */ --_active-track-max-clip: calc(100% - var(--_edge-inset) * 2); --_start-fraction-not-zero: min(var(--_start-fraction) * 1e9, 1); --_active-track-start-offset: calc(var(--_with-tick-marks-container-size) * var(--_start-fraction-not-zero)); --_active-track-start-clip: calc(var(--_active-track-start-offset) + var(--_active-track-max-clip) * var(--_start-fraction)); --_end-fraction-not-one: min((1 - var(--_end-fraction)) * 1e9, 1); --_active-track-end-offset: calc(var(--_with-tick-marks-container-size) * var(--_end-fraction-not-one)); --_active-track-end-clip: calc(var(--_active-track-end-offset) + var(--_active-track-max-clip) * (1 - var(--_end-fraction))); display: inline-flex; vertical-align: middle; min-inline-size: 200px; block-size: var(--_handle-size); position: relative; } :host([direction='vertical']) { writing-mode: vertical-lr; /* In vertical-lr: block-size = horizontal width, inline-size = vertical height. */ block-size: var(--_handle-size); inline-size: 200px; min-inline-size: unset; min-block-size: unset; } :host([disabled]) { opacity: var(--_disabled-active-track-opacity); } @media (prefers-reduced-motion) { .label { transition-duration: 0; } } /* ── Size presets (Figma-locked geometry) ──────────────────────────── */ :host([size='extra-small']) { --_track-height: var(--_extra-small-active-track-height); --_active-leading-shape: var(--_extra-small-active-track-leading-shape); --_active-trailing-shape: var(--_extra-small-active-track-trailing-shape); --_inactive-leading-shape: var(--_extra-small-inactive-track-leading-shape); --_inactive-trailing-shape: var(--_extra-small-inactive-track-trailing-shape); --_handle-size: var(--_extra-small-active-handle-height); --_handle-width: var(--_extra-small-handle-width); } :host([size='small']) { --_track-height: var(--_small-active-track-height); --_active-leading-shape: var(--_small-active-track-leading-shape); --_active-trailing-shape: var(--_small-active-track-trailing-shape); --_inactive-leading-shape: var(--_small-inactive-track-leading-shape); --_inactive-trailing-shape: var(--_small-inactive-track-trailing-shape); --_handle-size: var(--_small-active-handle-height); --_handle-width: var(--_small-handle-width); } :host([size='medium']) { --_track-height: var(--_medium-active-track-height); --_active-leading-shape: var(--_medium-active-track-leading-shape); --_active-trailing-shape: var(--_medium-active-track-trailing-shape); --_inactive-leading-shape: var(--_medium-inactive-track-leading-shape); --_inactive-trailing-shape: var(--_medium-inactive-track-trailing-shape); --_handle-size: var(--_medium-active-handle-height); --_handle-width: var(--_medium-handle-width); } :host([size='large']) { --_track-height: var(--_large-active-track-height); --_active-leading-shape: var(--_large-active-track-leading-shape); --_active-trailing-shape: var(--_large-active-track-trailing-shape); --_inactive-leading-shape: var(--_large-inactive-track-leading-shape); --_inactive-trailing-shape: var(--_large-inactive-track-trailing-shape); --_handle-size: var(--_large-active-handle-height); --_handle-width: var(--_large-handle-width); } :host([size='extra-large']) { --_track-height: var(--_extra-large-active-track-height); --_active-leading-shape: var(--_extra-large-active-track-leading-shape); --_active-trailing-shape: var(--_extra-large-active-track-trailing-shape); --_inactive-leading-shape: var(--_extra-large-inactive-track-leading-shape); --_inactive-trailing-shape: var(--_extra-large-inactive-track-trailing-shape); --_handle-size: var(--_extra-large-active-handle-height); --_handle-width: var(--_extra-large-handle-width); } /* ── Container (block layout) ───────────────────────────────────────── Absolute-positioned tracks and handles; no flex gap. The visible gap between the handle and surrounding tracks is set by the inline styles (and reads var(--_thumb-track-gap)). Cursor-to- value mapping stays linear because the native input spans the full container. For ranged mode the .ranged input.start/end clip-path rules consume --_clip-to-start/--_clip-to-end which are published from the inline style. */ .container { flex: 1; display: block; inline-size: 100%; block-size: 100%; pointer-events: none; touch-action: none; user-select: none; position: relative; } /* ── Track segments (active + inactive) ─────────────────────────────── */ .track { position: absolute; /* Cross-axis centering on the BLOCK axis. In horizontal mode (block axis = vertical) this centers the track vertically; in vertical-lr (block axis = horizontal) it centers the track horizontally. The track's block-size matches its track-height, so this is exact centering. */ inset-block-start: calc(50% - var(--_track-height) / 2); block-size: var(--_track-height); background: var(--_enabled-inactive-track-color); overflow: clip; /* Main-axis edge-inset fallback. The TS render code always overrides these via inline styles, but the defaults keep the track from bleeding past the visible track range before the first paint. */ inset-inline-start: var(--_edge-inset); inset-inline-end: var(--_edge-inset); } .track[data-active='true'] { background: var(--_enabled-active-track-color); } /* Range middle track — fills the active selection between the two handles. The handle body sits over each end, so both caps are rounded (CornerFull / --_active-leading-shape) — no inner- corner shaping is needed since the handle hides the very end. */ .track.track-middle { border-start-start-radius: var(--_active-leading-shape); border-end-start-radius: var(--_active-leading-shape); border-start-end-radius: var(--_active-leading-shape); border-end-end-radius: var(--_active-leading-shape); } /* All four corners of every track segment are the rounded cap value. The active track uses --_active-leading-shape (the rounded-cap token); the inactive uses --_inactive-trailing-shape (also rounded-cap, equal to leading per size). The handle body hides the very end of each track, so we don't need an inner-corner shaping. Writing-mode handles the axis swap. */ .track[data-position='start'][data-active='true'], .track[data-position='end'][data-active='true'] { border-start-start-radius: var(--_active-leading-shape); border-end-start-radius: var(--_active-leading-shape); border-start-end-radius: var(--_active-leading-shape); border-end-end-radius: var(--_active-leading-shape); } .track[data-position='start'][data-active='false'], .track[data-position='end'][data-active='false'] { border-start-start-radius: var(--_inactive-trailing-shape); border-end-start-radius: var(--_inactive-trailing-shape); border-start-end-radius: var(--_inactive-trailing-shape); border-end-end-radius: var(--_inactive-trailing-shape); } :host([disabled]) .track[data-active='true'] { background: var(--_disabled-active-track-color); } :host([disabled]) .track[data-active='false'] { background: var(--_disabled-inactive-track-color); opacity: calc(var(--_disabled-inactive-track-opacity) / var(--_disabled-active-track-opacity)); } /* ── Stop indicator (4px dot at the OUTER end of each inactive segment) ── The dot's CENTER sits on the visible track's outer edge, which is edge-inset away from the slider edge. The dot's CENTER also lines up with the edge tick dot (when ticks enabled) and the handle center at value=min/value=max. Inset is calc'd so the dot's left/right edge lands at the slider edge: the dot then spans [0, stopIndicatorSize] with center at stopIndicatorSize/2 which equals edge-inset when stop-indicator-size == handle-width. */ .stop-indicator { position: absolute; inline-size: var(--_stop-indicator-size); block-size: var(--_stop-indicator-size); border-radius: 50%; background: var(--_enabled-active-stop-indicator-color); /* Cross-axis centering on the BLOCK axis (horizontal for vertical mode, vertical for horizontal mode). */ inset-block-start: calc(50% - var(--_stop-indicator-size) / 2); /* Default for data-position='end' (right half / bottom half): dot pinned to the slider's right edge; center at edge-inset. */ inset-inline-end: calc(var(--_edge-inset) - var(--_stop-indicator-size) / 2); } /* Horizontal: flip dot to inline-start when the segment sits on the LEFT half of the slider. */ .track[data-position='start'] .stop-indicator { inset-inline-end: auto; inset-inline-start: calc(var(--_edge-inset) - var(--_stop-indicator-size) / 2); } :host([disabled]) .stop-indicator { background: var(--_disabled-active-stop-indicator-color); } /* Vertical: writing-mode: vertical-lr → inline axis is vertical, block axis is horizontal. The dot is centered horizontally (block-center) and pinned to the outer vertical edge. For data-position='start' (TOP half): dot's top edge at the slider top; for data-position='end' (BOTTOM half): dot's bottom edge at the slider bottom. */ :host([direction='vertical']) .track[data-position='start'] .stop-indicator { /* In vertical mode, block axis is horizontal (cross-axis). Center the dot horizontally on the cross-axis. The inline-axis position (top/bottom) is the edge. */ inset-block-start: calc(50% - var(--_stop-indicator-size) / 2); inset-block-end: auto; inset-inline-start: calc(var(--_edge-inset) - var(--_stop-indicator-size) / 2); inset-inline-end: auto; } :host([direction='vertical']) .track[data-position='end'] .stop-indicator { inset-block-start: calc(50% - var(--_stop-indicator-size) / 2); inset-block-end: auto; inset-inline-start: auto; inset-inline-end: calc(var(--_edge-inset) - var(--_stop-indicator-size) / 2); } /* ── Center dot (range slider center reference) ────────────────────── */ .center-dot { position: absolute; inset-inline-start: 50%; inset-block-start: 50%; inline-size: var(--_center-dot-size); block-size: var(--_center-dot-size); border-radius: 50%; background: var(--_enabled-active-stop-indicator-color); transform: translate(-50%, -50%); z-index: 1; pointer-events: none; } /* ── Centered-mode center stop indicator (rendered when value=0) ────── A 4px dot at the slider's geometric center, used as the visual marker when the centered slider's value is exactly at the midpoint. Same shape and color as the corner stop indicators. */ .center-stop { position: absolute; inset-inline-start: 50%; inset-block-start: 50%; inline-size: var(--_stop-indicator-size); block-size: var(--_stop-indicator-size); border-radius: 50%; background: var(--_enabled-active-stop-indicator-color); transform: translate(-50%, -50%); z-index: 1; pointer-events: none; } /* ── Handle (visual thumb) ───────────────────────────────────────────── */ .handle { position: absolute; /* Cross-axis centering on the BLOCK axis. In horizontal mode (block = vertical) this centers the handle vertically; in vertical-lr (block = horizontal) it centers horizontally. */ inset-block-start: calc(50% - var(--_handle-size) / 2); inline-size: var(--_handle-width); block-size: var(--_handle-size); background: var(--_enabled-handle-color); border-radius: 2px; z-index: 2; } :host([disabled]) .handle { background: var(--_disabled-handle-color); opacity: var(--_disabled-handle-opacity); } /* Vertical: handle is transposed (wide + 4px tall). In writing-mode: vertical-lr the inline axis is vertical, block is horizontal — so the wide dimension is block-size and the short dimension is inline-size. */ :host([direction='vertical']) .handle { block-size: var(--_handle-size); inline-size: var(--_handle-width); } /* ── Handle nub (rendered inside .handle for state layers / focus) ───── Base slider's .handleNub holds the elevation wrapper; in the expressive variant we don't use elevation, but the nub still provides a hit-test surface for the ripple / focus-ring. Make it the full size of the handle so the state layers cover it. */ .handleNub { inline-size: 100%; block-size: 100%; border-radius: inherit; pointer-events: none; } /* Range handles inherit positioning from the .handle rule above — cross-axis centering (inset-block-start: calc(50% - handle-size / 2)), sizes, and colors are all shared. The inline style set by the render code drives per-handle placement along the main axis. */ /* ── Tick marks (per-step dots) ─────────────────────────────────────── Two radial-gradients on ::before (inactive) and ::after (active), tiled across the visible-track width (inset by edge-inset on each side). The active variant is clipped by --_active-track-start-clip and --_active-track-end-clip (published from .container) so dots in the active region render in the active color. Per the base slider's pattern: the dot is placed at the LEFT edge of each background tile so the dots land on the slider's value positions (0%, 10%, ..., 100% for step=10 over min=0,max=100). Vertical mode: the radial-gradient tile pattern tiles along the inline axis (vertical in writing-mode: vertical-lr) — switch background-repeat from repeat-x to repeat-y under the vertical host. */ .tickmarks { position: absolute; inset: 0; pointer-events: none; /* The tick container is inset by --_edge-inset on the main axis so the dots line up with the visible track edges. */ padding-inline-start: var(--_edge-inset); padding-inline-end: var(--_edge-inset); inset-inline-start: var(--_edge-inset); inset-inline-end: var(--_edge-inset); block-size: 100%; } .tickmarks::before, .tickmarks::after { content: ''; position: absolute; inset: 0; background-size: calc((100% - var(--_with-tick-marks-container-size) * 2) / var(--_tick-count)) 100%; background-repeat: repeat-x; } /* Inactive ticks — dots in the inactive color tiled across the visible track. The radial-gradient places the dot at the LEFT edge of each tile, and the background is offset so the FIRST dot lands at the left edge of the visible track. */ .tickmarks::before { background-image: radial-gradient( circle at var(--_with-tick-marks-container-size) center, var(--_enabled-with-tick-marks-inactive-container-color) 0, var(--_enabled-with-tick-marks-inactive-container-color) calc(var(--_with-tick-marks-container-size) / 2), transparent calc(var(--_with-tick-marks-container-size) / 2) ); } /* Active ticks — same gradient but in the active color, clipped by the active region (between valueStart and valueEnd). */ .tickmarks::after { background-image: radial-gradient( circle at var(--_with-tick-marks-container-size) center, var(--_enabled-with-tick-marks-active-container-color) 0, var(--_enabled-with-tick-marks-active-container-color) calc(var(--_with-tick-marks-container-size) / 2), transparent calc(var(--_with-tick-marks-container-size) / 2) ); clip-path: inset(0 var(--_active-track-end-clip) 0 var(--_active-track-start-clip)); } /* RTL: swap the clip sides so the active-tick region flips with the value direction. */ .tickmarks:dir(rtl)::after { clip-path: inset(0 var(--_active-track-start-clip) 0 var(--_active-track-end-clip)); } /* Vertical: switch repeat axis so dots tile along the main axis (vertical in writing-mode: vertical-lr). */ :host([direction='vertical']) .tickmarks::before, :host([direction='vertical']) .tickmarks::after { background-size: 100% calc((100% - var(--_with-tick-marks-container-size) * 2) / var(--_tick-count)); background-repeat: repeat-y; background-image: radial-gradient( circle at center var(--_with-tick-marks-container-size), var(--_enabled-with-tick-marks-inactive-container-color) 0, var(--_enabled-with-tick-marks-inactive-container-color) calc(var(--_with-tick-marks-container-size) / 2), transparent calc(var(--_with-tick-marks-container-size) / 2) ); } :host([direction='vertical']) .tickmarks::after { background-image: radial-gradient( circle at center var(--_with-tick-marks-container-size), var(--_enabled-with-tick-marks-active-container-color) 0, var(--_enabled-with-tick-marks-active-container-color) calc(var(--_with-tick-marks-container-size) / 2), transparent calc(var(--_with-tick-marks-container-size) / 2) ); clip-path: inset(var(--_active-track-start-clip) 0 var(--_active-track-end-clip) 0); } /* ── Value indicator (label pill) ───────────────────────────────────── 48×44 pill (MD3E spec) with 16px horizontal and 12px vertical padding, Full shape, InverseSurface color, InverseOnSurface text, label-large typography, and a 6px gap to the slider. Position is relative to the .handle element (its parent). In horizontal mode the label sits ABOVE the handle, centered horizontally. In vertical mode it sits to the RIGHT of the handle, centered vertically. The transform folds in the centering offset so the scale animation pivots on the same point as the placement. */ .label { position: absolute; box-sizing: border-box; display: flex; padding-inline: var(--_label-container-padding-inline); padding-block: var(--_label-container-padding-block); place-content: center; place-items: center; border-radius: ${t(o.Full.ToCSSVariable())}; color: var(--_enabled-label-text-color); font-family: var(--_label-text-font); font-size: var(--_label-text-size); line-height: var(--_label-text-line-height); font-weight: var(--_label-text-weight); letter-spacing: var(--_label-text-tracking); /* 6px gap above the handle's bottom edge in horizontal mode. */ inset-block-end: calc(100% + var(--_label-bottom-space)); inset-inline-start: 50%; min-inline-size: var(--_label-container-width); min-block-size: var(--_label-container-height); background: var(--_enabled-label-container-color); transform-origin: center bottom; transform: translate(-50%, 0) scale(0); transition-property: transform; transition-duration: ${u}; transition-timing-function: ${d}; z-index: 3; pointer-events: none; white-space: nowrap; } /* Vertical mode: in writing-mode: vertical-lr the inline axis is vertical and the block axis is horizontal. The label sits to the RIGHT of the handle (block axis = horizontal) and is vertically centered (inline axis = vertical). The translateY(-50%) folds the vertical centering into the transform so the scale animation pivots on the same point as the placement. */ :host([direction='vertical']) .label { inset-block-end: auto; inset-block-start: calc(100% + var(--_label-bottom-space)); inset-inline-start: 50%; transform-origin: center left; transform: translate(0, -50%) scale(0); } :host(:focus-within) .label, :where(:has(input:active)) .label, .handle.hover .label { transform: translate(-50%, 0) scale(1); } :host([direction='vertical']:focus-within) .label, :host([direction='vertical']) .handle.hover .label { transform: translate(0, -50%) scale(1); } /* ── Native input (interaction target) ──────────────────────────────── */ input[type='range'] { opacity: 0; -webkit-tap-highlight-color: transparent; position: absolute; box-sizing: border-box; inset: 0; margin: 0; background: transparent; cursor: pointer; pointer-events: auto; appearance: none; } input[type='range']:focus { outline: none; } /* Range clip-paths: - isVisualReversed = false: - horizontal default: start on left, end on right - vertical reversed: start on top, end on bottom - isVisualReversed = true: - horizontal reversed: start on right, end on left - vertical default: start on bottom, end on top */ .ranged input.start { clip-path: inset(0 var(--_clip-to-end) 0 0); } .ranged input.end { clip-path: inset(0 0 0 var(--_clip-to-start)); } :host(:not([direction='vertical'])[reversed]) .ranged input.start { clip-path: inset(0 0 0 var(--_clip-to-start)); } :host(:not([direction='vertical'])[reversed]) .ranged input.end { clip-path: inset(0 var(--_clip-to-end) 0 0); } :host([direction='vertical']:not([reversed])) .ranged input.start { clip-path: inset(var(--_clip-to-start) 0 0 0); } :host([direction='vertical']:not([reversed])) .ranged input.end { clip-path: inset(0 0 var(--_clip-to-end) 0); } :host([direction='vertical'][reversed]) .ranged input.start { clip-path: inset(0 0 var(--_clip-to-end) 0); } :host([direction='vertical'][reversed]) .ranged input.end { clip-path: inset(var(--_clip-to-start) 0 0 0); } /* Vertical: native input needs vertical orientation. */ :host([direction='vertical']) input[type='range'] { writing-mode: vertical-lr; inline-size: 100%; block-size: 100%; } :host(:not([direction='vertical'])[reversed]) input[type='range'], :host([direction='vertical']:not([reversed])) input[type='range'] { direction: rtl; } :host([direction='vertical'][reversed]) input[type='range'] { direction: ltr; } ::-webkit-slider-runnable-track { -webkit-appearance: none; } ::-moz-range-track { appearance: none; } ::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; block-size: var(--_handle-size); inline-size: var(--_handle-width); opacity: 0; z-index: 2; } ::-moz-range-thumb { appearance: none; block-size: var(--_handle-size); inline-size: var(--_handle-size); transform: scaleX(0); opacity: 0; z-index: 2; } /* ── Active overlay (centered type only) ───────────────────────────── The overlay's block-size matches the track height (not the container) so it sits flush with the inactive tracks on the cross axis. It's vertically centered within the container. The center-side edge sits against --_center-inset (not against the handle), so both ends of the overlay are rounded (CornerFull). z-index: 1 keeps it above the tracks in vertical mode where DOM order may place tracks above the overlay. */ .active-overlay { position: absolute; background: var(--_enabled-active-track-color); block-size: var(--_track-height); inset-block-start: calc((100% - var(--_track-height)) / 2); z-index: 1; border-start-start-radius: var(--_active-leading-shape); border-end-start-radius: var(--_active-leading-shape); border-start-end-radius: var(--_active-leading-shape); border-end-end-radius: var(--_active-leading-shape); } :host([disabled]) .active-overlay { background: var(--_disabled-active-track-color); } `];export{f as ExpressiveSliderStyles}; //# sourceMappingURL=expressive-slider.style.js.map