feat: extract room code display into ES module component

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 12:54:30 -04:00
parent 1ed647208e
commit 6b78928269

279
js/room-code-display.js Normal file
View File

@@ -0,0 +1,279 @@
/**
* Room code lobby display: staggered opacity animation for header, footer, and two code lines.
* Implements the overlay component contract: activate, deactivate, update, getStatus.
*/
/**
* @typedef {object} RoomCodeDisplayElements
* @property {HTMLElement} header
* @property {HTMLElement} footer
* @property {HTMLElement} codePart1
* @property {HTMLElement} codePart2
*/
/**
* @typedef {object} RoomCodeDisplayInputs
* @property {HTMLInputElement} code1
* @property {HTMLInputElement} code2
* @property {HTMLInputElement} color1
* @property {HTMLInputElement} color2
* @property {HTMLInputElement} offset1
* @property {HTMLInputElement} offset2
* @property {HTMLInputElement} size
* @property {HTMLInputElement} cycle
* @property {HTMLInputElement} headerText
* @property {HTMLInputElement} headerColor
* @property {HTMLInputElement} headerSize
* @property {HTMLInputElement} headerOffset
* @property {HTMLInputElement} footerText
* @property {HTMLInputElement} footerColor
* @property {HTMLInputElement} footerSize
* @property {HTMLInputElement} footerOffset
* @property {HTMLInputElement} headerAppearDelay
* @property {HTMLInputElement} headerAppearDuration
* @property {HTMLInputElement} headerHideTime
* @property {HTMLInputElement} headerHideDuration
* @property {HTMLInputElement} line1AppearDelay
* @property {HTMLInputElement} line1AppearDuration
* @property {HTMLInputElement} line1HideTime
* @property {HTMLInputElement} line1HideDuration
* @property {HTMLInputElement} line2AppearDelay
* @property {HTMLInputElement} line2AppearDuration
* @property {HTMLInputElement} line2HideTime
* @property {HTMLInputElement} line2HideDuration
*/
export class RoomCodeDisplay {
/** @type {RoomCodeDisplayElements | null} */
#elements = null;
/** @type {RoomCodeDisplayInputs | null} */
#inputs = null;
/** @type {ReturnType<typeof setTimeout>[]} */
#animationTimers = [];
/** @type {boolean} */
#active = false;
/**
* @param {RoomCodeDisplayElements} elements
* @param {RoomCodeDisplayInputs} inputs
*/
init(elements, inputs) {
this.#elements = elements;
this.#inputs = inputs;
}
/**
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
*/
activate(ctx) {
this.deactivate();
const inputs = this.#inputs;
if (!inputs) {
return;
}
this.#active = true;
if (ctx?.roomCode != null && String(ctx.roomCode).trim().length > 0) {
const raw = String(ctx.roomCode).trim().toUpperCase();
const mid = Math.floor(raw.length / 2);
inputs.code1.value = raw.slice(0, mid);
inputs.code2.value = raw.slice(mid);
}
this.#applySettings();
this.#startAnimation();
}
deactivate() {
this.#active = false;
this.#clearAnimationTimers();
const { header, footer, codePart1, codePart2 } = this.#elements ?? {};
if (!header || !footer || !codePart1 || !codePart2) {
return;
}
const fadeTime = '0.3s';
header.style.transition = `opacity ${fadeTime} ease-out`;
footer.style.transition = `opacity ${fadeTime} ease-out`;
codePart1.style.transition = `opacity ${fadeTime} ease-out`;
codePart2.style.transition = `opacity ${fadeTime} ease-out`;
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
}
/**
* @param {{ roomCode?: string, [key: string]: unknown }} ctx
*/
update(ctx) {
if (!this.#inputs) {
return;
}
const next = ctx?.roomCode != null ? String(ctx.roomCode).trim().toUpperCase() : '';
const current =
this.#inputs.code1.value.toUpperCase() + this.#inputs.code2.value.toUpperCase();
if (next !== current) {
this.activate(ctx);
}
}
getStatus() {
const inputs = this.#inputs;
const roomCode = inputs
? (inputs.code1.value + inputs.code2.value).toUpperCase()
: '';
return {
active: this.#active,
roomCode,
timersRunning: this.#animationTimers.length > 0,
};
}
#clearAnimationTimers() {
this.#animationTimers.forEach((id) => clearTimeout(id));
this.#animationTimers = [];
}
#applySettings() {
const el = this.#elements;
const inputs = this.#inputs;
if (!el || !inputs) {
return;
}
const {
header,
footer,
codePart1,
codePart2,
} = el;
codePart1.textContent = inputs.code1.value.toUpperCase();
codePart2.textContent = inputs.code2.value.toUpperCase();
codePart1.style.color = inputs.color1.value;
codePart2.style.color = inputs.color2.value;
codePart1.style.transform = `translateY(${inputs.offset1.value}px)`;
codePart2.style.transform = `translateY(${inputs.offset2.value}px)`;
codePart1.style.fontSize = `${inputs.size.value}px`;
codePart2.style.fontSize = `${inputs.size.value}px`;
header.textContent = inputs.headerText.value;
header.style.color = inputs.headerColor.value;
header.style.fontSize = `${inputs.headerSize.value}px`;
header.style.transform = `translateY(${inputs.headerOffset.value}px)`;
footer.textContent = inputs.footerText.value;
footer.style.color = inputs.footerColor.value;
footer.style.fontSize = `${inputs.footerSize.value}px`;
footer.style.transform = `translateY(${inputs.footerOffset.value}px)`;
}
#startAnimation() {
const el = this.#elements;
const inputs = this.#inputs;
if (!el || !inputs) {
return;
}
const { header, footer, codePart1, codePart2 } = el;
this.#clearAnimationTimers();
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
const sec = (input) => parseInt(input.value, 10) * 1000;
const cycleDuration = sec(inputs.cycle);
const headerAppearDelayMs = sec(inputs.headerAppearDelay);
const headerAppearDurationMs = sec(inputs.headerAppearDuration);
this.#animationTimers.push(
setTimeout(() => {
header.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
header.style.opacity = 1;
footer.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`;
footer.style.opacity = 1;
}, headerAppearDelayMs),
);
const line1AppearDelayMs = sec(inputs.line1AppearDelay);
const line1AppearDurationMs = sec(inputs.line1AppearDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart1.style.transition = `opacity ${line1AppearDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 1;
}, line1AppearDelayMs),
);
const line1HideTimeMs = sec(inputs.line1HideTime);
const line1HideDurationMs = sec(inputs.line1HideDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart1.style.transition = `opacity ${line1HideDurationMs / 1000}s ease-out`;
codePart1.style.opacity = 0;
}, line1HideTimeMs),
);
const line2AppearDelayMs = sec(inputs.line2AppearDelay);
const line2AppearDurationMs = sec(inputs.line2AppearDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart2.style.transition = `opacity ${line2AppearDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 1;
}, line2AppearDelayMs),
);
const line2HideTimeMs = sec(inputs.line2HideTime);
const line2HideDurationMs = sec(inputs.line2HideDuration);
this.#animationTimers.push(
setTimeout(() => {
codePart2.style.transition = `opacity ${line2HideDurationMs / 1000}s ease-out`;
codePart2.style.opacity = 0;
}, line2HideTimeMs),
);
const headerHideTimeMs = sec(inputs.headerHideTime);
const headerHideDurationMs = sec(inputs.headerHideDuration);
this.#animationTimers.push(
setTimeout(() => {
header.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
header.style.opacity = 0;
footer.style.transition = `opacity ${headerHideDurationMs / 1000}s ease-out`;
footer.style.opacity = 0;
}, headerHideTimeMs),
);
this.#animationTimers.push(
setTimeout(() => {
if (this.#active) {
this.#startAnimation();
}
}, cycleDuration),
);
}
}