mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
style(ui): add compact versions of button, input, number input and a combined button + dropdown component
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
left: 'left-0',
|
||||
right: 'right-0',
|
||||
middle: 'left-1/2 -translate-x-1/2'
|
||||
}[position];
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
101
src/lib/client/ui/dropdown/DropdownSelect.svelte
Normal file
101
src/lib/client/ui/dropdown/DropdownSelect.svelte
Normal 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>
|
||||
@@ -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'
|
||||
: ''}"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
{#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>
|
||||
<DropdownSelect
|
||||
label="Schedule:"
|
||||
value={schedule}
|
||||
options={scheduleOptions}
|
||||
responsiveButton
|
||||
compactDropdown
|
||||
on:change={(e) => onScheduleChange?.(e.detail)}
|
||||
/>
|
||||
|
||||
<!-- 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)}
|
||||
/>
|
||||
{#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>
|
||||
<DropdownSelect
|
||||
label="Mode:"
|
||||
value={filterMode}
|
||||
options={modeOptions}
|
||||
minWidth="10rem"
|
||||
responsiveButton
|
||||
compactDropdown
|
||||
on:change={(e) => onFilterModeChange?.(e.detail as FilterMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status -->
|
||||
|
||||
@@ -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}
|
||||
{/if}
|
||||
</select>
|
||||
{#if field}
|
||||
<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}
|
||||
|
||||
<!-- 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}
|
||||
{/if}
|
||||
</select>
|
||||
{#if field.values}
|
||||
<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}
|
||||
{: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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user