finish custom class and improved options overhaul

This commit is contained in:
Elijah Duffy
2025-07-03 14:45:23 -07:00
parent c54002f5fa
commit 02311a0e7b
16 changed files with 170 additions and 131 deletions

View File

@@ -1,21 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
let { interface Props {
icon,
animate = true,
loading,
children,
onclick
}: {
icon?: string; icon?: string;
animate?: boolean; animate?: boolean;
loading?: boolean; loading?: boolean;
class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
} = $props(); }
let { icon, animate = true, loading, class: classValue, children, onclick }: Props = $props();
let iconElement = $state<HTMLSpanElement | null>(null); let iconElement = $state<HTMLSpanElement | null>(null);
@@ -68,7 +65,8 @@
class={[ class={[
'button group relative flex gap-3 overflow-hidden rounded-sm px-5', 'button group relative flex gap-3 overflow-hidden rounded-sm px-5',
'text-background cursor-pointer py-3 font-medium transition-colors', 'text-background cursor-pointer py-3 font-medium transition-colors',
!loading ? ' bg-primary hover:bg-secondary' : 'bg-primary/50 cursor-not-allowed ' !loading ? ' bg-primary hover:bg-secondary' : 'bg-primary/50 cursor-not-allowed ',
classValue
]} ]}
onclick={handleButtonClick} onclick={handleButtonClick}
> >

View File

@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
let { children }: { children: Snippet } = $props(); interface Props {
class?: ClassValue | null | undefined;
children: Snippet;
}
let { class: classValue, children }: Props = $props();
</script> </script>
<div class="min-h-screen items-center justify-center sm:flex"> <div class={['min-h-screen items-center justify-center sm:flex', classValue]}>
<main class="m-4 max-w-(--breakpoint-sm) sm:mb-[5%] sm:w-full lg:max-w-[950px]"> <main class="m-4 max-w-(--breakpoint-sm) sm:mb-[5%] sm:w-full lg:max-w-[950px]">
{@render children()} {@render children()}
</main> </main>

View File

@@ -6,12 +6,14 @@
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.js'; import { generateIdentifier } from './util.js';
import type { ClassValue } from 'svelte/elements';
interface Props { interface Props {
name?: string; name?: string;
required?: boolean; required?: boolean;
value?: CheckboxState; value?: CheckboxState;
color?: 'default' | 'contrast'; color?: 'default' | 'contrast';
class?: ClassValue | null | undefined;
children?: Snippet; children?: Snippet;
onchange: (value: CheckboxState) => void; onchange: (value: CheckboxState) => void;
} }
@@ -21,6 +23,7 @@
required = false, required = false,
value = $bindable(false), value = $bindable(false),
color = 'contrast', color = 'contrast',
class: classValue,
children, children,
onchange onchange
}: Props = $props(); }: Props = $props();
@@ -30,7 +33,7 @@
let valid = $state(true); let valid = $state(true);
</script> </script>
<div class="flex items-center"> <div class={['flex items-center', classValue]}>
<input <input
type="hidden" type="hidden"
{name} {name}

View File

