11 Commits

Author SHA1 Message Date
end 5461061e7e 1.3.1 2026-07-14 18:01:12 -07:00
end 39e1f9e9ad fix(error): GraphQL-aligned GraphError type
Expand GraphError.path to accept strings and numbers for field names
and list indexes, rather than strings only. Constrain isRawError to
check this assumption. Add custom path formatter to use dot notation for
fields and brackets for indexes.
2026-07-14 18:01:03 -07:00
end d75b8333e5 1.3.0 2026-05-06 19:10:58 -07:00
end a2097567a0 improve tab padding controls 2026-05-06 19:10:48 -07:00
end 7386bff7be fix PhoneInput combobox icon override 2026-05-06 19:06:41 -07:00
end e8440bc106 1.2.0 2026-05-04 03:34:14 -07:00
end d1019659f5 ErrorMessage: accept unknown in constructor 2026-04-21 18:08:57 -07:00
end 52cd3c6e8d add preventable and reversible event helper system 2026-04-20 19:15:00 -07:00
end 7e6fdc4071 Dialog: pass state to close snippet 2026-04-15 17:06:08 -07:00
end 9f19a36994 Combobox: allow null value 2026-04-15 17:04:49 -07:00
end 740e038382 error: extend ErrorMessage & helpers for improved ergonomics
- added isRawError helper
- added catchErrorString helper to convert any error to a string
- added checkGraphResponse to throw if error is present or data missing
- ErrorMessage now supports wrapping multiple errors ergonomically
2026-04-15 17:04:32 -07:00
9 changed files with 223 additions and 63 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "1.1.3",
"version": "1.3.1",
"scripts": {
"dev": "vite dev",
"build": "vite build && pnpm run prepack",
+5 -4
View File
@@ -29,10 +29,11 @@
};
/** returns option label, falling back to value or 'Undefined Option' if no option provided */
const getLabel = (opt: ComboboxOption | undefined): string =>
const getLabel = (opt: ComboboxOption | undefined | null): string =>
opt ? (opt.label ?? opt.value) : 'Undefined Option';
/** returns option preview, falling back to getLabel if missing */
const getPreview = (opt: ComboboxOption | undefined): string => opt?.preview ?? getLabel(opt);
const getPreview = (opt: ComboboxOption | undefined | null): string =>
opt?.preview ?? getLabel(opt);
</script>
<script lang="ts">
@@ -78,7 +79,7 @@
stateless?: boolean;
/** Bindable value of the combobox, the currently selected option */
value?: ComboboxOption;
value?: ComboboxOption | null;
/** Array of ComboboxOptions for the picker */
options: ComboboxOption[];
/**
@@ -271,7 +272,7 @@
});
/** currently highlighted option, updated by keyboard navigation or defaults to first item */
let highlighted = $derived.by((): ComboboxOption | undefined => {
let highlighted = $derived.by((): ComboboxOption | undefined | null => {
if (!searching) return undefined; // otherwise, the first item is highlighted on first open
if (filteredItems.length === 0) return undefined;
if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
+2 -2
View File
@@ -126,7 +126,7 @@
/** Sets bottom alignment of controls (default: end) */
controlsAlign?: 'start' | 'center' | 'end';
/** Top-right close control */
close?: Snippet | Omit<DialogControlButton, 'label'> | null;
close?: Snippet<[state: DialogState]> | Omit<DialogControlButton, 'label'> | null;
/**
* Callback when the dialog is opened
* @deprecated use onopenchange instead and check the open parameter
@@ -319,7 +319,7 @@
<!-- Close Button -->
{#if close !== null}
{#if typeof close === 'function'}
{@render close()}
{@render close(getState())}
{:else}
{@render dialogCloseButton(getState(), close)}
{/if}
+8 -9
View File
@@ -50,8 +50,7 @@
const options: ComboboxOption[] = countries.map((country) => ({
value: country.isoCode,
label: `${country.name} (+${country.phonecode})`,
preview: `+${country.phonecode}`,
icon: renderIcon
preview: `+${country.phonecode}`
}));
let phonecode: string = $derived.by(() => {
@@ -118,12 +117,6 @@
});
</script>
{#snippet renderIcon(item: ComboboxOption)}
{#if countrycodeMap[item.value]?.flag}
{countrycodeMap[item.value].flag}
{/if}
{/snippet}
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
@@ -149,7 +142,13 @@
countriesValid = e.detail.valid;
}}
invalidMessage={null}
/>
>
{#snippet iconRender(opt)}
{#if countrycodeMap[opt.value]?.flag}
{countrycodeMap[opt.value].flag}
{/if}
{/snippet}
</Combobox>
</div>
<div class="w-full">
+9 -14
View File
@@ -23,22 +23,17 @@
activeIndex?: number;
/** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header (default: false) */
padHeader?: boolean;
/** Applies layout padding to the content areas (default: false) */
padContent?: 'padding' | 'margin' | 'none';
/**
* Controls padding of content areas. True applies padding to content
* and header, false applies no padding (default), and 'content' and
* 'header' apply padding to their respective areas only.
*/
padded?: boolean | 'content' | 'header';
/** Additional classes applied to the outer container */
class?: ClassValue | null;
}
let {
pages,
activeIndex = 0,
onchange,
padHeader = false,
padContent = 'none',
class: classValue
}: Props = $props();
let { pages, activeIndex = 0, onchange, padded = false, class: classValue }: Props = $props();
let primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement;
@@ -109,7 +104,7 @@
bind:this={tabContainerEl}
class={[
'border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2',
padHeader && 'px-layout'
padded === true || padded === 'header' ? 'px-layout' : ''
]}
>
{#each pages as page, i (page.title)}
@@ -144,7 +139,7 @@
{#key activeIndex}
<div
class={[padContent === 'padding' && 'px-layout', padContent === 'margin' && 'mx-layout']}
class={[padded === true || padded === 'content' ? 'px-layout' : '']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight}
+125 -31
View File
@@ -3,26 +3,134 @@
*/
export interface GraphError {
message: string;
path?: string[];
/** GraphQL response path - field names and list indexes. */
path?: ReadonlyArray<string | number>;
}
/** RawError is an error that can be converted to a string by ErrorMessage */
export type RawError = Error | string | GraphError[];
export type RawError = ErrorMessage | Error | string | GraphError[];
/**
* Type guard to check if an error is a RawError
* @param error The error to check
* @returns true if the error is a RawError, false otherwise
*/
export const isRawError = (error: unknown): error is RawError => {
const isGraphError = (entry: unknown): entry is GraphError => {
if (!entry || typeof entry !== 'object') return false;
const candidate = entry as { message?: unknown; path?: unknown };
if (typeof candidate.message !== 'string') return false;
if (candidate.path === undefined) return true;
return (
Array.isArray(candidate.path) &&
candidate.path.every((p) => typeof p === 'string' || typeof p === 'number')
);
};
return (
error instanceof ErrorMessage ||
error instanceof Error ||
typeof error === 'string' ||
(Array.isArray(error) && error.every(isGraphError))
);
};
/**
* Converts any error (including GraphQL errors, standard Errors, strings, and
* ErrorMessages) into a consistent string format for display.
* @param error The error to convert to a string.
* @returns A string representation of the error, suitable for display to users.
*/
export const catchErrorString = (error: unknown): string => {
if (error instanceof ErrorMessage) {
return error.toString();
} else if (isRawError(error)) {
return new ErrorMessage(error).toString();
} else {
return String(error);
}
};
/**
* Checks a typical response from a GraphQL server for error and missing data,
* throwing an appropriate ErrorMessage if an error is found or if no data is
* returned.
* @param resourceName A human-readable name for the resource being fetched,
* used in error messages.
* @param response The response from the GraphQL server, which may contain an
* 'errors' array and a 'data' field.
* @throws An ErrorMessage if the response contains errors or if the data field
* is missing (undefined or null).
*/
export const checkGraphResponse = (
resourceName: string,
response: { errors?: GraphError[] | null; data?: unknown }
): void => {
if (response.errors && response.errors.length > 0) {
throw new ErrorMessage(`Error fetching ${resourceName}`, response.errors);
}
if (!response.data) {
throw new ErrorMessage(`No data returned for ${resourceName}`);
}
};
/**
* A class that represents an error message, which can be constructed from various types of raw errors
* and provides methods to convert those errors into a consistent format (lines of text) for display.
* It also supports wrapping existing error messages with additional context.
*/
export class ErrorMessage {
private _lines: string[] = [];
/**
* Converts a RawError to an array of lines and stores it for later access,
* or initializes without any errors if the input is null or undefined.
* @param raw The raw error to convert and store, or null/undefined for no error.
* @throws If the raw error is of an unsupported type.
*/
constructor(raw: RawError | null | undefined) {
if (raw) {
this._lines = ErrorMessage.rawErrorToLines(raw);
/** formats a GraphQL path using dot notation for fields and brackets for indexes */
private static formatGraphPath(path: ReadonlyArray<string | number>): string {
let formatted = '';
for (const segment of path) {
if (typeof segment === 'number') {
formatted += `[${segment}]`;
} else if (formatted.length === 0) {
formatted = segment;
} else {
formatted += `.${segment}`;
}
}
return formatted;
}
/**
* Always creates a new ErrorMessage instance, even if there are no errors.
* @param errors The raw errors to convert and store, or null/undefined for no error.
* @throws If any of the raw errors are of an unsupported type.
*/
constructor(...errors: (unknown | null | undefined)[]) {
if (errors.length === 0) return;
this._lines = errors.flatMap((e) => ErrorMessage.rawErrorToLines(e));
}
/**
* Creates a new ErrorMessage only if the provided errors are not null or
* undefined. If no errors are provided, returns null.
*/
static from(...errors: (unknown | null | undefined)[]): ErrorMessage | null {
if (errors.length === 0) return null;
return new ErrorMessage(...errors);
}
/**
* Wraps this ErrorMessage inside another error, nesting the original error.
* @param errors The raw errors to wrap around this error, or null/undefined for no additional error.
* @returns A new ErrorMessage instance that includes the original error and any new errors.
*/
wrap(...errors: (unknown | null | undefined)[]): ErrorMessage {
if (errors.length === 0) return this;
const newLines = errors.flatMap((e) => ErrorMessage.rawErrorToLines(e));
return new ErrorMessage(...newLines, ...this._lines);
}
/** returns true if there are any error lines */
hasError(): boolean {
return this._lines.length > 0;
}
/** returns the stored lines */
get lines(): string[] {
@@ -37,28 +145,14 @@ export class ErrorMessage {
return this._lines.join('<br />');
}
/** returns true if there are any error lines */
hasError(): boolean {
return this._lines.length > 0;
}
/** adds a new line to the error message */
addLine(line: string): void {
this._lines.push(line);
}
/** optionally returns a new ErrorMessage only if the RawError is not empty */
static from(raw: RawError | null | undefined): ErrorMessage | null {
if (!raw) return null;
return new ErrorMessage(raw);
}
/** converts a RawError to an array of lines */
static rawErrorToLines(raw: RawError | null | undefined): string[] {
if (!raw) return ['No error'];
static rawErrorToLines(raw: unknown | null | undefined): string[] {
if (!raw) return [];
let errorLines: string[];
if (typeof raw === 'string') {
if (raw instanceof ErrorMessage) {
errorLines = raw.lines;
} else if (typeof raw === 'string') {
errorLines = [raw];
} else if (raw instanceof Error) {
errorLines = [raw.message];
@@ -66,7 +160,7 @@ export class ErrorMessage {
errorLines = raw.map((e) => {
const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`;
return `"${messageString}" at ${ErrorMessage.formatGraphPath(e.path)}`;
}
return messageString;
});
+9 -1
View File
@@ -31,6 +31,7 @@ export { default as Label } from './Label.svelte';
export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte';
export { type Preventable, checkPreventer, type Reversible, checkReversible } from './prevent';
export { default as RadioGroup } from './RadioGroup.svelte';
export { default as ScrollBox } from './ScrollBox.svelte';
export { default as Spinner } from './Spinner.svelte';
@@ -67,7 +68,14 @@ export {
ToolbarGroup,
Toolbar
} from './Toolbar';
export { type GraphError, type RawError, ErrorMessage } from './error';
export {
type GraphError,
type RawError,
isRawError,
catchErrorString,
checkGraphResponse,
ErrorMessage
} from './error';
export {
NavigationItem,
NavigationManager,
+63
View File
@@ -0,0 +1,63 @@
/**
* A type for a function that can be prevented from executing its default behavior.
* See checkPreventer for easy usage of this type.
*/
export type Preventable<T> = (ev: T & { prevent: () => void }) => Promise<void> | void;
/**
* Calls the provided function with a preventer object, and if the preventer is not
* prevented, calls the callback function.
* @param fn The function to call with the preventer.
* @param ev The event object to pass to the function.
* @param cb The callback function to call if the preventer is not prevented.
* @returns A promise that resolves to true if the action was not prevented, or false if it was prevented.
*/
export const checkPreventer = async <T>(
fn: Preventable<T>,
ev: T,
cb?: () => void
): Promise<boolean> => {
let prevented = false;
const preventer = {
prevent: () => {
prevented = true;
}
};
await fn({ ...ev, ...preventer });
if (!prevented && cb) {
cb();
}
return !prevented;
};
/**
* A type for a function that can be reversed after executing its behavior.
* See checkReversible for easy usage of this type.
*/
export type Reversible<T> = (ev: T & { reverse: () => void }) => Promise<void> | void;
/**
* Calls the provided function with a reverser object, and if the reverser is
* reversed, calls the callback function.
* @param fn The function to call with the reverser.
* @param ev The event object to pass to the function.
* @param cb The callback function to call if the reverser is reversed.
* @returns A promise that resolves to true if the action was not reversed, or false if it was reversed.
*/
export const checkReversible = async <T>(
fn: Reversible<T>,
ev: T,
cb?: () => void
): Promise<boolean> => {
let reversed = false;
const reverser = {
reverse: () => {
reversed = true;
}
};
await fn({ ...ev, ...reverser });
if (reversed && cb) {
cb();
}
return !reversed;
};
+1 -1
View File
@@ -448,7 +448,7 @@
<p class="title">Tabs</p>
<Tabs
padded={true}
padded
pages={[
{
title: 'Dashboard',