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