feat(frontend): Add icon app component (#1207)

* feat(frontend): Add icon app component

* fix(frontend): Import only one icon at a time

* fix(frontend): Handle file names correctly

* feat(frontend): Use all arrow keys in popup navigation

* fix(frontend): Close popup after icon select

* fix(frontend): Position picker correctly

* fix(frontend): Handle empty search

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
Ádám Kovács
2023-02-17 20:20:28 +01:00
committed by GitHub
co-authored by Ruben Fiszel
parent c33e79e0b8
commit e4791c2b7e
11 changed files with 331 additions and 79 deletions
+7 -20
View File
@@ -8,7 +8,6 @@
"name": "windmill",
"version": "1.64.0",
"dependencies": {
"@canutin/svelte-currency-input": "^0.8.0",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@leeoniya/ufuzzy": "^0.9.1",
@@ -19,6 +18,7 @@
"chartjs-plugin-zoom": "^2.0.0",
"date-fns": "^2.29.3",
"highlight.js": "^11.7.0",
"lucide-svelte": "^0.115.0",
"monaco-editor": "0.33.0",
"monaco-editor-workers": "0.33.0",
"monaco-languageclient": "2.1.0",
@@ -48,7 +48,6 @@
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"lucide-svelte": "^0.104.0",
"openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1",
"postcss": "^8.4.18",
@@ -206,11 +205,6 @@
"node": ">=4"
}
},
"node_modules/@canutin/svelte-currency-input": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@canutin/svelte-currency-input/-/svelte-currency-input-0.8.0.tgz",
"integrity": "sha512-u/ywCjAQzBTU1GvoljNgVzA/xV6UjVXsFbe4KISxk3cZJOA3eN35qaGtNMJhWQ0JI+QbFjxv2I2nHbdzmmn1Nw=="
},
"node_modules/@csstools/selector-specificity": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz",
@@ -3865,10 +3859,9 @@
}
},
"node_modules/lucide-svelte": {
"version": "0.104.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.104.0.tgz",
"integrity": "sha512-PhckIn28E571Kx2gs2SVh5gc0RlDge521AE63ce4c8gRUMYeo9USPUr80bt/26J5+8qce+hw1zRjx/ag2BqBgw==",
"dev": true,
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.115.0.tgz",
"integrity": "sha512-kR7XlwoSVjGX+jwOiHKjTCDMPcTKrsKGyYnJkgW55wyKmWAzfOSESDGuRHcfsRAIQgg133mDNtCm++S8V5rRjg==",
"peerDependencies": {
"svelte": "^3.49.0"
}
@@ -7284,11 +7277,6 @@
}
}
},
"@canutin/svelte-currency-input": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@canutin/svelte-currency-input/-/svelte-currency-input-0.8.0.tgz",
"integrity": "sha512-u/ywCjAQzBTU1GvoljNgVzA/xV6UjVXsFbe4KISxk3cZJOA3eN35qaGtNMJhWQ0JI+QbFjxv2I2nHbdzmmn1Nw=="
},
"@csstools/selector-specificity": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz",
@@ -9843,10 +9831,9 @@
}
},
"lucide-svelte": {
"version": "0.104.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.104.0.tgz",
"integrity": "sha512-PhckIn28E571Kx2gs2SVh5gc0RlDge521AE63ce4c8gRUMYeo9USPUr80bt/26J5+8qce+hw1zRjx/ag2BqBgw==",
"dev": true,
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.115.0.tgz",
"integrity": "sha512-kR7XlwoSVjGX+jwOiHKjTCDMPcTKrsKGyYnJkgW55wyKmWAzfOSESDGuRHcfsRAIQgg133mDNtCm++S8V5rRjg==",
"requires": {}
},
"magic-string": {
+1 -1
View File
@@ -33,7 +33,6 @@
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"lucide-svelte": "^0.104.0",
"openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1",
"postcss": "^8.4.18",
@@ -71,6 +70,7 @@
"chartjs-plugin-zoom": "^2.0.0",
"date-fns": "^2.29.3",
"highlight.js": "^11.7.0",
"lucide-svelte": "^0.115.0",
"monaco-editor": "0.33.0",
"monaco-editor-workers": "0.33.0",
"monaco-languageclient": "2.1.0",
@@ -1,24 +1,41 @@
<script lang="ts">
import type { AppInput } from '../inputType'
import { classNames } from '$lib/utils'
import type { HorizontalAlignment, VerticalAlignment } from '../types'
import AlignWrapper from './helpers/AlignWrapper.svelte'
import InputValue from './helpers/InputValue.svelte'
export let id: string
export let configuration: Record<string, AppInput>
export let horizontalAlignment: HorizontalAlignment | undefined = undefined
export let verticalAlignment: VerticalAlignment | undefined = undefined
export let position: 'horizontal' | 'vertical'
let size = 2
let color = '#00000060'
export let noWFull = false
function tailwindHorizontalAlignment(alignment?: HorizontalAlignment) {
if (!alignment) return ''
const classes: Record<HorizontalAlignment, string> = {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end'
}
return classes[alignment]
}
function tailwindVerticalAlignment(alignment?: VerticalAlignment) {
if (!alignment) return ''
const classes: Record<VerticalAlignment, string> = {
top: 'items-start',
center: 'items-center',
bottom: 'items-end'
}
return classes[alignment]
}
$: classes = classNames(
'flex z-auto',
noWFull ? '' : 'w-full',
tailwindHorizontalAlignment(horizontalAlignment),
tailwindVerticalAlignment(verticalAlignment),
verticalAlignment ? 'h-full' : '',
$$props.class || ''
)
</script>
<InputValue {id} input={configuration.size} bind:value={size} />
<InputValue {id} input={configuration.color} bind:value={color} />
<AlignWrapper {horizontalAlignment} {verticalAlignment} class="h-full">
<div
class="rounded-full {position === 'horizontal' ? 'w-full' : 'h-full'}"
style="{position === 'horizontal' ? 'height' : 'width'}: {size}px; background-color: {color}"
></div>
</AlignWrapper>
<div class={classes}>
<slot />
</div>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { AppInput } from '../../inputType'
import { AlignWrapper, InputValue, RunnableWrapper } from '../helpers'
import { toKebabCase } from '../../utils'
export let id: string
export let componentInput: AppInput | undefined
export let horizontalAlignment: 'left' | 'center' | 'right' | undefined = 'left'
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
export let configuration: Record<string, AppInput>
export let initializing: boolean | undefined = undefined
export const staticOutputs: string[] = ['result', 'loading']
let result: string | undefined = undefined
let icon: string | undefined = undefined
let size: number
let color: string
let strokeWidth: number
let iconComponent: any
$: icon && loadIcon(icon)
async function loadIcon(name: string) {
try {
if(name) {
name = toKebabCase(name).replace(/([a-z])(\d)/i, '$1-$2')
iconComponent = (await import(
`../../../../../../node_modules/lucide-svelte/dist/svelte/icons/${name}.svelte`
)).default
} else {
iconComponent = undefined
}
} catch (error) {
console.error(error);
iconComponent = undefined
}
}
</script>
<InputValue {id} input={configuration.icon} bind:value={icon} />
<InputValue {id} input={configuration.size} bind:value={size} />
<InputValue {id} input={configuration.color} bind:value={color} />
<InputValue {id} input={configuration.strokeWidth} bind:value={strokeWidth} />
<RunnableWrapper autoRefresh flexWrap bind:componentInput {id} bind:initializing bind:result>
<AlignWrapper {horizontalAlignment} {verticalAlignment}>
{#if iconComponent}
<svelte:component
this={iconComponent}
size={size || 24}
color={color || 'currentColor'}
strokeWidth={strokeWidth || 2}
/>
{/if}
</AlignWrapper>
</RunnableWrapper>
@@ -0,0 +1,9 @@
export { default as AlignWrapper } from './AlignWrapper.svelte'
export { default as DebouncedInput } from './DebouncedInput.svelte'
export { default as InputDefaultValue } from './InputDefaultValue.svelte'
export { default as InputValue } from './InputValue.svelte'
export { default as MissingConnectionWarning } from './MissingConnectionWarning.svelte'
export { default as NonRunnableComponent } from './NonRunnableComponent.svelte'
export { default as RefreshButton } from './RefreshButton.svelte'
export { default as RunnableComponent } from './RunnableComponent.svelte'
export { default as RunnableWrapper } from './RunnableWrapper.svelte'
@@ -17,6 +17,7 @@
Code2,
SlidersHorizontal,
PlusSquare,
Smile,
DollarSign,
SeparatorHorizontal,
SeparatorVertical
@@ -28,7 +29,7 @@
}
/* How to add a new Svelte Component:
1. add the the type to the union below
1. add the type to the union below
2. add the component to the components record
3. add the comoonent to one of the components set (buttons, inputs, display)
4. add the component in the svelte if switch
@@ -70,6 +71,7 @@
export type SelectComponent = BaseComponent<'selectcomponent'>
export type CheckboxComponent = BaseComponent<'checkboxcomponent'>
export type RadioComponent = BaseComponent<'radiocomponent'>
export type IconComponent = BaseComponent<'iconcomponent'>
export type HorizontalDividerComponent = BaseComponent<'horizontaldividercomponent'>
export type VerticalDividerComponent = BaseComponent<'verticaldividercomponent'>
@@ -98,6 +100,7 @@
| FormButtonComponent
| VegaLiteComponent
| PlotlyComponent
| IconComponent
| HorizontalDividerComponent
| VerticalDividerComponent
)
@@ -108,7 +111,7 @@
{ name: string; icon: any; dims: `${number}:${number}-${number}:${number}`; data: AppComponent }
> = {
displaycomponent: {
name: 'Result',
name: 'Rich Result',
icon: Monitor,
dims: '2:8-6:8',
data: {
@@ -896,7 +899,7 @@
}
},
dateinputcomponent: {
name: 'Date Input',
name: 'Date',
icon: Calendar,
dims: '2:1-3:1',
data: {
@@ -925,6 +928,44 @@
card: false
}
},
iconcomponent: {
name: 'Icon',
icon: Smile,
dims: '1:3-1:2',
data: {
softWrap: false,
horizontalAlignment: 'center',
verticalAlignment: 'center',
id: '',
type: 'iconcomponent',
componentInput: undefined,
configuration: {
icon: {
type: 'static',
value: 'Smile',
fieldType: 'icon-select'
},
color: {
type: 'static',
value: 'currentColor',
fieldType: 'text'
},
size: {
type: 'static',
value: 24,
fieldType: 'number',
onlyStatic: true
},
strokeWidth: {
type: 'static',
value: 2,
fieldType: 'number',
onlyStatic: true
}
},
card: false
}
},
horizontaldividercomponent: {
name: 'Divider X',
icon: SeparatorHorizontal,
@@ -939,14 +980,14 @@
type: 'static',
value: 2,
fieldType: 'number',
onlyStatic: true,
onlyStatic: true
},
color: {
type: 'static',
value: '#00000060',
fieldType: 'text',
onlyStatic: true,
},
onlyStatic: true
}
},
card: false
}
@@ -965,18 +1006,18 @@
type: 'static',
value: 2,
fieldType: 'number',
onlyStatic: true,
onlyStatic: true
},
color: {
type: 'static',
value: '#00000060',
fieldType: 'text',
onlyStatic: true,
},
onlyStatic: true
}
},
card: false
}
},
}
}
const inputs: ComponentSet = {
@@ -1002,18 +1043,19 @@
const display: ComponentSet = {
title: 'Display',
components: [
'textcomponent',
'iconcomponent',
'htmlcomponent',
'tablecomponent',
'barchartcomponent',
'piechartcomponent',
'vegalitecomponent',
'plotlycomponent',
'textcomponent',
'tablecomponent',
'piechartcomponent',
'barchartcomponent',
'scatterchartcomponent',
'timeseriescomponent',
'displaycomponent',
'horizontaldividercomponent',
'verticaldividercomponent',
'verticaldividercomponent'
]
}
@@ -1329,6 +1371,13 @@
"backgroundColor": "orange"
}
]`
},
iconcomponent: {
deno: `export async function main() {
return "smile";
}`,
python3: `def main():
return "smile"`
}
} as const
@@ -1375,6 +1424,7 @@
import PlotlyHtml from '../components/dataDisplay/PlotlyHtml.svelte'
import { defaultAlignement } from './componentsPanel/componentDefaultProps'
import AppRangeInput from '../components/numberInputs/AppRangeInput.svelte'
import AppIcon from '../components/dataDisplay/AppIcon.svelte'
import AppCurrencyInput from '../components/numberInputs/AppCurrencyInput.svelte'
import AppDivider from '../components/AppDivider.svelte'
@@ -1535,6 +1585,8 @@
<AppDivider {...component} position="vertical" />
{:else if component.type === 'rangecomponent'}
<AppRangeInput {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{:else if component.type === 'iconcomponent'}
<AppIcon {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{/if}
</div>
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import type { AppInput, StaticInput } from '../../../inputType'
import { Loader2 } from 'lucide-svelte'
import { Popup } from '../../../../common'
import { fade } from 'svelte/transition'
export let componentInput: StaticInput<string> & Extract<AppInput, { fieldType: 'icon-select' }>
let anchor: HTMLElement
let loading = false
let items: { label: string, icon: any }[]
let filteredItems: { label: string, icon: any }[]
let search = ''
$: if(search) {
filteredItems = items.filter(item => {
return item.label.toLowerCase().includes(search.toLowerCase())
})
} else {
filteredItems = items
}
async function getData() {
if(items) return;
loading = true
const data = await import('lucide-svelte/dist/svelte/icons')
filteredItems = items = Object.entries(data)
.filter(([key]) => !(key.endsWith('Icon') || key.startsWith('Lucide')))
.map(([key, icon]) => ({ label: key, icon }))
loading = false
}
function formatName(name?: string) {
// Inserts space before capital letters and numbers
return name?.replace(/([A-Z])/g, ' $1').trim().replace(/([a-z])(\d)/i, '$1 $2') || ''
}
function select(label: string) {
componentInput.value = label;
if((document.activeElement as HTMLElement)?.blur) {
(document.activeElement as HTMLElement).blur()
}
}
</script>
<input
readonly
value={formatName(componentInput.value)}
bind:this={anchor}
on:focus={getData}
/>
{#if anchor}
<Popup ref={anchor} options={{ placement: 'bottom' }} transition={fade} let:close>
<div class="max-w-xs shadow-[0_10px_40px_-5px_rgba(0,0,0,0.25)] bg-white rounded-md p-2">
{#if loading}
<div class="center-center p-2">
<Loader2 class="animate-spin" size={18} />
</div>
{:else}
{#if filteredItems}
<input bind:value={search} type="text" placeholder="Search" class="col-span-4 mb-2">
<div class="grid gap-1 grid-cols-4 max-h-[300px] overflow-auto">
{#each filteredItems as {label, icon}}
{@const formatedLabel = formatName(label)}
<button
type="button"
title={formatedLabel}
on:click={() => {
select(label)
close()
}}
in:fade|local={{ duration: 200 }}
class="w-full center-center flex-col font-normal p-1
hover:bg-gray-100 focus:bg-gray-100 rounded duration-200
{label === componentInput.value ? 'text-blue-600 bg-blue-50 pointer-events-none' : ''}"
>
<svelte:component this={icon} size={22} />
<span class="inline-block w-full text-[10px] ellipsize pt-0.5">
{formatedLabel}
</span>
</button>
{:else}
<div
in:fade|local={{ duration: 200, delay: 210 }}
class="col-span-4 text-center text-gray-700 text-sm p-2"
>
No icons match your search
</div>
{/each}
</div>
{:else}
<div class="text-center text-sm text-gray-600 p-2">
Couldn't load options
</div>
{/if}
{/if}
</div>
</Popup>
{/if}
@@ -7,6 +7,7 @@
import JsonEditor from './JsonEditor.svelte'
import { getContext } from 'svelte'
import type { AppEditorContext } from '$lib/components/apps/types'
import IconSelectInput from './IconSelectInput.svelte'
export let componentInput: StaticAppInput | undefined
@@ -32,6 +33,8 @@
</option>
{/each}
</select>
{:else if componentInput.fieldType === 'icon-select'}
<IconSelectInput bind:componentInput />
{:else if componentInput.fieldType === 'object'}
{#if componentInput?.format?.startsWith('resource-')}
<ResourcePicker
@@ -8,6 +8,7 @@ export type InputType =
| 'number'
| 'boolean'
| 'select'
| 'icon-select'
| 'date'
| 'time'
| 'datetime'
@@ -116,6 +117,7 @@ export type AppInput =
*/
optionValuesKey: keyof typeof staticValues
})
| AppInputSpec<'icon-select', string>
| AppInputSpec<'array', string[], 'text'>
| AppInputSpec<'array', string[], 'textarea'>
| AppInputSpec<'array', number[], 'number'>
+12
View File
@@ -200,3 +200,15 @@ export function getAllScriptNames(app: App): string[] {
return [...names, ...unusedNames, ...backgroundNames]
}
function clearAndUpper(text: string) {
return text.replace(/-/, "").toUpperCase();
}
export function toPascalCase(text: string) {
return text.replace(/(^\w|-\w)/g, clearAndUpper);
}
export function toKebabCase(text: string) {
return text.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
}
@@ -1,41 +1,40 @@
<svelte:options accessors />
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { slide } from 'svelte/transition'
import { onDestroy } from 'svelte'
import { slide, type TransitionConfig, } from 'svelte/transition'
import { createPopperActions, type PopperOptions } from 'svelte-popperjs'
import { clickOutside } from '../../../utils'
import { createStateMachine } from '../../../stateMachine'
export let ref: HTMLElement
export let options: PopperOptions<any> = { placement: 'auto' }
export let ref: HTMLElement | undefined
export let options: PopperOptions<any> = { placement: 'auto', strategy: 'fixed' }
/** Events on the reference element */
export let openOn: (keyof HTMLElementEventMap)[] = ['focus']
/** Events on the reference element */
export let closeOn: (keyof HTMLElementEventMap)[] = ['blur']
export let innerClasses = ''
export let outerClasses = ''
export let transition: (node: Element, params?: Record<string, any>) => TransitionConfig = slide
const states = ['closed', 'open-focus-in', 'open-focus-out'] as const
const stateMachine = createStateMachine(states, {
to: {
closed: ({ previousState, currentState }) => {
const activeElem = document.activeElement
const revert = popup.contains(activeElem) || ref.contains(activeElem)
return revert ? previousState : currentState
return isFocusContained() ? previousState : currentState
}
}
})
const [popperRef, popperContent] = createPopperActions()
let popup: HTMLElement
const [popperRef, popperContent, getInstance] = createPopperActions()
let popup: HTMLElement | undefined
let focusableElements: HTMLElement[]
function getFocusableElements() {
let elements: HTMLElement[] = []
popup
.querySelectorAll<HTMLElement>(
?.querySelectorAll<HTMLElement>(
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
)
.forEach((elem) => elements.push(elem))
@@ -46,10 +45,15 @@
focusableElements.forEach((el) => {
el.tabIndex = -1
el.addEventListener('click', openFocusIn)
el.addEventListener('blur', closed)
el.addEventListener('blur', conditionalClosed)
})
}
function isFocusContained() {
const activeElem = document.activeElement
return popup?.contains(activeElem) || ref?.contains(activeElem)
}
function closed() {
if ($stateMachine.currentState === 'open-focus-out') {
setTimeout(() => {
@@ -59,6 +63,10 @@
stateMachine.setState('closed')
}
}
function conditionalClosed() {
if(isFocusContained()) return;
closed()
}
function openFocusOut() {
stateMachine.setState('open-focus-out')
}
@@ -69,22 +77,25 @@
function keyDown(event: KeyboardEvent & { currentTarget: EventTarget & Window }) {
const modifiers = ['Shift', 'Control', 'Command', 'Alt']
// Prevent closing the popup when the only key pressed is a modifier key
if (modifiers.includes(event.key) || $stateMachine.currentState === 'closed') return
if (modifiers.includes(event.key) || $stateMachine.currentState === 'closed') return;
if (event.key === 'Escape') {
return (<HTMLElement>document.activeElement)?.blur()
}
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
const prev = ['Up', 'Left']
const next = ['Down', 'Right']
if (![...prev, ...next].some(dir => `Arrow${dir}` === event.key)) return;
event.preventDefault()
if (popup.contains(document.activeElement)) {
if (popup?.contains(document.activeElement)) {
const index = focusableElements.findIndex((elem) => elem === document.activeElement)
if (index === -1) return
if (index === -1) return;
let targetIndex: number | undefined = undefined
if (event.key === 'ArrowUp') {
if (prev.some(dir => `Arrow${dir}` === event.key)) {
targetIndex = index === 0 ? focusableElements.length - 1 : index - 1
} else if (event.key === 'ArrowDown') {
} else if (next.some(dir => `Arrow${dir}` === event.key)) {
targetIndex = index + 1 === focusableElements.length ? 0 : index + 1
}
if (targetIndex !== undefined) {
@@ -101,15 +112,17 @@
}
function addRefListeners() {
openOn.forEach((action) => ref.addEventListener(action, openFocusOut))
closeOn.forEach((action) => ref.addEventListener(action, closed))
if(!ref) return;
openOn.forEach((action) => ref!.addEventListener(action, openFocusOut))
closeOn.forEach((action) => ref!.addEventListener(action, closed))
}
function removeAllListeners() {
focusableElements?.forEach((el) => el.removeEventListener('click', openFocusIn))
focusableElements?.forEach((el) => el.removeEventListener('blur', closed))
openOn.forEach((action) => ref.removeEventListener(action, openFocusOut))
closeOn.forEach((action) => ref.removeEventListener(action, closed))
focusableElements?.forEach((el) => el.removeEventListener('blur', conditionalClosed))
if(!ref) return;
openOn.forEach((action) => ref!.removeEventListener(action, openFocusOut))
closeOn.forEach((action) => ref!.removeEventListener(action, closed))
}
$: if ($stateMachine.currentState === 'closed') {
@@ -117,13 +130,16 @@
} else {
setTimeout(() => {
getFocusableElements()
getInstance()?.update()
}, 0)
}
onMount(() => {
$: if(ref) {
popperRef(ref)
addRefListeners()
})
}
$: $$slots.default && getInstance()?.update()
onDestroy(removeAllListeners)
</script>
@@ -139,9 +155,9 @@
aria-expanded={$stateMachine.currentState !== 'closed'}
>
{#if $stateMachine.currentState !== 'closed'}
<div transition:slide|local={{ duration: 200 }} class={outerClasses}>
<div transition:transition|local={{ duration: 200 }} class={outerClasses}>
<div class={innerClasses}>
<slot />
<slot close={closed} />
</div>
</div>
{/if}