Add a dd-range component for repeated checkboxes that represent something like "1/3"
This commit is contained in:
parent
0972a0491f
commit
c3e7aee501
3 changed files with 209 additions and 0 deletions
|
|
@ -1,9 +1,11 @@
|
||||||
import { DotDungeonIncrementer } from "./incrementer.mjs";
|
import { DotDungeonIncrementer } from "./incrementer.mjs";
|
||||||
import { DotDungeonIcon } from "./icon.mjs";
|
import { DotDungeonIcon } from "./icon.mjs";
|
||||||
|
import { DotDungeonRange } from "./range.mjs";
|
||||||
|
|
||||||
const components = [
|
const components = [
|
||||||
DotDungeonIcon,
|
DotDungeonIcon,
|
||||||
DotDungeonIncrementer,
|
DotDungeonIncrementer,
|
||||||
|
DotDungeonRange,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function registerCustomComponents() {
|
export function registerCustomComponents() {
|
||||||
|
|
|
||||||
139
module/components/range.mjs
Normal file
139
module/components/range.mjs
Normal file
|
|
@ -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 }));
|
||||||
|
};
|
||||||
|
};
|
||||||
68
styles/v3/components/range.scss
Normal file
68
styles/v3/components/range.scss
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue