Files
windmill/frontend/src/lib/components/common/button/Button.svelte
Guilhem 38bbbb7d6f fix(frontend): add transparency to color palette (#6947)
* fix input padding

* fix surface hover on surface hover

* fix metadata gen

* fix border selected darkmode
2025-10-27 21:21:21 +00:00

467 lines
13 KiB
Svelte

<script lang="ts">
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import { createEventDispatcher } from 'svelte'
import { ButtonType } from './model'
import { twMerge } from 'tailwind-merge'
import Dropdown from '$lib/components/DropdownV2.svelte'
import { getModifierKey, type Item } from '$lib/utils'
import { Loader2, ChevronDown } from 'lucide-svelte'
import { createTooltip } from '@melt-ui/svelte'
import type { Placement } from '@floating-ui/core'
import { conditionalMelt } from '$lib/utils'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
type MenuItem = {
label: string
onClick?: (e?: Event) => void
href?: string
icon?: any
disabled?: boolean
}
interface Props {
id?: string
aiId?: string | undefined
aiDescription?: string | undefined
size?: ButtonType.Size
spacingSize?: ButtonType.Size
unifiedSize?: ButtonType.UnifiedSize
/**
* @description Extended size for App only, do not use
*/
extendedSize?: ButtonType.ExtendedSize
/**
* @deprecated Use `variant` instead
*/
color?: ButtonType.Color | string
variant?: ButtonType.Variant
btnClasses?: string
wrapperClasses?: string
wrapperStyle?: string
disabled?: boolean
selected?: boolean
href?: string | undefined
target?: '_self' | '_blank' | undefined
iconOnly?: boolean
loadUntilNav?: boolean
clickableWhileLoading?: boolean
element?: ButtonType.Element | undefined
nonCaptureEvent?: boolean
propagateEvent?: boolean
loading?: boolean
title?: string | undefined
style?: string
download?: string | undefined
startIcon?: ButtonType.Icon | undefined
endIcon?: ButtonType.Icon | undefined
destructive?: boolean
shortCut?: { key?: string; hide?: boolean; Icon?: any; withoutModifier?: boolean } | undefined
tooltipPopover?:
| {
placement?: Placement
openDelay?: number
closeDelay?: number
portal?: string
}
| undefined
dropdownBtnClasses?: string
dropdownItems?: MenuItem[] | (() => MenuItem[]) | undefined
hideDropdown?: boolean
onClick?: (e?: Event) => void
children?: import('svelte').Snippet
tooltip?: import('svelte').Snippet
[key: string]: any
dropdownOpen?: boolean
}
let {
id = '',
aiId = undefined,
aiDescription = undefined,
size = 'md',
spacingSize = size,
unifiedSize = undefined,
extendedSize = undefined,
color = 'blue',
variant = 'default',
btnClasses = '',
wrapperClasses = '',
wrapperStyle = '',
disabled = false,
selected = false,
href = undefined,
target = undefined,
iconOnly = false,
loadUntilNav = false,
clickableWhileLoading = false,
element = $bindable(undefined),
nonCaptureEvent = false,
propagateEvent = false,
loading = $bindable(false),
title = undefined,
style = '',
download = undefined,
startIcon = undefined,
endIcon = undefined,
destructive = false,
shortCut = undefined,
tooltipPopover = undefined,
dropdownBtnClasses = '',
dropdownItems = undefined,
hideDropdown = false,
children,
tooltip,
onClick,
dropdownOpen = $bindable(false),
...rest
}: Props = $props()
function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] {
const items = typeof menuItems === 'function' ? menuItems() : menuItems
return items.map((item) => ({
displayName: item.label,
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
icon: item.icon,
disabled: item.disabled ?? false,
href: item.href
}))
}
export function focus() {
element?.focus({})
}
export function click() {
element?.click()
}
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
async function onclick(event: MouseEvent) {
if (!nonCaptureEvent) {
event.preventDefault()
if (!propagateEvent) {
// by default events are not propagated, added this prop so that we can
event.stopPropagation()
}
onClick?.(event)
dispatch('click', event)
}
}
function getStyleClass(color, variant) {
// Check if using new design system variants
if (['accent-secondary', 'accent', 'default', 'subtle'].includes(variant)) {
let style = destructive
? ButtonType.DestructiveVariantStyles[variant]
: ButtonType.VariantStyles[variant]
// For default variant with dropdowns, remove border from button since it's on wrapper
if (
variant === 'default' &&
dropdownItems &&
((typeof dropdownItems === 'function' && dropdownItems().length > 0) ||
dropdownItems.length > 0)
) {
style = style.replace('border border-border-light', '')
}
return style
}
// Legacy color-based styling
if (color in ButtonType.ColorVariants) {
return ButtonType.ColorVariants[color][variant]
} else {
return color
}
}
function getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize) {
// Check if using new extended sizing system (for App editor only)
if (extendedSize) {
const horizontalPadding = iconOnly
? ButtonType.ExtendedIconOnlySizingClasses[extendedSize]
: ButtonType.ExtendedSizingClasses[extendedSize]
const height = ButtonType.ExtendedHeightClasses[extendedSize]
return `${horizontalPadding} ${height}`
}
// Check if using new unified sizing system
if (unifiedSize) {
const horizontalPadding = iconOnly
? ButtonType.UnifiedIconOnlySizingClasses[unifiedSize]
: ButtonType.UnifiedSizingClasses[unifiedSize]
const height = ButtonType.UnifiedMinHeightClasses[unifiedSize]
return `${horizontalPadding} ${height}`
}
// Check if using new design system variants
if (iconOnly) {
return ButtonType.IconOnlyVariantSpacingClasses[spacingSize]
}
if (['accent-secondary', 'accent', 'default', 'subtle'].includes(variant)) {
return ButtonType.VariantSpacingClasses[spacingSize]
}
// Legacy spacing
return ButtonType.SpacingClasses[spacingSize][variant]
}
function getDividerClass(color, variant) {
// Check if using new design system variants
if (variant === 'default') {
return 'border border-border-light divide-x divide-border-light'
} else if (variant === 'accent') {
return 'divide-x divide-blue-100 dark:divide-blue-200'
} else if (variant === 'accent-secondary') {
return 'divide-x divide-deep-blue-400 dark:divide-deep-blue-100'
} else if (variant === 'subtle') {
return 'divide-x divide-transparent'
}
// Legacy color-based dividers
if (color in ButtonType.ColorVariants) {
return ButtonType.ColorVariants[color].divider
}
return ''
}
let buttonClass = $derived(
twMerge(
'w-full',
getStyleClass(color, variant),
variant === 'border' ? 'border' : '',
extendedSize
? ButtonType.ExtendedFontSizeClasses[extendedSize]
: ButtonType.FontSizeClasses[size],
getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize),
unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '',
'focus-visible:ring-2',
dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md',
'justify-center items-center text-center inline-flex gap-2',
'active:opacity-80 transition-[background-color,opacity] duration-150',
disabled
? ['default', 'subtle'].includes(variant)
? '!text-disabled'
: '!bg-surface-disabled !text-disabled'
: '',
loading ? 'cursor-wait' : '',
selected && ['default', 'subtle'].includes(variant)
? '!bg-surface-accent-selected !text-accent !border-border-selected'
: '',
btnClasses
)
)
const iconMap = {
xs3: 12,
xs2: 14,
xs: 14,
sm: 16,
md: 16,
lg: 18,
xl: 18
}
let lucideIconSize = $derived(
extendedSize
? ButtonType.ExtendedIconSizes[extendedSize]
: unifiedSize
? ButtonType.UnifiedIconSizes[unifiedSize]
: (iconMap[size] ?? 12)
)
const {
elements: { trigger, content },
states: { open },
options: { openDelay }
} = tooltipPopover
? createTooltip({
positioning: {
placement: tooltipPopover?.placement
},
closeDelay: tooltipPopover?.closeDelay,
group: true,
portal: tooltipPopover?.portal
})
: {
elements: { trigger: undefined, content: undefined },
states: { open: undefined },
options: { openDelay: undefined }
}
$effect(() => {
tooltipPopover && openDelay !== undefined && ($openDelay = tooltipPopover?.openDelay)
}) //This option is reactive
$effect(() => {
$open !== undefined && dispatchIfMounted('tooltipOpen', $open)
})
const dividerClass = $derived(getDividerClass(color, variant))
</script>
<div
class={twMerge(
dropdownItems && dropdownItems.length > 0 ? dividerClass : '',
wrapperClasses,
'flex flex-row rounded-md',
disabled ? 'divide-text-disabled cursor-not-allowed' : ''
)}
style={wrapperStyle}
data-interactive
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
element?.click()
}
}}
>
{#if href && !disabled}
<a
bind:this={element}
onpointerdown={bubble('pointerdown')}
onfocus={bubble('focus')}
onblur={bubble('blur')}
onmouseenter={bubble('mouseenter')}
onmouseleave={bubble('mouseleave')}
onclick={() => {
loading = true
dispatch('click', event)
if (!loadUntilNav) {
loading = false
}
}}
{href}
{download}
class={buttonClass}
{id}
{target}
tabindex={disabled ? -1 : 0}
{...rest}
{style}
>
{#if loading}
<Loader2 class={twMerge('animate-spin shrink-0')} size={lucideIconSize} />
{:else if startIcon?.icon}
<startIcon.icon
class={twMerge('shrink-0', startIcon?.classes)}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
{#if !iconOnly}
{@render children?.()}
{/if}
{#if endIcon?.icon}
<endIcon.icon class={twMerge('shrink-0', endIcon?.classes)} size={lucideIconSize} />
{/if}
{#if shortCut && !shortCut.hide}
<div
class={twMerge(
'flex flex-row items-center !text-md opacity-60 gap-0',
ButtonType.UnifiedFontSizes[size]
)}
>
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<shortCut.Icon
class="w-4 h-4 shrink-0"
size={lucideIconSize}
/>{:else}{shortCut.key}{/if}
</div>
{/if}
</a>
{:else}
<button
bind:this={element}
onpointerdown={bubble('pointerdown')}
{onclick}
onfocus={bubble('focus')}
onblur={bubble('blur')}
onmouseenter={bubble('mouseenter')}
onmouseleave={bubble('mouseleave')}
class={buttonClass}
{id}
tabindex={disabled ? -1 : 0}
{title}
{...rest}
disabled={disabled || (loading && !clickableWhileLoading)}
{style}
use:conditionalMelt={trigger}
{...$trigger}
>
{#if loading}
<Loader2 class={twMerge('animate-spin shrink-0')} size={lucideIconSize} />
{:else if startIcon?.icon}
<startIcon.icon
class={twMerge('shrink-0', startIcon?.classes)}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
{#if !iconOnly}
{@render children?.()}
{/if}
{#if endIcon?.icon}
<endIcon.icon class={twMerge('shrink-0', endIcon?.classes)} size={lucideIconSize} />
{/if}
{#if shortCut && !shortCut.hide}
{@const Icon = shortCut.Icon}
<div
class={twMerge(
'flex flex-row items-center !text-md opacity-60 gap-0',
ButtonType.UnifiedFontSizes[size]
)}
>
{#if shortCut.withoutModifier !== true}{getModifierKey()}{/if}{#if shortCut.Icon}<Icon
class="shrink-0"
size={lucideIconSize}
/>{:else}{shortCut.key}{/if}
</div>
{/if}
</button>
{#if tooltipPopover && $open}
<div use:conditionalMelt={content} {...$content} class="z-[20000]">
{@render tooltip?.()}
</div>
{/if}
{/if}
{#if dropdownItems && dropdownItems.length > 0}
<Dropdown
aiId={aiId ? `${aiId}-dropdown` : undefined}
aiDescription={aiDescription ? `${aiDescription} dropdown` : undefined}
items={computeDropdowns(dropdownItems)}
class="h-auto w-fit"
hidePopup={hideDropdown}
usePointerDownOutside
on:open={() => dispatch('dropdownOpen', true)}
on:close={() => dispatch('dropdownOpen', false)}
bind:open={dropdownOpen}
enableFlyTransition
>
{#snippet buttonReplacement()}
<div
class={twMerge(
buttonClass,
'rounded-md m-0 p-0 center-center h-full',
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
'rounded-r-md !rounded-l-none',
size === 'xs2' || size === 'xs' || unifiedSize === 'md' || unifiedSize === 'sm'
? '!w-8'
: '!w-10',
dropdownBtnClasses
)}
>
<ChevronDown class="shrink-0" size={lucideIconSize} />
</div>
{/snippet}
</Dropdown>
{/if}
</div>