Files
OwnCord/Client/src/components/StatusPicker.ts
T

314 lines
9.5 KiB
TypeScript
Raw Normal View History

/**
* StatusPicker — compact dropdown for selecting user online status.
* Shows a colored status dot; clicking it opens a floating menu
* with all status options. Intended for use in the UserBar.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
2026-08-01 22:06:14 +02:00
import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface StatusPickerOptions {
readonly currentStatus: UserStatus;
readonly onStatusChange: (status: UserStatus) => void;
2026-08-01 22:06:14 +02:00
/** The custom status line to pre-fill the input with. */
readonly currentCustomStatus?: string;
/** Called when the user commits a custom status (Enter or blur). Passing an
* empty string means "clear it". Omitted = the input is not rendered. */
readonly onCustomStatusChange?: (text: string) => void;
}
export type StatusPickerComponent = MountableComponent & {
/** Update the displayed status without recreating the picker. */
setStatus(status: UserStatus): void;
2026-08-01 22:06:14 +02:00
/** Update the custom status input without recreating the picker. */
setCustomStatus(text: string): void;
};
// ---------------------------------------------------------------------------
// Status definitions
// ---------------------------------------------------------------------------
interface StatusDef {
readonly value: UserStatus;
readonly label: string;
readonly color: string;
}
2026-08-01 22:06:14 +02:00
/**
* "invisible" is its own value now, not "offline" wearing a different label.
* The server stores it as chosen and shows everyone else offline, so the
* picker can finally send what it means — and the status survives a reconnect
* instead of flashing back to online.
*/
const STATUS_DEFS: readonly StatusDef[] = [
{ value: "online", label: "Online", color: "#3ba55d" },
{ value: "idle", label: "Idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", color: "#ed4245" },
2026-08-01 22:06:14 +02:00
{ value: "invisible", label: "Invisible", color: "#747f8d" },
];
function colorForStatus(status: UserStatus): string {
return STATUS_DEFS.find((d) => d.value === status)?.color ?? "#747f8d";
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
export function createStatusPicker(options: StatusPickerOptions): StatusPickerComponent {
const ac = new AbortController();
const { signal } = ac;
let currentStatus: UserStatus = options.currentStatus;
let root: HTMLDivElement | null = null;
let dotEl: HTMLDivElement | null = null;
let dropdownEl: HTMLDivElement | null = null;
let checkEls = new Map<UserStatus, HTMLSpanElement>();
2026-08-01 22:06:14 +02:00
let customInputEl: HTMLInputElement | null = null;
/** Last text handed to the callback. Guards the blur-after-Enter double
* send, which would otherwise cost a second presence_update against the
* server's one-per-ten-seconds limit. */
let lastCommittedCustom = options.currentCustomStatus ?? "";
// ---- Dropdown visibility --------------------------------------------------
function isOpen(): boolean {
return dropdownEl?.classList.contains("status-picker-dropdown--open") === true;
}
function openDropdown(): void {
dropdownEl?.classList.add("status-picker-dropdown--open");
dotEl?.setAttribute("aria-expanded", "true");
}
function closeDropdown(): void {
dropdownEl?.classList.remove("status-picker-dropdown--open");
dotEl?.setAttribute("aria-expanded", "false");
}
function toggleDropdown(): void {
if (isOpen()) {
closeDropdown();
} else {
openDropdown();
}
}
// ---- Status update --------------------------------------------------------
function applyStatus(status: UserStatus): void {
currentStatus = status;
// Update trigger dot color
if (dotEl !== null) {
dotEl.style.background = colorForStatus(status);
}
// Update checkmarks
for (const [value, el] of checkEls) {
el.style.display = value === status ? "" : "none";
}
}
// ---- Build DOM ------------------------------------------------------------
function buildOption(def: StatusDef): HTMLDivElement {
const row = createElement("div", {
class: "status-picker-option",
role: "menuitem",
tabindex: "0",
});
const optDot = createElement("span", { class: "status-picker-option-dot" });
optDot.style.background = def.color;
const label = createElement("span", { class: "status-picker-option-label" }, def.label);
const check = createElement("span", { class: "status-picker-option-check" });
check.style.display = def.value === currentStatus ? "" : "none";
const checkIcon = createIcon("check", 16);
check.appendChild(checkIcon);
checkEls.set(def.value, check);
appendChildren(row, optDot, label, check);
row.addEventListener(
"click",
() => {
applyStatus(def.value);
closeDropdown();
options.onStatusChange(def.value);
},
{ signal },
);
row.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
row.click();
}
},
{ signal },
);
return row;
}
2026-08-01 22:06:14 +02:00
/**
* The "Set a custom status" row. Only built when a handler was supplied —
* an input whose value goes nowhere is worse than no input.
*/
function buildCustomStatusRow(onChange: (text: string) => void): HTMLDivElement {
const row = createElement("div", { class: "status-picker-custom" });
const input = createElement("input", {
class: "status-picker-custom-input",
type: "text",
placeholder: "Set a custom status",
maxlength: String(MAX_CUSTOM_STATUS_LEN),
"aria-label": "Custom status",
"data-testid": "custom-status-input",
});
input.value = options.currentCustomStatus ?? "";
customInputEl = input;
const commit = (): void => {
const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);
if (text === lastCommittedCustom) return;
lastCommittedCustom = text;
input.value = text;
onChange(text);
};
input.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Keystrokes inside the input must not reach the dropdown's own
// Enter/Escape handling, which would close the menu mid-edit.
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
commit();
closeDropdown();
} else if (e.key === "Escape") {
e.preventDefault();
input.value = lastCommittedCustom;
closeDropdown();
}
},
{ signal },
);
input.addEventListener("blur", commit, { signal });
// The row is inside the dropdown; clicking the input must not be treated
// as picking a status or as an outside click.
input.addEventListener("click", (e: MouseEvent) => e.stopPropagation(), { signal });
row.appendChild(input);
return row;
}
// ---- MountableComponent ---------------------------------------------------
function mount(container: Element): void {
root = createElement("div", { class: "status-picker" });
// Trigger dot
dotEl = createElement("div", {
class: "status-picker-dot",
role: "button",
tabindex: "0",
"aria-label": "Change status",
"aria-haspopup": "true",
"aria-expanded": "false",
});
dotEl.style.background = colorForStatus(currentStatus);
dotEl.addEventListener(
"click",
(e: MouseEvent) => {
e.stopPropagation();
toggleDropdown();
},
{ signal },
);
dotEl.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleDropdown();
}
},
{ signal },
);
// Dropdown menu
dropdownEl = createElement("div", {
class: "status-picker-dropdown",
role: "menu",
});
for (const def of STATUS_DEFS) {
dropdownEl.appendChild(buildOption(def));
}
2026-08-01 22:06:14 +02:00
const onCustomStatusChange = options.onCustomStatusChange;
if (onCustomStatusChange !== undefined) {
dropdownEl.appendChild(createElement("div", { class: "status-picker-divider" }));
dropdownEl.appendChild(buildCustomStatusRow(onCustomStatusChange));
}
appendChildren(root, dotEl, dropdownEl);
container.appendChild(root);
// Close on outside click
document.addEventListener(
"click",
(e: MouseEvent) => {
if (isOpen() && root !== null && !root.contains(e.target as Node)) {
closeDropdown();
}
},
{ signal },
);
// Close on Escape
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen()) {
closeDropdown();
dotEl?.focus();
}
},
{ signal },
);
}
function destroy(): void {
ac.abort();
checkEls = new Map();
root?.remove();
root = null;
dotEl = null;
dropdownEl = null;
2026-08-01 22:06:14 +02:00
customInputEl = null;
}
function setStatus(status: UserStatus): void {
applyStatus(status);
}
2026-08-01 22:06:14 +02:00
function setCustomStatus(text: string): void {
lastCommittedCustom = text;
if (customInputEl !== null) customInputEl.value = text;
}
return { mount, destroy, setStatus, setCustomStatus };
}