* fix input padding * fix surface hover on surface hover * fix metadata gen * fix border selected darkmode
467 lines
13 KiB
Svelte
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>
|