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