From afe73eec43ebfd3aa06e10839bac2f69b3cdbcd5 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Fri, 21 Jul 2023 12:17:51 +0200 Subject: [PATCH] feat: Introduce `fitHeight` property for chart components --- pages/area-chart/fit-height.page.tsx | 104 ++++++++++++++ .../mixed-line-bar-chart/fit-height.page.tsx | 80 +++++++++++ pages/pie-chart/fit-height.page.tsx | 81 +++++++++++ .../__snapshots__/documenter.test.ts.snap | 45 +++++- .../area-chart-initial-state.test.tsx | 26 +++- src/area-chart/chart-container.tsx | 21 ++- src/area-chart/internal.tsx | 5 + src/area-chart/model/index.ts | 1 + src/area-chart/model/use-chart-model.ts | 16 ++- .../cartesian-chart/chart-container.tsx | 47 ++++++- .../components/cartesian-chart/interfaces.ts | 6 + .../components/cartesian-chart/styles.scss | 41 +++++- .../chart-plot/__tests__/chart-plot.test.tsx | 4 +- src/internal/components/chart-plot/index.tsx | 8 +- .../components/chart-wrapper/index.tsx | 61 ++++++-- .../components/chart-wrapper/styles.scss | 17 +++ .../__tests__/mixed-chart.test.tsx | 14 +- src/mixed-line-bar-chart/chart-container.tsx | 30 +++- src/mixed-line-bar-chart/internal.tsx | 5 +- src/mixed-line-bar-chart/styles.scss | 1 - src/pie-chart/__tests__/utils.test.tsx | 34 ++++- src/pie-chart/index.tsx | 14 +- src/pie-chart/interfaces.ts | 6 + src/pie-chart/labels.tsx | 10 +- src/pie-chart/pie-chart.tsx | 133 +++++++++++------- src/pie-chart/segments.tsx | 12 +- src/pie-chart/styles.scss | 22 +++ src/pie-chart/utils.ts | 61 ++++++-- 28 files changed, 785 insertions(+), 120 deletions(-) create mode 100644 pages/area-chart/fit-height.page.tsx create mode 100644 pages/mixed-line-bar-chart/fit-height.page.tsx create mode 100644 pages/pie-chart/fit-height.page.tsx diff --git a/pages/area-chart/fit-height.page.tsx b/pages/area-chart/fit-height.page.tsx new file mode 100644 index 0000000000..eea9e4c9bc --- /dev/null +++ b/pages/area-chart/fit-height.page.tsx @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import { createLinearTimeLatencyProps } from './series'; +import { AreaChart, Box, Button, Checkbox, SpaceBetween } from '~components'; +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +type DemoContext = React.Context< + AppContextType<{ fitHeight: boolean; hideFilter: boolean; hideLegend: boolean; minHeight: number }> +>; + +const chartData = createLinearTimeLatencyProps(); + +export default function () { + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const minHeight = parseInt(urlParams.minHeight?.toString() || '0'); + const heights = [800, 600, 400, 300, 200, 100]; + const fitHeight = urlParams.fitHeight ?? true; + return ( + +

Area chart fit height

+ + + setUrlParams({ fitHeight: e.detail.checked })}> + fit height + + setUrlParams({ hideFilter: e.detail.checked })}> + hide filter + + setUrlParams({ hideLegend: e.detail.checked })}> + hide legend + + + setUrlParams({ minHeight: parseInt(e.target.value) })} + /> + + + + + + + {heights.map(height => ( + + {height}px +
+ {}} + empty={ + + No data + + There is no data to display + + + } + noMatch={ + + No matching data + + There is no data to display + + + + } + i18nStrings={{ + filterLabel: 'Filter displayed data', + filterPlaceholder: 'Filter data', + filterSelectedAriaLabel: '(selected)', + detailTotalLabel: 'Total', + detailPopoverDismissAriaLabel: 'Dismiss', + legendAriaLabel: 'Legend', + chartAriaRoleDescription: 'area chart', + xAxisAriaRoleDescription: 'x axis', + yAxisAriaRoleDescription: 'y axis', + xTickFormatter: value => `${value}\nxxx`, + }} + xDomain={[0, 119]} + {...chartData} + /> +
+
+ ))} +
+
+
+ ); +} diff --git a/pages/mixed-line-bar-chart/fit-height.page.tsx b/pages/mixed-line-bar-chart/fit-height.page.tsx new file mode 100644 index 0000000000..fb1c833d8e --- /dev/null +++ b/pages/mixed-line-bar-chart/fit-height.page.tsx @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import { Box, Button, Checkbox, MixedLineBarChart, SpaceBetween } from '~components'; +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; +import { barChartInstructions, commonProps, data3, data4 } from './common'; +import { colorChartsThresholdInfo } from '~design-tokens'; + +type DemoContext = React.Context< + AppContextType<{ fitHeight: boolean; hideFilter: boolean; hideLegend: boolean; minHeight: number }> +>; + +export default function () { + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const minHeight = parseInt(urlParams.minHeight?.toString() || '0'); + const heights = [800, 600, 400, 300, 200, 100]; + const fitHeight = urlParams.fitHeight ?? true; + return ( + +

Mixed chart fit height

+ + + setUrlParams({ fitHeight: e.detail.checked })}> + fit height + + setUrlParams({ hideFilter: e.detail.checked })}> + hide filter + + setUrlParams({ hideLegend: e.detail.checked })}> + hide legend + + + setUrlParams({ minHeight: parseInt(e.target.value) })} + /> + + + + + + + {heights.map(height => ( + + {height}px +
+ x !== 'Chocolate') }, + { title: 'Calories', type: 'line', data: data3 }, + { title: 'Threshold', type: 'threshold', y: 420, color: colorChartsThresholdInfo }, + ]} + xDomain={data3.map(d => d.x)} + yDomain={[0, 650]} + xTitle="Food" + yTitle="Calories (kcal)" + xScaleType="categorical" + ariaLabel="Mixed chart 1" + ariaDescription={barChartInstructions} + detailPopoverFooter={xValue => } + /> +
+
+ ))} +
+
+
+ ); +} diff --git a/pages/pie-chart/fit-height.page.tsx b/pages/pie-chart/fit-height.page.tsx new file mode 100644 index 0000000000..8a26e3fa66 --- /dev/null +++ b/pages/pie-chart/fit-height.page.tsx @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import { Box, Button, Checkbox, PieChart, SegmentedControl, SpaceBetween } from '~components'; +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; +import { FoodData, commonProps, data1 } from './common'; + +type DemoContext = React.Context< + AppContextType<{ + fitHeight: boolean; + hideFilter: boolean; + hideLegend: boolean; + minSize: 'large' | 'medium' | 'small'; + }> +>; + +export default function () { + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const minSize = urlParams.minSize ?? 'small'; + const heights = [800, 600, 400, 300, 200, 100]; + const fitHeight = urlParams.fitHeight ?? true; + return ( + +

Pie chart fit height

+ + + setUrlParams({ fitHeight: e.detail.checked })}> + fit height + + setUrlParams({ hideFilter: e.detail.checked })}> + hide filter + + setUrlParams({ hideLegend: e.detail.checked })}> + hide legend + + + setUrlParams({ minSize: e.detail.selectedId as any })} + /> + + + + + + + {heights.map(height => ( + + {height}px +
+ + {...commonProps} + fitHeight={fitHeight} + hideFilter={urlParams.hideFilter} + hideLegend={urlParams.hideLegend} + data={data1} + ariaLabel="Food facts" + size={minSize} + detailPopoverFooter={segment => } + variant="donut" + innerMetricValue="180" + /> +
+
+ ))} +
+
+
+ ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index b3c723f84c..71b9449933 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -773,10 +773,17 @@ Do not use \`ariaLabel\` and \`ariaLabelledby\` at the same time.", "optional": true, "type": "string", }, + Object { + "description": "Enable this property to make the chart fit into the available height of the parent container.", + "name": "fitHeight", + "optional": true, + "type": "boolean", + }, Object { "defaultValue": "500", "description": "An optional pixel value number that fixes the height of the chart area. -If not set explicitly, the component will use a default height that is defined internally.", +If not set explicitly, the component will use a default height that is defined internally. +When used with \`fitHeight\`, this property defines the minimum height of the chart area.", "name": "height", "optional": true, "type": "number", @@ -1975,10 +1982,17 @@ See the usage guidelines for more details.", "optional": true, "type": "string", }, + Object { + "description": "Enable this property to make the chart fit into the available height of the parent container.", + "name": "fitHeight", + "optional": true, + "type": "boolean", + }, Object { "defaultValue": "500", "description": "An optional pixel value number that fixes the height of the chart area. -If not set explicitly, the component will use a default height that is defined internally.", +If not set explicitly, the component will use a default height that is defined internally. +When used with \`fitHeight\`, this property defines the minimum height of the chart area.", "name": "height", "optional": true, "type": "number", @@ -7721,10 +7735,17 @@ See the usage guidelines for more details.", "optional": true, "type": "string", }, + Object { + "description": "Enable this property to make the chart fit into the available height of the parent container.", + "name": "fitHeight", + "optional": true, + "type": "boolean", + }, Object { "defaultValue": "500", "description": "An optional pixel value number that fixes the height of the chart area. -If not set explicitly, the component will use a default height that is defined internally.", +If not set explicitly, the component will use a default height that is defined internally. +When used with \`fitHeight\`, this property defines the minimum height of the chart area.", "name": "height", "optional": true, "type": "number", @@ -8317,10 +8338,17 @@ See the usage guidelines for more details.", "optional": true, "type": "string", }, + Object { + "description": "Enable this property to make the chart fit into the available height of the parent container.", + "name": "fitHeight", + "optional": true, + "type": "boolean", + }, Object { "defaultValue": "500", "description": "An optional pixel value number that fixes the height of the chart area. -If not set explicitly, the component will use a default height that is defined internally.", +If not set explicitly, the component will use a default height that is defined internally. +When used with \`fitHeight\`, this property defines the minimum height of the chart area.", "name": "height", "optional": true, "type": "number", @@ -9518,6 +9546,12 @@ Each pair has the following properties: "optional": true, "type": "string", }, + Object { + "description": "Enable this property to make the chart fit into the available height of the parent container.", + "name": "fitHeight", + "optional": true, + "type": "boolean", + }, Object { "defaultValue": "false", "description": "Hides the label descriptions next to the chart segments when set to \`true\`.", @@ -9683,7 +9717,8 @@ The function is called with the data object of each segment and is expected to r }, Object { "defaultValue": "\\"medium\\"", - "description": "Specifies the size of the pie or donut chart.", + "description": "Specifies the size of the pie or donut chart. +When used with \`fitHeight\`, this property defines the minimum size of the chart area.", "inlineType": Object { "name": "", "type": "union", diff --git a/src/area-chart/__tests__/area-chart-initial-state.test.tsx b/src/area-chart/__tests__/area-chart-initial-state.test.tsx index f05617ac73..abd0ce12a8 100644 --- a/src/area-chart/__tests__/area-chart-initial-state.test.tsx +++ b/src/area-chart/__tests__/area-chart-initial-state.test.tsx @@ -6,6 +6,8 @@ import { AreaChartWrapper } from '../../../lib/components/test-utils/dom'; import AreaChart, { AreaChartProps } from '../../../lib/components/area-chart'; import { KeyCode } from '@cloudscape-design/test-utils-core/dist/utils'; import popoverStyles from '../../../lib/components/popover/styles.css.js'; +import chartWrapperStyles from '../../../lib/components/internal/components/chart-wrapper/styles.css.js'; +import cartesianStyles from '../../../lib/components/internal/components/cartesian-chart/styles.css.js'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import TestI18nProvider from '../../../lib/components/internal/i18n/testing'; import { cloneDeep } from 'lodash'; @@ -84,9 +86,29 @@ test('error and recovery texts are assigned', () => { expect(wrapper.findStatusContainer()!.getElement()).toHaveTextContent('Ooops! Try again'); }); -test('chart height is assigned', () => { +test('explicit chart height is assigned', () => { const { wrapper } = renderAreaChart(); - expect(wrapper.findChart()!.getElement()).toHaveAttribute('height', '333'); + expect(wrapper.findChart()!.getElement().style.height).toContain('333'); +}); + +test('when fitHeight=true chart height is flexible', () => { + const { wrapper } = renderAreaChart( + + ); + expect(wrapper.findChart()!.getElement().style.height).toContain('100%'); +}); + +test('when fitHeight=false content min height is explicitly set', () => { + const { wrapper } = renderAreaChart(); + expect(wrapper.findByClassName(chartWrapperStyles.content)?.getElement()).toHaveStyle({ minHeight: '333px' }); +}); + +test.each([false, true])('when fitHeight=%s plot min-height is explicitly set', fitHeight => { + const { wrapper } = renderAreaChart(); + const selector = fitHeight ? cartesianStyles['chart-container-plot-wrapper'] : chartWrapperStyles.content; + const chartElement = wrapper.findByClassName(selector)!.getElement(); + expect(chartElement.style.minHeight).toBeDefined(); + expect(parseInt(chartElement.style.minHeight)).toBeGreaterThanOrEqual(333); }); test('empty text is assigned', () => { diff --git a/src/area-chart/chart-container.tsx b/src/area-chart/chart-container.tsx index a122c9ffc2..5a45dc7e2d 100644 --- a/src/area-chart/chart-container.tsx +++ b/src/area-chart/chart-container.tsx @@ -44,6 +44,8 @@ interface ChartContainerProps > { model: ChartModel; autoWidth: (value: number) => void; + fitHeight?: boolean; + minHeight: number; } export default memo(ChartContainer) as typeof ChartContainer; @@ -68,6 +70,8 @@ function ChartContainer({ yAxisAriaRoleDescription, detailPopoverDismissAriaLabel, } = {}, + fitHeight, + minHeight, xTickFormatter = deprecatedXTickFormatter, yTickFormatter = deprecatedYTickFormatter, detailTotalFormatter = deprecatedDetailTotalFormatter, @@ -106,6 +110,8 @@ function ChartContainer({ return ( } leftAxisLabelMeasure={ ({ chartPlot={ ({ onFocus={model.handlers.onSVGFocus} onBlur={model.handlers.onSVGBlur} > + + = SomeRequired< InternalBaseComponentProps; export default function InternalAreaChart({ + fitHeight, height, xScaleType, yScaleType, @@ -94,6 +95,7 @@ export default function InternalAreaChart({ controlledOnHighlightChange ); const model = useChartModel({ + fitHeight, externalSeries, visibleSeries, setVisibleSeries, @@ -137,6 +139,7 @@ export default function InternalAreaChart({ ref={mergedRef} {...baseProps} className={clsx(baseProps.className, styles.root)} + fitHeight={!!fitHeight} contentMinHeight={height} defaultFilter={ showFilters && !hideFilter ? ( @@ -181,6 +184,8 @@ export default function InternalAreaChart({ ariaLabelledby={ariaLabelledby} ariaDescription={ariaDescription} i18nStrings={i18nStrings} + fitHeight={fitHeight} + minHeight={height} /> ) : null } diff --git a/src/area-chart/model/index.ts b/src/area-chart/model/index.ts index 9e22c62596..faebf2c902 100644 --- a/src/area-chart/model/index.ts +++ b/src/area-chart/model/index.ts @@ -32,6 +32,7 @@ export interface ChartModel { interactions: ReadonlyAsyncStore>; refs: { plot: React.RefObject; + plotMeasure: React.Ref; container: React.RefObject; verticalMarker: React.RefObject; popoverRef: React.RefObject; diff --git a/src/area-chart/model/use-chart-model.ts b/src/area-chart/model/use-chart-model.ts index 31d3d02d7d..aed775f809 100644 --- a/src/area-chart/model/use-chart-model.ts +++ b/src/area-chart/model/use-chart-model.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { AreaChartProps } from '../interfaces'; -import React, { useEffect, useMemo, useRef, RefObject, MouseEvent } from 'react'; +import React, { useEffect, useMemo, useRef, RefObject, MouseEvent, useState } from 'react'; import { findClosest, circleIndex } from './utils'; import { nodeContains } from '../../internal/utils/dom'; @@ -15,12 +15,14 @@ import { ChartModel } from './index'; import { ChartPlotRef } from '../../internal/components/chart-plot'; import { throttle } from '../../internal/utils/throttle'; import { useReaction } from '../async-store'; +import { useResizeObserver } from '../../internal/hooks/container-queries'; const MAX_HOVER_MARGIN = 6; const SVG_HOVER_THROTTLE = 25; const POPOVER_DEADZONE = 12; export interface UseChartModelProps { + fitHeight?: boolean; externalSeries: readonly AreaChartProps.Series[]; visibleSeries: readonly AreaChartProps.Series[]; setVisibleSeries: (series: readonly AreaChartProps.Series[]) => void; @@ -37,6 +39,7 @@ export interface UseChartModelProps { // Represents the core the chart logic, including the model of all allowed user interactions. export default function useChartModel({ + fitHeight, externalSeries: allSeries, visibleSeries: series, setVisibleSeries, @@ -46,7 +49,7 @@ export default function useChartModel({ yDomain, xScaleType, yScaleType, - height, + height: explicitHeight, width, popoverRef, }: UseChartModelProps): ChartModel { @@ -55,6 +58,14 @@ export default function useChartModel({ const containerRef = useRef(null); const verticalMarkerRef = useRef(null); + const plotMeasureRef = useRef(null); + const [measuredHeight, setHeight] = useState(0); + useResizeObserver( + () => plotMeasureRef.current, + entry => fitHeight && setHeight(entry.borderBoxHeight) + ); + const height = fitHeight ? measuredHeight : explicitHeight; + const stableSetVisibleSeries = useStableEventHandler(setVisibleSeries); const model = useMemo(() => { @@ -341,6 +352,7 @@ export default function useChartModel({ }, refs: { plot: plotRef, + plotMeasure: plotMeasureRef, container: containerRef, verticalMarker: verticalMarkerRef, popoverRef, diff --git a/src/internal/components/cartesian-chart/chart-container.tsx b/src/internal/components/cartesian-chart/chart-container.tsx index df10fe70a2..87d5879d47 100644 --- a/src/internal/components/cartesian-chart/chart-container.tsx +++ b/src/internal/components/cartesian-chart/chart-container.tsx @@ -3,8 +3,11 @@ import React, { forwardRef } from 'react'; import styles from './styles.css.js'; +import clsx from 'clsx'; interface CartesianChartContainerProps { + minHeight: number; + fitHeight: boolean; leftAxisLabel: React.ReactNode; leftAxisLabelMeasure: React.ReactNode; bottomAxisLabel: React.ReactNode; @@ -12,21 +15,57 @@ interface CartesianChartContainerProps { popover: React.ReactNode; } +const CONTENT_MIN_HEIGHT_BOUNDARY = 40; + export const CartesianChartContainer = forwardRef( ( - { leftAxisLabel, leftAxisLabelMeasure, bottomAxisLabel, chartPlot, popover }: CartesianChartContainerProps, + { + minHeight, + fitHeight, + leftAxisLabel, + leftAxisLabelMeasure, + bottomAxisLabel, + chartPlot, + popover, + }: CartesianChartContainerProps, ref: React.Ref ) => { + if (fitHeight) { + return ( +
+ {leftAxisLabel} + +
+ {leftAxisLabelMeasure} + +
+
+
{chartPlot}
+
+ +
+ {bottomAxisLabel} +
+
+ + {popover} +
+
+ ); + } + return (
{leftAxisLabel} -
+
{leftAxisLabelMeasure} -
+
{chartPlot} - {bottomAxisLabel}
diff --git a/src/internal/components/cartesian-chart/interfaces.ts b/src/internal/components/cartesian-chart/interfaces.ts index 27149b2886..055f4497ba 100644 --- a/src/internal/components/cartesian-chart/interfaces.ts +++ b/src/internal/components/cartesian-chart/interfaces.ts @@ -86,6 +86,7 @@ export interface CartesianChartProps extends B /** * An optional pixel value number that fixes the height of the chart area. * If not set explicitly, the component will use a default height that is defined internally. + * When used with `fitHeight`, this property defines the minimum height of the chart area. */ height?: number; @@ -186,6 +187,11 @@ export interface CartesianChartProps extends B * Called when the highlighted series has changed because of user interaction. */ onHighlightChange?: NonCancelableEventHandler>; + + /** + * Enable this property to make the chart fit into the available height of the parent container. + */ + fitHeight?: boolean; } export namespace CartesianChartProps { diff --git a/src/internal/components/cartesian-chart/styles.scss b/src/internal/components/cartesian-chart/styles.scss index 08fa16b630..290397bb2b 100644 --- a/src/internal/components/cartesian-chart/styles.scss +++ b/src/internal/components/cartesian-chart/styles.scss @@ -114,14 +114,49 @@ display: flex; width: 100%; flex-direction: column; + + &.fit-height { + height: 100%; + min-height: inherit; + } } -.chart-container__vertical { +.chart-container-outer { + display: flex; + + &.fit-height { + flex: 1; + } +} + +.chart-container-inner { + position: relative; display: flex; flex-direction: column; width: 100%; } -.chart-container__horizontal { - display: flex; +.chart-container-plot-wrapper { + &.fit-height { + display: block; + position: relative; + flex: 1; + } +} + +.chart-container-plot { + &.fit-height { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } +} + +.chart-container-bottom-labels { + &.fit-height { + display: block; + } } diff --git a/src/internal/components/chart-plot/__tests__/chart-plot.test.tsx b/src/internal/components/chart-plot/__tests__/chart-plot.test.tsx index 32530e954c..ea990638c5 100644 --- a/src/internal/components/chart-plot/__tests__/chart-plot.test.tsx +++ b/src/internal/components/chart-plot/__tests__/chart-plot.test.tsx @@ -65,8 +65,8 @@ describe('initial state', () => { ); const plot = plotWrapper.getElement(); - expect(plot.getAttribute('width')).toBe('200'); - expect(plot.getAttribute('height')).toBe('100'); + expect(plot.style.width).toContain('200'); + expect(plot.style.height).toContain('100'); expect(plot.style.margin).toContain('1px 2px 3px 4px'); expect(plot.textContent).toContain('Test'); expect(plot.classList.contains(styles.clickable)).toBe(true); diff --git a/src/internal/components/chart-plot/index.tsx b/src/internal/components/chart-plot/index.tsx index ee186975f6..931b2e330b 100644 --- a/src/internal/components/chart-plot/index.tsx +++ b/src/internal/components/chart-plot/index.tsx @@ -23,8 +23,8 @@ export interface ChartPlotRef { } export interface ChartPlotProps { - width: number; - height: number; + width: number | string; + height: number | string; transform?: string; offsetTop?: number; offsetBottom?: number; @@ -172,9 +172,9 @@ function ChartPlot( aria-hidden="false" {...plotAria} ref={svgRef} - width={width} - height={height} style={{ + width, + height, marginTop: offsetTop, marginBottom: offsetBottom, marginLeft: offsetLeft, diff --git a/src/internal/components/chart-wrapper/index.tsx b/src/internal/components/chart-wrapper/index.tsx index 494eb0c972..4afd054439 100644 --- a/src/internal/components/chart-wrapper/index.tsx +++ b/src/internal/components/chart-wrapper/index.tsx @@ -10,6 +10,7 @@ import InternalBox from '../../../box/internal.js'; import InternalSpaceBetween from '../../../space-between/internal.js'; interface ChartWrapperProps extends BaseComponentProps { + fitHeight: boolean; defaultFilter: React.ReactNode; additionalFilters: React.ReactNode; reserveFilterSpace: boolean; @@ -35,25 +36,59 @@ export const ChartWrapper = forwardRef( onBlur, contentClassName, contentMinHeight, + fitHeight, ...props }: ChartWrapperProps, ref: React.Ref ) => { const baseProps = getBaseProps(props); + + const filtersNode = (defaultFilter || additionalFilters) && ( + + + {defaultFilter} + {additionalFilters} + + + ); + + const legendNode = legend && {legend}; + + if (fitHeight) { + return ( +
+
+ {filtersNode} + +
+ {chartStatus} + {chart} +
+ + {legendNode} +
+
+ ); + } + return (
- {(defaultFilter || additionalFilters) && ( - - - {defaultFilter} - {additionalFilters} - - - )} + {filtersNode}
- {legend && {legend}} + {legendNode}
); } diff --git a/src/internal/components/chart-wrapper/styles.scss b/src/internal/components/chart-wrapper/styles.scss index 6419763a63..ccb773ce04 100644 --- a/src/internal/components/chart-wrapper/styles.scss +++ b/src/internal/components/chart-wrapper/styles.scss @@ -10,6 +10,19 @@ @include styles.styles-reset; position: relative; display: block; + + &--fit-height { + height: 100%; + overflow-y: auto; + } +} + +.inner-wrapper { + &--fit-height { + display: flex; + flex-direction: column; + height: 100%; + } } .has-default-filter { @@ -33,6 +46,10 @@ margin-bottom: calc(2 * #{awsui.$font-body-m-line-height}); } +.content--fit-height { + flex: 1; +} + .filter-container { /* used in test-utils */ } diff --git a/src/mixed-line-bar-chart/__tests__/mixed-chart.test.tsx b/src/mixed-line-bar-chart/__tests__/mixed-chart.test.tsx index 59706baf5f..555dbb94de 100644 --- a/src/mixed-line-bar-chart/__tests__/mixed-chart.test.tsx +++ b/src/mixed-line-bar-chart/__tests__/mixed-chart.test.tsx @@ -811,11 +811,21 @@ describe('Reserve space', () => { const reserveFilterClass = chartWrapperStyles['content--reserve-filter']; const reserveLegendClass = chartWrapperStyles['content--reserve-legend']; - test('by applying the correct minimum height', () => { - const { wrapper } = renderMixedChart(); + test('by applying the correct minimum height when fitHeight=false', () => { + const { wrapper } = renderMixedChart(); expect(wrapper.findByClassName(chartWrapperStyles.content)?.getElement()).toHaveStyle({ minHeight: '100px' }); }); + test.each([false, true])('when fitHeight=%s plot min-height is explicitly set', fitHeight => { + const { wrapper } = renderMixedChart( + + ); + const selector = fitHeight ? cartesianStyles['chart-container-plot-wrapper'] : chartWrapperStyles.content; + const chartElement = wrapper.findByClassName(selector)!.getElement(); + expect(chartElement.style.minHeight).toBeDefined(); + expect(parseInt(chartElement.style.minHeight)).toBeGreaterThanOrEqual(100); + }); + test('unless there is a chart showing', () => { const { wrapper } = renderMixedChart(); diff --git a/src/mixed-line-bar-chart/chart-container.tsx b/src/mixed-line-bar-chart/chart-container.tsx index 90992f7760..5f6ddfb760 100644 --- a/src/mixed-line-bar-chart/chart-container.tsx +++ b/src/mixed-line-bar-chart/chart-container.tsx @@ -33,6 +33,7 @@ import useContainerWidth from '../internal/utils/use-container-width'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; import { nodeBelongs } from '../internal/utils/node-belongs'; import { CartesianChartContainer } from '../internal/components/cartesian-chart/chart-container'; +import { useResizeObserver } from '../internal/hooks/container-queries'; const LEFT_LABELS_MARGIN = 16; const BOTTOM_LABELS_OFFSET = 12; @@ -43,6 +44,7 @@ export interface ChartContainerProps { series: ReadonlyArray>; visibleSeries: ReadonlyArray>; + fitHeight?: boolean; height: number; detailPopoverSize: MixedLineBarChartProps['detailPopoverSize']; detailPopoverFooter: MixedLineBarChartProps['detailPopoverFooter']; @@ -79,7 +81,8 @@ export interface ChartContainerProps { } export default function ChartContainer({ - height: plotHeight, + fitHeight, + height: explicitPlotHeight, series, visibleSeries, highlightedSeries, @@ -118,6 +121,14 @@ export default function ChartContainer({ const containerRef = useMergeRefs(containerMeasureRef, containerRefObject); const popoverRef = useRef(null); + const plotMeasureRef = useRef(null); + const [measuredHeight, setHeight] = useState(0); + useResizeObserver( + () => plotMeasureRef.current, + entry => fitHeight && setHeight(entry.borderBoxHeight) + ); + const plotHeight = fitHeight ? measuredHeight : explicitPlotHeight; + const isRefresh = useVisualRefresh(); const linesOnly = series.every(({ series }) => series.type === 'line' || series.type === 'threshold'); @@ -445,6 +456,8 @@ export default function ChartContainer({ return ( } leftAxisLabelMeasure={ ({ chartPlot={ ({ onBlur={onSVGBlur} onKeyDown={onSVGKeyDown} > + + = SomeRequired< InternalBaseComponentProps; export default function InternalMixedLineBarChart({ + fitHeight, height, xScaleType, yScaleType, @@ -217,6 +218,7 @@ export default function InternalMixedLineBarChart { }); }); }); + +describe.each([false, true])('getDimensionsBySize visualRefresh=%s', visualRefresh => { + const d = visualRefresh ? refreshDimensionsBySize : dimensionsBySize; + + test.each(['small', 'medium', 'large'] as const)('get correct dimensions for size="%s"', size => { + const dimensions = getDimensionsBySize({ size, hasLabels: true, visualRefresh }); + expect(dimensions).toEqual({ ...d[size], size }); + }); + + test.each([ + [d.medium.outerRadius * 2 + d.medium.padding * 2 - 1, 'small'], + [d.large.outerRadius * 2 + d.large.padding * 2 - 1, 'medium'], + [d.large.outerRadius * 2 + d.large.padding * 2 + 1, 'large'], + ])('matches size correctly for height=$0 and hasLabels=false', (height, matchedSize) => { + const dimensions = getDimensionsBySize({ size: height, hasLabels: false, visualRefresh }); + expect(dimensions.size).toBe(matchedSize); + }); + + test.each([ + [d.medium.outerRadius * 2 + d.medium.padding * 2 + d.medium.paddingLabels * 2 - 1, 'small'], + [d.large.outerRadius * 2 + d.large.padding * 2 + d.large.paddingLabels * 2 - 1, 'medium'], + [d.large.outerRadius * 2 + d.large.padding * 2 + d.large.paddingLabels * 2 + 1, 'large'], + ])('matches size correctly for height=$0 and hasLabels=true', (height, matchedSize) => { + const dimensions = getDimensionsBySize({ size: height, hasLabels: true, visualRefresh }); + expect(dimensions.size).toBe(matchedSize); + }); +}); diff --git a/src/pie-chart/index.tsx b/src/pie-chart/index.tsx index 3994cbe56e..aa9762b0f4 100644 --- a/src/pie-chart/index.tsx +++ b/src/pie-chart/index.tsx @@ -21,10 +21,13 @@ import useContainerWidth from '../internal/utils/use-container-width'; import { nodeBelongs } from '../internal/utils/node-belongs'; import { ChartWrapper } from '../internal/components/chart-wrapper'; import ChartStatusContainer, { getChartStatus } from '../internal/components/chart-status-container'; +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { getDimensionsBySize } from './utils'; export { PieChartProps }; const PieChart = function PieChart({ + fitHeight, variant = 'pie', size = 'medium', hideTitles = false, @@ -151,13 +154,20 @@ const PieChart = function PieChart { pieData: PieArcDatum>[]; visibleDataSum: number; - size: NonNullable; + dimensions: Dimension; hideTitles: boolean; hideDescriptions: boolean; highlightedSegment: PieChartProps.Datum | null; @@ -69,7 +69,7 @@ function LabelElement({ export default ({ pieData, - size, + dimensions, highlightedSegment, segmentDescription, visibleDataSum, @@ -80,7 +80,7 @@ export default ({ const containerBoundaries = useElementBoundaries(containerRef); const markers = useMemo(() => { - const { outerRadius: radius, innerLabelPadding } = dimensionsBySize[size]; + const { outerRadius: radius, innerLabelPadding } = dimensions; // More arc factories for the label positioning const arcMarkerStart = arc>() @@ -118,7 +118,7 @@ export default ({ datum, }; }); - }, [pieData, size]); + }, [pieData, dimensions]); const rootRef = useRef(null); diff --git a/src/pie-chart/pie-chart.tsx b/src/pie-chart/pie-chart.tsx index da9197d353..e515537f5c 100644 --- a/src/pie-chart/pie-chart.tsx +++ b/src/pie-chart/pie-chart.tsx @@ -13,13 +13,15 @@ import InternalBox from '../box/internal'; import Labels from './labels'; import { PieChartProps, SeriesInfo } from './interfaces'; import styles from './styles.css.js'; -import { defaultDetails, dimensionsBySize, refreshDimensionsBySize } from './utils'; +import { defaultDetails, getDimensionsBySize } from './utils'; import Segments from './segments'; -import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import ChartPlot, { ChartPlotRef } from '../internal/components/chart-plot'; import { SomeRequired } from '../internal/types'; import { useInternalI18n } from '../internal/i18n/context'; import { nodeBelongs } from '../internal/utils/node-belongs'; +import clsx from 'clsx'; +import { useResizeObserver } from '../internal/hooks/container-queries'; +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; export interface InternalChartDatum { index: number; @@ -33,6 +35,7 @@ interface InternalPieChartProps 'variant' | 'size' | 'i18nStrings' | 'hideTitles' | 'hideDescriptions' > { width: number; + height: number; highlightedSegment: T | null; onHighlightChange: (segment: null | T) => void; @@ -53,8 +56,11 @@ export interface TooltipData { } export default ({ + fitHeight, + height: explicitHeight, variant, size, + width, i18nStrings, ariaLabel, ariaLabelledby, @@ -66,7 +72,6 @@ export default ({ detailPopoverContent, detailPopoverSize, detailPopoverFooter, - width, segmentDescription, highlightedSegment, onHighlightChange, @@ -81,16 +86,26 @@ export default ({ const focusedSegmentRef = useRef(null); const popoverTrackRef = useRef(null); const popoverRef = useRef(null); + + const hasLabels = !(hideTitles && hideDescriptions); const isRefresh = useVisualRefresh(); - const dimensions = isRefresh ? refreshDimensionsBySize[size] : dimensionsBySize[size]; - const radius = dimensions.outerRadius; + const [measuredHeight, setHeight] = useState(0); + useResizeObserver( + () => plotRef.current?.svg ?? null, + entry => fitHeight && setHeight(entry.borderBoxHeight) + ); + const height = fitHeight ? measuredHeight : explicitHeight; - const hasLabels = !(hideTitles && hideDescriptions); - const height = 2 * (radius + dimensions.padding + (hasLabels ? dimensions.paddingLabels : 0)); + const dimensions = useMemo( + () => + getDimensionsBySize({ size: fitHeight ? Math.min(height, width) : size, hasLabels, visualRefresh: isRefresh }), + [fitHeight, height, width, size, hasLabels, isRefresh] + ); // Inner content is only available for donut charts and the inner description is not displayed for small charts - const hasInnerContent = variant === 'donut' && (innerMetricValue || (innerMetricDescription && size !== 'small')); + const hasInnerContent = + variant === 'donut' && (innerMetricValue || (innerMetricDescription && dimensions.size !== 'small')); const innerMetricId = useUniqueId('awsui-pie-chart__inner'); @@ -281,60 +296,76 @@ export default ({ }; return ( -
- +
- - {hasLabels && ( - + - )} - + {hasLabels && ( + + )} + +
+ {hasInnerContent && (
{innerMetricValue && ( - + {innerMetricValue} )} - {innerMetricDescription && size !== 'small' && ( + {innerMetricDescription && dimensions.size !== 'small' && ( {innerMetricDescription} diff --git a/src/pie-chart/segments.tsx b/src/pie-chart/segments.tsx index 82a0d8453a..131f67bbdb 100644 --- a/src/pie-chart/segments.tsx +++ b/src/pie-chart/segments.tsx @@ -4,9 +4,8 @@ import React, { useMemo } from 'react'; import { arc, PieArcDatum } from 'd3-shape'; import { PieChartProps } from './interfaces'; -import { dimensionsBySize, refreshDimensionsBySize } from './utils'; +import { Dimension } from './utils'; import { InternalChartDatum } from './pie-chart'; -import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import styles from './styles.css.js'; import clsx from 'clsx'; import { useInternalI18n } from '../internal/i18n/context'; @@ -14,12 +13,11 @@ import { useInternalI18n } from '../internal/i18n/context'; interface SegmentsProps { pieData: Array>>; highlightedSegment: T | null; - size: NonNullable; + dimensions: Dimension; variant: PieChartProps['variant']; focusedSegmentRef: React.RefObject; popoverTrackRef: React.RefObject; segmentAriaRoleDescription?: string; - onMouseDown: (datum: InternalChartDatum) => void; onMouseOver: (datum: InternalChartDatum) => void; onMouseOut: (event: React.MouseEvent) => void; @@ -28,7 +26,7 @@ interface SegmentsProps { export default function Segments({ pieData, highlightedSegment, - size, + dimensions, variant, focusedSegmentRef, popoverTrackRef, @@ -38,10 +36,8 @@ export default function Segments({ onMouseOut, }: SegmentsProps) { const i18n = useInternalI18n('pie-chart'); - const isRefresh = useVisualRefresh(); const { arcFactory, highlightedArcFactory } = useMemo(() => { - const dimensions = isRefresh ? refreshDimensionsBySize[size] : dimensionsBySize[size]; const radius = dimensions.outerRadius; const innerRadius = variant === 'pie' ? 0 : dimensions.innerRadius; const cornerRadius = dimensions.cornerRadius || 0; @@ -59,7 +55,7 @@ export default function Segments({ arcFactory, highlightedArcFactory, }; - }, [size, variant, isRefresh]); + }, [dimensions, variant]); const centroid = useMemo(() => { for (const datum of pieData) { diff --git a/src/pie-chart/styles.scss b/src/pie-chart/styles.scss index 6907bba55d..4ae6435623 100644 --- a/src/pie-chart/styles.scss +++ b/src/pie-chart/styles.scss @@ -27,6 +27,10 @@ } } +.content--fit-height { + flex: 1; +} + .status-container { /* used in test utils */ } @@ -34,6 +38,24 @@ .chart-container { display: flex; flex: 1; + + &--fit-height { + height: 100%; + min-height: inherit; + } +} + +.chart-container-chart-plot { + display: contents; + + &--fit-height { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } } .inner-content { diff --git a/src/pie-chart/utils.ts b/src/pie-chart/utils.ts index 3824ef6618..48aa91e576 100644 --- a/src/pie-chart/utils.ts +++ b/src/pie-chart/utils.ts @@ -4,7 +4,7 @@ import { ComponentFormatFunction } from '../internal/i18n/context'; import { PieChartProps } from './interfaces'; import styles from './styles.css.js'; -interface Dimension { +export interface Dimension { innerRadius: number; outerRadius: number; padding: number; @@ -13,27 +13,31 @@ interface Dimension { cornerRadius?: number; } +const paddingLabels = 44; // = 2 * (size-lineHeight-body-100) +const defaultPadding = 12; // = space-s +const smallPadding = 8; // = space-xs + export const dimensionsBySize: Record, Dimension> = { small: { innerRadius: 33, outerRadius: 50, - innerLabelPadding: 8, - padding: 8, // = space-xs - paddingLabels: 44, // = 2 * (size-lineHeight-body-100) + innerLabelPadding: smallPadding, + padding: smallPadding, + paddingLabels, }, medium: { innerRadius: 66, outerRadius: 100, - innerLabelPadding: 12, - padding: 12, // = space-s - paddingLabels: 44, // = 2 * (size-lineHeight-body-100) + innerLabelPadding: defaultPadding, + padding: defaultPadding, + paddingLabels, }, large: { innerRadius: 93, outerRadius: 140, - innerLabelPadding: 12, - padding: 12, // = space-s - paddingLabels: 44, // = 2 * (size-lineHeight-body-100) + innerLabelPadding: defaultPadding, + padding: defaultPadding, + paddingLabels, }, }; @@ -55,6 +59,43 @@ export const refreshDimensionsBySize: Record, }, }; +/** + * When `size` is a string ("small", "medium" or "large") the predefined pie chart element dimensions for classic and visual refresh are used. + * When `size` is a number the outer and inner radii are computed and the rest of the dimensions are taken from the closest predefined size. + */ +export function getDimensionsBySize({ + size, + hasLabels, + visualRefresh, +}: { + size: NonNullable | number; + hasLabels: boolean; + visualRefresh?: boolean; +}): Dimension & { size: NonNullable } { + if (typeof size === 'string') { + const dimensions = visualRefresh ? refreshDimensionsBySize[size] : dimensionsBySize[size]; + return { ...dimensions, size }; + } + const sizeSpec = visualRefresh ? refreshDimensionsBySize : dimensionsBySize; + const getPixelSize = (d: Dimension) => d.outerRadius * 2 + d.padding * 2 + (hasLabels ? d.paddingLabels : 0) * 2; + + let matchedSize: NonNullable = 'small'; + if (size > getPixelSize(sizeSpec.medium)) { + matchedSize = 'medium'; + } + if (size > getPixelSize(sizeSpec.large)) { + matchedSize = 'large'; + } + + const padding = sizeSpec[matchedSize].padding; + const paddingLabels = hasLabels ? sizeSpec[matchedSize].paddingLabels : 0; + const radiiRatio = sizeSpec[matchedSize].outerRadius / sizeSpec[matchedSize].innerRadius; + const outerRadius = (size - 2 * paddingLabels - 2 * padding) / 2; + const innerRadius = outerRadius / radiiRatio; + + return { ...sizeSpec[matchedSize], outerRadius, innerRadius, size: matchedSize }; +} + export const defaultDetails = (i18n: ComponentFormatFunction<'pie-chart'>, i18nStrings: PieChartProps.I18nStrings) => (datum: PieChartProps.Datum, dataSum: number) =>