@@ -29,6 +29,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import { generateIdentifier } from './util.js'; import { generateIdentifier } from './util.js';
import type { ClassValue } from 'svelte/elements';
interface Props { interface Props {
name?: string; name?: string;
@@ -42,6 +43,7 @@
label?: string; label?: string;
placeholder?: string; placeholder?: string;
notFoundMessage?: string; notFoundMessage?: string;
class?: ClassValue | null | undefined;
onvalidate?: (e: InputValidatorEvent) => void; onvalidate?: (e: InputValidatorEvent) => void;
onchange?: (e: ComboboxChangeEvent) => void; onchange?: (e: ComboboxChangeEvent) => void;
} }
@@ -54,10 +56,11 @@
matchWidth = false, matchWidth = false,
options, options,
required = false, required = false,
invalidMessage, invalidMessage = 'Please select an option',
label, label,
placeholder, placeholder,
notFoundMessage = 'No results found', notFoundMessage = 'No results found',
class: classValue,
onvalidate, onvalidate,
onchange onchange
}: Props = $props(); }: Props = $props();
@@ -324,7 +327,7 @@
{/if} {/if}
</Portal> </Portal>
<div> <div class={classValue}>
<!-- Combobox Label --> <!-- Combobox Label -->
{#if label} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
@@ -335,7 +338,7 @@
{name} {name}
{id} {id}
value={value?.value ?? ''} value={value?.value ?? ''}
class="hidden" type="hidden"
use:validate={validateOpts} use:validate={validateOpts}
onvalidate={(e) => { onvalidate={(e) => {
valid = e.detail.valid; valid = e.detail.valid;
@@ -349,11 +352,9 @@
</div> </div>
<!-- Error message if invalid --> <!-- Error message if invalid -->
{#if invalidMessage}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}> <div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={id} error>{invalidMessage}</Label> <Label for={id} error>{invalidMessage}</Label>
</div> </div>
{/if}
</div> </div>
{#snippet searchInputBox(caret: boolean = true)} {#snippet searchInputBox(caret: boolean = true)}

View File

@@ -35,7 +35,7 @@
label, label,
required = false, required = false,
invalidMessage = 'Valid date is required', invalidMessage = 'Valid date is required',
class: classList, class: classValue,
format = ['year', 'month', 'day'] format = ['year', 'month', 'day']
}: Props = $props(); }: Props = $props();
@@ -200,7 +200,7 @@
}; };
</script> </script>
<div> <div class={classValue}>
<!-- Date input Label --> <!-- Date input Label -->
{#if label} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
@@ -213,8 +213,7 @@
'border-accent rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors', 'border-accent rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors',
'text-text dark:border-accent/50 dark:bg-text-800 dark:text-background dark:sm:bg-slate-800', 'text-text dark:border-accent/50 dark:bg-text-800 dark:text-background dark:sm:bg-slate-800',
'ring-accent focus-within:ring-1', 'ring-accent focus-within:ring-1',
!valid && 'border-red-500!', !valid && 'border-red-500!'
classList
]} ]}
> >
<input <input

View File

@@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
interface Props {
icon?: string;
iconPosition?: 'left' | 'right';
disabled?: boolean;
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
}
let { let {
icon, icon,
iconPosition = 'right', iconPosition = 'right',
disabled = false, disabled = false,
class: classValue,
children, children,
onclick onclick
}: { }: Props = $props();
icon?: string;
iconPosition?: 'left' | 'right';
disabled?: boolean;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
} = $props();
</script> </script>
{#snippet iconSnippet()} {#snippet iconSnippet()}
@@ -24,7 +28,8 @@
<button <button
class={[ class={[
'text-accent hover:text-primary inline-flex cursor-pointer items-center gap-1.5 transition-colors', 'text-accent hover:text-primary 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',
classValue
]} ]}
{onclick} {onclick}
> >

View File

@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
let { interface Props {
for: target, for: string;
error, error?: boolean;
bigError, bigError?: boolean;
children class?: ClassValue | null | undefined;
}: { for: string; error?: boolean; bigError?: boolean; children: Snippet } = $props(); children: Snippet;
}
let { for: target, error, bigError, class: classValue, children }: Props = $props();
</script> </script>
<label <label
@@ -16,7 +20,8 @@
error && !bigError error && !bigError
? 'mt-1 text-sm font-normal text-red-500' ? 'mt-1 text-sm font-normal text-red-500'
: 'text-text dark:text-background mb-2 text-base font-medium', : 'text-text dark:text-background mb-2 text-base font-medium',
bigError && 'text-red-500!' bigError && 'text-red-500!',
classValue
]} ]}
> >
{@render children()} {@render children()}

View File

