Compare commits

...

28 Commits

Author SHA1 Message Date
Elijah Duffy
bf77a20ff9 0.3.4 2025-12-15 17:14:34 -08:00
Elijah Duffy
42c9bc0bcc banner: fix control config merge behaviour 2025-12-15 17:14:33 -08:00
Elijah Duffy
ed48b404d4 0.3.3 2025-12-15 17:05:39 -08:00
Elijah Duffy
54f7924c4a banner: add control swap 2025-12-15 17:05:20 -08:00
Elijah Duffy
d9cd2b406a banner: fix control placement with optionals disabled 2025-12-15 16:42:44 -08:00
Elijah Duffy
46bd6b935a 0.3.2 2025-12-15 15:40:33 -08:00
Elijah Duffy
9782a31846 add Banner component 2025-12-15 15:24:53 -08:00
Elijah Duffy
63b29e3f6a actually fix date input demo bad bind:value 2025-12-15 15:24:19 -08:00
Elijah Duffy
1c4fac7523 demo page: set title properly in svelte:head 2025-12-15 15:23:55 -08:00
Elijah Duffy
098bf75bd3 frameless button, link: add inverted colour state 2025-12-15 15:23:24 -08:00
Elijah Duffy
a321cbffe9 input group: allow wrapping by default 2025-12-15 15:23:12 -08:00
Elijah Duffy
1cc8cd6913 fix date input demo bad bind:value 2025-12-15 14:40:43 -08:00
Elijah Duffy
72b277d138 0.3.1 2025-12-12 13:59:35 -08:00
Elijah Duffy
3885ac09a1 link: refactor href rewrite
- uses prop instead of attempting to read env variable
- uses URL type instead of custom heuristics
2025-12-12 13:59:21 -08:00
Elijah Duffy
ae3abad769 use tinyduration for ISO8601 parsing and serializing 2025-12-11 20:40:27 -08:00
Elijah Duffy
837029b598 0.3.0 2025-12-11 17:30:36 -08:00
Elijah Duffy
3409adc614 date input: add onchange callback prop 2025-12-11 17:02:32 -08:00
Elijah Duffy
a538db4065 date input: use distinct 'null' value state if unset by user 2025-12-11 16:02:09 -08:00
Elijah Duffy
9d0f10f0fd duration & time input: input visual invalidation 2025-12-11 16:00:41 -08:00
Elijah Duffy
df1dd238e2 StyledRawInput: support external force invalid visual state 2025-12-11 16:00:26 -08:00
Elijah Duffy
0608343741 DurationInput: limit precision to TimeDuration type
Remove support for 'days', adds support for 'milliseconds'
2025-12-11 15:42:59 -08:00
Elijah Duffy
f843c91284 add DurationInput component 2025-12-11 15:20:42 -08:00
Elijah Duffy
a7fa9fd6d8 fix '0' > prefixZero > '00' (should still be '0') 2025-12-11 15:19:45 -08:00
Elijah Duffy
7fd2bbb879 dialog: don't default to scrollable demo to open 2025-12-11 11:43:16 -08:00
Elijah Duffy
f260038aac checkbox: support disabled state 2025-12-11 11:42:28 -08:00
Elijah Duffy
058e20fa2d checkbox: support MaybeGetter pattern 2025-12-10 19:04:44 -08:00
Elijah Duffy
020f5ea4ad 0.2.15 2025-12-09 16:24:16 -08:00
Elijah Duffy
08cde9c3ee dialog: handle overflow scrolling 2025-12-09 16:24:08 -08:00
16 changed files with 860 additions and 107 deletions

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": "0.2.14", "version": "0.3.4",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && pnpm run prepack", "build": "vite build && pnpm run prepack",
@@ -40,12 +40,12 @@
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.20.2", "@sveltejs/kit": "^2.20.2",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.11" "@tailwindcss/vite": "^4.1.11",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7"
}, },
"dependencies": { "dependencies": {
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",
@@ -56,7 +56,8 @@
"match-sorter": "^8.0.0", "match-sorter": "^8.0.0",
"melt": "^0.37.0", "melt": "^0.37.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"phosphor-svelte": "^3.0.1" "phosphor-svelte": "^3.0.1",
"tinyduration": "^3.4.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",

8
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.11) version: 1.0.7(tailwindcss@4.1.11)
tinyduration:
specifier: ^3.4.1
version: 3.4.1
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.2.5 specifier: ^1.2.5
@@ -1539,6 +1542,9 @@ packages:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
tinyduration@3.4.1:
resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==}
tinyglobby@0.2.14: tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -2917,6 +2923,8 @@ snapshots:
mkdirp: 3.0.1 mkdirp: 3.0.1
yallist: 5.0.0 yallist: 5.0.0
tinyduration@3.4.1: {}
tinyglobby@0.2.14: tinyglobby@0.2.14:
dependencies: dependencies:
fdir: 6.4.6(picomatch@4.0.3) fdir: 6.4.6(picomatch@4.0.3)

259
src/lib/Banner.svelte Normal file
View File

