UNPKG

media-chrome

Version:

Custom elements (web components) for making audio and video player controls that look great in your website or app.

635 lines (554 loc) • 21 kB
import { MediaStateReceiverAttributes } from './constants.js'; import { globalThis, document } from './utils/server-safe-globals.js'; import { getOrInsertCSSRule, getPointProgressOnLine, } from './utils/element-utils.js'; import { observeResize, unobserveResize } from './utils/resize-observer.js'; const template: HTMLTemplateElement = document.createElement('template'); template.innerHTML = /*html*/ ` <style> :host { --_focus-box-shadow: var(--media-focus-box-shadow, inset 0 0 0 2px rgb(27 127 204 / .9)); --_media-range-padding: var(--media-range-padding, var(--media-control-padding, 10px)); box-shadow: var(--_focus-visible-box-shadow, none); background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7))); height: calc(var(--media-control-height, 24px) + 2 * var(--_media-range-padding)); display: inline-flex; align-items: center; ${ /* Don't horizontal align w/ justify-content! #container can go negative on the x-axis w/ small width. */ '' } vertical-align: middle; box-sizing: border-box; position: relative; width: 100px; transition: background .15s linear; cursor: var(--media-cursor, pointer); pointer-events: auto; touch-action: none; ${/* Prevent scrolling when dragging on mobile. */ ''} } ${/* Reset before `outline` on track could be set by a CSS var */ ''} input[type=range]:focus { outline: 0; } input[type=range]:focus::-webkit-slider-runnable-track { outline: 0; } :host(:hover) { background: var(--media-control-hover-background, rgb(50 50 70 / .7)); } #leftgap { padding-left: var(--media-range-padding-left, var(--_media-range-padding)); } #rightgap { padding-right: var(--media-range-padding-right, var(--_media-range-padding)); } #startpoint, #endpoint { position: absolute; } #endpoint { right: 0; } #container { ${ /* Not using the CSS `padding` prop makes it easier for slide open volume ranges so the width can be zero. */ '' } width: var(--media-range-track-width, 100%); transform: translate(var(--media-range-track-translate-x, 0px), var(--media-range-track-translate-y, 0px)); position: relative; height: 100%; display: flex; align-items: center; min-width: 40px; } #range { ${/* The input range acts as a hover and hit zone for input events. */ ''} display: var(--media-time-range-hover-display, block); bottom: var(--media-time-range-hover-bottom, -7px); height: var(--media-time-range-hover-height, max(100% + 7px, 25px)); width: 100%; position: absolute; cursor: var(--media-cursor, pointer); -webkit-appearance: none; ${ /* Hides the slider so that custom slider can be made */ '' } -webkit-tap-highlight-color: transparent; background: transparent; ${/* Otherwise white in Chrome */ ''} margin: 0; z-index: 1; } @media (hover: hover) { #range { bottom: var(--media-time-range-hover-bottom, -5px); height: var(--media-time-range-hover-height, max(100% + 5px, 20px)); } } ${/* Special styling for WebKit/Blink */ ''} ${ /* Make thumb width/height small so it has no effect on range click position. */ '' } #range::-webkit-slider-thumb { -webkit-appearance: none; background: transparent; width: .1px; height: .1px; } ${/* The thumb is not positioned relative to the track in Firefox */ ''} #range::-moz-range-thumb { background: transparent; border: transparent; width: .1px; height: .1px; } #appearance { height: var(--media-range-track-height, 4px); display: flex; flex-direction: column; justify-content: center; width: 100%; position: absolute; ${/* Required for Safari to stop glitching track height on hover */ ''} will-change: transform; } #track { background: var(--media-range-track-background, rgb(255 255 255 / .2)); border-radius: var(--media-range-track-border-radius, 1px); border: var(--media-range-track-border, none); outline: var(--media-range-track-outline); outline-offset: var(--media-range-track-outline-offset); backdrop-filter: var(--media-range-track-backdrop-filter); -webkit-backdrop-filter: var(--media-range-track-backdrop-filter); box-shadow: var(--media-range-track-box-shadow, none); position: absolute; width: 100%; height: 100%; overflow: hidden; } #progress, #pointer { position: absolute; height: 100%; will-change: width; } #progress { background: var(--media-range-bar-color, var(--media-primary-color, rgb(238 238 238))); transition: var(--media-range-track-transition); } #pointer { background: var(--media-range-track-pointer-background); border-right: var(--media-range-track-pointer-border-right); transition: visibility .25s, opacity .25s; visibility: hidden; opacity: 0; } @media (hover: hover) { :host(:hover) #pointer { transition: visibility .5s, opacity .5s; visibility: visible; opacity: 1; } } #thumb, ::slotted([slot=thumb]) { width: var(--media-range-thumb-width, 10px); height: var(--media-range-thumb-height, 10px); transition: var(--media-range-thumb-transition); transform: var(--media-range-thumb-transform, none); opacity: var(--media-range-thumb-opacity, 1); translate: -50%; position: absolute; left: 0; cursor: var(--media-cursor, pointer); } #thumb { border-radius: var(--media-range-thumb-border-radius, 10px); background: var(--media-range-thumb-background, var(--media-primary-color, rgb(238 238 238))); box-shadow: var(--media-range-thumb-box-shadow, 1px 1px 1px transparent); border: var(--media-range-thumb-border, none); } :host([disabled]) #thumb { background-color: #777; } .segments #appearance { height: var(--media-range-segment-hover-height, 7px); } #track { clip-path: url(#segments-clipping); } #segments { --segments-gap: var(--media-range-segments-gap, 2px); position: absolute; width: 100%; height: 100%; } #segments-clipping { transform: translateX(calc(var(--segments-gap) / 2)); } #segments-clipping:empty { display: none; } #segments-clipping rect { height: var(--media-range-track-height, 4px); y: calc((var(--media-range-segment-hover-height, 7px) - var(--media-range-track-height, 4px)) / 2); transition: var(--media-range-segment-transition, transform .1s ease-in-out); transform: var(--media-range-segment-transform, scaleY(1)); transform-origin: center; } </style> <div id="leftgap"></div> <div id="container"> <div id="startpoint"></div> <div id="endpoint"></div> <div id="appearance"> <div id="track" part="track"> <div id="pointer"></div> <div id="progress" part="progress"></div> </div> <slot name="thumb"> <div id="thumb" part="thumb"></div> </slot> <svg id="segments"><clipPath id="segments-clipping"></clipPath></svg> </div> <input id="range" type="range" min="0" max="1" step="any" value="0"> </div> <div id="rightgap"></div> `; /** * @extends {HTMLElement} * * @slot thumb - The thumb element to use for the range. * * @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable. * @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within). * * @csspart track - The runnable track of the range. * @csspart progress - The progress part of the track. * @csspart thumb - The thumb of the range. * * @cssproperty --media-primary-color - Default color of range bar. * @cssproperty --media-secondary-color - Default color of range background. * * @cssproperty [--media-control-display = inline-block] - `display` property of control. * @cssproperty --media-control-padding - `padding` of control. * @cssproperty --media-control-background - `background` of control. * @cssproperty --media-control-hover-background - `background` of control hover state. * @cssproperty --media-control-height - `height` of control. * * @cssproperty --media-range-padding - `padding` of range. * @cssproperty --media-range-padding-left - `padding-left` of range. * @cssproperty --media-range-padding-right - `padding-right` of range. * * @cssproperty --media-range-thumb-width - `width` of range thumb. * @cssproperty --media-range-thumb-height - `height` of range thumb. * @cssproperty --media-range-thumb-border - `border` of range thumb. * @cssproperty --media-range-thumb-border-radius - `border-radius` of range thumb. * @cssproperty --media-range-thumb-background - `background` of range thumb. * @cssproperty --media-range-thumb-box-shadow - `box-shadow` of range thumb. * @cssproperty --media-range-thumb-transition - `transition` of range thumb. * @cssproperty --media-range-thumb-transform - `transform` of range thumb. * @cssproperty --media-range-thumb-opacity - `opacity` of range thumb. * * @cssproperty [--media-range-bar-color = var(--media-primary-color, rgb(238 238 238))] - `background` of range progress. * @cssproperty --media-range-track-background - `background` of range track background. * @cssproperty --media-range-track-backdrop-filter - `backdrop-filter` of range track. * @cssproperty --media-range-track-width - `width` of range track. * @cssproperty --media-range-track-height - `height` of range track. * @cssproperty --media-range-track-border - `border` of range track. * @cssproperty --media-range-track-outline - `outline` of range track. * @cssproperty --media-range-track-outline-offset - `outline-offset` of range track. * @cssproperty --media-range-track-border-radius - `border-radius` of range track. * @cssproperty --media-range-track-box-shadow - `box-shadow` of range track. * @cssproperty --media-range-track-transition - `transition` of range track. * @cssproperty --media-range-track-translate-x - `translate` x-coordinate of range track. * @cssproperty --media-range-track-translate-y - `translate` y-coordinate of range track. * * @cssproperty --media-time-range-hover-display - `display` of range hover zone. * @cssproperty --media-time-range-hover-bottom - `bottom` of range hover zone. * @cssproperty --media-time-range-hover-height - `height` of range hover zone. * * @cssproperty --media-range-track-pointer-background - `background` of range track pointer. * @cssproperty --media-range-track-pointer-border-right - `border-right` of range track pointer. */ class MediaChromeRange extends globalThis.HTMLElement { #mediaController; #isInputTarget; #startpoint; #endpoint; #cssRules: Record<string, CSSStyleRule> = {}; #segments = []; static get observedAttributes(): string[] { return [ 'disabled', 'aria-disabled', MediaStateReceiverAttributes.MEDIA_CONTROLLER, ]; } container: HTMLElement; range: HTMLInputElement; appearance: HTMLElement; constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(template.content.cloneNode(true)); } this.container = this.shadowRoot.querySelector('#container'); this.#startpoint = this.shadowRoot.querySelector('#startpoint'); this.#endpoint = this.shadowRoot.querySelector('#endpoint'); /** @type {Omit<HTMLInputElement, "value" | "min" | "max"> & * {value: number, min: number, max: number}} */ this.range = this.shadowRoot.querySelector('#range'); this.appearance = this.shadowRoot.querySelector('#appearance'); } #onFocusIn = (): void => { if (this.range.matches(':focus-visible')) { const { style } = getOrInsertCSSRule(this.shadowRoot, ':host'); style.setProperty( '--_focus-visible-box-shadow', 'var(--_focus-box-shadow)' ); } }; #onFocusOut = (): void => { const { style } = getOrInsertCSSRule(this.shadowRoot, ':host'); style.removeProperty('--_focus-visible-box-shadow'); }; attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ): void { if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) { if (oldValue) { this.#mediaController?.unassociateElement?.(this); this.#mediaController = null; } if (newValue && this.isConnected) { // @ts-ignore this.#mediaController = this.getRootNode()?.getElementById(newValue); this.#mediaController?.associateElement?.(this); } } else if ( attrName === 'disabled' || (attrName === 'aria-disabled' && oldValue !== newValue) ) { if (newValue == null) { this.range.removeAttribute(attrName); this.#enableUserEvents(); } else { this.range.setAttribute(attrName, newValue); this.#disableUserEvents(); } } } connectedCallback(): void { const { style } = getOrInsertCSSRule(this.shadowRoot, ':host'); style.setProperty( 'display', `var(--media-control-display, var(--${this.localName}-display, inline-flex))` ); this.#cssRules.pointer = getOrInsertCSSRule(this.shadowRoot, '#pointer'); this.#cssRules.progress = getOrInsertCSSRule(this.shadowRoot, '#progress'); this.#cssRules.thumb = getOrInsertCSSRule( this.shadowRoot, '#thumb, ::slotted([slot="thumb"])' ); this.#cssRules.activeSegment = getOrInsertCSSRule( this.shadowRoot, '#segments-clipping rect:nth-child(0)' ); const mediaControllerId = this.getAttribute( MediaStateReceiverAttributes.MEDIA_CONTROLLER ); if (mediaControllerId) { // @ts-ignore this.#mediaController = (this.getRootNode() as Document)?.getElementById( mediaControllerId ); this.#mediaController?.associateElement?.(this); } this.updateBar(); this.shadowRoot.addEventListener('focusin', this.#onFocusIn); this.shadowRoot.addEventListener('focusout', this.#onFocusOut); this.#enableUserEvents(); observeResize(this.container, this.#updateComputedStyles); } disconnectedCallback(): void { this.#disableUserEvents(); // Use cached mediaController, getRootNode() doesn't work if disconnected. this.#mediaController?.unassociateElement?.(this); this.#mediaController = null; this.shadowRoot.removeEventListener('focusin', this.#onFocusIn); this.shadowRoot.removeEventListener('focusout', this.#onFocusOut); unobserveResize(this.container, this.#updateComputedStyles); } #updateComputedStyles = () => { // This fixes a Chrome bug where it doesn't refresh the clip-path on content resize. const clipping = this.shadowRoot.querySelector('#segments-clipping'); if (clipping) clipping.parentNode.append(clipping); }; updatePointerBar(evt) { this.#cssRules.pointer?.style.setProperty( 'width', `${this.getPointerRatio(evt) * 100}%` ); } updateBar() { const rangePercent = this.range.valueAsNumber * 100; this.#cssRules.progress?.style.setProperty('width', `${rangePercent}%`); this.#cssRules.thumb?.style.setProperty('left', `${rangePercent}%`); } updateSegments(segments) { const clipping = this.shadowRoot.querySelector('#segments-clipping'); clipping.textContent = ''; this.container.classList.toggle('segments', !!segments?.length); if (!segments?.length) return; const normalized = [ ...new Set([ +this.range.min, ...segments.flatMap((s) => [s.start, s.end]), +this.range.max, ]), ]; this.#segments = [...normalized]; const lastMarker = normalized.pop(); for (const [i, marker] of normalized.entries()) { const [isFirst, isLast] = [i === 0, i === normalized.length - 1]; const x = isFirst ? 'calc(var(--segments-gap) / -1)' : `${marker * 100}%`; const x2 = isLast ? lastMarker : normalized[i + 1]; const width = `calc(${(x2 - marker) * 100}%${ isFirst || isLast ? '' : ` - var(--segments-gap)` })`; const segmentEl = document.createElementNS( 'http://www.w3.org/2000/svg', 'rect' ); const cssRule = getOrInsertCSSRule( this.shadowRoot, `#segments-clipping rect:nth-child(${i + 1})` ); cssRule.style.setProperty('x', x); cssRule.style.setProperty('width', width); clipping.append(segmentEl); } } #updateActiveSegment(evt) { const rule = this.#cssRules.activeSegment; if (!rule) return; const pointerRatio = this.getPointerRatio(evt); const segmentIndex = this.#segments.findIndex((start, i, arr) => { const end = arr[i + 1]; return end != null && pointerRatio >= start && pointerRatio <= end; }); const selectorText = `#segments-clipping rect:nth-child(${ segmentIndex + 1 })`; if (rule.selectorText != selectorText || !rule.style.transform) { rule.selectorText = selectorText; rule.style.setProperty( 'transform', 'var(--media-range-segment-hover-transform, scaleY(2))' ); } } getPointerRatio(evt) { return getPointProgressOnLine( evt.clientX, evt.clientY, this.#startpoint.getBoundingClientRect(), this.#endpoint.getBoundingClientRect() ); } get dragging() { return this.hasAttribute('dragging'); } #enableUserEvents() { if (this.hasAttribute('disabled')) return; this.addEventListener('input', this); this.addEventListener('pointerdown', this); this.addEventListener('pointerenter', this); } #disableUserEvents() { this.removeEventListener('input', this); this.removeEventListener('pointerdown', this); this.removeEventListener('pointerenter', this); globalThis.window?.removeEventListener('pointerup', this); globalThis.window?.removeEventListener('pointermove', this); } handleEvent(evt) { switch (evt.type) { case 'pointermove': this.#handlePointerMove(evt); break; case 'input': this.updateBar(); break; case 'pointerenter': this.#handlePointerEnter(evt); break; case 'pointerdown': this.#handlePointerDown(evt); break; case 'pointerup': this.#handlePointerUp(); break; case 'pointerleave': this.#handlePointerLeave(); break; } } #handlePointerDown(evt) { // Events outside the range element are handled manually below. this.#isInputTarget = evt.composedPath().includes(this.range); globalThis.window?.addEventListener('pointerup', this); } #handlePointerEnter(evt) { // On mobile a pointerdown is not required to drag the range. if (evt.pointerType !== 'mouse') this.#handlePointerDown(evt); this.addEventListener('pointerleave', this); globalThis.window?.addEventListener('pointermove', this); } #handlePointerUp() { globalThis.window?.removeEventListener('pointerup', this); this.toggleAttribute('dragging', false); this.range.disabled = this.hasAttribute('disabled'); } #handlePointerLeave() { this.removeEventListener('pointerleave', this); globalThis.window?.removeEventListener('pointermove', this); this.toggleAttribute('dragging', false); this.range.disabled = this.hasAttribute('disabled'); this.#cssRules.activeSegment?.style.removeProperty('transform'); } #handlePointerMove(evt) { this.toggleAttribute( 'dragging', evt.buttons === 1 || evt.pointerType !== 'mouse' ); this.updatePointerBar(evt); this.#updateActiveSegment(evt); // If the native input target & events are used don't fire manual input events. if ( this.dragging && (evt.pointerType !== 'mouse' || !this.#isInputTarget) ) { // Disable native input events if manual events are fired. this.range.disabled = true; this.range.valueAsNumber = this.getPointerRatio(evt); this.range.dispatchEvent( new Event('input', { bubbles: true, composed: true }) ); } } get keysUsed() { return ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft']; } } if (!globalThis.customElements.get('media-chrome-range')) { globalThis.customElements.define('media-chrome-range', MediaChromeRange); } export { MediaChromeRange }; export default MediaChromeRange;