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

235 lines
8.3 KiB
TypeScript
Raw Normal View History

/**
2026-08-01 22:06:14 +02:00
* CreateChannelModal — modal for creating a new channel.
*
* The category is an editable text field pre-filled with the group the "+" was
* clicked on, backed by a <datalist> of the categories already in use. It used
* to be read-only, and the channel TYPE was inferred from the category name
* ("voice" anywhere in it meant voice-only), which made every other category
* name second-class: a voice channel could not live under "Gaming", and
* renaming a category silently changed what could be created there. Categories
* are free text and grouping is a display concern, so every type is offered
* under every category — the server agrees (it validates the type alone).
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { ChannelType } from "@lib/types";
2026-08-01 22:06:14 +02:00
import { getKnownCategories, UNCATEGORIZED_VOICE_CATEGORY } from "@stores/channels.store";
export interface CreateChannelModalOptions {
2026-08-01 22:06:14 +02:00
/** The category the create affordance was invoked from ("" = uncategorized). */
readonly category: string;
/** Called when the user submits the form. */
readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise<void>;
/** Called when the modal is closed without creating. */
readonly onClose: () => void;
}
2026-08-01 22:06:14 +02:00
/** Every channel type is creatable under every category. */
export const CHANNEL_TYPES: readonly ChannelType[] = ["text", "voice", "announcement"] as const;
2026-08-01 22:06:14 +02:00
/**
* The type pre-selected for a category. Only a hint for the dropdown's initial
* value — every type stays selectable. The one case worth guessing is the
* synthetic "Voice" fallback group the sidebar puts uncategorized voice
* channels in: creating from its "+" almost certainly means another voice
* channel.
*/
export function defaultTypeForCategory(category: string): ChannelType {
return category === UNCATEGORIZED_VOICE_CATEGORY ? "voice" : "text";
}
export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent {
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
class: "modal-overlay visible",
"data-testid": "create-channel-modal",
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "create-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", { id: "create-channel-title" }, "Create Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
appendChildren(header, title, closeBtn);
// Body
const body = createElement("div", { class: "modal-body" });
2026-08-01 22:06:14 +02:00
// Category — free text, with the categories already in use as suggestions.
const categoryGroup = createElement("div", { class: "form-group" });
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
2026-08-01 22:06:14 +02:00
const categoryInput = createElement("input", {
class: "form-input",
2026-08-01 22:06:14 +02:00
type: "text",
list: "create-channel-categories",
autocomplete: "off",
placeholder: "Leave blank for no category",
"data-testid": "channel-category-input",
});
2026-08-01 22:06:14 +02:00
categoryInput.value = category;
const categoryList = createElement("datalist", { id: "create-channel-categories" });
for (const known of getKnownCategories()) {
categoryList.appendChild(createElement("option", { value: known }));
}
appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList);
// Channel name
const nameGroup = createElement("div", { class: "form-group" });
const nameLabel = createElement("label", { class: "form-label" }, "Name");
const nameInput = createElement("input", {
class: "form-input",
type: "text",
2026-08-01 22:06:14 +02:00
placeholder: defaultTypeForCategory(category) === "voice" ? "lounge" : "general",
"data-testid": "channel-name-input",
});
appendChildren(nameGroup, nameLabel, nameInput);
// Channel type
const typeGroup = createElement("div", { class: "form-group" });
const typeLabel = createElement("label", { class: "form-label" }, "Type");
const typeSelect = createElement("select", {
class: "form-input",
"data-testid": "channel-type-select",
});
2026-08-01 22:06:14 +02:00
for (const t of CHANNEL_TYPES) {
const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1));
typeSelect.appendChild(opt);
}
2026-08-01 22:06:14 +02:00
typeSelect.value = defaultTypeForCategory(category);
appendChildren(typeGroup, typeLabel, typeSelect);
// Error display
const errorEl = createElement("div", {
class: "form-group",
style: "color: var(--red); font-size: 13px; display: none;",
"data-testid": "channel-create-error",
});
appendChildren(body, categoryGroup, nameGroup, typeGroup, errorEl);
// Footer
const footer = createElement("div", { class: "modal-footer" });
const cancelBtn = createElement(
"button",
{ class: "btn-modal-cancel", type: "button" },
"Cancel",
);
cancelBtn.addEventListener("click", onClose, { signal: ac.signal });
const createBtn = createElement(
"button",
{
class: "btn-modal-save",
type: "button",
"data-testid": "channel-create-submit",
},
"Create Channel",
);
createBtn.addEventListener(
"click",
async () => {
const name = nameInput.value.trim();
if (name === "") {
errorEl.style.display = "block";
setText(errorEl, "Channel name is required");
nameInput.classList.add("error");
return;
}
// Clear previous errors
errorEl.style.display = "none";
nameInput.classList.remove("error");
createBtn.setAttribute("disabled", "true");
setText(createBtn, "Creating...");
try {
await onCreate({
name,
type: typeSelect.value as ChannelType,
2026-08-01 22:06:14 +02:00
category: categoryInput.value.trim(),
});
} catch (err) {
errorEl.style.display = "block";
setText(errorEl, err instanceof Error ? err.message : "Failed to create channel");
createBtn.removeAttribute("disabled");
setText(createBtn, "Create Channel");
}
},
{ signal: ac.signal },
);
appendChildren(footer, cancelBtn, createBtn);
appendChildren(modal, header, body, footer);
overlay.appendChild(modal);
// Close on backdrop click
overlay.addEventListener(
"click",
(e) => {
if (e.target === overlay) {
onClose();
}
},
{ signal: ac.signal },
);
// Escape cancels — never creates. Document-level so it works wherever
// focus sits; guarded on the overlay still being attached because the
// listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
// Focus the name input
nameInput.focus();
}
function destroy(): void {
ac.abort();
if (overlay !== null) {
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
}