Files
sui/src/lib/util.ts
2025-12-17 22:17:15 -08:00

198 lines
5.9 KiB
TypeScript

import type { IconComponentProps } from 'phosphor-svelte';
import type { Component } from 'svelte';
/**
* IconDef is an object that represents an icon element from the phosphor library, alongside an optional set of typed properties.
*/
export type IconDef = {
component: Component;
props?: IconComponentProps;
};
/**
* Defines a set of default properties for icons used in the application.
*/
export const defaultIconProps: IconComponentProps = {
size: '1.2rem',
weight: 'bold'
};
/**
* MaybeGetter is a type that can either be a value of type T or a function that returns a value of type T.
* This is useful for cases where you might want to pass a value directly or a function that computes the
* value later, potentially taking advantage of reactivity.
*/
export type MaybeGetter<T> = T | (() => T);
/**
* ResolveGetter returns the underlying value stored by a MaybeGetter type.
* @returns Raw value T or function return T.
*/
export const resolveGetter = <T>(getter: MaybeGetter<T>): T => {
if (typeof getter === 'function') {
return (getter as () => T)();
}
return getter;
};
/**
* Generates a unique identifier string unless an identifier is provided.
* If a prefix is provided, it will be prepended to the identifier.
* The identifier is a combination of a random part and a timestamp.
*
* @param {string} [prefix] - Optional prefix to prepend to the identifier.
* @param {string} [identifier] - Optional identifier to use instead of generating a new one.
* @returns {string} - A unique identifier string.
*/
export const generateIdentifier = (prefix?: string, identifier?: string): string => {
if (identifier) {
return `${prefix ? prefix + '-' : ''}${identifier}`;
}
const randomPart = Math.random().toString(36).substring(2, 10);
const timestampPart = Date.now().toString(36);
return `${prefix ? prefix + '-' : ''}${randomPart}-${timestampPart}`;
};
/**
* Option type definition for a select option. Used in various components.
*/
export type Option =
| {
value: string;
label?: string;
}
| string;
/** getLabel returns the option label if it exists*/
export const getLabel = (option: Option): string => {
if (typeof option === 'string') {
return option;
} else {
return option.label ?? option.value;
}
};
/** getValue returns the option value */
export const getValue = (option: Option): string => {
if (typeof option === 'string') {
return option;
} else {
return option.value;
}
};
/**
* targetMust returns the target of an event coerced to a particular type and
* throws an error if the target does not exist or is not connected.
*
* @returns The target element coerced to a particular type.
* @throws Will throw an error if the target is null or not connected to the DOM.
*/
export function targetMust<T extends HTMLElement>(event: Event): T {
const target = event.target as T | null;
if (!target) {
throw new Error('Event target is null');
}
if (!target.isConnected) {
throw new Error('Event target is not connected to the DOM');
}
return target;
}
/**
* capitalizeFirstLetter capitalizes the first letter of a string
* @param str The string to capitalize
* @returns The string with the first letter capitalized and all others lowercase
*/
export const capitalizeFirstLetter = (str: string): string => {
const lower = str.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
};
/**
* prefixZero adds a leading zero to the string if it is less than 10 and not 0
* @param str The string to prefix
* @returns The string with a leading zero if it was only 1 digit long
*/
export const prefixZero = (str: string): string => {
if (str.length === 1 && str !== '0') {
return '0' + str;
}
return str;
};
/**
* Trims the specified character from the start and/or end of the string.
* @param str The string to trim.
* @param char The character to trim.
* @param trimStart Whether to trim from the start of the string. Default: true.
* @param trimEnd Whether to trim from the end of the string. Default: true.
* @returns The trimmed string.
*/
export const trimEdges = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => {
let start = 0,
end = str.length;
if (trimStart || trimStart === undefined) {
while (start < end && str[start] === char) start++;
}
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
}
return str.substring(start, end);
};
// helper: only treat plain objects as mergeable
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' &&
v !== null &&
!Array.isArray(v) &&
Object.getPrototypeOf(v) === Object.prototype;
/** Merge two plain object maps. No `any` used. */
function mergePlainObjects(
baseObj: Record<string, unknown>,
overrideObj: Record<string, unknown>
): Record<string, unknown> {
const res: Record<string, unknown> = { ...baseObj };
for (const k of Object.keys(overrideObj)) {
const v = overrideObj[k];
if (v === undefined) continue; // undefined preserves base
const b = res[k];
if (isPlainObject(v) && isPlainObject(b)) {
res[k] = mergePlainObjects(b as Record<string, unknown>, v as Record<string, unknown>);
} else {
// primitives, null, arrays, non-plain objects replace base
res[k] = v;
}
}
return res;
}
/**
* Merge `base` with `override`.
* - `null` in `override` replaces (kept as valid override)
* - `undefined` in `override` is ignored (keeps base)
* - Only plain objects are deep-merged
* - If `override` is null/undefined we return a shallow copy of `base`
*/
export const mergeOverrideObject = <T extends Record<string, unknown>>(
base: T,
override?: Partial<T> | null
): T => {
if (override == null) return { ...base } as T;
// Use plain maps internally to avoid explicit any
const baseMap = { ...base } as Record<string, unknown>;
const overrideMap = override as Record<string, unknown>;
const merged = mergePlainObjects(baseMap, overrideMap);
return merged as T;
};