Convert tabbedview to functional component (#12478)

* Convert tabbedview to functional component

The 'Tab' is still a class, so now it's a functional component that
has a supporting class, which is maybe a bit... jarring, but I think
is actually perfectly logical.

* put comment back

* Fix bad tab ID behaviour

* Change to sub-components

and use contitional call syntax

* Comments

* Fix element IDs
pull/28217/head
David Baker 2024-05-03 13:59:56 +01:00 committed by GitHub
parent 95ee2979c8
commit 050f61752f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 120 additions and 105 deletions

View File

@ -1,7 +1,7 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020, 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -52,133 +52,148 @@ export enum TabLocation {
TOP = "top",
}
interface ITabPanelProps<T extends string> {
tab: Tab<T>;
}
function domIDForTabID(tabId: string): string {
return `mx_tabpanel_${tabId}`;
}
function TabPanel<T extends string>({ tab }: ITabPanelProps<T>): JSX.Element {
return (
<div
className="mx_TabbedView_tabPanel"
key={tab.id}
id={domIDForTabID(tab.id)}
aria-labelledby={`${domIDForTabID(tab.id)}_label`}
>
<AutoHideScrollbar className="mx_TabbedView_tabPanelContent">{tab.body}</AutoHideScrollbar>
</div>
);
}
interface ITabLabelProps<T extends string> {
tab: Tab<T>;
isActive: boolean;
onClick: () => void;
}
function TabLabel<T extends string>({ tab, isActive, onClick }: ITabLabelProps<T>): JSX.Element {
const classes = classNames("mx_TabbedView_tabLabel", {
mx_TabbedView_tabLabel_active: isActive,
});
let tabIcon: JSX.Element | undefined;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const id = domIDForTabID(tab.id);
const label = _t(tab.label);
return (
<RovingAccessibleButton
className={classes}
onClick={onClick}
data-testid={`settings-tab-${tab.id}`}
role="tab"
aria-selected={isActive}
aria-controls={id}
element="li"
>
{tabIcon}
<span className="mx_TabbedView_tabLabel_text" id={`${id}_label`}>
{label}
</span>
</RovingAccessibleButton>
);
}
interface IProps<T extends string> {
// An array of objects representign tabs that the tabbed view will display.
tabs: NonEmptyArray<Tab<T>>;
// The ID of the tab to display initially.
initialTabId?: T;
tabLocation: TabLocation;
// The location of the tabs, dictating the layout of the TabbedView.
tabLocation?: TabLocation;
// A callback that is called when the active tab changes.
onChange?: (tabId: T) => void;
// The screen name to report to Posthog.
screenName?: ScreenName;
}
interface IState<T extends string> {
activeTabId: T;
}
export default class TabbedView<T extends string> extends React.Component<IProps<T>, IState<T>> {
public constructor(props: IProps<T>) {
super(props);
/**
* A tabbed view component. Given objects representing content with titles, displays
* them in a tabbed view where the user can select which one of the items to view at once.
*/
export default function TabbedView<T extends string>(props: IProps<T>): JSX.Element {
const tabLocation = props.tabLocation ?? TabLocation.LEFT;
const [activeTabId, setActiveTabId] = React.useState<T>((): T => {
const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId);
this.state = {
activeTabId: initialTabIdIsValid ? props.initialTabId! : props.tabs[0].id,
};
}
// unfortunately typescript doesn't infer the types coorectly if the null check is included above
return initialTabIdIsValid && props.initialTabId ? props.initialTabId : props.tabs[0].id;
});
public static defaultProps = {
tabLocation: TabLocation.LEFT,
const getTabById = (id: T): Tab<T> | undefined => {
return props.tabs.find((tab) => tab.id === id);
};
private getTabById(id: T): Tab<T> | undefined {
return this.props.tabs.find((tab) => tab.id === id);
}
/**
* Shows the given tab
* @param {Tab} tab the tab to show
* @private
*/
private setActiveTab(tab: Tab<T>): void {
const setActiveTab = (tab: Tab<T>): void => {
// make sure this tab is still in available tabs
if (!!this.getTabById(tab.id)) {
if (this.props.onChange) this.props.onChange(tab.id);
this.setState({ activeTabId: tab.id });
if (!!getTabById(tab.id)) {
props.onChange?.(tab.id);
setActiveTabId(tab.id);
} else {
logger.error("Could not find tab " + tab.label + " in tabs");
}
}
};
private renderTabLabel(tab: Tab<T>): JSX.Element {
const isActive = this.state.activeTabId === tab.id;
const classes = classNames("mx_TabbedView_tabLabel", {
mx_TabbedView_tabLabel_active: isActive,
});
const labels = props.tabs.map((tab) => (
<TabLabel
key={"tab_label_" + tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onClick={() => setActiveTab(tab)}
/>
));
const tab = getTabById(activeTabId);
const panel = tab ? <TabPanel tab={tab} /> : null;
let tabIcon: JSX.Element | undefined;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const tabbedViewClasses = classNames({
mx_TabbedView: true,
mx_TabbedView_tabsOnLeft: tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: tabLocation == TabLocation.TOP,
});
const onClickHandler = (): void => this.setActiveTab(tab);
const id = this.getTabId(tab);
const screenName = tab?.screenName ?? props.screenName;
const label = _t(tab.label);
return (
<RovingAccessibleButton
className={classes}
key={"tab_label_" + tab.label}
onClick={onClickHandler}
data-testid={`settings-tab-${tab.id}`}
role="tab"
aria-selected={isActive}
aria-controls={id}
element="li"
return (
<div className={tabbedViewClasses}>
{screenName && <PosthogScreenTracker screenName={screenName} />}
<RovingTabIndexProvider
handleLoop
handleHomeEnd
handleLeftRight={tabLocation == TabLocation.TOP}
handleUpDown={tabLocation == TabLocation.LEFT}
>
{tabIcon}
<span className="mx_TabbedView_tabLabel_text" id={`${id}_label`}>
{label}
</span>
</RovingAccessibleButton>
);
}
private getTabId(tab: Tab<T>): string {
return `mx_tabpanel_${tab.id}`;
}
private renderTabPanel(tab: Tab<T>): React.ReactNode {
const id = this.getTabId(tab);
return (
<div className="mx_TabbedView_tabPanel" key={id} id={id} aria-labelledby={`${id}_label`}>
<AutoHideScrollbar className="mx_TabbedView_tabPanelContent">{tab.body}</AutoHideScrollbar>
</div>
);
}
public render(): React.ReactNode {
const labels = this.props.tabs.map((tab) => this.renderTabLabel(tab));
const tab = this.getTabById(this.state.activeTabId);
const panel = tab ? this.renderTabPanel(tab) : null;
const tabbedViewClasses = classNames({
mx_TabbedView: true,
mx_TabbedView_tabsOnLeft: this.props.tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: this.props.tabLocation == TabLocation.TOP,
});
const screenName = tab?.screenName ?? this.props.screenName;
return (
<div className={tabbedViewClasses}>
{screenName && <PosthogScreenTracker screenName={screenName} />}
<RovingTabIndexProvider
handleLoop
handleHomeEnd
handleLeftRight={this.props.tabLocation == TabLocation.TOP}
handleUpDown={this.props.tabLocation == TabLocation.LEFT}
>
{({ onKeyDownHandler }) => (
<ul
className="mx_TabbedView_tabLabels"
role="tablist"
aria-orientation={this.props.tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
onKeyDown={onKeyDownHandler}
>
{labels}
</ul>
)}
</RovingTabIndexProvider>
{panel}
</div>
);
}
{({ onKeyDownHandler }) => (
<ul
className="mx_TabbedView_tabLabels"
role="tablist"
aria-orientation={tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
onKeyDown={onKeyDownHandler}
>
{labels}
</ul>
)}
</RovingTabIndexProvider>
{panel}
</div>
);
}