| @@ -46,11 +46,29 @@ | ||
| 46 | 46 | const { tabIndex } = state; |
| 47 | 47 | return activeTabIndex === tabIndex; |
| 48 | 48 | }, |
| 49 | 49 | /** |
| 50 | - * The value of the tabindex attribute for tab buttons. | |
| 51 | - * Only the active tab should be in the tab sequence. | |
| 50 | + * The value of the hidden attribute for tab panels. | |
| 52 | 51 | * |
| 52 | + * Inactive panels use the `until-found` state rather than being | |
| 53 | + * hidden outright, so their content stays reachable by the | |
| 54 | + * browser's find-in-page and by fragment navigation. The browser | |
| 55 | + * fires `beforematch` on the panel before revealing it, which is | |
| 56 | + * where the matching tab gets activated. | |
| 57 | + * | |
| 58 | + * @type {string|null} | |
| 59 | + */ | |
| 60 | + get isHidden() { | |
| 61 | + return state.isActiveTab ? null : "until-found"; | |
| 62 | + }, | |
| 63 | + /** | |
| 64 | + * The value of the tabindex attribute for tab buttons and tab | |
| 65 | + * panels. Only the active tab should be in the tab sequence. | |
| 66 | + * | |
| 67 | + * An inactive panel is hidden with `until-found`, which leaves it | |
| 68 | + * in the layout, so it stays focusable unless it is taken out of | |
| 69 | + * the tab sequence here. | |
| 70 | + * | |
| 53 | 71 | * @type {number} |
| 54 | 72 | */ |
| 55 | 73 | get tabIndexAttribute() { |
| 56 | 74 | return state.isActiveTab ? 0 : -1; |
| @@ -62,9 +80,9 @@ | ||
| 62 | 80 | * |
| 63 | 81 | * @param {KeyboardEvent} event The keydown event. |
| 64 | 82 | */ |
| 65 | 83 | handleTabKeyDown: withSyncEvent((event) => { |
| 66 | - const { tabIndex } = state; | |
| 84 | + const { tabIndex, tabsList } = state; | |
| 67 | 85 | if (tabIndex === null) { |
| 68 | 86 | return; |
| 69 | 87 | } |
| 70 | 88 | if (event.key === "ArrowRight") { |
| @@ -72,8 +90,14 @@ | ||
| 72 | 90 | actions.moveFocus(tabIndex + 1); |
| 73 | 91 | } else if (event.key === "ArrowLeft") { |
| 74 | 92 | event.preventDefault(); |
| 75 | 93 | actions.moveFocus(tabIndex - 1); |
| 94 | + } else if (event.key === "Home") { | |
| 95 | + event.preventDefault(); | |
| 96 | + actions.moveFocus(0); | |
| 97 | + } else if (event.key === "End") { | |
| 98 | + event.preventDefault(); | |
| 99 | + actions.moveFocus(tabsList.length - 1); | |
| 76 | 100 | } |
| 77 | 101 | }), |
| 78 | 102 | /** |
| 79 | 103 | * Handles the click event for the tab label. |
| @@ -87,8 +111,20 @@ | ||
| 87 | 111 | actions.setActiveTab(tabIndex); |
| 88 | 112 | } |
| 89 | 113 | }), |
| 90 | 114 | /** |
| 115 | + * Activates the tab whose panel the browser is about to reveal. | |
| 116 | + * | |
| 117 | + * Fired on a panel hidden with `until-found` when find-in-page or | |
| 118 | + * a fragment navigation matches content inside it. | |
| 119 | + */ | |
| 120 | + handleBeforeMatch: () => { | |
| 121 | + const { tabIndex } = state; | |
| 122 | + if (tabIndex !== null) { | |
| 123 | + actions.setActiveTab(tabIndex); | |
| 124 | + } | |
| 125 | + }, | |
| 126 | + /** | |
| 91 | 127 | * Moves focus to a specific tab without activating it. |
| 92 | 128 | * |
| 93 | 129 | * @param {number} tabIndex The index to move focus to. |
| 94 | 130 | */ |
| @@ -139,23 +175,37 @@ | ||
| 139 | 175 | } |
| 140 | 176 | } |
| 141 | 177 | }, |
| 142 | 178 | callbacks: { |
| 143 | - /** | |
| 144 | - * When the tabs are initialized, we need to check if there is a hash in the url and if so if it exists in the current tabsList, set the active tab to that index. | |
| 145 | - * | |
| 146 | - */ | |
| 147 | - onTabsInit: () => { | |
| 179 | + activateTabByHash: () => { | |
| 148 | 180 | const { tabsList } = state; |
| 149 | - if (tabsList.length === 0) { | |
| 181 | + if (!tabsList || tabsList.length === 0) { | |
| 150 | 182 | return; |
| 151 | 183 | } |
| 152 | - const { hash } = window.location; | |
| 153 | - const tabId = hash.replace("#", ""); | |
| 154 | - const tabIndex = tabsList.findIndex((t) => t === tabId); | |
| 155 | - if (tabIndex >= 0) { | |
| 156 | - actions.setActiveTab(tabIndex, true); | |
| 184 | + const targetElement = document.querySelector(":target"); | |
| 185 | + if (!targetElement) { | |
| 186 | + return; | |
| 157 | 187 | } |
| 188 | + const panelIndex = tabsList.findIndex( | |
| 189 | + (t) => t === targetElement.id | |
| 190 | + ); | |
| 191 | + if (panelIndex >= 0) { | |
| 192 | + actions.setActiveTab(panelIndex, true); | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + let panel = targetElement.closest(".wp-block-tab-panel"); | |
| 196 | + let tabIndex = -1; | |
| 197 | + while (panel && tabIndex < 0) { | |
| 198 | + tabIndex = tabsList.findIndex((t) => t === panel.id); | |
| 199 | + panel = panel.parentElement?.closest(".wp-block-tab-panel") ?? null; | |
| 200 | + } | |
| 201 | + if (tabIndex < 0) { | |
| 202 | + return; | |
| 203 | + } | |
| 204 | + actions.setActiveTab(tabIndex); | |
| 205 | + window.setTimeout(() => { | |
| 206 | + targetElement.scrollIntoView(); | |
| 207 | + }, 0); | |
| 158 | 208 | } |
| 159 | 209 | } |
| 160 | 210 | }, |
| 161 | 211 | { |