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
This commit is contained in:
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

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,53 +52,34 @@ export enum TabLocation {
TOP = "top",
}
interface IProps<T extends string> {
tabs: NonEmptyArray<Tab<T>>;
initialTabId?: T;
tabLocation: TabLocation;
onChange?: (tabId: T) => void;
screenName?: ScreenName;
interface ITabPanelProps<T extends string> {
tab: Tab<T>;
}
interface IState<T extends string> {
activeTabId: T;
function domIDForTabID(tabId: string): string {
return `mx_tabpanel_${tabId}`;
}
export default class TabbedView<T extends string> extends React.Component<IProps<T>, IState<T>> {
public constructor(props: IProps<T>) {
super(props);
const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId);
this.state = {
activeTabId: initialTabIdIsValid ? props.initialTabId! : props.tabs[0].id,
};
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>
);
}
public static defaultProps = {
tabLocation: TabLocation.LEFT,
};
private getTabById(id: T): Tab<T> | undefined {
return this.props.tabs.find((tab) => tab.id === id);
interface ITabLabelProps<T extends string> {
tab: Tab<T>;
isActive: boolean;
onClick: () => void;
}
/**
* Shows the given tab
* @param {Tab} tab the tab to show
* @private
*/
private 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 });
} 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;
function TabLabel<T extends string>({ tab, isActive, onClick }: ITabLabelProps<T>): JSX.Element {
const classes = classNames("mx_TabbedView_tabLabel", {
mx_TabbedView_tabLabel_active: isActive,
});
@ -108,15 +89,13 @@ export default class TabbedView<T extends string> extends React.Component<IProps
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const onClickHandler = (): void => this.setActiveTab(tab);
const id = this.getTabId(tab);
const id = domIDForTabID(tab.id);
const label = _t(tab.label);
return (
<RovingAccessibleButton
className={classes}
key={"tab_label_" + tab.label}
onClick={onClickHandler}
onClick={onClick}
data-testid={`settings-tab-${tab.id}`}
role="tab"
aria-selected={isActive}
@ -131,31 +110,68 @@ export default class TabbedView<T extends string> extends React.Component<IProps
);
}
private getTabId(tab: Tab<T>): string {
return `mx_tabpanel_${tab.id}`;
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;
// 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;
}
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>
);
}
/**
* 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;
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 [activeTabId, setActiveTabId] = React.useState<T>((): T => {
const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId);
// 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;
});
const getTabById = (id: T): Tab<T> | undefined => {
return props.tabs.find((tab) => tab.id === id);
};
/**
* Shows the given tab
* @param {Tab} tab the tab to show
*/
const setActiveTab = (tab: Tab<T>): void => {
// make sure this tab is still in available tabs
if (!!getTabById(tab.id)) {
props.onChange?.(tab.id);
setActiveTabId(tab.id);
} else {
logger.error("Could not find tab " + tab.label + " in tabs");
}
};
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;
const tabbedViewClasses = classNames({
mx_TabbedView: true,
mx_TabbedView_tabsOnLeft: this.props.tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: this.props.tabLocation == TabLocation.TOP,
mx_TabbedView_tabsOnLeft: tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: tabLocation == TabLocation.TOP,
});
const screenName = tab?.screenName ?? this.props.screenName;
const screenName = tab?.screenName ?? props.screenName;
return (
<div className={tabbedViewClasses}>
@ -163,14 +179,14 @@ export default class TabbedView<T extends string> extends React.Component<IProps
<RovingTabIndexProvider
handleLoop
handleHomeEnd
handleLeftRight={this.props.tabLocation == TabLocation.TOP}
handleUpDown={this.props.tabLocation == TabLocation.LEFT}
handleLeftRight={tabLocation == TabLocation.TOP}
handleUpDown={tabLocation == TabLocation.LEFT}
>
{({ onKeyDownHandler }) => (
<ul
className="mx_TabbedView_tabLabels"
role="tablist"
aria-orientation={this.props.tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
aria-orientation={tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
onKeyDown={onKeyDownHandler}
>
{labels}
@ -181,4 +197,3 @@ export default class TabbedView<T extends string> extends React.Component<IProps
</div>
);
}
}