瀏覽代碼

feat: [Logic] (Add <Numeral /> component for number formatting)

Signed-off-by: uiuing <[email protected]>
uiuing 3 年之前
父節點
當前提交
1a2f547b43

+ 4 - 2
packages/semi-foundation/typography/constants.ts

@@ -8,10 +8,12 @@ const strings = {
     TYPE: ['primary', 'secondary', 'danger', 'warning', 'success', 'tertiary', 'quaternary'],
     SIZE: ['normal', 'small'],
     SPACING: ['normal', 'extended'],
-    HEADING: [1, 2, 3, 4, 5, 6]
+    HEADING: [1, 2, 3, 4, 5, 6],
+    RULE: ['text', 'numbers', 'bytes-decimal', 'bytes-binary', 'percentages', 'currency', 'exponential'],
+    MANTISSA_ROUND: ['ceil', 'floor', 'round'],
 } as const;
 
 export {
     cssClasses,
     strings
-};
+};

+ 129 - 0
packages/semi-foundation/typography/formatNumeral.ts

@@ -0,0 +1,129 @@
+import { strings } from './constants';
+
+// rule types: 'text' | 'numbers' | 'bytes-decimal' | 'bytes-binary' | 'percentages' | 'currency' | 'exponential'
+type Rule = typeof strings.RULE[number];
+type Truncate = typeof strings.MANTISSA_ROUND[number];
+type Parser = (value: string) => string;
+
+type RuleMethods = {
+    [key in Rule]?: (value: number) => string;
+};
+type TruncateMethods = {
+    [key in Truncate]: (value: number) => number;
+};
+
+export default class FormatNumeral {
+    private readonly content: string;
+    private readonly rule: Rule;
+    private readonly mantissa: number;
+    private readonly truncate: Truncate;
+    private readonly parser: Parser | undefined;
+    private readonly isDiyParser: boolean;
+
+    private readonly truncateMethods: TruncateMethods = {
+        ceil: Math.ceil,
+        floor: Math.floor,
+        round: Math.round,
+    };
+    // Collection of formatting methods;  Methods: Rule (strings.RULE);  Not included: 'text' & 'numbers'
+    private readonly ruleMethods: RuleMethods = {
+        'bytes-decimal': (value: number) => {
+            const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+            let i = 0;
+            while (value >= 1000) {
+                value /= 1000;
+                i++;
+            }
+            return `${this.truncateMantissa(value)} ${units[i]}`;
+        },
+        'bytes-binary': (value: number) => {
+            const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+            let i = 0;
+            while (value >= 1024) {
+                value /= 1024;
+                i++;
+            }
+            return `${this.truncateMantissa(value)} ${units[i]}`;
+        },
+        percentages: (value: number) => {
+            const cArr = value.toString().split('.');
+            if (Number(cArr[0]) === 0) {
+                return `${this.truncateMantissa(value * 100)}%`;
+            }
+            return `${this.truncateMantissa(value)}%`;
+        },
+        currency: (value: number) => {
+            const cArr = this.truncateMantissa(value).split('.');
+            const cInt = cArr[0].replace(/\d{1,3}(?=(\d{3})+$)/g, '$&,');
+            const cFloat = cArr[1] ? `.${cArr[1]}` : '';
+            return `${cInt}${cFloat}`;
+        },
+        exponential: (value: number) => {
+            const vExponential = value.toExponential(this.mantissa + 2);
+            return this.truncateMantissa(vExponential);
+        },
+    };
+
+    constructor(content: string, rule: Rule, mantissa: number, truncate: Truncate, parser: Parser | undefined) {
+        this.isDiyParser = typeof parser !== 'undefined';
+        this.content = content;
+        this.rule = rule;
+        this.mantissa = mantissa;
+        this.truncate = truncate;
+        this.parser = parser;
+    }
+
+    private truncateMantissa(content: string | number): string {
+        // Truncation and selection of rounding methods for processing. function from: truncateMethods
+        const cTruncated =
+            this.truncateMethods[this.truncate](Number(content) * Math.pow(10, this.mantissa)) /
+            Math.pow(10, this.mantissa);
+        const cArr = cTruncated.toString().split('.');
+        // is an integer then the end number is normalised
+        if (cArr.length === 1) {
+            return cTruncated.toFixed(this.mantissa);
+        }
+        const cTLength = cArr[1].length;
+        // Fill in any missing `0` at the end.
+        if (cTLength < this.mantissa) {
+            return `${cArr[0]}.${cArr[1]}${'0'.repeat(this.mantissa - cTLength)}`;
+        }
+        return cTruncated.toString();
+    }
+
+    // Formatting numbers within a string.
+    public format(): string {
+        // Executed when a custom method exists
+        if (this.isDiyParser) {
+            return this.parser(this.content);
+        } else if (this.rule === 'text') {
+            return this.content;
+        }
+        // Separate extraction of numbers when `rule` type is `numbers`.
+        if (this.rule === 'numbers') {
+            return extractNumbers(this.content)
+                .filter(item => checkIsNumeral(item))
+                .map(item => this.truncateMantissa(item))
+                .join(',');
+        }
+        // Run formatting methods that exist.
+        return extractNumbers(this.content)
+            .map(item => {
+                if (checkIsNumeral(item)) {
+                    return this.ruleMethods[this.rule](Number(item));
+                }
+                return item;
+            })
+            .join('');
+    }
+}
+
+// Separate numbers from strings, the `-` symbol is a numeric prefix not allowed on its own.
+function extractNumbers(content: string): Array<string> {
+    const reg = /(-?[0-9]*\.?[0-9]+([eE]-?[0-9]+)?)|([^-\d\.]+)/g;
+    return content.match(reg) || [];
+}
+
+function checkIsNumeral(str: string): boolean {
+    return !(isNaN(Number(str)) && str.replace(/\s+/g, '') === '');
+}

