diff --git a/src/components/common/Change24hBadge.tsx b/src/components/common/Change24hBadge.tsx
index 877f597..ad99579 100644
--- a/src/components/common/Change24hBadge.tsx
+++ b/src/components/common/Change24hBadge.tsx
@@ -1,5 +1,6 @@
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
+import { formatPercent } from '@/utils/numberFormat.utils';
interface Change24hBadgeProps {
/** Percentage change over 24h. Positive = up, negative = down, undefined = no data. */
@@ -10,6 +11,7 @@ interface Change24hBadgeProps {
const Change24hBadge: React.FC = ({ change, className }) => {
const isPositive = change !== undefined && change > 0;
const isNegative = change !== undefined && change < 0;
+ const formatted = formatPercent(change, { signed: true });
return (
= ({ change, className }) =>
!isPositive && !isNegative && 'border-white/10 bg-white/[0.06] text-white/40',
className
)}
- title={change !== undefined ? `${change > 0 ? '+' : ''}${change.toFixed(2)}% (24h)` : 'No 24h data'}
+ title={change !== undefined ? `${formatted} (24h)` : 'No 24h data'}
>
{isPositive && }
{isNegative && }
{!isPositive && !isNegative && }
-
- {change !== undefined
- ? `${change > 0 ? '+' : ''}${change.toFixed(2)}%`
- : '—'}
-
+ {formatted}
);
};
diff --git a/src/components/common/CreatorBio.tsx b/src/components/common/CreatorBio.tsx
new file mode 100644
index 0000000..5b8bd97
--- /dev/null
+++ b/src/components/common/CreatorBio.tsx
@@ -0,0 +1,52 @@
+import { cn } from '@/lib/utils';
+
+interface CreatorBioProps {
+ /** Raw bio string from the creator profile. Anything falsy or whitespace-only is treated as missing. */
+ bio?: string | null;
+ /** Override the default fallback copy. */
+ fallback?: string;
+ /** Variant — `card` is muted/italic for list rows, `profile` is slightly more prominent for the detail header. */
+ variant?: 'card' | 'profile';
+ className?: string;
+}
+
+const DEFAULT_FALLBACK = "This creator hasn't shared a bio yet.";
+
+const variantClasses: Record<'card' | 'profile', { value: string; fallback: string }> = {
+ card: {
+ value: 'text-sm text-white/60 leading-relaxed',
+ fallback: 'text-xs italic text-white/35',
+ },
+ profile: {
+ value: 'font-jakarta text-sm text-white/70 leading-relaxed',
+ fallback: 'font-jakarta text-sm italic text-white/40',
+ },
+};
+
+/**
+ * Renders a creator bio with a consistent fallback when the bio is missing.
+ *
+ * Centralizing this keeps wording aligned across the list and profile detail
+ * surfaces — change the fallback once and every consumer picks it up.
+ */
+const CreatorBio: React.FC = ({
+ bio,
+ fallback = DEFAULT_FALLBACK,
+ variant = 'card',
+ className,
+}) => {
+ const trimmed = bio?.trim();
+ const styles = variantClasses[variant];
+
+ if (!trimmed) {
+ return (
+
+ {fallback}
+
+ );
+ }
+
+ return {trimmed}
;
+};
+
+export default CreatorBio;
diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx
index 218f4c8..1bf4740 100644
--- a/src/components/common/CreatorCard.tsx
+++ b/src/components/common/CreatorCard.tsx
@@ -22,6 +22,7 @@ import KeySupplyBadge from '@/components/common/KeySupplyBadge';
import CreatorListRowDivider from '@/components/common/CreatorListRowDivider';
import BuyActionHelperText from '@/components/common/BuyActionHelperText';
import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow';
+import CreatorBio from '@/components/common/CreatorBio';
import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry';
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
@@ -151,6 +152,8 @@ const CreatorCard: React.FC = ({ creator, className }) => {
@{creator.instructorId || 'creator'}
+
+
{creator.socialHandle ? (
diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx
index b360f51..e316a7c 100644
--- a/src/components/common/CreatorProfileHeader.tsx
+++ b/src/components/common/CreatorProfileHeader.tsx
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import VerifiedBadge from '@/components/common/VerifiedBadge';
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
+import CreatorBio from '@/components/common/CreatorBio';
interface CreatorProfileHeaderProps {
name: string;
@@ -12,6 +13,7 @@ interface CreatorProfileHeaderProps {
creatorId?: string | number | null;
avatarUrl?: string;
isVerified?: boolean;
+ bio?: string | null;
className?: string;
}
@@ -21,6 +23,7 @@ const CreatorProfileHeader: React.FC = ({
creatorId,
avatarUrl,
isVerified,
+ bio,
className,
}) => {
const [copied, setCopied] = useState(false);
@@ -75,6 +78,7 @@ const CreatorProfileHeader: React.FC = ({
{isVerified && }
@{handle}
+
diff --git a/src/components/common/FormFileUpload.tsx b/src/components/common/FormFileUpload.tsx
index 55acdee..5f4589e 100644
--- a/src/components/common/FormFileUpload.tsx
+++ b/src/components/common/FormFileUpload.tsx
@@ -1,6 +1,10 @@
import React, { Fragment, useEffect, useState } from 'react';
import { Trash2, RefreshCw, CloudUpload, X, Check } from 'lucide-react';
import { Alert } from '../common/Alert';
+import {
+ isFileAccepted,
+ unsupportedFormatMessage,
+} from '@/utils/fileFormat.utils';
type FileUploadStatus =
| 'initial'
@@ -8,6 +12,7 @@ type FileUploadStatus =
| 'success'
| 'error'
| 'too-large'
+ | 'unsupported-format'
| 'failed';
// Configuration props for the component
@@ -65,6 +70,22 @@ const FormFileUpload: React.FC = ({
}, [id, value]);
const simulateUpload = (file: File) => {
+ // Reject unsupported formats before kicking off the upload simulation.
+ // The native picker filters via the `accept` attribute, but drag-and-drop
+ // and some platforms ignore it, so this is the user-facing safety net.
+ if (!isFileAccepted(file, acceptedFormats)) {
+ setUploadedFile({
+ id,
+ name: file.name,
+ size: file.size,
+ progress: 0,
+ status: 'unsupported-format',
+ errorMessage: unsupportedFormatMessage(acceptedFormats),
+ });
+ onChange?.(null);
+ return;
+ }
+
// Check file size
if (file.size > maxSize * 1024 * 1024) {
setUploadedFile({
@@ -301,6 +322,53 @@ const FormFileUpload: React.FC = ({
);
+ case 'unsupported-format':
+ return (
+
+
+
+
+
+
+
+
+
+ {uploadedFile?.name || 'Selected file'}
+
+
+ {uploadedFile?.errorMessage ||
+ unsupportedFormatMessage(acceptedFormats)}
+
+
+
+
+
+
+
+
+ );
+
case 'failed':
case 'error':
return (
diff --git a/src/components/common/PercentageBadge.tsx b/src/components/common/PercentageBadge.tsx
new file mode 100644
index 0000000..b0bbc3f
--- /dev/null
+++ b/src/components/common/PercentageBadge.tsx
@@ -0,0 +1,60 @@
+import { cn } from '@/lib/utils';
+import { formatPercent, type FormatPercentOptions } from '@/utils/numberFormat.utils';
+
+export type PercentageBadgeTone =
+ | 'neutral'
+ | 'positive'
+ | 'negative'
+ | 'muted';
+
+interface PercentageBadgeProps extends FormatPercentOptions {
+ /** Percentage value (already in percentage units — `12.5` renders `12.5%`). */
+ value: number | null | undefined;
+ /**
+ * Visual tone. `neutral` (default) renders a quiet white chip suitable for
+ * informational badges; the other tones map to the color system used by
+ * `Change24hBadge`.
+ */
+ tone?: PercentageBadgeTone;
+ /** Optional label rendered next to the value, e.g. `Funded`. */
+ label?: string;
+ className?: string;
+}
+
+const toneClasses: Record = {
+ neutral: 'border-white/10 bg-white/[0.06] text-white/70',
+ positive: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
+ negative: 'border-red-500/30 bg-red-500/10 text-red-400',
+ muted: 'border-white/10 bg-white/[0.04] text-white/40',
+};
+
+/**
+ * Standardized chip for percentage values. Uses the shared `formatPercent`
+ * helper so precision and edge-case behavior (null / NaN / Infinity) stay
+ * consistent across every percentage badge in the app.
+ */
+const PercentageBadge: React.FC = ({
+ value,
+ tone = 'neutral',
+ label,
+ className,
+ ...formatOptions
+}) => {
+ const formatted = formatPercent(value, formatOptions);
+
+ return (
+
+ {label && {label}}
+ {formatted}
+
+ );
+};
+
+export default PercentageBadge;
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index 70b24e9..598254a 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { formatNumber } from '@/utils/numberFormat.utils';
+import PercentageBadge from '@/components/common/PercentageBadge';
export type TradeSide = 'buy' | 'sell';
@@ -92,9 +93,23 @@ const TradeDialog: React.FC = ({
!amountValid && amountText.trim() ? 'border-red-500/40' : ''
)}
aria-label="Trade amount"
+ data-focus-order="1"
+ data-testid="trade-dialog-amount"
/>
-
- Holdings: {formatNumber(availableHoldings)} keys
+
+
Holdings: {formatNumber(availableHoldings)} keys
+ {side === 'sell' &&
+ availableHoldings > 0 &&
+ Number.isFinite(parsedAmount) &&
+ parsedAmount > 0 && (
+
availableHoldings ? 'negative' : 'neutral'
+ }
+ />
+ )}
{side === 'sell' && parsedAmount > availableHoldings && (
@@ -103,12 +118,23 @@ const TradeDialog: React.FC = ({
)}
+ {/*
+ * Focus order is intentional: amount input → Cancel → Confirm.
+ * That matches the visual left-to-right reading order in the
+ * footer (`sm:justify-between` puts Cancel on the left, Confirm
+ * on the right) and keeps the destructive action one Tab away
+ * from the primary action so users always pass through Cancel
+ * before reaching Confirm. The covering test in
+ * `__tests__/TradeDialog.focusOrder.test.tsx` guards this.
+ */}
@@ -116,6 +142,8 @@ const TradeDialog: React.FC = ({
type="button"
onClick={() => onConfirm(parsedAmount)}
disabled={!amountValid || isSubmitting}
+ data-focus-order="3"
+ data-testid="trade-dialog-confirm"
>
{isSubmitting ? 'Submitting…' : confirmLabel}
diff --git a/src/components/common/__tests__/CreatorBio.test.tsx b/src/components/common/__tests__/CreatorBio.test.tsx
new file mode 100644
index 0000000..434dbdc
--- /dev/null
+++ b/src/components/common/__tests__/CreatorBio.test.tsx
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import CreatorBio from '@/components/common/CreatorBio';
+
+describe('CreatorBio', () => {
+ it('renders the bio text when provided', () => {
+ render();
+ expect(
+ screen.getByText('Building things on Stellar.')
+ ).toBeInTheDocument();
+ });
+
+ it('falls back to the default helper text when the bio is missing', () => {
+ render();
+ expect(
+ screen.getByText("This creator hasn't shared a bio yet.")
+ ).toBeInTheDocument();
+ });
+
+ it('treats whitespace-only strings as missing', () => {
+ render();
+ expect(
+ screen.getByText("This creator hasn't shared a bio yet.")
+ ).toBeInTheDocument();
+ });
+
+ it('respects an explicit fallback override', () => {
+ render();
+ expect(screen.getByText('No story shared yet.')).toBeInTheDocument();
+ });
+
+ it('marks the fallback for assistive technology', () => {
+ render();
+ expect(
+ screen.getByLabelText('Bio not provided')
+ ).toBeInTheDocument();
+ });
+
+ it('trims surrounding whitespace from a real bio before rendering', () => {
+ render();
+ expect(screen.getByText('real bio')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx
new file mode 100644
index 0000000..e139913
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx
@@ -0,0 +1,87 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import TradeDialog from '@/components/common/TradeDialog';
+
+/**
+ * The dialog's focus order is part of its accessibility contract — keyboard
+ * users tab through `amount input → Cancel → Confirm`, matching the
+ * left-to-right visual order. These tests guard against future refactors
+ * that accidentally swap Cancel and Confirm in the DOM (which would also
+ * swap them in the tab sequence) or remove the marker attributes.
+ */
+describe('TradeDialog focus order', () => {
+ function renderDialog(overrides: Partial> = {}) {
+ return render(
+
+ );
+ }
+
+ it('renders the focus-order markers on the three primary controls', () => {
+ renderDialog();
+
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveAttribute(
+ 'data-focus-order',
+ '1'
+ );
+ expect(screen.getByTestId('trade-dialog-cancel')).toHaveAttribute(
+ 'data-focus-order',
+ '2'
+ );
+ expect(screen.getByTestId('trade-dialog-confirm')).toHaveAttribute(
+ 'data-focus-order',
+ '3'
+ );
+ });
+
+ it('orders the controls in DOM as amount → Cancel → Confirm so tab sequence matches', () => {
+ renderDialog();
+
+ const ordered = Array.from(
+ document.querySelectorAll('[data-focus-order]')
+ ).map(el => ({
+ testId: el.getAttribute('data-testid'),
+ order: el.getAttribute('data-focus-order'),
+ }));
+
+ expect(ordered).toEqual([
+ { testId: 'trade-dialog-amount', order: '1' },
+ { testId: 'trade-dialog-cancel', order: '2' },
+ { testId: 'trade-dialog-confirm', order: '3' },
+ ]);
+ });
+
+ it('keeps the primary action reachable (not removed from the tab sequence)', () => {
+ renderDialog();
+
+ const confirm = screen.getByTestId('trade-dialog-confirm');
+ // A button with no explicit tabindex is in the tab sequence as long as
+ // it isn't disabled. This regression-tests that we never accidentally
+ // add `tabIndex={-1}` to the primary action.
+ expect(confirm.getAttribute('tabindex')).not.toBe('-1');
+ });
+
+ it('keeps Cancel reachable so users can always back out via the keyboard', () => {
+ renderDialog();
+
+ const cancel = screen.getByTestId('trade-dialog-cancel');
+ expect(cancel.getAttribute('tabindex')).not.toBe('-1');
+ });
+
+ it('preserves the same focus order in the sell variant', () => {
+ renderDialog({ side: 'sell' });
+
+ const ordered = Array.from(
+ document.querySelectorAll('[data-focus-order]')
+ ).map(el => el.getAttribute('data-focus-order'));
+
+ expect(ordered).toEqual(['1', '2', '3']);
+ });
+});
diff --git a/src/utils/__tests__/fileFormat.utils.test.ts b/src/utils/__tests__/fileFormat.utils.test.ts
new file mode 100644
index 0000000..d4b3d4b
--- /dev/null
+++ b/src/utils/__tests__/fileFormat.utils.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isFileAccepted,
+ unsupportedFormatMessage,
+} from '@/utils/fileFormat.utils';
+
+function makeFile(name: string, type: string): File {
+ return new File([new Uint8Array([0])], name, { type });
+}
+
+describe('isFileAccepted', () => {
+ it('matches by extension token', () => {
+ expect(isFileAccepted(makeFile('avatar.png', 'image/png'), '.png')).toBe(
+ true
+ );
+ expect(
+ isFileAccepted(makeFile('avatar.PNG', 'image/png'), '.png')
+ ).toBe(true);
+ });
+
+ it('rejects extensions that are not in the accept list', () => {
+ expect(isFileAccepted(makeFile('avatar.bmp', 'image/bmp'), '.png,.jpg')).toBe(
+ false
+ );
+ });
+
+ it('matches by exact MIME type', () => {
+ expect(
+ isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/png')
+ ).toBe(true);
+ expect(
+ isFileAccepted(
+ makeFile('avatar.png', 'image/png'),
+ 'image/jpeg'
+ )
+ ).toBe(false);
+ });
+
+ it('honors the image/* wildcard', () => {
+ expect(
+ isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/*')
+ ).toBe(true);
+ expect(
+ isFileAccepted(makeFile('doc.pdf', 'application/pdf'), 'image/*')
+ ).toBe(false);
+ });
+
+ it('treats an empty accept list as accepting any file', () => {
+ expect(isFileAccepted(makeFile('anything', ''), '')).toBe(true);
+ });
+
+ it('handles whitespace and mixed case in the accept list', () => {
+ expect(
+ isFileAccepted(
+ makeFile('avatar.png', 'image/png'),
+ ' .JPG, .PNG '
+ )
+ ).toBe(true);
+ });
+});
+
+describe('unsupportedFormatMessage', () => {
+ it('lists allowed formats in upper case without leading dots', () => {
+ expect(unsupportedFormatMessage('.png,.jpg')).toBe(
+ "That file format isn't supported. Use PNG, JPG."
+ );
+ });
+
+ it('falls back to generic copy when accept is empty', () => {
+ expect(unsupportedFormatMessage('')).toBe(
+ "That file format isn't supported. Pick a different file."
+ );
+ });
+
+ it('preserves wildcards as-is so MIME ranges read sensibly', () => {
+ expect(unsupportedFormatMessage('image/*')).toBe(
+ "That file format isn't supported. Use IMAGE/*."
+ );
+ });
+});
diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts
new file mode 100644
index 0000000..b9564ab
--- /dev/null
+++ b/src/utils/__tests__/numberFormat.utils.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import { formatPercent } from '@/utils/numberFormat.utils';
+
+describe('formatPercent', () => {
+ it('renders the value with a trailing percent sign', () => {
+ expect(formatPercent(12.5)).toBe('12.5%');
+ });
+
+ it('rounds to two decimal places by default', () => {
+ expect(formatPercent(12.345)).toBe('12.35%');
+ expect(formatPercent(0.001)).toBe('0%');
+ });
+
+ it('renders zero cleanly without spurious decimals', () => {
+ expect(formatPercent(0)).toBe('0%');
+ });
+
+ it('handles small decimals without scientific notation', () => {
+ expect(formatPercent(0.05)).toBe('0.05%');
+ expect(formatPercent(0.0001)).toBe('0%');
+ });
+
+ it('handles large values without truncation', () => {
+ expect(formatPercent(123456)).toMatch(/123,?456%/);
+ });
+
+ it('returns the placeholder for null, undefined, NaN, and Infinity', () => {
+ expect(formatPercent(null)).toBe('—');
+ expect(formatPercent(undefined)).toBe('—');
+ expect(formatPercent(Number.NaN)).toBe('—');
+ expect(formatPercent(Number.POSITIVE_INFINITY)).toBe('—');
+ expect(formatPercent(Number.NEGATIVE_INFINITY)).toBe('—');
+ });
+
+ it('respects a custom placeholder', () => {
+ expect(formatPercent(null, { emptyPlaceholder: 'no data' })).toBe(
+ 'no data'
+ );
+ });
+
+ it('prefixes positive values with + when signed=true', () => {
+ expect(formatPercent(12.5, { signed: true })).toBe('+12.5%');
+ expect(formatPercent(-12.5, { signed: true })).toBe('-12.5%');
+ expect(formatPercent(0, { signed: true })).toBe('0%');
+ });
+
+ it('respects custom precision options', () => {
+ expect(
+ formatPercent(12.345, {
+ maximumFractionDigits: 1,
+ minimumFractionDigits: 1,
+ })
+ ).toBe('12.3%');
+ expect(
+ formatPercent(12, {
+ minimumFractionDigits: 2,
+ })
+ ).toBe('12.00%');
+ });
+});
diff --git a/src/utils/fileFormat.utils.ts b/src/utils/fileFormat.utils.ts
new file mode 100644
index 0000000..c0b9d60
--- /dev/null
+++ b/src/utils/fileFormat.utils.ts
@@ -0,0 +1,40 @@
+/**
+ * Returns true when `file` matches the comma-separated `accept` string used
+ * by ``. Mirrors browser semantics: each
+ * token is an extension (`.png`), a MIME type (`image/png`), or a wildcard
+ * (`image/*`). An empty `accept` accepts everything.
+ */
+export function isFileAccepted(file: File, accept: string): boolean {
+ const tokens = accept
+ .split(',')
+ .map(token => token.trim().toLowerCase())
+ .filter(Boolean);
+ if (tokens.length === 0) return true;
+
+ const fileType = file.type.toLowerCase();
+ const fileName = file.name.toLowerCase();
+
+ return tokens.some(token => {
+ if (token.startsWith('.')) {
+ return fileName.endsWith(token);
+ }
+ if (token.endsWith('/*')) {
+ return fileType.startsWith(token.slice(0, -1));
+ }
+ return fileType === token;
+ });
+}
+
+/** User-facing helper text shown when a file's format is rejected. */
+export function unsupportedFormatMessage(accept: string): string {
+ const formats = accept
+ .split(',')
+ .map(token => token.trim())
+ .filter(Boolean)
+ .map(token => (token.startsWith('.') ? token.slice(1) : token))
+ .map(token => token.toUpperCase());
+ if (formats.length === 0) {
+ return "That file format isn't supported. Pick a different file.";
+ }
+ return `That file format isn't supported. Use ${formats.join(', ')}.`;
+}
diff --git a/src/utils/numberFormat.utils.ts b/src/utils/numberFormat.utils.ts
index d4b5723..53a6ad8 100644
--- a/src/utils/numberFormat.utils.ts
+++ b/src/utils/numberFormat.utils.ts
@@ -39,3 +39,50 @@ export function formatCompactNumber(
return formatNumber(value, { ...options, style: 'compact' });
}
+export interface FormatPercentOptions {
+ /** Maximum fractional digits in the rendered value. Defaults to 2. */
+ maximumFractionDigits?: number;
+ /** Minimum fractional digits. Defaults to 0 so whole-number values render cleanly. */
+ minimumFractionDigits?: number;
+ /**
+ * Prefix positive values with `+` so badges read `+12.5%` / `-3.4%`.
+ * Defaults to false.
+ */
+ signed?: boolean;
+ /** Placeholder rendered when the value is missing or non-finite. Defaults to `—`. */
+ emptyPlaceholder?: string;
+}
+
+/**
+ * Formats a percentage value for badges and chips with consistent precision.
+ *
+ * Treats the input as a percentage (i.e. `12.5` renders as `12.5%`, not `1250%`).
+ * Edge cases are stable: `null`, `undefined`, `NaN`, and `Infinity` all render
+ * as the placeholder; values smaller than the requested precision are rounded
+ * (the previous hand-rolled `toFixed(2)` behavior) so badges never show
+ * scientific notation.
+ */
+export function formatPercent(
+ value: number | null | undefined,
+ options: FormatPercentOptions = {}
+): string {
+ const {
+ maximumFractionDigits = 2,
+ minimumFractionDigits = 0,
+ signed = false,
+ emptyPlaceholder = '—',
+ } = options;
+
+ if (value == null || !Number.isFinite(value)) {
+ return emptyPlaceholder;
+ }
+
+ const formatted = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits,
+ minimumFractionDigits,
+ }).format(value);
+
+ const sign = signed && value > 0 ? '+' : '';
+ return `${sign}${formatted}%`;
+}
+