From c3e7aee501eaa833b4f9ebec2838c1a272142ccc Mon Sep 17 00:00:00 2001 From: Oliver-Akins Date: Sun, 5 May 2024 16:40:59 -0600 Subject: [PATCH] Add a dd-range component for repeated checkboxes that represent something like "1/3" --- module/components/index.mjs | 2 + module/components/range.mjs | 139 ++++++++++++++++++++++++++++++++ styles/v3/components/range.scss | 68 ++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 module/components/range.mjs create mode 100644 styles/v3/components/range.scss diff --git a/module/components/index.mjs b/module/components/index.mjs index f4d39e9..7d870b0 100644 --- a/module/components/index.mjs +++ b/module/components/index.mjs @@ -1,9 +1,11 @@ import { DotDungeonIncrementer } from "./incrementer.mjs"; import { DotDungeonIcon } from "./icon.mjs"; +import { DotDungeonRange } from "./range.mjs"; const components = [ DotDungeonIcon, DotDungeonIncrementer, + DotDungeonRange, ]; export function registerCustomComponents() { diff --git a/module/components/range.mjs b/module/components/range.mjs new file mode 100644 index 0000000..db286fe --- /dev/null +++ b/module/components/range.mjs @@ -0,0 +1,139 @@ +import { DotDungeonIcon } from "./icon.mjs"; +import { StyledShadowElement } from "./mixins/Styles.mjs"; + +/** +Attributes: +@property {string} name - The path to the value to update in the datamodel +@property {number} value - The actual value of the input +@property {number} max - The maximum value that this range has + +@extends {HTMLElement} +*/ +export class DotDungeonRange +extends StyledShadowElement( + HTMLElement, + { mode: `open`, delegatesFocus: true } +) { + static elementName = `dd-range`; + static formAssociated = true; + + static observedAttributes = [`max`]; + + static _stylePath = `v3/components/range.css`; + + _internals; + #input; + + constructor() { + super(); + + // Form internals + this._internals = this.attachInternals(); + this._internals.role = `spinbutton`; + }; + + get form() { + return this._internals.form; + }; + + get name() { + return this.getAttribute(`name`); + }; + set name(value) { + this.setAttribute(`name`, value); + }; + + get value() { + try { + return parseInt(this.getAttribute(`value`)); + } catch { + throw new Error(`Failed to parse attribute: "value" - Make sure it's an integer`); + }; + }; + set value(value) { + this.setAttribute(`value`, value); + }; + + get max() { + try { + return parseInt(this.getAttribute(`max`)); + } catch { + throw new Error(`Failed to parse attribute: "max" - Make sure it's an integer`); + }; + }; + set max(value) { + this.setAttribute(`max`, value); + }; + + get type() { + return `number`; + }; + + connectedCallback() { + super.connectedCallback(); + + // Attribute validation + if (!this.hasAttribute(`max`)) { + throw new Error(`dotdungeon | Cannot have a range without a maximum value`); + }; + + // Keyboard accessible input for the thing + this.#input = document.createElement(`input`); + this.#input.type = `number`; + this.#input.min = 0; + this.#input.max = this.max; + this.#input.value = this.value; + this.#input.addEventListener(`change`, () => { + const inputValue = parseInt(this.#input.value); + if (inputValue === this.value) return; + this._updateValue.bind(this)(Math.sign(this.value - inputValue)); + this._updateValue(Math.sign(this.value - inputValue)); + }); + this._shadow.appendChild(this.#input); + + // Shadow-DOM construction + this._elements = new Array(this.max); + const container = document.createElement(`div`); + container.classList.add(`container`); + + // Creating the node for filled content + const filledContainer = document.createElement(`div`); + filledContainer.classList.add(`range-increment`, `filled`); + const filledNode = this.querySelector(`[slot="filled"]`); + if (filledNode) filledContainer.appendChild(filledNode); + + const emptyContainer = document.createElement(`div`); + emptyContainer.classList.add(`range-increment`, `empty`); + const emptyNode = this.querySelector(`[slot="empty"]`); + if (emptyNode) emptyContainer.appendChild(emptyNode); + + this._elements.fill(filledContainer, 0, this.value); + this._elements.fill(emptyContainer, this.value); + container.append(...this._elements.map((slot, i) => { + const node = slot.cloneNode(true); + node.setAttribute(`data-index`, i + 1); + node.addEventListener(`click`, () => { + const filled = node.classList.contains(`filled`); + this._updateValue(filled ? -1 : 1); + }); + return node; + })); + this._shadow.appendChild(container); + + /* + This converts all of the namespace prefixed properties on the element to + CSS variables so that they don't all need to be provided by doing style="" + */ + for (const attrVar of this.attributes) { + if (attrVar.name?.startsWith(`var:`)) { + const prop = attrVar.name.replace(`var:`, ``); + this.style.setProperty(`--` + prop, attrVar.value); + }; + }; + }; + + _updateValue(delta) { + this.value += delta; + this.dispatchEvent(new Event(`change`, { bubbles: true })); + }; +}; diff --git a/styles/v3/components/range.scss b/styles/v3/components/range.scss new file mode 100644 index 0000000..15ea6d2 --- /dev/null +++ b/styles/v3/components/range.scss @@ -0,0 +1,68 @@ +/* +Disclaimer: This CSS is used by a custom web component and is scoped to JUST +the corresponding web component. Importing this into other files is forbidden +*/ + +@use "../mixins/material"; +@use "./common.scss"; + +.container { + @include material.elevate(4); + display: grid; + flex-direction: row; + gap: 4px; + border-radius: 4px; + grid-template-columns: minmax(0, 1fr); + grid-auto-columns: minmax(0, 1fr); + grid-template-rows: 1fr; + padding: 4px; + box-sizing: border-box; +} + +input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; + + &:focus ~ .container { + @include material.elevate(8) + } +} + +.range-increment { + grid-row: 1; + + &:empty { + @include material.elevate(4); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border-radius: 2px; + margin: 0; + cursor: pointer; + width: 1.25rem; + height: 1.25rem; + position: relative; + + &:hover { + @include material.elevate(8); + } + + &.filled::before { + content: ""; + background: var(--checkbox-checked); + border-radius: 3px; + $margin: 4px; + top: $margin; + bottom: $margin; + left: $margin; + right: $margin; + position: absolute; + } + } +}