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