feat(trading): 3114 pane context (#4442)
This commit is contained in:
parent
674baa1ee3
commit
74bf183fad
@ -58,7 +58,11 @@ const MainGrid = memo(
|
||||
>
|
||||
<TradeGridChild>
|
||||
<Tabs storageKey="console-trade-grid-main-left">
|
||||
<Tab id="chart" name={t('Chart')}>
|
||||
<Tab
|
||||
id="chart"
|
||||
name={t('Chart')}
|
||||
menu={<TradingViews.candles.menu />}
|
||||
>
|
||||
<TradingViews.candles.component marketId={marketId} />
|
||||
</Tab>
|
||||
<Tab id="depth" name={t('Depth')}>
|
||||
|
@ -37,6 +37,7 @@ export const TradePanels = ({
|
||||
const onOrderTypeClick = useMarketLiquidityClickHandler();
|
||||
|
||||
const [view, setView] = useState<TradingView>('candles');
|
||||
|
||||
const renderView = () => {
|
||||
const Component = memo<{
|
||||
marketId: string;
|
||||
@ -65,8 +66,23 @@ export const TradePanels = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenu = () => {
|
||||
const viewCfg = TradingViews[view];
|
||||
|
||||
if ('menu' in viewCfg) {
|
||||
const Menu = viewCfg.menu;
|
||||
return (
|
||||
<div className="flex gap-1 p-1 bg-vega-clight-800 dark:bg-vega-cdark-800 border-b border-default">
|
||||
<Menu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[min-content_1fr_min-content]">
|
||||
<div className="h-full grid grid-rows-[min-content_min-content_1fr_min-content]">
|
||||
<div>
|
||||
{FLAGS.SUCCESSOR_MARKETS && (
|
||||
<>
|
||||
@ -76,6 +92,7 @@ export const TradePanels = ({
|
||||
)}
|
||||
<OracleBanner marketId={market?.id || ''} />
|
||||
</div>
|
||||
<div>{renderMenu()}</div>
|
||||
<div className="h-full">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
|
@ -2,7 +2,10 @@ import type { ComponentProps } from 'react';
|
||||
import { Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import { TradesContainer } from '@vegaprotocol/trades';
|
||||
import { DepthChartContainer } from '@vegaprotocol/market-depth';
|
||||
import { CandlesChartContainer } from '@vegaprotocol/candles-chart';
|
||||
import {
|
||||
CandlesChartContainer,
|
||||
CandlesMenu,
|
||||
} from '@vegaprotocol/candles-chart';
|
||||
import { Filter } from '@vegaprotocol/orders';
|
||||
import { NO_MARKET } from './constants';
|
||||
import { OrderbookContainer } from '../../components/orderbook-container';
|
||||
@ -35,6 +38,7 @@ export const TradingViews = {
|
||||
candles: {
|
||||
label: 'Candles',
|
||||
component: requiresMarket(CandlesChartContainer),
|
||||
menu: CandlesMenu,
|
||||
},
|
||||
depth: {
|
||||
label: 'Depth',
|
||||
|
@ -1,71 +0,0 @@
|
||||
import { CandlesChartContainer } from './candles-chart';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { VegaWalletContext } from '@vegaprotocol/wallet';
|
||||
import { CandlesEventsDocument } from './__generated__/Candles';
|
||||
import type { CandlesEventsSubscription } from './__generated__/Candles';
|
||||
|
||||
const candles: CandlesEventsSubscription = {
|
||||
candles: {
|
||||
lastUpdateInPeriod: 0,
|
||||
periodStart: 0,
|
||||
open: '0',
|
||||
high: '0',
|
||||
low: '0',
|
||||
close: '0',
|
||||
volume: '0',
|
||||
},
|
||||
};
|
||||
const mocks = [
|
||||
{
|
||||
request: {
|
||||
query: CandlesEventsDocument,
|
||||
variables: { marketId: 'market-id', interval: 'INTERVAL_I15M' },
|
||||
},
|
||||
result: { data: candles },
|
||||
},
|
||||
];
|
||||
|
||||
describe('TradingChart', () => {
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(
|
||||
<MockedProvider>
|
||||
<VegaWalletContext.Provider value={{} as never}>
|
||||
<CandlesChartContainer marketId={'market-id'} />
|
||||
</VegaWalletContext.Provider>
|
||||
</MockedProvider>
|
||||
);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it('volume study should be preselected', async () => {
|
||||
act(() => {
|
||||
render(
|
||||
<MockedProvider mocks={mocks}>
|
||||
<VegaWalletContext.Provider value={{} as never}>
|
||||
<CandlesChartContainer marketId={'market-id'} />
|
||||
</VegaWalletContext.Provider>
|
||||
</MockedProvider>
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Studies', {
|
||||
selector: '[type="button"]',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
act(() => {
|
||||
userEvent.click(
|
||||
screen.getByText('Studies', {
|
||||
selector: '[type="button"]',
|
||||
})
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Volume')).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
@ -1,92 +1,12 @@
|
||||
import 'pennant/dist/style.css';
|
||||
import {
|
||||
CandlestickChart,
|
||||
ChartType,
|
||||
Interval,
|
||||
Overlay,
|
||||
Study,
|
||||
chartTypeLabels,
|
||||
intervalLabels,
|
||||
overlayLabels,
|
||||
studyLabels,
|
||||
} from 'pennant';
|
||||
import { CandlestickChart } from 'pennant';
|
||||
import { VegaDataSource } from './data-source';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useMemo } from 'react';
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
import {
|
||||
useThemeSwitcher,
|
||||
getValidItem,
|
||||
getValidSubset,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
import type { IconName } from '@blueprintjs/icons';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
interface StoredSettings {
|
||||
interval?: Interval;
|
||||
type?: ChartType;
|
||||
overlays?: Overlay[];
|
||||
studies?: Study[];
|
||||
}
|
||||
|
||||
export const useCandlesChartSettings = create<
|
||||
StoredSettings & {
|
||||
merge: (settings: StoredSettings) => void;
|
||||
setType: (type: ChartType) => void;
|
||||
setInterval: (interval: Interval) => void;
|
||||
setOverlays: (overlays: Overlay[]) => void;
|
||||
setStudies: (studies: Study[]) => void;
|
||||
}
|
||||
>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
merge: (settings: StoredSettings) =>
|
||||
set((state) => {
|
||||
Object.assign(state, settings);
|
||||
}),
|
||||
setType: (type: ChartType) =>
|
||||
set((state) => {
|
||||
state.type = type;
|
||||
}),
|
||||
setInterval: (interval: Interval) =>
|
||||
set((state) => {
|
||||
state.interval = interval;
|
||||
}),
|
||||
setOverlays: (overlays: Overlay[]) =>
|
||||
set((state) => {
|
||||
state.overlays = overlays;
|
||||
}),
|
||||
setStudies: (studies: Study[]) =>
|
||||
set((state) => {
|
||||
state.studies = studies;
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: 'console-candles',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const chartTypeIcon = new Map<ChartType, IconName>([
|
||||
[ChartType.AREA, IconNames.TIMELINE_AREA_CHART],
|
||||
[ChartType.CANDLE, IconNames.WATERFALL_CHART],
|
||||
[ChartType.LINE, IconNames.TIMELINE_LINE_CHART],
|
||||
[ChartType.OHLC, IconNames.WATERFALL_CHART],
|
||||
]);
|
||||
import { useCandlesChartSettings } from './use-candles-chart-settings';
|
||||
|
||||
export type CandlesChartContainerProps = {
|
||||
marketId: string;
|
||||
@ -99,161 +19,32 @@ export const CandlesChartContainer = ({
|
||||
const { pubKey } = useVegaWallet();
|
||||
const { theme } = useThemeSwitcher();
|
||||
|
||||
const settings = useCandlesChartSettings();
|
||||
|
||||
const interval: Interval = getValidItem(
|
||||
settings.interval,
|
||||
Object.values(Interval),
|
||||
Interval.I15M
|
||||
);
|
||||
|
||||
const chartType: ChartType = getValidItem(
|
||||
settings.type,
|
||||
Object.values(ChartType),
|
||||
ChartType.CANDLE
|
||||
);
|
||||
|
||||
const overlays: Overlay[] = getValidSubset(
|
||||
settings.overlays,
|
||||
Object.values(Overlay),
|
||||
[]
|
||||
);
|
||||
|
||||
const studies: Study[] = getValidSubset(
|
||||
settings.studies,
|
||||
Object.values(Study),
|
||||
[Study.VOLUME]
|
||||
);
|
||||
const { interval, chartType, overlays, studies, merge } =
|
||||
useCandlesChartSettings();
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
return new VegaDataSource(client, marketId, pubKey);
|
||||
}, [client, marketId, pubKey]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-3 lg:px-4 py-2 flex flex-row flex-wrap gap-2 bg-vega-clight-700 dark:bg-vega-cdark-700">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger>
|
||||
{t(`Interval: ${intervalLabels[interval]}`)}
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={interval}
|
||||
onValueChange={(value) => {
|
||||
settings.setInterval(value as Interval);
|
||||
}}
|
||||
>
|
||||
{Object.values(Interval).map((timeInterval) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={timeInterval}
|
||||
inset
|
||||
value={timeInterval}
|
||||
>
|
||||
{intervalLabels[timeInterval]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger>
|
||||
<Icon name={chartTypeIcon.get(chartType) as IconName} />
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={chartType}
|
||||
onValueChange={(value) => {
|
||||
settings.setType(value as ChartType);
|
||||
}}
|
||||
>
|
||||
{Object.values(ChartType).map((type) => (
|
||||
<DropdownMenuRadioItem key={type} inset value={type}>
|
||||
{chartTypeLabels[type]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={<DropdownMenuTrigger>{t('Overlays')}</DropdownMenuTrigger>}
|
||||
>
|
||||
<DropdownMenuContent>
|
||||
{Object.values(Overlay).map((overlay) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={overlay}
|
||||
checked={overlays.includes(overlay)}
|
||||
onCheckedChange={() => {
|
||||
const newOverlays = [...overlays];
|
||||
const index = overlays.findIndex((item) => item === overlay);
|
||||
|
||||
index !== -1
|
||||
? newOverlays.splice(index, 1)
|
||||
: newOverlays.push(overlay);
|
||||
|
||||
settings.setOverlays(newOverlays);
|
||||
}}
|
||||
>
|
||||
{overlayLabels[overlay]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={<DropdownMenuTrigger>{t('Studies')}</DropdownMenuTrigger>}
|
||||
>
|
||||
<DropdownMenuContent>
|
||||
{Object.values(Study).map((study) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={study}
|
||||
checked={studies.includes(study)}
|
||||
onCheckedChange={() => {
|
||||
const newStudies = [...studies];
|
||||
const index = studies.findIndex((item) => item === study);
|
||||
|
||||
index !== -1
|
||||
? newStudies.splice(index, 1)
|
||||
: newStudies.push(study);
|
||||
|
||||
settings.setStudies(newStudies);
|
||||
}}
|
||||
>
|
||||
{studyLabels[study]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CandlestickChart
|
||||
dataSource={dataSource}
|
||||
options={{
|
||||
chartType: chartType,
|
||||
overlays: overlays,
|
||||
studies: studies,
|
||||
notEnoughDataText: (
|
||||
<span className="text-xs text-center">{t('No data')}</span>
|
||||
),
|
||||
}}
|
||||
interval={interval}
|
||||
theme={theme}
|
||||
onOptionsChanged={(options) => {
|
||||
settings.merge({
|
||||
overlays: options.overlays,
|
||||
studies: options.studies,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CandlestickChart
|
||||
dataSource={dataSource}
|
||||
options={{
|
||||
chartType,
|
||||
overlays,
|
||||
studies,
|
||||
notEnoughDataText: (
|
||||
<span className="text-xs text-center">{t('No data')}</span>
|
||||
),
|
||||
}}
|
||||
interval={interval}
|
||||
theme={theme}
|
||||
onOptionsChanged={(options) => {
|
||||
merge({
|
||||
overlays: options.overlays,
|
||||
studies: options.studies,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
17
libs/candles-chart/src/lib/candles-menu.spec.tsx
Normal file
17
libs/candles-chart/src/lib/candles-menu.spec.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CandlesMenu } from './candles-menu';
|
||||
|
||||
describe('CandlesMenu', () => {
|
||||
it('should render with volume study showing by default', async () => {
|
||||
render(<CandlesMenu />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByText('Studies', {
|
||||
selector: '[type="button"]',
|
||||
})
|
||||
);
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Volume')).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
160
libs/candles-chart/src/lib/candles-menu.tsx
Normal file
160
libs/candles-chart/src/lib/candles-menu.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import 'pennant/dist/style.css';
|
||||
import {
|
||||
ChartType,
|
||||
Interval,
|
||||
Overlay,
|
||||
Study,
|
||||
chartTypeLabels,
|
||||
intervalLabels,
|
||||
overlayLabels,
|
||||
studyLabels,
|
||||
} from 'pennant';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
import type { IconName } from '@blueprintjs/icons';
|
||||
import { IconNames } from '@blueprintjs/icons';
|
||||
import { t } from '@vegaprotocol/i18n';
|
||||
import { useCandlesChartSettings } from './use-candles-chart-settings';
|
||||
|
||||
const chartTypeIcon = new Map<ChartType, IconName>([
|
||||
[ChartType.AREA, IconNames.TIMELINE_AREA_CHART],
|
||||
[ChartType.CANDLE, IconNames.WATERFALL_CHART],
|
||||
[ChartType.LINE, IconNames.TIMELINE_LINE_CHART],
|
||||
[ChartType.OHLC, IconNames.WATERFALL_CHART],
|
||||
]);
|
||||
|
||||
export const CandlesMenu = () => {
|
||||
const {
|
||||
interval,
|
||||
chartType,
|
||||
studies,
|
||||
overlays,
|
||||
setInterval,
|
||||
setType,
|
||||
setStudies,
|
||||
setOverlays,
|
||||
} = useCandlesChartSettings();
|
||||
const triggerClasses = 'text-xs';
|
||||
const contentAlign = 'end';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger className={triggerClasses}>
|
||||
{t(`Interval: ${intervalLabels[interval]}`)}
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent align={contentAlign}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={interval}
|
||||
onValueChange={(value) => {
|
||||
setInterval(value as Interval);
|
||||
}}
|
||||
>
|
||||
{Object.values(Interval).map((timeInterval) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={timeInterval}
|
||||
inset
|
||||
value={timeInterval}
|
||||
>
|
||||
{intervalLabels[timeInterval]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger className={triggerClasses}>
|
||||
<Icon name={chartTypeIcon.get(chartType) as IconName} />
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent align={contentAlign}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={chartType}
|
||||
onValueChange={(value) => {
|
||||
setType(value as ChartType);
|
||||
}}
|
||||
>
|
||||
{Object.values(ChartType).map((type) => (
|
||||
<DropdownMenuRadioItem key={type} inset value={type}>
|
||||
{chartTypeLabels[type]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger className={triggerClasses}>
|
||||
{t('Overlays')}
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent align={contentAlign}>
|
||||
{Object.values(Overlay).map((overlay) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={overlay}
|
||||
checked={overlays.includes(overlay)}
|
||||
onCheckedChange={() => {
|
||||
const newOverlays = [...overlays];
|
||||
const index = overlays.findIndex((item) => item === overlay);
|
||||
|
||||
index !== -1
|
||||
? newOverlays.splice(index, 1)
|
||||
: newOverlays.push(overlay);
|
||||
|
||||
setOverlays(newOverlays);
|
||||
}}
|
||||
>
|
||||
{overlayLabels[overlay]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenuTrigger className={triggerClasses}>
|
||||
{t('Studies')}
|
||||
</DropdownMenuTrigger>
|
||||
}
|
||||
>
|
||||
<DropdownMenuContent align={contentAlign}>
|
||||
{Object.values(Study).map((study) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={study}
|
||||
checked={studies.includes(study)}
|
||||
onCheckedChange={() => {
|
||||
const newStudies = [...studies];
|
||||
const index = studies.findIndex((item) => item === study);
|
||||
|
||||
index !== -1
|
||||
? newStudies.splice(index, 1)
|
||||
: newStudies.push(study);
|
||||
|
||||
setStudies(newStudies);
|
||||
}}
|
||||
>
|
||||
{studyLabels[study]}
|
||||
<DropdownMenuItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
export * from './__generated__/Candles';
|
||||
export * from './__generated__/Chart';
|
||||
export * from './candles-chart';
|
||||
export * from './candles-menu';
|
||||
export * from './data-source';
|
||||
|
99
libs/candles-chart/src/lib/use-candles-chart-settings.ts
Normal file
99
libs/candles-chart/src/lib/use-candles-chart-settings.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { getValidItem, getValidSubset } from '@vegaprotocol/react-helpers';
|
||||
import { ChartType, Interval, Study } from 'pennant';
|
||||
import { Overlay } from 'pennant';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
interface StoredSettings {
|
||||
interval: Interval;
|
||||
type: ChartType;
|
||||
overlays: Overlay[];
|
||||
studies: Study[];
|
||||
}
|
||||
|
||||
const DEFAULT_CHART_SETTINGS = {
|
||||
interval: Interval.I15M,
|
||||
type: ChartType.CANDLE,
|
||||
overlays: [],
|
||||
studies: [Study.VOLUME],
|
||||
};
|
||||
|
||||
export const useCandlesChartSettingsStore = create<
|
||||
StoredSettings & {
|
||||
merge: (settings: Partial<StoredSettings>) => void;
|
||||
setType: (type: ChartType) => void;
|
||||
setInterval: (interval: Interval) => void;
|
||||
setOverlays: (overlays: Overlay[]) => void;
|
||||
setStudies: (studies: Study[]) => void;
|
||||
}
|
||||
>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
...DEFAULT_CHART_SETTINGS,
|
||||
merge: (settings: Partial<StoredSettings>) =>
|
||||
set((state) => {
|
||||
Object.assign(state, settings);
|
||||
}),
|
||||
setType: (type) =>
|
||||
set((state) => {
|
||||
state.type = type;
|
||||
}),
|
||||
setInterval: (interval) =>
|
||||
set((state) => {
|
||||
state.interval = interval;
|
||||
}),
|
||||
setOverlays: (overlays) =>
|
||||
set((state) => {
|
||||
state.overlays = overlays;
|
||||
}),
|
||||
setStudies: (studies) =>
|
||||
set((state) => {
|
||||
state.studies = studies;
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: 'vega_candles_chart_store',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const useCandlesChartSettings = () => {
|
||||
const settings = useCandlesChartSettingsStore();
|
||||
|
||||
const interval: Interval = getValidItem(
|
||||
settings.interval,
|
||||
Object.values(Interval),
|
||||
Interval.I15M
|
||||
);
|
||||
|
||||
const chartType: ChartType = getValidItem(
|
||||
settings.type,
|
||||
Object.values(ChartType),
|
||||
ChartType.CANDLE
|
||||
);
|
||||
|
||||
const overlays: Overlay[] = getValidSubset(
|
||||
settings.overlays,
|
||||
Object.values(Overlay),
|
||||
[]
|
||||
);
|
||||
|
||||
const studies: Study[] = getValidSubset(
|
||||
settings.studies,
|
||||
Object.values(Study),
|
||||
[Study.VOLUME]
|
||||
);
|
||||
|
||||
return {
|
||||
interval,
|
||||
chartType,
|
||||
overlays,
|
||||
studies,
|
||||
setInterval: settings.setInterval,
|
||||
setType: settings.setType,
|
||||
setStudies: settings.setStudies,
|
||||
setOverlays: settings.setOverlays,
|
||||
merge: settings.merge,
|
||||
};
|
||||
};
|
@ -43,17 +43,18 @@ export const DropdownMenuTrigger = forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
DropdownMenuPrimitive.DropdownMenuTriggerProps
|
||||
>(({ className, children, ...props }, forwardedRef) => {
|
||||
const defaultClasses = [
|
||||
const triggerClasses = classNames(
|
||||
'text-sm py-1 px-2 rounded bg-transparent border whitespace-nowrap',
|
||||
'border-vega-light-200 dark:border-vega-dark-200',
|
||||
'hover:border-vega-light-300 dark:hover:border-vega-dark-300',
|
||||
].join(' ');
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
asChild={true}
|
||||
ref={forwardedRef}
|
||||
className={className || defaultClasses}
|
||||
className={triggerClasses}
|
||||
{...props}
|
||||
>
|
||||
<button>{children}</button>
|
||||
|
@ -30,4 +30,43 @@ describe('Tabs', () => {
|
||||
await userEvent.click(screen.getByText('Tab two'));
|
||||
expect(await screen.getByText('Tab two content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the menu component for the selected tab', async () => {
|
||||
render(
|
||||
<Tabs>
|
||||
<Tab
|
||||
id="one"
|
||||
name="Tab one"
|
||||
menu={
|
||||
<>
|
||||
<button>Tab 1 button 1</button>
|
||||
<button>Tab 1 button 2</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>Tab one content</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="two"
|
||||
name="Tab two"
|
||||
menu={
|
||||
<>
|
||||
<button>Tab 2 button 1</button>
|
||||
<button>Tab 2 button 2</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>Tab two content</p>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tab one content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tab 1 button 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tab 1 button 2')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Tab two'));
|
||||
expect(await screen.getByText('Tab two content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tab 2 button 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tab 2 button 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -2,10 +2,11 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import {
|
||||
useLocalStorageSnapshot,
|
||||
getValidItem,
|
||||
useResizeObserver,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Children, isValidElement, useState } from 'react';
|
||||
import { Children, isValidElement, useRef, useState } from 'react';
|
||||
export interface TabsProps extends TabsPrimitive.TabsProps {
|
||||
children: (ReactElement<TabProps> | null)[];
|
||||
}
|
||||
@ -24,6 +25,19 @@ export const Tabs = ({
|
||||
return children.find((v) => v)?.props.id;
|
||||
});
|
||||
|
||||
// Bunch of refs in order to detect wrapping in side the tabs so that we
|
||||
// can apply a bg color
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [wrapped, setWrapped] = useState(() =>
|
||||
isWrapped(tabsRef.current, menuRef.current)
|
||||
);
|
||||
|
||||
useResizeObserver(wrapperRef.current, () => {
|
||||
setWrapped(isWrapped(tabsRef.current, menuRef.current));
|
||||
});
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
{...props}
|
||||
@ -31,10 +45,14 @@ export const Tabs = ({
|
||||
onValueChange={onValueChange || setActiveTab}
|
||||
className="h-full grid grid-rows-[min-content_1fr]"
|
||||
>
|
||||
<div className="border-b border-default min-w-0">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="flex flex-wrap justify-between border-b border-default min-w-0"
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
className="flex flex-nowrap overflow-visible"
|
||||
role="tablist"
|
||||
ref={tabsRef}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child) || child.props.hidden) return null;
|
||||
@ -67,6 +85,27 @@ export const Tabs = ({
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={classNames('flex-1 p-1', {
|
||||
'bg-vega-clight-700 dark:bg-vega-cdark-700': wrapped,
|
||||
'': wrapped,
|
||||
})}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child) || child.props.hidden) return null;
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
value={child.props.id}
|
||||
className={classNames('flex flex-nowrap gap-1', {
|
||||
'justify-end': !wrapped,
|
||||
})}
|
||||
>
|
||||
{child.props.menu}
|
||||
</TabsPrimitive.Content>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full overflow-auto">
|
||||
{Children.map(children, (child) => {
|
||||
@ -92,6 +131,7 @@ interface TabProps {
|
||||
name: string;
|
||||
indicator?: ReactNode;
|
||||
hidden?: boolean;
|
||||
menu?: ReactNode;
|
||||
}
|
||||
|
||||
export const Tab = ({ children, ...props }: TabProps) => {
|
||||
@ -122,3 +162,13 @@ export const LocalStoragePersistTabs = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const isWrapped = (
|
||||
tabs: HTMLDivElement | null,
|
||||
menu: HTMLDivElement | null
|
||||
) => {
|
||||
if (!tabs || !menu) return;
|
||||
const listRect = tabs.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
return menuRect.y > listRect.y;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user