Better form themes (#465)

* Working on custom radius + input size

* Fix date input clear vertical align

* Moslty finished implementing small size

* Polishing larger theme

* Finish large theme

* Added size/radius options in form editor

* Darken help text, improve switch input help location

* Slight form editor improvement

* Fix styling

* Polish of the form editor
This commit is contained in:
Julien Nahum 2024-06-27 17:52:49 +02:00 committed by GitHub
parent a84abf9f72
commit 2ca2d97e8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1058 additions and 494 deletions

View File

@ -31,6 +31,8 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
// Customization // Customization
'theme' => ['required', Rule::in(Form::THEMES)], 'theme' => ['required', Rule::in(Form::THEMES)],
'width' => ['required', Rule::in(Form::WIDTHS)], 'width' => ['required', Rule::in(Form::WIDTHS)],
'size' => ['required', Rule::in(Form::SIZES)],
'border_radius' => ['required', Rule::in(Form::BORDER_RADIUS)],
'cover_picture' => 'url|nullable', 'cover_picture' => 'url|nullable',
'logo_picture' => 'url|nullable', 'logo_picture' => 'url|nullable',
'dark_mode' => ['required', Rule::in(Form::DARK_MODE_VALUES)], 'dark_mode' => ['required', Rule::in(Form::DARK_MODE_VALUES)],

View File

@ -30,6 +30,10 @@ class Form extends Model implements CachableAttributes
public const DARK_MODE_VALUES = ['auto', 'light', 'dark']; public const DARK_MODE_VALUES = ['auto', 'light', 'dark'];
public const SIZES = ['sm','md','lg'];
public const BORDER_RADIUS = ['none','small','full'];
public const THEMES = ['default', 'simple', 'notion']; public const THEMES = ['default', 'simple', 'notion'];
public const WIDTHS = ['centered', 'full']; public const WIDTHS = ['centered', 'full'];
@ -49,6 +53,8 @@ class Form extends Model implements CachableAttributes
// Customization // Customization
'custom_domain', 'custom_domain',
'size',
'border_radius',
'theme', 'theme',
'width', 'width',
'cover_picture', 'cover_picture',

View File

@ -10,9 +10,16 @@
:disabled="disabled ? true : null" :disabled="disabled ? true : null"
:name="name" :name="name"
:color="color" :color="color"
:theme="theme"
> >
<slot name="label"> <slot
{{ label }} name="label"
>
<span
:class="[
theme.SelectInput.fontSize,
]"
>{{ label }}</span>
<span <span
v-if="required" v-if="required"
class="text-red-500 required-dot" class="text-red-500 required-dot"

View File

