refactor(form): migrate form components to use doc-entry pattern
- Replace FieldGroup/Field/Label with DE.Cell/DE.Field/DE.Label components - Add ButtonAction component with preset styling for common form actions - Simplify form layouts using doc-entry grid system - Add support for disabled/readonly states in form fields
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '~/components/pub/ui/button'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
/**
|
||||
* Button Action Component untuk form
|
||||
* Support preset: add, delete, save, cancel
|
||||
*/
|
||||
interface Props {
|
||||
/**
|
||||
* Preset button type dengan styling bawaan
|
||||
* - add: Button tambah (outline primary)
|
||||
* - delete: Button hapus (ghost)
|
||||
* - save: Button simpan (primary)
|
||||
* - cancel: Button batal (secondary)
|
||||
* - custom: Custom styling
|
||||
*/
|
||||
preset?: 'add' | 'delete' | 'save' | 'cancel' | 'custom'
|
||||
|
||||
/**
|
||||
* Icon name (UnoCSS/Lucide)
|
||||
* Default akan diset berdasarkan preset
|
||||
*/
|
||||
icon?: string
|
||||
|
||||
/**
|
||||
* Button text
|
||||
* Set ke empty string ('') untuk icon-only mode
|
||||
*/
|
||||
label?: string
|
||||
|
||||
/**
|
||||
* Button title (tooltip)
|
||||
* Wajib untuk icon-only buttons (accessibility)
|
||||
*/
|
||||
title?: string
|
||||
|
||||
/**
|
||||
* Icon only mode (no label)
|
||||
* Otomatis true jika label kosong
|
||||
*/
|
||||
iconOnly?: boolean
|
||||
|
||||
/**
|
||||
* Button type
|
||||
*/
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
|
||||
/**
|
||||
* Disabled state
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
/**
|
||||
* Custom class untuk override styling
|
||||
*/
|
||||
class?: string
|
||||
|
||||
/**
|
||||
* Responsive width (full width on mobile)
|
||||
*/
|
||||
fullWidthMobile?: boolean
|
||||
|
||||
/**
|
||||
* Button size
|
||||
*/
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
|
||||
/**
|
||||
* Button variant (override preset variant)
|
||||
*/
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
preset: 'custom',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
fullWidthMobile: false,
|
||||
iconOnly: false,
|
||||
})
|
||||
|
||||
// Preset configurations
|
||||
const presetConfig = {
|
||||
add: {
|
||||
variant: 'outline' as const,
|
||||
icon: 'i-lucide-plus',
|
||||
label: 'Tambah',
|
||||
classes:
|
||||
'border-primary bg-white text-primary hover:bg-primary hover:text-white dark:bg-slate-800 dark:border-primary dark:text-primary dark:hover:bg-primary dark:hover:text-white',
|
||||
},
|
||||
delete: {
|
||||
variant: 'ghost' as const,
|
||||
icon: 'i-lucide-trash-2',
|
||||
label: '', // Default kosong untuk icon-only
|
||||
classes:
|
||||
'hover:bg-destructive hover:text-white hover:border-destructive dark:hover:bg-destructive dark:hover:text-white',
|
||||
},
|
||||
save: {
|
||||
variant: 'default' as const,
|
||||
icon: 'i-lucide-save',
|
||||
label: 'Simpan',
|
||||
classes: 'bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/90',
|
||||
},
|
||||
cancel: {
|
||||
variant: 'secondary' as const,
|
||||
icon: 'i-lucide-x',
|
||||
label: 'Batal',
|
||||
classes: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 dark:bg-slate-700 dark:hover:bg-slate-600',
|
||||
},
|
||||
custom: {
|
||||
variant: 'default' as const,
|
||||
icon: '',
|
||||
label: '',
|
||||
classes: '',
|
||||
},
|
||||
}
|
||||
|
||||
const currentPreset = computed(() => presetConfig[props.preset])
|
||||
const buttonVariant = computed(() => props.variant || currentPreset.value.variant)
|
||||
const buttonIcon = computed(() => props.icon || currentPreset.value.icon)
|
||||
|
||||
// Label handling: gunakan prop label jika ada, fallback ke preset, atau undefined jika iconOnly
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.label !== undefined) return props.label
|
||||
return currentPreset.value.label
|
||||
})
|
||||
|
||||
const buttonTitle = computed(() => props.title || buttonLabel.value)
|
||||
|
||||
// Deteksi icon-only mode
|
||||
const isIconOnly = computed(() => {
|
||||
return props.iconOnly || buttonLabel.value === '' || !buttonLabel.value
|
||||
})
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
// Base classes berbeda untuk icon-only vs with-label
|
||||
const baseClasses = isIconOnly.value
|
||||
? 'rounded-md p-2 w-9 h-9 transition-colors flex items-center justify-center' // Icon only: square button
|
||||
: 'rounded-md px-4 py-2 transition-colors sm:text-sm' // With label: padding horizontal lebih besar
|
||||
|
||||
const widthClasses = props.fullWidthMobile && !isIconOnly.value ? 'w-full sm:w-auto' : ''
|
||||
const presetClasses = currentPreset.value.classes
|
||||
|
||||
return cn(baseClasses, widthClasses, presetClasses, props.class)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:title="buttonTitle"
|
||||
:type="type"
|
||||
:variant="buttonVariant"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:class="buttonClasses"
|
||||
>
|
||||
<Icon
|
||||
v-if="buttonIcon"
|
||||
:name="buttonIcon"
|
||||
:class="cn('h-4 w-4 align-middle transition-colors', !isIconOnly ? 'mr-2' : '')"
|
||||
/>
|
||||
<slot v-if="!isIconOnly">{{ buttonLabel }}</slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -1,8 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/my-ui/form/field.vue'
|
||||
import Label from '~/components/pub/my-ui/form/label.vue'
|
||||
import { Input } from '~/components/pub/ui/input'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
@@ -11,7 +8,7 @@ import * as DE from '~/components/pub/my-ui/doc-entry'
|
||||
const props = defineProps<{
|
||||
fieldName: string
|
||||
placeholder: string
|
||||
label: string
|
||||
label?: string
|
||||
errors?: FormErrors
|
||||
class?: string
|
||||
colSpan?: number
|
||||
@@ -21,6 +18,8 @@ const props = defineProps<{
|
||||
isDisabled?: boolean
|
||||
rightLabel?: string
|
||||
bottomLabel?: string
|
||||
suffixMsg?: string
|
||||
iconName?: string
|
||||
}>()
|
||||
|
||||
function handleInput(event: Event) {
|
||||
@@ -49,7 +48,7 @@ function handleInput(event: Event) {
|
||||
<template>
|
||||
<DE.Cell :col-span="colSpan || 1">
|
||||
<DE.Label
|
||||
v-if="label !== ''"
|
||||
v-if="label"
|
||||
:label-for="fieldName"
|
||||
:is-required="isRequired && !isDisabled"
|
||||
>
|
||||
@@ -63,27 +62,50 @@ function handleInput(event: Event) {
|
||||
v-slot="{ componentField }"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem :class="cn(`relative`,)">
|
||||
<FormControl>
|
||||
<Input
|
||||
:disabled="isDisabled"
|
||||
v-bind="componentField"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0', props.class)"
|
||||
autocomplete="off"
|
||||
aria-autocomplete="none"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<p v-show="rightLabel" class="text-gray-400 absolute top-0 right-3">{{ rightLabel }}</p>
|
||||
<FormItem>
|
||||
<FormControl class="relative">
|
||||
<div class="relative w-full max-w-sm items-center">
|
||||
<Input
|
||||
:disabled="isDisabled"
|
||||
v-bind="componentField"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
|
||||
autocomplete="off"
|
||||
aria-autocomplete="none"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<span
|
||||
v-if="suffixMsg"
|
||||
class="absolute inset-y-0 end-0 flex items-center justify-center px-2 text-muted-foreground"
|
||||
>
|
||||
{{ suffixMsg }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="iconName"
|
||||
:name="iconName"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<p
|
||||
v-show="rightLabel"
|
||||
class="absolute right-3 top-0 text-gray-400"
|
||||
>
|
||||
{{ rightLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</DE.Field>
|
||||
<p v-show="bottomLabel" class="text-gray-400 mt-1">{{ bottomLabel }}</p>
|
||||
<p
|
||||
v-show="bottomLabel"
|
||||
class="text-gray-400"
|
||||
>
|
||||
{{ bottomLabel }}
|
||||
</p>
|
||||
</DE.Cell>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user