style(ui): add compact versions of button, input, number input and a combined button + dropdown component

This commit is contained in:
Sam Chau
2026-01-21 00:05:38 +10:30
parent 51d382754a
commit 4c90c729e4
9 changed files with 400 additions and 169 deletions

View File

@@ -1,22 +1,48 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { ComponentType } from 'svelte';
export let text: string = '';
export let variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'ghost';
export let size: 'sm' | 'md' = 'sm';
export let size: 'xs' | 'sm' | 'md' = 'sm';
export let disabled: boolean = false;
export let icon: ComponentType | null = null;
export let iconColor: string = '';
export let textColor: string = '';
export let iconPosition: 'left' | 'right' = 'left';
export let type: 'button' | 'submit' = 'button';
// Responsive: auto-switch to xs on smaller screens (< 1280px)
export let responsive: boolean = false;
export let fullWidth: boolean = false;
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
if (responsive && typeof window !== 'undefined') {
mediaQuery = window.matchMedia('(max-width: 1279px)');
isSmallScreen = mediaQuery.matches;
mediaQuery.addEventListener('change', handleMediaChange);
}
});
onDestroy(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', handleMediaChange);
}
});
function handleMediaChange(e: MediaQueryListEvent) {
isSmallScreen = e.matches;
}
const baseClasses =
'inline-flex items-center justify-center font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50';
const sizeClasses = {
sm: 'gap-1.5 rounded-md px-2.5 py-1.5 text-sm',
md: 'gap-2 rounded-lg px-4 py-2 text-sm'
xs: 'gap-1 rounded px-2 py-1 text-xs',
sm: 'gap-1.5 rounded-lg px-3 py-2',
md: 'gap-2 rounded-lg px-4 py-2.5'
};
const variantClasses = {
@@ -29,22 +55,24 @@
'border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'
};
$: effectiveSize = responsive && isSmallScreen ? 'xs' : size;
$: widthClass = fullWidth ? 'w-full' : '';
$: baseTextColor =
textColor || (variant === 'ghost' ? 'text-neutral-700 dark:text-neutral-300' : '');
$: baseIconColor =
iconColor || (variant === 'ghost' ? 'text-neutral-500 dark:text-neutral-400' : '');
$: classes = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]}`;
$: classes = `${baseClasses} ${sizeClasses[effectiveSize]} ${variantClasses[variant]} ${widthClass}`;
</script>
<button {type} {disabled} class={classes} on:click on:mouseenter on:mouseleave>
{#if icon && iconPosition === 'left'}
<svelte:component this={icon} size={size === 'sm' ? 14 : 16} class={baseIconColor} />
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
{#if text}
<span class={baseTextColor}>{text}</span>
{/if}
<slot />
{#if icon && iconPosition === 'right'}
<svelte:component this={icon} size={size === 'sm' ? 14 : 16} class={baseIconColor} />
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
</button>

View File

@@ -1,22 +1,79 @@
<script lang="ts">
export let position: 'left' | 'right' | 'middle' = 'left';
export let minWidth: string = '12rem'; // Allow customization
import { onMount } from 'svelte';
// Compute position classes
$: positionClass = {
export let position: 'left' | 'right' | 'middle' = 'left';
export let minWidth: string = '12rem';
export let compact: boolean = false;
// Fixed positioning to escape overflow containers
export let fixed: boolean = false;
export let triggerEl: HTMLElement | null = null;
let dropdownEl: HTMLElement;
let fixedStyle = '';
$: positionClass = fixed
? ''
: {
left: 'left-0',
right: 'right-0',
middle: 'left-1/2 -translate-x-1/2'
}[position];
$: marginClass = compact ? 'mt-1' : 'mt-3';
$: gap = compact ? 4 : 12; // pixels gap below trigger
$: roundedClass = compact ? 'rounded-md' : 'rounded-lg';
function updateFixedPosition() {
if (!fixed || !triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
let left = rect.left;
if (position === 'right') {
left = rect.right;
// Adjust to align right edge
if (dropdownEl) {
left = rect.right - dropdownEl.offsetWidth;
}
} else if (position === 'middle') {
left = rect.left + rect.width / 2;
if (dropdownEl) {
left -= dropdownEl.offsetWidth / 2;
}
}
fixedStyle = `top: ${rect.bottom + gap}px; left: ${left}px;`;
}
onMount(() => {
if (fixed && triggerEl) {
updateFixedPosition();
// Update position on scroll/resize
window.addEventListener('scroll', updateFixedPosition, true);
window.addEventListener('resize', updateFixedPosition);
return () => {
window.removeEventListener('scroll', updateFixedPosition, true);
window.removeEventListener('resize', updateFixedPosition);
};
}
});
$: if (fixed && triggerEl && dropdownEl) {
updateFixedPosition();
}
</script>
<!-- Dropdown content - can be used standalone or within a trigger wrapper -->
<!-- Invisible hover bridge to keep dropdown open when moving mouse down -->
<div class="absolute top-full z-40 h-3 w-full"></div>
{#if !fixed}
<div class="absolute top-full z-40 h-3 w-full"></div>
{/if}
<div
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
style="min-width: {minWidth}"
bind:this={dropdownEl}
class="z-50 border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800 {roundedClass} {fixed
? 'fixed'
: 'absolute top-full ' + marginClass} {positionClass}"
style="min-width: {minWidth}; {fixed ? fixedStyle : ''}"
>
<slot />
</div>

View File

@@ -7,23 +7,31 @@
export let disabled: boolean = false;
export let danger: boolean = false;
export let selected: boolean = false;
</script>
export let compact: boolean = false;
<button
class="flex w-full items-center gap-3 border-b border-neutral-200 px-4 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
{disabled
$: sizeClasses = compact
? 'gap-2 px-2 py-1 text-xs first:rounded-t-md last:rounded-b-md'
: 'gap-3 px-3 py-2 first:rounded-t-lg last:rounded-b-lg';
$: stateClasses = disabled
? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600'
: danger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30'
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700'}"
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700';
$: iconSize = compact ? 12 : 16;
</script>
<button
class="flex w-full items-center border-b border-neutral-200 text-left transition-colors last:border-b-0 dark:border-neutral-700 {sizeClasses} {stateClasses}"
{disabled}
on:click
>
{#if icon}
<svelte:component this={icon} size={16} />
<svelte:component this={icon} size={iconSize} />
{/if}
<span class="flex-1">{label}</span>
{#if selected}
<Check size={16} class="text-accent-600 dark:text-accent-400" />
<Check size={iconSize} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { clickOutside } from '$lib/client/utils/clickOutside';
import { ChevronDown } from 'lucide-svelte';
import Button from '$ui/button/Button.svelte';
import Dropdown from './Dropdown.svelte';
import DropdownItem from './DropdownItem.svelte';
export let label: string | undefined = undefined;
export let value: string;
export let options: { value: string; label: string; description?: string }[];
export let minWidth: string = '8rem';
export let position: 'left' | 'right' | 'middle' = 'left';
// Separate compact controls - compact is shorthand for both
export let compact: boolean = false;
export let compactButton: boolean | undefined = undefined;
export let compactDropdown: boolean | undefined = undefined;
// Auto-compact dropdown when options exceed this threshold (0 = disabled)
export let compactDropdownThreshold: number = 0;
// Responsive: auto-compact button on smaller screens (< 1280px)
export let responsiveButton: boolean = false;
export let fullWidth: boolean = false;
// Fixed positioning to escape overflow containers (e.g. tables)
export let fixed: boolean = false;
const dispatch = createEventDispatcher<{ change: string }>();
let open = false;
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
let triggerEl: HTMLElement;
onMount(() => {
if (responsiveButton && typeof window !== 'undefined') {
mediaQuery = window.matchMedia('(max-width: 1279px)');
isSmallScreen = mediaQuery.matches;
mediaQuery.addEventListener('change', handleMediaChange);
}
});
onDestroy(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', handleMediaChange);
}
});
function handleMediaChange(e: MediaQueryListEvent) {
isSmallScreen = e.matches;
}
$: currentLabel = options.find((o) => o.value === value)?.label ?? value;
$: isCompactButton = compactButton ?? (responsiveButton ? isSmallScreen : compact);
$: isCompactDropdown =
compactDropdown !== undefined
? compactDropdown
: compactDropdownThreshold > 0 && options.length >= compactDropdownThreshold
? true
: compact;
$: buttonSize = (isCompactButton ? 'xs' : 'sm') as 'xs' | 'sm';
$: labelClasses = isCompactButton
? 'text-xs text-neutral-500 dark:text-neutral-400'
: 'text-sm text-neutral-500 dark:text-neutral-400';
function select(optionValue: string) {
dispatch('change', optionValue);
open = false;
}
</script>
<div class="flex items-center gap-2" class:w-full={fullWidth}>
{#if label}
<span class={labelClasses}>{label}</span>
{/if}
<div
class="relative"
class:flex-1={fullWidth}
bind:this={triggerEl}
use:clickOutside={() => (open = false)}
>
<Button
text={currentLabel}
icon={ChevronDown}
iconPosition="right"
size={buttonSize}
{fullWidth}
on:click={() => (open = !open)}
/>
{#if open}
<Dropdown {position} {minWidth} compact={isCompactDropdown} {fixed} {triggerEl}>
{#each options as option}
<DropdownItem
label={option.label}
selected={value === option.value}
compact={isCompactDropdown}
on:click={() => select(option.value)}
/>
{/each}
</Dropdown>
{/if}
</div>
</div>

View File

@@ -1,14 +1,43 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
export let value: string = '';
export let placeholder: string = '';
export let type: 'text' | 'number' | 'email' | 'password' = 'text';
export let disabled: boolean = false;
export let width: string = 'w-28';
export let compact: boolean = false;
// Responsive: auto-switch to compact on smaller screens (< 1280px)
export let responsive: boolean = false;
const dispatch = createEventDispatcher<{ input: string }>();
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
if (responsive && typeof window !== 'undefined') {
mediaQuery = window.matchMedia('(max-width: 1279px)');
isSmallScreen = mediaQuery.matches;
mediaQuery.addEventListener('change', handleMediaChange);
}
});
onDestroy(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', handleMediaChange);
}
});
function handleMediaChange(e: MediaQueryListEvent) {
isSmallScreen = e.matches;
}
$: isCompact = compact || (responsive && isSmallScreen);
$: sizeClasses = isCompact
? 'rounded px-2 py-1 text-xs'
: 'rounded-lg px-3 py-2';
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
@@ -22,7 +51,7 @@
{placeholder}
{disabled}
on:input={handleInput}
class="{width} rounded-lg border border-neutral-300 bg-white px-2.5 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500 {disabled
class="{width} border border-neutral-300 bg-white text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500 {sizeClasses} {disabled
? 'cursor-not-allowed opacity-50'
: ''}"
/>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { ChevronUp, ChevronDown } from 'lucide-svelte';
const dispatch = createEventDispatcher<{ change: number }>();
@@ -14,11 +14,41 @@
export let required: boolean = false;
export let disabled: boolean = false;
export let font: 'mono' | 'sans' | undefined = undefined;
export let compact: boolean = false;
// Responsive: auto-switch to compact on smaller screens (< 1280px)
export let responsive: boolean = false;
export let onchange: ((value: number) => void) | undefined = undefined;
export let onMinBlocked: (() => void) | undefined = undefined;
export let onMaxBlocked: (() => void) | undefined = undefined;
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
if (responsive && typeof window !== 'undefined') {
mediaQuery = window.matchMedia('(max-width: 1279px)');
isSmallScreen = mediaQuery.matches;
mediaQuery.addEventListener('change', handleMediaChange);
}
});
onDestroy(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', handleMediaChange);
}
});
function handleMediaChange(e: MediaQueryListEvent) {
isSmallScreen = e.matches;
}
$: isCompact = compact || (responsive && isSmallScreen);
$: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : '';
$: inputSizeClasses = isCompact
? 'rounded px-2 py-1 pr-7 text-xs'
: 'rounded-lg px-3 py-2 pr-10';
$: buttonSizeClasses = isCompact ? 'h-2.5 w-4' : 'h-4 w-6';
$: iconSize = isCompact ? 10 : 12;
function updateValue(newValue: number) {
value = newValue;
@@ -76,7 +106,7 @@
{step}
{required}
{disabled}
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {fontClass}"
class="block w-full [appearance:textfield] border border-neutral-300 bg-white text-neutral-900 placeholder-neutral-400 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {inputSizeClasses} {fontClass}"
/>
<!-- Custom increment/decrement buttons -->
@@ -85,17 +115,17 @@
type="button"
on:click={increment}
{disabled}
class="flex h-4 w-6 items-center justify-center rounded-t border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
class="flex {buttonSizeClasses} items-center justify-center rounded-t border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
>
<ChevronUp size={12} />
<ChevronUp size={iconSize} />
</button>
<button
type="button"
on:click={decrement}
{disabled}
class="flex h-4 w-6 items-center justify-center rounded-b border border-t-0 border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
class="flex {buttonSizeClasses} items-center justify-center rounded-b border border-t-0 border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
>
<ChevronDown size={12} />
<ChevronDown size={iconSize} />
</button>
</div>
</div>

View File

@@ -2,12 +2,8 @@
import { onMount, onDestroy } from 'svelte';
import { filterModes, type FilterMode } from '$lib/shared/filters';
import { parseUTC } from '$shared/dates';
import { clickOutside } from '$lib/client/utils/clickOutside';
import { ChevronDown } from 'lucide-svelte';
import Toggle from '$ui/toggle/Toggle.svelte';
import Button from '$ui/button/Button.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
export let enabled: boolean = true;
export let dryRun: boolean = false;
@@ -20,9 +16,6 @@
export let onScheduleChange: ((value: string) => void) | undefined = undefined;
export let onFilterModeChange: ((value: FilterMode) => void) | undefined = undefined;
let scheduleOpen = false;
let modeOpen = false;
const scheduleOptions = [
{ value: '30', label: '30 min' },
{ value: '60', label: '1 hour' },
@@ -34,8 +27,8 @@
{ value: '1440', label: '24 hours' }
];
$: currentScheduleLabel = scheduleOptions.find((o) => o.value === schedule)?.label ?? '6 hours';
$: currentModeLabel = filterModes.find((m) => m.id === filterMode)?.label ?? 'Round Robin';
// Map filterModes to DropdownSelect format
$: modeOptions = filterModes.map((m) => ({ value: m.id, label: m.label }));
// Cooldown tracking
let now = Date.now();
@@ -133,58 +126,25 @@
<div class="hidden h-6 w-px bg-neutral-200 sm:block dark:bg-neutral-700"></div>
<!-- Schedule -->
<div class="flex items-center gap-2">
<span class="text-sm text-neutral-500 dark:text-neutral-400">Schedule:</span>
<div class="relative" use:clickOutside={() => (scheduleOpen = false)}>
<Button
text={currentScheduleLabel}
icon={ChevronDown}
iconPosition="right"
on:click={() => (scheduleOpen = !scheduleOpen)}
<DropdownSelect
label="Schedule:"
value={schedule}
options={scheduleOptions}
responsiveButton
compactDropdown
on:change={(e) => onScheduleChange?.(e.detail)}
/>
{#if scheduleOpen}
<Dropdown position="left" minWidth="8rem">
{#each scheduleOptions as option}
<DropdownItem
label={option.label}
selected={schedule === option.value}
on:click={() => {
onScheduleChange?.(option.value);
scheduleOpen = false;
}}
/>
{/each}
</Dropdown>
{/if}
</div>
</div>
<!-- Filter Mode -->
<div class="flex items-center gap-2">
<span class="text-sm text-neutral-500 dark:text-neutral-400">Mode:</span>
<div class="relative" use:clickOutside={() => (modeOpen = false)}>
<Button
text={currentModeLabel}
icon={ChevronDown}
iconPosition="right"
on:click={() => (modeOpen = !modeOpen)}
<DropdownSelect
label="Mode:"
value={filterMode}
options={modeOptions}
minWidth="10rem"
responsiveButton
compactDropdown
on:change={(e) => onFilterModeChange?.(e.detail as FilterMode)}
/>
{#if modeOpen}
<Dropdown position="left" minWidth="10rem">
{#each filterModes as mode}
<DropdownItem
label={mode.label}
selected={filterMode === mode.id}
on:click={() => {
onFilterModeChange?.(mode.id);
modeOpen = false;
}}
/>
{/each}
</Dropdown>
{/if}
</div>
</div>
</div>
<!-- Right: Status -->

View File

@@ -11,7 +11,10 @@
type FilterGroup,
type FilterRule
} from '$lib/shared/filters';
import Input from '$ui/form/Input.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import Button from '$ui/button/Button.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
export let group: FilterGroup;
export let onRemove: (() => void) | null = null;
@@ -63,16 +66,23 @@
<!-- Group Header -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Match</span>
<select
bind:value={group.match}
on:change={notifyChange}
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option value="all">All (AND)</option>
<option value="any">Any (OR)</option>
</select>
<span class="text-sm text-neutral-500 dark:text-neutral-400">of the following rules</span>
<span class="text-xs font-medium text-neutral-700 dark:text-neutral-300">Match</span>
<DropdownSelect
value={group.match}
options={[
{ value: 'all', label: 'All (AND)' },
{ value: 'any', label: 'Any (OR)' }
]}
minWidth="7rem"
responsiveButton
compactDropdownThreshold={7}
fixed
on:change={(e) => {
group.match = e.detail as 'all' | 'any';
notifyChange();
}}
/>
<span class="text-xs text-neutral-500 dark:text-neutral-400">of the following rules</span>
</div>
{#if onRemove}
<button
@@ -97,51 +107,61 @@
{@const field = getFilterField(child.field)}
<div class="flex items-center gap-2">
<!-- Field -->
<select
<DropdownSelect
value={child.field}
on:change={(e) => onFieldChange(child, e.currentTarget.value)}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each filterFields as f}
<option value={f.id}>{f.label}</option>
{/each}
</select>
options={filterFields.map((f) => ({ value: f.id, label: f.label }))}
minWidth="10rem"
responsiveButton
compactDropdownThreshold={7}
fixed
on:change={(e) => onFieldChange(child, e.detail)}
/>
<!-- Operator -->
<select
bind:value={child.operator}
on:change={notifyChange}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#if field}
{#each field.operators as op}
<option value={op.id}>{op.label}</option>
{/each}
<DropdownSelect
value={child.operator}
options={field.operators.map((op) => ({ value: op.id, label: op.label }))}
minWidth="8rem"
responsiveButton
compactDropdownThreshold={7}
fixed
on:change={(e) => {
child.operator = e.detail;
notifyChange();
}}
/>
{/if}
</select>
<!-- Value -->
{#if field?.valueType === 'boolean' || field?.valueType === 'select'}
<select
bind:value={child.value}
on:change={notifyChange}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#if field.values}
{#each field.values as v}
<option value={v.value}>{v.label}</option>
{/each}
<DropdownSelect
value={String(child.value)}
options={field.values.map((v) => ({ value: String(v.value), label: v.label }))}
minWidth="8rem"
responsiveButton
compactDropdownThreshold={7}
fixed
on:change={(e) => {
const originalValue = field.values?.find((v) => String(v.value) === e.detail)?.value;
child.value = originalValue ?? e.detail;
notifyChange();
}}
/>
{/if}
</select>
{:else if field?.valueType === 'text'}
<input
type="text"
bind:value={child.value}
on:input={notifyChange}
class="flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
<Input
value={child.value as string}
width="flex-1"
responsive
on:input={(e) => {
child.value = e.detail;
notifyChange();
}}
/>
{:else if field?.valueType === 'number'}
<div class="w-32">
<div class="w-24">
<NumberInput
name="value-{childIndex}"
value={child.value as number}
@@ -150,12 +170,13 @@
notifyChange();
}}
font="mono"
responsive
/>
</div>
{:else if field?.valueType === 'date'}
{#if child.operator === 'in_last' || child.operator === 'not_in_last'}
<div class="flex items-center gap-2">
<div class="w-24">
<div class="w-20">
<NumberInput
name="value-{childIndex}"
value={child.value as number}
@@ -165,16 +186,17 @@
}}
min={1}
font="mono"
responsive
/>
</div>
<span class="text-sm text-neutral-500 dark:text-neutral-400">days</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400">days</span>
</div>
{:else}
<input
type="date"
bind:value={child.value}
on:change={notifyChange}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
class="rounded border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
{/if}
{/if}
@@ -205,21 +227,7 @@
<!-- Add Buttons -->
<div class="mt-3 flex items-center gap-2">
<button
type="button"
on:click={addRule}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Plus size={14} />
Add Rule
</button>
<button
type="button"
on:click={addNestedGroup}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<FolderPlus size={14} />
Add Group
</button>
<Button text="Add Rule" icon={Plus} responsive on:click={addRule} />
<Button text="Add Group" icon={FolderPlus} responsive on:click={addNestedGroup} />
</div>
</div>

View File

@@ -17,6 +17,7 @@
import FilterGroupComponent from './FilterGroup.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import FiltersInfoModal from './FiltersInfoModal.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
@@ -307,6 +308,7 @@
min={0}
max={100}
font="mono"
responsive
on:change={handleChange}
/>
</div>
@@ -327,6 +329,7 @@
bind:value={row.searchCooldown}
min={24}
font="mono"
responsive
on:change={handleChange}
/>
</div>
@@ -337,20 +340,26 @@
<div>
<label
for="selector-{row.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
class="block mb-1 text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Method
</label>
<select
id="selector-{row.id}"
bind:value={row.selector}
on:change={handleChange}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-accent-500 focus:ring-1 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each selectors as s}
<option value={s.id}>{s.label} - {s.description}</option>
{/each}
</select>
<DropdownSelect
value={row.selector}
options={selectors.map((s) => ({
value: s.id,
label: `${s.label} - ${s.description}`
}))}
minWidth="14rem"
responsiveButton
compactDropdownThreshold={7}
fullWidth
fixed
on:change={(e) => {
row.selector = e.detail;
handleChange();
}}
/>
</div>
<div>
<label
@@ -366,6 +375,7 @@
min={1}
max={5}
font="mono"
responsive
on:change={handleChange}
/>
</div>