@ -11,6 +11,7 @@
<div <div
:class="[ :class="[
theme.CodeInput.input, theme.CodeInput.input,
theme.CodeInput.borderRadius,
{ {
'!ring-red-500 !ring-2 !border-transparent': hasError, '!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,

View File

@ -10,18 +10,18 @@
:popper="{ placement: 'bottom-start' }" :popper="{ placement: 'bottom-start' }"
> >
<button <button
ref="datepicker"
class="cursor-pointer overflow-hidden" class="cursor-pointer overflow-hidden"
:class="inputClasses" :class="inputClasses"
:disabled="props.disabled" :disabled="props.disabled"
ref="datepicker"
> >
<div class="flex items-center min-w-0"> <div class="flex items-stretch min-w-0">
<div <div
class="flex-grow min-w-0 flex items-center gap-x-2" class="flex-grow min-w-0 flex items-center gap-x-2"
:class="[ :class="[
props.theme.default.inputSpacing.vertical, props.theme.DateInput.spacing.horizontal,
props.theme.default.inputSpacing.horizontal, props.theme.DateInput.spacing.vertical,
{'hover:bg-gray-50 dark:hover:bg-gray-900': !props.disabled} props.theme.DateInput.fontSize,
]" ]"
> >
<Icon <Icon
@ -29,16 +29,24 @@
class="w-4 h-4 flex-shrink-0" class="w-4 h-4 flex-shrink-0"
dynamic dynamic
/> />
<div class="flex-grow truncate overflow-hidden"> <div class="flex-grow truncate overflow-hidden flex items-center">
<p class="flex-grow truncate h-[24px]"> <p
v-if="formattedDatePreview"
class="flex-grow truncate"
>
{{ formattedDatePreview }} {{ formattedDatePreview }}
</p> </p>
<p
v-else
class="text-transparent"
>
-
</p>
</div> </div>
</div> </div>
<button <button
v-if="fromDate && !props.disabled" v-if="fromDate && !props.disabled"
class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2" class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2 flex items-center"
:class="[props.theme.default.inputSpacing.vertical]"
@click.prevent="clear()" @click.prevent="clear()"
> >
<Icon <Icon
@ -133,7 +141,7 @@ const modeledValue = computed({
}) })
const inputClasses = computed(() => { const inputClasses = computed(() => {
const classes = [props.theme.DateInput.input, 'w-full'] const classes = [props.theme.DateInput.input, props.theme.DateInput.borderRadius]
if (props.disabled) { if (props.disabled) {
classes.push('!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200') classes.push('!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200')
} }

View File

@ -5,7 +5,10 @@
</template> </template>
<div <div
v-if="cameraUpload && isInWebcam" v-if="cameraUpload && isInWebcam"
class="hidden sm:block w-full min-h-40" class="hidden sm:block w-full"
:class="[
theme.fileInput.minHeight
]"
> >
<camera-upload <camera-upload
v-if="cameraUpload" v-if="cameraUpload"
@ -17,9 +20,17 @@
<div <div
v-else v-else
class="flex flex-col w-full items-center justify-center transition-colors duration-40" class="flex flex-col w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled, :class="[
{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent, [theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]" ['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading},
theme.fileInput.input,
theme.fileInput.borderRadius,
theme.fileInput.spacing.horizontal,
theme.fileInput.spacing.vertical,
theme.fileInput.fontSize,
theme.fileInput.minHeight
]"
@dragover.prevent="uploadDragoverEvent=true" @dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false" @dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent" @drop.prevent="onUploadDropEvent"
@ -66,7 +77,7 @@
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
class="w-6 h-6" class="w-5 h-5"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -76,7 +87,7 @@
</svg> </svg>
</div> </div>
<p class="mt-2 text-sm text-gray-500 font-semibold select-none"> <p class="mt-2 text-sm text-gray-500 font-medium select-none">
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
</p> </p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none"> <p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
@ -129,24 +140,24 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import {inputProps, useFormInput} from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from './components/InputWrapper.vue'
import UploadedFile from './components/UploadedFile.vue' import UploadedFile from './components/UploadedFile.vue'
import OpenFormButton from '../open/forms/OpenFormButton.vue' import OpenFormButton from '../open/forms/OpenFormButton.vue'
import CameraUpload from './components/CameraUpload.vue' import CameraUpload from './components/CameraUpload.vue'
import { storeFile } from "~/lib/file-uploads.js" import {storeFile} from "~/lib/file-uploads.js"
export default { export default {
name: 'FileInput', name: 'FileInput',
components: { InputWrapper, UploadedFile, OpenFormButton }, components: {InputWrapper, UploadedFile, OpenFormButton},
mixins: [], mixins: [],
props: { props: {
...inputProps, ...inputProps,
multiple: { type: Boolean, default: true }, multiple: {type: Boolean, default: true},
cameraUpload: { type: Boolean, default: false }, cameraUpload: {type: Boolean, default: false},
mbLimit: { type: Number, default: 5 }, mbLimit: {type: Number, default: 5},
accept: { type: String, default: '' }, accept: {type: String, default: ''},
moveToFormAssets: { type: Boolean, default: false } moveToFormAssets: {type: Boolean, default: false}
}, },
setup(props, context) { setup(props, context) {
@ -159,7 +170,7 @@ export default {
files: [], files: [],
uploadDragoverEvent: false, uploadDragoverEvent: false,
loading: false, loading: false,
isInWebcam:false isInWebcam: false
}), }),
computed: { computed: {
@ -254,13 +265,13 @@ export default {
this.uploadFileToServer(files.item(i)) this.uploadFileToServer(files.item(i))
} }
}, },
openWebcam(){ openWebcam() {
if(!this.cameraUpload){ if (!this.cameraUpload) {
return return
} }
this.isInWebcam = true this.isInWebcam = true
}, },
cameraFileUpload(file){ cameraFileUpload(file) {
this.isInWebcam = false this.isInWebcam = false
this.isUploading = false this.isUploading = false
this.uploadFileToServer(file) this.uploadFileToServer(file)

View File

@ -10,41 +10,74 @@
class="h-6 w-6 text-nt-blue mx-auto" class="h-6 w-6 text-nt-blue mx-auto"
/> />
<div <div
v-for="(option, index) in options"
v-else v-else
:key="option[optionKey]" class="relative overflow-hidden"
role="button"
:class="[ :class="[
theme.default.input, theme.default.input,
'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex', theme.default.borderRadius,
{ {
'mb-2': index !== options.length, 'mb-2': index !== options.length,
'!ring-red-500 !ring-2 !border-transparent': hasError, '!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
}, },
]" ]"
>
<div
v-for="(option, index) in options"
v-if="options && options.length"
:key="option[optionKey]"
:role="multiple?'checkbox':'radio'"
:aria-checked="isSelected(option[optionKey])"
:class="[
theme.FlatSelectInput.spacing.vertical,
theme.FlatSelectInput.fontSize,
theme.FlatSelectInput.option,
]"
@click="onSelect(option[optionKey])" @click="onSelect(option[optionKey])"
> >
<template v-if="multiple">
<Icon
v-if="isSelected(option[optionKey])"
name="material-symbols:check-box"
class="text-inherit"
:color="color"
:class="[theme.FlatSelectInput.icon]"
/>
<Icon
v-else
name="material-symbols:check-box-outline-blank"
:class="[theme.FlatSelectInput.icon,theme.FlatSelectInput.unselectedIcon]"
/>
</template>
<template v-else>
<Icon
v-if="isSelected(option[optionKey])"
name="material-symbols:radio-button-checked-outline"
class="text-inherit"
:color="color"
:class="[theme.FlatSelectInput.icon]"
/>
<Icon
v-else
name="material-symbols:radio-button-unchecked"
:class="[theme.FlatSelectInput.icon,theme.FlatSelectInput.unselectedIcon]"
/>
</template>
<p class="flex-grow"> <p class="flex-grow">
{{ option[displayKey] }} {{ option[displayKey] }}
</p> </p>
</div>
<div <div
v-if="isSelected(option[optionKey])" v-else
class="flex items-center" :class="[
theme.FlatSelectInput.spacing.horizontal,
theme.FlatSelectInput.spacing.vertical,
theme.FlatSelectInput.fontSize,
theme.FlatSelectInput.option,
'!text-gray-500 !cursor-not-allowed'
]"
> >
<svg No options available.
:color="color"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
</div> </div>
@ -58,7 +91,7 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js" import {inputProps, useFormInput} from "./useFormInput.js"
import InputWrapper from "./components/InputWrapper.vue" import InputWrapper from "./components/InputWrapper.vue"
/** /**
@ -66,16 +99,16 @@ import InputWrapper from "./components/InputWrapper.vue"
*/ */
export default { export default {
name: "FlatSelectInput", name: "FlatSelectInput",
components: { InputWrapper }, components: {InputWrapper},
props: { props: {
...inputProps, ...inputProps,
options: { type: Array, required: true }, options: {type: Array, required: true},
optionKey: { type: String, default: "value" }, optionKey: {type: String, default: "value"},
emitKey: { type: String, default: "value" }, emitKey: {type: String, default: "value"},
displayKey: { type: String, default: "name" }, displayKey: {type: String, default: "name"},
loading: { type: Boolean, default: false }, loading: {type: Boolean, default: false},
multiple: { type: Boolean, default: false }, multiple: {type: Boolean, default: false},
}, },
setup(props, context) { setup(props, context) {
return { return {

View File

@ -11,18 +11,24 @@
aria-expanded="true" aria-expanded="true"
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="cursor-pointer relative w-full" class="cursor-pointer relative w-full"
:class="[theme.default.input, { 'ring-red-500 ring-2': hasError }]" :class="[
theme.default.input,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
theme.default.borderRadius,
{ 'ring-red-500 ring-2': hasError }
]"
:style="inputStyle" :style="inputStyle"
@click.prevent="showUploadModal = true" @click.prevent="showUploadModal = true"
> >
<div <div
v-if="currentUrl == null" v-if="currentUrl == null"
class="h-6 text-gray-600 dark:text-gray-400" class="text-gray-600 dark:text-gray-400"
> >
Upload image
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline" class="h-5 w-5 inline"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@ -34,6 +40,7 @@
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/> />
</svg> </svg>
Upload image
</div> </div>
<div <div
v-else v-else

View File

@ -8,12 +8,12 @@
:id="id ? id : name" :id="id ? id : name"
:name="name" :name="name"
:style="inputStyle" :style="inputStyle"
class="flex items-start" class="flex items-stretch"
> >
<v-select <v-select
v-model="selectedCountryCode" v-model="selectedCountryCode"
class="w-[130px]" class="w-[130px]"
dropdown-class="w-[300px]" dropdown-class="max-w-[300px]"
input-class="rounded-r-none" input-class="rounded-r-none"
:data="countries" :data="countries"
:disabled="disabled || countries.length === 1 ? true : null" :disabled="disabled || countries.length === 1 ? true : null"
@ -28,13 +28,13 @@
@update:model-value="onChangeCountryCode" @update:model-value="onChangeCountryCode"
> >
<template #option="props"> <template #option="props">
<div class="flex items-center space-x-2 hover:text-white"> <div class="flex items-center space-x-2 max-w-full">
<country-flag <country-flag
size="normal" size="normal"
class="!-mt-[9px]" class="!-mt-[9px] rounded"
:country="props.option.code" :country="props.option.code"
/> />
<span class="grow">{{ props.option.name }}</span> <span class="grow truncate">{{ props.option.name }}</span>
<span>{{ props.option.dial_code }}</span> <span>{{ props.option.dial_code }}</span>
</div> </div>
</template> </template>
@ -44,7 +44,7 @@
> >
<country-flag <country-flag
size="normal" size="normal"
class="!-mt-[9px]" class="!-mt-[9px] rounded"
:country="props.option.code" :country="props.option.code"
/> />
<span>{{ props.option.dial_code }}</span> <span>{{ props.option.dial_code }}</span>
@ -58,6 +58,10 @@
:disabled="disabled ? true : null" :disabled="disabled ? true : null"
:class="[ :class="[
theme.default.input, theme.default.input,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
theme.default.borderRadius,
{ {
'!ring-red-500 !ring-2': hasError, '!ring-red-500 !ring-2': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,

View File

@ -20,16 +20,10 @@
@mouseenter="onMouseHover(i)" @mouseenter="onMouseHover(i)"
@mouseleave="hoverRating = -1" @mouseleave="hoverRating = -1"
> >
<svg <Icon
class="w-8 h-8" name="heroicons:star-20-solid"
fill="currentColor" :class="theme.RatingInput.size"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/> />
</svg>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
}, },
theme.RichTextAreaInput.input, theme.RichTextAreaInput.input,
theme.RichTextAreaInput.borderRadius,
]" ]"
:editor-toolbar="editorToolbar" :editor-toolbar="editorToolbar"
class="rich-editor resize-y" class="rich-editor resize-y"

View File

@ -11,6 +11,10 @@
:class="[ :class="[
{ 'font-semibold': compVal === i }, { 'font-semibold': compVal === i },
theme.ScaleInput.button, theme.ScaleInput.button,
theme.ScaleInput.borderRadius,
theme.ScaleInput.spacing.horizontal,
theme.ScaleInput.spacing.vertical,
theme.ScaleInput.fontSize,
compVal !== i ? unselectedButtonClass : '', compVal !== i ? unselectedButtonClass : '',
]" ]"
:style="btnStyle(i === compVal)" :style="btnStyle(i === compVal)"

View File

@ -33,7 +33,12 @@
<template #selected="{ option }"> <template #selected="{ option }">
<template v-if="multiple"> <template v-if="multiple">
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
<span class="truncate"> <span
class="truncate"
:class="[
theme.SelectInput.fontSize,
]"
>
{{ getOptionNames(selectedValues).join(', ') }} {{ getOptionNames(selectedValues).join(', ') }}
</span> </span>
</div> </div>
@ -45,7 +50,13 @@
:option-name="getOptionName(option)" :option-name="getOptionName(option)"
> >
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
<div>{{ getOptionName(option) }}</div> <div
:class="[
theme.SelectInput.fontSize,
]"
>
{{ getOptionName(option) }}
</div>
</div> </div>
</slot> </slot>
</template> </template>
@ -57,7 +68,12 @@
:selected="selected" :selected="selected"
> >
<span class="flex"> <span class="flex">
<p class="flex-grow"> <p
class="flex-grow"
:class="[
theme.SelectInput.fontSize,
]"
>
{{ option.name }} {{ option.name }}
</p> </p>
<span <span
@ -85,7 +101,7 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from './useFormInput.js' import {inputProps, useFormInput} from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from './components/InputWrapper.vue'
/** /**
@ -93,41 +109,41 @@ import InputWrapper from './components/InputWrapper.vue'
*/ */
export default { export default {
name: 'SelectInput', name: 'SelectInput',
components: { InputWrapper }, components: {InputWrapper},
props: { props: {
...inputProps, ...inputProps,
options: { type: Array, required: true }, options: {type: Array, required: true},
optionKey: { type: String, default: 'value' }, optionKey: {type: String, default: 'value'},
emitKey: { type: String, default: 'value' }, emitKey: {type: String, default: 'value'},
displayKey: { type: String, default: 'name' }, displayKey: {type: String, default: 'name'},
loading: { type: Boolean, default: false }, loading: {type: Boolean, default: false},
multiple: { type: Boolean, default: false }, multiple: {type: Boolean, default: false},
searchable: { type: Boolean, default: false }, searchable: {type: Boolean, default: false},
clearable: { type: Boolean, default: false }, clearable: {type: Boolean, default: false},
allowCreation: { type: Boolean, default: false }, allowCreation: {type: Boolean, default: false},
dropdownClass: { type: String, default: 'w-full' }, dropdownClass: {type: String, default: 'w-full'},
remote: { type: Function, default: null } remote: {type: Function, default: null}
}, },
setup (props, context) { setup(props, context) {
return { return {
...useFormInput(props, context) ...useFormInput(props, context)
} }
}, },
data () { data() {
return { return {
additionalOptions: [], additionalOptions: [],
selectedValues: [] selectedValues: []
} }
}, },
computed: { computed: {
finalOptions () { finalOptions() {
return this.options.concat(this.additionalOptions) return this.options.concat(this.additionalOptions)
} }
}, },
watch: { watch: {
compVal: { compVal: {
handler (newVal, oldVal) { handler(newVal, oldVal) {
if (!oldVal) { if (!oldVal) {
this.handleCompValChanged() this.handleCompValChanged()
} }
@ -135,32 +151,32 @@ export default {
immediate: false immediate: false
} }
}, },
mounted () { mounted() {
this.handleCompValChanged() this.handleCompValChanged()
}, },
methods: { methods: {
getOptionName (val) { getOptionName(val) {
const option = this.finalOptions.find((optionCandidate) => { const option = this.finalOptions.find((optionCandidate) => {
return optionCandidate[this.optionKey] === val return optionCandidate[this.optionKey] === val
}) })
if (option) return option[this.displayKey] if (option) return option[this.displayKey]
return null return null
}, },
getOptionNames (values) { getOptionNames(values) {
return values.map(val => { return values.map(val => {
return this.getOptionName(val) return this.getOptionName(val)
}) })
}, },
updateModelValue (newValues) { updateModelValue(newValues) {
if (newValues === null) newValues = [] if (newValues === null) newValues = []
this.selectedValues = newValues this.selectedValues = newValues
}, },
updateOptions (newItem) { updateOptions(newItem) {
if (newItem) { if (newItem) {
this.additionalOptions.push(newItem) this.additionalOptions.push(newItem)
} }
}, },
handleCompValChanged () { handleCompValChanged() {
if (this.compVal) { if (this.compVal) {
this.selectedValues = this.compVal this.selectedValues = this.compVal
} }

View File

@ -7,13 +7,17 @@
<VueSignaturePad <VueSignaturePad
ref="signaturePad" ref="signaturePad"
:class="[ :class="[
theme.default.input, theme.SignatureInput.input,
theme.SignatureInput.spacing.horizontal,
theme.SignatureInput.spacing.vertical,
theme.SignatureInput.fontSize,
theme.SignatureInput.borderRadius,
theme.SignatureInput.minHeight,
{ {
'!ring-red-500 !ring-2 !border-transparent': hasError, '!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
}, },
]" ]"
height="150px"
:name="name" :name="name"
:options="{ onEnd }" :options="{ onEnd }"
/> />

View File

@ -10,6 +10,10 @@
:disabled="disabled ? true : null" :disabled="disabled ? true : null"
:class="[ :class="[
theme.default.input, theme.default.input,
theme.default.borderRadius,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
{ {
'!ring-red-500 !ring-2 !border-transparent': hasError, '!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
@ -48,18 +52,18 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js" import {inputProps, useFormInput} from "./useFormInput.js"
import InputWrapper from "./components/InputWrapper.vue" import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: "TextAreaInput", name: "TextAreaInput",
components: { InputWrapper }, components: {InputWrapper},
mixins: [], mixins: [],
props: { props: {
...inputProps, ...inputProps,
maxCharLimit: { type: Number, required: false, default: null }, maxCharLimit: {type: Number, required: false, default: null},
showCharLimit: { type: Boolean, required: false, default: false }, showCharLimit: {type: Boolean, required: false, default: false},
}, },
setup(props, context) { setup(props, context) {

View File

@ -14,6 +14,10 @@
:style="inputStyle" :style="inputStyle"
:class="[ :class="[
theme.default.input, theme.default.input,
theme.default.borderRadius,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
{ {
'!ring-red-500 !ring-2 !border-transparent': hasError, '!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled, '!cursor-not-allowed !bg-gray-200': disabled,
@ -55,23 +59,23 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js" import {inputProps, useFormInput} from "./useFormInput.js"
import InputWrapper from "./components/InputWrapper.vue" import InputWrapper from "./components/InputWrapper.vue"
export default { export default {
name: "TextInput", name: "TextInput",
components: { InputWrapper }, components: {InputWrapper},
props: { props: {
...inputProps, ...inputProps,
nativeType: { type: String, default: "text" }, nativeType: {type: String, default: "text"},
accept: { type: String, default: null }, accept: {type: String, default: null},
min: { type: Number, required: false, default: null }, min: {type: Number, required: false, default: null},
max: { type: Number, required: false, default: null }, max: {type: Number, required: false, default: null},
autocomplete: { type:[Boolean, String, Object], default: null }, autocomplete: {type: [Boolean, String, Object], default: null},
maxCharLimit: { type: Number, required: false, default: null }, maxCharLimit: {type: Number, required: false, default: null},
showCharLimit: { type: Boolean, required: false, default: false }, showCharLimit: {type: Boolean, required: false, default: false},
pattern: { type: String, default: null }, pattern: {type: String, default: null},
}, },
setup(props, context) { setup(props, context) {
@ -92,10 +96,12 @@ export default {
...useFormInput( ...useFormInput(
props, props,
context, context,
props.nativeType === "file" ? "file-" : null, {
formPrefixKey: props.nativeType === "file" ? "file-" : null
},
), ),
onEnterPress, onEnterPress,
onChange, onChange
} }
}, },
computed: { computed: {

View File

@ -4,25 +4,43 @@
<span /> <span />
</template> </template>
<div class="flex"> <div class="flex space-x-2 items-center">
<v-switch <v-switch
:id="id ? id : name" :id="id ? id : name"
v-model="compVal" v-model="compVal"
class="inline-block mr-2"
:disabled="disabled ? true : null" :disabled="disabled ? true : null"
:color="color" :color="color"
:theme="theme"
/> />
<div>
<slot name="label"> <slot name="label">
<span>{{ label }} <label
:aria-label="id ? id : name"
:for="id ? id : name"
:class="theme.default.fontSize"
>
{{ label }}
<span <span
v-if="required" v-if="required"
class="text-red-500 required-dot" class="text-red-500 required-dot"
>*</span></span> >*</span>
</label>
</slot> </slot>
<slot name="help">
<InputHelp
:help="help"
:help-classes="theme.default.help"
>
<template #after-help>
<slot name="bottom_after_help" />
</template>
</InputHelp>
</slot>
</div>
</div> </div>
<template #help> <template #help>
<slot name="help" /> <span class="hidden" />
</template> </template>
<template #error> <template #error>
@ -32,13 +50,15 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js" import {inputProps, useFormInput} from "./useFormInput.js"
import VSwitch from "./components/VSwitch.vue" import VSwitch from "./components/VSwitch.vue"
import InputWrapper from "./components/InputWrapper.vue" import InputWrapper from "./components/InputWrapper.vue"
import InputHelp from "~/components/forms/components/InputHelp.vue"
export default { export default {
name: "ToggleSwitchInput", name: "ToggleSwitchInput",
components: { InputWrapper, VSwitch }, components: {InputHelp, InputWrapper, VSwitch},
props: { props: {
...inputProps, ...inputProps,
}, },

View File

@ -0,0 +1,20 @@
<template>
<form>
<slot />
</form>
</template>
<script setup>
/**
* Used to pass props to children input components
*/
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
const props = defineProps({
themeName: { type: String, default: 'default' },
size: { type: String, default: "md" },
})
const theme = computed(() => (new ThemeBuilder(props.themeName, {size: props.size})).getAllComponents())
provide('theme', theme)
</script>

View File

@ -4,7 +4,7 @@
id="webcam" id="webcam"
autoplay autoplay
playsinline playsinline
:class="[{ hidden: !isCapturing }, theme.fileInput.cameraInput]" :class="[{ hidden: !isCapturing }, theme.fileInput.minHeight, theme.fileInput.borderRadius]"
width="1280" width="1280"
height="720" height="720"
/> />
@ -136,7 +136,7 @@
<script> <script>
import Webcam from "webcam-easy" import Webcam from "webcam-easy"
import { themes } from "~/lib/forms/form-themes.js" import { themes } from "~/lib/forms/themes/form-themes.js"
export default { export default {
name: "FileInput", name: "FileInput",
props: { props: {

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
:class="wrapperClass" :class="[ twMerge(theme.default.wrapper,wrapperClass)]"
:style="inputStyle" :style="inputStyle"
> >
<slot name="label"> <slot name="label">
@ -54,20 +54,21 @@
<script setup> <script setup>
import InputLabel from "./InputLabel.vue" import InputLabel from "./InputLabel.vue"
import InputHelp from "./InputHelp.vue" import InputHelp from "./InputHelp.vue"
import {twMerge} from "tailwind-merge"
defineProps({ defineProps({
id: { type: String, required: false }, id: {type: String, required: false},
name: { type: String, required: false }, name: {type: String, required: false},
label: { type: String, required: false }, label: {type: String, required: false},
form: { type: Object, required: false }, form: {type: Object, required: false},
theme: { type: Object, required: true }, theme: {type: Object, required: true},
wrapperClass: { type: String, required: false }, wrapperClass: {type: String, required: false},
inputStyle: { type: Object, required: false }, inputStyle: {type: Object, required: false},
help: { type: String, required: false }, help: {type: String, required: false},
helpPosition: { type: String, default: "below_input" }, helpPosition: {type: String, default: "below_input"},
uppercaseLabels: { type: Boolean, default: true }, uppercaseLabels: {type: Boolean, default: true},
hideFieldName: { type: Boolean, default: true }, hideFieldName: {type: Boolean, default: true},
required: { type: Boolean, default: false }, required: {type: Boolean, default: false},
hasValidation: { type: Boolean, default: true }, hasValidation: {type: Boolean, default: true},
}) })
</script> </script>

View File

@ -5,8 +5,8 @@
v-model="internalValue" v-model="internalValue"
:name="name" :name="name"
type="checkbox" type="checkbox"
:class="sizeClasses" class="rounded border-gray-500 w-10 h-10 cursor-pointer checkbox"
class="rounded border-gray-500 cursor-pointer checkbox" :class="theme.CheckboxInput.size"
:style="{ '--accent-color': color }" :style="{ '--accent-color': color }"
:disabled="disabled ? true : null" :disabled="disabled ? true : null"
> >
@ -32,7 +32,7 @@ const props = defineProps({
name: { type: String, default: "checkbox" }, name: { type: String, default: "checkbox" },
modelValue: { type: [Boolean, String], default: false }, modelValue: { type: [Boolean, String], default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
sizeClasses: { type: String, default: "w-4 h-4" }, theme: { type: Object },
color: { type: String, default: null }, color: { type: String, default: null },
}) })

View File

@ -7,7 +7,12 @@
<div <div
class="inline-block w-full flex overflow-hidden" class="inline-block w-full flex overflow-hidden"
:style="inputStyle" :style="inputStyle"
:class="[theme.SelectInput.input, { '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled }, inputClass]" :class="[
theme.SelectInput.input,
theme.SelectInput.borderRadius,
{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled },
inputClass
]"
> >
<button <button
type="button" type="button"
@ -15,10 +20,18 @@
aria-expanded="true" aria-expanded="true"
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="cursor-pointer w-full flex-grow relative" class="cursor-pointer w-full flex-grow relative"
:class="[{'py-2': !multiple || loading, 'py-1': multiple},theme.default.inputSpacing.horizontal]" :class="[
theme.SelectInput.spacing.horizontal,
theme.SelectInput.spacing.vertical
]"
@click="toggleDropdown" @click="toggleDropdown"
> >
<div :class="{ 'h-6': !multiple, 'min-h-8': multiple && !loading }"> <div
class="flex items-center"
:class="[
theme.SelectInput.minHeight
]"
>
<transition <transition
name="fade" name="fade"
mode="out-in" mode="out-in"
@ -32,7 +45,6 @@
v-else-if="modelValue" v-else-if="modelValue"
key="value" key="value"
class="flex" class="flex"
:class="{ 'min-h-8': multiple }"
> >
<slot <slot
name="selected" name="selected"
@ -47,7 +59,10 @@
<slot name="placeholder"> <slot name="placeholder">
<div <div
class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3" class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
:class="{ 'py-1': multiple && !loading }" :class="[
{ 'py-1': multiple && !loading },
theme.SelectInput.fontSize
]"
> >
{{ placeholder }} {{ placeholder }}
</div> </div>
@ -65,7 +80,7 @@
<button <button
v-if="clearable && !isEmpty" v-if="clearable && !isEmpty"
class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2" class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2"
:class="[theme.default.inputSpacing.vertical]" :class="[theme.SelectInput.spacing.vertical]"
@click.prevent="clear()" @click.prevent="clear()"
> >
<Icon <Icon
@ -78,15 +93,18 @@
</div> </div>
<collapsible <collapsible
v-model="isOpen" v-model="isOpen"
class="absolute mt-1 bg-white overflow-auto dark:bg-notion-dark-light shadow-xl z-10" class="absolute mt-1 bg-white overflow-auto dark:bg-notion-dark-light shadow-xl z-30"
:class="[dropdownClass,theme.SelectInput.dropdown]" :class="[dropdownClass,theme.SelectInput.dropdown, theme.SelectInput.borderRadius]"
@click-away="onClickAway" @click-away="onClickAway"
> >
<ul <ul
tabindex="-1" tabindex="-1"
role="listbox" role="listbox"
class="text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative" class="leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
:class="{ 'max-h-42': !isSearchable, 'max-h-48': isSearchable }" :class="[
{ 'max-h-42': !isSearchable, 'max-h-48': isSearchable },
theme.SelectInput.fontSize
]"
> >
<div <div
v-if="isSearchable" v-if="isSearchable"
@ -95,15 +113,29 @@
<input <input
v-model="searchTerm" v-model="searchTerm"
type="text" type="text"
class="flex-grow pl-3 pr-7 py-3 w-full focus:outline-none dark:text-white" class="flex-grow pl-3 pr-7 py-2 w-full focus:outline-none dark:text-white"
placeholder="Search" placeholder="Search"
> >
<div class="flex absolute right-0 inset-y-0 items-center px-2 justify-center pointer-events-none"> <div
v-if="!searchTerm"
class="flex absolute right-0 inset-y-0 items-center px-2 justify-center pointer-events-none"
>
<Icon <Icon
name="heroicons:magnifying-glass-solid" name="heroicons:magnifying-glass-solid"
class="h-5 w-5 text-gray-500 dark:text-gray-400" class="h-5 w-5 text-gray-500 dark:text-gray-400"
/> />
</div> </div>
<div
v-else
role="button"
class="flex absolute right-0 inset-y-0 items-center px-2 justify-center"
@click="searchTerm = ''"
>
<Icon
name="heroicons:backspace"
class="h-5 w-5 text-gray-500 dark:text-gray-400"
/>
</div>
</div> </div>
<div <div
v-if="loading" v-if="loading"
@ -120,8 +152,14 @@
:key="item[optionKey]" :key="item[optionKey]"
role="option" role="option"
:style="optionStyle" :style="optionStyle"
:class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]" :class="[
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none" dropdownClass,
theme.SelectInput.option,
theme.SelectInput.spacing.horizontal,
theme.SelectInput.spacing.vertical,
{ 'pr-9': multiple},
]"
class="text-gray-900 select-none relative cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
@click="select(item)" @click="select(item)"
> >
<slot <slot
@ -145,10 +183,11 @@
role="option" role="option"
:style="optionStyle" :style="optionStyle"
:class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]" :class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none" class="text-gray-900 select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
@click="createOption(searchTerm)" @click="createOption(searchTerm)"
> >
Create <span class="px-2 bg-gray-100 border border-gray-300 rounded group-hover-text-black">{{ searchTerm Create <span class="px-2 bg-gray-100 border border-gray-300 rounded group-hover-text-black">{{
searchTerm
}}</span> }}</span>
</li> </li>
</div> </div>
@ -159,38 +198,38 @@
<script> <script>
import Collapsible from '~/components/global/transitions/Collapsible.vue' import Collapsible from '~/components/global/transitions/Collapsible.vue'
import { themes } from '../../../lib/forms/form-themes.js' import {themes} from '../../../lib/forms/themes/form-themes.js'
import debounce from 'debounce' import debounce from 'debounce'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
export default { export default {
name: 'VSelect', name: 'VSelect',
components: { Collapsible }, components: {Collapsible},
directives: {}, directives: {},
props: { props: {
data: Array, data: Array,
modelValue: { default: null, type: [String, Number, Array, Object] }, modelValue: {default: null, type: [String, Number, Array, Object]},
inputClass: { type: String, default: null }, inputClass: {type: String, default: null},
dropdownClass: { type: String, default: 'w-full' }, dropdownClass: {type: String, default: 'w-full'},
loading: { type: Boolean, default: false }, loading: {type: Boolean, default: false},
required: { type: Boolean, default: false }, required: {type: Boolean, default: false},
multiple: { type: Boolean, default: false }, multiple: {type: Boolean, default: false},
searchable: { type: Boolean, default: false }, searchable: {type: Boolean, default: false},
clearable: { type: Boolean, default: false }, clearable: {type: Boolean, default: false},
hasError: { type: Boolean, default: false }, hasError: {type: Boolean, default: false},
remote: { type: Function, default: null }, remote: {type: Function, default: null},
searchKeys: { type: Array, default: () => ['name'] }, searchKeys: {type: Array, default: () => ['name']},
optionKey: { type: String, default: 'id' }, optionKey: {type: String, default: 'id'},
emitKey: { type: String, default: null }, emitKey: {type: String, default: null},
color: { type: String, default: '#3B82F6' }, color: {type: String, default: '#3B82F6'},
placeholder: { type: String, default: null }, placeholder: {type: String, default: null},
uppercaseLabels: { type: Boolean, default: true }, uppercaseLabels: {type: Boolean, default: true},
theme: { type: Object, default: () => themes.default }, theme: {type: Object, default: () => themes.default},
allowCreation: { type: Boolean, default: false }, allowCreation: {type: Boolean, default: false},
disabled: { type: Boolean, default: false } disabled: {type: Boolean, default: false}
}, },
emits: ['update:modelValue', 'update-options'], emits: ['update:modelValue', 'update-options'],
data () { data() {
return { return {
isOpen: false, isOpen: false,
searchTerm: '', searchTerm: '',
@ -198,46 +237,46 @@ export default {
} }
}, },
computed: { computed: {
optionStyle () { optionStyle() {
return { return {
'--bg-form-color': this.color '--bg-form-color': this.color
} }
}, },
inputStyle () { inputStyle() {
return { return {
'--tw-ring-color': this.color '--tw-ring-color': this.color
} }
}, },
debouncedRemote () { debouncedRemote() {
if (this.remote) { if (this.remote) {
return debounce(this.remote, 300) return debounce(this.remote, 300)
} }
return null return null
}, },
filteredOptions () { filteredOptions() {
if (!this.data) return [] if (!this.data) return []
if (!this.searchable || this.remote || this.searchTerm === '') { if (!this.searchable || this.remote || this.searchTerm === '') {
return this.data return this.data
} }
// Fuse search // Fuse search
const fuzeOptions = { const fuse = new Fuse(this.data, {
keys: this.searchKeys keys: this.searchKeys,
} includeScore: true
const fuse = new Fuse(this.data, fuzeOptions) })
return fuse.search(this.searchTerm).map((res) => { return fuse.search(this.searchTerm).filter((res) => res.score < 0.5).map((res) => {
return res.item return res.item
}) })
}, },
isSearchable () { isSearchable() {
return this.searchable || this.remote !== null || this.allowCreation return this.searchable || this.remote !== null || this.allowCreation
}, },
isEmpty () { isEmpty() {
return this.multiple ? !this.modelValue || this.modelValue.length === 0 : !this.modelValue return this.multiple ? !this.modelValue || this.modelValue.length === 0 : !this.modelValue
} }
}, },
watch: { watch: {
searchTerm (val) { searchTerm(val) {
if (!this.debouncedRemote) return if (!this.debouncedRemote) return
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) { if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
return this.debouncedRemote(val) return this.debouncedRemote(val)
@ -245,13 +284,13 @@ export default {
} }
}, },
methods: { methods: {
onClickAway (event) { onClickAway(event) {
// Check that event target isn't children of dropdown // Check that event target isn't children of dropdown
if (this.$refs.select && !this.$refs.select.contains(event.target)) { if (this.$refs.select && !this.$refs.select.contains(event.target)) {
this.isOpen = false this.isOpen = false
} }
}, },
isSelected (value) { isSelected(value) {
if (!this.modelValue) return false if (!this.modelValue) return false
if (this.emitKey && value[this.emitKey]) { if (this.emitKey && value[this.emitKey]) {
@ -263,7 +302,7 @@ export default {
} }
return this.modelValue === value return this.modelValue === value
}, },
toggleDropdown () { toggleDropdown() {
if (this.disabled) { if (this.disabled) {
this.isOpen = false this.isOpen = false
} else { } else {
@ -273,7 +312,7 @@ export default {
this.searchTerm = '' this.searchTerm = ''
} }
}, },
select (value) { select(value) {
if (!this.multiple) { if (!this.multiple) {
// Close after select // Close after select
this.toggleDropdown() this.toggleDropdown()
@ -306,10 +345,10 @@ export default {
} }
} }
}, },
clear () { clear() {
this.$emit('update:modelValue', this.multiple ? [] : null) this.$emit('update:modelValue', this.multiple ? [] : null)
}, },
createOption (newOption) { createOption(newOption) {
if (newOption) { if (newOption) {
const newItem = { const newItem = {
name: newOption, name: newOption,

View File

@ -1,17 +1,20 @@
<template> <template>
<div <div
role="button" :id="id || name"
:aria-labelledby="id || name"
role="checkbox"
:aria-checked="props.modelValue"
class="flex"
@click.stop="onClick" @click.stop="onClick"
> >
<div <div
class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100" class="inline-flex items-center bg-gray-300 rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100"
:class="{ 'toggle-switch': props.modelValue }" :class="[{ 'toggle-switch': props.modelValue }, theme.SwitchInput.containerSize]"
:style="{ '--accent-color': props.color }" :style="{ '--accent-color': props.color }"
> >
<div <div
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100" class="inline-block h-4 w-4 rounded-full bg-white transition-all transform ease-in-out duration-150 scale-100"
:class="{ 'translate-x-5.5': props.modelValue }" :class="{ [theme.SwitchInput.translatedClass]: props.modelValue}"
/> />
</div> </div>
</div> </div>
@ -21,9 +24,12 @@
import { defineEmits, defineProps } from "vue" import { defineEmits, defineProps } from "vue"
const props = defineProps({ const props = defineProps({
id: { type: String, default: null },
name: { type: String, default: "checkbox" },
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
color: { type: String, default: '#3B82F6' }, color: { type: String, default: '#3B82F6' },
theme: { type: Object },
}) })
const emit = defineEmits(["update:modelValue"]) const emit = defineEmits(["update:modelValue"])

View File

@ -1,29 +1,41 @@
import { ref, computed, watch } from "vue" import {ref, computed, watch} from "vue"
import { themes } from "~/lib/forms/form-themes.js" import {default as _get} from "lodash/get"
import { default as _get } from "lodash/get" import {default as _set} from "lodash/set"
import { default as _set } from "lodash/set" import {default as _has} from "lodash/has"
import { default as _has } from "lodash/has" import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
export const inputProps = { export const inputProps = {
id: { type: String, default: null }, id: {type: String, default: null},
name: { type: String, required: true }, name: {type: String, required: true},
label: { type: String, required: false }, label: {type: String, required: false},
form: { type: Object, required: false }, form: {type: Object, required: false},
theme: { type: Object, default: () => themes.default }, theme: {
modelValue: { required: false }, type: Object, default: () => {
required: { type: Boolean, default: false }, const theme = inject("theme", null)
disabled: { type: Boolean, default: false }, if (theme) {
placeholder: { type: String, default: null }, return theme.value
uppercaseLabels: { type: Boolean, default: false }, }
hideFieldName: { type: Boolean, default: false }, return CachedDefaultTheme.getInstance()
showCharLimit: { type: Boolean, default: false }, }
help: { type: String, default: null }, },
helpPosition: { type: String, default: "below_input" }, modelValue: {required: false},
color: { type: String, default: "#3B82F6" }, required: {type: Boolean, default: false},
wrapperClass: { type: String, default: "relative mb-3" }, disabled: {type: Boolean, default: false},
placeholder: {type: String, default: null},
uppercaseLabels: {type: Boolean, default: false},
hideFieldName: {type: Boolean, default: false},
showCharLimit: {type: Boolean, default: false},
help: {type: String, default: null},
helpPosition: {type: String, default: "below_input"},
color: {type: String, default: "#3B82F6"},
wrapperClass: {type: String, default: ""},
} }
export function useFormInput(props, context, formPrefixKey = null) { export function useFormInput(props, context, options = {}) {
const composableOptions = {
formPrefixKey: null,
...options
}
const content = ref(props.modelValue) const content = ref(props.modelValue)
const inputStyle = computed(() => { const inputStyle = computed(() => {
@ -47,13 +59,13 @@ export function useFormInput(props, context, formPrefixKey = null) {
const compVal = computed({ const compVal = computed({
get: () => { get: () => {
if (props.form) { if (props.form) {
return _get(props.form, (formPrefixKey || "") + props.name) return _get(props.form, (composableOptions.formPrefixKey || "") + props.name)
} }
return content.value return content.value
}, },
set: (val) => { set: (val) => {
if (props.form) { if (props.form) {
_set(props.form, (formPrefixKey || "") + props.name, val) _set(props.form, (composableOptions.formPrefixKey || "") + props.name, val)
} else { } else {
content.value = val content.value = val
} }

View File

@ -28,7 +28,7 @@
<script setup> <script setup>
import { ref, defineProps, defineEmits } from "vue" import { ref, defineProps, defineEmits } from "vue"
import OpenForm from "../forms/OpenForm.vue" import OpenForm from "../forms/OpenForm.vue"
import { themes } from "~/lib/forms/form-themes.js" import { themes } from "~/lib/forms/themes/form-themes.js"
const props = defineProps({ const props = defineProps({
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },

View File

@ -193,13 +193,12 @@
<script> <script>
import OpenForm from './OpenForm.vue' import OpenForm from './OpenForm.vue'
import OpenFormButton from './OpenFormButton.vue' import OpenFormButton from './OpenFormButton.vue'
import { themes } from '~/lib/forms/form-themes.js'
import VButton from '~/components/global/VButton.vue' import VButton from '~/components/global/VButton.vue'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue' import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
import VTransition from '~/components/global/transitions/VTransition.vue' import VTransition from '~/components/global/transitions/VTransition.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js" import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"
import clonedeep from "clone-deep" import clonedeep from "clone-deep"
import { default as _has } from 'lodash/has' import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
export default { export default {
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings }, components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
@ -227,7 +226,6 @@ export default {
return { return {
loading: false, loading: false,
submitted: false, submitted: false,
themes: themes,
passwordForm: useForm({ passwordForm: useForm({
password: null password: null
}), }),
@ -241,7 +239,10 @@ export default {
return import.meta.client && window.location.href.includes('popup=true') return import.meta.client && window.location.href.includes('popup=true')
}, },
theme () { theme () {
return this.themes[_has(this.themes, this.form.theme) ? this.form.theme : 'default'] return new ThemeBuilder(this.form.theme, {
size: this.form.size,
borderRadius: this.form.border_radius
}).getAllComponents()
}, },
isPublicFormPage () { isPublicFormPage () {
return this.$route.name === 'forms-slug' return this.$route.name === 'forms-slug'

View File

@ -2,7 +2,7 @@
<button <button
:type="nativeType" :type="nativeType"
:disabled="loading ? true : null" :disabled="loading ? true : null"
:class="`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']} ${theme.Button.body}`" :class="[`py-${sizes['p-y']} px-${sizes['p-x']} text-${sizes['font']}`,theme.Button.body,theme.Button.borderRadius]"
:style="buttonStyle" :style="buttonStyle"
class="btn" class="btn"
> >
@ -17,7 +17,7 @@
</template> </template>
<script> <script>
import { themes } from "~/lib/forms/form-themes.js" import {themes} from "~/lib/forms/themes/form-themes.js"
export default { export default {
name: "OpenFormButton", name: "OpenFormButton",
@ -43,7 +43,7 @@ export default {
default: false, default: false,
}, },
theme: { type: Object, default: () => themes.default }, theme: {type: Object, default: () => themes.default},
}, },
computed: { computed: {

View File

@ -91,6 +91,10 @@
allow you to preview your form changes. allow you to preview your form changes.
</div> </div>
<VForm
size="sm"
@submit.prevent=""
>
<form-information /> <form-information />
<form-structure /> <form-structure />
<form-customization /> <form-customization />
@ -99,6 +103,7 @@
<form-security-privacy /> <form-security-privacy />
<form-custom-seo /> <form-custom-seo />
<form-custom-code /> <form-custom-code />
</VForm>
</div> </div>
<form-editor-preview /> <form-editor-preview />

View File

@ -55,9 +55,11 @@
<p class="flex-grow truncate"> <p class="flex-grow truncate">
{{ field.name }} {{ field.name }}
</p> </p>
<v-switch <ToggleSwitchInput
v-model="displayColumns[field.id]" v-model="displayColumns[field.id]"
class="float-right" wrapper-class="mb-0"
label=""
name="field.id"
@update:model-value="onChangeDisplayColumns" @update:model-value="onChangeDisplayColumns"
/> />
</div> </div>
@ -77,9 +79,11 @@
<p class="flex-grow truncate"> <p class="flex-grow truncate">
{{ field.name }} {{ field.name }}
</p> </p>
<v-switch <ToggleSwitchInput
v-model="displayColumns[field.id]" v-model="displayColumns[field.id]"
class="float-right" wrapper-class="mb-0"
label=""
name="field.id"
@update:model-value="onChangeDisplayColumns" @update:model-value="onChangeDisplayColumns"
/> />
</div> </div>
@ -98,12 +102,14 @@
class="flex flex-wrap items-end" class="flex flex-wrap items-end"
> >
<div class="flex-grow"> <div class="flex-grow">
<VForm size="sm">
<text-input <text-input
class="w-64" class="w-64"
:form="searchForm" :form="searchForm"
name="search" name="search"
placeholder="Search..." placeholder="Search..."
/> />
</VForm>
</div> </div>
<div class="font-semibold flex gap-4"> <div class="font-semibold flex gap-4">
<p class="float-right text-xs uppercase mb-2"> <p class="float-right text-xs uppercase mb-2">

View File

@ -35,8 +35,10 @@
help="Gives user a unique url to update their submission" help="Gives user a unique url to update their submission"
> >
<template #label> <template #label>
<span class="text-sm">
Editable submissions Editable submissions
<pro-tag class="ml-1" /> </span>
<pro-tag class="-mt-1 ml-1" />
</template> </template>
</toggle-switch-input> </toggle-switch-input>
<text-input <text-input
@ -138,7 +140,7 @@
help="Show a message, or redirect to a URL" help="Show a message, or redirect to a URL"
> >
<template #selected="{ option, optionName }"> <template #selected="{ option, optionName }">
<div class="flex items-center truncate mr-6"> <div class="flex items-center text-sm truncate mr-6">
{{ optionName }} {{ optionName }}
<pro-tag <pro-tag
v-if="option === 'redirect'" v-if="option === 'redirect'"
@ -147,8 +149,8 @@
</div> </div>
</template> </template>
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<span class="flex hover:text-white"> <span class="flex">
<p class="flex-grow hover:text-white"> <p class="flex-grow">
{{ option.name }} {{ option.name }}
<template v-if="option.value === 'redirect'"><pro-tag /></template> <template v-if="option.value === 'redirect'"><pro-tag /></template>
</p> </p>

View File

@ -53,9 +53,34 @@
label="Form Theme" label="Form Theme"
/> />
<div class="flex space-x-4 justify-stretch">
<select-input
name="size"
class="flex-grow"
:options="[
{ name: 'Small', value: 'sm' },
{ name: 'Medium', value: 'md' },
{ name: 'Large', value: 'lg' },
]"
:form="form"
label="Input Size"
/>
<select-input
name="border_radius"
class="flex-grow"
:options="[
{ name: 'None', value: 'none' },
{ name: 'Small', value: 'small' },
{ name: 'Full', value: 'full' },
]"
:form="form"
label="Input Roundness"
/>
</div>
<select-input <select-input
name="dark_mode" name="dark_mode"
class="mt-4"
help="To see changes, save your form and open it" help="To see changes, save your form and open it"
:options="[ :options="[
{ name: 'Auto - use Device System Preferences', value: 'auto' }, { name: 'Auto - use Device System Preferences', value: 'auto' },
@ -68,7 +93,6 @@
<select-input <select-input
name="width" name="width"
class="mt-4"
:options="[ :options="[
{ name: 'Centered', value: 'centered' }, { name: 'Centered', value: 'centered' },
{ name: 'Full Width', value: 'full' }, { name: 'Full Width', value: 'full' },
@ -80,7 +104,6 @@
<image-input <image-input
name="cover_picture" name="cover_picture"
class="mt-4"
:form="form" :form="form"
label="Cover Picture" label="Cover Picture"
help="Not visible when form is embedded" help="Not visible when form is embedded"
@ -89,7 +112,6 @@
<image-input <image-input
name="logo_picture" name="logo_picture"
class="mt-4"
:form="form" :form="form"
label="Logo" label="Logo"
help="Not visible when form is embedded" help="Not visible when form is embedded"
@ -98,49 +120,44 @@
<color-input <color-input
name="color" name="color"
class="mt-4"
:form="form" :form="form"
label="Color (for buttons & inputs border)" label="Color (for buttons & inputs border)"
/> />
<toggle-switch-input <toggle-switch-input
name="hide_title" name="hide_title"
:form="form" :form="form"
class="mt-4"
label="Hide Title" label="Hide Title"
/> />
<toggle-switch-input <toggle-switch-input
name="no_branding" name="no_branding"
:form="form" :form="form"
class="mt-4"
> >
<template #label> <template #label>
<span class="text-sm">
Remove OpnForm Branding Remove OpnForm Branding
<pro-tag class="ml-1" /> </span>
<pro-tag class="-mt-1" />
</template> </template>
</toggle-switch-input> </toggle-switch-input>
<toggle-switch-input <toggle-switch-input
name="show_progress_bar" name="show_progress_bar"
:form="form" :form="form"
class="mt-4"
label="Show progress bar" label="Show progress bar"
/> />
<toggle-switch-input <toggle-switch-input
name="uppercase_labels" name="uppercase_labels"
:form="form" :form="form"
class="mt-4"
label="Uppercase Input Labels" label="Uppercase Input Labels"
/> />
<toggle-switch-input <toggle-switch-input
name="transparent_background" name="transparent_background"
:form="form" :form="form"
class="mt-4"
label="Transparent Background" label="Transparent Background"
help="Only applies when form is embedded" help="Only applies when form is embedded"
/> />
<toggle-switch-input <toggle-switch-input
name="confetti_on_submission" name="confetti_on_submission"
:form="form" :form="form"
class="mt-4"
label="Confetti on successful submisison" label="Confetti on successful submisison"
@update:model-value="onChangeConfettiOnSubmission" @update:model-value="onChangeConfettiOnSubmission"
/> />

View File

@ -1,6 +1,10 @@
<template> <template>
<editor-right-sidebar <editor-right-sidebar
:show="form && (showEditFieldSidebar || showAddFieldSidebar)" :show="form && (showEditFieldSidebar || showAddFieldSidebar)"
>
<VForm
size="sm"
@submit.prevent=""
> >
<transition mode="out-in"> <transition mode="out-in">
<form-field-edit <form-field-edit
@ -13,6 +17,7 @@
v-motion-fade="'fade'" v-motion-fade="'fade'"
/> />
</transition> </transition>
</VForm>
</editor-right-sidebar> </editor-right-sidebar>
</template> </template>

View File

@ -36,7 +36,6 @@
name="tags" name="tags"
label="Tags" label="Tags"
:form="form" :form="form"
class="mt-4"
help="To organize your forms (hidden to respondents)" help="To organize your forms (hidden to respondents)"
placeholder="Select Tag(s)" placeholder="Select Tag(s)"
:multiple="true" :multiple="true"
@ -47,7 +46,6 @@
name="visibility" name="visibility"
label="Visibility" label="Visibility"
:form="form" :form="form"
class="mt-4"
help="Only public form will be accessible" help="Only public form will be accessible"
placeholder="Select Visibility" placeholder="Select Visibility"
:required="true" :required="true"
@ -56,7 +54,7 @@
<v-button <v-button
v-if="copyFormOptions.length > 0" v-if="copyFormOptions.length > 0"
color="light-gray" color="light-gray"
class="w-full mt-4" class="w-full"
@click="showCopyFormSettingsModal = true" @click="showCopyFormSettingsModal = true"
> >
<svg <svg
@ -98,20 +96,13 @@
</svg> </svg>
</template> </template>
<template #title> <template #title>
Copy Settings from another form Import Settings from another form
</template> </template>
<div class="p-4 min-h-[450px]"> <div>
<p class="text-gray-600">
If you already have another form that you like to use as a base for
this form, you can do that here. Select another form, confirm, and we
will copy all of the other form settings (except the form structure)
to this form.
</p>
<select-input <select-input
v-model="copyFormId" v-model="copyFormId"
name="copy_form_id" name="copy_form_id"
label="Copy Settings From" label="Copy Settings From"
class="mt-3 mb-6"
placeholder="Choose a form" placeholder="Choose a form"
:searchable="copyFormOptions.length > 5" :searchable="copyFormOptions.length > 5"
:options="copyFormOptions" :options="copyFormOptions"

View File

@ -31,14 +31,13 @@
<!-- Remember Me --> <!-- Remember Me -->
<div class="relative flex items-center my-5"> <div class="relative flex items-center my-5">
<v-checkbox <CheckboxInput
v-model="remember" v-model="remember"
class="w-full md:w-1/2" class="w-full md:w-1/2"
name="remember" name="remember"
size="small" size="small"
> label="Remember me"
Remember me />
</v-checkbox>
<div class="w-full md:w-1/2 text-right"> <div class="w-full md:w-1/2 text-right">
<a <a

View File

@ -109,7 +109,7 @@
<script> <script>
import FormUrlPrefill from "../../../open/forms/components/FormUrlPrefill.vue" import FormUrlPrefill from "../../../open/forms/components/FormUrlPrefill.vue"
import OpenForm from "../../../open/forms/OpenForm.vue" import OpenForm from "../../../open/forms/OpenForm.vue"
import { themes } from "~/lib/forms/form-themes.js" import { themes } from "~/lib/forms/themes/form-themes.js"
export default { export default {
name: "UrlFormPrefill", name: "UrlFormPrefill",

View File

@ -15,7 +15,7 @@ export const initForm = (defaultValue = {}, withDefaultProperties = false) => {
color: "#3B82F6", color: "#3B82F6",
hide_title: false, hide_title: false,
no_branding: false, no_branding: false,
uppercase_labels: true, uppercase_labels: false,
transparent_background: false, transparent_background: false,
closes_at: null, closes_at: null,
closed_text: closed_text:

View File

@ -1,174 +0,0 @@
/**
Input classes for each supported form themes
*/
export const themes = {
default: {
default: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
inputSpacing: {
vertical: 'py-2',
horizontal: 'px-4'
},
help: 'text-gray-400 dark:text-gray-500'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg filter hover:brightness-110'
},
DateInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'rounded-lg flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-400 dark:text-gray-500'
},
CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2',
help: 'text-gray-400 dark:text-gray-500'
},
SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'relative w-full rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500',
dropdown: 'rounded-lg border border-gray-300 dark:border-gray-600',
option: 'rounded-md'
},
ScaleInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
button: 'cursor-pointer text-gray-700 inline-block rounded-lg border-gray-300 px-4 py-2 flex-grow dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white hover:bg-gray-50 border',
help: 'text-gray-400 dark:text-gray-500'
},
SliderInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs',
help: 'text-gray-400 dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded-lg',
cameraInput: 'min-h-40 rounded-lg',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded-lg shadow-sm max-w-[10rem]'
}
},
simple: {
default: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-2 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
inputSpacing: {
vertical: 'py-2',
horizontal: 'px-4'
},
help: 'text-gray-400 dark:text-gray-500'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-110'
},
DateInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-400 dark:text-gray-500'
},
SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'relative w-full flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500',
dropdown: 'border border-gray-300 dark:border-gray-600',
option: ''
},
CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2',
help: 'text-gray-400 dark:text-gray-500'
},
ScaleInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
button: 'flex-1 appearance-none border-gray-300 dark:border-gray-600 w-full py-2 px-2 bg-gray-50 text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white hover:bg-gray-50 border -mx-4',
help: 'text-gray-400 dark:text-gray-500'
},
SliderInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs',
help: 'text-gray-400 dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4',
cameraInput: 'min-h-40',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light shadow-sm max-w-[10rem]'
}
},
notion: {
default: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion w-full py-2 px-2 bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
inputSpacing: {
vertical: 'py-2',
horizontal: 'px-4'
},
help: 'text-notion-input-help dark:text-gray-500'
},
Button: {
body: 'rounded-md transition ease-in duration-200 text-center font-semibold shadow shadow-inner-notion focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-110'
},
DateInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded shadow-inner-notion border-transparent focus:border-transparent flex-1 appearance-none w-full bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion p-[1px]',
help: 'text-notion-input-help dark:text-gray-500'
},
SelectInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded relative w-full border-transparent flex-1 appearance-none bg-notion-input-background shadow-inner-notion w-full px-2 text-gray-900 placeholder-gray-400 dark:bg-notion-dark-light dark:placeholder-gray-500 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500',
dropdown: 'rounded border border-gray-300 dark:border-gray-600',
option: 'rounded'
},
CodeInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded shadow-inner-notion border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-notion-input-help dark:text-gray-500'
},
RichTextAreaInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion border border-gray-300 dark:border-gray-600 w-full text-gray-900 bg-notion-input-background dark:bg-notion-dark-light shadow-inner dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:ring-opacity-100 focus:border-transparent focus:ring-0 focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500'
},
ScaleInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
button: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion w-full py-2 px-2 bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 text-center',
unselectedButton: 'bg-notion-input-background dark:bg-notion-dark-light hover:bg-gray-50 border',
help: 'text-notion-input-help dark:text-gray-500'
},
SliderInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs',
help: 'text-gray-400 dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded bg-notion-input-background',
cameraInput: 'min-h-40 rounded',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded shadow-sm max-w-[10rem]'
}
}
}

View File

@ -0,0 +1,21 @@
import ThemeBuilder from './ThemeBuilder.js'
const CachedDefaultTheme = (function() {
let instance
function createInstance() {
const themeBuilder = new ThemeBuilder()
return themeBuilder.getAllComponents()
}
return {
getInstance: function() {
if (!instance) {
instance = createInstance()
}
return instance
}
}
})()
export default CachedDefaultTheme

69
client/lib/forms/themes/ThemeBuilder.js vendored Normal file
View File

@ -0,0 +1,69 @@
import {twMerge} from 'tailwind-merge'
import {themes} from './form-themes.js'
export const sizes = ['sm', 'md', 'lg']
class ThemeBuilder {
constructor(theme = 'default', options = {}) {
this.theme = themes[theme] || themes.default
this.size = options.size || 'md'
this.borderRadius = options.borderRadius || 'small'
}
extractSizedClasses(baseConfig) {
if (typeof baseConfig === 'object' &&
sizes.every((size) => baseConfig[size])) {
return baseConfig[this.size]
}
return baseConfig
}
mergeNestedClasses(baseConfig, componentConfig) {
const mergedConfig = {}
const allKeys = new Set([
...Object.keys(baseConfig),
...Object.keys(componentConfig),
])
allKeys.forEach((key) => {
const baseValue = this.extractSizedClasses(baseConfig[key])
const componentValue = this.extractSizedClasses(componentConfig[key])
if (key === 'borderRadius') {
// Special case for border radius
const borderRadiusClass = baseConfig.borderRadius?.[this.borderRadius] || ''
mergedConfig[key] = twMerge(borderRadiusClass, componentValue)
} else if (
typeof baseValue === 'object' &&
baseValue !== null &&
!Array.isArray(baseValue)) {
mergedConfig[key] = this.mergeNestedClasses(baseValue, componentValue || {})
} else {
mergedConfig[key] = twMerge(baseValue || '', componentValue || '')
}
})
return mergedConfig
}
getComponentTheme(componentName = 'default') {
const baseComponentConfig = this.theme.default || {}
const componentConfig = this.theme[componentName] || {}
return this.mergeNestedClasses(baseComponentConfig, componentConfig)
}
// Get all components classes for the selected theme
getAllComponents() {
const allComponents = {}
Object.keys(this.theme).forEach((componentName) => {
allComponents[componentName] = this.getComponentTheme(componentName)
})
return allComponents
}
}
export default ThemeBuilder

380
client/lib/forms/themes/form-themes.js vendored Normal file
View File

@ -0,0 +1,380 @@
/**
Input classes for each supported form themes
*/
export const themes = {
default: {
default: {
wrapper: {
sm: 'relative mb-2',
md: 'relative mb-3',
lg: 'relative mb-3',
},
label: 'text-gray-700 dark:text-gray-300 font-medium',
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-500',
spacing: {
horizontal: {
sm: 'px-2',
md: 'px-4',
lg: 'px-5'
},
vertical: {
sm: 'py-1.5',
md: 'py-2',
lg: 'py-3'
}
},
fontSize: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
},
borderRadius: {
none: 'rounded-none',
small: 'rounded-lg',
full: 'rounded-[20px]'
}
},
ScaleInput: {
button: 'cursor-pointer text-gray-700 inline-block border-gray-300 flex-grow dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white border'
},
SliderInput: {
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-90'
},
CodeInput: {
input: 'overflow-hidden'
},
RichTextAreaInput: {
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2'
},
SelectInput: {
input:
'relative w-full flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
dropdown: 'border border-gray-300 dark:border-gray-600',
option: 'rounded',
minHeight: {
sm: 'min-h-[20px]',
md: 'min-h-[24px]',
lg: 'min-h-[28px]'
}
},
FlatSelectInput: {
option: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex items-center space-x-2 border-t first:border-t-0 px-2',
unselectedIcon: 'text-gray-300 dark:text-gray-600',
icon: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6 mx-1'
}
},
DateInput: {
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100'
},
CheckboxInput:{
size: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-5 h-5'
},
},
SwitchInput:{
containerSize: {
sm: 'h-5 w-10 p-0.5',
md: 'h-6 w-12 p-1',
lg: 'h-6 w-12 p-1'
},
circleSize: {
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-4 w-4'
},
translatedClass: {
sm: 'translate-x-5',
md: 'translate-x-6',
lg: 'translate-x-6'
}
},
RatingInput:{
size: {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10'
},
},
fileInput: {
input:
'border border-dashed border-gray-300 dark:border-gray-600 p-4 shadow-none',
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-58'
},
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile:
'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded-lg shadow-sm max-w-[10rem]'
},
SignatureInput: {
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-48'
},
}
},
simple: {
default: {
wrapper: {
sm: 'relative mb-2',
md: 'relative mb-3',
lg: 'relative mb-3',
},
label: 'text-gray-700 dark:text-gray-300 font-medium',
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100',
help: 'text-gray-500',
spacing: {
horizontal: {
sm: 'px-2',
md: 'px-4',
lg: 'px-5'
},
vertical: {
sm: 'py-1.5',
md: 'py-2',
lg: 'py-3'
}
},
fontSize: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
},
borderRadius: {
none: 'rounded-none',
small: 'rounded-lg',
full: 'rounded-[20px]'
}
},
ScaleInput: {
button: 'flex-1 appearance-none border-gray-300 dark:border-gray-600 w-full bg-gray-50 text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white border'
},
SliderInput: {
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-90'
},
CodeInput: {
input: 'overflow-hidden'
},
RichTextAreaInput: {
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2'
},
SelectInput: {
input:
'relative w-full flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 text-base focus:outline-none focus:ring-2 focus:border-transparent',
dropdown: 'border border-gray-300 dark:border-gray-600',
option: 'rounded',
minHeight: {
sm: 'min-h-[20px]',
md: 'min-h-[24px]',
lg: 'min-h-[28px]'
}
},
FlatSelectInput: {
option: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex items-center space-x-2 border-t first:border-t-0 px-2',
unselectedIcon: 'text-gray-300 dark:text-gray-600',
icon: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6 mx-1'
}
},
DateInput: {
input: 'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100'
},
CheckboxInput:{
size: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-5 h-5'
},
},
SwitchInput:{
containerSize: {
sm: 'h-5 w-10',
md: 'h-6 w-12',
lg: 'h-6 w-12'
},
circleSize: {
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-4 w-4'
},
},
RatingInput:{
size: {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10'
},
},
fileInput: {
input:
'border border-dashed border-gray-300 dark:border-gray-600 p-4 shadow-none',
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-48'
},
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile:
'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light shadow-sm max-w-[10rem]'
},
SignatureInput: {
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-48'
},
}
},
notion: {
default: {
wrapper: {
sm: 'relative mb-2',
md: 'relative mb-3',
lg: 'relative mb-3',
},
label: 'text-gray-900 dark:text-gray-100 mb-1 block mt-4',
input:
'rounded border-transparent flex-1 appearance-none shadow-inner-notion w-full bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 dark:placeholder-gray-500 placeholder-gray-400 focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
help: 'text-gray-500',
spacing: {
horizontal: {
sm: 'px-2',
md: 'px-4',
lg: 'px-5'
},
vertical: {
sm: 'py-1.5',
md: 'py-2',
lg: 'py-3'
}
},
fontSize: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
},
borderRadius: {
none: 'rounded-none',
small: 'rounded',
full: 'rounded-[20px]'
}
},
ScaleInput: {
button: 'border-transparent flex-1 appearance-none shadow-inner-notion w-full bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 text-center',
unselectedButton: 'bg-notion-input-background dark:bg-notion-dark-light border'
},
SliderInput: {
stepLabel: 'text-gray-700 dark:text-gray-300 text-center text-xs'
},
Button: {
body: 'transition ease-in duration-200 text-center font-semibold shadow shadow-inner-notion focus:outline-none focus:ring-2 focus:ring-offset-2 filter hover:brightness-90'
},
CodeInput: {
input: 'shadow-inner-notion border-transparent focus:border-transparent overflow-hidden'
},
RichTextAreaInput: {
input:
'flex-1 appearance-none shadow-inner-notion border-transparent focus:border-transparent w-full text-gray-900 bg-notion-input-background dark:bg-notion-dark-light dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:ring-opacity-100 focus:border-transparent focus:ring-0 focus:shadow-focus-notion'
},
SelectInput: {
input:
'relative w-full border-transparent flex-1 appearance-none bg-notion-input-background shadow-inner-notion w-full text-gray-900 placeholder-gray-400 dark:bg-notion-dark-light dark:placeholder-gray-500 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
dropdown: 'border border-gray-300 dark:border-gray-600',
option: 'rounded',
minHeight: {
sm: 'min-h-[20px]',
md: 'min-h-[24px]',
lg: 'min-h-[28px]'
}
},
FlatSelectInput: {
option: 'cursor-pointer hover:backdrop-brightness-95 flex items-center space-x-2 border-t border-neutral-300 first:border-t-0 px-2',
unselectedIcon: 'text-neutral-300 dark:text-neutral-600',
icon: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6 mx-1'
}
},
DateInput: {
input: 'shadow-inner-notion border-transparent focus:border-transparent flex-1 appearance-none w-full bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion p-[1px]'
},
CheckboxInput:{
size: {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-5 h-5'
},
},
SwitchInput:{
containerSize: {
sm: 'h-5 w-10',
md: 'h-6 w-12',
lg: 'h-6 w-12'
},
circleSize: {
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-4 w-4'
},
},
RatingInput:{
size: {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10'
},
},
fileInput: {
input:
'p-4 rounded bg-notion-input-background dark:bg-notion-dark',
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-48'
},
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile:
'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded shadow-sm max-w-[10rem]'
},
SignatureInput: {
minHeight: {
sm: 'min-h-28',
md: 'min-h-40',
lg: 'min-h-48'
},
}
}
}

View File

@ -1,4 +1,4 @@
import { themes } from "~/lib/forms/form-themes.js" import { themes } from "~/lib/forms/themes/form-themes.js"
import { default as _has } from "lodash/has" import { default as _has } from "lodash/has"
export default { export default {

View File

@ -34,6 +34,7 @@
"prismjs": "^1.24.1", "prismjs": "^1.24.1",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"query-builder-vue-3": "^1.0.1", "query-builder-vue-3": "^1.0.1",
"tailwind-merge": "^2.3.0",
"tinymotion": "^0.2.0", "tinymotion": "^0.2.0",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
"vue": "^3.2.13", "vue": "^3.2.13",
@ -13772,11 +13773,11 @@
} }
}, },
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz",
"integrity": "sha512-tWANXsnmJzgw6mQ07nE3aCDkCK4QdT3ThPMCzawoYA2Pws7vSTCvz3Vrjg61jVUGfFZPJzxEP+NimbcW+EdaDw==", "integrity": "sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.24.0" "@babel/runtime": "^7.24.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",

View File

@ -56,6 +56,7 @@
"prismjs": "^1.24.1", "prismjs": "^1.24.1",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"query-builder-vue-3": "^1.0.1", "query-builder-vue-3": "^1.0.1",
"tailwind-merge": "^2.3.0",
"tinymotion": "^0.2.0", "tinymotion": "^0.2.0",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
"vue": "^3.2.13", "vue": "^3.2.13",

View File

@ -9,7 +9,8 @@ module.exports = {
"./plugins/**/*.{js,ts}", "./plugins/**/*.{js,ts}",
"./app.vue", "./app.vue",
"./error.vue", "./error.vue",
"./lib/forms/form-themes.js", "./lib/forms/themes/form-themes.js",
"./lib/forms/themes/ThemeBuilder.js",
], ],
safelist: [ safelist: [
{ {

View File

@ -60,6 +60,8 @@ class FormFactory extends Factory
'description' => $this->faker->randomHtml(1), 'description' => $this->faker->randomHtml(1),
'visibility' => 'public', 'visibility' => 'public',
'theme' => $this->faker->randomElement(Form::THEMES), 'theme' => $this->faker->randomElement(Form::THEMES),
'size' => $this->faker->randomElement(Form::SIZES),
'border_radius' => $this->faker->randomElement(Form::BORDER_RADIUS),
'width' => $this->faker->randomElement(Form::WIDTHS), 'width' => $this->faker->randomElement(Form::WIDTHS),
'dark_mode' => $this->faker->randomElement(Form::DARK_MODE_VALUES), 'dark_mode' => $this->faker->randomElement(Form::DARK_MODE_VALUES),
'color' => '#3B82F6', 'color' => '#3B82F6',

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('forms', function (Blueprint $table) {
$table->string('size')->default('md');
$table->string('border_radius')->default('small');
});
// Then for each form with "Simple" theme on, disable border radius
\App\Models\Forms\Form::whereTheme('simple')->update(['border_radius' => 'none']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('forms', function (Blueprint $table) {
$table->dropColumn(['size', 'border_radius']);
});
}
};