Merge branch 'master' into bui-text
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -9191,8 +9191,8 @@
|
||||
--bui-gray-6: #7b7b7b;
|
||||
--bui-gray-7: #9e9e9e;
|
||||
--bui-gray-8: #b4b4b4;
|
||||
--bui-bg: var(--bui-black);
|
||||
--bui-bg-surface-1: var(--bui-gray-1);
|
||||
--bui-bg: #333;
|
||||
--bui-bg-surface-1: #424242;
|
||||
--bui-bg-surface-2: var(--bui-gray-2);
|
||||
--bui-bg-solid: #9cc9ff;
|
||||
--bui-bg-solid-hover: #83b9fd;
|
||||
@@ -9217,7 +9217,7 @@
|
||||
--bui-fg-danger: #e22b2b;
|
||||
--bui-fg-warning: #e36d05;
|
||||
--bui-fg-success: #1db954;
|
||||
--bui-border: #fff3;
|
||||
--bui-border: #ffffff1f;
|
||||
--bui-border-hover: #fff6;
|
||||
--bui-border-pressed: #ffffff80;
|
||||
--bui-border-disabled: #fff3;
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
|
||||
import { Table as Table_2 } from '@tanstack/react-table';
|
||||
import type { TabListProps as TabListProps_2 } from 'react-aria-components';
|
||||
import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components';
|
||||
import { TabProps } from 'react-aria-components';
|
||||
import type { TabProps as TabProps_2 } from 'react-aria-components';
|
||||
import { TabsProps as TabsProps_2 } from 'react-aria-components';
|
||||
import { TdHTMLAttributes } from 'react';
|
||||
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
|
||||
@@ -1026,6 +1026,8 @@ export interface HeaderProps {
|
||||
tabs?: HeaderTab[];
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
// (undocumented)
|
||||
titleLink?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -1036,6 +1038,7 @@ export interface HeaderTab {
|
||||
id: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
matchStrategy?: TabMatchStrategy;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1642,12 +1645,20 @@ export const TabList: (props: TabListProps) => JSX_2.Element;
|
||||
// @public
|
||||
export interface TabListProps extends Omit<TabListProps_2<object>, 'items'> {}
|
||||
|
||||
// @public
|
||||
export type TabMatchStrategy = 'exact' | 'prefix';
|
||||
|
||||
// @public
|
||||
export const TabPanel: (props: TabPanelProps) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface TabPanelProps extends TabPanelProps_2 {}
|
||||
|
||||
// @public
|
||||
export interface TabProps extends TabProps_2 {
|
||||
matchStrategy?: 'exact' | 'prefix';
|
||||
}
|
||||
|
||||
// @public
|
||||
export const Tabs: (props: TabsProps) => JSX_2.Element | null;
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ const layoutDecorator = [
|
||||
left: 'var(--sb-panel-left)',
|
||||
top: 'var(--sb-panel-top)',
|
||||
bottom: 'var(--sb-panel-bottom)',
|
||||
backgroundColor: 'var(--bui-bg-surface-1)',
|
||||
backgroundColor: 'var(--sb-sidebar-bg)',
|
||||
borderRadius: 'var(--sb-panel-radius)',
|
||||
border: 'var(--sb-sidebar-border)',
|
||||
borderRight: 'var(--sb-sidebar-border-right)',
|
||||
@@ -132,7 +132,7 @@ const layoutDecorator = [
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
paddingInline: 'var(--sb-content-padding-inline)',
|
||||
paddingLeft: 'var(--sb-content-padding-inline)',
|
||||
minHeight: '200vh',
|
||||
}}
|
||||
>
|
||||
@@ -144,20 +144,6 @@ const layoutDecorator = [
|
||||
</Text>
|
||||
</Container>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
right: 'var(--sb-panel-right)',
|
||||
top: 'var(--sb-panel-top)',
|
||||
bottom: 'var(--sb-panel-bottom)',
|
||||
backgroundColor: 'var(--bui-bg-surface-1)',
|
||||
borderRadius: 'var(--sb-panel-radius)',
|
||||
border: 'var(--sb-options-border)',
|
||||
borderLeft: 'var(--sb-options-border-left)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
withRouter,
|
||||
@@ -244,6 +230,7 @@ export const WithEverything: Story = {
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
tabs,
|
||||
titleLink: '/',
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: args => (
|
||||
@@ -336,3 +323,154 @@ export const WithMockedURLNoMatch: Story = {
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTabsMatchingStrategies: Story = {
|
||||
args: {
|
||||
title: 'Route Matching Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
href: '/home',
|
||||
},
|
||||
{
|
||||
id: 'mentorship',
|
||||
label: 'Mentorship',
|
||||
href: '/mentorship',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Catalog',
|
||||
href: '/catalog',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<Header {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /mentorship/events
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
Notice how the "Mentorship" tab is active even though we're on a
|
||||
nested route. This is because it uses{' '}
|
||||
<code>matchStrategy="prefix"</code>.
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
• <strong>Home</strong>: exact matching (default) - not active
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Mentorship</strong>: prefix matching - IS active (URL starts
|
||||
with /mentorship)
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Catalog</strong>: prefix matching - not active
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Settings</strong>: exact matching (default) - not active
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTabsExactMatching: Story = {
|
||||
args: {
|
||||
title: 'Exact Matching Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'mentorship',
|
||||
label: 'Mentorship',
|
||||
href: '/mentorship',
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
label: 'Events',
|
||||
href: '/mentorship/events',
|
||||
},
|
||||
{
|
||||
id: 'mentors',
|
||||
label: 'Mentors',
|
||||
href: '/mentorship/mentors',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<Header {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /mentorship/events
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
With default exact matching, only the "Events" tab is active because
|
||||
it exactly matches the current URL. The "Mentorship" tab is not active
|
||||
even though the URL is under /mentorship.
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTabsPrefixMatchingDeep: Story = {
|
||||
args: {
|
||||
title: 'Deep Nesting Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Catalog',
|
||||
href: '/catalog',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
href: '/catalog/users',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
label: 'Components',
|
||||
href: '/catalog/components',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
|
||||
<Header {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /catalog/users/john/details
|
||||
</Text>
|
||||
<br />
|
||||
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
|
||||
<Text>
|
||||
• <strong>Catalog</strong>: URL starts with /catalog
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Users</strong>: URL starts with /catalog/users
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Components</strong>: not active (URL doesn't start with
|
||||
/catalog/components)
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
This demonstrates how prefix matching works with deeply nested routes.
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -32,7 +32,16 @@ declare module 'react-aria-components' {
|
||||
* @public
|
||||
*/
|
||||
export const Header = (props: HeaderProps) => {
|
||||
const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props;
|
||||
const {
|
||||
tabs,
|
||||
icon,
|
||||
title,
|
||||
titleLink,
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
customActions,
|
||||
onTabSelectionChange,
|
||||
} = props;
|
||||
|
||||
const { classNames } = useStyles('Header');
|
||||
|
||||
@@ -43,6 +52,7 @@ export const Header = (props: HeaderProps) => {
|
||||
<HeaderToolbar
|
||||
icon={icon}
|
||||
title={title}
|
||||
titleLink={titleLink}
|
||||
menuItems={menuItems}
|
||||
breadcrumbs={breadcrumbs}
|
||||
customActions={customActions}
|
||||
@@ -50,10 +60,15 @@ export const Header = (props: HeaderProps) => {
|
||||
/>
|
||||
{tabs && (
|
||||
<div className={classNames.tabsWrapper}>
|
||||
<Tabs onSelectionChange={props.onTabSelectionChange}>
|
||||
<Tabs onSelectionChange={onTabSelectionChange}>
|
||||
<TabList>
|
||||
{tabs?.map(tab => (
|
||||
<Tab key={tab.id} id={tab.id} href={tab.href}>
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
href={tab.href}
|
||||
matchStrategy={tab.matchStrategy}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-aria-components';
|
||||
import { Link, RouterProvider } from 'react-aria-components';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
|
||||
@@ -23,6 +23,7 @@ import { ButtonIcon } from '../ButtonIcon';
|
||||
import { Menu } from '../Menu';
|
||||
import { Text } from '../Text';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* A component that renders a toolbar.
|
||||
@@ -30,9 +31,17 @@ import { motion, useScroll, useTransform } from 'motion/react';
|
||||
* @internal
|
||||
*/
|
||||
export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
const { icon, title, menuItems, breadcrumbs, customActions, hasTabs } = props;
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
titleLink,
|
||||
menuItems,
|
||||
breadcrumbs,
|
||||
customActions,
|
||||
hasTabs,
|
||||
} = props;
|
||||
const { classNames } = useStyles('Header');
|
||||
|
||||
let navigate = useNavigate();
|
||||
const { scrollY } = useScroll();
|
||||
const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]);
|
||||
|
||||
@@ -87,80 +96,90 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
<div className={classNames.toolbarIcon}>{icon || <RiShapesLine />}</div>
|
||||
<Text variant="body">{title || 'Your plugin'}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames.toolbar} data-has-tabs={hasTabs}>
|
||||
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
|
||||
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
|
||||
<div className={classNames.toolbarName}>
|
||||
<div className={classNames.toolbarIcon}>
|
||||
{icon || <RiShapesLine />}
|
||||
</div>
|
||||
<Text variant="body-medium">{title || 'Your plugin'}</Text>
|
||||
<RouterProvider navigate={navigate} useHref={useHref}>
|
||||
<div className={classNames.toolbar} data-has-tabs={hasTabs}>
|
||||
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
|
||||
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
|
||||
{titleLink ? (
|
||||
<Link className={classNames.toolbarName} href={titleLink}>
|
||||
{titleContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classNames.toolbarName}>{titleContent}</div>
|
||||
)}
|
||||
{breadcrumbs && (
|
||||
<motion.div
|
||||
className={classNames.breadcrumbs}
|
||||
style={{
|
||||
opacity: breadcrumbOpacity,
|
||||
visibility: showBreadcrumbs ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.label} className={classNames.breadcrumb}>
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className={classNames.breadcrumbLink}
|
||||
data-active={index === breadcrumbs.length - 1}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
{breadcrumbs && (
|
||||
<motion.div
|
||||
className={classNames.breadcrumbs}
|
||||
style={{
|
||||
opacity: breadcrumbOpacity,
|
||||
visibility: showBreadcrumbs ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
/>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.label} className={classNames.breadcrumb}>
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className={classNames.breadcrumbLink}
|
||||
data-active={index === breadcrumbs.length - 1}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<RiArrowRightSLine
|
||||
size={16}
|
||||
className={classNames.breadcrumbSeparator}
|
||||
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
|
||||
{customActions}
|
||||
{menuItems && (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={props => (
|
||||
<ButtonIcon
|
||||
size="small"
|
||||
icon={<RiMore2Line />}
|
||||
variant="tertiary"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => option.onClick?.()}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner sideOffset={4} align="end">
|
||||
<Menu.Popup>
|
||||
{menuItems.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
onClick={() => option.onClick?.()}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { TabsProps } from 'react-aria-components';
|
||||
import { TabMatchStrategy } from '../Tabs';
|
||||
|
||||
/**
|
||||
* Props for the main Header component.
|
||||
@@ -24,6 +25,7 @@ import { TabsProps } from 'react-aria-components';
|
||||
export interface HeaderProps {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
titleLink?: string;
|
||||
breadcrumbs?: HeaderBreadcrumb[];
|
||||
customActions?: React.ReactNode;
|
||||
menuItems?: HeaderMenuItem[];
|
||||
@@ -40,6 +42,12 @@ export interface HeaderTab {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
/**
|
||||
* Strategy for matching the current route to determine if this tab should be active.
|
||||
* - 'exact': Tab href must exactly match the current pathname (default)
|
||||
* - 'prefix': Tab is active if current pathname starts with tab href
|
||||
*/
|
||||
matchStrategy?: TabMatchStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +79,7 @@ export interface HeaderBreadcrumb {
|
||||
export interface HeaderToolbarProps {
|
||||
icon?: HeaderProps['icon'];
|
||||
title?: HeaderProps['title'];
|
||||
titleLink?: HeaderProps['titleLink'];
|
||||
breadcrumbs?: HeaderProps['breadcrumbs'];
|
||||
customActions?: HeaderProps['customActions'];
|
||||
menuItems?: HeaderProps['menuItems'];
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
|
||||
import { HeaderPage } from './HeaderPage';
|
||||
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
|
||||
import { Button } from '../Button';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Button } from '../Button';
|
||||
import { Container } from '../Container';
|
||||
import { Text } from '../Text';
|
||||
|
||||
@@ -33,12 +33,6 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const withRouter = (Story: StoryFn) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const tabs: HeaderTab[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
@@ -73,8 +67,14 @@ const menuItems: HeaderMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const withRouter = (Story: StoryFn) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Extract layout decorator as a reusable constant
|
||||
export const layoutDecorator = [
|
||||
const layoutDecorator = [
|
||||
(Story: StoryFn) => (
|
||||
<>
|
||||
<div
|
||||
@@ -84,7 +84,7 @@ export const layoutDecorator = [
|
||||
left: 'var(--sb-panel-left)',
|
||||
top: 'var(--sb-panel-top)',
|
||||
bottom: 'var(--sb-panel-bottom)',
|
||||
backgroundColor: 'var(--bui-bg-surface-1)',
|
||||
backgroundColor: 'var(--sb-sidebar-bg)',
|
||||
borderRadius: 'var(--sb-panel-radius)',
|
||||
border: 'var(--sb-sidebar-border)',
|
||||
borderRight: 'var(--sb-sidebar-border-right)',
|
||||
@@ -93,7 +93,7 @@ export const layoutDecorator = [
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
paddingInline: 'var(--sb-content-padding-inline)',
|
||||
paddingLeft: 'var(--sb-content-padding-inline)',
|
||||
minHeight: '200vh',
|
||||
}}
|
||||
>
|
||||
@@ -105,23 +105,8 @@ export const layoutDecorator = [
|
||||
</Text>
|
||||
</Container>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '250px',
|
||||
position: 'fixed',
|
||||
right: 'var(--sb-panel-right)',
|
||||
top: 'var(--sb-panel-top)',
|
||||
bottom: 'var(--sb-panel-bottom)',
|
||||
backgroundColor: 'var(--bui-bg-surface-1)',
|
||||
borderRadius: 'var(--sb-panel-radius)',
|
||||
border: 'var(--sb-options-border)',
|
||||
borderLeft: 'var(--sb-options-border-left)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
withRouter,
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
@@ -171,13 +156,157 @@ export const WithLayout: Story = {
|
||||
args: {
|
||||
...WithEverything.args,
|
||||
},
|
||||
decorators: layoutDecorator,
|
||||
render: () => (
|
||||
<HeaderPage
|
||||
{...Default.args}
|
||||
menuItems={menuItems}
|
||||
tabs={tabs}
|
||||
customActions={<Button>Custom action</Button>}
|
||||
/>
|
||||
decorators: [withRouter, ...layoutDecorator],
|
||||
render: WithEverything.render,
|
||||
};
|
||||
|
||||
export const WithTabsMatchingStrategies: Story = {
|
||||
args: {
|
||||
title: 'Route Matching Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
href: '/home',
|
||||
},
|
||||
{
|
||||
id: 'mentorship',
|
||||
label: 'Mentorship',
|
||||
href: '/mentorship',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Catalog',
|
||||
href: '/catalog',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<HeaderPage {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /mentorship/events
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
Notice how the "Mentorship" tab is active even though we're on a
|
||||
nested route. This is because it uses{' '}
|
||||
<code>matchStrategy="prefix"</code>.
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
• <strong>Home</strong>: exact matching (default) - not active
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Mentorship</strong>: prefix matching - IS active (URL starts
|
||||
with /mentorship)
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Catalog</strong>: prefix matching - not active
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Settings</strong>: exact matching (default) - not active
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTabsExactMatching: Story = {
|
||||
args: {
|
||||
title: 'Exact Matching Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'mentorship',
|
||||
label: 'Mentorship',
|
||||
href: '/mentorship',
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
label: 'Events',
|
||||
href: '/mentorship/events',
|
||||
},
|
||||
{
|
||||
id: 'mentors',
|
||||
label: 'Mentors',
|
||||
href: '/mentorship/mentors',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<HeaderPage {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /mentorship/events
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
With default exact matching, only the "Events" tab is active because
|
||||
it exactly matches the current URL. The "Mentorship" tab is not active
|
||||
even though the URL is under /mentorship.
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTabsPrefixMatchingDeep: Story = {
|
||||
args: {
|
||||
title: 'Deep Nesting Demo',
|
||||
tabs: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Catalog',
|
||||
href: '/catalog',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
href: '/catalog/users',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
label: 'Components',
|
||||
href: '/catalog/components',
|
||||
matchStrategy: 'prefix',
|
||||
},
|
||||
],
|
||||
},
|
||||
render: args => (
|
||||
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
|
||||
<HeaderPage {...args} />
|
||||
<Container>
|
||||
<Text>
|
||||
<strong>Current URL:</strong> /catalog/users/john/details
|
||||
</Text>
|
||||
<br />
|
||||
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
|
||||
<Text>
|
||||
• <strong>Catalog</strong>: URL starts with /catalog
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Users</strong>: URL starts with /catalog/users
|
||||
</Text>
|
||||
<Text>
|
||||
• <strong>Components</strong>: not active (URL doesn't start with
|
||||
/catalog/components)
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
This demonstrates how prefix matching works with deeply nested routes.
|
||||
</Text>
|
||||
</Container>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -74,7 +74,12 @@ export const HeaderPage = (props: HeaderPageProps) => {
|
||||
<Tabs>
|
||||
<TabList>
|
||||
{tabs.map(tab => (
|
||||
<Tab key={tab.id} id={tab.id} href={tab.href}>
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
href={tab.href}
|
||||
matchStrategy={tab.matchStrategy}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
|
||||
@@ -174,3 +174,282 @@ export const WithMockedURLNoMatch: Story = {
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
// New stories for testing match strategies
|
||||
|
||||
export const ExactMatchingDefault: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="mentorship" href="/mentorship">
|
||||
Mentorship
|
||||
</Tab>
|
||||
<Tab id="events" href="/mentorship/events">
|
||||
Events
|
||||
</Tab>
|
||||
<Tab id="catalog" href="/catalog">
|
||||
Catalog
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/mentorship/events</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Using default exact matching, only the "Events" tab is active because
|
||||
it exactly matches the URL.
|
||||
</Text>
|
||||
<Text>
|
||||
The "Mentorship" tab is NOT active even though the URL contains
|
||||
"/mentorship".
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const PrefixMatchingForNestedRoutes: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/mentorship/events']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
|
||||
Mentorship
|
||||
</Tab>
|
||||
<Tab id="events" href="/mentorship/events">
|
||||
Events
|
||||
</Tab>
|
||||
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
|
||||
Catalog
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/mentorship/events</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
The "Mentorship" tab uses prefix matching and IS active because
|
||||
"/mentorship/events" starts with "/mentorship".
|
||||
</Text>
|
||||
<Text>
|
||||
The "Events" tab uses exact matching and is also active because it
|
||||
exactly matches.
|
||||
</Text>
|
||||
<Text>
|
||||
The "Catalog" tab uses prefix matching but is NOT active because the
|
||||
URL doesn't start with "/catalog".
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const PrefixMatchingDeepNesting: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="home" href="/home">
|
||||
Home
|
||||
</Tab>
|
||||
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
|
||||
Catalog
|
||||
</Tab>
|
||||
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
|
||||
Mentorship
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/catalog/users/john/details</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
The "Catalog" tab is active because it uses prefix matching and the
|
||||
URL starts with "/catalog".
|
||||
</Text>
|
||||
<Text>This works for any level of nesting under "/catalog".</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const MixedMatchingStrategies: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/dashboard/analytics/reports']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="overview" href="/dashboard">
|
||||
Overview
|
||||
</Tab>
|
||||
<Tab
|
||||
id="analytics"
|
||||
href="/dashboard/analytics"
|
||||
matchStrategy="prefix"
|
||||
>
|
||||
Analytics
|
||||
</Tab>
|
||||
<Tab id="settings" href="/dashboard/settings" matchStrategy="prefix">
|
||||
Settings
|
||||
</Tab>
|
||||
<Tab id="help" href="/help">
|
||||
Help
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/dashboard/analytics/reports</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
• "Overview" tab: exact matching, NOT active (doesn't exactly match
|
||||
"/dashboard")
|
||||
</Text>
|
||||
<Text>
|
||||
• "Analytics" tab: prefix matching, IS active (URL starts with
|
||||
"/dashboard/analytics")
|
||||
</Text>
|
||||
<Text>
|
||||
• "Settings" tab: prefix matching, NOT active (URL doesn't start with
|
||||
"/dashboard/settings")
|
||||
</Text>
|
||||
<Text>
|
||||
• "Help" tab: exact matching, NOT active (doesn't exactly match
|
||||
"/help")
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const PrefixMatchingEdgeCases: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/foobar']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="foo" href="/foo" matchStrategy="prefix">
|
||||
Foo
|
||||
</Tab>
|
||||
<Tab id="foobar" href="/foobar">
|
||||
Foobar
|
||||
</Tab>
|
||||
<Tab id="foo-exact" href="/foo">
|
||||
Foo (exact)
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/foobar</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
• "Foo" tab (prefix): NOT active - prevents "/foo" from matching
|
||||
"/foobar"
|
||||
</Text>
|
||||
<Text>
|
||||
• "Foobar" tab (exact): IS active - exactly matches "/foobar"
|
||||
</Text>
|
||||
<Text>
|
||||
• "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
|
||||
</Text>
|
||||
<Text>
|
||||
This shows that prefix matching properly requires a "/" separator to
|
||||
prevent false matches.
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const PrefixMatchingWithSlash: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="foo" href="/foo" matchStrategy="prefix">
|
||||
Foo
|
||||
</Tab>
|
||||
<Tab id="foobar" href="/foobar">
|
||||
Foobar
|
||||
</Tab>
|
||||
<Tab id="bar" href="/bar" matchStrategy="prefix">
|
||||
Bar
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/foo/bar</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
• "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
|
||||
</Text>
|
||||
<Text>
|
||||
• "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
|
||||
</Text>
|
||||
<Text>
|
||||
• "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
|
||||
"/bar"
|
||||
</Text>
|
||||
<Text>
|
||||
This demonstrates proper prefix matching with the "/" separator.
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
export const RootPathMatching: Story = {
|
||||
args: {
|
||||
children: '',
|
||||
},
|
||||
render: () => (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="home" href="/">
|
||||
Home
|
||||
</Tab>
|
||||
<Tab id="home-prefix" href="/" matchStrategy="prefix">
|
||||
Home (prefix)
|
||||
</Tab>
|
||||
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
|
||||
Catalog
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<Box mt="6" pl="2">
|
||||
<Text>
|
||||
Current URL: <strong>/</strong>
|
||||
</Text>
|
||||
<Text>• "Home" tab (exact): IS active - exactly matches "/"</Text>
|
||||
<Text>• "Home (prefix)" tab: IS active - "/" matches "/"</Text>
|
||||
<Text>
|
||||
• "Catalog" tab (prefix): NOT active - "/" doesn't start with
|
||||
"/catalog"
|
||||
</Text>
|
||||
</Box>
|
||||
</MemoryRouter>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
TabListProps,
|
||||
TabPanelProps,
|
||||
TabsContextValue,
|
||||
TabProps,
|
||||
} from './types';
|
||||
import { useLocation, useNavigate, useHref } from 'react-router-dom';
|
||||
import { TabsIndicators } from './TabsIndicators';
|
||||
@@ -53,6 +54,29 @@ const useTabsContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to determine if a tab should be active based on the matching strategy.
|
||||
* This follows the pattern used in WorkaroundNavLink from the sidebar.
|
||||
*/
|
||||
const isTabActive = (
|
||||
tabHref: string,
|
||||
currentPathname: string,
|
||||
matchStrategy: 'exact' | 'prefix',
|
||||
): boolean => {
|
||||
if (matchStrategy === 'exact') {
|
||||
return tabHref === currentPathname;
|
||||
}
|
||||
|
||||
// Prefix matching - similar to WorkaroundNavLink behavior
|
||||
if (tabHref === currentPathname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if current path starts with tab href followed by a slash
|
||||
// This prevents /foo matching /foobar
|
||||
return currentPathname.startsWith(`${tabHref}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that renders a list of tabs.
|
||||
*
|
||||
@@ -83,11 +107,12 @@ export const Tabs = (props: TabsProps) => {
|
||||
if (isValidElement(child) && child.type === TabList) {
|
||||
const tabListChildren = Children.toArray(child.props.children);
|
||||
for (const tabChild of tabListChildren) {
|
||||
if (
|
||||
isValidElement(tabChild) &&
|
||||
tabChild.props.href === location.pathname
|
||||
) {
|
||||
return tabChild.props.id;
|
||||
if (isValidElement(tabChild) && tabChild.props.href) {
|
||||
// Use tab-specific strategy, defaulting to 'exact'
|
||||
const strategy = tabChild.props.matchStrategy || 'exact';
|
||||
if (isTabActive(tabChild.props.href, location.pathname, strategy)) {
|
||||
return tabChild.props.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +198,8 @@ export const TabList = (props: TabListProps) => {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const Tab = (props: AriaTabProps) => {
|
||||
const { href, children, id, ...rest } = props;
|
||||
export const Tab = (props: TabProps) => {
|
||||
const { href, children, id, matchStrategy: _matchStrategy, ...rest } = props;
|
||||
const { classNames } = useStyles('Tabs');
|
||||
const { setTabRef } = useTabsContext();
|
||||
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
*/
|
||||
|
||||
export { Tabs, TabList, Tab, TabPanel } from './Tabs';
|
||||
export type { TabsProps, TabListProps, TabPanelProps } from './types';
|
||||
export type {
|
||||
TabsProps,
|
||||
TabListProps,
|
||||
TabPanelProps,
|
||||
TabProps,
|
||||
TabMatchStrategy,
|
||||
} from './types';
|
||||
|
||||
@@ -18,9 +18,17 @@ import type {
|
||||
TabsProps as AriaTabsProps,
|
||||
TabListProps as AriaTabListProps,
|
||||
TabPanelProps as AriaTabPanelProps,
|
||||
TabProps as AriaTabProps,
|
||||
} from 'react-aria-components';
|
||||
import { MutableRefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Strategies for matching the current route to determine which tab should be active.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TabMatchStrategy = 'exact' | 'prefix';
|
||||
|
||||
/**
|
||||
* Props for the Tabs component.
|
||||
*
|
||||
@@ -35,6 +43,20 @@ export interface TabsProps extends AriaTabsProps {}
|
||||
*/
|
||||
export interface TabListProps extends Omit<AriaTabListProps<object>, 'items'> {}
|
||||
|
||||
/**
|
||||
* Props for the Tab component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TabProps extends AriaTabProps {
|
||||
/**
|
||||
* Strategy for matching the current route to determine if this tab should be active.
|
||||
* - 'exact': Tab href must exactly match the current pathname (default)
|
||||
* - 'prefix': Tab is active if current pathname starts with tab href
|
||||
*/
|
||||
matchStrategy?: 'exact' | 'prefix';
|
||||
}
|
||||
|
||||
/** Context for sharing refs between Tabs and TabList
|
||||
*
|
||||
* @internal
|
||||
@@ -66,17 +88,3 @@ export interface TabsIndicatorsProps {
|
||||
hoveredKey: string | null;
|
||||
prevHoveredKey: MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for sharing refs and state between Tabs and TabList components.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface TabsContextValue {
|
||||
tabsRef: React.RefObject<HTMLDivElement>;
|
||||
tabRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
hoveredKey: string | null;
|
||||
prevHoveredKey: React.MutableRefObject<string | null>;
|
||||
setHoveredKey: (key: string | null) => void;
|
||||
setTabRef: (key: string, element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@
|
||||
--bui-gray-8: #b4b4b4;
|
||||
|
||||
/* Background Colors */
|
||||
--bui-bg: var(--bui-black);
|
||||
--bui-bg-surface-1: var(--bui-gray-1);
|
||||
--bui-bg: #333333;
|
||||
--bui-bg-surface-1: #424242;
|
||||
--bui-bg-surface-2: var(--bui-gray-2);
|
||||
--bui-bg-solid: #9cc9ff;
|
||||
--bui-bg-solid-hover: #83b9fd;
|
||||
@@ -185,7 +185,7 @@
|
||||
--bui-fg-success: #1db954;
|
||||
|
||||
/* Border Colors */
|
||||
--bui-border: rgba(255, 255, 255, 0.2);
|
||||
--bui-border: rgba(255, 255, 255, 0.12);
|
||||
--bui-border-hover: rgba(255, 255, 255, 0.4);
|
||||
--bui-border-pressed: rgba(255, 255, 255, 0.5);
|
||||
--bui-border-disabled: rgba(255, 255, 255, 0.2);
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
--sb-panel-right: 0;
|
||||
--sb-sidebar-border: none;
|
||||
--sb-sidebar-border-right: 1px solid var(--bui-border);
|
||||
--sb-sidebar-bg: #000;
|
||||
--sb-options-border: none;
|
||||
--sb-options-border-left: 1px solid var(--bui-border);
|
||||
--sb-content-padding-inline: 250px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--sb-sidebar-bg: var(--bui-bg-surface-1);
|
||||
}
|
||||
|
||||
[data-theme-name='spotify'] {
|
||||
--sb-panel-radius: var(--bui-radius-3);
|
||||
--sb-panel-top: 8px;
|
||||
@@ -19,6 +24,7 @@
|
||||
--sb-panel-right: 8px;
|
||||
--sb-sidebar-border: none;
|
||||
--sb-sidebar-border-right: none;
|
||||
--sb-sidebar-bg: var(--bui-bg-surface-1);
|
||||
--sb-options-border: none;
|
||||
--sb-options-border-left: none;
|
||||
--sb-content-padding-inline: 258px;
|
||||
|
||||
Reference in New Issue
Block a user