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">
import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements';
import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import Spinner from './Spinner.svelte';
let {
icon,
animate = true,
loading,
children,
onclick
}: {
interface Props {
icon?: string;
animate?: boolean;
loading?: boolean;
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
} = $props();
}
let { icon, animate = true, loading, class: classValue, children, onclick }: Props = $props();
let iconElement = $state<HTMLSpanElement | null>(null);
@@ -68,7 +65,8 @@
class={[
'button group relative flex gap-3 overflow-hidden rounded-sm px-5',
'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}
>

View File

@@ -1,10 +1,16 @@
<script lang="ts">
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>
<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]">
{@render children()}
</main>

View File

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

View File

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

View File

@@ -35,7 +35,7 @@
label,
required = false,
invalidMessage = 'Valid date is required',
class: classList,
class: classValue,
format = ['year', 'month', 'day']
}: Props = $props();
@@ -200,7 +200,7 @@
};
</script>
<div>
<div class={classValue}>
<!-- Date input Label -->
{#if 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',
'text-text dark:border-accent/50 dark:bg-text-800 dark:text-background dark:sm:bg-slate-800',
'ring-accent focus-within:ring-1',
!valid && 'border-red-500!',
classList
!valid && 'border-red-500!'
]}
>
<input

View File

@@ -1,20 +1,24 @@
<script lang="ts">
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 {
icon,
iconPosition = 'right',
disabled = false,
class: classValue,
children,
onclick
}: {
icon?: string;
iconPosition?: 'left' | 'right';
disabled?: boolean;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
} = $props();
}: Props = $props();
</script>
{#snippet iconSnippet()}
@@ -24,7 +28,8 @@
<button
class={[
'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}
>

View File

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

View File

@@ -21,21 +21,25 @@
<script lang="ts">
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 {
href,
disabled = false,
tab = 'current',
class: classValue,
children,
onclick
}: {
href: string;
disabled?: boolean;
tab?: 'current' | 'new';
children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>;
} = $props();
}: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) {
let prefix = trim(PUBLIC_BASEPATH, '/');
@@ -46,7 +50,8 @@
<a
class={[
'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}
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">
import { RadioGroup, type RadioGroupProps } from 'melt/builders';
import type { ClassValue } from 'svelte/elements';
import { scale } from 'svelte/transition';
import Label from './Label.svelte';
import { validate } from '@svelte-toolkit/validate';
import { getLabel, getValue, type Option } from './util.js';
interface Props extends RadioGroupProps {
options: RadioGroupOption[];
options: Option[];
label?: string;
required?: boolean;
invalidMessage?: string;

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { MouseEventHandler } from 'svelte/elements';
import type { ClassValue, MouseEventHandler } from 'svelte/elements';
let {
name,
selected = false,
children,
onclick,
ontoggle
}: {
interface Props {
name?: string;
selected?: boolean;
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
ontoggle?: (selected: boolean) => void;
} = $props();
}
let { name, selected = false, class: classValue, children, onclick, ontoggle }: Props = $props();
const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => {
selected = !selected; // update state
@@ -28,9 +25,13 @@
{/if}
<button
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-accent text-text dark:border-accent/50 dark:bg-text dark:text-background dark:hover:bg-text-900 bg-white hover:bg-slate-100'}"
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-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}
>
{@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 PinInput } from './PinInput.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 StateMachine, type StateMachinePage } from './StateMachine.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 ToggleGroup } from './ToggleGroup.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);
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
name="example-toggle-group"
label="Toggler"
items={['item one', 'item two', 'item three']}
options={['item one', 'item two', { value: 'complex', label: 'Complex item' }]}
/>
</div>