From ac70b2df37b5649db5d85533f7660dca9233e33d Mon Sep 17 00:00:00 2001
From: bananarama92 <bananarama921@outlook.com>
Date: Sun, 16 Feb 2025 23:01:06 +0100
Subject: [PATCH 1/2] ENH: Add API for creating a DOM-based dialog focus grid

---
 BondageClub/CSS/dialog.css    | 37 +++++++++++++++++
 BondageClub/Scripts/Dialog.js | 75 +++++++++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+)

diff --git a/BondageClub/CSS/dialog.css b/BondageClub/CSS/dialog.css
index 55b682164f..59b36a1643 100644
--- a/BondageClub/CSS/dialog.css
+++ b/BondageClub/CSS/dialog.css
@@ -320,6 +320,31 @@
 	padding-inline: 0.15em 0.15em;
 }
 
+.dialog-focus-grid [role="radiogroup"] {
+	position: relative;
+	width: 100%;
+	height: 100%;
+	border: min(0.3vh, 0.15vw) solid #80808040;
+}
+
+.dialog-focus-grid [role="radio"] {
+	position: absolute;
+	border: min(0.3vh, 0.15vw) solid #80808040;
+}
+
+.dialog-focus-grid [role="radio"][data-blocked] {
+	border-color: #88000580;
+}
+
+.dialog-focus-grid [role="radio"][data-equipped] {
+	border-color: #D5A30080;
+}
+
+.dialog-focus-grid [role="radio"][aria-checked="true"] {
+	border-width: min(0.5vh, 0.25vw);
+	border-color: cyan;
+}
+
 @supports(height: 100dvh) {
 	.dialog-root {
 		--menu-button-size: min(9dvh, 4.5dvw);
@@ -334,6 +359,18 @@
 	.dialog-grid-button > .button-tooltip {
 		width: calc(400% + 4 * min(0.2dvh, 0.1dvw) + 3 * var(--gap));
 	}
+
+	.dialog-focus-grid [role="radiogroup"] {
+		border-width: min(0.3dvh, 0.15dvw);
+	}
+
+	.dialog-focus-grid [role="radio"] {
+		border-width: min(0.3dvh, 0.15dvw);
+	}
+
+	.dialog-focus-grid [role="radio"][aria-checked="true"] {
+		border-width: min(0.5dvh, 0.25dvw);
+	}
 }
 
 @supports (background-color: color-mix(in srgb, black 50%, transparent)) {
diff --git a/BondageClub/Scripts/Dialog.js b/BondageClub/Scripts/Dialog.js
index 6b3cbb3eef..33b3ce21cc 100644
--- a/BondageClub/Scripts/Dialog.js
+++ b/BondageClub/Scripts/Dialog.js
@@ -4481,6 +4481,81 @@ function DialogDrawTopMenu(C) {
 	}
 }
 
+var DialogFocusGroup = {
+	/**
+	 *
+	 * @param {string} id - The ID for the to-be created focus group grid
+	 * @param {(this: HTMLButtonElement, ev: MouseEvent) => any} listener - The listener to-be executed upon selecting a group; the group name can be retrieved from `this.name`
+	 * @param {null | { required?: boolean, useDynamicGroupName?: boolean }} options - Further options for the to-be created focus group grid
+	 * @returns {HTMLElement} - The created element
+	 */
+	Create(id, listener, options=null) {
+		options ??= {};
+		const root = document.getElementById(id);
+		if (root) {
+			console.error(`Element "${id}" already exists`);
+			return root;
+		}
+
+		let top = Infinity;
+		let bottom = 0;
+		let left = Infinity;
+		let right = 0;
+
+		/** @type {{ group: AssetGroup, index: number, zone: RectTuple }[]} */
+		const grid = [];
+		for (const group of AssetGroup) {
+			for (const [index, zone] of (group.Zone ?? []).entries()) {
+				grid.push({ group, index, zone});
+				left = Math.min(left, zone[0]);
+				right = Math.max(right, zone[0] + zone[2]);
+				top = Math.min(top, zone[1]);
+				bottom = Math.max(bottom, zone[1] + zone[3]);
+			}
+		}
+		grid.sort((a, b) => (a.zone[1] - b.zone[1]) || (a.zone[0] - b.zone[0]));
+
+		const width = right - left;
+		const height = bottom - top;
+
+		const children = grid.map(({ group, index, zone }, i) => ElementButton.Create(
+			`${id}-${group.Name}-${index}`,
+			listener,
+			{ noStyling: true, role: "radio" },
+			{ button: {
+				attributes: {
+					name: options.useDynamicGroupName ? group.DynamicGroupName : group.Name,
+					tabindex: i === 0 ? 0 : -1,
+					"aria-hidden": index !== 0 ? "true" : undefined,
+					"aria-label": group.Description,
+				},
+				style: {
+					left: `${100 * (zone[0] - left) / width}%`,
+					top: `${100 * (zone[1] - top) / height}%`,
+					width: `${100 * (zone[2] / width)}%`,
+					height: `${100 * (zone[3] / height)}%`,
+				},
+			}},
+		));
+
+		return ElementCreate({
+			tag: "div",
+			attributes: { id },
+			classList: ["dialog-focus-grid"],
+			children: [{
+				tag: "div",
+				children,
+				attributes: {
+					id: `${id}-radiogroup`,
+					role: "radiogroup",
+					"aria-required": options.required ? "true" : "false",
+					"aria-label": "Select focus group",
+				},
+			}],
+		});
+	},
+};
+
 /**
  * Draws the left menu for the character
  * @param {Character} C - The currently focused character

From d66c818d7e0a583e1a3289e65aab1d1b8148cab1 Mon Sep 17 00:00:00 2001
From: bananarama92 <bananarama921@outlook.com>
Date: Thu, 27 Mar 2025 17:55:47 +0100
Subject: [PATCH 2/2] ENH: Add a focus grid for filtering crafting items by
 group

---
 BondageClub/CSS/Crafting.css                  |   5 +
 BondageClub/Screens/Room/Crafting/Crafting.js | 173 +++++++++++++-----
 .../Screens/Room/Crafting/Text_Crafting.csv   |   2 +
 3 files changed, 138 insertions(+), 42 deletions(-)

diff --git a/BondageClub/CSS/Crafting.css b/BondageClub/CSS/Crafting.css
index 34be9db9d7..8767add8c5 100644
--- a/BondageClub/CSS/Crafting.css
+++ b/BondageClub/CSS/Crafting.css
@@ -132,6 +132,11 @@
 	height: calc(2.5 * var(--button-size));
 }
 
+#crafting-asset-grid button[data-unload]:not([aria-checked="true"]),
+#crafting-asset-grid button[data-unload-group]:not([aria-checked="true"]) {
+	display: none;
+}
+
 #crafting-property-grid button {
 	width: calc(5.7 * var(--button-size));
 	height: calc(1.5 * var(--button-size));
diff --git a/BondageClub/Screens/Room/Crafting/Crafting.js b/BondageClub/Screens/Room/Crafting/Crafting.js
index f784dbdf7b..5216b59b75 100644
--- a/BondageClub/Screens/Room/Crafting/Crafting.js
+++ b/BondageClub/Screens/Room/Crafting/Crafting.js
@@ -748,10 +748,6 @@ var CraftingEventListeners = {
 			return;
 		}
 
-		// Trigger a search query in order to filter the results by whatever input the user has specified
-		const searchInput = sidePannel.querySelector("input[type='search']");
-		searchInput?.dispatchEvent(new Event("input"));
-
 		if (this.getAttribute("aria-checked") === "true") {
 			controlButton.innerHTML = this.innerHTML;
 			return;
@@ -780,8 +776,8 @@ var CraftingEventListeners = {
 		searchResultCandidates?.querySelectorAll("button.button").forEach(button => {
 			const label = button.querySelector(".button-label");
 			if (label) {
-				const displayStyle = (button.getAttribute("aria-checked") === "true" || label.textContent.toUpperCase().includes(query)) ? "" : "none";
-				/** @type {HTMLButtonElement} */(button).style.display = displayStyle;
+				const displayStyle = (button.getAttribute("aria-checked") === "true" || label.textContent.toUpperCase().includes(query)) ? false : true;
+				button.toggleAttribute("data-unload", displayStyle);
 			}
 		});
 	},