@@ -0,0 +1,259 @@
<script lang="ts" module>
export interface BannerAPI {
/** opens the banner */
open: () => void;
/** closes the banner */
close: () => void;
/** returns whether the banner is open */
isOpen: () => boolean;
/** disables banner controls */
disable: () => void;
/** enables banner controls */
enable: () => void;
/** returns whether the banner controls are disabled */
isDisabled: () => boolean;
/** freezes banner state (cannot be dismissed) */
freeze: () => void;
/** unfreezes banner state */
unfreeze: () => void;
/** returns whether the banner is frozen */
isFrozen: () => boolean;
}
type BannerControlButton = {
label?: string;
class?: ClassValue;
/** if framed is false, FramelessButton is used instead of Button */
framed?: boolean;
action?: (banner: BannerAPI) => void;
};
export type BannerControls = {
accept?: BannerControlButton | null;
decline?: BannerControlButton | null;
moreInfo?:
| (Omit<BannerControlButton, 'framed'> & {
type?: 'link' | 'framed' | 'frameless';
href?: string;
})
| null;
dismiss?: Omit<BannerControlButton, 'framed'> | null;
/** if true, accept and decline buttons are swapped with more info */
swap?: boolean | null;
};
const defaultBannerControls: BannerControls = {
accept: {
label: 'Accept',
framed: true,
action: (banner) => banner.close()
},
decline: {
label: 'Decline',
framed: false,
action: (banner) => banner.close()
},
dismiss: {
action: (banner) => banner.close()
},
swap: false
};
const mergeBannerControls = (
defaults: BannerControls,
overrides?: BannerControls | null
): BannerControls => {
if (overrides == null) return { ...defaults };
const result: BannerControls = { ...defaults };
const keys: (keyof BannerControls)[] = ['accept', 'decline', 'moreInfo', 'dismiss', 'swap'];
for (const key of keys) {
const ov = overrides[key];
if (ov === null) {
// explicit disable
result[key] = null;
continue;
}
if (ov === undefined) continue; // keep default
if (key === 'swap') {
result.swap = ov as boolean;
continue;
}
// shallow merge individual control fields
result[key] = {
...(defaults[key] ?? {}),
...(ov as any)
} as any;
}
return result;
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import Link from './Link.svelte';
import Button from './Button.svelte';
import FramelessButton from './FramelessButton.svelte';
import { X } from 'phosphor-svelte';
/**
* Banner provides a simple component to display a banner message with
* additional tooling for easy control integration. Controls tools are
* geared toward cookie consent, announcements, and similar use cases.
*/
interface Props {
title?: string;
open?: boolean;
disabled?: boolean;
frozen?: boolean;
position?: 'top' | 'bottom';
controls?: BannerControls | null;
/** if true, frameless buttons and links are inverted (default: true) */
invertFrameless?: boolean;
class?: ClassValue | null;
onaccept?: (banner: BannerAPI) => void;
ondecline?: (banner: BannerAPI) => void;
children?: Snippet;
}
let {
title,
open = $bindable(false),
disabled = $bindable(false),
frozen = $bindable(false),
position,
controls: rawControls = defaultBannerControls,
invertFrameless = true,
class: classValue,
onaccept,
ondecline,
children
}: Props = $props();
const controls = $derived(mergeBannerControls(defaultBannerControls, rawControls));
const api: BannerAPI = {
open: () => (open = true),
close: () => (open = false),
isOpen: () => open,
disable: () => (disabled = true),
enable: () => (disabled = false),
isDisabled: () => disabled,
freeze: () => (frozen = true),
unfreeze: () => (frozen = false),
isFrozen: () => frozen
};
const handleAccept = () => {
controls.accept?.action?.(api);
onaccept?.(api);
};
const handleDecline = () => {
controls.decline?.action?.(api);
ondecline?.(api);
};
</script>
{#if open}
<div
class={[
'fixed left-0 z-50 w-screen px-8 py-6',
position === 'top' ? 'top-0' : 'bottom-0',
'bg-sui-secondary-800 text-sui-background',
classValue
]}
>
{#if title || controls?.dismiss}
<div class="mb-2 flex items-center justify-between gap-4">
{#if title}
<h3 class="mb-2 text-lg font-semibold">{title}</h3>
{/if}
{#if controls?.dismiss}
<FramelessButton
inverted={invertFrameless}
onclick={() => controls.dismiss?.action?.(api)}
class="ml-auto"
>
{#if controls.dismiss?.label}
{controls.dismiss.label}
{/if}
<X class="mt-0.5" size="1.2rem" weight="bold" />
</FramelessButton>
{/if}
</div>
{/if}
{@render children?.()}
{#if controls !== null}
<div
class={['mt-4 flex flex-wrap justify-between gap-4', controls.swap && 'flex-row-reverse']}
>
<!-- More info button/link -->
{#if controls.moreInfo}
{#if controls.moreInfo.type === 'link' && controls.moreInfo.href}
<Link
href={controls.moreInfo.href}
class={controls.moreInfo.class}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</Link>
{:else if controls.moreInfo.type === 'framed'}
<Button
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
>
{controls.moreInfo.label || 'More Info'}
</Button>
{:else}
<FramelessButton
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</FramelessButton>
{/if}
{/if}
<div
class={['flex justify-end gap-4', controls.swap ? 'mr-auto flex-row-reverse' : 'ml-auto']}
>
<!-- Decline button -->
{@render buttonControl(controls.decline, handleDecline, false)}
<!-- Accept button -->
{@render buttonControl(controls.accept, handleAccept, true)}
</div>
</div>
{/if}
</div>
{/if}
{#snippet buttonControl(
button: BannerControlButton | undefined | null,
handleClick: (banner: BannerAPI) => void,
framedDefault: boolean
)}
{#if button}
{#if button.framed || framedDefault}
<Button class={button.class} onclick={() => handleClick(api)}>
{button.label || 'Button'}
</Button>
{:else}
<FramelessButton
class={button.class}
onclick={() => handleClick(api)}
inverted={invertFrameless}
>
{button.label || 'Button'}
</FramelessButton>
{/if}
{/if}
{/snippet}

View File

@@ -5,14 +5,15 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { validate } from '@svelte-toolkit/validate'; import { validate } from '@svelte-toolkit/validate';
import { generateIdentifier } from './util'; import { generateIdentifier, resolveGetter, type MaybeGetter } from './util';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { Check, Minus } from 'phosphor-svelte'; import { Check, Minus } from 'phosphor-svelte';
interface Props { interface Props {
name?: string; name?: string;
required?: boolean; required?: boolean;
value?: CheckboxState; disabled?: boolean;
value?: MaybeGetter<CheckboxState>;
color?: 'default' | 'contrast'; color?: 'default' | 'contrast';
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children?: Snippet; children?: Snippet;
@@ -22,6 +23,7 @@
let { let {
name, name,
required = false, required = false,
disabled = false,
value = $bindable(), value = $bindable(),
color = 'contrast', color = 'contrast',
class: classValue, class: classValue,
@@ -32,17 +34,19 @@
let id = $derived(generateIdentifier('checkbox', name)); let id = $derived(generateIdentifier('checkbox', name));
let valid = $state(true); let valid = $state(true);
let Icon = $derived(value === 'indeterminate' ? Minus : value ? Check : undefined); let Icon = $derived(
resolveGetter(value) === 'indeterminate' ? Minus : resolveGetter(value) ? Check : undefined
);
</script> </script>
<div class={['flex items-center', classValue]}> <div class={['flex items-center', classValue]}>
<input <input
type="hidden" type="hidden"
{name} {name}
value={value?.toString() ?? 'false'} value={resolveGetter(value)?.toString() ?? 'false'}
use:validate={{ use:validate={{
valfunc: () => { valfunc: () => {
if (required && value !== true) { if (required && resolveGetter(value) !== true) {
return false; return false;
} }
return true; return true;
@@ -56,15 +60,18 @@
<button <button
type="button" type="button"
{id} {id}
{disabled}
class={[ class={[
'text-sui-text flex size-7 shrink-0 appearance-none items-center', 'text-sui-text flex size-7 shrink-0 appearance-none items-center',
'justify-center rounded-lg shadow transition-all hover:opacity-75', 'justify-center rounded-lg shadow transition-all',
color === 'default' && 'bg-white', color === 'default' && 'bg-white',
color === 'contrast' && 'border-text/40 border bg-white', color === 'contrast' && 'border-text/40 border bg-white',
!valid && 'border border-red-500' !valid && 'border border-red-500',
disabled ? 'cursor-not-allowed opacity-50' : 'hover:opacity-75'
]} ]}
onclick={() => { onclick={() => {
if (value === false || value === undefined || value === 'indeterminate') { const resolved = resolveGetter(value);
if (resolved === false || resolved === undefined || resolved === 'indeterminate') {
value = true; value = true;
} else { } else {
value = false; value = false;

View File

@@ -16,7 +16,7 @@
interface Props { interface Props {
name?: string; name?: string;
value?: CalendarDate; value?: CalendarDate | null;
min?: CalendarDate; min?: CalendarDate;
// max?: CalendarDate; // TODO: Implement validation. // max?: CalendarDate; // TODO: Implement validation.
label?: string; label?: string;
@@ -24,11 +24,12 @@
invalidMessage?: string; invalidMessage?: string;
class?: ClassValue | undefined | null; class?: ClassValue | undefined | null;
format?: FormatString[]; format?: FormatString[];
onchange?: (date: CalendarDate | null) => void;
} }
let { let {
name, name,
value = $bindable<CalendarDate | undefined>(), value = $bindable<CalendarDate | null>(null),
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */ /** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
min = new CalendarDate(1900, 0, 1), min = new CalendarDate(1900, 0, 1),
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */ /** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
@@ -37,7 +38,8 @@
required = false, required = false,
invalidMessage = 'Valid date is required', invalidMessage = 'Valid date is required',
class: classValue, class: classValue,
format = ['year', 'month', 'day'] format = ['year', 'month', 'day'],
onchange
}: Props = $props(); }: Props = $props();
const id = $derived(generateIdentifier('dateinput', name)); const id = $derived(generateIdentifier('dateinput', name));
@@ -108,7 +110,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousYearValue = undefined; previousYearValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -121,7 +124,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousMonthValue = undefined; previousMonthValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -134,7 +138,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousDayValue = undefined; previousDayValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -146,6 +151,7 @@
day ?? (value ? value.day : min.day) day ?? (value ? value.day : min.day)
); );
value = newDate; value = newDate;
onchange?.(value);
}; };
type caretPos = { start: number; end: number } | null; type caretPos = { start: number; end: number } | null;

View File

@@ -145,7 +145,8 @@
{#snippet dialog()} {#snippet dialog()}
<div <div
class={[ class={[
'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm', 'fixed inset-0 z-50 flex items-center-safe justify-center bg-black/50 backdrop-blur-sm',
'overflow-auto p-8',
classValue classValue
]} ]}
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
@@ -165,7 +166,7 @@
<div <div
bind:this={dialogContainer} bind:this={dialogContainer}
class={[ class={[
'relative max-h-[85vh] w-[90vw] rounded-xl bg-white p-6 shadow-lg', 'relative w-[90vw] rounded-xl bg-white p-6 shadow-lg',
size === 'sm' && 'max-w-[450px]', size === 'sm' && 'max-w-[450px]',
size === 'md' && 'max-w-[650px]', size === 'md' && 'max-w-[650px]',
size === 'lg' && 'max-w-[850px]', size === 'lg' && 'max-w-[850px]',

View File

@@ -0,0 +1,295 @@
<script lang="ts" module>
/**
* formatDuration returns a human readable string for a TimeDuration.
*
* @param duration The duration to format, can be null or undefined.
* @param fallback Optional fallback string to return if duration is null or undefined.
* If not provided, an empty string will be returned.
* If provided, it will be returned when duration is null or undefined.
* @returns The duration in human readable format or the fallback string if undefined.
*/
export const formatDuration = (
duration: TimeDuration | null | undefined,
fallback?: string
): string => {
if (!duration) return fallback ?? '';
const parts: string[] = [];
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds) parts.push(`${duration.seconds}s`);
return parts.join(' ');
};
/**
* durationToISO8601 converts a TimeDuration to an ISO 8601 duration string.
* @param duration The duration to convert.
* @returns The ISO 8601 duration string.
*/
export const durationToISO8601 = (duration: TimeDuration): string => {
return serialize(duration);
};
/**
* iso8601ToDuration converts an ISO 8601 duration string to a TimeDuration.
* @param str The ISO 8601 duration string.
* @returns The TimeDuration object.
*/
export const iso8601ToDuration = (str: string): TimeDuration => {
return parse(str);
};
</script>
<script lang="ts">
import type { TimeDuration } from '@internationalized/date';
import type { ClassValue } from 'svelte/elements';
import { FocusManager } from './focus';
import { generateIdentifier, targetMust } from './util';
import { decrementValue, incrementValue } from './numeric-utils';
import Label from './Label.svelte';
import { liveValidator, validate } from '@svelte-toolkit/validate';
import StyledRawInput from './StyledRawInput.svelte';
import { parse, serialize } from 'tinyduration';
interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string;
/** Label for the input */
label?: string;
/** Precision for duration input */
precision?: { min: componentKey; max: componentKey };
/** Bindable TimeDuration value */
value?: TimeDuration | null;
/** Bindable formatted duration string, always matches current value and cannot be set */
formattedValue?: string;
/** Whether the input is required */
required?: boolean;
/** Message to show when the input is invalid */
invalidMessage?: string | null;
/** Whether to use compact styling */
compact?: boolean;
/** Additional classes are applied to the root container (div element) */
class?: ClassValue | null;
/** Triggered whenever the duration value is changed by the user */
onchange?: (details: { duration: TimeDuration | null; formattedDuration: string }) => void;
}
let {
name,
label,
precision = { max: 'minutes', min: 'hours' },
value = $bindable(null),
formattedValue = $bindable(''),
required = false,
invalidMessage = null,
compact,
class: classValue,
onchange
}: Props = $props();
const COMPONENT_KEYS = ['hours', 'minutes', 'seconds', 'milliseconds'] as const;
type componentKey = (typeof COMPONENT_KEYS)[number];
/** selected components are controlled by min and max precision */
let selectedComponents: componentKey[] = $derived.by(() => {
const minIndex = COMPONENT_KEYS.indexOf(precision.min);
const maxIndex = COMPONENT_KEYS.indexOf(precision.max);
if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
throw new Error('Invalid precision settings for DurationInput');
}
return COMPONENT_KEYS.slice(minIndex, maxIndex + 1);
});
const focusList = new FocusManager();
const numericPattern = /^\d*$/;
const keydownValidatorOpts = { constrain: true };
const id = $derived(generateIdentifier('duration-input', name));
const values: Record<componentKey, string> = $derived.by(() => {
return COMPONENT_KEYS.reduce(
(acc, key) => {
if (!value || value[key] === 0) {
acc[key] = '';
} else {
acc[key] = value[key]?.toString() || '0';
}
return acc;
},
{} as Record<componentKey, string>
);
});
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
/** updateValue updates `value` with the current values from the input fields */
const updateValue = (override?: Partial<Record<componentKey, string>>) => {
const newValues: Record<componentKey, number> = {
hours: parseInt(override?.hours ?? values.hours) || 0,
minutes: parseInt(override?.minutes ?? values.minutes) || 0,
seconds: parseInt(override?.seconds ?? values.seconds) || 0,
milliseconds: parseInt(override?.milliseconds ?? values.milliseconds) || 0
};
let zero = true;
for (const key of selectedComponents) {
if (newValues[key] !== 0) {
zero = false;
break;
}
}
if (zero) {
value = null;
} else if (!value) {
// Create a new value object if we don't have one yet
value = newValues;
} else {
// Otherwise apply all new values to existing value object
for (const [k, v] of Object.entries(newValues)) {
value[k as componentKey] = v;
}
}
updateHiddenInput();
// update formatted value
formattedValue = formatDuration(value);
};
/**
* updateHiddenInput pushes the current value to the hidden input in ISO
* 8601 duration format and triggers a keyup event to allow validation to
* detect the change.
*/
const updateHiddenInput = () => {
hiddenInput.value = value ? durationToISO8601(value) : '';
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
onchange?.({ duration: value, formattedDuration: formattedValue });
};
/**
* buildEventHandlers generates a fairly generic onkeydown and onblur
* handler pair for a given component key. Start is always 0, max is
* unset for all keys. No special handling per key.
*/
const buildEventHandlers = (key: componentKey) => {
return {
onkeydown: (e: KeyboardEvent) => {
const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') {
incrementValue(target, { start: 0 });
} else if (e.key === 'ArrowDown') {
decrementValue(target, { start: 0 });
} else {
return;
}
updateValue({ [key]: target.value });
e.preventDefault();
},
onblur: (e: FocusEvent) => {
const target = targetMust<HTMLInputElement>(e);
updateValue({ [key]: target.value });
}
};
};
/** component definitions */
const components: Record<
componentKey,
{
label?: string;
pattern: RegExp;
onkeydown?: (e: KeyboardEvent) => void;
oninput?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
divider?: string;
placeholder?: string;
}
> = {
hours: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('hours')
},
minutes: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('minutes')
},
seconds: {
pattern: numericPattern,
divider: '.',
placeholder: '0',
...buildEventHandlers('seconds')
},
milliseconds: {
label: 'MS',
pattern: numericPattern,
placeholder: '000',
...buildEventHandlers('milliseconds')
}
};
</script>
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores the selected duration in ISO 8601 format -->
<input
type="hidden"
{id}
{name}
use:validate={{ required, autovalOnInvalid: true }}
onvalidate={(e) => (valid = e.detail.valid)}
bind:this={hiddenInput}
/>
<div class="flex items-start">
{#each selectedComponents as componentKey[] as key, index}
{@const opts = components[key]}
{@const partID = generateIdentifier('duration-input-part', key)}
<div class="flex flex-col items-center">
<StyledRawInput
id={partID}
class={[
'text-center text-xl focus:placeholder:text-transparent',
compact ? 'h-9 w-16! placeholder:text-base' : 'h-16 w-24!'
]}
value={values[key]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={opts.placeholder}
validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)}
forceInvalid={!valid}
onkeydown={opts.onkeydown}
oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input({ selectAll: true })}
/>
<Label for={partID} class={['capitalize', compact && '-mt-0.5']}>{opts.label ?? key}</Label>
</div>
{#if opts.divider && index < selectedComponents.length - 1}
<span class={[compact ? 'mx-1 text-2xl' : 'mx-2 mt-3 text-3xl']}>
{opts.divider}
</span>
{/if}
{/each}
</div>
{#if !valid && invalidMessage}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={id} error={true}>
{invalidMessage}
</Label>
</div>
{/if}
</div>

View File

@@ -7,6 +7,7 @@
icon?: IconDef; icon?: IconDef;
iconPosition?: 'left' | 'right'; iconPosition?: 'left' | 'right';
disabled?: boolean; disabled?: boolean;
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
@@ -16,6 +17,7 @@
icon, icon,
iconPosition = 'right', iconPosition = 'right',
disabled = false, disabled = false,
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
@@ -31,8 +33,11 @@
<button <button
type="button" type="button"
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex cursor-pointer items-center gap-1.5 transition-colors', 'inline-flex cursor-pointer items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
{onclick} {onclick}

View File

@@ -9,6 +9,6 @@
let { class: classList, children }: Props = $props(); let { class: classList, children }: Props = $props();
</script> </script>
<div class={['mt-4 flex min-w-80 items-center justify-start gap-2', classList]}> <div class={['mt-4 flex min-w-80 flex-wrap items-center justify-start gap-2', classList]}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,32 +1,46 @@
<script lang="ts" module> <script lang="ts" module>
import { env } from '$env/dynamic/public'; /**
* Rewrites the href based on a given basepath.
* If the href is absolute, it is returned as is.
* If the href is relative, the basepath is prepended.
* @param href The original href.
* @returns The rewritten href.
*/
export const rewriteHref = (href: string, basepath?: string | null): string => {
// If no base path is set, return the href as is
if (!basepath) return href;
const { PUBLIC_BASEPATH } = env; // Use URL API to determine if href is relative or absolute
try {
const trim = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => { // this will only succeed if href is absolute
let start = 0, const independentUrl = new URL(href);
end = str.length; return independentUrl.toString();
} catch {
if (trimStart || trimStart === undefined) { // now we can assume that href is relative or entirely invalid
while (start < end && str[start] === char) start++; // test with a generic baseURI to see if it's valid relative
try {
const relativeUrl = new URL(href, 'http://example.com');
// if we reach here, it's a valid relative URL
const prefix = trimEdges(basepath, '/');
return `/${prefix}/${trimEdges(relativeUrl.pathname, '/', true, false)}`;
} catch {
throw new Error(`Attempted to rewrite invalid href: ${href}`);
} }
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
} }
return str.substring(start, end);
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue, MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import { trimEdges } from './util';
interface Props { interface Props {
href: string; href: string;
basepath?: string | null;
disabled?: boolean; disabled?: boolean;
tab?: 'current' | 'new'; tab?: 'current' | 'new';
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>; onclick?: MouseEventHandler<HTMLAnchorElement>;
@@ -34,26 +48,28 @@
let { let {
href, href,
basepath,
disabled = false, disabled = false,
tab = 'current', tab = 'current',
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
}: Props = $props(); }: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) { const computedHref = $derived(rewriteHref(href, basepath));
let prefix = trim(PUBLIC_BASEPATH, '/');
href = `/${prefix}/${trim(href, '/', true, false)}`;
}
</script> </script>
<a <a
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex items-center gap-1.5 transition-colors', 'inline-flex items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
{href} href={computedHref}
target={tab === 'new' ? '_blank' : undefined} target={tab === 'new' ? '_blank' : undefined}
rel={tab === 'new' ? 'noopener noreferrer' : undefined} rel={tab === 'new' ? 'noopener noreferrer' : undefined}
{onclick} {onclick}

View File

@@ -16,6 +16,8 @@
compact?: boolean; compact?: boolean;
use?: (node: HTMLInputElement) => void; use?: (node: HTMLInputElement) => void;
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
/** Forces the input to be visually marked as invalid */
forceInvalid?: boolean;
onvalidate?: (e: InputValidatorEvent) => void; onvalidate?: (e: InputValidatorEvent) => void;
}; };
@@ -30,6 +32,7 @@
compact = false, compact = false,
use, use,
ref = $bindable<HTMLInputElement | null>(null), ref = $bindable<HTMLInputElement | null>(null),
forceInvalid = false,
onvalidate, onvalidate,
class: classValue, class: classValue,
...others ...others
@@ -71,7 +74,7 @@
'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800', 'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800',
'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2', 'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2',
!compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2', !compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2',
!valid && 'border-red-500!', (!valid || forceInvalid) && 'border-red-500!',
disabled && disabled &&
'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed', 'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed',
classValue classValue

View File

@@ -23,17 +23,31 @@
import { generateIdentifier, prefixZero, targetMust } from './util'; import { generateIdentifier, prefixZero, targetMust } from './util';
import { FocusManager } from './focus'; import { FocusManager } from './focus';
import { Time } from '@internationalized/date'; import { Time } from '@internationalized/date';
import { incrementValue, decrementValue } from './numeric-utils';
interface Props { interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string; name?: string;
/** Label for the input */
label?: string; label?: string;
/** Bindable Time value */
value?: Time | null; value?: Time | null;
/** Bindable formatted time string, always matches current value and cannot be set */
formattedValue?: string; formattedValue?: string;
/** Whether the input is required */
required?: boolean; required?: boolean;
/** Message to show when the input is invalid */
invalidMessage?: string; invalidMessage?: string;
/** Controls visibility for a confirmation text below the input */
showConfirm?: boolean; showConfirm?: boolean;
/** Whether to use compact styling */
compact?: boolean; compact?: boolean;
/** Class is applied to the root container (div element) */
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
/** Triggered whenever the time value is changed by the user */
onchange?: (details: { time: Time | null; formattedTime: string }) => void; onchange?: (details: { time: Time | null; formattedTime: string }) => void;
} }
@@ -95,55 +109,6 @@
} }
}; };
/**
* incrementValue increments the value of the input by 1
* @param input The input element to increment
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was incremented, false if it looped back to 0
*/
const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
if (input.value.length === 0) {
input.value = start.toString();
return true;
}
const value = parseInt(input.value);
if (value === max) {
input.value = start.toString();
return false;
} else if (value > max) {
input.value = (value - max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
/**
* decrementValue decrements the value of the input by 1
* @param input The input element to decrement
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was decremented, false if it looped back to max
*/
const decrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
if (input.value.length === 0) {
input.value = max.toString();
return true;
}
const value = parseInt(input.value);
if (value <= start) {
input.value = max.toString();
return false;
} else {
input.value = (value - 1).toString();
return true;
}
};
/** /**
* updateValue updates `value` with the current time in 24-hour format. * updateValue updates `value` with the current time in 24-hour format.
* If any component is invalid or blank, it sets `value` to an empty string. * If any component is invalid or blank, it sets `value` to an empty string.
@@ -216,10 +181,10 @@
} }
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
incrementValue(target, 12, 1); incrementValue(target, { max: 12, start: 1 });
if (target.value === '12') toggleAMPM(); if (target.value === '12') toggleAMPM();
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
decrementValue(target, 12, 1); decrementValue(target, { max: 12, start: 1 });
if (target.value === '11') toggleAMPM(); if (target.value === '11') toggleAMPM();
} else { } else {
return; return;
@@ -248,9 +213,9 @@
const target = targetMust<HTMLInputElement>(e); const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
incrementValue(target, 59, 0); incrementValue(target, { max: 59, start: 0 });
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
decrementValue(target, 59, 0); decrementValue(target, { max: 59, start: 0 });
} else { } else {
return; return;
} }
@@ -301,6 +266,7 @@
min={0} min={0}
validate={{ pattern: opts.pattern }} validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)} use={(n) => liveValidator(n, keydownValidatorOpts)}
forceInvalid={!valid}
onkeydown={opts.onkeydown} onkeydown={opts.onkeydown}
oninput={opts.oninput} oninput={opts.oninput}
onblur={opts.onblur} onblur={opts.onblur}

View File

@@ -1,18 +1,25 @@
// Reexport your entry components here // Reexport your entry components here
export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte'; export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte';
export { default as Banner, type BannerControls, type BannerAPI } from './Banner.svelte';
export { default as Button } from './Button.svelte'; export { default as Button } from './Button.svelte';
export { default as CenterBox } from './CenterBox.svelte'; export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte'; export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte'; export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.svelte'; export { default as DateInput } from './DateInput.svelte';
export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte'; export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte';
export {
default as DurationInput,
formatDuration,
durationToISO8601,
iso8601ToDuration
} from './DurationInput.svelte';
export { default as ErrorBox } from './ErrorBox.svelte'; export { default as ErrorBox } from './ErrorBox.svelte';
export { default as FramelessButton } from './FramelessButton.svelte'; export { default as FramelessButton } from './FramelessButton.svelte';
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte'; export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
export { default as InjectUmami } from './InjectUmami.svelte'; export { default as InjectUmami } from './InjectUmami.svelte';
export { default as InputGroup } from './InputGroup.svelte'; export { default as InputGroup } from './InputGroup.svelte';
export { default as Label } from './Label.svelte'; export { default as Label } from './Label.svelte';
export { default as Link } 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 { default as RadioGroup } from './RadioGroup.svelte'; export { default as RadioGroup } from './RadioGroup.svelte';
@@ -41,7 +48,8 @@ export {
getValue, getValue,
targetMust, targetMust,
capitalizeFirstLetter, capitalizeFirstLetter,
prefixZero prefixZero,
trimEdges
} from './util'; } from './util';
export { export {
type ToolbarToggleState, type ToolbarToggleState,

67
src/lib/numeric-utils.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* numeric-utils.ts
* Utility functions for numeric input manipulation.
*/
/**
* incrementValue increments the value of the input by 1
* @param input The input element to increment
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was incremented, false if it looped back to 0
*/
export const incrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
if (input.value.length === 0) {
input.value = opts.start.toString();
return true;
}
const value = parseInt(input.value);
if (value === opts.max) {
input.value = opts.start.toString();
return false;
} else if (opts.max && value > opts.max) {
input.value = (value - opts.max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
/**
* decrementValue decrements the value of the input by 1
* @param input The input element to decrement
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was decremented, false if it looped back to max
*/
export const decrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
const setToMax = (): boolean => {
if (opts.max) {
input.value = opts.max.toString();
return true;
} else {
input.value = '0';
return false;
}
};
if (input.value.length === 0) {
return setToMax();
}
const value = parseInt(input.value);
if (value <= opts.start) {
return !setToMax();
} else {
input.value = (value - 1).toString();
return true;
}
};

View File

@@ -110,13 +110,36 @@ export const capitalizeFirstLetter = (str: string): string => {
}; };
/** /**
* prefixZero adds a leading zero to the string if it is less than 10 * prefixZero adds a leading zero to the string if it is less than 10 and not 0
* @param str The string to prefix * @param str The string to prefix
* @returns The string with a leading zero if it was only 1 digit long * @returns The string with a leading zero if it was only 1 digit long
*/ */
export const prefixZero = (str: string): string => { export const prefixZero = (str: string): string => {
if (str.length === 1) { if (str.length === 1 && str !== '0') {
return '0' + str; return '0' + str;
} }
return 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);
};

View File

@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date'; import {
CalendarDate,
today,
getLocalTimeZone,
type TimeDuration
} from '@internationalized/date';
import Button from '$lib/Button.svelte'; import Button from '$lib/Button.svelte';
import ActionSelect from '$lib/ActionSelect.svelte'; import ActionSelect from '$lib/ActionSelect.svelte';
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte'; import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
@@ -33,6 +38,8 @@
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import ErrorBox from '$lib/ErrorBox.svelte'; import ErrorBox from '$lib/ErrorBox.svelte';
import TextareaInput from '$lib/TextareaInput.svelte'; import TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
import Banner from '$lib/Banner.svelte';
// Lazy-load heavy components // Lazy-load heavy components
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte')); let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
@@ -50,15 +57,17 @@
{ value: 'option3', label: 'Option 3', disabled: true } { value: 'option3', label: 'Option 3', disabled: true }
]; ];
let lazyOptions: ComboboxOption[] = $state([]); let lazyOptions: ComboboxOption[] = $state([]);
let dateInputValue = $state<CalendarDate | undefined>(undefined); let dateInputValue = $state<CalendarDate | null>(null);
let checkboxValue = $state<CheckboxState>('indeterminate'); let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false); let dialogOpen = $state(false);
let scrollableDialogOpen = $state(false);
let toggleOptions: Option[] = $state([ let toggleOptions: Option[] = $state([
'item one', 'item one',
'item two', 'item two',
{ value: 'complex', label: 'Complex item' } { value: 'complex', label: 'Complex item' }
]); ]);
let timeValue = $state<Time | null>(null); let timeValue = $state<Time | null>(null);
let durationValue = $state<TimeDuration | null>(null);
const toolbar = new Toolbar(); const toolbar = new Toolbar();
const fontGroup = toolbar.group(); const fontGroup = toolbar.group();
@@ -66,7 +75,25 @@
const boldStore = boldToggle.store; const boldStore = boldToggle.store;
</script> </script>
<svelte:head>
<title>sui</title> <title>sui</title>
</svelte:head>
<!-- Cookie Consent Banner Demo -->
<Banner
title="Manage Cookies"
controls={{
moreInfo: { label: 'More Info', type: 'link', href: '#!' },
dismiss: null,
swap: true
}}
onaccept={() => console.log('Cookies accepted!')}
ondecline={() => console.log('Cookies declined!')}
open
>
We use cookies and similar technologies to enhance your experience, analyze site traffic, and
measure our ads. You can manage your preferences anytime.
</Banner>
<h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1> <h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1>
@@ -92,6 +119,7 @@
<Link href="https://svelte.dev">Visit Svelte</Link> <Link href="https://svelte.dev">Visit Svelte</Link>
<Button onclick={() => (dialogOpen = true)}>Open Dialog</Button> <Button onclick={() => (dialogOpen = true)}>Open Dialog</Button>
<Button onclick={() => (scrollableDialogOpen = true)}>Open Scrollable Dialog</Button>
</div> </div>
</div> </div>
@@ -284,11 +312,18 @@
</div> </div>
<div class="component"> <div class="component">
<p class="title">Time Input</p> <p class="title">Time & Duration Input</p>
<InputGroup class="gap-8"> <InputGroup class="gap-8">
<TimeInput label="Regular time input" name="example-time-input" /> <TimeInput label="Regular time input" name="example-time-input" />
<TimeInput label="Compact time" compact bind:value={timeValue} /> <TimeInput label="Compact time" compact bind:value={timeValue} />
<DurationInput
label="Duration input"
name="example-duration-input"
precision={{ min: 'hours', max: 'seconds' }}
bind:value={durationValue}
/>
<DurationInput label="Compact duration" compact />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p> <p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p>
@@ -299,6 +334,7 @@
> >
Set 3:00 PM Set 3:00 PM
</Button> </Button>
<p>Precise duration is {formatDuration(durationValue)}</p>
</InputGroup> </InputGroup>
</div> </div>
@@ -459,6 +495,24 @@
{/snippet} {/snippet}
</div> </div>
<!-- Link with href rewriting -->
<div class="component">
<p class="title">Link (with href rewriting)</p>
<p class="mb-3">
href rewriting allows you to prepend a basepath to relative links, making it easier to manage
URLs in your application. It is recommended to wrap this element with your own, e.g. AppLink,
that automatically provides the basepath from your app's configuration.
</p>
<div class="flex flex-col gap-3">
<Link href="/about" basepath="/sui-demo">Go to About Page (with basepath)</Link>
<Link href="https://svelte.dev" basepath="/sui-demo">External Svelte Site</Link>
<Link href="contact">Contact Us (relative link, no basepath)</Link>
</div>
</div>
<!-- Regular Dialog Demo -->
<Dialog <Dialog
bind:open={dialogOpen} bind:open={dialogOpen}
title="Dialog Title" title="Dialog Title"
@@ -482,6 +536,40 @@
<p>This is a dialog content area.</p> <p>This is a dialog content area.</p>
</Dialog> </Dialog>
<!-- Scrollable Dialog Demo -->
<Dialog bind:open={scrollableDialogOpen} title="Scrollable Dialog" size="sm">
<div class="space-y-4">
<p>
Ullamco nulla sunt laboris esse commodo irure id pariatur est irure eiusmod. Cupidatat Lorem
ad deserunt non culpa aliqua qui qui ut reprehenderit minim consequat amet. Qui elit ipsum
dolor enim laboris. Exercitation sint esse dolore enim irure veniam esse incididunt fugiat.
</p>
<p>
In elit tempor quis enim id fugiat cillum consectetur minim sint ex. Minim reprehenderit culpa
sunt in reprehenderit. Amet in minim in nulla officia fugiat laborum velit dolor laborum
deserunt aliqua nostrud.
</p>
<p>
Ad dolor ad nisi est fugiat anim aute amet. Fugiat excepteur proident incididunt anim sunt.
Proident quis dolor ea voluptate esse commodo voluptate quis culpa cupidatat excepteur.
</p>
<p>
Cillum ut laboris laboris ea ex ex. Aliquip magna irure eiusmod qui eiusmod. Mollit id et
incididunt sint mollit anim cillum reprehenderit exercitation labore incididunt culpa. Officia
et ad occaecat quis ipsum. Culpa quis cupidatat reprehenderit reprehenderit incididunt
excepteur quis minim. Laboris cupidatat laborum est ipsum esse sint aliqua cillum laborum est
cillum dolore cupidatat pariatur. Dolor ipsum cillum enim esse consectetur dolor sunt magna.
</p>
<p>
Eu cillum reprehenderit Lorem duis sunt. Mollit laborum tempor magna dolor ad ipsum do fugiat
nisi quis culpa tempor veniam officia. Voluptate irure labore aliqua elit officia nulla dolor.
Lorem duis ea ea commodo deserunt minim enim. Excepteur non magna cupidatat ea eiusmod dolore
elit dolor veniam cupidatat. Amet voluptate culpa ut ex consequat culpa cillum. Exercitation
ex voluptate incididunt laboris qui sint id quis in aliqua excepteur incididunt.
</p>
</div>
</Dialog>
<style lang="postcss"> <style lang="postcss">
@reference '$lib/styles/tailwind.css'; @reference '$lib/styles/tailwind.css';