Skip to content

Commit 7891046

Browse files
authored
web: Consistent Tab Panel URL Parameters (#17804)
* web: Fix tabpanel focus attempting to scroll page. * web: Fix issues surrounding consistent tab panel parameter testing. * web: add shared helpers. * web: Tidy comments.
1 parent c4af2ee commit 7891046

File tree

5 files changed

+141
-82
lines changed

5 files changed

+141
-82
lines changed

web/src/elements/Tabs.ts

Lines changed: 93 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "#common/constants";
1+
import { CURRENT_CLASS, EVENT_REFRESH } from "#common/constants";
22

33
import { AKElement } from "#elements/Base";
44
import { getURLParams, updateURLParams } from "#elements/router/RouteMatch";
@@ -7,8 +7,7 @@ import { isFocusable } from "#elements/utils/focus";
77

88
import { msg } from "@lit/localize";
99
import { css, CSSResult, html, LitElement, TemplateResult } from "lit";
10-
import { customElement, property } from "lit/decorators.js";
11-
import { ifDefined } from "lit/directives/if-defined.js";
10+
import { customElement, property, state } from "lit/decorators.js";
1211
import { createRef, ref } from "lit/directives/ref.js";
1312

1413
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
@@ -20,18 +19,6 @@ export class Tabs extends AKElement {
2019
...LitElement.shadowRootOptions,
2120
delegatesFocus: true,
2221
};
23-
24-
#focusTargetRef = createRef<HTMLSlotElement>();
25-
26-
@property()
27-
pageIdentifier = "page";
28-
29-
@property()
30-
currentPage?: string;
31-
32-
@property({ type: Boolean })
33-
vertical = false;
34-
3522
static styles: CSSResult[] = [
3623
PFGlobal,
3724
PFTabs,
@@ -55,37 +42,90 @@ export class Tabs extends AKElement {
5542
`,
5643
];
5744

58-
observer: MutationObserver;
45+
@property({ type: String })
46+
public pageIdentifier = "page";
5947

60-
constructor() {
61-
super();
62-
this.observer = new MutationObserver(() => {
63-
this.requestUpdate();
64-
});
65-
}
48+
@property({ type: Boolean, useDefault: true })
49+
public vertical = false;
50+
51+
@state()
52+
protected activeTabName: string | null = null;
53+
54+
@state()
55+
protected tabs: ReadonlyMap<string, Element> = new Map();
56+
57+
#focusTargetRef = createRef<HTMLSlotElement>();
58+
#observer: MutationObserver | null = null;
59+
60+
#updateTabs = (): void => {
61+
this.tabs = new Map(
62+
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
63+
return [element.getAttribute("slot") || "", element];
64+
}),
65+
);
66+
};
6667

67-
connectedCallback(): void {
68+
public override connectedCallback(): void {
6869
super.connectedCallback();
69-
this.observer.observe(this, {
70+
71+
this.#observer = new MutationObserver(this.#updateTabs);
72+
73+
this.addEventListener("focus", this.#delegateFocusListener);
74+
75+
if (!this.activeTabName) {
76+
const params = getURLParams();
77+
const tabParam = params[this.pageIdentifier];
78+
79+
if (
80+
tabParam &&
81+
typeof tabParam === "string" &&
82+
this.querySelector(`[slot='${tabParam}']`)
83+
) {
84+
this.activeTabName = tabParam;
85+
} else {
86+
this.#updateTabs();
87+
this.activeTabName = this.tabs.keys().next().value || null;
88+
}
89+
}
90+
}
91+
92+
public override firstUpdated(): void {
93+
this.#observer?.observe(this, {
7094
attributes: true,
7195
childList: true,
7296
subtree: true,
7397
});
74-
75-
this.addEventListener("focus", this.#delegateFocusListener);
7698
}
7799

78-
disconnectedCallback(): void {
79-
this.observer.disconnect();
100+
public override disconnectedCallback(): void {
101+
this.#observer?.disconnect();
80102
super.disconnectedCallback();
81103
}
82104

83-
onClick(slot?: string): void {
84-
this.currentPage = slot;
85-
const params: { [key: string]: string | undefined } = {};
86-
params[this.pageIdentifier] = slot;
87-
updateURLParams(params);
88-
const page = this.querySelector(`[slot='${this.currentPage}']`);
105+
public activateTab(nextTabName: string): void {
106+
if (!nextTabName) {
107+
console.warn("Cannot activate falsey tab name:", nextTabName);
108+
return;
109+
}
110+
111+
if (!this.tabs.has(nextTabName)) {
112+
console.warn("Cannot activate unknown tab name:", nextTabName, this.tabs);
113+
return;
114+
}
115+
116+
const firstTab = this.tabs.keys().next().value || null;
117+
118+
// We avoid adding the tab parameter to the URL if it's the first tab
119+
// to both reduce URL length and ensure that tests do not have to deal with
120+
// unnecessary URL parameters.
121+
122+
updateURLParams({
123+
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
124+
});
125+
126+
this.activeTabName = nextTabName;
127+
128+
const page = this.querySelector(`[slot='${this.activeTabName}']`);
89129
if (!page) return;
90130

91131
page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
@@ -103,59 +143,49 @@ export class Tabs extends AKElement {
103143

104144
// We don't want to refocus if the user is tabbing between elements inside the tabpanel.
105145
if (focusableElement && event.relatedTarget !== focusableElement) {
106-
focusableElement.focus();
146+
focusableElement.focus({
147+
preventScroll: true,
148+
});
107149
}
108150
};
109151

110-
renderTab(page: Element): TemplateResult {
111-
const slot = page.attributes.getNamedItem("slot")?.value;
112-
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
152+
renderTab(slotName: string, tabPanel: Element): TemplateResult {
153+
return html` <li
154+
class="pf-c-tabs__item ${slotName === this.activeTabName ? CURRENT_CLASS : ""}"
155+
>
113156
<button
114157
type="button"
115158
role="tab"
116-
id=${`${slot}-tab`}
117-
aria-selected=${slot === this.currentPage ? "true" : "false"}
118-
aria-controls=${ifPresent(slot)}
159+
id=${`${slotName}-tab`}
160+
aria-selected=${slotName === this.activeTabName ? "true" : "false"}
161+
aria-controls=${ifPresent(slotName)}
119162
class="pf-c-tabs__link"
120-
@click=${() => this.onClick(slot)}
163+
@click=${() => this.activateTab(slotName)}
121164
>
122-
<span class="pf-c-tabs__item-text"> ${page.getAttribute("aria-label")}</span>
165+
<span class="pf-c-tabs__item-text"> ${tabPanel.getAttribute("aria-label")}</span>
123166
</button>
124167
</li>`;
125168
}
126169

127170
render(): TemplateResult {
128-
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
129-
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
130-
const params = getURLParams();
131-
if (
132-
this.pageIdentifier in params &&
133-
!this.currentPage &&
134-
this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
135-
) {
136-
// To update the URL to match with the current slot
137-
this.onClick(params[this.pageIdentifier] as string);
138-
}
139-
}
140-
if (!this.currentPage) {
141-
if (pages.length < 1) {
142-
return html`<h1>${msg("no tabs defined")}</h1>`;
143-
}
144-
const wantedPage = pages[0].attributes.getNamedItem("slot")?.value;
145-
this.onClick(wantedPage);
171+
if (!this.tabs.size) {
172+
return html`<h1>${msg("no tabs defined")}</h1>`;
146173
}
174+
147175
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
148176
<ul
149177
class="pf-c-tabs__list"
150178
role="tablist"
151179
aria-orientation=${this.vertical ? "vertical" : "horizontal"}
152180
aria-label=${ifPresent(this.ariaLabel)}
153181
>
154-
${pages.map((page) => this.renderTab(page))}
182+
${Array.from(this.tabs, ([slotName, tabPanel]) =>
183+
this.renderTab(slotName, tabPanel),
184+
)}
155185
</ul>
156186
</div>
157187
<slot name="header"></slot>
158-
<slot ${ref(this.#focusTargetRef)} name="${ifDefined(this.currentPage)}"></slot>`;
188+
<slot ${ref(this.#focusTargetRef)} name=${ifPresent(this.activeTabName)}></slot>`;
159189
}
160190
}
161191

web/src/elements/router/RouteMatch.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ROUTE_SEPARATOR } from "#common/constants";
22

33
import { Route } from "#elements/router/Route";
4+
import { RouteParameterRecord } from "#elements/router/shared";
45

56
import { TemplateResult } from "lit";
67

@@ -49,10 +50,10 @@ export function getURLParam<T>(key: string, fallback: T): T {
4950
return fallback;
5051
}
5152

52-
export function getURLParams(): { [key: string]: unknown } {
53+
export function getURLParams(): RouteParameterRecord {
5354
const params = {};
5455
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
55-
const urlParts = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR, 2);
56+
const urlParts = window.location.hash.slice(1).split(ROUTE_SEPARATOR, 2);
5657
const rawParams = decodeURIComponent(urlParts[1]);
5758
try {
5859
return JSON.parse(rawParams);
@@ -63,21 +64,43 @@ export function getURLParams(): { [key: string]: unknown } {
6364
return params;
6465
}
6566

66-
export function setURLParams(params: { [key: string]: unknown }, replace = true): void {
67-
const paramsString = JSON.stringify(params);
68-
const currentUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
69-
const newUrl = `#${currentUrl};${encodeURIComponent(paramsString)}`;
67+
/**
68+
* Serialize route parameters to a JSON string, removing empty values.
69+
*
70+
* @param params The route parameters to serialize.
71+
*/
72+
export function prepareURLParams(params: RouteParameterRecord): RouteParameterRecord {
73+
const preparedParams: RouteParameterRecord = {};
74+
for (const [key, value] of Object.entries(params)) {
75+
if (value !== null && value !== undefined && value !== "") {
76+
preparedParams[key] = value;
77+
}
78+
}
79+
return preparedParams;
80+
}
81+
82+
export function serializeURLParams(params: RouteParameterRecord): string {
83+
const preparedParams = prepareURLParams(params);
84+
85+
return Object.keys(preparedParams).length === 0 ? "" : JSON.stringify(preparedParams);
86+
}
87+
88+
export function setURLParams(params: RouteParameterRecord, replace = true): void {
89+
const [currentHash] = window.location.hash.slice(1).split(ROUTE_SEPARATOR);
90+
let nextHash = "#" + currentHash;
91+
const preparedParams = prepareURLParams(params);
92+
93+
if (Object.keys(preparedParams).length) {
94+
nextHash += ROUTE_SEPARATOR + encodeURIComponent(JSON.stringify(preparedParams));
95+
}
96+
7097
if (replace) {
71-
history.replaceState(undefined, "", newUrl);
98+
history.replaceState(undefined, "", nextHash);
7299
} else {
73-
history.pushState(undefined, "", newUrl);
100+
history.pushState(undefined, "", nextHash);
74101
}
75102
}
76103

77-
export function updateURLParams(params: { [key: string]: unknown }, replace = true): void {
78-
const currentParams = getURLParams();
79-
for (const key in params) {
80-
currentParams[key] = params[key] as string;
81-
}
82-
setURLParams(currentParams, replace);
104+
export function updateURLParams(params: RouteParameterRecord, replace = true): void {
105+
setURLParams({ ...getURLParams(), ...params }, replace);
83106
}

web/src/elements/router/shared.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @file Common types for routing.
3+
*/
4+
5+
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
6+
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };

web/src/elements/router/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type RouteInterfaceName = "user" | "admin" | "flow" | "unknown";
1414
/**
1515
* Read the current interface route parameter from the URL.
1616
*
17-
* @param location - The location object to read the pathname from. Defaults to `window.location`.
17+
* @param location The location object to read the pathname from. Defaults to `window.location`.
1818
* @returns The name of the current interface, or "unknown" if not found.
1919
*
2020
* @category Routing
@@ -51,7 +51,7 @@ export function isUserRoute(location: Pick<URL, "pathname"> = window.location):
5151
* The input is converted to lowercase and non-alphanumeric characters are
5252
* replaced with a hyphen. Trailing whitespace and hyphens are removed.
5353
*
54-
* @param input - The input string to format.
54+
* @param input The input string to format.
5555
*
5656
* @category Routing
5757
*
@@ -68,7 +68,7 @@ export function formatSlug(input: string): string {
6868
/**
6969
* Predicate to determine if the input is a valid route slug.
7070
*
71-
* @param input - The input string to check.
71+
* @param input The input string to check.
7272
*/
7373
export function isSlug(input: string): boolean {
7474
return kebabCase(input) === input;

web/src/elements/table/Table.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export abstract class Table<T extends object>
148148
@property({ attribute: false })
149149
public data: PaginatedResponse<T> | null = null;
150150

151-
@property({ type: Number })
151+
@property({ type: Number, useDefault: true })
152152
public page = getURLParam(this.#pageParam, 1);
153153

154154
/**
@@ -256,7 +256,7 @@ export abstract class Table<T extends object>
256256
protected willUpdate(changedProperties: PropertyValues<this>): void {
257257
if (changedProperties.has("page")) {
258258
updateURLParams({
259-
[this.#pageParam]: this.page,
259+
[this.#pageParam]: this.page === 1 ? null : this.page,
260260
});
261261
}
262262
if (changedProperties.has("search")) {

0 commit comments

Comments
 (0)