+ 3 - 1
packages/semi-ui/typography/index.tsx

@@ -2,18 +2,20 @@ import BaseTypography from './typography';
 import Text from './text';
 import Title from './title';
 import Paragraph from './paragraph';
+import Numeral from './numeral';
 
 export type TypographyType = typeof BaseTypography & {
     Text: typeof Text;
     Title: typeof Title;
     Paragraph: typeof Paragraph;
+    Numeral: typeof Numeral;
 };
 
 const Typography = BaseTypography as TypographyType;
 Typography.Text = Text;
 Typography.Title = Title;
 Typography.Paragraph = Paragraph;
-
+Typography.Numeral = Numeral;
 
 export type { BaseTypographyProps } from './base';
 export type { CopyableProps } from './copyable';

+ 2 - 0
packages/semi-ui/typography/interface.ts

@@ -25,3 +25,5 @@ export type OmitTypographyProps = 'dangerouslySetInnerHTML';
 export type TypographyBaseType = ArrayElement<typeof strings.TYPE>;
 export type TypographyBaseSize = ArrayElement<typeof strings.SIZE>;
 export type TypographyBaseSpacing = ArrayElement<typeof strings.SPACING>;
+export type TypographyBaseRule = ArrayElement<typeof strings.RULE>;
+export type TypographyBaseTruncate = ArrayElement<typeof strings.MANTISSA_ROUND>;

+ 119 - 0
packages/semi-ui/typography/numeral.tsx

@@ -0,0 +1,119 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { strings } from '@douyinfe/semi-foundation/typography/constants';
+import Base from './base';
+import {
+    TypographyBaseSize,
+    TypographyBaseType,
+    TypographyBaseRule,
+    OmitTypographyProps,
+    TypographyBaseTruncate,
+} from './interface';
+import { CopyableConfig, LinkType } from './title';
+import FormatNumeral from '@douyinfe/semi-foundation/typography/formatNumeral';
+
+type OmitNumeralProps = OmitTypographyProps;
+
+export interface NumeralProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, OmitNumeralProps> {
+    rule?: TypographyBaseRule;
+    mantissa?: number;
+    truncate?: TypographyBaseTruncate;
+    parser?: (value: string) => string;
+    children?: React.ReactNode;
+    className?: string;
+    code?: boolean;
+    component?: React.ElementType;
+    copyable?: CopyableConfig | boolean;
+    delete?: boolean;
+    disabled?: boolean;
+    icon?: React.ReactNode | string;
+    link?: LinkType;
+    mark?: boolean;
+    size?: TypographyBaseSize;
+    strong?: boolean;
+    style?: React.CSSProperties;
+    type?: TypographyBaseType;
+    underline?: boolean;
+}
+
+export default class Numeral extends PureComponent<NumeralProps> {
+    static propTypes = {
+        rule: PropTypes.oneOf(strings.RULE),
+        mantissa: PropTypes.number,
+        truncate: PropTypes.oneOf(strings.MANTISSA_ROUND),
+        parser: PropTypes.func,
+        copyable: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
+        delete: PropTypes.bool,
+        disabled: PropTypes.bool,
+        icon: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+        mark: PropTypes.bool,
+        underline: PropTypes.bool,
+        link: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
+        strong: PropTypes.bool,
+        type: PropTypes.oneOf(strings.TYPE),
+        size: PropTypes.oneOf(strings.SIZE),
+        style: PropTypes.object,
+        className: PropTypes.string,
+        code: PropTypes.bool,
+        component: PropTypes.string,
+    };
+
+    static defaultProps = {
+        rule: 'text',
+        mantissa: 0,
+        truncate: 'round',
+        parser: undefined,
+        copyable: false,
+        delete: false,
+        icon: '',
+        mark: false,
+        underline: false,
+        strong: false,
+        link: false,
+        type: 'primary',
+        style: {},
+        size: 'normal',
+        className: '',
+    };
+
+    // Traverse the entire virtual DOM using a depth-first traversal algorithm, then format each piece. (in react)
+    formatNodeDFS(node) {
+        if (!Array.isArray(node)) {
+            node = [node];
+        }
+        // Because the property is read-only, an object is returned for overwriting rather than directly modifying the object's contents.
+        node = node.map(item => {
+            if (typeof item === 'string' || typeof item === 'number') {
+                // Formatting the digital content of nodes.
+                return new FormatNumeral(
+                    String(item),
+                    this.props.rule,
+                    this.props.mantissa,
+                    this.props.truncate,
+                    this.props.parser
+                ).format();
+            }
+            if (typeof item === 'function') {
+                return this.formatNodeDFS(item());
+            }
+            if (typeof item === 'object' && 'children' in item['props']) {
+                return {
+                    ...item,
+                    props: { ...item['props'], children: this.formatNodeDFS(item['props']['children']) },
+                };
+            }
+            return item;
+        });
+        return node.length === 1 ? node[0] : node;
+    }
+
+    render() {
+        // Deep copy and remove props that are not needed by the Base component.
+        const baseProps = Object.assign({}, this.props) as Record<string, unknown>;
+        delete baseProps.rule;
+        delete baseProps.parser;
+        // Each piece of content in the virtual DOM is formatted by the `formatNumeral` function.
+        baseProps.children = this.formatNodeDFS(this.props.children);
+        return <Base component={'span'} {...baseProps} />;
+    }
+}