feat(frontend): App component style input grouping (#1334)

* feat(frontend): App component style input grouping

* fix: default value

* fix

* update styling

* fix property toggling

---------

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
This commit is contained in:
Ádám Kovács
2023-03-29 19:24:20 +02:00
committed by GitHub
parent 537793a256
commit be9e5309c6
9 changed files with 252 additions and 144 deletions
@@ -1,20 +1,21 @@
<script lang="ts">
import { Paintbrush2 } from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import { addWhitespaceBeforeCapitals } from '../../../../utils'
import { Button, ClearableInput } from '../../../common'
import Popover from '../../../Popover.svelte'
import type { AppViewerContext, ComponentCssProperty } from '../../types'
import type { ComponentCssProperty } from '../../types'
import type { TypedComponent } from '../component'
import QuickStyleMenu from './QuickStyleMenu.svelte'
import type { StylePropertyKey } from './quickStyleProperties'
import type { PropertyGroup } from './quickStyleProperties'
export let name: string
export let value: ComponentCssProperty = {}
export let forceStyle: boolean = false
export let forceClass: boolean = false
export let quickStyleProperties: StylePropertyKey[] | undefined = undefined
const { app } = getContext<AppViewerContext>('AppViewerContext')
export let quickStyleProperties: PropertyGroup[] | undefined = undefined
export let componentType: TypedComponent['type'] | undefined = undefined
const dispatch = createEventDispatcher()
let isQuickMenuOpen = false
@@ -26,12 +27,12 @@
</script>
<div
class="sticky top-0 z-20 text-lg bg-white font-semibold [font-variant:small-caps] text-gray-700 pt-2 pb-1"
class="sticky top-0 z-20 text-lg bg-gray-100 font-semibold lowercase leading-none [font-variant:small-caps] text-gray-700 px-3 pb-1 mt-4 mb-1"
>
{addWhitespaceBeforeCapitals(name)}
</div>
{#if value}
<div class="border-l border-gray-400/80 py-1 pl-3.5 ml-0.5">
<div class="px-3">
{#if value.style !== undefined || forceStyle}
<div class="pb-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
@@ -54,7 +55,7 @@
color="light"
size="xs"
btnClasses="!p-1 !w-[34px] !h-[34px] {isQuickMenuOpen
? '!bg-gray-200/60 hover:!bg-gray-200'
? '!bg-gray-200/60 hover:!bg-gray-200 focus:!bg-gray-200'
: ''}"
aria-label="{isQuickMenuOpen ? 'Close' : 'Open'} styling menu"
on:click={toggleQuickMenu}
@@ -71,7 +72,12 @@
</label>
{#if quickStyleProperties?.length && isQuickMenuOpen}
<div transition:fade|local={{ duration: 200 }} class="w-full pt-1">
<QuickStyleMenu bind:value={value.style} properties={quickStyleProperties} />
<QuickStyleMenu
bind:value={value.style}
properties={quickStyleProperties}
{componentType}
componentProperty={name}
/>
</div>
{/if}
</div>
@@ -2,10 +2,15 @@
import { slide } from 'svelte/transition'
import { ChevronDown } from 'lucide-svelte'
import { isOpenStore } from './store'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
export let title: string
export let prefix: string | undefined = undefined
export let openByDefault: boolean = false
export let wrapperClasses = ''
export let toggleClasses = ''
export let contentWrapperClasses = ''
export let isOpen = false
const dispatch = createEventDispatcher()
@@ -13,14 +18,20 @@
$: isOpen = prefix ? $isOpenStore[storeTitle] : true
$: dispatch('open', isOpen)
onMount(() => {
if (prefix !== undefined && !(prefix + title in $isOpenStore)) {
$isOpenStore[prefix + title] = openByDefault
}
})
</script>
<section class="pt-1 pb-2 px-1">
<section class="pt-1 pb-2 px-1 {wrapperClasses}">
{#if prefix !== undefined}
<button
on:click|preventDefault={() => isOpenStore.toggle(storeTitle)}
class="w-full flex justify-between items-center text-gray-700 px-2 py-1
rounded-sm duration-200 hover:bg-gray-100"
rounded-sm duration-200 hover:bg-gray-100 {toggleClasses}"
>
<h1 class="text-sm font-semibold text-left">
<slot name="title">
@@ -30,7 +41,7 @@
<ChevronDown class="rotate-0 duration-300 {isOpen ? '!rotate-180' : ''}" />
</button>
{#if isOpen}
<div transition:slide|local={{ duration: 300 }} class="px-2">
<div transition:slide|local={{ duration: 300 }} class="px-2 {contentWrapperClasses}">
<slot />
</div>
{/if}
@@ -8,18 +8,24 @@
StylePropertyType,
StylePropertyUnits,
STYLE_STORE_KEY,
type PropertyGroup,
type StylePropertyKey,
type TopColors
} from './quickStyleProperties'
import QuickStyleProperty from './QuickStyleProperty.svelte'
import ListItem from './ListItem.svelte'
import type { TypedComponent } from '../component'
export let value = ''
export let properties: StylePropertyKey[]
export let properties: PropertyGroup[]
export let componentType: TypedComponent['type'] | undefined = undefined
export let componentProperty: string | undefined = undefined
const { app } = getContext<AppViewerContext>('AppViewerContext')
const styleStore = createStyleStore(properties)
setContext(STYLE_STORE_KEY, styleStore)
let multiValues: Record<number, string[]> = initiateMultiValues()
let mounted = false
let isOpen: Record<string, boolean> = {}
$: mounted && $styleStore && writeStyle()
$: mounted && (!value || value) && parseStyle()
@@ -154,32 +160,70 @@
$styleStore.style[index].value = values.join(' ').trim()
}
function formatKebabCase(text: string) {
text = text.toLowerCase().replace(/-/, ' ')
if (text.length) {
text = text[0].toUpperCase() + text.slice(1)
}
return text
}
onMount(() => {
parseStyle()
mounted = true
})
</script>
<div class="bg-gray-200/60 rounded-md p-2">
{#each $styleStore.style as { prop }, index}
<div class="pb-3 last:pb-0">
<div class="text-sm font-medium text-gray-600 pb-0.5"> {prop.key} </div>
<div class="flex items-center gap-1 w-full">
{#if Array.isArray(prop.value)}
<div class="flex justify-start items-center flex-wrap gap-x-4 gap-y-1">
{#each prop.value as value, i}
<QuickStyleProperty
prop={{ ...prop, value }}
inline
bind:value={multiValues[index][i]}
on:change={() => setMultiValueProperty(index)}
/>
{/each}
</div>
{:else}
<QuickStyleProperty {prop} bind:value={$styleStore.style[index].value} />
{/if}
</div>
</div>
<div class="pb-2 pt-1">
{#each properties as property, i}
{#each Object.keys(property) as group, j}
{@const prefix = `${componentType}_${componentProperty}_${group}`}
<ListItem
bind:isOpen={isOpen[prefix]}
title={group}
{prefix}
openByDefault={true}
wrapperClasses="!px-0 !pt-0"
toggleClasses="border-b border-gray-300 !rounded-b-none !py-0
{isOpen[prefix] ? '!bg-gray-100 hover:!bg-gray-200' : ''}"
>
<svelte:fragment slot="title">
<span class="font-semibold text-gray-600 capitalize">
{group}
</span>
</svelte:fragment>
<div class="flex justify-start items-center flex-wrap gap-x-4 gap-y-1 pt-3">
{#each property[group] as p}
{@const {
prop: { prop },
index
} = styleStore.getProp(p)}
{#if prop !== undefined && index !== undefined}
<div class="pb-2 pt-1">
<div class="text-sm font-medium text-gray-600 pb-0.5">
{formatKebabCase(prop.key)}
</div>
<div class="flex items-center gap-1">
{#if Array.isArray(prop.value)}
<div class="flex justify-start items-center flex-wrap gap-x-4 gap-y-1">
{#each prop.value as value, i}
<QuickStyleProperty
prop={{ ...prop, value }}
inline
bind:value={multiValues[index][i]}
on:change={() => setMultiValueProperty(index)}
/>
{/each}
</div>
{:else}
<QuickStyleProperty {prop} inline bind:value={$styleStore.style[index].value} />
{/if}
</div>
</div>
{/if}
{/each}
</div>
</ListItem>
{/each}
{/each}
</div>
@@ -60,6 +60,7 @@
{:else if type === StylePropertyType.number}
<ClearableInput
type="number"
inputClass={inline ? '!w-[90px]' : ''}
bind:value
step={prop.value?.['step'] || 1}
min={prop.value?.['min']}
@@ -21,6 +21,7 @@ import {
Underline
} from 'lucide-svelte'
import type { components } from '../component'
import type { SvelteComponent } from 'svelte'
export const STYLE_STORE_KEY = 'style_store' as const
@@ -33,8 +34,12 @@ export type StyleStoreValue = {
topColors: TopColors
}
export function createStyleStore(properties: StylePropertyKey[]) {
const style = StyleProperty.filter((p) => properties.includes(p.key)).map((p) => ({
export function createStyleStore(properties: PropertyGroup[]) {
const propertyNames = properties.reduce((acc, p) => {
Object.values(p).forEach((names) => acc.push(...names))
return acc
}, [] as string[])
const style = StyleProperty.filter((p) => propertyNames.includes(p.key)).map((p) => ({
prop: p,
value: '' as string | undefined
}))
@@ -93,10 +98,45 @@ export const StylePropertyUnits = ['px', 'em', 'rem', '%', 'vh', 'vw']
export type TopColors = [] | [string] | [string, string] | [string, string, string]
export type StylePropertyOption = {
text: string
icon: string | typeof SvelteComponent
}
export type BaseStylePropertyValue<T extends StylePropertyType> = {
type: T
title?: string
}
export type StylePropertyColorValue = BaseStylePropertyValue<StylePropertyType.color>
export type StylePropertyUnitValue = BaseStylePropertyValue<StylePropertyType.unit>
export type StylePropertyNumberValue = BaseStylePropertyValue<StylePropertyType.number> & {
step?: number
min?: number
max?: number
}
export type StylePropertyTextValue = BaseStylePropertyValue<StylePropertyType.text> & {
options?: StylePropertyOption[]
}
export type StylePropertyValue =
| StylePropertyColorValue
| StylePropertyUnitValue
| StylePropertyNumberValue
| StylePropertyTextValue
export type StyleProperty = {
key: string
value: StylePropertyValue | StylePropertyValue[]
}
export type StylePropertyKey = (typeof StyleProperty)[number]['key']
// Using an array instead of an object to preserve the order of the properties
export const StyleProperty = [
export const StyleProperty: StyleProperty[] = [
{
key: 'display',
value: {
@@ -277,18 +317,18 @@ export const StyleProperty = [
type: StylePropertyType.color
}
},
{
key: 'font-size',
value: {
type: StylePropertyType.unit
}
},
{
key: 'font-family',
value: {
type: StylePropertyType.text
}
},
{
key: 'font-size',
value: {
type: StylePropertyType.unit
}
},
{
key: 'font-weight',
value: {
@@ -421,67 +461,91 @@ export const StyleProperty = [
]
}
}
] as const
const allDefaultProps = StyleProperty.map(({ key }) => key)
const containerDefaultProps: StylePropertyKey[] = [
'padding',
'opacity',
'border',
'border-radius',
'box-shadow',
'background-color',
'overflow'
]
const textDefaultProps: StylePropertyKey[] = [
'padding',
'opacity',
'background-color',
'color',
'font-size',
'font-family',
'font-weight',
'font-style',
'text-align',
'text-decoration',
'text-transform',
'line-height',
'letter-spacing',
'word-spacing'
export type PropertyGroup = Record<string, StylePropertyKey[]>
// Property groups
const layoutGrouping: PropertyGroup = {
layout: ['display', 'overflow']
}
const sizeGrouping: PropertyGroup = {
size: ['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height']
}
const spacingGrouping: PropertyGroup = {
spacing: ['padding']
}
const borderGrouping: PropertyGroup = {
borders: ['border', 'border-radius']
}
const typographyGrouping: PropertyGroup = {
typography: [
'color',
'font-size',
'font-family',
'font-weight',
'font-style',
'text-align',
'text-decoration',
'text-transform',
'line-height',
'letter-spacing',
'word-spacing'
]
}
const backgroundGrouping: PropertyGroup = {
background: ['background-color', 'opacity', 'box-shadow']
}
const miscGrouping: PropertyGroup = {
miscellaneous: ['cursor']
}
// Commonly used-together property groups
const containerDefaultProps: PropertyGroup[] = [
layoutGrouping,
spacingGrouping,
backgroundGrouping,
borderGrouping
]
const sizeDefaultProps: StylePropertyKey[] = [
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height'
const buttonDefaultProps: PropertyGroup[] = [
backgroundGrouping,
borderGrouping,
typographyGrouping,
miscGrouping
]
const buttonDefaultProps: StylePropertyKey[] = [
'cursor',
'border',
'border-radius',
'box-shadow',
...textDefaultProps
const inputDefaultProps: PropertyGroup[] = [borderGrouping, typographyGrouping, miscGrouping]
const sliderDefaultProps: PropertyGroup[] = [
{
colors: ['color', 'background-color', 'opacity']
},
borderGrouping,
miscGrouping
]
const inputDefaultProps: StylePropertyKey[] = [
'cursor',
'border',
'border-radius',
...textDefaultProps
const dividerDefaultProps: PropertyGroup[] = [
{
colors: ['background-color', 'opacity']
},
sizeGrouping,
spacingGrouping,
borderGrouping
]
export const quickStyleProperties: Record<
keyof typeof components,
Record<string, StylePropertyKey[]>
Record<string, PropertyGroup[]>
> = {
mapcomponent: {
map: containerDefaultProps
map: [spacingGrouping, borderGrouping]
},
pdfcomponent: {
container: containerDefaultProps
@@ -491,40 +555,43 @@ export const quickStyleProperties: Record<
button: buttonDefaultProps
},
htmlcomponent: {
container: allDefaultProps
container: [
layoutGrouping,
sizeGrouping,
spacingGrouping,
borderGrouping,
typographyGrouping,
backgroundGrouping
]
},
iconcomponent: {
container: containerDefaultProps,
icon: [
'padding',
'opacity',
'cursor',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'color'
{
colors: ['color', 'background-color', 'opacity']
},
sizeGrouping,
spacingGrouping,
miscGrouping
]
},
tabscomponent: {
tabRow: containerDefaultProps,
allTabs: [...textDefaultProps, ...sizeDefaultProps],
selectedTab: [...textDefaultProps, ...sizeDefaultProps],
allTabs: [typographyGrouping, sizeGrouping],
selectedTab: [typographyGrouping, sizeGrouping],
container: containerDefaultProps
},
textcomponent: {
text: textDefaultProps
text: [typographyGrouping]
},
imagecomponent: {
image: containerDefaultProps
},
rangecomponent: {
handles: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
bar: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
limits: textDefaultProps,
values: textDefaultProps
handles: sliderDefaultProps,
bar: sliderDefaultProps,
limits: [typographyGrouping],
values: [typographyGrouping]
},
tablecomponent: {
tableHeader: containerDefaultProps,
@@ -544,20 +611,20 @@ export const quickStyleProperties: Record<
input: inputDefaultProps
},
slidercomponent: {
handles: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
bar: ['opacity', 'cursor', 'border', 'border-radius', 'background-color'],
limits: textDefaultProps,
values: textDefaultProps
handles: sliderDefaultProps,
bar: sliderDefaultProps,
limits: [typographyGrouping],
values: [typographyGrouping]
},
displaycomponent: {
header: [...containerDefaultProps, ...textDefaultProps],
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
},
barchartcomponent: {
container: containerDefaultProps
},
checkboxcomponent: {
text: textDefaultProps
text: [typographyGrouping]
},
currencycomponent: {
input: inputDefaultProps
@@ -607,35 +674,11 @@ export const quickStyleProperties: Record<
input: inputDefaultProps
},
verticaldividercomponent: {
divider: [
'padding',
'opacity',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'border',
'border-radius',
'background-color'
],
divider: dividerDefaultProps,
container: containerDefaultProps
},
horizontaldividercomponent: {
divider: [
'padding',
'opacity',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'border',
'border-radius',
'background-color'
],
divider: dividerDefaultProps,
container: containerDefaultProps
},
verticalsplitpanescomponent: {
@@ -7,6 +7,7 @@ export type IsOpenStoreItem = Record<string, boolean>
export const isOpenStore = {
subscribe: store.subscribe,
update: store.update,
set: store.set,
/** If an item is already set, it won't get updated. */
addItems: (items: IsOpenStoreItem[]) => {
let newItems = {}
@@ -47,6 +47,7 @@
color="light"
size="xs"
aria-label="Apply to all instances of this component"
btnClasses="ml-3 mt-2"
on:click={applyToAllInstances}
>
Copy style to global CSS &nbsp;<Copy size={18} />
@@ -60,6 +61,7 @@
forceStyle={ccomponents[component.type].customCss[name].style !== undefined}
forceClass={ccomponents[component.type].customCss[name].class !== undefined}
{name}
componentType={component.type}
bind:value={component.customCss[name]}
on:change={() => app.set($app)}
/>
@@ -32,7 +32,7 @@
box-shadow: 0 10px 40px -5px rgba(0, 0, 0, 0.25) !important;
border-style: none !important;
border-radius: 0.375rem !important;
z-index: 20;
z-index: 30;
}
.color-picker-input .slider-wrapper {
@@ -27,7 +27,7 @@
id={SECONDARY_MENU_ID}
class="flex flex-col w-full h-full bg-white"
>
<div class="flex justify-between items-center bg-white gap-1 p-3">
<div class="flex justify-between items-center bg-white gap-1 px-3 py-2">
<Button
color="light"
size="xs"
@@ -39,7 +39,7 @@
</Button>
<Badge color="blue">{$selectedComponent}</Badge>
</div>
<div class="relative h-full overflow-y-auto px-3 pb-3">
<div class="relative h-full overflow-y-auto">
{#if typeof $secondaryMenu.component === 'string'}
{@html $secondaryMenu.component}
{:else}