Skip to content

Commit

Permalink
feat: New FormField component
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrisGV04 committed Feb 26, 2024
1 parent afa02dc commit ae1ccd1
Show file tree
Hide file tree
Showing 12 changed files with 132 additions and 102 deletions.
32 changes: 9 additions & 23 deletions src/runtime/components/forms/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ type UiConfig = Partial<typeof config> & { strategy?: Strategy };

<script setup lang="ts">
import UiIcon from '#ui/components/elements/Icon.vue';
import UiFormLabel from '#ui/components/forms/FormLabel.vue';
import UiFormField from '#ui/components/forms/FormField.vue';
import { useUI } from '#ui/composables/useUI';
import type { ComboboxItem, ComboboxOptions, ComboboxProps, Strategy } from '#ui/types';
import { getUiFormFieldProps } from '#ui/utils/forms';
import { useDebounceFn, useToNumber } from '@vueuse/core';
import omit from 'just-omit';
import pick from 'just-pick';
import { useForwardPropsEmits, type ComboboxRootEmits } from 'radix-vue';
import { useForwardProps, useForwardPropsEmits, type ComboboxRootEmits } from 'radix-vue';
import { Combobox } from 'radix-vue/namespaced';
import { twJoin, twMerge } from 'tailwind-merge';
import { computed, defineOptions, ref, toRef, watch, withDefaults } from 'vue';
Expand All @@ -43,18 +45,14 @@ const props = withDefaults(defineProps<ComboboxProps<UiConfig>>(), {
});
const emits = defineEmits<ComboboxRootEmits>();
const { ui, attrs } = useUI('combobox', toRef(props, 'ui'), config);
const { ui } = useUI('combobox', toRef(props, 'ui'), config);
const numOffset = useToNumber(props.offset);
const dataAttrs = computed(() => ({
'data-error': props.error ? '' : undefined,
'data-disabled-mode': props.readOnly ? 'read-only' : props.disabled ? 'disabled' : undefined,
}));
const rootProps = computed(() => pick(props, ['defaultValue', 'disabled', 'multiple', 'name', 'modelValue']));
const forwarded = useForwardPropsEmits(rootProps, emits);
const fieldProps = useForwardProps(() => getUiFormFieldProps(omit(props, ['ui'])));
const itemClasses = computed(() =>
twMerge(
twJoin(
Expand Down Expand Up @@ -184,13 +182,7 @@ watch(
:display-value="() => ''"
:filter-function="(v) => v"
>
<div v-bind="{ ...dataAttrs, ...attrs }" :class="twMerge(ui.anchor.wrapper, props.class, 'group')">
<slot name="label">
<UiFormLabel v-if="props.label" :for="props.name" :error="props.error" :mandatory="props.mandatory">{{
props.label
}}</UiFormLabel>
</slot>

<UiFormField v-bind="fieldProps" :name="props.name">
<Combobox.Anchor as-child>
<Combobox.Trigger
tabindex="0"
Expand All @@ -210,13 +202,7 @@ watch(
<UiIcon :name="props.suffixIcon" :class="[ui.anchor.icon, 'mr-3']" />
</Combobox.Trigger>
</Combobox.Anchor>

<slot name="message">
<p v-if="props.message" :id="`${props.name}-msg`" :class="ui.anchor.font.message">
{{ props.message }}
</p>
</slot>
</div>
</UiFormField>

<Combobox.Portal>
<Combobox.Content
Expand Down
57 changes: 57 additions & 0 deletions src/runtime/components/forms/FormField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script lang="ts">
// @ts-expect-error
import appConfig from '#build/app.config';
import { formField } from '#ui/ui.config';
import { mergeConfig } from '#ui/utils';
const config = mergeConfig<typeof formField>(
appConfig.ui?.formField?.strategy,
appConfig.ui?.formField,
formField,
);
type UiConfig = Partial<typeof config> & { strategy?: Strategy };
</script>

<script setup lang="ts">
import UiInputLabel from '#ui/components/forms/FormLabel.vue';
import { useUI } from '#ui/composables/useUI';
import type { FormFieldProps, Strategy } from '#ui/types';
import { Primitive } from 'radix-vue';
import { twMerge } from 'tailwind-merge';
import { computed, toRef, withDefaults } from 'vue';
const props = withDefaults(defineProps<FormFieldProps<UiConfig>>(), {
as: 'div',
class: undefined,
ui: () => ({}) as UiConfig,
});
const { ui } = useUI('formField', toRef(props, 'ui'), config);
const dataAttrs = computed(() => ({
'data-error': props.error ? '' : undefined,
'data-disabled-mode': props.readOnly ? 'read-only' : props.disabled ? 'disabled' : undefined,
}));
</script>

<template>
<Primitive
v-bind="dataAttrs"
:as="props.as"
:as-child="props.asChild"
:class="twMerge(ui.wrapper, props.class, 'group')"
>
<slot name="label">
<UiInputLabel v-if="props.label" :for="props.name" :mandatory="props.mandatory" :error="props.error">{{
props.label
}}</UiInputLabel>
</slot>

<slot />

<slot name="message">
<p v-if="props.message" :id="`${props.name}-hint`" :class="ui.message">{{ props.message }}</p>
</slot>
</Primitive>
</template>
58 changes: 16 additions & 42 deletions src/runtime/components/forms/FormInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,25 @@ type UiConfig = Partial<typeof config> & { strategy?: Strategy };

<script setup lang="ts">
import UiIcon from '#ui/components/elements/Icon.vue';
import UiInputLabel from '#ui/components/forms/FormLabel.vue';
import UiFormField from '#ui/components/forms/FormField.vue';
import { useUI } from '#ui/composables/useUI';
import type { InputProps, Strategy } from '#ui/types';
import { getUiFormFieldProps } from '#ui/utils/forms';
import { useVModel } from '@vueuse/core';
import { Primitive } from 'radix-vue';
import omit from 'just-omit';
import { useForwardProps } from 'radix-vue';
import { twMerge } from 'tailwind-merge';
import { computed, defineOptions, toRef, withDefaults } from 'vue';
import { defineOptions, toRef, withDefaults } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<InputProps<UiConfig>>(), {
as: 'div',
type: 'text',
label: undefined,
modelValue: undefined,
message: undefined,
prefixIcon: undefined,
prefixText: undefined,
suffixText: undefined,
placeholder: undefined,
defaultValue: undefined,
class: undefined,
ui: () => ({}) as UiConfig,
});
const emits = defineEmits({
Expand All @@ -48,33 +46,16 @@ const $value = useVModel(props, 'modelValue', emits, { passive: true, defaultVal
const { ui, attrs } = useUI('formInput', toRef(props, 'ui'), config);
const dataAttrs = computed(() => ({
'data-error': props.error ? '' : undefined,
'data-disabled-mode': props.readOnly ? 'read-only' : props.disabled ? 'disabled' : undefined,
}));
const fieldProps = useForwardProps(() => getUiFormFieldProps(omit(props, ['ui'])));
</script>

<template>
<Primitive
v-bind="dataAttrs"
:as="props.as"
:as-child="props.asChild"
:class="twMerge(ui.wrapper, props.class, 'group')"
>
<slot name="label">
<UiInputLabel v-if="props.label" :for="props.name" :mandatory="props.mandatory" :error="props.error">{{
props.label
}}</UiInputLabel>
</slot>

<div :class="twMerge(ui.container.base, ui.container.rounded, ui.container.ring, ui.container.border)">
<UiFormField v-bind="fieldProps" :name="props.name">
<div :class="twMerge(ui.base, ui.rounded, ui.ring, ui.border)">
<slot name="prefix">
<span
v-if="props.prefixText"
:class="twMerge(ui.font.addons, 'ml-3')"
@click="emits('click:prefix')"
>{{ props.prefixText }}</span
>
<span v-if="props.prefixText" :class="twMerge(ui.addons, 'ml-3')" @click="emits('click:prefix')">{{
props.prefixText
}}</span>
<UiIcon
v-else-if="props.prefixIcon"
:name="props.prefixIcon"
Expand All @@ -91,16 +72,13 @@ const dataAttrs = computed(() => ({
:type="props.type"
:placeholder="props.placeholder"
:disabled="props.disabled || props.readOnly"
:class="twMerge(ui.input.base, ui.input.padding, ui.font.input)"
:class="twMerge(ui.input.base, ui.input.padding, ui.input.font)"
/>

<slot name="suffix">
<span
v-if="props.suffixText"
:class="twMerge(ui.font.addons, 'mr-3')"
@click="emits('click:suffix')"
>{{ props.suffixText }}</span
>
<span v-if="props.suffixText" :class="twMerge(ui.addons, 'mr-3')" @click="emits('click:suffix')">{{
props.suffixText
}}</span>
<UiIcon
v-else-if="props.suffixIcon"
:name="props.suffixIcon"
Expand All @@ -109,9 +87,5 @@ const dataAttrs = computed(() => ({
/>
</slot>
</div>

<slot name="message">
<p v-if="props.message" :id="`${props.message}-hint`" :class="ui.font.message">{{ props.message }}</p>
</slot>
</Primitive>
</UiFormField>
</template>
11 changes: 2 additions & 9 deletions src/runtime/types/combobox.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FormFieldProps } from '#ui/types';
import type { ComboboxContentProps, ComboboxRootProps } from 'radix-vue';

export interface ComboboxItem {
Expand All @@ -8,19 +9,12 @@ export interface ComboboxItem {

export type ComboboxOptions = Record<string, ComboboxItem[]> | ComboboxItem[];

export interface ComboboxProps<UiConfig = any> {
name: string;
label?: string;
message?: string;
export interface ComboboxProps<UiConfig = any> extends Omit<FormFieldProps, 'ui'> {
emptyMsg?: string;
placeholder?: string;
searchPlaceholder?: string;

error?: boolean;
multiple?: boolean;
readOnly?: boolean;
disabled?: boolean;
mandatory?: boolean;

prefixIcon?: string;
prefixText?: string;
Expand All @@ -30,7 +24,6 @@ export interface ComboboxProps<UiConfig = any> {
loadingIcon?: string;
indicatorIcon?: string;

class?: any;
ui?: UiConfig;

options?: ComboboxOptions;
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/types/formField.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { PrimitiveProps } from 'radix-vue';

export interface FormFieldProps<UiConfig = any> extends PrimitiveProps {
name: string;
label?: string;
error?: boolean;
message?: string;
disabled?: boolean;
readOnly?: boolean;
mandatory?: boolean;

ui?: UiConfig;
class?: any;
}
17 changes: 6 additions & 11 deletions src/runtime/types/formInput.d.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import type { FormFieldProps } from '#ui/types';
import type { InputHTMLAttributes } from 'vue';
import type { PrimitiveProps } from 'radix-vue';

export interface InputProps<UiConfig = any> extends PrimitiveProps {
name: string;
label?: string;
error?: boolean;
message?: string;
readOnly?: boolean;
disabled?: boolean;
mandatory?: boolean;
export interface InputProps<UiConfig = any> extends Omit<FormFieldProps, 'ui'> {
prefixIcon?: string;
prefixText?: string;
suffixIcon?: string;
suffixText?: string;
placeholder?: string;
type?: InputHTMLAttributes['type'];

/** Controlled input value */
modelValue?: string | number;

/** Uncontrolled default value */
defaultValue?: string | number;
type?: InputHTMLAttributes['type'];

ui?: UiConfig;
class?: any;
}
1 change: 1 addition & 0 deletions src/runtime/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './badge';
export * from './button';
export * from './combobox';
export * from './dropdown';
export * from './formField';
export * from './formInput';
export * from './link';
export * from './utils';
4 changes: 1 addition & 3 deletions src/runtime/ui.config/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@ import type { ComboboxContentProps } from 'radix-vue';

export default /*ui*/ {
anchor: {
wrapper: 'flex flex-col gap-y-1 data-[disabled-mode=disabled]:opacity-70',
base: 'flex w-full items-center bg-white group-data-[error]:bg-red-50',
rounded: 'rounded-lg',
ring: 'focus-within:ring-2 focus-within:ring-primary-600 group-data-[error]:focus-within:ring-red-400',
border: 'border border-gray-900/10 group-data-[error]:border-red-800',
icon: 'size-4 text-gray-600 group-data-[error]:text-red-600',
font: {
value: 'text-sm text-left truncate text-gray-900 group-data-[error]:text-red-800',
message: 'text-xs text-gray-500 group-data-[error]:text-red-600',
addons: 'text-sm select-none text-gray-500 group-data-[error]:text-red-600',
},
value: {
base: 'block h-8 w-full flex-1',
padding: 'px-2 py-1.5',
},
icon: 'size-4 text-gray-600 group-data-[error]:text-red-600',
},

input: {
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/ui.config/formField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default /*ui*/ {
wrapper: 'flex flex-col gap-y-1 data-[disabled-mode=disabled]:opacity-70',
message: 'text-xs text-gray-500 group-data-[error]:text-red-600',
};
22 changes: 8 additions & 14 deletions src/runtime/ui.config/formInput.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
export default /*ui*/ {
wrapper: 'flex flex-col gap-y-1 data-[disabled-mode=disabled]:opacity-70',
container: {
base: 'flex w-full items-center bg-white group-data-[error]:bg-red-50',
rounded: 'rounded-lg',
ring: 'focus-within:ring-2 focus-within:ring-primary-600 group-data-[error]:focus-within:ring-red-400',
border: 'border border-gray-900/10 group-data-[error]:border-red-800',
},
font: {
input:
'text-sm text-gray-900 placeholder:text-gray-400 group-data-[error]:text-red-800 group-data-[error]:placeholder:text-red-500',
addons: 'text-sm select-none text-gray-500 peer-focus:text-primary-600 group-data-[error]:text-red-600',
message: 'text-xs text-gray-500 group-data-[error]:text-red-600',
},
base: 'flex w-full items-center bg-white group-data-[error]:bg-red-50',
rounded: 'rounded-lg',
ring: 'focus-within:ring-2 focus-within:ring-primary-600 group-data-[error]:focus-within:ring-red-400',
border: 'border border-gray-900/10 group-data-[error]:border-red-800',
icon: 'size-4 text-gray-600 peer-focus:text-primary-600 group-data-[error]:text-red-600 group-data-[error]:peer-focus:text-red-600',
addons: 'text-sm select-none text-gray-500 peer-focus:text-primary-600 group-data-[error]:text-red-600',
input: {
base: 'block w-full flex-1 border-0 bg-transparent focus:ring-0 group-data-[disabled-mode=disabled]:cursor-not-allowed',
padding: 'px-2 py-1.5',
font:
'text-sm text-gray-900 placeholder:text-gray-400 group-data-[error]:text-red-800 group-data-[error]:placeholder:text-red-500',
},
icon: 'size-4 text-gray-600 peer-focus:text-primary-600 group-data-[error]:text-red-600 group-data-[error]:peer-focus:text-red-600',
};
1 change: 1 addition & 0 deletions src/runtime/ui.config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { default as tooltip } from './tooltip';
// Forms
export { default as checkbox } from './checkbox';
export { default as combobox } from './combobox';
export { default as formField } from './formField';
export { default as formInput } from './formInput';
export { default as formLabel } from './formLabel';
export { default as formRadioGroupItem } from './formRadioGroupItem';
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/utils/forms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import UiFormField from '#ui/components/forms/FormField.vue';
import type { FormFieldProps } from '#ui/types';

export const getUiFormFieldProps = (props: any): FormFieldProps => {
const keys = Object.keys(UiFormField.props);

return keys.reduce((acc, key) => {
if (props[key] !== undefined) {
acc[key] = props[key];
}
return acc;
}, {}) as FormFieldProps;
};

0 comments on commit ae1ccd1

Please sign in to comment.