Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow selecting props using other props in presets #4304

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/config/vuestic-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const VuesticConfig = defineVuesticConfig({
presets: {
VaButton: {
addToCart: { round: true, color: 'success', icon: 'shopping_cart', 'slot:default': 'Add to card' },
promotion: { gradient: true, color: 'primary' },
deleteFromCart: { size: 'small', plain: true },
landingHeader: VaButtonLandingHeader,
github: {
Expand Down
1 change: 1 addition & 0 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"build": "yarn build:analysis && nuxt generate --max_old_space_size=4096",
"build:ci": "yarn build:analysis && nuxt generate",
"start:ci": "yarn preview",
"typecheck": "yarn vue-tsc --noEmit",
"build:analysis": "yarn workspace sandbox build:analysis ../docs/page-config/getting-started/tree-shaking",
"serve": "yarn build:analysis --use-cache && nuxt dev",
"generate": "yarn build:analysis && nuxt generate --max_old_space_size=4096",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
createVuestic({
components: {
presets: {
VaButton: {
addToCart: { round: true, color: 'success', icon: 'shopping_cart', 'slot:default': 'Add to card' },
promotion: { gradient: true, color: 'primary' }
},
},
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<VaButton
:preset="['addToCart', 'promotion']"
/>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export default definePageConfig({
block.paragraph("For each component you can make preset configurations. It is useful when you have a set of props that you want to use in different places. For example, you can create a preset for a button with a specific color and size. Then you can use this preset in different places. For example:"),
block.code("components-presets"),
block.example("presets", { hideTitle: true, forceShowCode: true }),
block.paragraph("You can apply multiple presets to the same component. Props from the later presets will override props from the former:"),
block.code("components-presets-multiple"),
block.example("presets-multiple", { hideTitle: true, forceShowCode: true }),

block.subtitle("All components config"),
block.paragraph("You could use `components.all` global config property to set prop values for all components at once. It will be applied if there are no other source of prop value. For example:"),
Expand All @@ -66,7 +69,7 @@ export default definePageConfig({
block.alert("This feature is work in progress. We need to give names to child components and document them. If you want to be able to customize concrete child component, please create an issue on GitHub."),

block.subtitle("Slots config"),
block.paragraph("There are cases when `class` and `style` are not enough. In case you need to change HTML content for component globally use can provide `slot:`. For example:"),
block.paragraph("There are cases when `class` and `style` are not enough. In case you need to change HTML content for component globally use `slot:`. For example:"),
block.code("components-slots"),
block.code("components-slots-style", "css"),
block.example("slots", { hideTitle: true }),
Expand Down
5 changes: 4 additions & 1 deletion packages/docs/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"types": [
"vite/client",
]
}
},
"exclude": [
"page-config/**/**/code/*.ts",
]
}
16 changes: 0 additions & 16 deletions packages/ui/src/composables/useChildComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,3 @@ export const injectChildPropsFromParent = () => {

return computed(() => childProps.value[childName])
}

export const injectChildPresetPropFromParent = () => {
const childName = getCurrentInstance()?.attrs['va-child'] as string

if (!childName) {
return null
}

const childProps = inject(CHILD_COMPONENTS_INJECT_KEY)

if (!childProps?.value) {
return null
}

return computed(() => childProps.value[childName]?.preset as string)
}
8 changes: 7 additions & 1 deletion packages/ui/src/composables/useComponentPreset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { PropType, ExtractPropTypes } from 'vue'

export type PresetPropValue = string | string[];

export const useComponentPresetProp = {
preset: {
type: String,
type: [String, Array] as PropType<PresetPropValue>,
default: undefined,
},
}

export type ComponentPresetProp = ExtractPropTypes<typeof useComponentPresetProp>
39 changes: 34 additions & 5 deletions packages/ui/src/services/component-config/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
import type { VuesticComponentsMap } from '../vue-plugin'
import type { VNodeProps, AllowedComponentProps, HTMLAttributes } from 'vue'
import type { VNodeProps, AllowedComponentProps, HTMLAttributes, VNode, DefineComponent } from 'vue'
import { ComponentSlots } from '../../utils/component-options'
import { ObjectOrGetter } from '../../utils/types/object-or-getter'

export type VuesticComponentName = keyof VuesticComponentsMap
export type VueDefaultPropNames = keyof (VNodeProps & AllowedComponentProps) | `on${string}`

export type Props = { [propName: string]: any }
export type Presets = { [componentName in VuesticComponentName]?: { [presetName: string]: Props } }
export type PropTypes<C> = C extends { new(): { $props: infer Props } } ? Omit<Props, VueDefaultPropNames> : never

export type ComponentConfig = Partial<{
export type VuesticComponentPropsMap = {
// key-value hack to avoid generics in type (like Omit, PropTypes, etc.)
// `key: type` as result
[componentName in VuesticComponentName]: {
[key in keyof PropTypes<VuesticComponentsMap[componentName]>]?: PropTypes<VuesticComponentsMap[componentName]>[key]
} & HTMLAttributes
} & { all: Props, presets: Presets }>
}

export type Props = { [propName: string]: any }

type VuesticComponentSlotsMap = {
[componentName in VuesticComponentName]: {
[key in keyof RemoveIndex<ComponentSlots<VuesticComponentsMap[componentName]>>]?: ComponentSlots<VuesticComponentsMap[componentName]>[key]
}
}

type SlotPropPrefix<T extends string> = `slot:${T}`

export type SlotProp<Scope> = VNode | string | DefineComponent<Partial<Scope>, {}, {}, {}, {}>

type VuesticComponentSlotPropsMap = {
[componentName in VuesticComponentName]: {
// @ts-ignore
[key in keyof VuesticComponentSlotsMap[componentName] as SlotPropPrefix<key>]: SlotProp<Parameters<VuesticComponentSlotsMap[componentName][key]>[0]>
}
}

export type VuesticComponentPresetProps<T extends VuesticComponentName> = VuesticComponentPropsMap[T] & VuesticComponentSlotPropsMap[T]

export type Presets = {
[componentName in VuesticComponentName]?: {
[presetName: string]: ObjectOrGetter<VuesticComponentPresetProps<componentName>, VuesticComponentPropsMap[componentName]>
}
}

export type ComponentConfig = Partial<VuesticComponentPropsMap & { all: Props, presets: Presets }>

export type { DefineComponent as VuesticComponent } from 'vue'
Original file line number Diff line number Diff line change
@@ -1,31 +1,65 @@
import type { VuesticComponent, VuesticComponentName, Props } from '../types'
import { VuesticComponentName, Props, VuesticComponent } from '../types'
import { useLocalConfig } from '../../../composables/useLocalConfig'
import { useGlobalConfig } from '../../global-config/global-config'
import { computed } from 'vue'
import { injectChildPresetPropFromParent } from '../../../composables/useChildComponents'
import { injectChildPropsFromParent } from '../../../composables/useChildComponents'
import { ComponentPresetProp, PresetPropValue } from '../../../composables'
import { notNil } from '../../../utils/isNilValue'
import { head } from 'lodash'
import { getObject } from '../../../utils/object-or-getter'

const withPresetProp = <P extends Props>(props: P): props is P & ComponentPresetProp => 'preset' in props
const getPresetProp = <P extends Props>(props: P) => withPresetProp(props) ? props.preset : undefined

export const useComponentConfigProps = <T extends VuesticComponent>(component: T, originalProps: Props) => {
const localConfig = useLocalConfig()
const { globalConfig } = useGlobalConfig()

const instancePreset = computed(() => originalProps.preset)
const getPresetProps = (presetName: string) => globalConfig.value.components?.presets?.[component.name as VuesticComponentName]?.[presetName]
const parentPropPreset = injectChildPresetPropFromParent()
const componentName = component.name as VuesticComponentName

const getPresetProps = (presetPropValue: PresetPropValue): Props => {
return (presetPropValue instanceof Array ? presetPropValue : [presetPropValue]).reduce<Props>((acc, presetName) => {
const preset = globalConfig.value.components?.presets?.[componentName]?.[presetName]

if (!preset) {
return acc
}

const presetProps = getObject(preset, originalProps)

const extendedPresets = getPresetProp(presetProps)

return {
...acc,
...(extendedPresets ? getPresetProps(extendedPresets) : undefined),
...presetProps,
}
}, {})
}
const parentInjectedProps = injectChildPropsFromParent()

return computed(() => {
const globalConfigProps: Props = {
...globalConfig.value.components?.all,
...globalConfig.value.components?.[component.name as VuesticComponentName],
...globalConfig.value.components?.[componentName],
}

const localConfigProps: Props = localConfig.value
.reduce((finalConfig, config) => config[component.name as VuesticComponentName]
? { ...finalConfig, ...config[component.name as VuesticComponentName] }
: finalConfig
, {})
const localConfigProps = localConfig.value
.reduce<Props>((finalConfig, config) => {
const componentConfigProps = config[componentName]

return componentConfigProps
? { ...finalConfig, ...componentConfigProps }
: finalConfig
}, {})

const presetName = parentPropPreset?.value || instancePreset.value || localConfigProps.preset || globalConfigProps.preset
const presetProps = presetName && getPresetProps(presetName)
const presetProp = head([
parentInjectedProps?.value,
originalProps,
localConfigProps,
globalConfigProps,
].filter(notNil).map(getPresetProp).filter(notNil))
const presetProps = presetProp ? getPresetProps(presetProp) : undefined

return { ...globalConfigProps, ...localConfigProps, ...presetProps }
})
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/services/config-transport/createRenderFn.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { withCtx, h, DefineComponent, VNode, isVNode, Text, createBlock } from 'vue'
import type { SlotProp } from '../component-config'

type VueInternalRenderFunction = Function

export const renderSlotNode = (node: VNode, ctx = null) => {
return withCtx(() => [node], ctx)
}

export const makeVNode = (node: VNode | string | DefineComponent) => {
export const makeVNode = <T>(node: SlotProp<T>) => {
if (typeof node === 'string') {
return h(Text, node)
}
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/utils/component-options/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export type ComponentProps<T> =
T extends (props: infer P, ...args: any) => any ? P :
unknown;

export type ComponentSlots<T> =
T extends new () => { $slots: infer S; } ? NonNullable<S> :
T extends (props: any, ctx: { slots: infer S; attrs: any; emit: any; }, ...args: any) => any ? NonNullable<S> :
{};

export type UnKeyofString<T> = T extends infer E & ThisType<void> ? E : never
export type ExtractVolarEmitsType<T> = 'emits' extends keyof T
? UnKeyofString<(T['emits'] extends infer E | undefined ? E : never)>
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/utils/isNilValue.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
const nilValues = [null, undefined, '' as const]

/**
* Checks if provided value not exists.
*
* @param value any value to check it.
*/
export const isNilValue = (value: any): value is null | undefined | '' => {
// lodash `isNil` isn't an alternative, because we also want to handle empty string values
return [null, undefined, ''].includes(value)
return nilValues.includes(value)
}

export const notNil = <T>(value: T): value is NonNullable<T> => !isNilValue(value)
9 changes: 9 additions & 0 deletions packages/ui/src/utils/object-or-getter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ObjectOrGetter } from './types/object-or-getter'

export const getObject = <T extends object, P>(objectOrGetter: ObjectOrGetter<T, P>, baseProps: P) => {
if (typeof objectOrGetter === 'function') {
return objectOrGetter(baseProps)
}

return objectOrGetter
}
11 changes: 11 additions & 0 deletions packages/ui/src/utils/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type RemoveIndex<T> = {
[ K in keyof T as
string extends K
? never
: number extends K
? never
: symbol extends K
? never
: K
]: T[K];
}
1 change: 1 addition & 0 deletions packages/ui/src/utils/types/object-or-getter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ObjectOrGetter<T extends object, P> = T | ((props: P) => T)
Loading