Last active
September 21, 2026 20:26
-
-
Save Wigny/97cc83ea5179e7b8ab5a044fb14deb9a to your computer and use it in GitHub Desktop.
duration-input: form-associated custom element mapping ISO 8601 durations to a readable text field
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> | |
| <head> | |
| <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> | |
| <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"> | |
| </script> | |
| <script type="module"> | |
| const SEGMENT_MILLISECONDS = { | |
| hours: 3_600_000, | |
| minutes: 60_000, | |
| seconds: 1000 | |
| }; | |
| const SEGMENT_DESIGNATORS = { hours: "H", minutes: "M", seconds: "S" }; | |
| const FRACTIONAL_SEGMENT = "seconds"; | |
| const FRACTION_DIGITS = 3; | |
| const SEGMENT_DIGITS = 2; | |
| // A duration is not a clock reading: 90 minutes is as valid as 1 hour 30, so the only | |
| // bound on a segment is the width of its box. | |
| const SEGMENT_MAXIMUM = 10 ** SEGMENT_DIGITS - 1; | |
| const ISO_DURATION = /^PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/; | |
| const WHOLE_UNITS = /^\d*$/; | |
| const FRACTIONAL_UNITS = /^\d*(?:\.\d*)?$/; | |
| /** Pads the whole units to two digits, leaving any fraction of a second alone. */ | |
| const padUnits = (value) => { | |
| const [units = "", fraction] = value.split("."); | |
| return [units.padStart(SEGMENT_DIGITS, "0"), fraction].filter(Boolean).join("."); | |
| }; | |
| /** | |
| * A form-associated web component that edits a duration as one box per unit, in the | |
| * spirit of the browser's own `<input type="time">`. | |
| * | |
| * The segments are declared by the markup: one `<input data-segment="...">` child per | |
| * unit, in the order they are displayed. The component submits an ISO 8601 duration | |
| * under its own `name`, so the server never has to guess which unit a bare number or a | |
| * colon stood for. The `value` attribute is an ISO 8601 duration. | |
| * | |
| * The `seconds` segment takes a fractional part, since ISO 8601 carries sub-second | |
| * precision on the seconds term itself: typing `30.5` there submits `30.5S`. | |
| * | |
| * @example | |
| * <duration-input name="duration" value="PT1H30M"> | |
| * <input data-segment="hours" aria-label="Hours" /> | |
| * <span aria-hidden="true">:</span> | |
| * <input data-segment="minutes" aria-label="Minutes" /> | |
| * </duration-input> | |
| */ | |
| class DurationInput extends HTMLElement { | |
| static formAssociated = true; | |
| #internals; | |
| #segments = []; | |
| constructor() { | |
| super(); | |
| this.#internals = this.attachInternals(); | |
| } | |
| connectedCallback() { | |
| if (this.#initialize()) return; | |
| queueMicrotask(() => { | |
| if (this.#initialize()) return; | |
| throw new Error("duration-input requires at least one <input data-segment> child"); | |
| }); | |
| } | |
| /** Takes up the segment children, which a parser may not have reached yet on connect. */ | |
| #initialize() { | |
| if (this.#segments.length) return true; | |
| const segments = this.#collectSegments(); | |
| if (!segments.length) return false; | |
| this.#segments = segments; | |
| this.addEventListener("click", this.#handleClick); | |
| this.addEventListener("beforeinput", this.#handleBeforeInput); | |
| this.addEventListener("input", this.#handleInput); | |
| this.addEventListener("change", this.#handleChange); | |
| this.addEventListener("keydown", this.#handleKeydown); | |
| this.addEventListener("focusout", this.#handleFocusOut); | |
| this.value = this.getAttribute("value") ?? ""; | |
| return true; | |
| } | |
| /** The edited duration as an ISO 8601 string, or `""` while a segment is blank. */ | |
| get value() { | |
| if (!this.#segments.length) return ""; | |
| if (this.#segments.some(({ field }) => field.value === "")) return ""; | |
| const parts = this.#segments.map( | |
| ({ name, field }) => `${Number(field.value)}${SEGMENT_DESIGNATORS[name]}` | |
| ); | |
| return `PT${parts.join("")}`; | |
| } | |
| set value(value) { | |
| const totalMilliseconds = this.#parse(value); | |
| if (totalMilliseconds === null) { | |
| for (const { field } of this.#segments) field.value = ""; | |
| } else { | |
| this.#segments.reduce((remainder, { field, milliseconds, fractional }) => { | |
| const units = fractional ? remainder / milliseconds : Math.floor(remainder / milliseconds); | |
| field.value = padUnits(String(units)); | |
| return fractional ? 0 : remainder % milliseconds; | |
| }, totalMilliseconds); | |
| } | |
| this.#commit(); | |
| } | |
| get name() { | |
| return this.getAttribute("name") ?? ""; | |
| } | |
| get required() { | |
| return this.hasAttribute("required"); | |
| } | |
| get form() { | |
| return this.#internals.form; | |
| } | |
| get labels() { | |
| return this.#internals.labels; | |
| } | |
| get validity() { | |
| return this.#internals.validity; | |
| } | |
| get validationMessage() { | |
| return this.#internals.validationMessage; | |
| } | |
| checkValidity() { | |
| return this.#internals.checkValidity(); | |
| } | |
| reportValidity() { | |
| return this.#internals.reportValidity(); | |
| } | |
| formResetCallback() { | |
| this.value = this.getAttribute("value") ?? ""; | |
| } | |
| formStateRestoreCallback(state) { | |
| this.value = state; | |
| } | |
| formDisabledCallback(disabled) { | |
| for (const { field } of this.#segments) field.disabled = disabled; | |
| } | |
| #collectSegments() { | |
| const fields = this.querySelectorAll("input[data-segment]"); | |
| return Array.from(fields, (field) => { | |
| const name = field.dataset.segment; | |
| if (!Object.hasOwn(SEGMENT_MILLISECONDS, name)) { | |
| throw new Error(`duration-input got an unknown segment: ${field.dataset.segment}`); | |
| } | |
| const fractional = name === FRACTIONAL_SEGMENT; | |
| const digits = fractional ? SEGMENT_DIGITS + FRACTION_DIGITS + 1 : SEGMENT_DIGITS; | |
| field.inputMode = fractional ? "decimal" : "numeric"; | |
| field.maxLength = digits; | |
| field.autocomplete = "off"; | |
| return { name, field, fractional, milliseconds: SEGMENT_MILLISECONDS[name] }; | |
| }); | |
| } | |
| #handleClick = (event) => { | |
| if (this.#indexOf(event.target) >= 0) return; | |
| this.#focus(0); | |
| }; | |
| #handleBeforeInput = (event) => { | |
| const segment = this.#segments[this.#indexOf(event.target)]; | |
| if (!segment) return; | |
| // A keystroke carries its text on `data`, a paste or a drop on the data transfer. | |
| const inserted = event.data || event.dataTransfer?.getData("text") || ""; | |
| if (inserted === "") return; | |
| const { field, fractional } = segment; | |
| const shape = fractional ? FRACTIONAL_UNITS : WHOLE_UNITS; | |
| const secondPoint = inserted.includes(".") && field.value.includes("."); | |
| if (!shape.test(inserted) || secondPoint) event.preventDefault(); | |
| }; | |
| #handleInput = (event) => { | |
| const index = this.#indexOf(event.target); | |
| const segment = this.#segments[index]; | |
| if (!segment) return; | |
| event.stopPropagation(); | |
| const { field, fractional } = segment; | |
| const [units = "", fraction] = field.value.split("."); | |
| if (Number(units) > SEGMENT_MAXIMUM) { | |
| field.value = [String(SEGMENT_MAXIMUM), fraction].filter(Boolean).join("."); | |
| } | |
| this.#commit(); | |
| this.#emit(); | |
| if (!fractional && units.length === SEGMENT_DIGITS) this.#leave(index, 1); | |
| }; | |
| #handleChange = (event) => { | |
| if (this.#indexOf(event.target) < 0) return; | |
| event.stopPropagation(); | |
| }; | |
| #handleKeydown = (event) => { | |
| const index = this.#indexOf(event.target); | |
| const segment = this.#segments[index]; | |
| if (!segment) return; | |
| const { field } = segment; | |
| const atStart = field.selectionStart === 0 && field.selectionEnd === 0; | |
| const atEnd = | |
| field.selectionStart === field.value.length && field.selectionEnd === field.value.length; | |
| switch (event.key) { | |
| case "ArrowUp": | |
| case "ArrowDown": { | |
| const step = event.key === "ArrowUp" ? 1 : -1; | |
| const [units = "", fraction] = field.value.split("."); | |
| const wrap = SEGMENT_MAXIMUM + 1; | |
| const next = units === "" ? (step > 0 ? 0 : SEGMENT_MAXIMUM) : (Number(units) + step + wrap) % wrap; | |
| field.value = padUnits([next, fraction].filter((part) => part !== undefined).join(".")); | |
| this.#commit(); | |
| this.#emit(); | |
| break; | |
| } | |
| case "ArrowLeft": | |
| if (!atStart) return; | |
| this.#leave(index, -1); | |
| break; | |
| case "ArrowRight": | |
| if (!atEnd) return; | |
| this.#leave(index, 1); | |
| break; | |
| case "Backspace": | |
| if (field.value !== "" && !atStart) return; | |
| this.#leave(index, -1); | |
| break; | |
| default: | |
| return; | |
| } | |
| event.preventDefault(); | |
| }; | |
| #handleFocusOut = (event) => { | |
| const index = this.#indexOf(event.target); | |
| if (index < 0) return; | |
| this.#pad(index); | |
| }; | |
| #indexOf(target) { | |
| return this.#segments.findIndex(({ field }) => field === target); | |
| } | |
| #focus(index) { | |
| const segment = this.#segments[index]; | |
| if (!segment) return; | |
| segment.field.focus(); | |
| segment.field.select(); | |
| } | |
| #leave(index, direction) { | |
| this.#pad(index); | |
| this.#focus(index + direction); | |
| } | |
| #pad(index) { | |
| const field = this.#segments[index]?.field; | |
| if (!field || field.value === "") return; | |
| field.value = padUnits(field.value); | |
| } | |
| #emit() { | |
| this.dispatchEvent(new Event("input", { bubbles: true })); | |
| this.dispatchEvent(new Event("change", { bubbles: true })); | |
| } | |
| #commit() { | |
| const blank = this.#segments.filter(({ field }) => field.value === ""); | |
| const anchor = (blank[0] ?? this.#segments[0])?.field; | |
| this.#internals.setFormValue(this.value); | |
| if (blank.length === this.#segments.length) { | |
| if (this.required) { | |
| this.#internals.setValidity({ valueMissing: true }, "Please enter a duration.", anchor); | |
| } else { | |
| this.#internals.setValidity({}); | |
| } | |
| } else if (blank.length) { | |
| const units = this.#segments.map(({ name }) => name).join(" and "); | |
| this.#internals.setValidity({ badInput: true }, `Please enter ${units}.`, anchor); | |
| } else { | |
| this.#internals.setValidity({}); | |
| } | |
| } | |
| #parse(value) { | |
| const text = String(value ?? "").trim(); | |
| const iso = text.match(ISO_DURATION); | |
| if (iso) { | |
| const [hours = 0, minutes = 0, seconds = 0] = iso.slice(1).map((part) => Number(part ?? 0)); | |
| return Math.round( | |
| hours * SEGMENT_MILLISECONDS.hours + | |
| minutes * SEGMENT_MILLISECONDS.minutes + | |
| seconds * SEGMENT_MILLISECONDS.seconds | |
| ); | |
| } | |
| return null; | |
| } | |
| } | |
| customElements.define("duration-input", DurationInput); | |
| document.getElementById("form").addEventListener("submit", (event) => { | |
| event.preventDefault(); | |
| alert(new FormData(event.target).get("duration")); | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <main class="mx-auto max-w-2xl p-8"> | |
| <form id="form" class="space-y-4"> | |
| <fieldset class="fieldset"> | |
| <legend class="fieldset-legend">Duration</legend> | |
| <duration-input name="duration" class="input w-fit gap-0" value="PT5M30S" required> | |
| <input data-segment="minutes" class="field-sizing-content w-auto min-w-[2ch] text-center" placeholder="mm" aria-label="Minutes" /> | |
| <span class="px-0.5" aria-hidden="true">:</span> | |
| <input data-segment="seconds" class="field-sizing-content w-auto min-w-[2ch] text-center" placeholder="ss" aria-label="Seconds" /> | |
| </duration-input> | |
| </fieldset> | |
| <button type="submit" class="btn">Submit</button> | |
| </form> | |
| </main> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment