Add Tabler Icons and Refactor Form Components (#771)
- Updated `package.json` and `package-lock.json` to include `@iconify-json/tabler` for additional icon support. - Refactored `ImageInput.vue` to utilize `nuxt/icon` for icon rendering, enhancing consistency across components. - Introduced `OptionSelectorInput.vue` as a new form component for selecting options in a grid layout, integrating with the form system. - Updated `FormCustomization.vue` and `FormEditorPreview.vue` to utilize the new `OptionSelectorInput` for improved user experience in form settings. - Enhanced `HiddenRequiredDisabled.vue` to replace manual button rendering with `OptionSelectorInput`, streamlining the component structure. These changes aim to improve the iconography and form component functionality, providing a more cohesive and user-friendly interface.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<input-wrapper v-bind="inputWrapperProps">
|
||||
<InputWrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<span class="inline-block w-full rounded-md shadow-sm">
|
||||
<span class="inline-block w-full rounded-md shadow-xs">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
@@ -12,7 +12,7 @@
|
||||
aria-labelledby="listbox-label"
|
||||
class="cursor-pointer relative w-full"
|
||||
:class="[
|
||||
theme.default.input,
|
||||
theme.default.input,
|
||||
theme.default.spacing.horizontal,
|
||||
theme.default.spacing.vertical,
|
||||
theme.default.fontSize,
|
||||
@@ -24,52 +24,37 @@
|
||||
>
|
||||
<div
|
||||
v-if="currentUrl == null"
|
||||
class="text-gray-600 dark:text-gray-400"
|
||||
class="text-gray-600 dark:text-gray-400 flex justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
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>
|
||||
Upload image
|
||||
<Icon
|
||||
name="heroicons:cloud-arrow-up"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="ml-2">
|
||||
Upload
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-6 text-gray-600 dark:text-gray-400 flex"
|
||||
class=" text-gray-600 dark:text-gray-400 flex"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<img
|
||||
:src="currentUrl"
|
||||
class="h-6 rounded shadow-md"
|
||||
:src="tmpFile ?? currentUrl"
|
||||
class="h-5 rounded shadow-md border"
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="hover:text-nt-blue flex"
|
||||
class="text-gray-500 hover:text-red-500 flex items-center"
|
||||
@click.prevent="clearUrl"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/></svg></a>
|
||||
<Icon
|
||||
name="heroicons:trash"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</button>
|
||||
</span>
|
||||
@@ -105,7 +90,7 @@
|
||||
v-if="loading"
|
||||
class="text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Loader class="h-6 w-6 mx-auto m-10" />
|
||||
<loader class="h-5 w-5 mx-auto m-10" />
|
||||
<p class="text-center mt-6">
|
||||
Uploading your file...
|
||||
</p>
|
||||
@@ -127,20 +112,10 @@
|
||||
accept="image/png, image/gif, image/jpeg, image/bmp, image/svg+xml"
|
||||
@change="manualFileUpload"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mx-auto h-24 w-24 text-gray-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
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>
|
||||
<Icon
|
||||
name="heroicons:cloud-arrow-up"
|
||||
class="x-auto h-24 w-24 text-gray-200"
|
||||
/>
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
@@ -161,7 +136,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</input-wrapper>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
199
client/components/forms/OptionSelectorInput.vue
Normal file
199
client/components/forms/OptionSelectorInput.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<input-wrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="grid"
|
||||
:class="[gridClass, { 'gap-2': !seamless }]"
|
||||
:style="optionStyle"
|
||||
role="listbox"
|
||||
:aria-multiselectable="multiple ? 'true' : 'false'"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
@keydown="onKeydown"
|
||||
ref="root"
|
||||
>
|
||||
<button
|
||||
v-for="(option, idx) in options"
|
||||
:key="option[optionKey]"
|
||||
class="flex flex-col items-center justify-center p-1.5 border transition-colors text-gray-500 focus:outline-none"
|
||||
:class="[
|
||||
option.class ? (typeof option.class === 'function' ? option.class(isSelected(option)) : option.class) : {},
|
||||
{
|
||||
'border-form-color text-form-color bg-form-color/10': isSelected(option),
|
||||
'hover:bg-gray-100 border-gray-300': !isSelected(option),
|
||||
'opacity-50 pointer-events-none': disabled || option.disabled,
|
||||
// Seamless mode: only first and last have radius
|
||||
'rounded-lg': !seamless,
|
||||
'rounded-l-lg': seamless && idx === 0,
|
||||
'rounded-r-lg': seamless && idx === options.length - 1,
|
||||
// Seamless mode: overlap borders with negative margin, keep all borders
|
||||
'-ml-px': seamless && idx > 0,
|
||||
// Seamless mode: z-index hierarchy - selected > hovered/focused > default
|
||||
'relative z-20': seamless && isSelected(option),
|
||||
'relative z-10': seamless && !isSelected(option) && focusedIdx === idx,
|
||||
'relative z-0': seamless && !isSelected(option) && focusedIdx !== idx,
|
||||
// Add hover z-index for seamless mode (but lower than selected)
|
||||
'hover:z-10': seamless && !isSelected(option)
|
||||
}
|
||||
]"
|
||||
:aria-selected="isSelected(option) ? 'true' : 'false'"
|
||||
:tabindex="disabled || option.disabled ? -1 : 0"
|
||||
:disabled="disabled || option.disabled"
|
||||
@click="selectOption(option)"
|
||||
@focus="focusedIdx = idx"
|
||||
@mouseenter="focusedIdx = idx"
|
||||
:title="option.tooltip || ''"
|
||||
role="option"
|
||||
>
|
||||
<slot name="icon" :option="option" :selected="isSelected(option)">
|
||||
<Icon
|
||||
v-if="option.icon"
|
||||
:name="isSelected(option) && option.selectedIcon ? option.selectedIcon : option.icon"
|
||||
:class="[
|
||||
'w-4 h-4',
|
||||
option.label ? 'mb-1' : '',
|
||||
isSelected(option) ? 'text-form-color' : 'text-inherit',
|
||||
option.iconClass ? (typeof option.iconClass === 'function' ? option.iconClass(isSelected(option)) : option.iconClass) : {}
|
||||
]"
|
||||
/>
|
||||
</slot>
|
||||
<span
|
||||
v-if="option.label || !option.icon"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-form-color': isSelected(option),
|
||||
'text-inherit': !isSelected(option),
|
||||
}"
|
||||
>{{ isSelected(option) ? option.selectedLabel ?? option.label : option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
/**
|
||||
* OptionSelectorInput.vue
|
||||
*
|
||||
* A form input component for selecting options in a grid layout with icons.
|
||||
* Integrates with the form system using InputWrapper and useFormInput.
|
||||
*
|
||||
* Props:
|
||||
* - options: Array<{ name, label, icon, selectedIcon?, iconClass?, tooltip?, disabled? }>
|
||||
* - multiple: Boolean (default: false)
|
||||
* - optionKey: String (default: 'name')
|
||||
* - columns: Number (default: 3, for grid layout)
|
||||
* - seamless: Boolean (default: false, removes gaps and only applies radius to first/last items)
|
||||
*
|
||||
* Features:
|
||||
* - Keyboard navigation (arrow keys, enter/space to select)
|
||||
* - Focus management
|
||||
* - Optional tooltips per option
|
||||
* - Form validation integration
|
||||
* - Notion-style look by default
|
||||
* - Seamless mode for connected button appearance
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
options: { type: Array, required: true },
|
||||
multiple: { type: Boolean, default: false },
|
||||
optionKey: { type: String, default: 'name' },
|
||||
columns: { type: Number, default: 3 },
|
||||
seamless: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
|
||||
|
||||
// Use form input composable
|
||||
const {
|
||||
compVal,
|
||||
inputWrapperProps
|
||||
} = useFormInput(props, { emit })
|
||||
|
||||
// Local state
|
||||
const focusedIdx = ref(-1)
|
||||
const root = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const gridClass = computed(() => `grid-cols-${props.columns}`)
|
||||
|
||||
const optionStyle = computed(() => ({
|
||||
'--bg-form-color': props.color
|
||||
}))
|
||||
|
||||
// Methods
|
||||
function isSelected(option) {
|
||||
if (props.multiple) {
|
||||
return Array.isArray(compVal.value) && compVal.value.includes(option[props.optionKey])
|
||||
}
|
||||
return compVal.value === option[props.optionKey]
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (props.disabled || option.disabled) return
|
||||
|
||||
if (props.multiple) {
|
||||
let newValue = Array.isArray(compVal.value) ? [...compVal.value] : []
|
||||
const idx = newValue.indexOf(option[props.optionKey])
|
||||
if (idx > -1) {
|
||||
newValue.splice(idx, 1)
|
||||
} else {
|
||||
newValue.push(option[props.optionKey])
|
||||
}
|
||||
compVal.value = newValue
|
||||
} else {
|
||||
compVal.value = isSelected(option) ? null : option[props.optionKey]
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (props.disabled) return
|
||||
const len = props.options.length
|
||||
if (len === 0) return
|
||||
|
||||
if (["ArrowRight", "ArrowDown"].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
focusedIdx.value = (focusedIdx.value + 1) % len
|
||||
focusButton(focusedIdx.value)
|
||||
} else if (["ArrowLeft", "ArrowUp"].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
focusedIdx.value = (focusedIdx.value - 1 + len) % len
|
||||
focusButton(focusedIdx.value)
|
||||
} else if (["Enter", " ", "Spacebar"].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
if (focusedIdx.value >= 0 && focusedIdx.value < len) {
|
||||
selectOption(props.options[focusedIdx.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusButton(idx) {
|
||||
nextTick(() => {
|
||||
const btns = root.value?.querySelectorAll('button')
|
||||
if (btns && btns[idx]) btns[idx].focus()
|
||||
})
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(compVal, (val) => {
|
||||
// Keep focus on selected
|
||||
if (!props.multiple && val != null) {
|
||||
const idx = props.options.findIndex(opt => opt[props.optionKey] === val)
|
||||
if (idx !== -1) focusedIdx.value = idx
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user