Fix/cleanup and background interactability (keyboard navigation, focus/hover) (#4503) * refactor: remove unused background styles * feat: add support for nested @media, @supports, and @container rules in dynamic focus styles * fix: add keyboard focus support for background menu buttons back * fix: move "custom" background copy functionality to unified button UI and make it work again * refactor: simplify CSSContainerRule instance check using window global * fix: update hover and focus selector placeholder replacements to handle multiple occurrences in selector * Fix focused outline offset of background samples * Fix locked background outline color * Add a11y roles to background menu items * Add mobile-only button selector * fix: update button and list item selectors * Improve keyboard navigation in narrow layout * Hide rename button for custom=true backgrounds --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

dcc6ddf7ad303b4de68f2db09d842f373b05d063

Wolfsblvt <wolfsblvt@gmail.com>

Signed
8 files changed, +90 -102Ignore whitespace
public/css/backgrounds.css+15 -4
@@ -59,8 +59,8 @@ body.reduced-motion #bg_custom {
5959 z-index: -2;
6060}
6161
6262.bg_example.flex-container.locked:not(:focus-visible) {
6363 outline-color: 2px solid var(--golden);
6464}
6565
6666/* This is the main flex container for the entire drawer */
@@ -122,6 +122,10 @@ body.reduced-motion #bg_custom {
122122 outline-offset: -1px;
123123}
124124
125+.bg_example.flex-container:focus-visible {
126+ outline-offset: inherit;
127+}
128+
125129.bg_example_img {
126130 position: absolute;
127131 top: -2px;
@@ -155,7 +159,8 @@ body.reduced-motion #bg_custom {
155159 transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
156160}
157161
158162.bg_example:hover .jg-menu {,
163+.bg_example:focus-within .jg-menu {
159164 opacity: 1;
160165 visibility: visible;
161166 transform: scale(1);
@@ -188,6 +193,11 @@ body.reduced-motion #bg_custom {
188193 display: flex;
189194}
190195
196+.bg_example:not([custom="true"]) .jg-copy,
197+.bg_example[custom="true"] .jg-edit {
198+ display: none;
199+}
200+
191201/* Thumbnail Title */
192202.bg_example .BGSampleTitle {
193203 position: absolute;
@@ -209,6 +219,7 @@ body.reduced-motion #bg_custom {
209219 border-radius: 0 0 8px 8px;
210220}
211221
212222.bg_example:hover .BGSampleTitle {,
223+.bg_example:focus-within .BGSampleTitle {
213224 opacity: 1;
214225}
public/css/mobile-styles.css+2 -1
@@ -30,7 +30,8 @@
3030 bottom: 0px;
3131 }
3232
3333 .bg_example:hover .jg-menu {,
34+ .bg_example:focus-within .jg-menu {
3435 display: none;
3536 }
3637
public/index.html+4 -4
@@ -6274,17 +6274,17 @@
62746274 <div id="background_template" class="template_element">
62756275 <div class="bg_example flex-container" bgfile="" title="">
62766276 <div class="bg_example_img"></div>
6277- <div title="Copy to system backgrounds" data-i18n="[title]Copy to system backgrounds" class="bg_button bg_example_copy fa-solid fa-file-arrow-up"></div>
6277+ <div class="mobile-only-menu-toggle">
6278+ <i class="fa-solid fa-ellipsis-vertical"></i>
6279+ </div>
62786280 <div class="jg-menu">
6281+ <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to system backgrounds" title="Copy to system backgrounds"></div>
62796282 <!-- temporarily moved lock icon here (will be moved to header) -->
62806283 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>
62816284 <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>
62826285 <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>
62836286 <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>
62846287 </div>
6285- <div class="mobile-only-menu-toggle">
6286- <i class="fa-solid fa-ellipsis-vertical"></i>
6287- </div>
62886288 <div class="BGSampleTitle"></div>
62896289 </div>
62906290 </div>
public/scripts/a11y.js+13 -1
@@ -13,6 +13,9 @@ const buttonSelectors = [
1313 '.swipe_right',
1414 '.character_select',
1515 '.tags .tag',
16+ '.jg-menu .jg-button',
17+ '.bg_example .mobile-only-menu-toggle',
18+ '.paginationjs-pages li a',
1619].join(', ');
1720
1821const listSelectors = [
@@ -26,10 +29,11 @@ const listSelectors = [
2629 '.recentChatList',
2730 '.dataMaidCategoryContent',
2831 '#userList',
32+ '.bg_list',
2933].join(', ');
3034
3135const listItemSelectors = [
3236 '.options-content .interactablelist-group-item',
3337 '.list-group .list-group-item',
3438 '#rm_print_characters_block .entity_block',
3539 '#rm_group_members .group_member',
@@ -39,6 +43,11 @@ const listItemSelectors = [
3943 '.recentChatList .recentChat',
4044 '.dataMaidCategoryContent .dataMaidItem',
4145 '#userList .userSelect',
46+ '.bg_list .bg_example',
47+].join(', ');
48+
49+const toolbarSelectors = [
50+ '.jg-menu',
4251].join(', ');
4352
4453/** @type {Record<string, (element: Element) => void>} */
@@ -52,6 +61,9 @@ const a11yRules = {
5261 [listItemSelectors]: (element) => {
5362 element.setAttribute('role', 'listitem');
5463 },
64+ [toolbarSelectors]: (element) => {
65+ element.setAttribute('role', 'toolbar');
66+ },
5567 '#toast-container .toast-message': (element) => {
5668 element.setAttribute('role', 'alert');
5769 },
public/scripts/backgrounds.js+3 -1
@@ -705,7 +705,6 @@ export function initBackgrounds() {
705705
706706 $(document)
707707 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
708- .off('click', '.bg_example_copy').on('click', '.bg_example_copy', onCopyToSystemBackgroundClick)
709708 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
710709 e.stopPropagation();
711710 const $context = $(this).closest('.bg_example');
@@ -733,6 +732,9 @@ export function initBackgrounds() {
733732 case 'delete':
734733 onDeleteBackgroundClick.call(this, e.originalEvent);
735734 break;
735+ case 'copy':
736+ onCopyToSystemBackgroundClick.call(this, e.originalEvent);
737+ break;
736738 }
737739 });
738740
public/scripts/dynamic-styles.js+52 -16
@@ -33,7 +33,8 @@ const observer = new MutationObserver(mutations => {
3333 * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
3434 */
3535function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
3636 /** @typetypedef {{baseSelector type: string'media'|'supports'|'container', ruleconditionText: CSSStyleRulestring }[]} WrapperCond */
37+ /** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */
3738 const hoverRules = [];
3839 /** @type {Set<string>} */
3940 const focusRules = new Set();
@@ -41,14 +42,28 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
4142 const PLACEHOLDER = ':__PLACEHOLDER__';
4243
4344 /**
45+ * Builds a stable signature string for a chain of wrapper conditions so we can distinguish
46+ * identical selectors under different contexts (e.g., different @media queries)
47+ * @param {WrapperCond[]} wrappers
48+ * @returns {string}
49+ */
50+ function wrapperSignature(wrappers) {
51+ return wrappers.map(w => `${w.type}:${w.conditionText}`).join(';');
52+ }
53+
54+ /**
4455 * Processes the CSS rules and separates selectors for hover and focus
4556 * @param {CSSRuleList} rules - The CSS rules to process
57+ * @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.)
4658 */
4759 function processRules(rules, wrappers = []) {
4860 Array.from(rules).forEach(rule => {
4961 if (rule instanceof CSSImportRule) {
5062 // Make sure that @import rules are processed recursively
51- processImportedStylesheet(rule.styleSheet);
63+ // If the @import has media conditions, treat them as wrappers as well
64+ /** @type {WrapperCond[]} */
65+ const extra = (rule.media && rule.media.mediaText) ? [{ type: 'media', conditionText: rule.media.mediaText }] : [];
66+ processImportedStylesheet(rule.styleSheet, [...wrappers, ...extra]);
5267 } else if (rule instanceof CSSStyleRule) {
5368 // Separate multiple selectors on a rule
5469 const selectors = rule.selectorText.split(',').map(s => s.trim());
@@ -60,17 +75,25 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
6075 // We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
6176 }
6277 else if (isHover) {
6378 const baseSelector = selector.replace('/:hover'/g, PLACEHOLDER).trim();
6479 hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] });
6580 } else if (isFocus) {
6681 // We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
6782 const baseSelector = selector.replace('/:focus(-within', PLACEHOLDER).replace(':focus|-visible', PLACEHOLDER).replace(':focus'?/g, PLACEHOLDER).trim();
6883 focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`);
6984 }
7085 });
7186 } else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {
7287 // Recursively process nested @media rules
73- processRules(rule.cssRules);
88+ processRules(rule.cssRules, [...wrappers, { type: 'media', conditionText: rule.conditionText }]);
89+ } else if (rule instanceof CSSSupportsRule) {
90+ // Recursively process nested @supports rules
91+ processRules(rule.cssRules, [...wrappers, { type: 'supports', conditionText: rule.conditionText }]);
92+ } else if (rule instanceof window.CSSContainerRule) {
93+ // Recursively process nested @container rules (if supported by the browser)
94+ // Note: conditionText contains the query like "(min-width: 300px)" or "style(color)"
95+ // Using 'container' as the type ensures uniqueness separate from @media/@supports
96+ processRules(rule.cssRules, [...wrappers, { type: 'container', conditionText: rule.conditionText }]);
7497 }
7598 });
7699 }
@@ -78,21 +101,22 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
78101 /**
79102 * Processes the CSS rules of an imported stylesheet recursively
80103 * @param {CSSStyleSheet} sheet - The imported stylesheet to process
104+ * @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media
81105 */
82106 function processImportedStylesheet(sheet, wrappers = []) {
83107 if (sheet && sheet.cssRules) {
84108 processRules(sheet.cssRules, wrappers);
85109 }
86110 }
87111
88112 processRules(styleSheet.cssRules, []);
89113
90114 /** @type {CSSStyleSheet} */
91115 let targetStyleSheet = null;
92116
93117 // Now finally create the dynamic focus rules
94118 hoverRules.forEach(({ baseSelector, rule, wrappers }) => {
95119 if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) {
96120 // Only initialize the dynamic stylesheet if needed
97121 targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
98122
@@ -103,7 +127,19 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
103127 // If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
104128 // it should be manually defined in CSS.
105129 const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
106130 constlet focusRule = `${focusSelector} { ${rule.style.cssText} }`;
131+
132+ // Wrap the generated rule into the same @media/@supports/@container chain (if any)
133+ if (wrappers.length > 0) {
134+ // Build nested blocks from outermost to innermost
135+ // Example: @media (x) { @supports (y) { <rule> } }
136+ focusRule = wrappers.reduceRight((inner, w) => {
137+ if (w.type === 'media') return `@media ${w.conditionText} { ${inner} }`;
138+ if (w.type === 'supports') return `@supports ${w.conditionText} { ${inner} }`;
139+ if (w.type === 'container') return `@container ${w.conditionText} { ${inner} }`;
140+ return inner;
141+ }, focusRule);
142+ }
107143
108144 try {
109145 targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
public/scripts/keyboard.js+1 -1
@@ -11,7 +11,7 @@ const interactableSelectors = [
1111 '.avatar-container', // Persona list blocks
1212 '.tag .tag_remove', // Remove button in removable tags
1313 '.bg_example', // Background elements in the background menu
1414 '.bg_example .bg_buttonjg-button, .bg_example .mobile-only-menu-toggle', // The inline buttons on the backgrounds
1515 '#options a', // Option entries in the popup options menu
1616 '.mes_buttons .mes_button', // Small inline buttons on the chat messages
1717 '.extraMesButtons>div:not(.mes_button)', // The extra/extension buttons inline on the chat messages
public/style.css+0 -74
@@ -3245,80 +3245,6 @@ input[type=search]:focus::-webkit-search-cancel-button {
32453245 justify-content: space-evenly;
32463246}
32473247
3248-.bg_example.locked {
3249- outline: 2px solid var(--golden);
3250-}
3251-
3252-.bg_example:hover.locked .bg_example_lock,
3253-.bg_example:focus-within.locked .bg_example_lock {
3254- display: none;
3255-}
3256-
3257-.bg_example:hover:not(.locked) .bg_example_unlock,
3258-.bg_example:focus-within:not(.locked) .bg_example_unlock {
3259- display: none;
3260-}
3261-
3262-.bg_example:hover[custom="true"] .bg_example_edit,
3263-.bg_example:focus-within[custom="true"] .bg_example_edit {
3264- display: none;
3265-}
3266-
3267-.bg_example:hover[custom="false"] .bg_example_copy,
3268-.bg_example:focus-within[custom="false"] .bg_example_copy {
3269- display: none;
3270-}
3271-
3272-.bg_button {
3273- padding: 4px;
3274- position: absolute;
3275- top: 5px;
3276- cursor: pointer;
3277- opacity: 0.8;
3278- border-radius: 3px;
3279- font-size: 20px;
3280- color: var(--black70a);
3281- text-shadow: none;
3282- margin: 0;
3283- filter: drop-shadow(0px 0px 3px white);
3284- transition: opacity var(--animation-duration-2x) ease-in-out;
3285- display: none;
3286-}
3287-
3288-.bg_example:hover .bg_button,
3289-.bg_example:focus-within .bg_button {
3290- display: block;
3291-}
3292-
3293-.bg_button:hover {
3294- opacity: 1;
3295-}
3296-
3297-.bg_example_cross {
3298- right: 6px;
3299-}
3300-
3301-.bg_example_edit {
3302- left: 6px;
3303-}
3304-
3305-.bg_example_copy {
3306- left: 6px;
3307-}
3308-
3309-.bg_example_lock,
3310-.bg_example_unlock {
3311- left: 50%;
3312- transform: translateX(-50%);
3313-}
3314-
3315-.add_bg_but {
3316- cursor: pointer;
3317- opacity: 0.1;
3318- height: 100%;
3319- width: 100%;
3320-}
3321-
33223248.input-file {
33233249 display: flex;
33243250 justify-content: center;