26 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
end c991ac044f Combobox: fix icon padding issue with compact mode 2026-04-12 22:52:19 -07:00
end e6fc8e8b37 floating: add position reference element override 2026-04-12 17:52:08 -07:00
end ab352b217a floating: add constraint size option 2026-04-12 17:34:02 -07:00
end 18b0b8963a floating: add click & keydown helpers 2026-04-12 17:03:29 -07:00
end 337c2a50b3 Combobox: fix primary icon visibility
The primary icon for a Combobox was never visible if a value was
selected because we were always attempting to use the icon for that item
even if it didn't have one.
2026-04-08 17:39:04 -07:00
end e994d8e232 add .nvmrc 2026-04-08 17:37:39 -07:00
end d108a74aaf 1.1.3 2026-03-15 22:10:14 -07:00
end 2c99c16b48 tabs: more configurable content padding options
choice between margin and padding. ngl, this feels kind of dumb, it
wasn't be problem and I don't know when it would ever be a problem.
perhaps i'll simplify or revert this later.
2026-03-15 22:03:59 -07:00
end ffe9c0369b tabs: separate controls for header and content padding 2026-03-15 21:52:01 -07:00
end 622481a1ca dialog: add autofreeze to sync loading state
Enabled by default, autofreeze freezes the dialog whenever loading state
is enabled.
2026-03-15 15:58:44 -07:00
end ebdca97527 dialog: fix DialogState export 2026-03-15 13:03:39 -07:00
end bb27d0c9d7 dialog: pass state to snippet overrides 2026-03-15 12:58:28 -07:00
end ade904d0c9 1.1.2 2026-03-13 16:27:42 -07:00
end 50c052a3c3 combobox: don't use portal for picker
fixes z-stacking issues when used within dialogs
2026-03-13 16:27:35 -07:00
end bcfd3ea740 dialog: add z-index to dialog api 2026-03-13 16:27:13 -07:00
11 changed files with 396 additions and 136 deletions
+1
View File
@@ -0,0 +1 @@
24.12.0
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "git", "type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git" "url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
}, },
"version": "1.1.1", "version": "1.3.1",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && pnpm run prepack", "build": "vite build && pnpm run prepack",
+8 -14
View File
@@ -29,10 +29,11 @@
}; };
/** returns option label, falling back to value or 'Undefined Option' if no option provided */ /** 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'; opt ? (opt.label ?? opt.value) : 'Undefined Option';
/** returns option preview, falling back to getLabel if missing */ /** 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>
<script lang="ts"> <script lang="ts">
@@ -41,8 +42,7 @@
import Label from './Label.svelte'; import Label from './Label.svelte';
import StyledRawInput from './StyledRawInput.svelte'; import StyledRawInput from './StyledRawInput.svelte';
import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate'; import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate';
import { onMount, tick, untrack, type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { Portal } from '@jsrob/svelte-portal';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import { generateIdentifier, type IconDef } from './util'; import { generateIdentifier, type IconDef } from './util';
import type { ClassValue, MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
@@ -79,7 +79,7 @@
stateless?: boolean; stateless?: boolean;
/** Bindable value of the combobox, the currently selected option */ /** Bindable value of the combobox, the currently selected option */
value?: ComboboxOption; value?: ComboboxOption | null;
/** Array of ComboboxOptions for the picker */ /** Array of ComboboxOptions for the picker */
options: ComboboxOption[]; options: ComboboxOption[];
/** /**
@@ -272,7 +272,7 @@
}); });
/** currently highlighted option, updated by keyboard navigation or defaults to first item */ /** 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 (!searching) return undefined; // otherwise, the first item is highlighted on first open
if (filteredItems.length === 0) return undefined; if (filteredItems.length === 0) return undefined;
if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value; if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
@@ -315,12 +315,8 @@
easing: cubicOut easing: cubicOut
}); });
$effect(() => { $effect(() => {
if (iconWidth >= 0) {
untrack(() => {
inputPadding.target = calculatePadding(); inputPadding.target = calculatePadding();
}); });
}
});
/*** HELPER FUNCTIONS ***/ /*** HELPER FUNCTIONS ***/
@@ -502,7 +498,6 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<!-- Combobox picker --> <!-- Combobox picker -->
<Portal target="body">
{#if open} {#if open}
<!-- Picker container --> <!-- Picker container -->
<div <div
@@ -549,7 +544,6 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</Portal>
<!-- Combobox main input container --> <!-- Combobox main input container -->
<div class={classValue}> <div class={classValue}>
@@ -657,9 +651,9 @@
> >
{#if loading} {#if loading}
<Spinner class="stroke-sui-accent! -mt-0.5" size="1em" /> <Spinner class="stroke-sui-accent! -mt-0.5" size="1em" />
{:else if useHighlighted && highlighted} {:else if useHighlighted && highlighted && highlighted.icon}
{@render optionIcon(highlighted)} {@render optionIcon(highlighted)}
{:else if value} {:else if value && value.icon}
{@render optionIcon(value)} {@render optionIcon(value)}
{:else if icon} {:else if icon}
{#if typeof icon === 'function'} {#if typeof icon === 'function'}
+35 -17
View File
@@ -43,6 +43,14 @@
title: (title: string) => void; title: (title: string) => void;
/** Focuses the dialog */ /** Focuses the dialog */
focus: () => void; focus: () => void;
/** Returns the stack index for this dialog */
stackIndex: () => number;
/**
* Returns the z-index of this dialog, where each incremental stack
* index increases the z-index by 100 starting from 1000. This can be
* used to layer custom elements on top of and within the dialog.
*/
zIndex: () => number;
} }
type DialogControlButton = { type DialogControlButton = {
@@ -68,9 +76,9 @@
/** /**
* Stores internal state of the dialog, everything necessary to render * Stores internal state of the dialog, everything necessary to render
* internal snippets. * internal and override capable snippets.
*/ */
type DialogState = { export type DialogState = {
frozen: boolean; frozen: boolean;
loading: boolean; loading: boolean;
disabled: boolean; disabled: boolean;
@@ -104,9 +112,9 @@
/** Bindable open state of the dialog */ /** Bindable open state of the dialog */
open?: boolean; open?: boolean;
/** Title of the dialog */ /** Title of the dialog */
title: string | Snippet; title: string | Snippet<[state: DialogState]>;
/** Description of the dialog, optionally rendered below the title */ /** Description of the dialog, optionally rendered below the title */
description?: string | Snippet; description?: string | Snippet<[state: DialogState]>;
/** Size of the dialog (default: 'sm') */ /** Size of the dialog (default: 'sm') */
size?: 'sm' | 'md' | 'lg' | 'max'; size?: 'sm' | 'md' | 'lg' | 'max';
/** Additional classes for the dialog */ /** Additional classes for the dialog */
@@ -114,11 +122,11 @@
/** Content of the dialog */ /** Content of the dialog */
children?: Snippet; children?: Snippet;
/** Bottom controls for the dialog */ /** Bottom controls for the dialog */
controls?: Snippet | DialogControls; controls?: Snippet<[state: DialogState]> | DialogControls;
/** Sets bottom alignment of controls (default: end) */ /** Sets bottom alignment of controls (default: end) */
controlsAlign?: 'start' | 'center' | 'end'; controlsAlign?: 'start' | 'center' | 'end';
/** Top-right close control */ /** Top-right close control */
close?: Snippet | Omit<DialogControlButton, 'label'> | null; close?: Snippet<[state: DialogState]> | Omit<DialogControlButton, 'label'> | null;
/** /**
* Callback when the dialog is opened * Callback when the dialog is opened
* @deprecated use onopenchange instead and check the open parameter * @deprecated use onopenchange instead and check the open parameter
@@ -133,8 +141,10 @@
onopenchange?: ({ open, dialog }: { open: boolean; dialog: DialogAPI }) => void; onopenchange?: ({ open, dialog }: { open: boolean; dialog: DialogAPI }) => void;
/** If default controls are used, controls loading state of submit button */ /** If default controls are used, controls loading state of submit button */
loading?: boolean; loading?: boolean;
/** If default controls are used, freezes all interactions */ /** If default controls are used, freezes all interactions preventing user input */
frozen?: boolean; frozen?: boolean;
/** If enabled, automatically freezes dialog when loading (default: true) */
autoFreeze?: boolean;
/** If default controls are used, disables submit button */ /** If default controls are used, disables submit button */
disabled?: boolean; disabled?: boolean;
} }
@@ -154,6 +164,7 @@
onopenchange, onopenchange,
loading = $bindable(false), loading = $bindable(false),
frozen = $bindable(false), frozen = $bindable(false),
autoFreeze = true,
disabled = $bindable(false) disabled = $bindable(false)
}: Props = $props(); }: Props = $props();
@@ -170,6 +181,9 @@
let dialogContainer = $state<HTMLDivElement | null>(null); let dialogContainer = $state<HTMLDivElement | null>(null);
let error = $state<ErrorMessage | null>(null); let error = $state<ErrorMessage | null>(null);
let stackIndex = $state(-1); let stackIndex = $state(-1);
const zIndex = $derived(1000 + stackIndex * 100);
const reallyFrozen = $derived(autoFreeze ? frozen || loading : frozen);
/** handles open change */ /** handles open change */
const handleOpenChange = (localOpen: boolean) => { const handleOpenChange = (localOpen: boolean) => {
@@ -227,13 +241,15 @@
isFrozen: () => frozen, isFrozen: () => frozen,
canContinue: () => !loading && !disabled && !frozen, canContinue: () => !loading && !disabled && !frozen,
title: (newTitle) => (title = newTitle), title: (newTitle) => (title = newTitle),
focus: () => dialogPage?.focus() focus: () => dialogPage?.focus(),
stackIndex: () => stackIndex,
zIndex: () => zIndex
}; };
/** Returns the current state of the dialog */ /** Returns the current state of the dialog */
export const getState = (): DialogState => { export const getState = (): DialogState => {
return { return {
frozen, frozen: reallyFrozen,
loading, loading,
disabled, disabled,
api: dialogAPI api: dialogAPI
@@ -256,15 +272,15 @@
classValue classValue
]} ]}
style={// increase z-index and decrease opacity for each nested dialog style={// increase z-index and decrease opacity for each nested dialog
`z-index: ${1000 + stackIndex}`} `z-index: ${zIndex}`}
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
onclick={(e) => { onclick={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (open && !frozen && !dialogContainer?.contains(target) && target !== dialogContainer) if (open && !reallyFrozen && !dialogContainer?.contains(target) && target !== dialogContainer)
open = false; open = false;
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape' && !frozen) { if (e.key === 'Escape' && !reallyFrozen) {
if (stackIndex === dialogStack.length - 1) { if (stackIndex === dialogStack.length - 1) {
// only close if this dialog is the topmost dialog // only close if this dialog is the topmost dialog
open = false; open = false;
@@ -303,7 +319,7 @@
<!-- Close Button --> <!-- Close Button -->
{#if close !== null} {#if close !== null}
{#if typeof close === 'function'} {#if typeof close === 'function'}
{@render close()} {@render close(getState())}
{:else} {:else}
{@render dialogCloseButton(getState(), close)} {@render dialogCloseButton(getState(), close)}
{/if} {/if}
@@ -331,7 +347,9 @@
controlsAlign === 'end' && 'justify-end' controlsAlign === 'end' && 'justify-end'
]} ]}
> >
{#if controls && typeof controls === 'function'}{@render controls()}{:else if controls?.flip} {#if controls && typeof controls === 'function'}{@render controls(
getState()
)}{:else if controls?.flip}
{#if controls.ok !== null} {#if controls.ok !== null}
{@render dialogOkButton(getState(), controls.ok)} {@render dialogOkButton(getState(), controls.ok)}
{/if} {/if}
@@ -361,7 +379,7 @@
state.api.close(); state.api.close();
} }
}} }}
disabled={state.frozen} disabled={state.frozen || state.disabled}
> >
{opts?.label || 'Cancel'} {opts?.label || 'Cancel'}
</Button> </Button>
@@ -406,10 +424,10 @@
</button> </button>
{/snippet} {/snippet}
{#snippet stringOrSnippet(val: string | Snippet)} {#snippet stringOrSnippet(val: string | Snippet<[state: DialogState]>)}
{#if typeof val === 'string'} {#if typeof val === 'string'}
{val} {val}
{:else} {:else}
{@render val()} {@render val(getState())}
{/if} {/if}
{/snippet} {/snippet}
+8 -9
View File
@@ -50,8 +50,7 @@
const options: ComboboxOption[] = countries.map((country) => ({ const options: ComboboxOption[] = countries.map((country) => ({
value: country.isoCode, value: country.isoCode,
label: `${country.name} (+${country.phonecode})`, label: `${country.name} (+${country.phonecode})`,
preview: `+${country.phonecode}`, preview: `+${country.phonecode}`
icon: renderIcon
})); }));
let phonecode: string = $derived.by(() => { let phonecode: string = $derived.by(() => {
@@ -118,12 +117,6 @@
}); });
</script> </script>
{#snippet renderIcon(item: ComboboxOption)}
{#if countrycodeMap[item.value]?.flag}
{countrycodeMap[item.value].flag}
{/if}
{/snippet}
<div class={classValue}> <div class={classValue}>
{#if label} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
@@ -149,7 +142,13 @@
countriesValid = e.detail.valid; countriesValid = e.detail.valid;
}} }}
invalidMessage={null} invalidMessage={null}
/> >
{#snippet iconRender(opt)}
{#if countrycodeMap[opt.value]?.flag}
{countrycodeMap[opt.value].flag}
{/if}
{/snippet}
</Combobox>
</div> </div>
<div class="w-full"> <div class="w-full">
+9 -4
View File
@@ -23,8 +23,13 @@
activeIndex?: number; activeIndex?: number;
/** Callback fired when the active tab changes */ /** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void; onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header & content areas (default: false) */ /**
padded?: boolean; * 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; class?: ClassValue | null;
} }
@@ -99,7 +104,7 @@
bind:this={tabContainerEl} bind:this={tabContainerEl}
class={[ class={[
'border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2', 'border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2',
padded && 'px-layout' padded === true || padded === 'header' ? 'px-layout' : ''
]} ]}
> >
{#each pages as page, i (page.title)} {#each pages as page, i (page.title)}
@@ -134,7 +139,7 @@
{#key activeIndex} {#key activeIndex}
<div <div
class={[padded && 'px-layout']} class={[padded === true || padded === 'content' ? 'px-layout' : '']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }} in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }} out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight} onoutrostart={lockHeight}
+125 -31
View File
@@ -3,26 +3,134 @@
*/ */
export interface GraphError { export interface GraphError {
message: string; 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 */ /** 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 { export class ErrorMessage {
private _lines: string[] = []; private _lines: string[] = [];
/** /** formats a GraphQL path using dot notation for fields and brackets for indexes */
* Converts a RawError to an array of lines and stores it for later access, private static formatGraphPath(path: ReadonlyArray<string | number>): string {
* or initializes without any errors if the input is null or undefined. let formatted = '';
* @param raw The raw error to convert and store, or null/undefined for no error. for (const segment of path) {
* @throws If the raw error is of an unsupported type. if (typeof segment === 'number') {
*/ formatted += `[${segment}]`;
constructor(raw: RawError | null | undefined) { } else if (formatted.length === 0) {
if (raw) { formatted = segment;
this._lines = ErrorMessage.rawErrorToLines(raw); } 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 */ /** returns the stored lines */
get lines(): string[] { get lines(): string[] {
@@ -37,28 +145,14 @@ export class ErrorMessage {
return this._lines.join('<br />'); 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 */ /** converts a RawError to an array of lines */
static rawErrorToLines(raw: RawError | null | undefined): string[] { static rawErrorToLines(raw: unknown | null | undefined): string[] {
if (!raw) return ['No error']; if (!raw) return [];
let errorLines: string[]; let errorLines: string[];
if (typeof raw === 'string') { if (raw instanceof ErrorMessage) {
errorLines = raw.lines;
} else if (typeof raw === 'string') {
errorLines = [raw]; errorLines = [raw];
} else if (raw instanceof Error) { } else if (raw instanceof Error) {
errorLines = [raw.message]; errorLines = [raw.message];
@@ -66,7 +160,7 @@ export class ErrorMessage {
errorLines = raw.map((e) => { errorLines = raw.map((e) => {
const messageString = e.message || 'Unknown error'; const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) { if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`; return `"${messageString}" at ${ErrorMessage.formatGraphPath(e.path)}`;
} }
return messageString; return messageString;
}); });
+77 -4
View File
@@ -4,7 +4,7 @@
* for more details, examples, and original source. * for more details, examples, and original source.
*/ */
import { computePosition, autoUpdate, flip, offset, type Placement } from '@floating-ui/dom'; import { computePosition, autoUpdate, flip, offset, type Placement, size } from '@floating-ui/dom';
import { createAttachmentKey } from 'svelte/attachments'; import { createAttachmentKey } from 'svelte/attachments';
/** /**
@@ -17,6 +17,13 @@ export interface PopoverOptions {
placement?: Placement; placement?: Placement;
/** Offset distance between the reference and floating elements (default: 8) */ /** Offset distance between the reference and floating elements (default: 8) */
offset?: number; offset?: number;
/**
* Constraints the width and height of the popover to prevent it from
* overflowing the viewport. If true, the popover will adjust its max-width
* and max-height to fit. Scrolling is the responsibility of the parent
* element. Default is true.
*/
constrainSize?: boolean;
/** Callback when the popover is opened or closed */ /** Callback when the popover is opened or closed */
ontoggle?: (open: boolean) => void; ontoggle?: (open: boolean) => void;
} }
@@ -29,10 +36,12 @@ export class Popover {
private options: PopoverOptions = { private options: PopoverOptions = {
interaction: 'click', interaction: 'click',
placement: 'bottom-start', placement: 'bottom-start',
offset: 8 offset: 8,
constrainSize: true
}; };
private referenceElement: HTMLElement | undefined = $state(); private referenceElement: HTMLElement | undefined = $state();
private floatingElement: HTMLElement | undefined = $state(); private floatingElement: HTMLElement | undefined = $state();
private positionElement: HTMLElement | undefined = $state();
private open = $state(false); private open = $state(false);
/** /**
@@ -87,6 +96,23 @@ export class Popover {
return attrs; return attrs;
} }
/**
* Optionally sets the positioning element for the popover, which is used
* for calculating the position of the floating element. If not set, the
* reference element is used by default.
* @returns An object containing an attachment key for the positioning element.
*/
positionReference() {
return {
[createAttachmentKey()]: (node: HTMLElement) => {
this.positionElement = node;
return () => {
this.positionElement = undefined;
};
}
};
}
/** Returns whether the popover is open */ /** Returns whether the popover is open */
isOpen() { isOpen() {
return this.open; return this.open;
@@ -122,6 +148,38 @@ export class Popover {
}; };
} }
/**
* Callback function that closes the popover when the Escape key is pressed.
* Can be applied to the window, typically via <svelte:window onkeydown={popover.handleEscape} />,
* or to any other element that will receive keyboard events while the popover is open.
* @param event - The keyboard event to handle.
*/
handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
this.setOpen(false);
}
}
/**
* Callback function that closes the popover when a click occurs outside of the
* reference and floating elements. Should be applied to the window, typically
* via <svelte:window onclick={popover.handleClickOutside} />.
* @param event - The click event to handle.
*/
handleClickOutside(event: MouseEvent) {
if (!this.isOpen() || !this.referenceElement || !this.floatingElement) {
return;
}
if (
event.target instanceof Node &&
!this.referenceElement.contains(event.target) &&
!this.floatingElement.contains(event.target)
) {
this.setOpen(false);
}
}
/** /**
* Updates the position of the floating element based on the reference element using * Updates the position of the floating element based on the reference element using
* the computePosition function from floating-ui. It applies the calculated * the computePosition function from floating-ui. It applies the calculated
@@ -131,9 +189,24 @@ export class Popover {
if (!this.referenceElement || !this.floatingElement) { if (!this.referenceElement || !this.floatingElement) {
return; return;
} }
const position = await computePosition(this.referenceElement, this.floatingElement, { const reference = this.positionElement || this.referenceElement;
const position = await computePosition(reference, this.floatingElement, {
placement: this.options.placement, placement: this.options.placement,
middleware: [flip(), offset(this.options.offset)] middleware: [
flip(),
offset(this.options.offset),
size({
apply: ({ availableWidth, availableHeight, elements }) => {
if (this.options.constrainSize) {
elements.floating.style.maxWidth = `${availableWidth}px`;
elements.floating.style.maxHeight = `${availableHeight}px`;
} else {
elements.floating.style.maxWidth = '';
elements.floating.style.maxHeight = '';
}
}
})
]
}); });
const { x, y } = position; const { x, y } = position;
Object.assign(this.floatingElement.style, { Object.assign(this.floatingElement.style, {
+10 -1
View File
@@ -10,6 +10,7 @@ export {
default as Dialog, default as Dialog,
type DialogAPI, type DialogAPI,
type DialogControls, type DialogControls,
type DialogState,
dialogCancelButton, dialogCancelButton,
dialogCloseButton, dialogCloseButton,
dialogOkButton dialogOkButton
@@ -30,6 +31,7 @@ export { default as Label } from './Label.svelte';
export { default as Link, rewriteHref } from './Link.svelte'; export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte'; export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.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 RadioGroup } from './RadioGroup.svelte';
export { default as ScrollBox } from './ScrollBox.svelte'; export { default as ScrollBox } from './ScrollBox.svelte';
export { default as Spinner } from './Spinner.svelte'; export { default as Spinner } from './Spinner.svelte';
@@ -66,7 +68,14 @@ export {
ToolbarGroup, ToolbarGroup,
Toolbar Toolbar
} from './Toolbar'; } from './Toolbar';
export { type GraphError, type RawError, ErrorMessage } from './error'; export {
type GraphError,
type RawError,
isRawError,
catchErrorString,
checkGraphResponse,
ErrorMessage
} from './error';
export { export {
NavigationItem, NavigationItem,
NavigationManager, 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;
};
+14 -10
View File
@@ -448,7 +448,7 @@
<p class="title">Tabs</p> <p class="title">Tabs</p>
<Tabs <Tabs
padded={true} padded
pages={[ pages={[
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -537,15 +537,6 @@
bind:open={dialogOpen} bind:open={dialogOpen}
title="Dialog Title" title="Dialog Title"
size="sm" size="sm"
controls={{
ok: {
action: (dialog) => {
dialog.close();
alert('Dialog submitted!');
}
},
cancel: null
}}
onopen={(dialog) => { onopen={(dialog) => {
dialog.error('Example error message!'); dialog.error('Example error message!');
dialog.loading(); dialog.loading();
@@ -554,6 +545,19 @@
}, 2000); }, 2000);
}} }}
> >
{#snippet controls(state)}
<Button onclick={() => state.api.close()}>Cancel</Button>
<Button
onclick={() => {
state.api.close();
alert('Dialog submitted!');
}}
loading={state.loading}
>
Submit
</Button>
{/snippet}
<p>This is a dialog content area.</p> <p>This is a dialog content area.</p>
<Button onclick={() => (nestedDialogOpen = true)}>Open Nested Dialog</Button> <Button onclick={() => (nestedDialogOpen = true)}>Open Nested Dialog</Button>