Last active
September 18, 2025 13:52
-
-
Save sorvell/db12f54bc5c9f73d01b993cac99f80e8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <script> | |
| console.clear(); | |
| </script> | |
| <!-- <script type="module" src="./range-group.js"></script> --> | |
| <script type="module" src="./range-group2.js"></script> | |
| <style> | |
| :root { | |
| font-family: system-ui; | |
| } | |
| h3 { margin: 0;} | |
| </style> | |
| </head> | |
| <body> | |
| <link rel="stylesheet" href="style.css"> | |
| <style> | |
| /* A demonstration of styling the web component using CSS Parts */ | |
| range-group { | |
| /* These are custom properties the component uses internally */ | |
| /*--track-height: 8px;*/ | |
| /*--thumb-size: 28px;*/ | |
| /*--thumb-bg: oklch(70% 0.28 260); !* Bright Indigo *!*/ | |
| /*--tick-color: oklch(60% 0.02 230);*/ | |
| /*--tick-label-color: oklch(70% 0.02 230);*/ | |
| /* General component styling */ | |
| width: 100%; | |
| } | |
| range-group { | |
| --transition: all 0.2s; | |
| &:state(active) { | |
| --transition: none; | |
| } | |
| &::part(segment), &::part(thumb) { | |
| transition: var(--transition); | |
| } | |
| } | |
| /* Styling the component parts from the outside */ | |
| range-group::part(track) { | |
| background-color: oklch(25% 0.02 230); | |
| border-radius: 9999px; | |
| } | |
| range-group::part(thumb) { | |
| border: 2px solid oklch(95% 0.01 230); | |
| border-radius: 50%; | |
| } | |
| </style> | |
| <div class="container-wrapper"> | |
| <div class="main-container"> | |
| <h3>range-group</h3> | |
| <div class="sections-container"> | |
| <section> | |
| <p class="description"> | |
| A common use case for selecting a minimum and maximum value. The | |
| segment between the handles is styled differently. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #price-range::part(segment-1) { | |
| background-color: oklch(35% 0.02 230); | |
| } | |
| #price-range::part(segment-2) { | |
| background-color: var(--color-purple); | |
| } | |
| #price-range::part(segment-3) { | |
| background-color: oklch(35% 0.02 230); | |
| } | |
| </style> | |
| <range-group id="price-range" min="0" max="1000"> | |
| <legend slot="legend" class="color-purple"> | |
| Dual-Handle Base Example | |
| </legend> | |
| <label for="price-min-input" class="visually-hidden" | |
| >Minimum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="price-min-input" | |
| name="price-min" | |
| value="250" | |
| /> | |
| <label for="price-max-input" class="visually-hidden" | |
| >Maximum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="price-max-input" | |
| name="price-max" | |
| value="750" | |
| /> | |
| </range-group> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Demonstrates two-way data binding. Drag the handles to update the | |
| input fields, or type in the fields to move the handles. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #interactive-price-range::part(segment-2) { | |
| background: var(--color-teal); | |
| } | |
| #interactive-price-range::part(thumb) { | |
| --thumb-bg: var(--color-teal); | |
| } | |
| </style> | |
| <range-group id="interactive-price-range" min="0" max="1000"> | |
| <legend slot="legend" class="color-teal"> | |
| Interactive Price Range | |
| </legend> | |
| <label for="interactive-price-min-input" class="visually-hidden" | |
| >Minimum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="interactive-price-min-input" | |
| name="price-min" | |
| value="250" | |
| /> | |
| <label for="interactive-price-max-input" class="visually-hidden" | |
| >Maximum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="interactive-price-max-input" | |
| name="price-max" | |
| value="750" | |
| /> | |
| </range-group> | |
| <div class="price-input-container"> | |
| <div class="price-input-group"> | |
| <label for="price-min-num-input">Min Price</label> | |
| <input | |
| type="number" | |
| id="price-min-num-input" | |
| min="0" | |
| max="1000" | |
| /> | |
| </div> | |
| <div class="price-input-group"> | |
| <label for="price-max-num-input">Max Price</label> | |
| <input | |
| type="number" | |
| id="price-max-num-input" | |
| min="0" | |
| max="1000" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| You could use stepbetween to prevent thumbs form colliding. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #stepbetween-example::part(segment-1) { | |
| background-color: oklch(0.554 0.104 233.66); | |
| } | |
| #stepbetween-example::part(segment-2) { | |
| background-color: oklch(0.849 0.288 142.427); | |
| } | |
| #stepbetween-example::part(segment-3) { | |
| background-color: oklch(0.554 0.104 233.66); | |
| } | |
| </style> | |
| <range-group | |
| id="stepbetween-example" | |
| min="0" | |
| max="100" | |
| stepbetween="10" | |
| > | |
| <legend slot="legend" class="color-pink"> | |
| Dual-Handle with 10 stepbetween | |
| </legend> | |
| <label for="price-min-input-stepbetween" class="visually-hidden" | |
| >Minimum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="price-min-input-stepbetween" | |
| name="price-min" | |
| value="50" | |
| /> | |
| <label for="price-max-input-stepbetween" class="visually-hidden" | |
| >Maximum Price</label | |
| > | |
| <input | |
| type="range" | |
| id="price-max-input-stepbetween" | |
| name="price-max" | |
| value="80" | |
| /> | |
| </range-group> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Demonstrates multiple handles and custom-colored segments for | |
| different temperature zones. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #temp-range::part(segment-1) { | |
| background-color: oklch(65% 0.25 260); | |
| } | |
| #temp-range::part(segment-2) { | |
| background-color: oklch(80% 0.25 180); | |
| } | |
| #temp-range::part(segment-3) { | |
| background-color: oklch(85% 0.25 90); | |
| } | |
| #temp-range::part(segment-4) { | |
| background-color: oklch(70% 0.28 25); | |
| } | |
| #temp-range::part(thumb-1) { | |
| --thumb-bg: oklch(65% 0.25 260); | |
| border: 2px solid oklch(98% 0.01 230); | |
| box-shadow: 0 0 12px 4px oklch(65% 0.25 260 / 0.75); | |
| } | |
| #temp-range::part(thumb-2) { | |
| --thumb-bg: oklch(85% 0.25 90); | |
| border: 2px solid oklch(98% 0.01 230); | |
| box-shadow: 0 0 12px 4px oklch(85% 0.25 90 / 0.75); | |
| } | |
| #temp-range::part(thumb-3) { | |
| --thumb-bg: oklch(70% 0.28 25); | |
| border: 2px solid oklch(98% 0.01 230); | |
| box-shadow: 0 0 12px 4px oklch(70% 0.28 25 / 0.75); | |
| } | |
| </style> | |
| <range-group id="temp-range" min="-100" max="100"> | |
| <legend slot="legend" class="color-indigo"> | |
| Multi-Handle Temperature Control | |
| </legend> | |
| <label for="temp-cold-input" class="visually-hidden" | |
| >Cold temperature bound</label | |
| > | |
| <input | |
| type="range" | |
| id="temp-cold-input" | |
| name="temp-cold" | |
| value="-50" | |
| /> | |
| <label for="temp-mild-input" class="visually-hidden" | |
| >Mild temperature bound</label | |
| > | |
| <input | |
| type="range" | |
| id="temp-mild-input" | |
| name="temp-mild" | |
| value="0" | |
| /> | |
| <label for="temp-hot-input" class="visually-hidden" | |
| >Hot temperature bound</label | |
| > | |
| <input | |
| type="range" | |
| id="temp-hot-input" | |
| name="temp-hot" | |
| value="50" | |
| /> | |
| </range-group> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Demonstrates using invisible handles with the CSS Anchoring API to | |
| position custom tooltips. The tooltips appear to be the draggable | |
| handles, and their values are updated via JavaScript. | |
| </p> | |
| <div class="card" style="position: relative"> | |
| <style> | |
| #floating-tooltip-range { | |
| --track-height: 12px; | |
| --thumb-size: 40px; | |
| margin-top: 2rem; | |
| margin-bottom: 2rem; | |
| } | |
| #floating-tooltip-range::part(track) { | |
| background-color: oklch(40% 0.02 230); | |
| } | |
| #floating-tooltip-range::part(segment-2) { | |
| background: var(--color-pink); | |
| } | |
| #floating-tooltip-range::part(thumb) { | |
| anchor-name: var(--anchor-name); | |
| background: transparent; | |
| border: none; | |
| box-shadow: none; | |
| width: 40px; | |
| height: 20px; | |
| padding: 0; | |
| border-radius: 9999px; | |
| } | |
| #floating-tooltip-range::part(thumb-1) { | |
| --thumb-transform: translate( | |
| -50%, | |
| calc(-100% - var(--track-height) / 2 - 5px) | |
| ); | |
| --anchor-name: --floating-thumb-1; | |
| transform: var(--thumb-transform); | |
| } | |
| #floating-tooltip-range::part(thumb-2) { | |
| --thumb-transform: translate( | |
| -50%, | |
| calc(0% + var(--track-height) / 2 + 5px) | |
| ); | |
| --anchor-name: --floating-thumb-2; | |
| transform: var(--thumb-transform); | |
| } | |
| /* Tooltip styling */ | |
| .floating-tooltip { | |
| position: absolute; | |
| position-anchor: var(--anchor-name); | |
| pointer-events: none; | |
| /* These styles create the tooltip shape */ | |
| border: none; | |
| border-radius: 9999px; | |
| background: white; | |
| box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), | |
| 0 2px 4px -1px rgb(0 0 0 / 0.1); | |
| width: 40px; | |
| height: 28px; | |
| color: oklch(10% 0.01 230); | |
| font-weight: 700; | |
| font-size: 0.875rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| translate: -50% -20%; | |
| z-index: 10; | |
| } | |
| #floating-tooltip-1 { | |
| --anchor-name: --floating-thumb-1; | |
| bottom: anchor(top); | |
| left: anchor(left); | |
| } | |
| #floating-tooltip-2 { | |
| --anchor-name: --floating-thumb-2; | |
| top: anchor(center); | |
| left: anchor(left); | |
| } | |
| </style> | |
| <range-group id="floating-tooltip-range" min="0" max="100"> | |
| <legend slot="legend" class="color-purple"> | |
| Floating Tooltip Handles | |
| </legend> | |
| <label for="floating-tooltip-input-1" class="visually-hidden" | |
| >Value Handle 1</label | |
| > | |
| <input | |
| type="range" | |
| id="floating-tooltip-input-1" | |
| name="floating-tooltip-1" | |
| value="40" | |
| /> | |
| <label for="floating-tooltip-input-2" class="visually-hidden" | |
| >Value Handle 2</label | |
| > | |
| <input | |
| type="range" | |
| id="floating-tooltip-input-2" | |
| name="floating-tooltip-2" | |
| value="65" | |
| /> | |
| </range-group> | |
| <div | |
| id="floating-tooltip-1" | |
| class="floating-tooltip" | |
| aria-hidden="true" | |
| > | |
| 40 | |
| </div> | |
| <div | |
| id="floating-tooltip-2" | |
| class="floating-tooltip" | |
| aria-hidden="true" | |
| > | |
| 65 | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Shows integration with a <code><datalist></code> for tick | |
| marks and labels, and enforces a minimum distance of 25 between | |
| handles via <code>stepbetween="25"</code>. | |
| </p> | |
| <div class="card"> | |
| <range-group | |
| min="0" | |
| max="100" | |
| list="rating-ticks" | |
| stepbetween="25" | |
| > | |
| <legend slot="legend" class="color-teal"> | |
| Datalist Integration & Step Between | |
| </legend> | |
| <label for="rating-min-input" class="visually-hidden" | |
| >Minimum rating</label | |
| > | |
| <input | |
| type="range" | |
| id="rating-min-input" | |
| name="rating-min" | |
| value="20" | |
| /> | |
| <label for="rating-max-input" class="visually-hidden" | |
| >Maximum rating</label | |
| > | |
| <input | |
| type="range" | |
| id="rating-max-input" | |
| name="rating-max" | |
| value="80" | |
| /> | |
| </range-group> | |
| <datalist id="rating-ticks"> | |
| <option value="0" label="0%"></option> | |
| <option value="25"></option> | |
| <option value="50" label="50%"></option> | |
| <option value="75"></option> | |
| <option value="100" label="100%"></option> | |
| </datalist> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| A complete visual transformation to create a budget allocation | |
| tool. The component's segments are styled as colored bars, and the | |
| thumbs as draggable separators. All labels and percentages are | |
| updated via JavaScript as well as an aria-polite to give feedback | |
| to the user about the current value of the slider.. | |
| </p> | |
| <div class="card" style="position: relative"> | |
| <style> | |
| #budget-range { | |
| --track-height: 50px; | |
| --thumb-size: 40px; | |
| --thumb-margin: calc(var(--track-height) - var(--thumb-size)); | |
| legend { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| label { | |
| font-size: 1rem; | |
| font-weight: normal; | |
| display: flex; | |
| align-items: center; | |
| } | |
| } | |
| } | |
| #budget-range::part(track) { | |
| border-radius: 25px; | |
| overflow: hidden; | |
| } | |
| #budget-range::part(thumb) { | |
| box-sizing: border-box; | |
| border: 4px solid var(--color-card-bg); | |
| background-color: white; | |
| background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --%3E%3Csvg width='800px' height='800px' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:none;stroke:%23000000;stroke-linecap:round;stroke-linejoin:bevel;stroke-width:1.5px;%7D%3C/style%3E%3C/defs%3E%3Cg id='ic-chevron-left-right'%3E%3Cpath class='cls-1' d='M15.3,16.78l4.11-4.11a1,1,0,0,0,0-1.41l-4-4'/%3E%3Cpath class='cls-1' d='M8.7,7.22,4.59,11.33a1,1,0,0,0,0,1.41l4,4'/%3E%3C/g%3E%3C/svg%3E"); | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| background-size: 60%; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), | |
| 0 2px 4px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| /* Set anchor-name on segments via a CSS variable, matching the working tooltip pattern */ | |
| #budget-range::part(segment) { | |
| anchor-name: var(--anchor-name); | |
| } | |
| #budget-range:has(input:checked) { | |
| &::part(thumb) { | |
| --extent: calc(100% - (var(--thumb-size) + var(--thumb-margin))); | |
| left: calc(var(--thumb-margin)/2 + var(--thumb-position) * var(--extent)); | |
| } | |
| &::part(segment) { | |
| --o: calc(var(--thumb-size) + var(--thumb-margin)); | |
| --extent: calc(100% - (var(--thumb-size) + var(--thumb-margin))); | |
| left: calc(var(--o)/2 + var(--segment-position) * var(--extent)); | |
| width: calc((var(--thumb-size) + var(--thumb-margin))/2 + var(--segment-size) * var(--extent)); | |
| } | |
| &::part(segment-1) { | |
| --o: 0px; | |
| } | |
| } | |
| #budget-range::part(segment-1) { | |
| background-color: #e53935; | |
| --anchor-name: --budget-segment-1; | |
| } | |
| #budget-range::part(segment-2) { | |
| background-color: #8e24aa; | |
| --anchor-name: --budget-segment-2; | |
| } | |
| #budget-range::part(segment-3) { | |
| background-color: #fdd835; | |
| --anchor-name: --budget-segment-3; | |
| } | |
| #budget-range::part(segment-4) { | |
| background-color: #3949ab; | |
| --anchor-name: --budget-segment-4; | |
| } | |
| .budget-label { | |
| color: white; | |
| /* Anchor the label to its corresponding segment part */ | |
| position-anchor: var(--anchor); | |
| position: fixed; | |
| left: anchor(left); | |
| right: anchor(right); | |
| top: anchor(top); | |
| bottom: anchor(bottom); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| text-align: center; | |
| font-size: 0.875rem; | |
| text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); | |
| line-height: 1.2; | |
| pointer-events: none; /* Allow clicks to pass through to the slider */ | |
| br { display: contents; } | |
| } | |
| /* Assign the correct anchor name to each label using their specific IDs */ | |
| #budget-label-0 { | |
| --anchor: --budget-segment-1; | |
| } | |
| #budget-label-1 { | |
| --anchor: --budget-segment-2; | |
| } | |
| #budget-label-2 { | |
| --anchor: --budget-segment-3; | |
| } | |
| #budget-label-3 { | |
| --anchor: --budget-segment-4; | |
| } | |
| .budget-label strong { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| } | |
| </style> | |
| <div class="budget-range-wrapper"> | |
| <range-group id="budget-range" min="0" max="100"> | |
| <legend slot="legend" class="color-pink"> | |
| Budget Allocator <label>keep thumbs in track <input type="checkbox"></label> | |
| </legend> | |
| <label for="budget-1" class="visually-hidden" | |
| >Groceries/Rent separator</label | |
| > | |
| <input type="range" id="budget-1" value="25" /> | |
| <label for="budget-2" class="visually-hidden" | |
| >Rent/Transport separator</label | |
| > | |
| <input type="range" id="budget-2" value="50" /> | |
| <label for="budget-3" class="visually-hidden" | |
| >Transport/Internet separator</label | |
| > | |
| <input type="range" id="budget-3" value="75" /> | |
| </range-group> | |
| <div id="budget-labels" class="budget-labels"> | |
| <div id="budget-label-0" class="budget-label"></div> | |
| <div id="budget-label-1" class="budget-label"></div> | |
| <div id="budget-label-2" class="budget-label"></div> | |
| <div id="budget-label-3" class="budget-label"></div> | |
| </div> | |
| </div> | |
| <div | |
| id="budget-announcer" | |
| class="visually-hidden" | |
| aria-live="polite" | |
| role="status" | |
| ></div> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Recreating a classic slider look. This demonstrates using the CSS | |
| Anchoring API for tooltips with heavy custom styling on all | |
| component parts. | |
| </p> | |
| <div class="card" style="position: relative"> | |
| <style> | |
| #classic-range { | |
| --track-height: 4px; | |
| --thumb-size: 24px; | |
| } | |
| #classic-range::part(track) { | |
| background: #444; | |
| border: 1px solid #111; | |
| border-radius: 0; | |
| box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5); | |
| } | |
| #classic-range::part(segment-2) { | |
| background: #3b82f6; | |
| } | |
| #classic-range::part(thumb) { | |
| anchor-name: var(--anchor-name); | |
| background: linear-gradient(to bottom, #f9fafb, #d1d5db); | |
| border: 1px solid #6b7280; | |
| border-radius: 3px; | |
| width: 14px; | |
| height: var(--thumb-size); | |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | |
| } | |
| #classic-range::part(thumb-1) { | |
| --anchor-name: --classic-thumb-1; | |
| } | |
| #classic-range::part(thumb-2) { | |
| --anchor-name: --classic-thumb-2; | |
| } | |
| .classic-tooltip { | |
| position: absolute; | |
| position-anchor: var(--anchor-name); | |
| position-area: top; | |
| translate: -20% -60%; | |
| background: #222; | |
| color: white; | |
| padding: 2px 8px; | |
| border-radius: 3px; | |
| border: 1px solid #555; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| pointer-events: none; | |
| } | |
| #classic-tooltip-1 { | |
| --anchor-name: --classic-thumb-1; | |
| } | |
| #classic-tooltip-2 { | |
| --anchor-name: --classic-thumb-2; | |
| } | |
| </style> | |
| <range-group id="classic-range" min="0" max="100"> | |
| <legend slot="legend" class="color-indigo"> | |
| Classic Look with anchored Tooltips | |
| </legend> | |
| <label for="classic-1" class="visually-hidden" | |
| >Classic Handle 1</label | |
| > | |
| <input | |
| type="range" | |
| id="classic-1" | |
| name="classic-1" | |
| value="20" | |
| /> | |
| <label for="classic-2" class="visually-hidden" | |
| >Classic Handle 2</label | |
| > | |
| <input | |
| type="range" | |
| id="classic-2" | |
| name="classic-2" | |
| value="80" | |
| /> | |
| </range-group> | |
| <div | |
| id="classic-tooltip-1" | |
| class="classic-tooltip" | |
| aria-hidden="true" | |
| > | |
| 20 | |
| </div> | |
| <div | |
| id="classic-tooltip-2" | |
| class="classic-tooltip" | |
| aria-hidden="true" | |
| > | |
| 80 | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| Use the component's public methods and properties to get and set | |
| values programmatically. | |
| </p> | |
| <div class="card"> | |
| <range-group id="api-range" min="0" max="500"> | |
| <legend slot="legend" class="color-purple"> | |
| JavaScript API Interaction | |
| </legend> | |
| <style> | |
| #api-range::part(segment-2) { | |
| background: linear-gradient( | |
| to right, | |
| var(--color-purple), | |
| var(--color-pink) | |
| ); | |
| } | |
| </style> | |
| <label for="api-1-input" class="visually-hidden" | |
| >API Handle 1</label | |
| > | |
| <input type="range" id="api-1-input" name="api-1" value="100" /> | |
| <label for="api-2-input" class="visually-hidden" | |
| >API Handle 2</label | |
| > | |
| <input type="range" id="api-2-input" name="api-2" value="400" /> | |
| </range-group> | |
| <div class="button-group"> | |
| <button id="getValuesBtn" class="btn btn-indigo"> | |
| Get Values | |
| </button> | |
| <button id="setValuesBtn" class="btn btn-pink"> | |
| Set Random Values | |
| </button> | |
| </div> | |
| <pre id="apiOutput" class="api-output"></pre> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| This example shows how to style individual tick marks and their | |
| labels using CSS Parts. The '50%' tick is highlighted with a | |
| different color and height. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #custom-ticks-example::part(tick) { | |
| background: var(--color-text-muted); | |
| height: 8px; | |
| transition: all 0.2s ease; | |
| } | |
| /* Style the 3rd tick (value="50") */ | |
| #custom-ticks-example::part(tick-3) { | |
| background: var(--color-pink); | |
| height: 16px; /* Taller */ | |
| width: 3px; | |
| transform: translateX(-1.5px); /* Re-center */ | |
| } | |
| /* Style the 3rd tick's label */ | |
| #custom-ticks-example::part(tick-label-3) { | |
| color: var(--color-pink); | |
| font-weight: bold; | |
| } | |
| </style> | |
| <range-group | |
| id="custom-ticks-example" | |
| min="0" | |
| max="100" | |
| list="custom-ticks-datalist" | |
| > | |
| <legend slot="legend" class="color-pink"> | |
| Custom Tick Styling | |
| </legend> | |
| <label for="custom-tick-1" class="visually-hidden" | |
| >Value 1</label | |
| > | |
| <input type="range" id="custom-tick-1" value="25" /> | |
| <label for="custom-tick-2" class="visually-hidden" | |
| >Value 2</label | |
| > | |
| <input type="range" id="custom-tick-2" value="75" /> | |
| </range-group> | |
| <datalist id="custom-ticks-datalist"> | |
| <option value="0" label="0%"></option> | |
| <option value="25"></option> | |
| <option value="50" label="50%"></option> | |
| <option value="75"></option> | |
| <option value="100" label="100%"></option> | |
| </datalist> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| A more creative example styling ticks to look like milestones on a | |
| project timeline. Key milestones are larger and have distinct | |
| colors. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #timeline-example { | |
| --track-height: 2px; | |
| } | |
| #timeline-example::part(track) { | |
| background: #555; | |
| } | |
| #timeline-example::part(thumb) { | |
| --thumb-bg: var(--color-teal); | |
| } | |
| #timeline-example::part(segment-2) { | |
| background: var(--color-teal); | |
| } | |
| #timeline-example::part(tick) { | |
| background: #888; | |
| width: 2px; | |
| height: 10px; | |
| border-radius: 2px; | |
| } | |
| /* Highlight the start (1st), middle (3rd), and end (5th) ticks */ | |
| #timeline-example::part(tick-1), | |
| #timeline-example::part(tick-3), | |
| #timeline-example::part(tick-5) { | |
| background: var(--color-teal); | |
| height: 20px; | |
| width: 4px; | |
| transform: translate( | |
| -2px, | |
| -7px | |
| ); /* Adjust position for height */ | |
| } | |
| #timeline-example::part(tick-label) { | |
| color: var(--color-text-muted); | |
| } | |
| #timeline-example::part(tick-label-1), | |
| #timeline-example::part(tick-label-3), | |
| #timeline-example::part(tick-label-5) { | |
| color: var(--color-teal); | |
| font-weight: bold; | |
| } | |
| </style> | |
| <range-group | |
| id="timeline-example" | |
| min="0" | |
| max="100" | |
| list="timeline-datalist" | |
| > | |
| <legend slot="legend" class="color-teal"> | |
| Styled Timeline | |
| </legend> | |
| <label for="timeline-1" class="visually-hidden" | |
| >Start Date</label | |
| > | |
| <input type="range" id="timeline-1" value="10" /> | |
| <label for="timeline-2" class="visually-hidden">End Date</label> | |
| <input type="range" id="timeline-2" value="90" /> | |
| </range-group> | |
| <datalist id="timeline-datalist"> | |
| <option value="0" label="Q1"></option> | |
| <option value="25"></option> | |
| <option value="50" label="Q3"></option> | |
| <option value="75"></option> | |
| <option value="100" label="End"></option> | |
| </datalist> | |
| </div> | |
| </section> | |
| <section> | |
| <p class="description"> | |
| This example uses three handles and styled ticks to represent | |
| different phases in a process, with each major tick mark having a | |
| unique color. | |
| </p> | |
| <div class="card"> | |
| <style> | |
| #multiphase-example::part(tick) { | |
| height: 12px; | |
| width: 2px; | |
| } | |
| #multiphase-example::part(tick-1) { | |
| background: #aaa; | |
| } | |
| #multiphase-example::part(tick-label-1) { | |
| color: #aaa; | |
| } | |
| #multiphase-example::part(tick-2) { | |
| background: #fdd835; | |
| } /* Yellow */ | |
| #multiphase-example::part(tick-label-2) { | |
| color: #fdd835; | |
| } | |
| #multiphase-example::part(tick-3) { | |
| background: #4caf50; | |
| } /* Green */ | |
| #multiphase-example::part(tick-label-3) { | |
| color: #4caf50; | |
| } | |
| #multiphase-example::part(tick-4) { | |
| background: #3b82f6; | |
| } /* Blue */ | |
| #multiphase-example::part(tick-label-4) { | |
| color: #3b82f6; | |
| } | |
| </style> | |
| <range-group | |
| id="multiphase-example" | |
| min="0" | |
| max="100" | |
| list="multiphase-datalist" | |
| > | |
| <legend slot="legend" class="color-indigo"> | |
| Three-Handle Process Flow | |
| </legend> | |
| <label for="multiphase-1" class="visually-hidden" | |
| >Phase 1 End</label | |
| > | |
| <input type="range" id="multiphase-1" value="20" /> | |
| <label for="multiphase-2" class="visually-hidden" | |
| >Phase 2 End</label | |
| > | |
| <input type="range" id="multiphase-2" value="50" /> | |
| <label for="multiphase-3" class="visually-hidden" | |
| >Phase 3 End</label | |
| > | |
| <input type="range" id="multiphase-3" value="80" /> | |
| </range-group> | |
| <datalist id="multiphase-datalist"> | |
| <option value="0" label="Start"></option> | |
| <option value="33" label="Review"></option> | |
| <option value="66" label="Approved"></option> | |
| <option value="100" label="Complete"></option> | |
| </datalist> | |
| </div> | |
| </section> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| document.addEventListener("DOMContentLoaded", () => { | |
| // --- Interactive Price Range Example --- | |
| const priceRange = document.getElementById("interactive-price-range"); | |
| const minPriceInput = document.getElementById("price-min-num-input"); | |
| const maxPriceInput = document.getElementById("price-max-num-input"); | |
| if (priceRange && minPriceInput && maxPriceInput) { | |
| const syncInputsToSlider = () => { | |
| const [minVal, maxVal] = priceRange.values.map((v) => | |
| Math.round(v) | |
| ); | |
| minPriceInput.value = minVal; | |
| maxPriceInput.value = maxVal; | |
| minPriceInput.max = maxVal; | |
| maxPriceInput.min = minVal; | |
| }; | |
| minPriceInput.addEventListener("input", () => { | |
| let value = parseInt(minPriceInput.value, 10); | |
| if (isNaN(value)) return; | |
| priceRange.setRangeValue(0, value); | |
| syncInputsToSlider(); // Re-sync to handle clamping | |
| }); | |
| maxPriceInput.addEventListener("input", () => { | |
| let value = parseInt(maxPriceInput.value, 10); | |
| if (isNaN(value)) return; | |
| priceRange.setRangeValue(1, value); | |
| syncInputsToSlider(); // Re-sync to handle clamping | |
| }); | |
| priceRange.addEventListener("change", syncInputsToSlider); | |
| priceRange.addEventListener("input", syncInputsToSlider); | |
| syncInputsToSlider(); // Initial sync | |
| } | |
| // --- Budget Allocator Example --- | |
| const budgetRange = document.getElementById("budget-range"); | |
| const budgetLabels = [ | |
| document.getElementById("budget-label-0"), | |
| document.getElementById("budget-label-1"), | |
| document.getElementById("budget-label-2"), | |
| document.getElementById("budget-label-3"), | |
| ]; | |
| const budgetCategories = ["Groceries", "Rent", "Transport", "Internet"]; | |
| const budgetAnnouncer = document.getElementById("budget-announcer"); | |
| if (budgetRange && budgetAnnouncer && budgetLabels.every((l) => l)) { | |
| const updateBudget = () => { | |
| const values = [0, ...budgetRange.values, 100]; | |
| let announcementText = "Budget updated. "; | |
| for (let i = 0; i < budgetLabels.length; i++) { | |
| const start = values[i]; | |
| const end = values[i + 1]; | |
| const percentage = Math.round(end - start); | |
| const label = budgetLabels[i]; | |
| label.innerHTML = `<strong>${budgetCategories[i]}</strong><br>${percentage}%`; | |
| announcementText += `${budgetCategories[i]}: ${percentage}%. `; | |
| } | |
| // Update the live region for screen readers | |
| budgetAnnouncer.textContent = announcementText.trim(); | |
| }; | |
| updateBudget(); | |
| budgetRange.addEventListener("input", updateBudget); | |
| } | |
| // --- Floating Tooltip Example --- | |
| const floatingRange = document.getElementById("floating-tooltip-range"); | |
| const floatingTooltip1 = document.getElementById("floating-tooltip-1"); | |
| const floatingTooltip2 = document.getElementById("floating-tooltip-2"); | |
| if (floatingRange && floatingTooltip1 && floatingTooltip2) { | |
| const tooltips = [floatingTooltip1, floatingTooltip2]; | |
| const updateFloatingTooltips = () => { | |
| const values = floatingRange.values; | |
| if (values && values.length >= 2) { | |
| tooltips[0].textContent = Math.round(values[0]); | |
| tooltips[1].textContent = Math.round(values[1]); | |
| } | |
| }; | |
| updateFloatingTooltips(); | |
| floatingRange.addEventListener("change", updateFloatingTooltips); | |
| } | |
| // --- Classic Tooltip Example --- | |
| const classicRange = document.getElementById("classic-range"); | |
| const classicTooltip1 = document.getElementById("classic-tooltip-1"); | |
| const classicTooltip2 = document.getElementById("classic-tooltip-2"); | |
| if (classicRange && classicTooltip1 && classicTooltip2) { | |
| const updateClassicTooltips = () => { | |
| const values = classicRange.values; | |
| classicTooltip1.textContent = Math.round(values[0]); | |
| classicTooltip2.textContent = Math.round(values[1]); | |
| }; | |
| updateClassicTooltips(); | |
| classicRange.addEventListener("change", updateClassicTooltips); | |
| } | |
| // --- API Example --- | |
| const apiRange = document.getElementById("api-range"); | |
| const getValuesBtn = document.getElementById("getValuesBtn"); | |
| const setValuesBtn = document.getElementById("setValuesBtn"); | |
| const apiOutput = document.getElementById("apiOutput"); | |
| if (apiRange && getValuesBtn && setValuesBtn && apiOutput) { | |
| getValuesBtn.addEventListener("click", () => { | |
| apiOutput.textContent = `Values: [${apiRange.values.join(", ")}]`; | |
| }); | |
| setValuesBtn.addEventListener("click", () => { | |
| const val1 = Math.round(Math.random() * 500); | |
| const val2 = Math.round(Math.random() * 500); | |
| apiRange.setRangeValue(0, Math.min(val1, val2)); | |
| apiRange.setRangeValue(1, Math.max(val1, val2)); | |
| apiOutput.textContent = `Set to: [${apiRange.values.join(", ")}]`; | |
| }); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "dependencies": { | |
| "lit": "^3.0.0", | |
| "@lit/reactive-element": "^2.0.0", | |
| "lit-element": "^4.0.0", | |
| "lit-html": "^3.0.0" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { LitElement, html, css, PropertyValueMap } from "lit"; | |
| import { | |
| customElement, | |
| property, | |
| state, | |
| queryAssignedElements, | |
| } from "lit/decorators.js"; | |
| @customElement("range-group") | |
| export class RangeGroup extends LitElement { | |
| declare shadowRoot: ShadowRoot; | |
| declare dispatchEvent: (event: Event) => boolean; | |
| declare hasAttribute: (name: string) => boolean; | |
| declare requestUpdate: (name?: PropertyKey, oldValue?: unknown) => void; | |
| @property({ type: Number }) min: number; | |
| @property({ type: Number }) max: number; | |
| @property({ type: Number }) stepbetween: number; | |
| @property({ type: String }) list: string; | |
| @state() private _values: number[]; | |
| @state() private _datalistOptions: { value: string; label: string }[]; | |
| @queryAssignedElements({ selector: 'input[type="range"]' }) | |
| private _inputs!: HTMLInputElement[]; | |
| @queryAssignedElements({ slot: "legend", selector: "legend" }) | |
| private _legendElements!: HTMLLegendElement[]; | |
| private _activeThumbIndex: number | null = null; | |
| private _containerRect: DOMRect | null = null; | |
| private _pendingActivation: { indices: number[]; initialX: number } | null = | |
| null; | |
| private _uniqueId = Math.random().toString(36).substring(2, 9); | |
| constructor() { | |
| super(); | |
| this.min = 0; | |
| this.max = 100; | |
| this.stepbetween = 0; | |
| this.list = ""; | |
| this._values = []; | |
| this._datalistOptions = []; | |
| } | |
| // Public API | |
| get values(): number[] { | |
| return this._values; | |
| } | |
| get inputs(): HTMLInputElement[] { | |
| return this._inputs; | |
| } | |
| getRangeInput(index: number): HTMLInputElement | undefined { | |
| return this._inputs[index]; | |
| } | |
| setRangeValue(index: number, value: number) { | |
| if (this._inputs[index]) { | |
| this._inputs[index].value = String(this._normalizeValue(value, index)); | |
| this._handleInputChange(); | |
| } | |
| } | |
| connectedCallback() { | |
| super.connectedCallback(); | |
| window.addEventListener("pointermove", this._handlePointerMove); | |
| window.addEventListener("pointerup", this._handlePointerUp); | |
| } | |
| disconnectedCallback() { | |
| super.disconnectedCallback(); | |
| window.removeEventListener("pointermove", this._handlePointerMove); | |
| window.removeEventListener("pointerup", this._handlePointerUp); | |
| } | |
| protected firstUpdated( | |
| _changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown> | |
| ): void { | |
| this._initializeInputs(); | |
| this._parseDatalist(); | |
| this.requestUpdate(); // Request update to process legend | |
| } | |
| private _onSlotChange() { | |
| this._initializeInputs(); | |
| this.requestUpdate(); | |
| } | |
| private _getAccessibleName(input: HTMLInputElement, index: number): string { | |
| let controlLabel: string | null = null; | |
| if (input) { | |
| // 1. aria-labelledby | |
| const labelledby = input.getAttribute("aria-labelledby"); | |
| if (labelledby) { | |
| const labelElement = document.getElementById(labelledby); | |
| controlLabel = labelElement?.textContent?.trim() || null; | |
| } | |
| // 2. aria-label | |
| if (!controlLabel) { | |
| controlLabel = input.getAttribute("aria-label"); | |
| } | |
| // 3. <label for="..."> | |
| if (!controlLabel && input.id) { | |
| const label = document.querySelector<HTMLLabelElement>( | |
| `label[for="${input.id}"]` | |
| ); | |
| controlLabel = label?.textContent?.trim() || null; | |
| } | |
| // 4. Fallback to name attribute | |
| if (!controlLabel) { | |
| controlLabel = input.name; | |
| } | |
| } | |
| // 5. Final fallback | |
| const finalControlLabel = controlLabel || `value ${index + 1}`; | |
| const legendText = this._legendElements?.[0]?.textContent?.trim(); | |
| if (legendText) { | |
| // Combine legend and control label for better context in screen readers. | |
| // Using a comma provides a natural pause. | |
| return `${legendText}, ${finalControlLabel}`; | |
| } | |
| return finalControlLabel; | |
| } | |
| private _initializeInputs() { | |
| if (this._inputs.length === 0) return; | |
| // Must sort by attribute value, as the `value` property may have been clamped | |
| // by the browser before our `min` and `max` attributes have been propagated. | |
| this._inputs.sort( | |
| (a, b) => | |
| Number(a.getAttribute("value")) - Number(b.getAttribute("value")) | |
| ); | |
| this._inputs.forEach((input) => { | |
| // Propagate min/max from this component to the underlying inputs. | |
| if (this.hasAttribute("min")) input.min = String(this.min); | |
| if (this.hasAttribute("max")) input.max = String(this.max); | |
| // Crucially, re-apply the value from the attribute *after* setting min/max. | |
| // This corrects any clamping the browser did with the default min=0. | |
| const initialValue = input.getAttribute("value"); | |
| if (initialValue !== null) { | |
| input.value = initialValue; | |
| } | |
| // Set up event listeners | |
| input.removeEventListener("input", this._handleInputChange); | |
| input.removeEventListener("change", this._handleInputChange); | |
| input.addEventListener("input", this._handleInputChange); | |
| input.addEventListener("change", this._handleInputChange); | |
| }); | |
| // Initialize the component's internal state from the now-correct input values. | |
| this._handleInputChange(); | |
| } | |
| private _parseDatalist() { | |
| if (!this.list) return; | |
| const datalist = document.getElementById(this.list); | |
| if (datalist instanceof HTMLDataListElement) { | |
| this._datalistOptions = Array.from(datalist.options).map((opt) => ({ | |
| value: opt.value, | |
| label: opt.label || opt.value, | |
| })); | |
| } | |
| } | |
| private _handleInputChange = () => { | |
| this._values = this._inputs.map((input, index) => | |
| this._normalizeValue(Number(input.value), index) | |
| ); | |
| this.dispatchEvent( | |
| new CustomEvent("change", { detail: { values: this._values } }) | |
| ); | |
| this.requestUpdate(); | |
| }; | |
| private _handlePointerDown(e: PointerEvent, index: number) { | |
| if (e.button !== 0) return; // Only main button | |
| e.preventDefault(); | |
| this._containerRect = | |
| this.shadowRoot?.querySelector(".container")?.getBoundingClientRect() ?? | |
| null; | |
| const currentValue = this._values[index]; | |
| const overlappingIndices = this._values.reduce((acc, v, i) => { | |
| if (Math.abs(v - currentValue) < 0.001) { | |
| // Use a tolerance for float comparison | |
| acc.push(i); | |
| } | |
| return acc; | |
| }, [] as number[]); | |
| if (overlappingIndices.length > 1) { | |
| // Overlap detected: defer activation and focus until pointer move. | |
| this._pendingActivation = { | |
| indices: overlappingIndices, | |
| initialX: e.clientX, | |
| }; | |
| this._activeThumbIndex = null; | |
| } else { | |
| // No overlap: activate and focus immediately. | |
| (e.target as HTMLElement).focus(); | |
| this._activeThumbIndex = index; | |
| } | |
| (e.target as HTMLElement).setPointerCapture(e.pointerId); | |
| } | |
| private _handlePointerMove = (e: PointerEvent) => { | |
| // If a thumb activation is pending, determine which thumb to activate based on drag direction. | |
| if (this._pendingActivation) { | |
| const dx = e.clientX - this._pendingActivation.initialX; | |
| // Wait for a clear move to determine direction to avoid accidental activation. | |
| if (Math.abs(dx) > 2) { | |
| const direction = dx > 0 ? "right" : "left"; | |
| const indices = this._pendingActivation.indices; | |
| // On left drag, grab the leftmost thumb of the stack. | |
| // On right drag, grab the rightmost thumb of the stack. | |
| this._activeThumbIndex = | |
| direction === "left" ? Math.min(...indices) : Math.max(...indices); | |
| // Now that the thumb is active, find its element and focus it. | |
| const thumbElements = | |
| this.shadowRoot?.querySelectorAll<HTMLElement>(".thumb"); | |
| if (thumbElements && thumbElements[this._activeThumbIndex]) { | |
| thumbElements[this._activeThumbIndex].focus(); | |
| } | |
| this._pendingActivation = null; // Activation is decided for this drag session. | |
| } | |
| } | |
| if (this._activeThumbIndex === null || !this._containerRect) return; | |
| e.preventDefault(); | |
| const percent = Math.max( | |
| 0, | |
| Math.min( | |
| 1, | |
| (e.clientX - this._containerRect.left) / this._containerRect.width | |
| ) | |
| ); | |
| const value = this.min + percent * (this.max - this.min); | |
| this.setRangeValue(this._activeThumbIndex, value); | |
| }; | |
| private _handlePointerUp = (e: PointerEvent) => { | |
| if (this._activeThumbIndex !== null || this._pendingActivation !== null) { | |
| e.preventDefault(); | |
| try { | |
| (e.target as HTMLElement).releasePointerCapture(e.pointerId); | |
| } catch (err) { | |
| // This can happen if pointer capture is lost for other reasons; it's safe to ignore. | |
| } | |
| } | |
| this._activeThumbIndex = null; | |
| this._pendingActivation = null; | |
| this._containerRect = null; | |
| }; | |
| private _handleKeyDown(e: KeyboardEvent, index: number) { | |
| const input = this._inputs[index]; | |
| if (!input) return; | |
| let step = Number(input.step) || 1; | |
| let newValue = Number(input.value); | |
| // If using a datalist, step becomes the next/previous option | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| const currentIndex = sortedValues.indexOf(newValue); | |
| if (e.key === "ArrowLeft" || e.key === "ArrowDown") { | |
| newValue = sortedValues[Math.max(0, currentIndex - 1)]; | |
| } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { | |
| newValue = | |
| sortedValues[Math.min(sortedValues.length - 1, currentIndex + 1)]; | |
| } | |
| } else { | |
| if (e.key === "ArrowLeft" || e.key === "ArrowDown") { | |
| newValue -= step; | |
| } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { | |
| newValue += step; | |
| } | |
| } | |
| if (e.key === "Home") { | |
| newValue = this.min; | |
| } else if (e.key === "End") { | |
| newValue = this.max; | |
| } else if ( | |
| e.key !== "ArrowLeft" && | |
| e.key !== "ArrowDown" && | |
| e.key !== "ArrowRight" && | |
| e.key !== "ArrowUp" | |
| ) { | |
| return; | |
| } | |
| e.preventDefault(); | |
| this.setRangeValue(index, newValue); | |
| } | |
| private _snapToDataList(value: number): number { | |
| if ( | |
| !this.list || | |
| !this._datalistOptions || | |
| this._datalistOptions.length === 0 | |
| ) { | |
| return value; | |
| } | |
| const validValues = this._datalistOptions.map((opt) => Number(opt.value)); | |
| // Find the closest value in the datalist | |
| const closest = validValues.reduce((prev, curr) => { | |
| return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev; | |
| }); | |
| return closest; | |
| } | |
| private _normalizeValue(value: number, index: number): number { | |
| const input = this._inputs[index]; | |
| if (!input) return value; | |
| let targetValue = value; | |
| // Snap to datalist first if it exists | |
| if (this.list && this._datalistOptions.length > 0) { | |
| targetValue = this._snapToDataList(targetValue); | |
| } | |
| // Use component's min/max as the source of truth for clamping. | |
| // This is more reliable than reading from the child input element during initialization. | |
| const minVal = this.min; | |
| const maxVal = this.max; | |
| let finalValue = Math.max(minVal, Math.min(maxVal, targetValue)); | |
| const step = this.stepbetween || 0; | |
| // Check against previous handle | |
| const prevInput = this._inputs[index - 1]; | |
| if (prevInput) { | |
| const minAllowed = Number(prevInput.value) + step; | |
| if (finalValue < minAllowed) { | |
| // If datalist exists, find the next valid option. Otherwise, just use the calculated min. | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| finalValue = sortedValues.find((v) => v >= minAllowed) ?? finalValue; | |
| } else { | |
| finalValue = minAllowed; | |
| } | |
| } | |
| } | |
| // Check against next handle | |
| const nextInput = this._inputs[index + 1]; | |
| if (nextInput) { | |
| const maxAllowed = Number(nextInput.value) - step; | |
| if (finalValue > maxAllowed) { | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| finalValue = | |
| [...sortedValues].reverse().find((v) => v <= maxAllowed) ?? | |
| finalValue; | |
| } else { | |
| finalValue = maxAllowed; | |
| } | |
| } | |
| } | |
| // Re-clamp (to ensure we didn't go out of bounds) | |
| return Math.max(minVal, Math.min(maxVal, finalValue)); | |
| } | |
| private _valueToPercent(value: number): number { | |
| return ((value - this.min) / (this.max - this.min)) * 100; | |
| } | |
| render() { | |
| const segmentPoints = [ | |
| 0, | |
| ...this._values.map((v) => this._valueToPercent(v)), | |
| 100, | |
| ]; | |
| const legend = this._legendElements?.[0]; | |
| if (legend && !legend.id) { | |
| legend.id = `rg-legend-${this._uniqueId}`; | |
| } | |
| const legendId = legend?.id; | |
| return html` | |
| <fieldset class="wrapper"> | |
| <slot name="legend" @slotchange=${this._onSlotChange}></slot> | |
| <div class="container" role="group" aria-labelledby=${legendId || ""}> | |
| <div part="track" class="track"> | |
| ${segmentPoints.slice(0, -1).map((p, i) => { | |
| const left = p; | |
| const width = segmentPoints[i + 1] - p; | |
| return html`<div | |
| part="segment segment-${i + 1}" | |
| class="segment" | |
| style="left: ${left}%; width: ${width}%;" | |
| ></div>`; | |
| })} | |
| </div> | |
| ${this._datalistOptions.length > 0 | |
| ? html` | |
| <div class="ticks-wrapper"> | |
| <div class="tick-marks" part="ticks"> | |
| ${this._datalistOptions.map( | |
| (opt, index) => html` | |
| <div | |
| part="tick tick-${index + 1}" | |
| class="tick" | |
| style="left: ${this._valueToPercent( | |
| Number(opt.value) | |
| )}%" | |
| ></div> | |
| ` | |
| )} | |
| </div> | |
| <div class="tick-labels" part="tick-labels"> | |
| ${this._datalistOptions.map( | |
| (opt, index) => html` | |
| <div | |
| part="tick-label tick-label-${index + 1}" | |
| class="tick-label" | |
| style="left: ${this._valueToPercent( | |
| Number(opt.value) | |
| )}%" | |
| > | |
| ${opt.label} | |
| </div> | |
| ` | |
| )} | |
| </div> | |
| </div> | |
| ` | |
| : ""} | |
| ${this._values.map( | |
| (value, index) => html` | |
| <button | |
| part="thumb thumb-${index + 1}" | |
| class="thumb" | |
| style="left: ${this._valueToPercent( | |
| value | |
| )}%; z-index: ${index === this._activeThumbIndex ? 12 : 10};" | |
| role="slider" | |
| tabindex="0" | |
| aria-label=${this._getAccessibleName( | |
| this._inputs[index], | |
| index | |
| )} | |
| aria-valuemin=${this.min} | |
| aria-valuemax=${this.max} | |
| aria-valuenow=${Math.round(value)} | |
| @pointerdown=${(e: PointerEvent) => | |
| this._handlePointerDown(e, index)} | |
| @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, index)} | |
| ></button> | |
| ` | |
| )} | |
| </div> | |
| <slot @slotchange=${this._onSlotChange} style="display: none;"></slot> | |
| </fieldset> | |
| `; | |
| } | |
| static styles = css` | |
| :host { | |
| display: block; | |
| position: relative; | |
| padding-bottom: calc(var(--thumb-size, 24px) / 2); | |
| box-sizing: content-box; | |
| --track-height: 6px; | |
| } | |
| .wrapper { | |
| border: 0; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .container { | |
| position: relative; | |
| width: 100%; | |
| height: var(--thumb-size, 24px); | |
| margin-top: calc(var(--thumb-size, 24px) / 2); | |
| } | |
| .track { | |
| position: absolute; | |
| top: 50%; | |
| left: 0; | |
| width: 100%; | |
| height: var(--track-height); | |
| transform: translateY(-50%); | |
| background-color: #ccc; | |
| } | |
| .segment { | |
| position: absolute; | |
| top: 0; | |
| height: 100%; | |
| background-color: #999; | |
| } | |
| .thumb { | |
| position: absolute; | |
| top: 50%; | |
| width: var(--thumb-size, 24px); | |
| height: var(--thumb-size, 24px); | |
| background-color: var(--thumb-bg, #007bff); | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%); | |
| cursor: pointer; | |
| touch-action: none; | |
| box-shadow: 0 0 8px 0 var(--thumb-bg, #007bff); | |
| transition: transform 0.1s ease-in-out; | |
| border: none; | |
| padding: 0; | |
| } | |
| .thumb::before { | |
| content: ""; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: calc(var(--thumb-size, 24px) + 20px); | |
| height: calc(var(--thumb-size, 24px) + 20px); | |
| background: transparent; | |
| border-radius: 50%; | |
| } | |
| .thumb:hover { | |
| transform: translate(-50%, -50%) scale(1.1); | |
| } | |
| .thumb:focus-visible { | |
| outline-offset: 4px; | |
| } | |
| .ticks-wrapper { | |
| position: absolute; | |
| top: calc(50% + var(--track-height) / 2 + 4px); | |
| left: 0; | |
| right: 0; | |
| height: 20px; | |
| pointer-events: none; | |
| } | |
| .tick-marks, | |
| .tick-labels { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .tick { | |
| position: absolute; | |
| transform: translateX(-50%); | |
| width: 1px; | |
| height: 6px; | |
| background: var(--tick-color, currentColor); | |
| } | |
| .tick-label { | |
| position: absolute; | |
| top: 10px; /* 6px tick height + 4px gap */ | |
| transform: translateX(-50%); | |
| font-size: 0.75rem; | |
| color: var(--tick-label-color, currentColor); | |
| } | |
| `; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { LitElement, html, css, PropertyValueMap } from "lit"; | |
| import { | |
| customElement, | |
| property, | |
| state, | |
| queryAssignedElements, | |
| queryAll, | |
| } from "lit/decorators.js"; | |
| const shallowEqual = (arr1, arr2) => | |
| arr1?.length === arr2?.length && | |
| arr1.every((val, i) => val === arr2[i]); | |
| @customElement("range-group") | |
| export class RangeGroup extends LitElement { | |
| @property({ type: Number }) | |
| accessor min = 0; | |
| @property({ type: Number }) | |
| accessor max = 100; | |
| @property({ type: Number }) | |
| accessor stepBetween = 0; | |
| @property({ type: String }) | |
| accessor list = ''; | |
| @state() | |
| private accessor _values = []; | |
| // used to determine if an event should fire. | |
| private _previousInputValues = []; | |
| private _previousChangeValues = []; | |
| @state() | |
| private accessor _datalistOptions = [] as { value: string; label: string }[]; | |
| @queryAssignedElements({ selector: 'input[type="range"]' }) | |
| private _inputs!: HTMLInputElement[]; | |
| @queryAll('.thumb') | |
| private _thumbs!: HTMLButtonElement[]; | |
| @queryAssignedElements({ slot: "legend", selector: "legend" }) | |
| private _legendElements!: HTMLLegendElement[]; | |
| private _activeThumbIndex: number | null = null; | |
| private _containerRect: DOMRect | null = null; | |
| private _pendingActivation: { indices: number[]; initialX: number } | null = | |
| null; | |
| private _uniqueId = Math.random().toString(36).substring(2, 9); | |
| private _internals = this.attachInternals(); | |
| // Public API | |
| get values(): number[] { | |
| return this._values; | |
| } | |
| get inputs(): HTMLInputElement[] { | |
| return this._inputs; | |
| } | |
| getRangeInput(index: number): HTMLInputElement | undefined { | |
| return this._inputs[index]; | |
| } | |
| setRangeValue(index: number, value: number) { | |
| if (this._inputs[index]) { | |
| this._inputs[index].value = String(this._normalizeValue(value, index)); | |
| this._updateValues(); | |
| } | |
| } | |
| protected willUpdate( | |
| changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown> | |
| ): void { | |
| if (changedProperties.has('list')) { | |
| this._parseDatalist(); | |
| } | |
| } | |
| private _onSlotChange() { | |
| this._initializeInputs(); | |
| } | |
| private _getAccessibleName(input: HTMLInputElement, index: number): string { | |
| let controlLabel: string | null = null; | |
| if (input) { | |
| // 1. aria-labelledby | |
| const labelledby = input.getAttribute("aria-labelledby"); | |
| if (labelledby) { | |
| const labelElement = document.getElementById(labelledby); | |
| controlLabel = labelElement?.textContent?.trim() || null; | |
| } | |
| // 2. aria-label | |
| if (!controlLabel) { | |
| controlLabel = input.getAttribute("aria-label"); | |
| } | |
| // 3. <label for="..."> | |
| if (!controlLabel && input.id) { | |
| const label = document.querySelector<HTMLLabelElement>( | |
| `label[for="${input.id}"]` | |
| ); | |
| controlLabel = label?.textContent?.trim() || null; | |
| } | |
| // 4. Fallback to name attribute | |
| if (!controlLabel) { | |
| controlLabel = input.name; | |
| } | |
| } | |
| // 5. Final fallback | |
| const finalControlLabel = controlLabel || `value ${index + 1}`; | |
| const legendText = this._legendElements?.[0]?.textContent?.trim(); | |
| if (legendText) { | |
| // Combine legend and control label for better context in screen readers. | |
| // Using a comma provides a natural pause. | |
| return `${legendText}, ${finalControlLabel}`; | |
| } | |
| return finalControlLabel; | |
| } | |
| private _initializeInputs() { | |
| if (this._inputs.length === 0) return; | |
| // Must sort by attribute value, as the `value` property may have been clamped | |
| // by the browser before our `min` and `max` attributes have been propagated. | |
| this._inputs.sort( | |
| (a, b) => | |
| Number(a.getAttribute("value")) - Number(b.getAttribute("value")) | |
| ); | |
| this._inputs.forEach((input) => { | |
| // Propagate min/max from this component to the underlying inputs. | |
| if (this.hasAttribute("min")) input.min = String(this.min); | |
| if (this.hasAttribute("max")) input.max = String(this.max); | |
| // Crucially, re-apply the value from the attribute *after* setting min/max. | |
| // This corrects any clamping the browser did with the default min=0. | |
| const initialValue = input.getAttribute("value"); | |
| if (initialValue !== null) { | |
| input.value = initialValue; | |
| } | |
| }); | |
| // Initialize the component's internal state from the now-correct input values. | |
| this._updateValues(); | |
| } | |
| private _parseDatalist() { | |
| if (!this.list) return; | |
| const datalist = document.getElementById(this.list); | |
| if (datalist instanceof HTMLDataListElement) { | |
| this._datalistOptions = Array.from(datalist.options).map((opt) => ({ | |
| value: opt.value, | |
| label: opt.label || opt.value, | |
| })); | |
| } | |
| } | |
| private _updateValues() { | |
| this._values = this._inputs.map((input, index) => | |
| this._normalizeValue(Number(input.value), index) | |
| ); | |
| } | |
| private async _dispatch(name = 'change') { | |
| let shouldDispatch = true; | |
| if (name === 'change') { | |
| shouldDispatch = !shallowEqual(this._values, this._previousChangeValues); | |
| this._previousChangeValues = this._values; | |
| } else { | |
| shouldDispatch = !shallowEqual(this._values, this._previousInputValues); | |
| this._previousInputValues = this._values; | |
| } | |
| if (!shouldDispatch) { | |
| return; | |
| } | |
| await this.updateComplete; | |
| this.dispatchEvent(new Event(name, {bubbles: true, composed: true, cancelable: true})); | |
| } | |
| private _findClosest(value) { | |
| return this._values.reduce((x, v, i) => { | |
| const current = Math.abs(v - value); | |
| const min = Math.abs(this._values[x] - value); | |
| return current < min ? i : x; | |
| }, 0); | |
| } | |
| private _pointerToValue(e) { | |
| const percent = Math.max( | |
| 0, | |
| Math.min( | |
| 1, | |
| (e.clientX - this._containerRect.left) / this._containerRect.width | |
| ) | |
| ); | |
| return this.min + percent * (this.max - this.min); | |
| } | |
| private _handlePointerDown(e: PointerEvent, index: number) { | |
| if (e.button !== 0) return; // Only main button | |
| e.preventDefault(); | |
| this._containerRect = | |
| this.shadowRoot?.querySelector(".container")?.getBoundingClientRect() ?? | |
| null; | |
| const pointerValue = this._pointerToValue(e); | |
| // select the right thumb to move... | |
| // 1. if the target is a thumb, use that. | |
| index ??= Array.from(this._thumbs).indexOf(e.target as HTMLButtonElement); | |
| let currentValue = this._values[index]; | |
| // 2. otherwise use focused input if the pointer value is in its range | |
| if (index < 0) { | |
| const {activeElement} = this.shadowRoot; | |
| index = Array.from(this._thumbs).indexOf(activeElement as HTMLButtonElement); | |
| const input = this._inputs[index]; | |
| if (input) { | |
| const min = Math.max(Number(input.min), this._values[index-1] ?? -Infinity); | |
| const max = Math.min(Number(input.max), this._values[index+1] ?? Infinity); | |
| if (min <= pointerValue && pointerValue <= max) { | |
| currentValue = pointerValue; | |
| } else { | |
| index = -1; | |
| } | |
| } | |
| } | |
| // 3. otherwise use closest thumb. | |
| if (index < 0) { | |
| index = this._findClosest(pointerValue); | |
| currentValue = pointerValue; | |
| } | |
| const target = this._thumbs[index]; | |
| const overlappingIndices = this._values.reduce((acc, v, i) => { | |
| if (Math.abs(v - currentValue) < 0.001) { | |
| // Use a tolerance for float comparison | |
| acc.push(i); | |
| } | |
| return acc; | |
| }, [] as number[]); | |
| if (overlappingIndices.length > 1) { | |
| // Overlap detected: defer activation and focus until pointer move. | |
| this._pendingActivation = { | |
| indices: overlappingIndices, | |
| initialX: e.clientX, | |
| }; | |
| this._activeThumbIndex = null; | |
| } else { | |
| // No overlap: activate and focus immediately. | |
| target.focus(); | |
| this._activeThumbIndex = index; | |
| } | |
| target.setPointerCapture(e.pointerId); | |
| if (this._activeThumbIndex !== null) { | |
| this._handlePointerMove(e); | |
| } | |
| } | |
| private _handlePointerMove = (e: PointerEvent) => { | |
| // If a thumb activation is pending, determine which thumb to activate based on drag direction. | |
| if (this._pendingActivation) { | |
| const dx = e.clientX - this._pendingActivation.initialX; | |
| // Wait for a clear move to determine direction to avoid accidental activation. | |
| if (Math.abs(dx) > 2) { | |
| const direction = dx > 0 ? "right" : "left"; | |
| const indices = this._pendingActivation.indices; | |
| // On left drag, grab the leftmost thumb of the stack. | |
| // On right drag, grab the rightmost thumb of the stack. | |
| this._activeThumbIndex = | |
| direction === "left" ? Math.min(...indices) : Math.max(...indices); | |
| // Now that the thumb is active, find its element and focus it. | |
| this._thumbs[this._activeThumbIndex]?.focus(); | |
| this._pendingActivation = null; // Activation is decided for this drag session. | |
| } | |
| } | |
| if (this._activeThumbIndex === null || !this._containerRect) { | |
| return; | |
| } | |
| e.preventDefault(); | |
| const value = this._pointerToValue(e); | |
| this.setRangeValue(this._activeThumbIndex, value); | |
| if (e.type === 'pointermove') { | |
| this._internals.states.add('active'); | |
| } | |
| this._dispatch('input'); | |
| }; | |
| private _handlePointerUp = (e: PointerEvent) => { | |
| if (this._activeThumbIndex !== null || this._pendingActivation !== null) { | |
| e.preventDefault(); | |
| } | |
| this._activeThumbIndex = null; | |
| this._pendingActivation = null; | |
| this._containerRect = null; | |
| this._internals.states.delete('active'); | |
| this._dispatch('change'); | |
| }; | |
| private _handleKeyDown(e: KeyboardEvent, index: number) { | |
| const input = this._inputs[index]; | |
| if (!input) return; | |
| let step = Number(input.step) || 1; | |
| let newValue = Number(input.value); | |
| // If using a datalist, step becomes the next/previous option | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| const currentIndex = sortedValues.indexOf(newValue); | |
| if (e.key === "ArrowLeft" || e.key === "ArrowDown") { | |
| newValue = sortedValues[Math.max(0, currentIndex - 1)]; | |
| } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { | |
| newValue = | |
| sortedValues[Math.min(sortedValues.length - 1, currentIndex + 1)]; | |
| } | |
| } else { | |
| if (e.key === "ArrowLeft" || e.key === "ArrowDown") { | |
| newValue -= step; | |
| } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { | |
| newValue += step; | |
| } | |
| } | |
| if (e.key === "Home") { | |
| newValue = this.min; | |
| } else if (e.key === "End") { | |
| newValue = this.max; | |
| } else if ( | |
| e.key !== "ArrowLeft" && | |
| e.key !== "ArrowDown" && | |
| e.key !== "ArrowRight" && | |
| e.key !== "ArrowUp" | |
| ) { | |
| return; | |
| } | |
| e.preventDefault(); | |
| this.setRangeValue(index, newValue); | |
| this._dispatch('input'); | |
| this._dispatch('change'); | |
| } | |
| private _snapToDataList(value: number): number { | |
| if ( | |
| !this.list || | |
| !this._datalistOptions || | |
| this._datalistOptions.length === 0 | |
| ) { | |
| return value; | |
| } | |
| const validValues = this._datalistOptions.map((opt) => Number(opt.value)); | |
| // Find the closest value in the datalist | |
| const closest = validValues.reduce((prev, curr) => { | |
| return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev; | |
| }); | |
| return closest; | |
| } | |
| private _normalizeValue(value: number, index: number): number { | |
| const input = this._inputs[index]; | |
| if (!input) return value; | |
| let targetValue = value; | |
| // Snap to datalist first if it exists | |
| if (this.list && this._datalistOptions.length > 0) { | |
| targetValue = this._snapToDataList(targetValue); | |
| } | |
| // Use component's min/max as the source of truth for clamping. | |
| // This is more reliable than reading from the child input element during initialization. | |
| const minVal = this.min; | |
| const maxVal = this.max; | |
| let finalValue = Math.max(minVal, Math.min(maxVal, targetValue)); | |
| const step = this.stepBetween || 0; | |
| // Check against previous handle | |
| const prevInput = this._inputs[index - 1]; | |
| if (prevInput) { | |
| const minAllowed = Number(prevInput.value) + step; | |
| if (finalValue < minAllowed) { | |
| // If datalist exists, find the next valid option. Otherwise, just use the calculated min. | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| finalValue = sortedValues.find((v) => v >= minAllowed) ?? finalValue; | |
| } else { | |
| finalValue = minAllowed; | |
| } | |
| } | |
| } | |
| // Check against next handle | |
| const nextInput = this._inputs[index + 1]; | |
| if (nextInput) { | |
| const maxAllowed = Number(nextInput.value) - step; | |
| if (finalValue > maxAllowed) { | |
| if (this.list && this._datalistOptions.length > 0) { | |
| const sortedValues = this._datalistOptions | |
| .map((opt) => Number(opt.value)) | |
| .sort((a, b) => a - b); | |
| finalValue = | |
| [...sortedValues].reverse().find((v) => v <= maxAllowed) ?? | |
| finalValue; | |
| } else { | |
| finalValue = maxAllowed; | |
| } | |
| } | |
| } | |
| // Re-clamp (to ensure we didn't go out of bounds) | |
| return Math.max(minVal, Math.min(maxVal, finalValue)); | |
| } | |
| private _valueToPercent(value: number): number { | |
| return ((value - this.min) / (this.max - this.min)) * 100; | |
| } | |
| render() { | |
| const segmentPoints = [ | |
| 0, | |
| ...this._values.map((v) => this._valueToPercent(v)), | |
| 100, | |
| ]; | |
| const legend = this._legendElements?.[0]; | |
| if (legend && !legend.id) { | |
| legend.id = `rg-legend-${this._uniqueId}`; | |
| } | |
| const legendId = legend?.id; | |
| return html` | |
| <fieldset class="wrapper"> | |
| <slot name="legend" @slotchange=${this._onSlotChange}></slot> | |
| <div class="container" role="group" aria-labelledby=${legendId || ""} | |
| @pointermove=${this._handlePointerMove} | |
| @lostpointercapture=${this._handlePointerUp} | |
| @pointerdown=${this._handlePointerDown}> | |
| <div part="track" class="track"> | |
| ${segmentPoints.slice(0, -1).map((p, i) => { | |
| const left = p; | |
| const width = segmentPoints[i + 1] - p; | |
| return html`<div | |
| part="segment segment-${i + 1}" | |
| class="segment" | |
| style="--segment-position: ${0.01 * left}; --segment-size: ${0.01 * width};" | |
| ></div>`; | |
| })} | |
| </div> | |
| ${this._datalistOptions.length > 0 | |
| ? html` | |
| <div class="ticks-wrapper"> | |
| <div class="tick-marks" part="ticks"> | |
| ${this._datalistOptions.map( | |
| (opt, index) => html` | |
| <div | |
| part="tick tick-${index + 1}" | |
| class="tick" | |
| style="--tick-position: ${this._valueToPercent( | |
| Number(opt.value) | |
| )}%" | |
| ></div> | |
| ` | |
| )} | |
| </div> | |
| <div class="tick-labels" part="tick-labels"> | |
| ${this._datalistOptions.map( | |
| (opt, index) => html` | |
| <div | |
| part="tick-label tick-label-${index + 1}" | |
| class="tick-label" | |
| style="--tick-position: ${0.01 * this._valueToPercent( | |
| Number(opt.value) | |
| )}" | |
| > | |
| ${opt.label} | |
| </div> | |
| ` | |
| )} | |
| </div> | |
| </div> | |
| ` | |
| : ""} | |
| ${this._values.map( | |
| (value, index) => html` | |
| <button | |
| part="thumb thumb-${index + 1}" | |
| class="thumb" | |
| style="--thumb-position: ${0.01 * this._valueToPercent( | |
| value | |
| )}; z-index: ${index === this._activeThumbIndex ? 12 : 10};" | |
| role="slider" | |
| aria-label=${this._getAccessibleName( | |
| this._inputs[index], | |
| index | |
| )} | |
| aria-valuemin=${this.min} | |
| aria-valuemax=${this.max} | |
| aria-valuenow=${Math.round(value)} | |
| @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, index)} | |
| ></button> | |
| ` | |
| )} | |
| </div> | |
| <slot @slotchange=${this._initializeInputs} style="display: none;"></slot> | |
| </fieldset> | |
| `; | |
| } | |
| static styles = css` | |
| :host { | |
| display: flex; | |
| --_thumb-size: var(--thumb-size, 24px); | |
| --_thumb-half-size: calc(var(--_thumb-size) / 2); | |
| --_thumb-bg: var(--thumb-bg, #007bff); | |
| --_track-height: var(--track-height, 6px); | |
| --_thumb-space: calc(var(--_thumb-size + 20px)); | |
| padding-block: var(--_thumb-half-size); | |
| flex-direction: column; | |
| gap: var(--_thumb-half-size); | |
| } | |
| .wrapper { | |
| display: contents; | |
| } | |
| .container { | |
| position: relative; | |
| height: var(--_thumb-size); | |
| display: flex; | |
| align-items: center; | |
| } | |
| .track { | |
| position: relative; | |
| width: 100%; | |
| height: var(--_track-height); | |
| background-color: #ccc; | |
| } | |
| .segment { | |
| position: absolute; | |
| height: 100%; | |
| left: calc(100% * var(--segment-position)); | |
| width: calc(100% * var(--segment-size)); | |
| background-color: #999; | |
| } | |
| .thumb { | |
| position: absolute; | |
| left: calc(100% * var(--thumb-position) - var(--_thumb-half-size)); | |
| width: var(--_thumb-size); | |
| height: var(--_thumb-size); | |
| background-color: var(--_thumb-bg); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| touch-action: none; | |
| box-shadow: 0 0 8px 0 var(--_thumb-bg); | |
| transition: transform 0.1s ease-in-out; | |
| border: none; | |
| padding: 0; | |
| } | |
| .thumb::before { | |
| content: ""; | |
| position: absolute; | |
| width: var(--_thumb-space); | |
| height: var(--_thumb-space); | |
| background: transparent; | |
| border-radius: 50%; | |
| } | |
| .thumb:hover { | |
| transform: scale(1.1); | |
| } | |
| .thumb:focus-visible { | |
| outline-offset: 4px; | |
| } | |
| .ticks-wrapper { | |
| position: absolute; | |
| top: calc(50% + var(--_track-height) / 2 + 4px); | |
| left: 0; | |
| right: 0; | |
| height: 20px; | |
| pointer-events: none; | |
| } | |
| .tick-marks, | |
| .tick-labels { | |
| position: absolute; | |
| inset: 0; | |
| } | |
| .tick { | |
| position: absolute; | |
| transform: translateX(-50%); | |
| width: 1px; | |
| height: 6px; | |
| left: calc(100% * var(--tick-position)); | |
| background: var(--tick-color, currentColor); | |
| } | |
| .tick-label { | |
| position: absolute; | |
| top: 10px; /* 6px tick height + 4px gap */ | |
| left: calc(100% * var(--tick-position)); | |
| transform: translateX(-50%); | |
| font-size: 0.75rem; | |
| color: var(--tick-label-color, currentColor); | |
| } | |
| `; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :root { | |
| --color-bg: oklch(15% 0.01 230); | |
| --color-text: oklch(95% 0.01 230); | |
| --color-text-muted: oklch(70% 0.02 230); | |
| --color-text-description: oklch(85% 0.01 230); | |
| --color-card-bg: oklch(20% 0.02 230); | |
| --color-purple: oklch(70% 0.3 300); | |
| --color-pink: oklch(75% 0.3 340); | |
| --color-teal: oklch(80% 0.25 180); | |
| --color-indigo: oklch(70% 0.28 260); | |
| --color-indigo-strong: oklch(65% 0.28 260); | |
| --color-indigo-hover: oklch(60% 0.28 260); | |
| --color-pink-strong: oklch(70% 0.3 340); | |
| --color-pink-hover: oklch(65% 0.3 340); | |
| } | |
| *, *::before, *::after { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| background-color: var(--color-bg); | |
| color: var(--color-text); | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
| margin: 0; | |
| } | |
| .container-wrapper { | |
| padding: 2rem 1rem; | |
| } | |
| .main-container { | |
| margin-left: auto; | |
| margin-right: auto; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 3rem; | |
| a { | |
| color: inherit; | |
| text-underline-offset: 3px; | |
| &:where(:hover, :focus) { | |
| color: #ddd; | |
| } | |
| } | |
| } | |
| h1 { | |
| font-size: 3rem; | |
| font-weight: 800; | |
| background-image: linear-gradient(to right, var(--color-purple), var(--color-pink)); | |
| background-clip: text; | |
| -webkit-background-clip: text; | |
| color: transparent; | |
| margin: 0; | |
| text-shadow: 0 0 10px oklch(75% 0.3 340 / 0.5); | |
| } | |
| .subtitle { | |
| margin-top: 1rem; | |
| font-size: 1.125rem; | |
| color: #FFF; | |
| } | |
| .subtitle code { | |
| font-size: 1em; | |
| color: var(--color-text); | |
| text-shadow: 0 0 5px currentColor; | |
| } | |
| .sections-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4rem; | |
| } | |
| /* Style the legend which now acts as the section header */ | |
| .sections-container legend { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| padding: 0; /* Legends have browser-default padding */ | |
| Xmargin-bottom: 0.5rem; | |
| } | |
| .description { | |
| color: var(--color-text-description); | |
| Xmargin-top: 0; | |
| Xmargin-bottom: 1.5rem; | |
| } | |
| .color-purple { color: var(--color-purple); } | |
| .color-pink { color: var(--color-pink); } | |
| .color-teal { color: var(--color-teal); } | |
| .color-indigo { color: var(--color-indigo); } | |
| .card { | |
| background-color: var(--color-card-bg); | |
| padding: 2rem; | |
| border-radius: 0.75rem; | |
| border: 1px solid oklch(30% 0.02 230); | |
| box-shadow: 0 10px 15px -3px rgba(0,0,0,0.2), 0 4px 6px -2px rgba(0,0,0,0.1); | |
| } | |
| .button-group { | |
| margin-top: 1.5rem; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| justify-content: center; | |
| } | |
| .btn { | |
| color: white; | |
| font-weight: 700; | |
| padding: 0.5rem 1rem; | |
| border: none; | |
| border-radius: 0.5rem; | |
| cursor: pointer; | |
| transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; | |
| } | |
| .btn:focus-visible { | |
| outline: 2px solid oklch(85% 0.15 240); | |
| outline-offset: 2px; | |
| } | |
| .btn-indigo { | |
| background-color: var(--color-indigo-strong); | |
| box-shadow: 0 0 10px 0 var(--color-indigo-strong); | |
| } | |
| .btn-indigo:hover { | |
| background-color: var(--color-indigo-hover); | |
| } | |
| .btn-pink { | |
| background-color: var(--color-pink-strong); | |
| box-shadow: 0 0 10px 0 var(--color-pink-strong); | |
| } | |
| .btn-pink:hover { | |
| background-color: var(--color-pink-hover); | |
| } | |
| .api-output { | |
| margin-top: 1rem; | |
| text-align: center; | |
| background-color: var(--color-bg); | |
| padding: 1rem; | |
| border-radius: 0.375rem; | |
| border: 1px solid oklch(30% 0.02 230); | |
| color: var(--color-text-muted); | |
| height: 3rem; | |
| font-family: monospace; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| /* Styles for Budget Allocator Example */ | |
| /*.budget-labels { | |
| position: absolute; | |
| top: 0; | |
| left: 2rem; | |
| right: 2rem; | |
| bottom: 0; | |
| pointer-events: none; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .budget-label { | |
| position: absolute; | |
| transform: translateX(-50%); | |
| color: white; | |
| text-align: center; | |
| font-size: 0.875rem; | |
| text-shadow: 0 1px 3px rgba(0,0,0,0.5); | |
| line-height: 1.2; | |
| } | |
| .budget-label strong { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| }*/ | |
| /* Styles for Interactive Price Range Example */ | |
| .price-input-container { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| margin-top: 1.5rem; | |
| } | |
| .price-input-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| flex-grow: 1; | |
| } | |
| .price-input-group label { | |
| color: var(--color-text-muted); | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| } | |
| .price-input-group input[type="number"] { | |
| background-color: var(--color-bg); | |
| border: 1px solid oklch(30% 0.02 230); | |
| border-radius: 0.5rem; | |
| padding: 0.5rem 0.75rem; | |
| color: var(--color-text); | |
| font-size: 1rem; | |
| width: 100%; | |
| } | |
| /* Hide the arrows on number inputs for a cleaner look */ | |
| .price-input-group input[type=number]::-webkit-inner-spin-button, | |
| .price-input-group input[type=number]::-webkit-outer-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| .price-input-group input[type=number] { | |
| -moz-appearance: textfield; | |
| } | |
| /* Utility class to hide elements visually but keep them accessible to screen readers */ | |
| .visually-hidden { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border-width: 0; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "files": { | |
| "index.html": { | |
| "position": 0 | |
| }, | |
| "package.json": { | |
| "position": 1, | |
| "hidden": true | |
| }, | |
| "range-group2.ts": { | |
| "position": 2 | |
| }, | |
| "range-group.ts": { | |
| "position": 3 | |
| }, | |
| "style.css": { | |
| "position": 4 | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment