diff --git a/pages/03-core/core-dual-axis-chart.page.tsx b/pages/03-core/core-dual-axis-chart.page.tsx new file mode 100644 index 00000000..a053c2ca --- /dev/null +++ b/pages/03-core/core-dual-axis-chart.page.tsx @@ -0,0 +1,224 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { omit } from "lodash"; + +import Link from "@cloudscape-design/components/link"; + +import CoreChart from "../../lib/components/internal-do-not-use/core-chart"; +import { dateFormatter } from "../common/formatters"; +import { PageSettingsForm, useChartSettings } from "../common/page-settings"; +import { Page } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + +function randomInt(min: number, max: number) { + return min + Math.floor(pseudoRandom() * (max - min)); +} + +function shuffleArray(array: T[]): void { + let currentIndex = array.length; + while (currentIndex !== 0) { + const randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]; + } +} + +const colors = [ + "#F15C80", + "#2B908F", + "#F45B5B", + "#91E8E1", + "#8085E9", + "#E4D354", + "#8D4654", + "#7798BF", + "#AAEEEE", + "#FF9655", +]; + +const dashStyles: Highcharts.DashStyleValue[] = [ + "Dash", + "DashDot", + "Dot", + "LongDash", + "LongDashDot", + "LongDashDotDot", + "ShortDash", + "ShortDashDot", + "ShortDashDotDot", + "ShortDot", + "Solid", +]; + +const baseline = [ + { x: 1600984800000, y: 58020 }, + { x: 1600985700000, y: 102402 }, + { x: 1600986600000, y: 104920 }, + { x: 1600987500000, y: 94031 }, + { x: 1600988400000, y: 125021 }, + { x: 1600989300000, y: 159219 }, + { x: 1600990200000, y: 193082 }, + { x: 1600991100000, y: 162592 }, + { x: 1600992000000, y: 274021 }, + { x: 1600992900000, y: 264286 }, + { x: 1600993800000, y: 289210 }, + { x: 1600994700000, y: 256362 }, + { x: 1600995600000, y: 257306 }, + { x: 1600996500000, y: 186776 }, + { x: 1600997400000, y: 294020 }, + { x: 1600998300000, y: 385975 }, + { x: 1600999200000, y: 486039 }, + { x: 1601000100000, y: 490447 }, + { x: 1601001000000, y: 361845 }, + { x: 1601001900000, y: 339058 }, + { x: 1601002800000, y: 298028 }, + { x: 1601003400000, y: 255555 }, + { x: 1601003700000, y: 231902 }, + { x: 1601004600000, y: 224558 }, + { x: 1601005500000, y: 253901 }, + { x: 1601006400000, y: 102839 }, + { x: 1601007300000, y: 234943 }, + { x: 1601008200000, y: 204405 }, + { x: 1601009100000, y: 190391 }, + { x: 1601010000000, y: 183570 }, + { x: 1601010900000, y: 162592 }, + { x: 1601011800000, y: 148910 }, +]; + +const generatePrimaryAxisData = (letter: string, index: number) => { + return baseline.map(({ x, y }) => ({ + name: `Events ${letter}`, + x, + y: y === null ? null : y + randomInt(-100000 * ((index % 3) + 1), 100000 * ((index % 3) + 1)), + })); +}; + +const generateSecondaryAxisData = (letter: string, index: number) => { + return baseline.map(({ x, y }) => ({ + name: `Percentage ${letter}`, + x, + y: y === null ? null : (y / 10000) * randomInt(3 + (index % 5), 10 + (index % 10)), + })); +}; + +const primarySeriesData: Record> = {}; +for (let i = 0; i < 10; i++) { + const letter = String.fromCharCode(65 + i); + primarySeriesData[`data${letter}`] = generatePrimaryAxisData(letter, i); +} + +const secondarySeriesData: Record> = {}; +for (let i = 0; i < 10; i++) { + const letter = String.fromCharCode(65 + i); + secondarySeriesData[`data${letter}`] = generateSecondaryAxisData(letter, i); +} + +const series: Highcharts.SeriesOptionsType[] = []; + +Object.entries(primarySeriesData).forEach(([, data], index) => { + series.push({ + name: data[0].name, + type: "line", + data: data, + yAxis: 0, + color: colors[index], + }); +}); + +Object.entries(secondarySeriesData).forEach(([, data], index) => { + series.push({ + name: data[0].name, + type: "line", + data: data, + yAxis: 1, + color: colors[index], + dashStyle: dashStyles[index % dashStyles.length], + }); +}); + +shuffleArray(series); + +export default function () { + const { chartProps } = useChartSettings(); + return ( + + } + > + ({ + header: ( +
+
+ {legendItem.marker} + {legendItem.name} +
+
+ ), + body: ( + <> + + + + + + + + + + + + + + + +
Period15 min
StatisticAverage
UnitCount
+ + ), + footer: ( + + Learn more + + ), + })} + /> +
+ ); +} diff --git a/pages/common/page-settings.tsx b/pages/common/page-settings.tsx index 2004aa22..0dbb0ba8 100644 --- a/pages/common/page-settings.tsx +++ b/pages/common/page-settings.tsx @@ -33,7 +33,9 @@ export interface PageSettings { tooltipSize: "small" | "medium" | "large"; showLegend: boolean; showLegendTitle: boolean; + showOppositeLegendTitle: boolean; showLegendActions: boolean; + legendType: "single" | "dual"; legendBottomMaxHeight?: number; legendPosition: "bottom" | "side"; showCustomHeader: boolean; @@ -59,6 +61,8 @@ const DEFAULT_SETTINGS: PageSettings = { tooltipSize: "medium", showLegend: true, showLegendTitle: false, + showOppositeLegendTitle: false, + legendType: "single", legendPosition: "bottom", showLegendActions: false, showCustomHeader: false, @@ -146,7 +150,9 @@ export function useChartSettings : undefined, + type: settings.legendType, position: settings.legendPosition, bottomMaxHeight: settings.legendBottomMaxHeight, }; @@ -343,6 +349,20 @@ export function PageSettingsForm({ Show legend ); + case "legendType": + return ( + + setSettings({ legendType: detail.selectedId as string as "single" | "dual" }) + } + /> + ); case "showLegendTitle": return ( ); + case "showOppositeLegendTitle": + return ( + setSettings({ showOppositeLegendTitle: detail.checked })} + > + Show opposite legend title + + ); case "showLegendActions": return ( { private initLegend = () => { const itemSpecs = getChartLegendItems(this.context.chart()); - const legendItems = itemSpecs.map(({ id, name, color, markerType, visible }) => { + const legendItems = itemSpecs.map(({ id, name, color, markerType, visible, oppositeAxis }) => { const marker = this.renderMarker(markerType, color, visible); - return { id, name, marker, visible, highlighted: false }; + return { id, name, marker, visible, oppositeAxis, highlighted: false }; }); this.updateLegendItems(legendItems); }; diff --git a/src/core/chart-container.tsx b/src/core/chart-container.tsx index c761ab83..51aa4d74 100644 --- a/src/core/chart-container.tsx +++ b/src/core/chart-container.tsx @@ -29,7 +29,6 @@ interface ChartContainerProps { filter?: React.ReactNode; navigator?: React.ReactNode; legend?: React.ReactNode; - legendBottomMaxHeight?: number; legendPosition: "bottom" | "side"; footer?: React.ReactNode; noData?: React.ReactNode; @@ -48,7 +47,6 @@ export function ChartContainer({ footer, legend, legendPosition, - legendBottomMaxHeight, navigator, noData, fitHeight, @@ -106,9 +104,7 @@ export function ChartContainer({
{navigator &&
{navigator}
} - {legend && - legendPosition === "bottom" && - (legendBottomMaxHeight ?
{legend}
: legend)} + {legendPosition === "bottom" && legend} {footer}
diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index ad4116b4..53a780a3 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -83,6 +83,7 @@ export function InternalCoreChart({ const rootRef = useRef(null); const mergedRootRef = useMergeRefs(rootRef, __internalRootRef); const rootProps = { ref: mergedRootRef, className: rootClassName, ...getDataAttributes(rest) }; + const legendType = legendOptions?.type ?? "single"; const legendPosition = legendOptions?.position ?? "bottom"; const containerProps = { fitHeight, @@ -91,7 +92,6 @@ export function InternalCoreChart({ chartMinWidth, legendPosition, verticalAxisTitlePlacement, - legendBottomMaxHeight: legendOptions?.bottomMaxHeight, }; // Render fallback using the same root and container props as for the chart to ensure consistent @@ -305,6 +305,7 @@ export function InternalCoreChart({ context.legendEnabled && hasVisibleLegendItems(options) ? ( ; @@ -37,10 +43,13 @@ export function ChartLegend({ return ( { diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index db134147..94385b56 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -413,7 +413,9 @@ export namespace CoreChartProps { export interface LegendOptions extends BaseLegendOptions { bottomMaxHeight?: number; + type?: "single" | "dual"; position?: "bottom" | "side"; + oppositeLegendTitle?: string; } export type LegendItem = InternalComponentTypes.LegendItem; export type LegendTooltipContent = InternalComponentTypes.LegendTooltipContent; diff --git a/src/core/styles.scss b/src/core/styles.scss index 73aa2c79..d408153e 100644 --- a/src/core/styles.scss +++ b/src/core/styles.scss @@ -114,7 +114,6 @@ $side-legend-min-inline-size: max(20%, 150px); .side-legend-container { flex: 0; - overflow-y: auto; max-inline-size: $side-legend-max-inline-size; min-inline-size: $side-legend-min-inline-size; } diff --git a/src/core/utils.ts b/src/core/utils.ts index d8ff9ee1..db36d3c9 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -15,6 +15,7 @@ export interface LegendItemSpec { markerType: ChartSeriesMarkerType; color: string; visible: boolean; + oppositeAxis: boolean; } // The below functions extract unique identifier from series, point, or options. The identifier can be item's ID or name. @@ -151,10 +152,11 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte markerType: getSeriesMarkerType(series), color: getSeriesColor(series), visible: series.visible, + oppositeAxis: series.yAxis.options.opposite ?? false, }); } }; - const addPointItem = (point: Highcharts.Point) => { + const addPointItem = (point: Highcharts.Point, oppositeAxis: boolean) => { if (point.series.type === "pie") { legendItems.push({ id: getPointId(point), @@ -162,12 +164,14 @@ export function getChartLegendItems(chart: Highcharts.Chart): readonly LegendIte markerType: getSeriesMarkerType(point.series), color: getPointColor(point), visible: point.visible, + oppositeAxis, }); } }; for (const s of getChartSeries(chart.series)) { addSeriesItem(s); - s.data.forEach(addPointItem); + const oppositeAxis = s.yAxis.options.opposite ?? false; + s.data.forEach((p) => addPointItem(p, oppositeAxis)); } return legendItems; } diff --git a/src/internal-do-not-use/core-legend/index.tsx b/src/internal-do-not-use/core-legend/index.tsx index 43663abc..00125f4b 100644 --- a/src/internal-do-not-use/core-legend/index.tsx +++ b/src/internal-do-not-use/core-legend/index.tsx @@ -22,11 +22,13 @@ export const CoreLegend = ({ return null; } + // TODO: surface the type and defaultTitle and oppositeTitle props return ( getLegendTooltipContent?.(props) ?? null} diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index d6b4e148..b35b30a8 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -28,9 +28,12 @@ const SCROLL_DELAY = 100; export interface ChartLegendProps { items: readonly LegendItem[]; - legendTitle?: string; + title?: string; ariaLabel?: string; + oppositeTitle?: string; actions?: React.ReactNode; + type: "single" | "dual"; + bottomMaxHeight?: number; position: "bottom" | "side"; onItemHighlightEnter: (item: LegendItem) => void; onItemHighlightExit: () => void; @@ -39,31 +42,49 @@ export interface ChartLegendProps { } export const ChartLegend = ({ + type, items, - legendTitle, + title, + oppositeTitle, ariaLabel, actions, position, + bottomMaxHeight, onItemVisibilityChange, onItemHighlightEnter, onItemHighlightExit, getTooltipContent, }: ChartLegendProps) => { + const tooltipRef = useRef(null); const containerRef = useRef(null); - const elementsByIndexRef = useRef>([]); + const tooltipTrack = useRef(null); const elementsByIdRef = useRef>({}); - const tooltipRef = useRef(null); const highlightControl = useMemo(() => new DebouncedCall(), []); - const scrollIntoViewControl = useMemo(() => new DebouncedCall(), []); - const [selectedIndex, setSelectedIndex] = useState(0); + + const [shouldStack, setShouldStack] = useState(false); const [tooltipItemId, setTooltipItemId] = useState(null); - const { showTooltip, hideTooltip } = useMemo(() => { + + const tooltipPosition = position === "bottom" ? "bottom" : "left"; + const tooltipTarget = items.find((item) => item.id === tooltipItemId) ?? null; + tooltipTrack.current = tooltipItemId ? elementsByIdRef.current[tooltipItemId] : null; + const tooltipContent = tooltipTarget && getTooltipContent({ legendItem: tooltipTarget }); + + const { defaultItems, oppositeItems } = useMemo(() => { + if (type === "single") { + return { defaultItems: items, oppositeItems: [] }; + } + const defaultItems = items.filter((item) => !item.oppositeAxis); + const oppositeItems = items.filter((item) => item.oppositeAxis); + return { defaultItems, oppositeItems }; + }, [items, type]); + + const { onShowTooltip, onHideTooltip } = useMemo(() => { const control = new DebouncedCall(); return { - showTooltip(itemId: string) { + onShowTooltip(itemId: string) { control.call(() => setTooltipItemId(itemId)); }, - hideTooltip(lock = false) { + onHideTooltip(lock = false) { control.call(() => setTooltipItemId(null), TOOLTIP_BLUR_DELAY); if (lock) { control.lock(TOOLTIP_BLUR_DELAY); @@ -72,13 +93,27 @@ export const ChartLegend = ({ }; }, []); + useEffect(() => { + if (!containerRef.current) { + return; + } + const resizeObserver = new ResizeObserver((entries) => { + if (entries.length > 0) { + const width = entries[0].borderBoxSize?.[0].inlineSize; + setShouldStack(width < 400); + } + }); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + useEffect(() => { if (!tooltipItemId) { return; } const onDocumentKeyDown = (event: KeyboardEvent) => { if (event.keyCode === KeyCode.escape) { - hideTooltip(true); + onHideTooltip(true); elementsByIdRef.current[tooltipItemId]?.focus(); } }; @@ -86,21 +121,228 @@ export const ChartLegend = ({ return () => { document.removeEventListener("keydown", onDocumentKeyDown, true); }; - }, [items, tooltipItemId, hideTooltip]); + }, [items, tooltipItemId, onHideTooltip]); + + const onShowHighlight = (itemId: string) => { + const item = items.find((item) => item.id === itemId); + if (item?.visible) { + highlightControl.cancelPrevious(); + onItemHighlightEnter(item); + } + }; + + const onToggleItem = (itemId: string) => { + const visibleItems = items.filter((i) => i.visible).map((i) => i.id); + if (visibleItems.includes(itemId)) { + onItemVisibilityChange(visibleItems.filter((visibleItemId) => visibleItemId !== itemId)); + } else { + onItemVisibilityChange([...visibleItems, itemId]); + } + // Needed for touch devices. + onItemHighlightExit(); + }; + + const onSelectItem = (itemId: string) => { + const visibleItems = items.filter((i) => i.visible).map((i) => i.id); + if (visibleItems.length === 1 && visibleItems[0] === itemId) { + onItemVisibilityChange(items.map((i) => i.id)); + } else { + onItemVisibilityChange([itemId]); + } + // Needed for touch devices. + onItemHighlightExit(); + }; + + const legendGroupProps = { + actions, + tooltipRef, + elementsByIdRef, + bottomMaxHeight, + highlightControl, + someHighlighted: items.some((item) => item.highlighted), + onHideTooltip, + onItemHighlightExit, + onSelectItem, + onShowHighlight, + onShowTooltip, + onToggleItem, + }; + + return ( +
+ {position === "bottom" ? ( + oppositeItems.length === 0 ? ( + + ) : shouldStack ? ( + <> + + {oppositeItems.length > 0 && ( + <> +
+ + + )} + + ) : ( +
+ + +
+ ) + ) : ( + <> + + {oppositeItems.length > 0 && ( + <> +
+ + + )} + + )} + {tooltipContent && ( + {}} + onBlur={() => onHideTooltip()} + onMouseLeave={() => onHideTooltip()} + onMouseEnter={() => onShowTooltip(tooltipTarget.id)} + position={tooltipPosition} + title={tooltipContent.header} + trackKey={tooltipTarget.id} + trackRef={tooltipTrack} + footer={ + tooltipContent.footer && ( + <> +
+ {tooltipContent.footer} + + ) + } + > + {tooltipContent.body} +
+ )} +
+ ); +}; + +interface LegendItemsProps { + title?: string; + ariaLabel: string; + someHighlighted: boolean; + bottomMaxHeight?: number; + actions?: React.ReactNode; + highlightControl: DebouncedCall; + items: readonly LegendItem[]; + tooltipRef: React.RefObject; + scrollableContainerRef?: React.RefObject; + position: "bottom" | "bottom-default" | "bottom-opposite" | "side"; + elementsByIdRef: React.MutableRefObject>; + onItemHighlightExit: () => void; + onHideTooltip: (lock?: boolean) => void; + onSelectItem: (itemId: string) => void; + onToggleItem: (itemId: string) => void; + onShowTooltip: (itemId: string) => void; + onShowHighlight: (itemId: string) => void; +} + +const LegendGroup = ({ + title, + items, + actions, + position, + ariaLabel, + tooltipRef, + bottomMaxHeight, + someHighlighted, + elementsByIdRef, + highlightControl, + scrollableContainerRef, + onToggleItem, + onSelectItem, + onShowTooltip, + onHideTooltip, + onShowHighlight, + onItemHighlightExit, +}: LegendItemsProps) => { + const containerRef = useRef(null); const isMouseInContainer = useRef(false); + const navigationAPI = useRef(null); + const elementsByIndexRef = useRef>({}); + + const [selectedIndex, setSelectedIndex] = useState(0); + const scrollIntoViewControl = useMemo(() => new DebouncedCall(), []); + + useEffect(() => { + navigationAPI.current!.updateFocusTarget(); + }); // Scrolling to the highlighted legend item. useEffect(() => { - const highlightedIndex = items.findIndex((item) => item.highlighted); - if (highlightedIndex === -1) { + const highlightedId = items.find((item) => item.highlighted)?.id; + if (highlightedId === undefined) { return; } scrollIntoViewControl.call(() => { if (isMouseInContainer.current) { return; } - const container = containerRef.current; - const element = elementsByIndexRef.current?.[highlightedIndex]; + const container = scrollableContainerRef?.current ?? containerRef.current; + const element = elementsByIdRef.current?.[highlightedId]; if (!container || !element) { return; } @@ -114,35 +356,26 @@ export const ChartLegend = ({ container.scrollTo({ top, behavior: "smooth" }); } }, SCROLL_DELAY); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [items, scrollIntoViewControl]); - const showHighlight = (itemId: string) => { - const item = items.find((item) => item.id === itemId); - if (item?.visible) { - highlightControl.cancelPrevious(); - onItemHighlightEnter(item); - } - }; const clearHighlight = () => { highlightControl.call(onItemHighlightExit, HIGHLIGHT_LOST_DELAY); }; - const navigationAPI = useRef(null); - function onFocus(index: number, itemId: string) { setSelectedIndex(index); navigationAPI.current!.updateFocusTarget(); - showHighlight(itemId); - showTooltip(itemId); + onShowHighlight(itemId); + onShowTooltip(itemId); } function onBlur(event: React.FocusEvent) { navigationAPI.current!.updateFocusTarget(); - // Hide tooltip and clear highlight unless focus moves inside tooltip; if (tooltipRef.current && event.relatedTarget && !tooltipRef.current.contains(event.relatedTarget)) { clearHighlight(); - hideTooltip(); + onHideTooltip(); } } @@ -177,10 +410,60 @@ export const ChartLegend = ({ } } + const renderedItems = items.map((item, index) => { + const handlers = { + onKeyDown, + onMouseEnter: () => { + onShowHighlight(item.id); + onShowTooltip(item.id); + }, + onMouseLeave: () => { + clearHighlight(); + onHideTooltip(); + }, + onFocus: () => { + onFocus(index, item.id); + }, + onBlur: (event: React.FocusEvent) => { + onBlur(event); + }, + onClick: (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey) { + onToggleItem(item.id); + } else { + onSelectItem(item.id); + } + }, + }; + const thisTriggerRef = (elem: null | HTMLElement) => { + if (elem) { + elementsByIndexRef.current[index] = elem; + elementsByIdRef.current[item.id] = elem; + } else { + delete elementsByIndexRef.current[index]; + delete elementsByIdRef.current[item.id]; + } + }; + return ( + + ); + }); + function getNextFocusTarget(): null | HTMLElement { if (containerRef.current) { + const highlightedIndex = items.findIndex((item) => item.highlighted); const buttons: HTMLButtonElement[] = Array.from(containerRef.current.querySelectorAll(`.${styles.item}`)); - return buttons[selectedIndex] ?? null; + return buttons[highlightedIndex] ?? buttons[0]; } return null; } @@ -190,43 +473,13 @@ export const ChartLegend = ({ navigationAPI: React.RefObject<{ getFocusTarget: () => HTMLElement | null }>, ) { const target = navigationAPI.current?.getFocusTarget(); - if (target && target.dataset.itemid !== focusableElement.dataset.itemid) { target.focus(); } } - useEffect(() => { - navigationAPI.current!.updateFocusTarget(); - }); - - const toggleItem = (itemId: string) => { - const visibleItems = items.filter((i) => i.visible).map((i) => i.id); - if (visibleItems.includes(itemId)) { - onItemVisibilityChange(visibleItems.filter((visibleItemId) => visibleItemId !== itemId)); - } else { - onItemVisibilityChange([...visibleItems, itemId]); - } - // Needed for touch devices. - onItemHighlightExit(); - }; - - const selectItem = (itemId: string) => { - const visibleItems = items.filter((i) => i.visible).map((i) => i.id); - if (visibleItems.length === 1 && visibleItems[0] === itemId) { - onItemVisibilityChange(items.map((i) => i.id)); - } else { - onItemVisibilityChange([itemId]); - } - // Needed for touch devices. - onItemHighlightExit(); - }; - - const tooltipTrack = useRef(null); - const tooltipTarget = items.find((item) => item.id === tooltipItemId) ?? null; - tooltipTrack.current = tooltipItemId ? elementsByIdRef.current[tooltipItemId] : null; - const tooltipContent = tooltipTarget && getTooltipContent({ legendItem: tooltipTarget }); - const tooltipPosition = position === "bottom" ? "bottom" : "left"; + const isDual = position.startsWith("bottom-"); + const isBottom = position.startsWith("bottom"); return ( onUnregisterActive(element, navigationAPI)} >
(isMouseInContainer.current = true)} onMouseLeave={() => (isMouseInContainer.current = false)} + className={clsx({ + [styles["legend-dual-group"]]: isDual, + })} > - {legendTitle && ( - - {legendTitle} + {title && ( + + {title} )} -
{actions && ( - <> +
+ {actions}
- {actions} -
-
- + /> +
)} -
- {items.map((item, index) => { - const handlers = { - onMouseEnter: () => { - showHighlight(item.id); - showTooltip(item.id); - }, - onMouseLeave: () => { - clearHighlight(); - hideTooltip(); - }, - onFocus: () => { - onFocus(index, item.id); - }, - onBlur: (event: React.FocusEvent) => { - onBlur(event); - }, - onKeyDown, - }; - const thisTriggerRef = (elem: null | HTMLElement) => { - if (elem) { - elementsByIndexRef.current[index] = elem; - elementsByIdRef.current[item.id] = elem; - } else { - delete elementsByIndexRef.current[index]; - delete elementsByIdRef.current[index]; - } - }; - return ( - { - if (event.metaKey || event.ctrlKey) { - toggleItem(item.id); - } else { - selectItem(item.id); - } - }} - isHighlighted={item.highlighted} - someHighlighted={items.some((item) => item.highlighted)} - itemId={item.id} - label={item.name} - visible={item.visible} - marker={item.marker} - /> - ); - })} -
+ {renderedItems}
- {tooltipContent && ( - {}} - position={tooltipPosition} - title={tooltipContent.header} - onMouseEnter={() => showTooltip(tooltipTarget.id)} - onMouseLeave={() => hideTooltip()} - onBlur={() => hideTooltip()} - footer={ - tooltipContent.footer && ( - <> -
- {tooltipContent.footer} - - ) - } - > - {tooltipContent.body} -
- )}
); }; +interface LegendItemTriggerProps { + isHighlighted?: boolean; + itemId: string; + label: string; + marker?: React.ReactNode; + actions?: React.ReactNode; + someHighlighted?: boolean; + triggerRef?: Ref; + visible: boolean; + onBlur?: (event: React.FocusEvent) => void; + onClick: (event: React.MouseEvent) => void; + onFocus?: () => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + onMarkerClick?: () => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + const LegendItemTrigger = forwardRef( ( { @@ -379,22 +578,7 @@ const LegendItemTrigger = forwardRef( onFocus, onBlur, onKeyDown, - }: { - isHighlighted?: boolean; - someHighlighted?: boolean; - itemId: string; - label: string; - marker?: React.ReactNode; - visible: boolean; - onClick: (event: React.MouseEvent) => void; - onMarkerClick?: () => void; - triggerRef?: Ref; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onFocus?: () => void; - onBlur?: (event: React.FocusEvent) => void; - onKeyDown?: (event: React.KeyboardEvent) => void; - }, + }: LegendItemTriggerProps, ref: Ref, ) => { const refObject = useRef(null); diff --git a/src/internal/components/chart-legend/styles.scss b/src/internal/components/chart-legend/styles.scss index 84d571d1..3bc257e3 100644 --- a/src/internal/components/chart-legend/styles.scss +++ b/src/internal/components/chart-legend/styles.scss @@ -12,9 +12,6 @@ @include styles.styles-reset; @include styles.default-text-style; - overflow: auto; - max-height: inherit; - &:focus { outline: none; } @@ -22,7 +19,20 @@ .root-side { display: flex; + overflow-y: auto; flex-direction: column; + max-block-size: inherit; + margin-inline-start: cs.$space-scaled-s; +} + +.legend-dual-group { + inline-size: calc(50% - 20px); +} + +.legend-bottom-dual-axis-container { + display: flex; + inline-size: 100%; + justify-content: space-between; } .list { @@ -37,27 +47,16 @@ .list-bottom { flex-wrap: wrap; - overflow-y: auto; - justify-content: space-between; + gap: cs.$space-scaled-xxs cs.$space-static-m; } -.list-side { - flex-wrap: nowrap; - flex-direction: column; +.list-bottom-opposite { + justify-content: flex-end; } -.legend-bottom { +.list-side { flex: 1; - display: flex; - flex-wrap: wrap; - flex-direction: row; - max-inline-size: 100%; - gap: cs.$space-scaled-xxs cs.$space-static-m; -} - -.legend-side { - display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; flex-direction: column; gap: cs.$space-scaled-xxs cs.$space-static-m; } @@ -74,7 +73,6 @@ background-color: transparent; cursor: pointer; opacity: 1; - max-inline-size: 100%; &:focus { outline: none; @@ -120,16 +118,19 @@ align-items: flex-start; } -.actions-divider-bottom { +.legend-divider-bottom { inline-size: 1px; block-size: 80%; margin-inline: cs.$space-scaled-xs; background: cs.$color-border-divider-default; } -.actions-divider-side { +.legend-divider-side { + display: block; + border: 0; + padding: 0; + height: 1px; inline-size: 80%; - block-size: 1px; margin-block-end: cs.$space-scaled-xs; - background: cs.$color-border-divider-default; + border-top: 1px solid cs.$color-border-divider-default; } diff --git a/src/internal/components/interfaces.ts b/src/internal/components/interfaces.ts index 91bc5f5c..e2c7e3be 100644 --- a/src/internal/components/interfaces.ts +++ b/src/internal/components/interfaces.ts @@ -7,6 +7,7 @@ export interface LegendItem { marker: React.ReactNode; visible: boolean; highlighted: boolean; + oppositeAxis?: boolean; } export type GetLegendTooltipContent = (props: GetLegendTooltipContentProps) => LegendTooltipContent;