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,53 +52,34 @@ export enum TabLocation {
TOP = "top", TOP = "top",
} }
interface IProps<T extends string> { interface ITabPanelProps<T extends string> {
tabs: NonEmptyArray<Tab<T>>; tab: Tab<T>;
initialTabId?: T;
tabLocation: TabLocation;
onChange?: (tabId: T) => void;
screenName?: ScreenName;
} }
interface IState<T extends string> { function domIDForTabID(tabId: string): string {
activeTabId: T; return `mx_tabpanel_${tabId}`;
} }
export default class TabbedView<T extends string> extends React.Component<IProps<T>, IState<T>> { function TabPanel<T extends string>({ tab }: ITabPanelProps<T>): JSX.Element {
public constructor(props: IProps<T>) { return (
super(props); <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>
);
}
const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId); interface ITabLabelProps<T extends string> {
this.state = { tab: Tab<T>;
activeTabId: initialTabIdIsValid ? props.initialTabId! : props.tabs[0].id, isActive: boolean;
}; onClick: () => void;
} }
public static defaultProps = { function TabLabel<T extends string>({ tab, isActive, onClick }: ITabLabelProps<T>): JSX.Element {
tabLocation: TabLocation.LEFT,
};
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 {
// 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;
const classes = classNames("mx_TabbedView_tabLabel", { const classes = classNames("mx_TabbedView_tabLabel", {
mx_TabbedView_tabLabel_active: isActive, 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}`} />; tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
} }
const onClickHandler = (): void => this.setActiveTab(tab); const id = domIDForTabID(tab.id);
const id = this.getTabId(tab);
const label = _t(tab.label); const label = _t(tab.label);
return ( return (
<RovingAccessibleButton <RovingAccessibleButton
className={classes} className={classes}
key={"tab_label_" + tab.label} onClick={onClick}
onClick={onClickHandler}
data-testid={`settings-tab-${tab.id}`} data-testid={`settings-tab-${tab.id}`}
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
@ -129,33 +108,70 @@ export default class TabbedView<T extends string> extends React.Component<IProps
</span> </span>
</RovingAccessibleButton> </RovingAccessibleButton>
); );
} }
private getTabId(tab: Tab<T>): string { interface IProps<T extends string> {
return `mx_tabpanel_${tab.id}`; // 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); * A tabbed view component. Given objects representing content with titles, displays
return ( * them in a tabbed view where the user can select which one of the items to view at once.
<div className="mx_TabbedView_tabPanel" key={id} id={id} aria-labelledby={`${id}_label`}> */
<AutoHideScrollbar className="mx_TabbedView_tabPanelContent">{tab.body}</AutoHideScrollbar> export default function TabbedView<T extends string>(props: IProps<T>): JSX.Element {
</div> const tabLocation = props.tabLocation ?? TabLocation.LEFT;
);
}
public render(): React.ReactNode { const [activeTabId, setActiveTabId] = React.useState<T>((): T => {
const labels = this.props.tabs.map((tab) => this.renderTabLabel(tab)); const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId);
const tab = this.getTabById(this.state.activeTabId); // unfortunately typescript doesn't infer the types coorectly if the null check is included above
const panel = tab ? this.renderTabPanel(tab) : null; 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({ const tabbedViewClasses = classNames({
mx_TabbedView: true, mx_TabbedView: true,
mx_TabbedView_tabsOnLeft: this.props.tabLocation == TabLocation.LEFT, mx_TabbedView_tabsOnLeft: tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: this.props.tabLocation == TabLocation.TOP, mx_TabbedView_tabsOnTop: tabLocation == TabLocation.TOP,
}); });
const screenName = tab?.screenName ?? this.props.screenName; const screenName = tab?.screenName ?? props.screenName;
return ( return (
<div className={tabbedViewClasses}> <div className={tabbedViewClasses}>
@ -163,14 +179,14 @@ export default class TabbedView<T extends string> extends React.Component<IProps
<RovingTabIndexProvider <RovingTabIndexProvider
handleLoop handleLoop
handleHomeEnd handleHomeEnd
handleLeftRight={this.props.tabLocation == TabLocation.TOP} handleLeftRight={tabLocation == TabLocation.TOP}
handleUpDown={this.props.tabLocation == TabLocation.LEFT} handleUpDown={tabLocation == TabLocation.LEFT}
> >
{({ onKeyDownHandler }) => ( {({ onKeyDownHandler }) => (
<ul <ul
className="mx_TabbedView_tabLabels" className="mx_TabbedView_tabLabels"
role="tablist" role="tablist"
aria-orientation={this.props.tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"} aria-orientation={tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
> >
{labels} {labels}
@ -180,5 +196,4 @@ export default class TabbedView<T extends string> extends React.Component<IProps
{panel} {panel}
</div> </div>
); );
}
} }