From 532cd9a6c590d12cd3304b4c89244a591f5d8fb3 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 18:08:29 +0300 Subject: [PATCH 1/2] feat(core): add FileSizeFormatter utility and hook --- docs/11-date-formatting.mdx | 4 +- docs/date-formatting.stories.tsx | 6 +- packages/core/src/hooks/index.ts | 1 + .../src/hooks/useFileSizeFormatter/index.ts | 1 + .../useFileSizeFormatter.test.tsx | 48 ++++ .../useFileSizeFormatter.ts | 28 +++ .../FileSizeFormatter/FileSizeFormatter.mdx | 92 +++++++ .../FileSizeFormatter.stories.tsx | 88 +++++++ .../FileSizeFormatter.test.ts | 113 +++++++++ .../FileSizeFormatter/FileSizeFormatter.ts | 238 ++++++++++++++++++ .../core/src/utils/FileSizeFormatter/index.ts | 1 + packages/core/src/utils/index.ts | 1 + tools/public_api_guard/react-core.api.md | 59 ++++- 13 files changed, 669 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/hooks/useFileSizeFormatter/index.ts create mode 100644 packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.test.tsx create mode 100644 packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.ts create mode 100644 packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.mdx create mode 100644 packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.stories.tsx create mode 100644 packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts create mode 100644 packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts create mode 100644 packages/core/src/utils/FileSizeFormatter/index.ts diff --git a/docs/11-date-formatting.mdx b/docs/11-date-formatting.mdx index 46b746459..f0cca2cf1 100644 --- a/docs/11-date-formatting.mdx +++ b/docs/11-date-formatting.mdx @@ -1,9 +1,9 @@ import { Meta, Story } from '../.storybook/components'; import * as Stories from './date-formatting.stories'; - + -# Date formatter +# DateFormatter The `DateFormatter` is a unified system for formatting dates and times. It ensures consistent output across the application based on shared product rules. diff --git a/docs/date-formatting.stories.tsx b/docs/date-formatting.stories.tsx index 2dc008bcc..58b85e3e5 100644 --- a/docs/date-formatting.stories.tsx +++ b/docs/date-formatting.stories.tsx @@ -11,7 +11,7 @@ import { import type { StoryObj } from '@storybook/react'; const meta = { - title: 'Date formatter', + title: 'Utilities/DateFormatter', parameters: { layout: 'padded', }, @@ -41,7 +41,7 @@ function FormatsTable({ return ( - +
Name Value @@ -71,7 +71,7 @@ function DurationShortestTable({ return ( -
+
Name Seconds diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 139086702..ef479c17e 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -22,3 +22,4 @@ export * from './useScrollPosition'; export * from './useCopyToClipboard'; export * from './useTimer'; export * from './useKeyedRefs'; +export * from './useFileSizeFormatter'; diff --git a/packages/core/src/hooks/useFileSizeFormatter/index.ts b/packages/core/src/hooks/useFileSizeFormatter/index.ts new file mode 100644 index 000000000..8aa5306ff --- /dev/null +++ b/packages/core/src/hooks/useFileSizeFormatter/index.ts @@ -0,0 +1 @@ +export * from './useFileSizeFormatter'; diff --git a/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.test.tsx b/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.test.tsx new file mode 100644 index 000000000..ea9273b10 --- /dev/null +++ b/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.test.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react'; + +import { I18nProvider } from '@react-aria/i18n'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { FileSizeFormatterConfig } from '../../utils/FileSizeFormatter'; + +import { useFileSizeFormatter } from './useFileSizeFormatter'; + +const withLocale = (locale: string) => + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +describe('useFileSizeFormatter', () => { + it('formats using the current locale', () => { + const { result } = renderHook(() => useFileSizeFormatter(), { + wrapper: withLocale('ru-RU'), + }); + + expect(result.current.format(1500)).toBe('1,5 КБ'); + }); + + it('applies the provided config', () => { + const { result } = renderHook( + () => useFileSizeFormatter({ defaultUnitSystem: 'IEC' }), + { wrapper: withLocale('en-US') } + ); + + expect(result.current.format(1024)).toBe('1 KiB'); + }); + + it('reuses the formatter across renders with an equal config', () => { + const config: FileSizeFormatterConfig = { defaultPrecision: 1 }; + + const { result, rerender } = renderHook( + () => useFileSizeFormatter(config), + { wrapper: withLocale('en-US') } + ); + + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.ts b/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.ts new file mode 100644 index 000000000..8bf476009 --- /dev/null +++ b/packages/core/src/hooks/useFileSizeFormatter/useFileSizeFormatter.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import { useLocale } from '@react-aria/i18n'; + +import { + FileSizeFormatter, + type FileSizeFormatterConfig, +} from '../../utils/FileSizeFormatter'; + +/** + * Returns a memoized {@link FileSizeFormatter} bound to the current locale. + * The formatter is recreated only when the locale or `config` changes. + * @example + * const formatter = useFileSizeFormatter(); + * formatter.format(1500); // "1.5 KB" + */ +export function useFileSizeFormatter( + config?: FileSizeFormatterConfig +): FileSizeFormatter { + const { locale } = useLocale(); + + // `config` is a plain data object, so a stable serialization keeps the + // formatter memoized across renders that pass an equal inline literal. + return useMemo( + () => new FileSizeFormatter(locale, config), + [locale, JSON.stringify(config)] + ); +} diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.mdx b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.mdx new file mode 100644 index 000000000..056ce303e --- /dev/null +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.mdx @@ -0,0 +1,92 @@ +import { Meta, Story, Status } from '../../../../../.storybook/components'; + +import * as Stories from './FileSizeFormatter.stories'; + + + +# FileSizeFormatter + + + +Formats byte values using SI and IEC measurement systems. + +## Import + +```tsx +import { FileSizeFormatter } from '@koobiq/react-core'; +``` + +## Usage + +Create one formatter for a locale and reuse it. + +```tsx +const formatter = new FileSizeFormatter('en-US'); + +formatter.format(1500); +// 1.5 KB +``` + +SI is used by default: `1 KB = 1000 bytes`. Use IEC for binary units, where +`1 KiB = 1024 bytes`. + +```tsx +const formatter = new FileSizeFormatter('en-US', { + defaultUnitSystem: 'IEC', +}); + +formatter.format(1536); +// 1.5 KiB +``` + + + +## Hook + +Use `useFileSizeFormatter` inside components. It reads the current locale from +context and memoizes the formatter, so it's only recreated when the locale or +`config` changes. + +```tsx +import { useFileSizeFormatter } from '@koobiq/react-core'; + +function FileSize({ bytes }: { bytes: number }) { + const formatter = useFileSizeFormatter(); + + return {formatter.format(bytes)}; +} +``` + +Pass a `config` to switch measurement systems, change precision, or override +unit labels: + +```tsx +const formatter = useFileSizeFormatter({ + defaultUnitSystem: 'IEC', + defaultPrecision: 1, +}); +``` + +## Precision + +The default maximum precision is two fraction digits. Override it for the +formatter or an individual value. + +```tsx +const formatter = new FileSizeFormatter('en-US', { + defaultPrecision: 1, +}); + +formatter.format(1550); +// 1.6 KB + +formatter.format(1550, { precision: 2 }); +// 1.55 KB +``` + +## Localization + +The locale controls number formatting and unit abbreviations. A non-breaking +space is always used between the value and unit. + + diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.stories.tsx b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.stories.tsx new file mode 100644 index 000000000..663b8eddf --- /dev/null +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.stories.tsx @@ -0,0 +1,88 @@ +import { Table, TableContainer } from '@koobiq/react-components'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { FileSizeFormatter } from './FileSizeFormatter'; + +const meta = { + title: 'Utilities/FileSizeFormatter', + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const values = [512, 1000, 1024, 1_000_000, 1024 ** 2]; + +type FormatRow = { id: number } & Record; + +const FormatsTable = ({ + formatters, +}: { + formatters: Array<{ name: string; formatter: FileSizeFormatter }>; +}) => { + const columns = [ + { id: 'bytes', name: 'Bytes' }, + ...formatters.map(({ name }, index) => ({ + id: `format-${index}`, + name, + })), + ]; + + const items: FormatRow[] = values.map((value) => ({ + id: value, + bytes: value, + ...Object.fromEntries( + formatters.map(({ formatter }, index) => [ + `format-${index}`, + formatter.format(value), + ]) + ), + })); + + return ( + +
+ + {(column) => {column.name}} + + + {(item) => ( + + {(columnKey) => ( + {item[String(columnKey)]} + )} + + )} + +
+
+ ); +}; + +export const Base: Story = { + render: () => ( + + ), +}; + +export const Localization: Story = { + render: () => ( + + ), +}; diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts new file mode 100644 index 000000000..19e0d986b --- /dev/null +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { FileSizeFormatter } from './FileSizeFormatter'; + +describe('FileSizeFormatter', () => { + it('formats values using SI units by default', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(999)).toBe('999\u00a0B'); + expect(formatter.format(1000)).toBe('1\u00a0KB'); + expect(formatter.format(1_000_000)).toBe('1\u00a0MB'); + }); + + it('formats values using IEC units', () => { + const formatter = new FileSizeFormatter('en-US', { + defaultUnitSystem: 'IEC', + }); + + expect(formatter.format(1023)).toBe('1,023\u00a0B'); + expect(formatter.format(1024)).toBe('1\u00a0KiB'); + expect(formatter.format(1024 ** 2)).toBe('1\u00a0MiB'); + }); + + it('caps formatting at the largest unit', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(1000 ** 6)).toBe('1,000,000\u00a0TB'); + }); + + it('uses the configured precision and allows overriding it', () => { + const formatter = new FileSizeFormatter('en-US', { + defaultPrecision: 1, + }); + + expect(formatter.format(1550)).toBe('1.6\u00a0KB'); + expect(formatter.format(1550, { precision: 2 })).toBe('1.55\u00a0KB'); + }); + + it('allows overriding the measurement system per call', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(1024, { unitSystem: 'IEC' })).toBe('1\u00a0KiB'); + }); + + it('localizes numbers and unit abbreviations', () => { + const formatter = new FileSizeFormatter('ru-RU'); + + expect(formatter.format(1500)).toBe('1,5\u00a0КБ'); + + expect(formatter.format(1536, { unitSystem: 'IEC' })).toBe('1,5\u00a0КиБ'); + }); + + it('allows overriding a localized unit system', () => { + const formatter = new FileSizeFormatter('en-US', { + unitSystems: { + SI: { abbreviations: ['byte', 'kB', 'MB', 'GB', 'TB'] }, + }, + }); + + expect(formatter.format(1000)).toBe('1\u00a0kB'); + }); + + it('formats zero and negative finite values', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(0)).toBe('0\u00a0B'); + expect(formatter.format(-100)).toBe('-100\u00a0B'); + }); + + it('scales negative values by magnitude', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(-1_500_000)).toBe('-1.5\u00a0MB'); + expect(formatter.format(-1024, { unitSystem: 'IEC' })).toBe('-1\u00a0KiB'); + }); + + it('moves up a unit when rounding reaches the next threshold', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(999_999)).toBe('1\u00a0MB'); + expect(formatter.format(999_999_999)).toBe('1\u00a0GB'); + // Below the rounding threshold the smaller unit is kept. + expect(formatter.format(999_990)).toBe('999.99\u00a0KB'); + }); + + it('clamps out-of-range precision instead of throwing', () => { + const formatter = new FileSizeFormatter('en-US'); + + expect(formatter.format(1550, { precision: -1 })).toBe('2\u00a0KB'); + expect(() => formatter.format(1550, { precision: 101 })).not.toThrow(); + }); + + it('does not treat "rue" as Russian', () => { + const formatter = new FileSizeFormatter('rue'); + + expect(formatter.format(1000)).toContain('KB'); + }); + + it('throws for non-finite values', () => { + const formatter = new FileSizeFormatter('en-US'); + const expectedError = 'Argument "value" must be a finite number!'; + + expect(() => formatter.format(Number.NaN)).toThrow(expectedError); + + expect(() => formatter.format(Number.POSITIVE_INFINITY)).toThrow( + expectedError + ); + + expect(() => formatter.format(null as unknown as number)).toThrow( + expectedError + ); + }); +}); diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts new file mode 100644 index 000000000..b13c1c015 --- /dev/null +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts @@ -0,0 +1,238 @@ +import { once } from '@koobiq/logger'; + +/** Measurement systems supported by `FileSizeFormatter`. */ +export const fileSizeMeasurementSystem = ['SI', 'IEC'] as const; + +/** A measurement system supported by `FileSizeFormatter`. */ +export type FileSizeMeasurementSystem = + (typeof fileSizeMeasurementSystem)[number]; + +/** Unit abbreviations ordered from bytes through terabytes. */ +export type FileSizeUnitAbbreviations = readonly [ + string, + string, + string, + string, + string, +]; + +/** Scaling and unit labels for a file-size measurement system. */ +export type FileSizeUnitSystem = { + abbreviations: FileSizeUnitAbbreviations; + base: number; + power: number; +}; + +/** Default settings and unit-system overrides for `FileSizeFormatter`. */ +export type FileSizeFormatterConfig = { + /** + * Default measurement system. + * @default 'SI' + */ + defaultUnitSystem?: FileSizeMeasurementSystem; + /** + * Default maximum number of fraction digits. + * @default 2 + */ + defaultPrecision?: number; + /** Overrides the built-in localized unit systems. */ + unitSystems?: Partial< + Record> + >; +}; + +/** Overrides for formatting an individual byte value. */ +export type FileSizeFormatOptions = { + /** Overrides the configured maximum number of fraction digits. */ + precision?: number; + /** Overrides the configured measurement system. */ + unitSystem?: FileSizeMeasurementSystem; +}; + +const INVALID_VALUE_ERROR = 'Argument "value" must be a finite number!'; +const NON_BREAKING_SPACE = '\u00a0'; + +const DEFAULT_PRECISION = 2; +const MIN_PRECISION = 0; +const MAX_PRECISION = 100; + +export const FILE_SIZE_FORMATTER_ERROR_PRECISION_RANGE = `FileSizeFormatter: "precision" must be an integer between ${MIN_PRECISION} and ${MAX_PRECISION}.`; + +// Base and power are physics, not localization — they stay constant across +// languages, so keep them separate from the per-locale abbreviations. +const unitSystemScales: Record< + FileSizeMeasurementSystem, + Pick +> = { + SI: { base: 10, power: 3 }, + IEC: { base: 2, power: 10 }, +}; + +const DEFAULT_LANGUAGE = 'en'; + +const localizedAbbreviations: Record< + string, + Record +> = { + en: { + SI: ['B', 'KB', 'MB', 'GB', 'TB'], + IEC: ['B', 'KiB', 'MiB', 'GiB', 'TiB'], + }, + ru: { + SI: ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], + IEC: ['Б', 'КиБ', 'МиБ', 'ГиБ', 'ТиБ'], + }, +}; + +const getLocalizedUnitSystems = ( + locale: string +): Record => { + const language = new Intl.Locale(locale).language; + + const abbreviations = + localizedAbbreviations[language] ?? + localizedAbbreviations[DEFAULT_LANGUAGE]; + + return { + SI: { ...unitSystemScales.SI, abbreviations: abbreviations.SI }, + IEC: { ...unitSystemScales.IEC, abbreviations: abbreviations.IEC }, + }; +}; + +const mergeUnitSystem = ( + defaults: FileSizeUnitSystem, + overrides?: Partial +): FileSizeUnitSystem => ({ + abbreviations: overrides?.abbreviations ?? defaults.abbreviations, + base: overrides?.base ?? defaults.base, + power: overrides?.power ?? defaults.power, +}); + +const isPrecisionInRange = (precision: number): boolean => + Number.isInteger(precision) && + precision >= MIN_PRECISION && + precision <= MAX_PRECISION; + +const clampPrecision = (precision: number): number => { + if (!Number.isFinite(precision)) return DEFAULT_PRECISION; + + return Math.min( + Math.max(Math.trunc(precision), MIN_PRECISION), + MAX_PRECISION + ); +}; + +const getHumanizedSize = ( + value: number, + system: FileSizeUnitSystem, + precision: number +): { value: number; unit: string } => { + const orderOfMagnitude = system.base ** system.power; + const lastUnitIndex = system.abbreviations.length - 1; + const magnitude = Math.abs(value); + + let unitIndex = 0; + let result = magnitude; + + while (unitIndex < lastUnitIndex && result >= orderOfMagnitude) { + unitIndex += 1; + result = magnitude / orderOfMagnitude ** unitIndex; + } + + // Rounding can land back on the threshold the loop just cleared, so 999_999 B + // would read as "1000 KB" rather than "1 MB". + if ( + unitIndex < lastUnitIndex && + Number(result.toFixed(precision)) >= orderOfMagnitude + ) { + unitIndex += 1; + result = magnitude / orderOfMagnitude ** unitIndex; + } + + return { + value: value < 0 ? -result : result, + unit: system.abbreviations[unitIndex], + }; +}; + +/** Formats byte values using Koobiq SI or IEC file-size conventions. */ +export class FileSizeFormatter { + private readonly locale: string; + private readonly defaultPrecision: number; + private readonly defaultUnitSystem: FileSizeMeasurementSystem; + private readonly unitSystems: Record< + FileSizeMeasurementSystem, + FileSizeUnitSystem + >; + private readonly numberFormatters = new Map(); + + constructor(locale?: string, config: FileSizeFormatterConfig = {}) { + this.locale = new Intl.NumberFormat(locale).resolvedOptions().locale; + + const localizedUnitSystems = getLocalizedUnitSystems(this.locale); + + this.defaultPrecision = this.resolvePrecision( + config.defaultPrecision, + DEFAULT_PRECISION + ); + + this.defaultUnitSystem = config.defaultUnitSystem ?? 'SI'; + + this.unitSystems = { + SI: mergeUnitSystem(localizedUnitSystems.SI, config.unitSystems?.SI), + IEC: mergeUnitSystem(localizedUnitSystems.IEC, config.unitSystems?.IEC), + }; + } + + /** Formats a byte value using the configured locale and measurement system. */ + format(value: number, options: FileSizeFormatOptions = {}): string { + if (!Number.isFinite(value)) { + throw new Error(INVALID_VALUE_ERROR); + } + + const precision = this.resolvePrecision( + options.precision, + this.defaultPrecision + ); + + const unitSystem = options.unitSystem ?? this.defaultUnitSystem; + + const formattedSize = getHumanizedSize( + value, + this.unitSystems[unitSystem], + precision + ); + + return `${this.getNumberFormatter(precision).format(formattedSize.value)}${NON_BREAKING_SPACE}${formattedSize.unit}`; + } + + private resolvePrecision( + precision: number | undefined, + fallback: number + ): number { + if (precision === undefined) return fallback; + + if ( + process.env.NODE_ENV !== 'production' && + !isPrecisionInRange(precision) + ) { + once.warn(FILE_SIZE_FORMATTER_ERROR_PRECISION_RANGE); + } + + return clampPrecision(precision); + } + + private getNumberFormatter(precision: number): Intl.NumberFormat { + let formatter = this.numberFormatters.get(precision); + + if (!formatter) { + formatter = new Intl.NumberFormat(this.locale, { + maximumFractionDigits: precision, + }); + + this.numberFormatters.set(precision, formatter); + } + + return formatter; + } +} diff --git a/packages/core/src/utils/FileSizeFormatter/index.ts b/packages/core/src/utils/FileSizeFormatter/index.ts new file mode 100644 index 000000000..35921d419 --- /dev/null +++ b/packages/core/src/utils/FileSizeFormatter/index.ts @@ -0,0 +1 @@ +export * from './FileSizeFormatter'; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index c6c0701b9..c300a2027 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './typeGuards/index.js'; export * from './setRef.js'; export * from './isBrowser.js'; export * from './capitalizeFirstLetter'; +export * from './FileSizeFormatter'; diff --git a/tools/public_api_guard/react-core.api.md b/tools/public_api_guard/react-core.api.md index abb08250b..427316b33 100644 --- a/tools/public_api_guard/react-core.api.md +++ b/tools/public_api_guard/react-core.api.md @@ -104,6 +104,50 @@ export type ExtendableComponentPropsWithRef // @public (undocumented) export type ExtendableProps, ExtendedProps = Record> = Merge; +// @public (undocumented) +export const FILE_SIZE_FORMATTER_ERROR_PRECISION_RANGE = "FileSizeFormatter: \"precision\" must be an integer between 0 and 100."; + +// @public +export type FileSizeFormatOptions = { + precision?: number; + unitSystem?: FileSizeMeasurementSystem; +}; + +// @public +export class FileSizeFormatter { + constructor(locale?: string, config?: FileSizeFormatterConfig); + format(value: number, options?: FileSizeFormatOptions): string; +} + +// @public +export type FileSizeFormatterConfig = { + defaultUnitSystem?: FileSizeMeasurementSystem; + defaultPrecision?: number; + unitSystems?: Partial>>; +}; + +// @public +export type FileSizeMeasurementSystem = (typeof fileSizeMeasurementSystem)[number]; + +// @public +export const fileSizeMeasurementSystem: readonly ["SI", "IEC"]; + +// @public +export type FileSizeUnitAbbreviations = readonly [ +string, +string, +string, +string, +string +]; + +// @public +export type FileSizeUnitSystem = { + abbreviations: FileSizeUnitAbbreviations; + base: number; + power: number; +}; + export { filterDOMProps } export { FocusableElement } @@ -276,12 +320,12 @@ export { useDescription } export function useDOMRef(ref?: RefObject_2 | Ref_2): RefObject_2; // @public -export function useElementOverflow(): { - ref: RefObject_2; - isOverflow: boolean; - isOverflowX: boolean; - isOverflowY: boolean; -}; +export function useElementOverflow(): { + ref: RefObject_2; + isOverflow: boolean; + isOverflowX: boolean; + isOverflowY: boolean; +}; // @public export function useElementSize(options?: ResizeObserverOptions): { @@ -307,6 +351,9 @@ export function useEventListener(input: UseEve // @public (undocumented) export function useEventListener(input: UseEventListener void, RefObject_2 | undefined>): void; +// @public +export function useFileSizeFormatter(config?: FileSizeFormatterConfig): FileSizeFormatter; + // @public export function useHideOverflowItems(input: UseHideOverflowItemsProps): { visibleMap: boolean[]; From be66a82da2ba62c7fd0cb8044ee7af76eba55b93 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 18:39:41 +0300 Subject: [PATCH 2/2] chore: code review --- .../FileSizeFormatter/FileSizeFormatter.test.ts | 14 ++++++++++++++ .../utils/FileSizeFormatter/FileSizeFormatter.ts | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts index 19e0d986b..ff8934030 100644 --- a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.test.ts @@ -90,6 +90,20 @@ describe('FileSizeFormatter', () => { expect(() => formatter.format(1550, { precision: 101 })).not.toThrow(); }); + it('falls back to the configured precision when a per-call override is non-finite', () => { + const formatter = new FileSizeFormatter('en-US', { + defaultPrecision: 1, + }); + + expect(formatter.format(1550, { precision: Number.NaN })).toBe( + '1.6\u00a0KB' + ); + + expect( + formatter.format(1550, { precision: Number.POSITIVE_INFINITY }) + ).toBe('1.6\u00a0KB'); + }); + it('does not treat "rue" as Russian', () => { const formatter = new FileSizeFormatter('rue'); diff --git a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts index b13c1c015..f8e3e1626 100644 --- a/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts +++ b/packages/core/src/utils/FileSizeFormatter/FileSizeFormatter.ts @@ -113,8 +113,8 @@ const isPrecisionInRange = (precision: number): boolean => precision >= MIN_PRECISION && precision <= MAX_PRECISION; -const clampPrecision = (precision: number): number => { - if (!Number.isFinite(precision)) return DEFAULT_PRECISION; +const clampPrecision = (precision: number, fallback: number): number => { + if (!Number.isFinite(precision)) return fallback; return Math.min( Math.max(Math.trunc(precision), MIN_PRECISION), @@ -219,7 +219,7 @@ export class FileSizeFormatter { once.warn(FILE_SIZE_FORMATTER_ERROR_PRECISION_RANGE); } - return clampPrecision(precision); + return clampPrecision(precision, fallback); } private getNumberFormatter(precision: number): Intl.NumberFormat {