14 Commits

Author SHA1 Message Date
Elijah Duffy
e8440bc106 1.2.0 2026-05-04 03:34:14 -07:00
Elijah Duffy
d1019659f5 ErrorMessage: accept unknown in constructor 2026-04-21 18:08:57 -07:00
Elijah Duffy
52cd3c6e8d add preventable and reversible event helper system 2026-04-20 19:15:00 -07:00
Elijah Duffy
7e6fdc4071 Dialog: pass state to close snippet 2026-04-15 17:06:08 -07:00
Elijah Duffy
9f19a36994 Combobox: allow null value 2026-04-15 17:04:49 -07:00
Elijah Duffy
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
Elijah Duffy
c991ac044f Combobox: fix icon padding issue with compact mode 2026-04-12 22:52:19 -07:00
Elijah Duffy
e6fc8e8b37 floating: add position reference element override 2026-04-12 17:52:08 -07:00
Elijah Duffy
ab352b217a floating: add constraint size option 2026-04-12 17:34:02 -07:00
Elijah Duffy
18b0b8963a floating: add click & keydown helpers 2026-04-12 17:03:29 -07:00
Elijah Duffy
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
Elijah Duffy
e994d8e232 add .nvmrc 2026-04-08 17:37:39 -07:00
Elijah Duffy
d108a74aaf 1.1.3 2026-03-15 22:10:14 -07:00
Elijah Duffy
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
9 changed files with 263 additions and 51 deletions

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.12.0

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.2", "version": "1.2.0",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && pnpm run prepack", "build": "vite build && pnpm run prepack",

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,7 +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 { untrack, type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
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';
@@ -78,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[];
/** /**
@@ -271,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;
@@ -314,11 +315,7 @@
easing: cubicOut easing: cubicOut
}); });
$effect(() => { $effect(() => {
if (iconWidth >= 0) { inputPadding.target = calculatePadding();
untrack(() => {
inputPadding.target = calculatePadding();
});
}
}); });
/*** HELPER FUNCTIONS ***/ /*** HELPER FUNCTIONS ***/
@@ -654,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'}

View File

@@ -126,7 +126,7 @@
/** 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
@@ -319,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}

View File

@@ -26,7 +26,7 @@
/** Applies layout padding to the tab header (default: false) */ /** Applies layout padding to the tab header (default: false) */
padHeader?: boolean; padHeader?: boolean;
/** Applies layout padding to the content areas (default: false) */ /** Applies layout padding to the content areas (default: false) */
padContent?: boolean; padContent?: 'padding' | 'margin' | 'none';
/** Additional classes applied to the outer container */ /** Additional classes applied to the outer container */
class?: ClassValue | null; class?: ClassValue | null;
} }
@@ -36,7 +36,7 @@
activeIndex = 0, activeIndex = 0,
onchange, onchange,
padHeader = false, padHeader = false,
padContent = false, padContent = 'none',
class: classValue class: classValue
}: Props = $props(); }: Props = $props();
@@ -144,7 +144,7 @@
{#key activeIndex} {#key activeIndex}
<div <div
class={[padContent && 'px-layout']} class={[padContent === 'padding' && 'px-layout', padContent === 'margin' && 'mx-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}

View File

@@ -7,21 +7,105 @@ export interface GraphError {
} }
/** 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 => {
return (
error instanceof ErrorMessage ||
error instanceof Error ||
typeof error === 'string' ||
(Array.isArray(error) &&
error.every(
(e) => typeof e.message === 'string' && (e.path === undefined || Array.isArray(e.path))
))
);
};
/**
* 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[] = [];
/** /**
* Converts a RawError to an array of lines and stores it for later access, * Always creates a new ErrorMessage instance, even if there are no errors.
* or initializes without any errors if the input is null or undefined. * @param errors The raw errors to convert and store, or null/undefined for no error.
* @param raw The raw error to convert and store, or null/undefined for no error. * @throws If any of the raw errors are of an unsupported type.
* @throws If the raw error is of an unsupported type.
*/ */
constructor(raw: RawError | null | undefined) { constructor(...errors: (unknown | null | undefined)[]) {
if (raw) { if (errors.length === 0) return;
this._lines = ErrorMessage.rawErrorToLines(raw); 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 */
@@ -37,28 +121,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];

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, {

View File

@@ -31,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';
@@ -67,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
src/lib/prevent.ts Normal file
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;
};