@hero-design/rn 8.114.0 → 8.115.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/index.js +138 -7
- package/package.json +1 -1
- package/src/components/Progress/SegmentedBar/StyledSegmentedBar.tsx +6 -1
- package/src/components/Progress/SegmentedBar/__tests__/index.spec.tsx +108 -0
- package/src/components/Progress/SegmentedBar/constants.ts +32 -0
- package/src/components/Progress/SegmentedBar/hooks/colors.ts +102 -0
- package/src/components/Progress/SegmentedBar/hooks/validation.ts +50 -0
- package/src/components/Progress/SegmentedBar/index.tsx +37 -15
- package/src/components/Progress/SegmentedBar/types.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @hero-design/rn
|
|
2
2
|
|
|
3
|
+
## 8.115.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#4620](https://github.com/Thinkei/hero-design/pull/4620) [`305a264890d57ea01402684d2ba42e34908cd852`](https://github.com/Thinkei/hero-design/commit/305a264890d57ea01402684d2ba42e34908cd852) Thanks [@vinhphan-eh](https://github.com/vinhphan-eh)! - [Progress.SegmentedBar] Add custom color config and empty state
|
|
8
|
+
|
|
3
9
|
## 8.114.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/lib/index.js
CHANGED
|
@@ -25402,11 +25402,13 @@ var ProgressStep = function ProgressStep(_ref) {
|
|
|
25402
25402
|
}));
|
|
25403
25403
|
};
|
|
25404
25404
|
|
|
25405
|
+
var SEGMENTED_BAR_COLORS = ['primaryMedium', 'blueMedium', 'greenMedium', 'redMedium', 'orangeMedium', 'yellowMedium', 'pinkMedium', 'greyMedium', 'primaryLight', 'blueLight', 'greenLight', 'redLight', 'orangeLight', 'yellowLight', 'pinkLight', 'greyLight', 'success', 'onSuccessSurface', 'onInfoSurface', 'warning', 'onWarningSurface', 'error', 'onErrorSurface', 'archive', 'onArchiveSurface'];
|
|
25405
25406
|
var MIN_PROGRESS_VALUE = 0;
|
|
25406
25407
|
var MAX_PROGRESS_VALUE = 100;
|
|
25407
25408
|
var SEGMENTED_BAR_ERRORS = {
|
|
25408
25409
|
negativeValue: '[Progress.SegmentedBar] value:$value is less than 0',
|
|
25409
|
-
legendDisplayValues: '[Progress.SegmentedBar] legendConfig.displayValues must include a non-null/non-undefined entry for each data series (length must match and no null/undefined values are allowed)'
|
|
25410
|
+
legendDisplayValues: '[Progress.SegmentedBar] legendConfig.displayValues must include a non-null/non-undefined entry for each data series (length must match and no null/undefined values are allowed)',
|
|
25411
|
+
customColorsInvalid: '[Progress.SegmentedBar] Invalid styleConfig.series: length must match the number of data series, each entry must include a color value, and labels must match the data series labels'
|
|
25410
25412
|
};
|
|
25411
25413
|
|
|
25412
25414
|
var useValidateSegmentedBarData = function useValidateSegmentedBarData(_ref) {
|
|
@@ -25454,6 +25456,115 @@ var useValidateSegmentedBarLegendDisplayValues = function useValidateSegmentedBa
|
|
|
25454
25456
|
assert(!(hasLengthMismatch || hasEmptyEntry), SEGMENTED_BAR_ERRORS.legendDisplayValues);
|
|
25455
25457
|
return valuesToUse;
|
|
25456
25458
|
};
|
|
25459
|
+
var useValidateSegmentedBarStyleConfig = function useValidateSegmentedBarStyleConfig(_ref3) {
|
|
25460
|
+
var dataSeries = _ref3.dataSeries,
|
|
25461
|
+
customSeries = _ref3.customSeries;
|
|
25462
|
+
var _React$useMemo3 = React__namespace.default.useMemo(function () {
|
|
25463
|
+
if (!customSeries || dataSeries.length === 0) {
|
|
25464
|
+
return {
|
|
25465
|
+
hasInvalidLength: false,
|
|
25466
|
+
hasMissingColor: false,
|
|
25467
|
+
hasMissingLabel: false
|
|
25468
|
+
};
|
|
25469
|
+
}
|
|
25470
|
+
var invalidLength = !Array.isArray(customSeries) || customSeries.length !== dataSeries.length;
|
|
25471
|
+
if (invalidLength) {
|
|
25472
|
+
return {
|
|
25473
|
+
hasInvalidLength: invalidLength,
|
|
25474
|
+
hasMissingColor: false,
|
|
25475
|
+
hasMissingLabel: false
|
|
25476
|
+
};
|
|
25477
|
+
}
|
|
25478
|
+
var missingColor = customSeries.some(function (item) {
|
|
25479
|
+
return !(item !== null && item !== void 0 && item.color);
|
|
25480
|
+
});
|
|
25481
|
+
var providedLabels = new Set(customSeries.map(function (item) {
|
|
25482
|
+
return item.label;
|
|
25483
|
+
}));
|
|
25484
|
+
var missingLabel = dataSeries.some(function (_ref4) {
|
|
25485
|
+
var label = _ref4.label;
|
|
25486
|
+
return !providedLabels.has(label);
|
|
25487
|
+
});
|
|
25488
|
+
return {
|
|
25489
|
+
hasInvalidLength: invalidLength,
|
|
25490
|
+
hasMissingColor: missingColor,
|
|
25491
|
+
hasMissingLabel: missingLabel
|
|
25492
|
+
};
|
|
25493
|
+
}, [customSeries, dataSeries]),
|
|
25494
|
+
hasInvalidLength = _React$useMemo3.hasInvalidLength,
|
|
25495
|
+
hasMissingColor = _React$useMemo3.hasMissingColor,
|
|
25496
|
+
hasMissingLabel = _React$useMemo3.hasMissingLabel;
|
|
25497
|
+
assert(!(hasInvalidLength || hasMissingColor || hasMissingLabel), SEGMENTED_BAR_ERRORS.customColorsInvalid);
|
|
25498
|
+
};
|
|
25499
|
+
|
|
25500
|
+
/**
|
|
25501
|
+
* Retrieves a color value from a theme source by token.
|
|
25502
|
+
*/
|
|
25503
|
+
var getThemeColorValue = function getThemeColorValue(source, token) {
|
|
25504
|
+
var value = source === null || source === void 0 ? void 0 : source[token];
|
|
25505
|
+
return typeof value === 'string' ? value : undefined;
|
|
25506
|
+
};
|
|
25507
|
+
/**
|
|
25508
|
+
* Resolves a SegmentedBar color token to a concrete CSS color string.
|
|
25509
|
+
* Falls back to the raw token string if not found.
|
|
25510
|
+
*/
|
|
25511
|
+
var getColorFromList = function getColorFromList(_ref) {
|
|
25512
|
+
var _ref2, _getThemeColorValue;
|
|
25513
|
+
var theme = _ref.theme,
|
|
25514
|
+
token = _ref.token;
|
|
25515
|
+
return (_ref2 = (_getThemeColorValue = getThemeColorValue(theme.colors, token)) !== null && _getThemeColorValue !== void 0 ? _getThemeColorValue : getThemeColorValue(palette$9, token)) !== null && _ref2 !== void 0 ? _ref2 : token;
|
|
25516
|
+
};
|
|
25517
|
+
/**
|
|
25518
|
+
* Maps custom series colors by matching labels.
|
|
25519
|
+
*/
|
|
25520
|
+
var mapCustomSeriesColors = function mapCustomSeriesColors(_ref3) {
|
|
25521
|
+
var dataSeries = _ref3.dataSeries,
|
|
25522
|
+
customSeries = _ref3.customSeries,
|
|
25523
|
+
theme = _ref3.theme;
|
|
25524
|
+
var colorByLabel = new Map(customSeries.map(function (_ref4) {
|
|
25525
|
+
var label = _ref4.label,
|
|
25526
|
+
color = _ref4.color;
|
|
25527
|
+
return [label, getColorFromList({
|
|
25528
|
+
theme: theme,
|
|
25529
|
+
token: color
|
|
25530
|
+
})];
|
|
25531
|
+
}));
|
|
25532
|
+
return dataSeries.map(function (_ref5) {
|
|
25533
|
+
var label = _ref5.label;
|
|
25534
|
+
var resolvedColor = colorByLabel.get(label);
|
|
25535
|
+
return resolvedColor || '';
|
|
25536
|
+
});
|
|
25537
|
+
};
|
|
25538
|
+
/**
|
|
25539
|
+
* Returns an array of colors (index-aligned to the segmented bar data).
|
|
25540
|
+
*
|
|
25541
|
+
* - if `customSeries` is provided, uses the provided series colors (resolved via theme/palette)
|
|
25542
|
+
* - otherwise returns the default palette-based colors
|
|
25543
|
+
*/
|
|
25544
|
+
var useSegmentedBarColors = function useSegmentedBarColors(_ref6) {
|
|
25545
|
+
var dataSeries = _ref6.dataSeries,
|
|
25546
|
+
customSeries = _ref6.customSeries;
|
|
25547
|
+
var theme = useTheme$1();
|
|
25548
|
+
var colors = React.useMemo(function () {
|
|
25549
|
+
if (customSeries !== undefined) {
|
|
25550
|
+
return mapCustomSeriesColors({
|
|
25551
|
+
dataSeries: dataSeries,
|
|
25552
|
+
customSeries: customSeries,
|
|
25553
|
+
theme: theme
|
|
25554
|
+
});
|
|
25555
|
+
}
|
|
25556
|
+
// Default colors
|
|
25557
|
+
return Array.from({
|
|
25558
|
+
length: dataSeries.length
|
|
25559
|
+
}, function (_, index) {
|
|
25560
|
+
return getColorFromList({
|
|
25561
|
+
theme: theme,
|
|
25562
|
+
token: SEGMENTED_BAR_COLORS[index % SEGMENTED_BAR_COLORS.length]
|
|
25563
|
+
});
|
|
25564
|
+
});
|
|
25565
|
+
}, [customSeries, dataSeries, theme]);
|
|
25566
|
+
return colors;
|
|
25567
|
+
};
|
|
25457
25568
|
|
|
25458
25569
|
var LegendBox = index$c(Box)(function (_ref) {
|
|
25459
25570
|
var theme = _ref.theme;
|
|
@@ -25546,14 +25657,23 @@ var StyledSegment = index$c(Box)(function (_ref3) {
|
|
|
25546
25657
|
width: "".concat(themeWidthPercent, "%")
|
|
25547
25658
|
};
|
|
25548
25659
|
});
|
|
25660
|
+
var StyledEmptyText = index$c(Typography.Caption)(function (_ref4) {
|
|
25661
|
+
var theme = _ref4.theme;
|
|
25662
|
+
return {
|
|
25663
|
+
marginTop: theme.__hd__.progress.space.segmentedLegendMarginTop
|
|
25664
|
+
};
|
|
25665
|
+
});
|
|
25549
25666
|
|
|
25550
|
-
var _excluded$j = ["data", "headerConfig", "legendConfig", "style", "testID"];
|
|
25667
|
+
var _excluded$j = ["data", "headerConfig", "emptyText", "legendConfig", "styleConfig", "style", "testID", "collapsable"];
|
|
25551
25668
|
var ProgressSegmentedBar = function ProgressSegmentedBar(_ref) {
|
|
25552
25669
|
var data = _ref.data,
|
|
25553
25670
|
headerConfig = _ref.headerConfig,
|
|
25671
|
+
emptyText = _ref.emptyText,
|
|
25554
25672
|
legendConfig = _ref.legendConfig,
|
|
25673
|
+
styleConfig = _ref.styleConfig,
|
|
25555
25674
|
style = _ref.style,
|
|
25556
25675
|
testID = _ref.testID,
|
|
25676
|
+
collapsable = _ref.collapsable,
|
|
25557
25677
|
nativeProps = _objectWithoutProperties(_ref, _excluded$j);
|
|
25558
25678
|
var values = React__namespace.default.useMemo(function () {
|
|
25559
25679
|
return data.map(function (s) {
|
|
@@ -25569,6 +25689,10 @@ var ProgressSegmentedBar = function ProgressSegmentedBar(_ref) {
|
|
|
25569
25689
|
legendConfig: legendConfig,
|
|
25570
25690
|
fallbackValues: values
|
|
25571
25691
|
});
|
|
25692
|
+
useValidateSegmentedBarStyleConfig({
|
|
25693
|
+
dataSeries: data,
|
|
25694
|
+
customSeries: styleConfig === null || styleConfig === void 0 ? void 0 : styleConfig.series
|
|
25695
|
+
});
|
|
25572
25696
|
var theme = useTheme$1();
|
|
25573
25697
|
var totalPercent = React__namespace.default.useMemo(function () {
|
|
25574
25698
|
return values.reduce(function (acc, value) {
|
|
@@ -25579,8 +25703,10 @@ var ProgressSegmentedBar = function ProgressSegmentedBar(_ref) {
|
|
|
25579
25703
|
var remainderPercent = React__namespace.default.useMemo(function () {
|
|
25580
25704
|
return totalPercent >= MAX_PROGRESS_VALUE ? 0 : MAX_PROGRESS_VALUE - totalPercent;
|
|
25581
25705
|
}, [totalPercent]);
|
|
25582
|
-
|
|
25583
|
-
|
|
25706
|
+
var segmentColors = useSegmentedBarColors({
|
|
25707
|
+
dataSeries: data,
|
|
25708
|
+
customSeries: styleConfig === null || styleConfig === void 0 ? void 0 : styleConfig.series
|
|
25709
|
+
});
|
|
25584
25710
|
var headerRight = React__namespace.default.useMemo(function () {
|
|
25585
25711
|
return getTextComponent(headerConfig === null || headerConfig === void 0 ? void 0 : headerConfig.right, /*#__PURE__*/React__namespace.default.createElement(Typography.Caption, {
|
|
25586
25712
|
intent: "muted"
|
|
@@ -25588,9 +25714,11 @@ var ProgressSegmentedBar = function ProgressSegmentedBar(_ref) {
|
|
|
25588
25714
|
}, [headerConfig === null || headerConfig === void 0 ? void 0 : headerConfig.right]);
|
|
25589
25715
|
var showHeader = Boolean((headerConfig === null || headerConfig === void 0 ? void 0 : headerConfig.left) || headerRight);
|
|
25590
25716
|
var showLegend = Boolean(legendConfig && Object.keys(legendConfig).length > 0);
|
|
25717
|
+
var showEmptyText = Boolean(!data.length && emptyText);
|
|
25591
25718
|
return /*#__PURE__*/React__namespace.default.createElement(Box, _extends$1({}, nativeProps, {
|
|
25592
25719
|
style: style,
|
|
25593
|
-
testID: testID
|
|
25720
|
+
testID: testID,
|
|
25721
|
+
collapsable: collapsable || false
|
|
25594
25722
|
}), showHeader ? /*#__PURE__*/React__namespace.default.createElement(StyledHeader, {
|
|
25595
25723
|
testID: testID ? "".concat(testID, "-header") : undefined
|
|
25596
25724
|
}, headerConfig !== null && headerConfig !== void 0 && headerConfig.left ? /*#__PURE__*/React__namespace.default.createElement(Typography.Body, {
|
|
@@ -25619,8 +25747,11 @@ var ProgressSegmentedBar = function ProgressSegmentedBar(_ref) {
|
|
|
25619
25747
|
themeColor: theme.__hd__.progress.colors.segmentedRemainderBackground,
|
|
25620
25748
|
themeWidthPercent: remainderPercent,
|
|
25621
25749
|
testID: testID ? "".concat(testID, "-segment-remainder") : undefined
|
|
25622
|
-
}) : null),
|
|
25623
|
-
|
|
25750
|
+
}) : null), showEmptyText ? /*#__PURE__*/React__namespace.default.createElement(StyledEmptyText, {
|
|
25751
|
+
intent: "muted",
|
|
25752
|
+
testID: testID ? "".concat(testID, "-empty-text") : undefined
|
|
25753
|
+
}, emptyText) : null, showLegend ? /*#__PURE__*/React__namespace.default.createElement(LegendBox, {
|
|
25754
|
+
testID: testID ? "".concat(testID, "-legend") : undefined
|
|
25624
25755
|
}, data.map(function (item, index) {
|
|
25625
25756
|
return /*#__PURE__*/React__namespace.default.createElement(ProgressSegmentedBarLegendItem, {
|
|
25626
25757
|
key: "".concat(item.label, "-").concat(segmentColors[index % segmentColors.length]),
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import styled from '@emotion/native';
|
|
2
|
+
import Typography from '../../Typography';
|
|
2
3
|
import Box from '../../Box';
|
|
3
4
|
|
|
4
5
|
const StyledHeader = styled(Box)(({ theme }) => ({
|
|
@@ -27,4 +28,8 @@ const StyledSegment = styled(Box)<{
|
|
|
27
28
|
width: `${themeWidthPercent}%`,
|
|
28
29
|
}));
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
const StyledEmptyText = styled(Typography.Caption)(({ theme }) => ({
|
|
32
|
+
marginTop: theme.__hd__.progress.space.segmentedLegendMarginTop,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
export { StyledHeader, StyledSegment, StyledTrack, StyledEmptyText };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { mobileVisualisationPalette } from '@hero-design/colors';
|
|
1
2
|
import React from 'react';
|
|
3
|
+
import { StyleSheet } from 'react-native';
|
|
2
4
|
import renderWithTheme from '../../../../testHelpers/renderWithTheme';
|
|
3
5
|
import ProgressSegmentedBar from '..';
|
|
4
6
|
import { SEGMENTED_BAR_ERRORS } from '../constants';
|
|
@@ -77,6 +79,33 @@ describe('Progress.SegmentedBar', () => {
|
|
|
77
79
|
expect(queryByTestId('progress-segmented-legend')).toBeNull();
|
|
78
80
|
});
|
|
79
81
|
|
|
82
|
+
it('renders empty text when data is empty', () => {
|
|
83
|
+
const { getByTestId, getByText, queryByTestId } = renderWithTheme(
|
|
84
|
+
<ProgressSegmentedBar
|
|
85
|
+
testID="progress-segmented"
|
|
86
|
+
emptyText="No data yet"
|
|
87
|
+
data={[]}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(getByTestId('progress-segmented-track')).toBeVisible();
|
|
92
|
+
expect(getByTestId('progress-segmented-empty-text')).toBeVisible();
|
|
93
|
+
expect(getByText('No data yet')).toBeVisible();
|
|
94
|
+
expect(queryByTestId('progress-segmented-legend')).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not render empty text when data exists', () => {
|
|
98
|
+
const { queryByTestId } = renderWithTheme(
|
|
99
|
+
<ProgressSegmentedBar
|
|
100
|
+
testID="progress-segmented"
|
|
101
|
+
emptyText="No data yet"
|
|
102
|
+
data={[{ label: 'A', data: 10 }]}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(queryByTestId('progress-segmented-empty-text')).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
80
109
|
it('throws on negative segment value', () => {
|
|
81
110
|
expect(() =>
|
|
82
111
|
renderWithTheme(
|
|
@@ -112,4 +141,83 @@ describe('Progress.SegmentedBar', () => {
|
|
|
112
141
|
).toThrow(SEGMENTED_BAR_ERRORS.legendDisplayValues);
|
|
113
142
|
}
|
|
114
143
|
);
|
|
144
|
+
|
|
145
|
+
it('uses custom colors from styleConfig', () => {
|
|
146
|
+
const { getByTestId } = renderWithTheme(
|
|
147
|
+
<ProgressSegmentedBar
|
|
148
|
+
testID="progress-segmented"
|
|
149
|
+
styleConfig={{
|
|
150
|
+
series: [
|
|
151
|
+
{ label: 'A', color: 'primaryLight' },
|
|
152
|
+
{ label: 'B', color: 'blueLight' },
|
|
153
|
+
],
|
|
154
|
+
}}
|
|
155
|
+
data={[
|
|
156
|
+
{ label: 'A', data: 40 },
|
|
157
|
+
{ label: 'B', data: 60 },
|
|
158
|
+
]}
|
|
159
|
+
legendConfig={{ displayValues: ['40%', '60%'] }}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const firstSegmentStyle = StyleSheet.flatten(
|
|
164
|
+
getByTestId('progress-segmented-segment-A').props.style
|
|
165
|
+
);
|
|
166
|
+
const secondSegmentStyle = StyleSheet.flatten(
|
|
167
|
+
getByTestId('progress-segmented-segment-B').props.style
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(firstSegmentStyle?.backgroundColor).toBe(
|
|
171
|
+
mobileVisualisationPalette.primaryLight
|
|
172
|
+
);
|
|
173
|
+
expect(secondSegmentStyle?.backgroundColor).toBe(
|
|
174
|
+
mobileVisualisationPalette.blueLight
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('uses default colors when styleConfig is not provided', () => {
|
|
179
|
+
const { getByTestId } = renderWithTheme(
|
|
180
|
+
<ProgressSegmentedBar
|
|
181
|
+
testID="progress-segmented"
|
|
182
|
+
data={[
|
|
183
|
+
{ label: 'A', data: 40 },
|
|
184
|
+
{ label: 'B', data: 60 },
|
|
185
|
+
]}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const firstSegmentStyle = StyleSheet.flatten(
|
|
190
|
+
getByTestId('progress-segmented-segment-A').props.style
|
|
191
|
+
);
|
|
192
|
+
const secondSegmentStyle = StyleSheet.flatten(
|
|
193
|
+
getByTestId('progress-segmented-segment-B').props.style
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(firstSegmentStyle?.backgroundColor).toBe(
|
|
197
|
+
mobileVisualisationPalette.primaryMedium
|
|
198
|
+
);
|
|
199
|
+
expect(secondSegmentStyle?.backgroundColor).toBe(
|
|
200
|
+
mobileVisualisationPalette.blueMedium
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it.each`
|
|
205
|
+
description | styleConfig
|
|
206
|
+
${'length mismatches data'} | ${{ series: [{ label: 'A', color: 'primaryLight' }] }}
|
|
207
|
+
${'labels do not match data labels'} | ${{ series: [{ label: 'C', color: 'primaryLight' }, { label: 'D', color: 'blueLight' }] }}
|
|
208
|
+
${'colors are missing'} | ${{ series: [{ label: 'A', color: '' as any }, { label: 'B', color: undefined as any }] }}
|
|
209
|
+
`('throws when styleConfig is invalid: $description', ({ styleConfig }) => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
renderWithTheme(
|
|
212
|
+
<ProgressSegmentedBar
|
|
213
|
+
testID="progress-segmented"
|
|
214
|
+
styleConfig={styleConfig}
|
|
215
|
+
data={[
|
|
216
|
+
{ label: 'A', data: 40 },
|
|
217
|
+
{ label: 'B', data: 60 },
|
|
218
|
+
]}
|
|
219
|
+
/>
|
|
220
|
+
)
|
|
221
|
+
).toThrow(SEGMENTED_BAR_ERRORS.customColorsInvalid);
|
|
222
|
+
});
|
|
115
223
|
});
|
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
export const SEGMENTED_BAR_COLORS = [
|
|
2
|
+
'primaryMedium',
|
|
3
|
+
'blueMedium',
|
|
4
|
+
'greenMedium',
|
|
5
|
+
'redMedium',
|
|
6
|
+
'orangeMedium',
|
|
7
|
+
'yellowMedium',
|
|
8
|
+
'pinkMedium',
|
|
9
|
+
'greyMedium',
|
|
10
|
+
'primaryLight',
|
|
11
|
+
'blueLight',
|
|
12
|
+
'greenLight',
|
|
13
|
+
'redLight',
|
|
14
|
+
'orangeLight',
|
|
15
|
+
'yellowLight',
|
|
16
|
+
'pinkLight',
|
|
17
|
+
'greyLight',
|
|
18
|
+
'success',
|
|
19
|
+
'onSuccessSurface',
|
|
20
|
+
'onInfoSurface',
|
|
21
|
+
'warning',
|
|
22
|
+
'onWarningSurface',
|
|
23
|
+
'error',
|
|
24
|
+
'onErrorSurface',
|
|
25
|
+
'archive',
|
|
26
|
+
'onArchiveSurface',
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export type SegmentedBarColorToken = typeof SEGMENTED_BAR_COLORS[number];
|
|
30
|
+
|
|
1
31
|
export const MIN_PROGRESS_VALUE = 0;
|
|
2
32
|
export const MAX_PROGRESS_VALUE = 100;
|
|
3
33
|
|
|
@@ -5,4 +35,6 @@ export const SEGMENTED_BAR_ERRORS = {
|
|
|
5
35
|
negativeValue: '[Progress.SegmentedBar] value:$value is less than 0',
|
|
6
36
|
legendDisplayValues:
|
|
7
37
|
'[Progress.SegmentedBar] legendConfig.displayValues must include a non-null/non-undefined entry for each data series (length must match and no null/undefined values are allowed)',
|
|
38
|
+
customColorsInvalid:
|
|
39
|
+
'[Progress.SegmentedBar] Invalid styleConfig.series: length must match the number of data series, each entry must include a color value, and labels must match the data series labels',
|
|
8
40
|
} as const;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useTheme } from '@emotion/react';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { mobileVisualisationPalette } from '@hero-design/colors';
|
|
4
|
+
import type { Theme } from '../../../../theme';
|
|
5
|
+
import type { SegmentedBarData, SegmentedBarStyleConfig } from '../types';
|
|
6
|
+
import {
|
|
7
|
+
SEGMENTED_BAR_COLORS,
|
|
8
|
+
type SegmentedBarColorToken,
|
|
9
|
+
} from '../constants';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Retrieves a color value from a theme source by token.
|
|
13
|
+
*/
|
|
14
|
+
const getThemeColorValue = (
|
|
15
|
+
source: Record<string, unknown> | undefined,
|
|
16
|
+
token: SegmentedBarColorToken
|
|
17
|
+
): string | undefined => {
|
|
18
|
+
const value = source?.[token];
|
|
19
|
+
return typeof value === 'string' ? value : undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a SegmentedBar color token to a concrete CSS color string.
|
|
24
|
+
* Falls back to the raw token string if not found.
|
|
25
|
+
*/
|
|
26
|
+
const getColorFromList = ({
|
|
27
|
+
theme,
|
|
28
|
+
token,
|
|
29
|
+
}: {
|
|
30
|
+
theme: Theme;
|
|
31
|
+
token: SegmentedBarColorToken;
|
|
32
|
+
}): string => {
|
|
33
|
+
return (
|
|
34
|
+
getThemeColorValue(theme.colors, token) ??
|
|
35
|
+
getThemeColorValue(mobileVisualisationPalette, token) ??
|
|
36
|
+
token
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maps custom series colors by matching labels.
|
|
42
|
+
*/
|
|
43
|
+
const mapCustomSeriesColors = ({
|
|
44
|
+
dataSeries,
|
|
45
|
+
customSeries,
|
|
46
|
+
theme,
|
|
47
|
+
}: {
|
|
48
|
+
dataSeries: SegmentedBarData;
|
|
49
|
+
customSeries: NonNullable<SegmentedBarStyleConfig['series']>;
|
|
50
|
+
theme: Theme;
|
|
51
|
+
}): string[] => {
|
|
52
|
+
const colorByLabel = new Map(
|
|
53
|
+
customSeries.map(({ label, color }) => [
|
|
54
|
+
label,
|
|
55
|
+
getColorFromList({
|
|
56
|
+
theme,
|
|
57
|
+
token: color,
|
|
58
|
+
}),
|
|
59
|
+
])
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return dataSeries.map(({ label }) => {
|
|
63
|
+
const resolvedColor = colorByLabel.get(label);
|
|
64
|
+
return resolvedColor || '';
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns an array of colors (index-aligned to the segmented bar data).
|
|
70
|
+
*
|
|
71
|
+
* - if `customSeries` is provided, uses the provided series colors (resolved via theme/palette)
|
|
72
|
+
* - otherwise returns the default palette-based colors
|
|
73
|
+
*/
|
|
74
|
+
export const useSegmentedBarColors = ({
|
|
75
|
+
dataSeries,
|
|
76
|
+
customSeries,
|
|
77
|
+
}: {
|
|
78
|
+
dataSeries: SegmentedBarData;
|
|
79
|
+
customSeries?: SegmentedBarStyleConfig['series'];
|
|
80
|
+
}): string[] => {
|
|
81
|
+
const theme = useTheme();
|
|
82
|
+
|
|
83
|
+
const colors = useMemo(() => {
|
|
84
|
+
if (customSeries !== undefined) {
|
|
85
|
+
return mapCustomSeriesColors({
|
|
86
|
+
dataSeries,
|
|
87
|
+
customSeries,
|
|
88
|
+
theme,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Default colors
|
|
93
|
+
return Array.from({ length: dataSeries.length }, (_, index) =>
|
|
94
|
+
getColorFromList({
|
|
95
|
+
theme,
|
|
96
|
+
token: SEGMENTED_BAR_COLORS[index % SEGMENTED_BAR_COLORS.length],
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
}, [customSeries, dataSeries, theme]);
|
|
100
|
+
|
|
101
|
+
return colors;
|
|
102
|
+
};
|
|
@@ -6,6 +6,8 @@ import { assert } from '../../../../utils/helpers';
|
|
|
6
6
|
import type {
|
|
7
7
|
SegmentedBarLegendConfig,
|
|
8
8
|
SegmentedBarLegendDisplayValue,
|
|
9
|
+
SegmentedBarData,
|
|
10
|
+
SegmentedBarStyleConfig,
|
|
9
11
|
} from '../types';
|
|
10
12
|
|
|
11
13
|
export const useValidateSegmentedBarData = ({
|
|
@@ -69,3 +71,51 @@ export const useValidateSegmentedBarLegendDisplayValues = ({
|
|
|
69
71
|
);
|
|
70
72
|
return valuesToUse;
|
|
71
73
|
};
|
|
74
|
+
|
|
75
|
+
export const useValidateSegmentedBarStyleConfig = ({
|
|
76
|
+
dataSeries,
|
|
77
|
+
customSeries,
|
|
78
|
+
}: {
|
|
79
|
+
dataSeries: SegmentedBarData;
|
|
80
|
+
customSeries: SegmentedBarStyleConfig['series'];
|
|
81
|
+
}): void => {
|
|
82
|
+
const { hasInvalidLength, hasMissingColor, hasMissingLabel } =
|
|
83
|
+
React.useMemo(() => {
|
|
84
|
+
if (!customSeries || dataSeries.length === 0) {
|
|
85
|
+
return {
|
|
86
|
+
hasInvalidLength: false,
|
|
87
|
+
hasMissingColor: false,
|
|
88
|
+
hasMissingLabel: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const invalidLength =
|
|
93
|
+
!Array.isArray(customSeries) ||
|
|
94
|
+
customSeries.length !== dataSeries.length;
|
|
95
|
+
|
|
96
|
+
if (invalidLength) {
|
|
97
|
+
return {
|
|
98
|
+
hasInvalidLength: invalidLength,
|
|
99
|
+
hasMissingColor: false,
|
|
100
|
+
hasMissingLabel: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const missingColor = customSeries.some((item) => !item?.color);
|
|
105
|
+
const providedLabels = new Set(customSeries.map((item) => item.label));
|
|
106
|
+
const missingLabel = dataSeries.some(
|
|
107
|
+
({ label }) => !providedLabels.has(label)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
hasInvalidLength: invalidLength,
|
|
112
|
+
hasMissingColor: missingColor,
|
|
113
|
+
hasMissingLabel: missingLabel,
|
|
114
|
+
};
|
|
115
|
+
}, [customSeries, dataSeries]);
|
|
116
|
+
|
|
117
|
+
assert(
|
|
118
|
+
!(hasInvalidLength || hasMissingColor || hasMissingLabel),
|
|
119
|
+
SEGMENTED_BAR_ERRORS.customColorsInvalid
|
|
120
|
+
);
|
|
121
|
+
};
|
|
@@ -9,19 +9,29 @@ import { MAX_PROGRESS_VALUE, MIN_PROGRESS_VALUE } from './constants';
|
|
|
9
9
|
import {
|
|
10
10
|
useValidateSegmentedBarData,
|
|
11
11
|
useValidateSegmentedBarLegendDisplayValues,
|
|
12
|
+
useValidateSegmentedBarStyleConfig,
|
|
12
13
|
} from './hooks/validation';
|
|
14
|
+
import { useSegmentedBarColors } from './hooks/colors';
|
|
13
15
|
import LegendItem from './Legend';
|
|
14
16
|
import { LegendBox } from './StyledLegend';
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
StyledEmptyText,
|
|
19
|
+
StyledHeader,
|
|
20
|
+
StyledSegment,
|
|
21
|
+
StyledTrack,
|
|
22
|
+
} from './StyledSegmentedBar';
|
|
16
23
|
import type { ProgressSegmentedBarProps } from './types';
|
|
17
24
|
import { getTextComponent } from './utils';
|
|
18
25
|
|
|
19
26
|
const ProgressSegmentedBar = ({
|
|
20
27
|
data,
|
|
21
28
|
headerConfig,
|
|
29
|
+
emptyText,
|
|
22
30
|
legendConfig,
|
|
31
|
+
styleConfig,
|
|
23
32
|
style,
|
|
24
33
|
testID,
|
|
34
|
+
collapsable,
|
|
25
35
|
...nativeProps
|
|
26
36
|
}: ProgressSegmentedBarProps): ReactElement => {
|
|
27
37
|
const values = React.useMemo(() => data.map((s) => s.data), [data]);
|
|
@@ -33,6 +43,11 @@ const ProgressSegmentedBar = ({
|
|
|
33
43
|
fallbackValues: values,
|
|
34
44
|
});
|
|
35
45
|
|
|
46
|
+
useValidateSegmentedBarStyleConfig({
|
|
47
|
+
dataSeries: data,
|
|
48
|
+
customSeries: styleConfig?.series,
|
|
49
|
+
});
|
|
50
|
+
|
|
36
51
|
const theme = useTheme();
|
|
37
52
|
const totalPercent = React.useMemo(
|
|
38
53
|
() => values.reduce((acc, value) => acc + value, 0),
|
|
@@ -47,16 +62,10 @@ const ProgressSegmentedBar = ({
|
|
|
47
62
|
? 0
|
|
48
63
|
: MAX_PROGRESS_VALUE - totalPercent;
|
|
49
64
|
}, [totalPercent]);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
theme.colors.success,
|
|
55
|
-
theme.colors.warning,
|
|
56
|
-
theme.colors.error,
|
|
57
|
-
theme.colors.info,
|
|
58
|
-
theme.colors.archived,
|
|
59
|
-
];
|
|
65
|
+
const segmentColors = useSegmentedBarColors({
|
|
66
|
+
dataSeries: data,
|
|
67
|
+
customSeries: styleConfig?.series,
|
|
68
|
+
});
|
|
60
69
|
|
|
61
70
|
const headerRight = React.useMemo(
|
|
62
71
|
() =>
|
|
@@ -73,9 +82,15 @@ const ProgressSegmentedBar = ({
|
|
|
73
82
|
const showLegend = Boolean(
|
|
74
83
|
legendConfig && Object.keys(legendConfig).length > 0
|
|
75
84
|
);
|
|
85
|
+
const showEmptyText = Boolean(!data.length && emptyText);
|
|
76
86
|
|
|
77
87
|
return (
|
|
78
|
-
<Box
|
|
88
|
+
<Box
|
|
89
|
+
{...nativeProps}
|
|
90
|
+
style={style}
|
|
91
|
+
testID={testID}
|
|
92
|
+
collapsable={collapsable || false}
|
|
93
|
+
>
|
|
79
94
|
{showHeader ? (
|
|
80
95
|
<StyledHeader testID={testID ? `${testID}-header` : undefined}>
|
|
81
96
|
{headerConfig?.left ? (
|
|
@@ -129,10 +144,17 @@ const ProgressSegmentedBar = ({
|
|
|
129
144
|
) : null}
|
|
130
145
|
</StyledTrack>
|
|
131
146
|
|
|
132
|
-
{
|
|
133
|
-
<
|
|
134
|
-
|
|
147
|
+
{showEmptyText ? (
|
|
148
|
+
<StyledEmptyText
|
|
149
|
+
intent="muted"
|
|
150
|
+
testID={testID ? `${testID}-empty-text` : undefined}
|
|
135
151
|
>
|
|
152
|
+
{emptyText}
|
|
153
|
+
</StyledEmptyText>
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
156
|
+
{showLegend ? (
|
|
157
|
+
<LegendBox testID={testID ? `${testID}-legend` : undefined}>
|
|
136
158
|
{data.map((item, index) => (
|
|
137
159
|
<LegendItem
|
|
138
160
|
key={`${item.label}-${
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactElement, ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
import type { ViewProps } from 'react-native';
|
|
4
|
+
import type { SegmentedBarColorToken } from './constants';
|
|
4
5
|
|
|
5
6
|
export interface SegmentedBarSeries<Data> {
|
|
6
7
|
/**
|
|
@@ -15,6 +16,18 @@ export interface SegmentedBarSeries<Data> {
|
|
|
15
16
|
|
|
16
17
|
export type SegmentedBarData = Array<SegmentedBarSeries<number>>;
|
|
17
18
|
|
|
19
|
+
export interface SegmentedBarStyleConfig {
|
|
20
|
+
/**
|
|
21
|
+
* Colors config per series.
|
|
22
|
+
* Provide one entry per series in `data` matched by the `label` value (order-agnostic).
|
|
23
|
+
* When provided, number of entries must match `data.length` and all data labels must have a corresponding entry, otherwise an error will be thrown.
|
|
24
|
+
*/
|
|
25
|
+
series?: Array<{
|
|
26
|
+
label: string;
|
|
27
|
+
color: SegmentedBarColorToken;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export interface SegmentedBarHeaderConfig {
|
|
19
32
|
/**
|
|
20
33
|
* Left header content.
|
|
@@ -54,9 +67,19 @@ export interface ProgressSegmentedBarProps extends ViewProps {
|
|
|
54
67
|
*/
|
|
55
68
|
headerConfig?: SegmentedBarHeaderConfig;
|
|
56
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Optional text shown when there is no data.
|
|
72
|
+
*/
|
|
73
|
+
emptyText?: string;
|
|
74
|
+
|
|
57
75
|
/**
|
|
58
76
|
* Optional legend config.
|
|
59
77
|
* If provided, the legend is rendered below the segmented bar.
|
|
60
78
|
*/
|
|
61
79
|
legendConfig?: SegmentedBarLegendConfig;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Custom style config to override series colors.
|
|
83
|
+
*/
|
|
84
|
+
styleConfig?: SegmentedBarStyleConfig;
|
|
62
85
|
}
|