@@ -21,21 +21,25 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
interface Props {
href: string;
disabled?: boolean;
tab?: 'current' | 'new';
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>;
}
let { let {
href, href,
disabled = false, disabled = false,
tab = 'current', tab = 'current',
class: classValue,
children, children,
onclick onclick
}: { }: Props = $props();
href: string;
disabled?: boolean;
tab?: 'current' | 'new';
children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>;
} = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) { if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) {
let prefix = trim(PUBLIC_BASEPATH, '/'); let prefix = trim(PUBLIC_BASEPATH, '/');
@@ -46,7 +50,8 @@
<a <a
class={[ class={[
'text-accent hover:text-primary inline-flex items-center gap-1.5 transition-colors', 'text-accent hover:text-primary 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',
classValue
]} ]}
{href} {href}
target={tab === 'new' ? '_blank' : undefined} target={tab === 'new' ? '_blank' : undefined}

View File

@@ -1,39 +1,13 @@
<script lang="ts" module>
export type RadioGroupOption =
| {
value: string;
label?: string;
}
| string;
/** getLabel returns the option label if it exists*/
const getLabel = (option: RadioGroupOption): string => {
if (typeof option === 'string') {
return option;
} else {
return option.label ?? option.value;
}
};
/** getValue returns the option value */
const getValue = (option: RadioGroupOption): string => {
if (typeof option === 'string') {
return option;
} else {
return option.value;
}
};
</script>
<script lang="ts"> <script lang="ts">
import { RadioGroup, type RadioGroupProps } from 'melt/builders'; import { RadioGroup, type RadioGroupProps } from 'melt/builders';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import Label from './Label.svelte'; import Label from './Label.svelte';
import { validate } from '@svelte-toolkit/validate'; import { validate } from '@svelte-toolkit/validate';
import { getLabel, getValue, type Option } from './util.js';
interface Props extends RadioGroupProps { interface Props extends RadioGroupProps {
options: RadioGroupOption[]; options: Option[];
label?: string; label?: string;
required?: boolean; required?: boolean;
invalidMessage?: string; invalidMessage?: string;

View File

@@ -28,13 +28,13 @@
validate: validateOpts, validate: validateOpts,
invalidMessage = 'Field is required', invalidMessage = 'Field is required',
ref = $bindable<HTMLInputElement | null>(null), ref = $bindable<HTMLInputElement | null>(null),
class: classList class: classValue
}: Props = $props(); }: Props = $props();
let valid: boolean = $state(true); let valid: boolean = $state(true);
</script> </script>
<div> <div class={classValue}>
{#if label} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
{/if} {/if}
@@ -50,7 +50,6 @@
onvalidate={(e) => { onvalidate={(e) => {
valid = e.detail.valid; valid = e.detail.valid;
}} }}
class={classList}
/> />
{#if validateOpts} {#if validateOpts}

View File

@@ -18,21 +18,27 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte'; import type { ClassValue } from 'svelte/elements';
import Combobox, { type ComboboxItem } from './Combobox.svelte';
interface Props {
label?: string;
name?: string;
value?: string;
invalidMessage?: string;
required?: boolean;
class?: ClassValue | null | undefined;
}
let { let {
label, label,
name, name,
value = $bindable(''), value = $bindable(''),
invalidMessage, invalidMessage = 'Please select a timezone',
required required,
}: { class: classValue
label?: string; }: Props = $props();
name: string;
value?: string;
invalidMessage?: string;
required?: boolean;
} = $props();
const sortedTimeZones = Intl.supportedValuesOf('timeZone') const sortedTimeZones = Intl.supportedValuesOf('timeZone')
.map((timeZone) => { .map((timeZone) => {
@@ -88,7 +94,7 @@
}); });
</script> </script>
<ExpandableCombobox <Combobox
{label} {label}
{name} {name}
{invalidMessage} {invalidMessage}
@@ -97,6 +103,7 @@
{options} {options}
matchWidth matchWidth
placeholder="Select a timezone" placeholder="Select a timezone"
class={classValue}
/> />
{#snippet timezoneLabel(item: ComboboxItem)} {#snippet timezoneLabel(item: ComboboxItem)}

View File

@@ -2,22 +2,30 @@
import Label from './Label.svelte'; import Label from './Label.svelte';
import ToggleSelect from './ToggleSelect.svelte'; import ToggleSelect from './ToggleSelect.svelte';
import { validate, validateInput } from '@svelte-toolkit/validate'; import { validate, validateInput } from '@svelte-toolkit/validate';
import { generateIdentifier, getLabel, getValue, type Option } from './util.js';
import type { ClassValue } from 'svelte/elements';
interface Props {
options: Option[];
selected?: string[];
name?: string;
label?: string;
required?: boolean;
missingMessage?: string;
class?: ClassValue | null | undefined;
}
let { let {
items, options,
selected = $bindable([]), selected = $bindable([]),
name, name,
label, label,
required, required,
missingMessage missingMessage,
}: { class: classValue
items: string[]; }: Props = $props();
selected?: string[];
name?: string; const id = $derived(generateIdentifier('toggle-group', name));
label: string;
required?: boolean;
missingMessage?: string;
} = $props();
let inputElement: HTMLInputElement | undefined = $state<HTMLInputElement>(); let inputElement: HTMLInputElement | undefined = $state<HTMLInputElement>();
let valid: boolean = $state(true); let valid: boolean = $state(true);
@@ -33,15 +41,16 @@
}; };
</script> </script>
<div> <div class={classValue}>
{#if label && name} {#if label}
<Label for={name}>{label}</Label> <Label for={id}>{label}</Label>
{/if} {/if}
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
{#if name} {#if name}
<input <input
type="hidden" type="hidden"
{id}
{name} {name}
required={required ? true : false} required={required ? true : false}
use:validate={{ required, baseval: '[]' }} use:validate={{ required, baseval: '[]' }}
@@ -52,20 +61,19 @@
/> />
{/if} {/if}
{#each items as item} {#each options as opt}
<ToggleSelect name={name ? undefined : `toggl_${item}`} ontoggle={makeSelectedHandler(item)}> <ToggleSelect
<span class="capitalize">{item}</span> name={name ? undefined : `toggle_${getValue(opt)}`}
ontoggle={makeSelectedHandler(getValue(opt))}
>
<span class={[typeof opt === 'string' && 'capitalize']}>{getLabel(opt)}</span>
</ToggleSelect> </ToggleSelect>
{/each} {/each}
</div> </div>
{#if name}
<div class={['mt-2 opacity-0 transition-opacity', !valid && 'opacity-100']}> <div class={['mt-2 opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={name} error> <Label for={id} error>
{missingMessage !== undefined && missingMessage !== '' {missingMessage !== undefined && missingMessage !== '' ? missingMessage : 'Field is required'}
? missingMessage
: 'Field is required'}
</Label> </Label>
</div> </div>
{/if}
</div> </div>

View File

@@ -1,20 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
let { interface Props {
name,
selected = false,
children,
onclick,
ontoggle
}: {
name?: string; name?: string;
selected?: boolean; selected?: boolean;
class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
ontoggle?: (selected: boolean) => void; ontoggle?: (selected: boolean) => void;
} = $props(); }
let { name, selected = false, class: classValue, children, onclick, ontoggle }: Props = $props();
const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => { const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => {
selected = !selected; // update state selected = !selected; // update state
@@ -28,9 +25,13 @@
{/if} {/if}
<button <button
class="rounded-3xl border px-6 py-2.5 font-medium transition-colors {selected class={[
'rounded-3xl border px-6 py-2.5 font-medium transition-colors',
selected
? 'border-secondary bg-primary text-background hover:bg-primary-600' ? 'border-secondary bg-primary text-background hover:bg-primary-600'
: 'border-accent text-text dark:border-accent/50 dark:bg-text dark:text-background dark:hover:bg-text-900 bg-white hover:bg-slate-100'}" : 'border-accent text-text dark:border-accent/50 dark:bg-text dark:text-background dark:hover:bg-text-900 bg-white hover:bg-slate-100',
classValue
]}
onclick={handleToggleSelectClick} onclick={handleToggleSelectClick}
> >
{@render children()} {@render children()}

View File

@@ -10,7 +10,6 @@ export { default as Link } 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';
export { default as Select } from './Select.svelte';
export { default as Spinner } from './Spinner.svelte'; export { default as Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte'; export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte'; export { default as StyledRawInput } from './StyledRawInput.svelte';
@@ -19,3 +18,4 @@ export { default as TimeInput } from './TimeInput.svelte';
export { default as TimezoneInput } from './TimezoneInput.svelte'; export { default as TimezoneInput } from './TimezoneInput.svelte';
export { default as ToggleGroup } from './ToggleGroup.svelte'; export { default as ToggleGroup } from './ToggleGroup.svelte';
export { default as ToggleSelect } from './ToggleSelect.svelte'; export { default as ToggleSelect } from './ToggleSelect.svelte';
export { type Option, getLabel, getValue } from './util.js';

View File

@@ -15,3 +15,31 @@ export const generateIdentifier = (prefix?: string, identifier?: string): string
const timestampPart = Date.now().toString(36); const timestampPart = Date.now().toString(36);
return `${prefix ? prefix + '-' : ''}${randomPart}-${timestampPart}`; return `${prefix ? prefix + '-' : ''}${randomPart}-${timestampPart}`;
}; };
/**
* Option type definition for a select option. Used in various components.
*/
export type Option =
| {
value: string;
label?: string;
}
| string;
/** getLabel returns the option label if it exists*/
export const getLabel = (option: Option): string => {
if (typeof option === 'string') {
return option;
} else {
return option.label ?? option.value;
}
};
/** getValue returns the option value */
export const getValue = (option: Option): string => {
if (typeof option === 'string') {
return option;
} else {
return option.value;
}
};

View File

@@ -139,7 +139,7 @@
<ToggleGroup <ToggleGroup
name="example-toggle-group" name="example-toggle-group"
label="Toggler" label="Toggler"
items={['item one', 'item two', 'item three']} options={['item one', 'item two', { value: 'complex', label: 'Complex item' }]}
/> />
</div> </div>