initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<input
:type="type"
:id="id"
:name="id"
:class="['form-control-custom', inputClass]"
:placeholder="placeholder"
:value="modelValue"
:required="required"
:disabled="disabled"
:list="datalistId"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div class="input-addons">
<!-- Icon/span append -->
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
:id="`imgspan${id}`"
/>
<span v-else-if="spanClass" :class="spanClass" :id="`${id}-span`"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
type: { type: String, default: 'text' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default', validator: (v) => ['default', 'glass', 'soft'].includes(v) },
inputClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.6);
border-color: var(--accent-color, #533dea);
}
:global(.dark-mode) .variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.1);
}
/* Soft Variant */
.variant-soft .form-control-custom {
background: var(--bg-tertiary, #f0f2f5);
border: none;
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<button
:class="[
'btn-custom',
`btn-${variant}`,
`btn-${size}`,
{ 'is-loading': loading, 'is-disabled': disabled || loading }
]"
:id="id"
:style="buttonStyle"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<div class="btn-content">
<LoadingSpinner v-if="loading" size="sm" color="currentColor" class="me-2" />
<slot>
{{ text }}
</slot>
</div>
</button>
</template>
<script setup>
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
defineProps({
text: { type: String, default: '' },
variant: { type: String, default: 'primary', validator: (v) => ['primary', 'secondary', 'outline', 'danger', 'glass', 'text'].includes(v) },
id: { type: String, default: '' },
buttonStyle: { type: String, default: '' },
loading: { type: Boolean, default: false },
size: { type: String, default: 'md', validator: (v) => ['sm', 'md', 'lg'].includes(v) },
disabled: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>
<style scoped>
.btn-custom {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
cursor: pointer;
position: relative;
overflow: hidden;
gap: 0.5rem;
}
.btn-content {
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* Sizes */
.btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; }
.btn-md { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn-lg { padding: 1rem 2rem; font-size: 1.125rem; }
/* Variants */
.btn-primary {
background: linear-gradient(135deg, var(--accent-color, #4f46e5) 0%, #3730a3 100%);
color: white;
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(79, 70, 229, 0.4));
}
.btn-primary:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px var(--accent-soft, rgba(79, 70, 229, 0.5));
}
.btn-secondary {
background: var(--bg-tertiary, #f0f2f5);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-secondary {
background: var(--bg-tertiary, #2d3748);
color: white;
}
.btn-secondary:hover:not(.is-disabled) {
background: var(--border-color, #e2e8f0);
}
:global(.dark-mode) .btn-secondary:hover:not(.is-disabled) {
background: #3d4a5d;
}
.btn-outline {
background: transparent;
border-color: var(--border-color, rgba(0, 0, 0, 0.1));
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-outline {
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.btn-outline:hover:not(.is-disabled) {
background: var(--accent-soft, rgba(0, 0, 0, 0.05));
border-color: var(--accent-color, rgba(0, 0, 0, 0.4));
}
:global(.dark-mode) .btn-outline:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.4);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(.is-disabled) {
background: #dc2626;
}
.btn-glass {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-glass {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}
.btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.5);
}
:global(.dark-mode) .btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.15);
}
.btn-text {
background: transparent;
color: var(--text-secondary, #717171);
}
.btn-text:hover:not(.is-disabled) {
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-text:hover:not(.is-disabled) {
color: white;
}
/* States */
.is-disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.is-loading {
cursor: wait;
}
.btn-custom:active:not(.is-disabled) {
transform: scale(0.98);
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div :class="['form-check-container mb-3', { 'has-error': error, 'is-switch': isSwitch }]">
<label :for="id" class="checkbox-wrapper">
<input
type="checkbox"
:id="id"
class="checkbox-input"
:checked="isChecked"
:disabled="disabled"
:value="value"
@change="handleChange"
/>
<div class="checkbox-custom" :class="{ 'is-premium': isPremium }">
<div v-if="isSwitch" class="switch-handle"></div>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="check-icon"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<span class="checkbox-label" v-if="label">{{ label }}</span>
</label>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [Boolean, Array], default: false },
value: { type: [String, Number, Boolean], default: null },
disabled: { type: Boolean, default: false },
isSwitch: { type: Boolean, default: false },
isPremium: { type: Boolean, default: true },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value)
}
return props.modelValue
})
const handleChange = (event) => {
const checked = event.target.checked
if (Array.isArray(props.modelValue)) {
const newValue = [...props.modelValue]
if (checked) {
newValue.push(props.value)
} else {
const index = newValue.indexOf(props.value)
if (index > -1) newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
} else {
emit('update:modelValue', checked)
}
}
</script>
<style scoped>
.form-check-container {
display: block;
min-height: 1.5rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
gap: 0.75rem;
}
.checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Custom Checkbox Design */
.checkbox-custom {
position: relative;
height: 22px;
width: 22px;
background: var(--bg-secondary, #f8f9fa);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark-mode) .checkbox-custom {
background: var(--bg-tertiary, #24272d);
border-color: rgba(255, 255, 255, 0.1);
}
.is-switch .checkbox-custom {
width: 44px;
border-radius: 20px;
}
.checkbox-input:checked ~ .checkbox-custom {
background: var(--accent-color, #533dea);
border-color: var(--accent-color, #533dea);
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(83, 61, 234, 0.4));
}
.check-icon {
color: white;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .check-icon {
opacity: 1;
transform: scale(1);
}
/* Switch Handle */
.switch-handle {
position: absolute;
left: 3px;
top: 3px;
height: 14px;
width: 14px;
background: white;
border-radius: 50%;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .switch-handle {
left: 25px;
}
/* Premium Effects */
.is-premium {
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.checkbox-wrapper:hover .checkbox-custom {
border-color: var(--accent-color, rgba(83, 61, 234, 0.3));
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-primary, #1e1e1e);
font-weight: 500;
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
margin-left: 2rem;
}
.checkbox-input:disabled ~ .checkbox-custom {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div>
<label v-if="label" :for="id">
<span v-html="label"></span>
<IconImage
v-if="!disabled"
src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin"
:id="`ClearUploadButton-${id}`"
class="clear-upload-btn"
@click="$emit('clear')"
/>
</label>
<div class="input-group mb-3">
<form
:id="id"
:action="uploadUrl"
:style="{ width: computedWidth, pointerEvents: disabled ? 'none' : 'auto' }"
class="dropzone"
:class="{ disabled: disabled }"
>
<slot>
<div class="dz-message">Drop files here or click to upload</div>
</slot>
</form>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
uploadUrl: { type: String, default: '/File/Upload/Unknown' },
width: { type: String, default: '100%' },
disabled: { type: Boolean, default: false },
})
defineEmits(['files-changed', 'clear'])
const computedWidth = computed(() => props.width || '100%')
</script>
<style scoped>
.clear-upload-btn {
cursor: pointer;
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<InputGroup
:label="label"
type="number"
:id="id"
:placeholder="placeholder"
:model-value="modelValue"
:required="required"
:disabled="disabled"
:input-class="inputClass"
:icon="icon"
:icon-width="iconWidth"
:icon-height="iconHeight"
:datalist-id="datalistId"
v-bind="numberAttrs"
@update:model-value="$emit('update:modelValue', $event)"
/>
</template>
<script setup>
import { computed } from 'vue'
import InputGroup from './InputGroup.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: 0 },
min: { type: [String, Number], default: '' },
max: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
inputClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '40px' },
iconHeight: { type: [String, Number], default: '40px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
const numberAttrs = computed(() => {
const attrs = {}
if (props.min !== '') attrs.min = props.min
if (props.max !== '') attrs.max = props.max
return attrs
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<select
:id="id"
:class="['form-control-custom', selectClass]"
:disabled="disabled"
:value="modelValue"
:required="required"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled selected>{{ placeholder }}</option>
<option
v-for="(opt, index) in formattedOptions"
:key="index"
:value="opt.value"
>
{{ opt.text }}
</option>
</select>
<div class="select-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div v-if="icon || spanClass" class="input-addons">
<IconImage v-if="icon" :src="icon" :width="iconWidth" :height="iconHeight" />
<span v-else-if="spanClass" :class="spanClass"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
selectClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
})
defineEmits(['update:modelValue'])
const formattedOptions = computed(() => {
return props.options.map(opt => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, text: opt }
}
return opt
})
})
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
appearance: none;
-webkit-appearance: none;
transition: all 0.2s ease;
cursor: pointer;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
.select-arrow {
position: absolute;
right: 1rem;
pointer-events: none;
color: var(--text-muted, #a0a0a0);
display: flex;
align-items: center;
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 2.5rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<textarea
:id="id"
:name="id"
:rows="rows"
:class="['form-control-custom', textareaClass]"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
rows: { type: Number, default: 3 },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
textareaClass: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
resize: vertical;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>