From ca077046885a4b8acad839d24ac36b47a7e9cbea Mon Sep 17 00:00:00 2001 From: Pedro Ladaria Date: Thu, 7 Nov 2024 18:24:42 +0100 Subject: [PATCH] WEB-2079 polishing details --- src/__stories__/meter-story.tsx | 40 +-- src/meter.tsx | 415 +++++++++++++++++--------------- src/theme-context-provider.tsx | 9 +- src/theme.tsx | 2 +- 4 files changed, 259 insertions(+), 207 deletions(-) diff --git a/src/__stories__/meter-story.tsx b/src/__stories__/meter-story.tsx index 279636435..5db15c55c 100644 --- a/src/__stories__/meter-story.tsx +++ b/src/__stories__/meter-story.tsx @@ -18,6 +18,13 @@ export default { valuesCount: { control: {type: 'range', min: 1, max: 8, step: 1}, }, + fullWidth: { + control: {type: 'boolean'}, + }, + width: { + if: {arg: 'fullWidth', eq: false}, + control: {type: 'range', min: 64, max: 600, step: 1}, + }, value1: { control: {type: 'range', min: 0, max: 100, step: 1}, }, @@ -52,6 +59,8 @@ type MeterStoryArgs = { type: MeterType; reverse: boolean; themeVariant: 'default' | 'inverse' | 'media'; + fullWidth: boolean; + width: number; valuesCount: number; value1: number; value2: number; @@ -68,24 +77,25 @@ export const MeterStory: StoryComponent = ({ reverse, themeVariant, valuesCount, + fullWidth, + width, ...valuesArgs }) => { const values = Object.values(valuesArgs).slice(0, valuesCount); return ( - - - {themeVariant === 'media' && ( - - )} - -
- -
-
-
+
+ + + + + +
); }; @@ -94,6 +104,8 @@ MeterStory.args = { type: 'angular', reverse: false, themeVariant: 'default', + fullWidth: false, + width: 400, valuesCount: 8, value1: 10, value2: 10, diff --git a/src/meter.tsx b/src/meter.tsx index 219e2a09c..599dcf3e4 100644 --- a/src/meter.tsx +++ b/src/meter.tsx @@ -6,7 +6,7 @@ import {vars} from './skins/skin-contract.css'; import bezier from 'cubic-bezier'; import {getPrefixedDataAttributes} from './utils/dom'; import {useThemeVariant} from './theme-variant-context'; -import {useTheme} from './hooks'; +import {useElementDimensions, useTheme} from './hooks'; import type {DataAttributes} from './utils/types'; @@ -14,7 +14,7 @@ const VIEW_BOX_WIDTH = 100; const CENTER_X = VIEW_BOX_WIDTH / 2; const CENTER_Y = VIEW_BOX_WIDTH / 2; -const STROKE_WIDTH_PX = 26; +const STROKE_WIDTH_PX = 6; const SEPARATION_PX = 2; const ANIMATION_DELAY_MS = 200; @@ -121,42 +121,48 @@ const createPath = ({ type MeterProps = { type?: MeterType; - /** Position of the meter. 0 is at the start, 1 is at the end. The sum of the values must not exceed 1. */ + // Position of the meter. 0 is at the start, 1 is at the end. The sum of the values must not exceed 1. values: Array; - width?: number; + width?: number | string; colors?: Array; reverse?: boolean; - 'aria-hidden'?: boolean | 'true' | 'false'; dataAttributes?: DataAttributes; + 'aria-hidden'?: boolean | 'true' | 'false'; + 'aria-label'?: string; }; const MeterComponent = ({ type = TYPE_ANGULAR, - width = 400, + width: widthProp = '100%', colors, - values: valuesFromProps, + values: valuesProp, reverse = false, - 'aria-hidden': ariaHidden, dataAttributes, + 'aria-hidden': ariaHidden = false, + 'aria-label': ariaLabel, }: MeterProps): JSX.Element => { - const theme = useTheme(); - const hasRoundLineCaps = theme.borderRadii.bar !== '0px'; + const {borderRadii, t} = useTheme(); + const {ref: containerRef, width: containerWidth} = useElementDimensions(); + const hasRoundLineCaps = parseInt(borderRadii.bar) !== 0; const themeVariant = useThemeVariant(); const isOverMedia = themeVariant === 'media'; const isInverse = themeVariant === 'inverse'; const segmentColors = colors || (isInverse || isOverMedia ? DEFAULT_COLORS_INVERSE : DEFAULT_COLORS); - const scaleFactor = VIEW_BOX_WIDTH / width; + const [width, setWidth] = React.useState(typeof widthProp === 'number' ? widthProp : 0); + const scaleFactor = width === 0 ? 1 : VIEW_BOX_WIDTH / width; const lineCapRadiusPx = hasRoundLineCaps ? STROKE_WIDTH_PX / 2 : 0; const lineCapRadius = lineCapRadiusPx * scaleFactor; const strokeWidth = STROKE_WIDTH_PX * scaleFactor; + const radius = type === TYPE_LINEAR ? 0 : CENTER_X - strokeWidth / 2; + const separation = SEPARATION_PX * scaleFactor; + const maxValue = type === TYPE_LINEAR ? VIEW_BOX_WIDTH - lineCapRadius * 2 : type === TYPE_CIRCULAR - ? 2 * Math.PI + ? Math.PI * 2 : Math.PI; - const radius = type === TYPE_LINEAR ? 0 : CENTER_X - strokeWidth / 2; - const separation = SEPARATION_PX * scaleFactor; + const segmentSeparation = type === TYPE_LINEAR ? separation / VIEW_BOX_WIDTH : separation / radius / maxValue; @@ -176,12 +182,12 @@ const MeterComponent = ({ ? vars.colors.barTrackInverse : vars.colors.barTrack; - /** scale values to the range [0, 1] */ + // scale values to the range [0, 1] const values = React.useMemo(() => { - return valuesFromProps.map((v) => v / MAX_SEGMENT_VALUE); - }, [valuesFromProps]); + return valuesProp.map((v) => v / MAX_SEGMENT_VALUE); + }, [valuesProp]); - /** the animation starts with these values */ + // the animation starts with these values const initialValuesRef = React.useRef>( Array.from({length: values.length}, () => (reverse ? 1 : 0)) ); @@ -190,9 +196,28 @@ const MeterComponent = ({ return values.map(() => ({start: 0, end: 0})); }); - const firstNonZeroIndex = segments.findIndex((s) => s.end - s.start > SMALL_VALUE_THRESHOLD); + // this is to know which are the first and last visible segments, which have special treatments + let firstNonZeroIndex = -1; + let lastNonZeroIndex = -1; + for (let i = 0; i < segments.length; i++) { + if (segments[i].end - segments[i].start > SMALL_VALUE_THRESHOLD) { + if (firstNonZeroIndex < 0) { + firstNonZeroIndex = i; + } + lastNonZeroIndex = i; + } + } + const lastSegment: Segment | undefined = segments.at(-1); + React.useEffect(() => { + if (typeof widthProp === 'number') { + setWidth(widthProp); + } else { + setWidth(containerWidth); + } + }, [widthProp, containerWidth]); + React.useEffect(() => { const shouldAnimate = window.matchMedia(`(prefers-reduced-motion: reduce)`).matches !== true; let raf: number; @@ -235,189 +260,199 @@ const MeterComponent = ({ const getColor = (index: number) => segmentColors[index % segmentColors.length]; return ( - 0 ? values[0] : undefined} + aria-valuetext={values.length > 0 ? values.map((v, i) => `${i + 1} ${v}`).join(' ') : ''} + aria-hidden={ariaHidden} > - - {hasRoundLineCaps && ( - <> - - - - + - - )} - - - {firstNonZeroIndex >= 0 && lastSegment && ( - <> + + )} + + + + + {firstNonZeroIndex >= 0 && + [...segments].reverse().map((segment, reversedIndex) => { + // note that the list is reversed, so the first segment is drawn last + const index = segments.length - 1 - reversedIndex; + const color = getColor(index); + const isFirst = index === firstNonZeroIndex; + const isLast = index === lastNonZeroIndex; + const start = + isFirst || segment.end - segment.start < segmentSeparation + ? segment.start + : segment.start + segmentSeparation / 2; + const end = + isLast || segment.end - segment.start < segmentSeparation + ? segment.end + : segment.end - segmentSeparation / 2; + + if (end - start < SMALL_VALUE_THRESHOLD) { + return null; + } + + const shouldIncludeStartMarker = isFirst && type !== TYPE_CIRCULAR; + return ( = 0.5 : 0, radius, - largeArchFlag: type === TYPE_CIRCULAR ? lastSegment.end >= 0.5 : 0, })} /> - {type === TYPE_CIRCULAR && hasRoundLineCaps && ( - - )} - {type === TYPE_CIRCULAR && lastSegment.end <= 0.5 && ( - - )} - - )} - - {type === TYPE_CIRCULAR && ( - - - - - )} - - - - - {firstNonZeroIndex >= 0 && - [...segments].reverse().map((segment, reversedIndex) => { - // note that the list is reversed, so the first segment is drawn last - const index = segments.length - 1 - reversedIndex; - const color = getColor(index); - const isFirst = index === firstNonZeroIndex; - const isLast = index === segments.length - 1; - // do not add separation if segment angles are too near to the start - const minValueForSeparation = segmentSeparation * segments.length; - const start = - isFirst || segment.end < minValueForSeparation - ? segment.start - : segment.start + segmentSeparation / 2; - const end = - isLast || segment.end < minValueForSeparation - ? segment.end - : segment.end - segmentSeparation / 2; - - if (end <= start || end - start < SMALL_VALUE_THRESHOLD) { - return null; - } - - const shouldIncludeStartMarker = isFirst && type !== TYPE_CIRCULAR; - return ( - = 0.5 : 0, - radius, - })} - /> - ); - })} - + ); + })} + + ); }; diff --git a/src/theme-context-provider.tsx b/src/theme-context-provider.tsx index ea8615ce9..f5b87da09 100644 --- a/src/theme-context-provider.tsx +++ b/src/theme-context-provider.tsx @@ -133,8 +133,13 @@ const ThemeContextProvider = ({theme, children, as, withoutStyles = false}: Prop const language = localeToLanguage(theme.i18n.locale); const translate = React.useCallback( - (token: TextToken): string => { - return token[language] || token.en; + (token: TextToken, ...params: Array): string => { + const text = token[language] || token.en; + // reverse loop because we want to substitute 11$s before 1$s + for (let i = params.length - 1; i >= 0; i--) { + text.replaceAll(`${i + 1}$s`, String(params[i])); + } + return text; }, [language] ); diff --git a/src/theme.tsx b/src/theme.tsx index d7b8db68e..6aa5361da 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -168,6 +168,6 @@ export type Theme = { isDarkMode: boolean; isIos: boolean; useHrefDecorator: () => (href: string) => string; - t: (token: TextToken) => string; + t: (token: TextToken, ...params: Array) => string; preventCopyInFormFields: boolean; };