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 {
59 z-index: -2;59 z-index: -2;
60}60}
6161
62.bg_example.locked {62.bg_example.flex-container.locked:not(:focus-visible) {
63 outline: 2px solid var(--golden);63 outline-color: var(--golden);
64}64}
6565
66/* This is the main flex container for the entire drawer */66/* This is the main flex container for the entire drawer */
@@ -122,6 +122,10 @@ body.reduced-motion #bg_custom {
122 outline-offset: -1px;122 outline-offset: -1px;
123}123}
124124
125.bg_example.flex-container:focus-visible {
126 outline-offset: inherit;
127}
128
125.bg_example_img {129.bg_example_img {
126 position: absolute;130 position: absolute;
127 top: -2px;131 top: -2px;
@@ -155,7 +159,8 @@ body.reduced-motion #bg_custom {
155 transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;159 transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
156}160}
157161
158.bg_example:hover .jg-menu {162.bg_example:hover .jg-menu,
163.bg_example:focus-within .jg-menu {
159 opacity: 1;164 opacity: 1;
160 visibility: visible;165 visibility: visible;
161 transform: scale(1);166 transform: scale(1);
@@ -188,6 +193,11 @@ body.reduced-motion #bg_custom {
188 display: flex;193 display: flex;
189}194}
190195
196.bg_example:not([custom="true"]) .jg-copy,
197.bg_example[custom="true"] .jg-edit {
198 display: none;
199}
200
191/* Thumbnail Title */201/* Thumbnail Title */
192.bg_example .BGSampleTitle {202.bg_example .BGSampleTitle {
193 position: absolute;203 position: absolute;
@@ -209,6 +219,7 @@ body.reduced-motion #bg_custom {
209 border-radius: 0 0 8px 8px;219 border-radius: 0 0 8px 8px;
210}220}
211221
212.bg_example:hover .BGSampleTitle {222.bg_example:hover .BGSampleTitle,
223.bg_example:focus-within .BGSampleTitle {
213 opacity: 1;224 opacity: 1;
214}225}
public/css/mobile-styles.css+2 -1
@@ -30,7 +30,8 @@
30 bottom: 0px;30 bottom: 0px;
31 }31 }
3232
33 .bg_example:hover .jg-menu {33 .bg_example:hover .jg-menu,
34 .bg_example:focus-within .jg-menu {
34 display: none;35 display: none;
35 }36 }
3637
public/index.html+4 -4
@@ -6274,17 +6274,17 @@
6274 <div id="background_template" class="template_element">6274 <div id="background_template" class="template_element">
6275 <div class="bg_example flex-container" bgfile="" title="">6275 <div class="bg_example flex-container" bgfile="" title="">
6276 <div class="bg_example_img"></div>6276 <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>
6278 <div class="jg-menu">6280 <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>
6279 <!-- temporarily moved lock icon here (will be moved to header) -->6282 <!-- temporarily moved lock icon here (will be moved to header) -->
6280 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>6283 <div data-action="lock" class="jg-button jg-lock fa-solid fa-lock fa-fw pointer" data-i18n="[title]Lock" title="Lock"></div>
6281 <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>6284 <div data-action="unlock" class="jg-button jg-unlock fa-solid fa-lock-open fa-fw pointer" data-i18n="[title]Unlock" title="Unlock"></div>
6282 <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>6285 <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>
6283 <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>6286 <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>
6284 </div>6287 </div>
6285 <div class="mobile-only-menu-toggle">
6286 <i class="fa-solid fa-ellipsis-vertical"></i>
6287 </div>
6288 <div class="BGSampleTitle"></div>6288 <div class="BGSampleTitle"></div>
6289 </div>6289 </div>
6290 </div>6290 </div>
public/scripts/a11y.js+13 -1
@@ -13,6 +13,9 @@ const buttonSelectors = [
13 '.swipe_right',13 '.swipe_right',
14 '.character_select',14 '.character_select',
15 '.tags .tag',15 '.tags .tag',
16 '.jg-menu .jg-button',
17 '.bg_example .mobile-only-menu-toggle',
18 '.paginationjs-pages li a',
16].join(', ');19].join(', ');
1720
18const listSelectors = [21const listSelectors = [
@@ -26,10 +29,11 @@ const listSelectors = [
26 '.recentChatList',29 '.recentChatList',
27 '.dataMaidCategoryContent',30 '.dataMaidCategoryContent',
28 '#userList',31 '#userList',
32 '.bg_list',
29].join(', ');33].join(', ');
3034
31const listItemSelectors = [35const listItemSelectors = [
32 '.options-content .interactable',36 '.options-content .list-group-item',
33 '.list-group .list-group-item',37 '.list-group .list-group-item',
34 '#rm_print_characters_block .entity_block',38 '#rm_print_characters_block .entity_block',
35 '#rm_group_members .group_member',39 '#rm_group_members .group_member',
@@ -39,6 +43,11 @@ const listItemSelectors = [
39 '.recentChatList .recentChat',43 '.recentChatList .recentChat',
40 '.dataMaidCategoryContent .dataMaidItem',44 '.dataMaidCategoryContent .dataMaidItem',
41 '#userList .userSelect',45 '#userList .userSelect',
46 '.bg_list .bg_example',
47].join(', ');
48
49const toolbarSelectors = [
50 '.jg-menu',
42].join(', ');51].join(', ');
4352
44/** @type {Record<string, (element: Element) => void>} */53/** @type {Record<string, (element: Element) => void>} */
@@ -52,6 +61,9 @@ const a11yRules = {
52 [listItemSelectors]: (element) => {61 [listItemSelectors]: (element) => {
53 element.setAttribute('role', 'listitem');62 element.setAttribute('role', 'listitem');
54 },63 },
64 [toolbarSelectors]: (element) => {
65 element.setAttribute('role', 'toolbar');
66 },
55 '#toast-container .toast-message': (element) => {67 '#toast-container .toast-message': (element) => {
56 element.setAttribute('role', 'alert');68 element.setAttribute('role', 'alert');
57 },69 },
public/scripts/backgrounds.js+3 -1
@@ -705,7 +705,6 @@ export function initBackgrounds() {
705705
706 $(document)706 $(document)
707 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)707 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
708 .off('click', '.bg_example_copy').on('click', '.bg_example_copy', onCopyToSystemBackgroundClick)
709 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {708 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
710 e.stopPropagation();709 e.stopPropagation();
711 const $context = $(this).closest('.bg_example');710 const $context = $(this).closest('.bg_example');
@@ -733,6 +732,9 @@ export function initBackgrounds() {
733 case 'delete':732 case 'delete':
734 onDeleteBackgroundClick.call(this, e.originalEvent);733 onDeleteBackgroundClick.call(this, e.originalEvent);
735 break;734 break;
735 case 'copy':
736 onCopyToSystemBackgroundClick.call(this, e.originalEvent);
737 break;
736 }738 }
737 });739 });
738740
public/scripts/dynamic-styles.js+52 -16
@@ -33,7 +33,8 @@ const observer = new MutationObserver(mutations => {
33 * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension33 * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
34 */34 */
35function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {35function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
36 /** @type {{baseSelector: string, rule: CSSStyleRule}[]} */36 /** @typedef {{ type: 'media'|'supports'|'container', conditionText: string }} WrapperCond */
37 /** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */
37 const hoverRules = [];38 const hoverRules = [];
38 /** @type {Set<string>} */39 /** @type {Set<string>} */
39 const focusRules = new Set();40 const focusRules = new Set();
@@ -41,14 +42,28 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
41 const PLACEHOLDER = ':__PLACEHOLDER__';42 const PLACEHOLDER = ':__PLACEHOLDER__';
4243
43 /**44 /**
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 /**
44 * Processes the CSS rules and separates selectors for hover and focus55 * Processes the CSS rules and separates selectors for hover and focus
45 * @param {CSSRuleList} rules - The CSS rules to process56 * @param {CSSRuleList} rules - The CSS rules to process
57 * @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.)
46 */58 */
47 function processRules(rules) {59 function processRules(rules, wrappers = []) {
48 Array.from(rules).forEach(rule => {60 Array.from(rules).forEach(rule => {
49 if (rule instanceof CSSImportRule) {61 if (rule instanceof CSSImportRule) {
50 // Make sure that @import rules are processed recursively62 // 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]);
52 } else if (rule instanceof CSSStyleRule) {67 } else if (rule instanceof CSSStyleRule) {
53 // Separate multiple selectors on a rule68 // Separate multiple selectors on a rule
54 const selectors = rule.selectorText.split(',').map(s => s.trim());69 const selectors = rule.selectorText.split(',').map(s => s.trim());
@@ -60,17 +75,25 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
60 // We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched75 // We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
61 }76 }
62 else if (isHover) {77 else if (isHover) {
63 const baseSelector = selector.replace(':hover', PLACEHOLDER).trim();78 const baseSelector = selector.replace(/:hover/g, PLACEHOLDER).trim();
64 hoverRules.push({ baseSelector, rule });79 hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] });
65 } else if (isFocus) {80 } else if (isFocus) {
66 // We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules81 // We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
67 const baseSelector = selector.replace(':focus-within', PLACEHOLDER).replace(':focus-visible', PLACEHOLDER).replace(':focus', PLACEHOLDER).trim();82 const baseSelector = selector.replace(/:focus(-within|-visible)?/g, PLACEHOLDER).trim();
68 focusRules.add(baseSelector);83 focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`);
69 }84 }
70 });85 });
71 } else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {86 } else if (rule instanceof CSSMediaRule) {
72 // Recursively process nested rules87 // 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 }]);
74 }97 }
75 });98 });
76 }99 }
@@ -78,21 +101,22 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
78 /**101 /**
79 * Processes the CSS rules of an imported stylesheet recursively102 * Processes the CSS rules of an imported stylesheet recursively
80 * @param {CSSStyleSheet} sheet - The imported stylesheet to process103 * @param {CSSStyleSheet} sheet - The imported stylesheet to process
104 * @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media
81 */105 */
82 function processImportedStylesheet(sheet) {106 function processImportedStylesheet(sheet, wrappers = []) {
83 if (sheet && sheet.cssRules) {107 if (sheet && sheet.cssRules) {
84 processRules(sheet.cssRules);108 processRules(sheet.cssRules, wrappers);
85 }109 }
86 }110 }
87111
88 processRules(styleSheet.cssRules);112 processRules(styleSheet.cssRules, []);
89113
90 /** @type {CSSStyleSheet} */114 /** @type {CSSStyleSheet} */
91 let targetStyleSheet = null;115 let targetStyleSheet = null;
92116
93 // Now finally create the dynamic focus rules117 // Now finally create the dynamic focus rules
94 hoverRules.forEach(({ baseSelector, rule }) => {118 hoverRules.forEach(({ baseSelector, rule, wrappers }) => {
95 if (!focusRules.has(baseSelector)) {119 if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) {
96 // Only initialize the dynamic stylesheet if needed120 // Only initialize the dynamic stylesheet if needed
97 targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });121 targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
98122
@@ -103,7 +127,19 @@ function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
103 // If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,127 // If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
104 // it should be manually defined in CSS.128 // it should be manually defined in CSS.
105 const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');129 const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
106 const focusRule = `${focusSelector} { ${rule.style.cssText} }`;130 let 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
108 try {144 try {
109 targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);145 targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
public/scripts/keyboard.js+1 -1
@@ -11,7 +11,7 @@ const interactableSelectors = [
11 '.avatar-container', // Persona list blocks11 '.avatar-container', // Persona list blocks
12 '.tag .tag_remove', // Remove button in removable tags12 '.tag .tag_remove', // Remove button in removable tags
13 '.bg_example', // Background elements in the background menu13 '.bg_example', // Background elements in the background menu
14 '.bg_example .bg_button', // The inline buttons on the backgrounds14 '.bg_example .jg-button, .bg_example .mobile-only-menu-toggle', // The inline buttons on the backgrounds
15 '#options a', // Option entries in the popup options menu15 '#options a', // Option entries in the popup options menu
16 '.mes_buttons .mes_button', // Small inline buttons on the chat messages16 '.mes_buttons .mes_button', // Small inline buttons on the chat messages
17 '.extraMesButtons>div:not(.mes_button)', // The extra/extension buttons inline on the chat messages17 '.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 {
3245 justify-content: space-evenly;3245 justify-content: space-evenly;
3246}3246}
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
3322.input-file {3248.input-file {
3323 display: flex;3249 display: flex;
3324 justify-content: center;3250 justify-content: center;