shelving
Version:
Toolkit for using data in JavaScript.
194 lines (193 loc) • 8.29 kB
JavaScript
import { isArray } from "./array.js";
import { SECOND } from "./constants.js";
import { isDate, requireDate } from "./date.js";
import { getBestTimeUnit, getMilliseconds } from "./duration.js";
import { getPercent } from "./number.js";
import { isObject } from "./object.js";
import { TIME_UNITS } from "./units.js";
import { requireURL } from "./url.js";
/** Format a number (based on the user's browser language settings). */
export function formatNumber(num, options) {
return Intl.NumberFormat(undefined, options).format(num);
}
/** Format a number range (based on the user's browser language settings). */
export function formatRange(from, to, options) {
return Intl.NumberFormat(undefined, options).formatRange(from, to);
}
/**
* Format a quantity of a given unit.
*
* - Javascript has built-in support for formatting a number of different units.
* - Unfortunately the list of supported units changes in different browsers.
* - Ideally we want to format units using the built-in formatting so things like translation and internationalisation are covered.
* - But we want provide fallback formatting for unsupported units, and do something _good enough_ job in most cases.
*/
export function formatUnit(num, unit, options) {
// Check if the unit is supported by the browser.
if (Intl.supportedValuesOf("unit").includes(unit))
return Intl.NumberFormat(undefined, { ...options, style: "unit", unit }).format(num);
// Otherwise, use the default number format.
const str = Intl.NumberFormat(undefined, { ...options, style: "decimal" }).format(num);
const { unitDisplay, abbr = unit, one = unit, many = `${one}s` } = options ?? {};
if (unitDisplay === "long")
return `${str} ${str === "1" ? one : many}`;
return `${str}${unitDisplay === "narrow" ? "" : " "}${abbr}`; // "short" is the default.
}
/**
* Format a currency amount (based on the user's browser language settings).
*/
export function formatCurrency(amount, currency, options) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency,
...options,
}).format(amount);
}
/**
* Format a percentage (combines `getPercent()` and `formatQuantity()` for convenience).
* - Defaults to showing no decimal places.
* - Defaults to rounding closer to zero (so that 99.99% is shown as 99%).
* - Javascript's built-in percent formatting works on the `0` zero to `1` range. This uses `getPercent()` which works on `0` to `100` for convenience.
*
* @param numerator Number representing the amount of progress (e.g. `50`).
* @param denumerator The number representing the whole amount (defaults to 100).
*/
export function formatPercent(numerator, denumerator, options) {
return Intl.NumberFormat(undefined, {
style: "percent",
maximumFractionDigits: 0,
roundingMode: "floor",
...options,
}).format(getPercent(numerator, denumerator) / 100);
}
/**
* Format an unknown object as a string.
* - Use the custom `.toString()` function if it exists (don't use built in `Object.prototype.toString` because it's useless.
* - Use `.title` or `.name` or `.id` if they exist and are strings.
* - Use `Object` otherwise.
*/
export function formatObject(obj) {
if (typeof obj.toString === "function" && obj.toString !== Object.prototype.toString)
return obj.toString();
const name = obj.name;
if (typeof name === "string")
return name;
const title = obj.title;
if (typeof title === "string")
return title;
const id = obj.id;
if (typeof id === "string")
return id;
return "Object";
}
/** Format an unknown array as a string. */
export function formatArray(arr, separator = ", ") {
return arr.map(formatValue).join(separator);
}
/** Format a date in the browser locale. */
export function formatDate(date, options) {
return requireDate(date, formatDate).toLocaleDateString(undefined, options);
}
/** Format a time in the browser locale (no seconds by default). */
export function formatTime(time, options) {
return requireDate(time, formatTime).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: undefined, // No seconds by default.
...options,
});
}
/** Format a datetime in the browser locale (no seconds by default). */
export function formatDateTime(date, options) {
return requireDate(date, formatDateTime).toLocaleString(undefined, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
// No seconds by default.
...options,
});
}
/** Format a URL as a user-friendly string, e.g. `http://shax.com/test?uid=129483` → `shax.com/test` */
export function formatURL(possible, base) {
const { host, pathname } = requireURL(possible, base, formatURL);
return `${host}${pathname.length > 1 ? pathname : ""}`;
}
/**
* Convert any unknown value into a friendly string for user-facing use.
* - Strings return the string.
* - Booleans return `"Yes"` or `"No"`
* - Numbers return formatted number with commas etc (e.g. `formatNumber()`).
* - Dates return formatted datetime (e.g. `formatDateTime()`).
* - Arrays return the array items converted to string (with `toTitle()`), and joined with a comma.
* - Objects return...
* 1. `object.name` if it exists, or
* 2. `object.title` if it exists.
* - Falsy values like `null` and `undefined` return `"None"`
* - Everything else returns `"Unknown"`
*/
export function formatValue(value) {
if (value === null || value === undefined)
return "None";
if (typeof value === "boolean")
return value ? "Yes" : "No";
if (typeof value === "string")
return value || "None";
if (typeof value === "number")
return formatNumber(value);
if (typeof value === "symbol")
return value.description || "Symbol";
if (typeof value === "function")
return "Function";
if (isDate(value))
return formatDateTime(value);
if (isArray(value))
return formatArray(value);
if (isObject(value))
return formatObject(value);
return "Unknown";
}
/**
* Compact best-fit when a date happens/happened, e.g. `in 10d` or `2h ago` or `in 1w` or `just now`
* - See `getBestTimeUnit()` for details on how the best-fit unit is chosen.
* - But: anything under 30 seconds will show `just now`, which makes more sense in most UIs.
*/
export function formatWhen(target, current, options) {
const ms = getMilliseconds(current, target, formatWhen);
const abs = Math.abs(ms);
if (abs < 30 * SECOND)
return "just now";
const unit = getBestTimeUnit(ms);
return ms > 0 ? `in ${unit.format(unit.from(abs), options)}` : `${unit.format(unit.from(abs), options)} ago`;
}
/** Compact when a date happens, e.g. `10d` or `2h` or `-1w` */
export function formatUntil(target, current, options) {
const ms = getMilliseconds(current, target, formatUntil);
const unit = getBestTimeUnit(ms);
return unit.format(unit.from(ms), options);
}
/** Compact when a date will happen, e.g. `10d` or `2h` or `-1w` */
export function formatAgo(target, current, options) {
const ms = getMilliseconds(target, current, formatAgo);
const unit = getBestTimeUnit(ms);
return unit.format(unit.from(ms), options);
}
/**
* Format a duration as a string, e.g. `1 year, 2 months, 3 days` or `1y 2m 3d`
* @todo Use `Intl.DurationFormat().format()` instead it's more widely supported and is available in TS lib.
*/
export function formatDuration(duration, options) {
// Map `DurationFormatOptions` to `NumberFormatOptions`
const style = options?.style ?? "short";
return new Intl.ListFormat(undefined, { style, type: "unit" }).format(_getDurationStrings(duration, { ...options, style: "unit", unitDisplay: style }));
}
export function* _getDurationStrings(duration, options) {
for (const key of TIME_KEYS) {
const value = duration[`${key}s`];
if (typeof value === "number" && value !== 0)
yield TIME_UNITS.require(key)?.format(value, options);
}
}
// Keys we loop through in the right order.
const TIME_KEYS = ["year", "month", "week", "day", "hour", "minute", "second", "millisecond"];