Skip to content

Instantly share code, notes, and snippets.

@Wigny
Last active September 21, 2026 20:26
Show Gist options
  • Select an option

  • Save Wigny/97cc83ea5179e7b8ab5a044fb14deb9a to your computer and use it in GitHub Desktop.

Select an option

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
<!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