@@ -808,21 +804,92 @@ var CraftingEventListeners = {
 		}
 		descriptionInput.dispatchEvent(new InputEvent("input"));
 	},
+
+	/**
+	 * @private
+	 * @type {(this: HTMLButtonElement, ev: MouseEvent) => void}
+	 */
+	_ClickGroup: function _ClickGroup(ev) {
+		const groupName = /** @type {AssetGroupItemName} */(this.name);
+		const assetList = document.getElementById(CraftingID.assetGrid);
+		if (!assetList) {
+			ev.stopImmediatePropagation();
+			return;
+		}
+
+		if (this.getAttribute("aria-checked") === "true") {
+			// Apply the filtering
+			for (const button of assetList.children) {
+				button.toggleAttribute("data-unload-group", button.getAttribute("data-group") !== groupName);
+			}
+
+			// Update the label of the asset panel
+			document.querySelector(`#${CraftingID.assetHeader} > span`)?.replaceChildren(
+				`${TextGet("SelectItem")} ${TextGet("SelectItemSuffix")} ${this.getAttribute("aria-label").toLocaleLowerCase()}`,
+			);
+
+			// Make sure that the asset panel is open and scroll to the top
+			document.querySelector(`#${CraftingID.assetButton}[aria-checked="false"]`)?.dispatchEvent(new MouseEvent("click"));
+			assetList.scrollTo({ top: 0 });
+		} else {
+			document.querySelector(`#${CraftingID.assetHeader} > span`)?.replaceChildren(TextGet("SelectItem"));
+			for (const button of assetList.children) {
+				button.toggleAttribute("data-unload-group", false);
+			}
+
+			const checked = assetList.querySelector("[aria-checked='true']");
+			if (checked) {
+				checked.scrollIntoView({ behavior: "instant" });
+			} else {
+				assetList.scrollTo({ top: 0 });
+			}
+		}
+	},
+
+	/**
+	 * @private
+	 * @type {(this: HTMLInputElement, ev: FocusEvent) => Promise<void>}
+	 */
+	_FocusSearchAsset: async function _FocusSearchAsset(ev) {
+		const focusGrid = document.getElementById(CraftingID.centerPanel);
+		if (!focusGrid) {
+			ev.stopImmediatePropagation();
+			return;
+		}
+
+		const group = /** @type {"ALL" | AssetGroupItemName} */(focusGrid.querySelector("[role='radio'][aria-checked='true']")?.getAttribute("name") ?? "ALL");
+		const cachedGroup = this.getAttribute("data-group");
+		if (cachedGroup === group) {
+			return;
+		}
+
+		let options = CraftingElements._SearchCache.get(group);
+		if (!options) {
+			const searchResults = document.getElementById(this.getAttribute("aria-controls"));
+			const query = group === "ALL" ? ".button-label" : `[data-group='${group}'] .button-label`;
+			options = Array.from(searchResults?.querySelectorAll(query) ?? []).map(e => ElementCreate({ tag: "option", attributes: { value: e.textContent }}));
+			CraftingElements._SearchCache.set(group, options);
+		}
+		this.list?.replaceChildren(...options);
+		this.setAttribute("data-group", group);
+	},
+
+	/**
+	 * @private
+	 * @type {(this: HTMLInputElement, ev: FocusEvent) => Promise<void>}
+	 */
+	_FocusSearch: async function _FocusSearch(ev) {
+		if (this.list?.options.length) {
+			return;
+		}
+
+		const searchResults = document.getElementById(this.getAttribute("aria-controls"));
+		const options = Array.from(searchResults?.querySelectorAll(".button-label") ?? []).map(e => ElementCreate({ tag: "option", attributes: { value: e.textContent }}));
+		this.list?.replaceChildren(...options);
+	},
 };
 
 var CraftingElements = {
-	/**
-	 * @private
-	 * @param {string} controls
-	 * @returns {() => string[]}
-	 */
-	_SearchInputGetDataList: function _SearchInputGetDataList(controls) {
-		return () => {
-			const searchResults = document.getElementById(controls);
-			return Array.from(searchResults?.querySelectorAll("button > label") ?? []).map(e => e.textContent);
-		};
-	},
-
 	/**
 	 * @private
 	 * @param {string} id
@@ -830,15 +897,35 @@ var CraftingElements = {
 	 * @param {string} placeholder
 	 * @returns {HTMLInputElement}
 	 */
-	_SearchInput: function _SearchInput(id, controls, placeholder) {
-		const ret = ElementCreateSearchInput(id, CraftingElements._SearchInputGetDataList(controls));
-		ret.setAttribute("aria-controls", controls);
-		ret.setAttribute("size", 0);
-		ret.addEventListener("input", CraftingEventListeners._InputSearch);
-		ret.placeholder = placeholder;
-		return ret;
+	_SearchInput: function _SearchInput(id, controls, placeholder, assetSearch=false) {
+		return ElementCreate({
+			tag: "input",
+			attributes: {
+				type: "search",
+				id,
+				placeholder,
+				list: `${id}-datalist`,
+				size: 0,
+				"aria-controls": controls,
+			},
+			dataAttributes: {
+				group: undefined, // Initialized and managed by the `focus` event listener for asset searches
+			},
+			eventListeners: {
+				input: CraftingEventListeners._InputSearch,
+				focus: assetSearch ? CraftingEventListeners._FocusSearchAsset : CraftingEventListeners._FocusSearch,
+			},
+			children: [
+				{ tag: "datalist", attributes: { id: `${id}-datalist` } },
+			],
+		});
 	},
 
+	/**
+	 * @type {Map<"ALL" | AssetGroupItemName, readonly HTMLOptionElement[]>}
+	 */
+	_SearchCache: new Map(),
+
 	/**
 	 * @private
 	 * @param {string} id
@@ -941,6 +1028,11 @@ function CraftingLoad() {
 						),
 						ElementButton.Create(CraftingID.uploadButton, CraftingEventListeners._ClickUpload, { tooltip: TextGet("Upload") }),
 						ElementButton.Create(CraftingID.downloadButton, CraftingEventListeners._ClickDownload, { tooltip: TextGet("Download") }),
+						ElementButton.Create(
+							CraftingID.undressButton, CraftingEventListeners._ClickUndress,
+							{ tooltip: TextGet("Undress"), role: "menuitemcheckbox" },
+							{ button: { attributes: { "aria-checked": CraftingNakedPreview ? "true" : "false" } } },
+						),
 					],
 					{ direction: "rtl" },
 				),
@@ -1019,7 +1111,7 @@ function CraftingLoad() {
 							attributes: { id: CraftingID.assetHeader },
 							children: [
 								{ tag: "span", children: [TextGet("SelectItem")] },
-								CraftingElements._SearchInput(CraftingID.assetSearch, CraftingID.assetGrid, TextGet("FilterAsset")),
+								CraftingElements._SearchInput(CraftingID.assetSearch, CraftingID.assetGrid, TextGet("FilterAsset"), true),
 							],
 						},
 						{
@@ -1076,14 +1168,7 @@ function CraftingLoad() {
 			{ menu: { attributes: { "aria-orientation": "vertical" }, parent } },
 		),
 
-		ElementCreate({
-			tag: "div",
-			attributes: { id: CraftingID.centerPanel },
-			parent,
-			children: [
-				ElementButton.Create(CraftingID.undressButton, CraftingEventListeners._ClickUndress),
-			],
-		});
+		parent.append(DialogFocusGroup.Create(CraftingID.centerPanel, CraftingEventListeners._ClickGroup, { useDynamicGroupName: true }));
 
 		ElementCreate({
 			tag: "div",
@@ -1920,12 +2005,13 @@ function CraftingExitResetElements() {
 
 	// Clear all search inputs and undo their filtering
 	const searchInputs = /** @type {NodeListOf<HTMLInputElement>} */(document.querySelectorAll(`#${CraftingID.leftPanel} input[type='search']`));
-	searchInputs.forEach((searchInp) => {
-		if (searchInp.value) {
-			searchInp.value = "";
-			searchInp.dispatchEvent(new Event("input"));
-		}
-	});
+	searchInputs.forEach((searchInp) => searchInp.value ||= "");
+
+	const focusGroup = document.querySelector(`#${CraftingID.centerPanel} [role='radio'][aria-checked='true']`);
+	if (focusGroup) {
+		focusGroup.dispatchEvent(new MouseEvent("click"));
+		document.querySelectorAll(`#${CraftingID.assetGrid} [data-unload-group]`).forEach(e => e.toggleAttribute("data-unload-group", false));
+	}
 
 	// Close the side pannel
 	document.querySelector(`#${CraftingID.leftPanel} > [aria-checked='true']`)?.dispatchEvent(new Event("click"));
@@ -1951,8 +2037,10 @@ function CraftingExit(allowPanelClose=true) {
 			return;
 		case "Name": {
 			const activePanel = document.querySelector(`#${CraftingID.leftPanel} > [aria-checked='true']`);
-			if (activePanel && allowPanelClose) {
-				activePanel.dispatchEvent(new Event("click"));
+			const activeGroup = document.querySelector(`#${CraftingID.centerPanel} [aria-checked='true']`);
+			if ((activePanel || activeGroup) && allowPanelClose) {
+				activePanel?.dispatchEvent(new MouseEvent("click"));
+				activeGroup?.dispatchEvent(new MouseEvent("click"));
 			} else {
 				CraftingExitResetElements();
 				CraftingUnload();
@@ -1964,6 +2052,7 @@ function CraftingExit(allowPanelClose=true) {
 		case "Slot": {
 			ElementRemove(CraftingID.root);
 			CharacterDelete(CraftingPreview);
+			CraftingElements._SearchCache.clear();
 			CraftingPreview = null;
 			CraftingOffset = 0;
 			CraftingDestroy = false;
diff --git a/BondageClub/Screens/Room/Crafting/Text_Crafting.csv b/BondageClub/Screens/Room/Crafting/Text_Crafting.csv
index bb7d743a0a..dced7db9df 100644
--- a/BondageClub/Screens/Room/Crafting/Text_Crafting.csv
+++ b/BondageClub/Screens/Room/Crafting/Text_Crafting.csv
@@ -13,6 +13,7 @@ NoLock,No lock
 SelectSlot,"Select an empty slot to craft a new item, or click on the item to edit.  Page"
 SelectDestroy,Select a crafted item slot to destroy.  Page
 SelectItem,Select an item
+SelectItemSuffix,for the
 SelectProperty,Select an item property
 SelectLock,Select a lock
 SelectName,Configure the crafted item
@@ -83,3 +84,4 @@ Upload,Import crafting code
 UploadPrompt,Please paste the crafting code. Importing crafting codes will overwrite existing settings. Are you sure?
 UploadSucces,Crafting code successfully parsed
 UploadFailure,Failed to parse the passed crafting code
+Undress,Undress character preview