mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 08:07:15 +00:00
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:
co-authored by
Ruben Fiszel
parent
c33e79e0b8
commit
e4791c2b7e
Generated
+7
-20
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+98
@@ -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}
|
||||
+3
@@ -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'>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user