Enhance layout and components with improved accessibility and performance
- Refactor JavaScript functions to prevent duplicate initializations on navigation - Add skeleton loaders for invoices and services during loading states - Introduce a new progress bar component with customizable properties - Update dropdown and checkbox components for better styling and accessibility - Improve modal and navigation components with enhanced animations and structure
This commit is contained in:
@@ -1,33 +1,33 @@
|
||||
<div class="relative" x-data="{ open: false, adjustWidth: 0 }" x-init="$watch('open', value => {
|
||||
<div class="relative" x-data="{ open: false, activeIndex: -1 }" @keydown.escape="open = false" @keydown.down.prevent="activeIndex = Math.min(activeIndex + 1, $refs.dropdown.children.length - 1)" @keydown.up.prevent="activeIndex = Math.max(activeIndex - 1, 0)" x-init="$watch('open', value => {
|
||||
if (value) {
|
||||
adjustWidth = 0; // Reset adjustWidth when opening
|
||||
activeIndex = -1;
|
||||
$nextTick(() => {
|
||||
let dropdown = $refs.dropdown;
|
||||
let rect = dropdown.getBoundingClientRect();
|
||||
let windowWidth = window.innerWidth;
|
||||
adjustWidth = rect.right > windowWidth ? rect.width - 40 : 0;
|
||||
dropdown.style.left = rect.right > windowWidth ? `-${rect.width - 40}px` : '0';
|
||||
});
|
||||
}
|
||||
})">
|
||||
|
||||
<button
|
||||
class="flex flex-row items-center px-2 py-1 text-sm font-semibold whitespace-nowrap text-base hover:text-base/80"
|
||||
x-on:click="open = !open">
|
||||
class="flex flex-row items-center px-2 py-1 text-sm font-semibold whitespace-nowrap text-base hover:text-primary transition-colors duration-200"
|
||||
x-on:click="open = !open"
|
||||
:aria-expanded="open">
|
||||
{{ $trigger }}
|
||||
<x-ri-arrow-down-s-line x-bind:class="{ '-rotate-180' : open }"
|
||||
class="md:block hidden size-4 text-base ease-out duration-300" />
|
||||
</button>
|
||||
|
||||
<div x-ref="dropdown"
|
||||
class="absolute mt-2 w-48 px-2 py-1 bg-background-secondary rounded-md shadow-lg z-10 border border-neutral"
|
||||
x-bind:style="{
|
||||
left: `-${adjustWidth}px`,
|
||||
}"
|
||||
class="absolute mt-2 w-48 bg-background-secondary rounded-xl shadow-xl z-10 border border-neutral/50 overflow-hidden"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 scale-90"
|
||||
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-90"
|
||||
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 scale-95 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0" x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
x-on:click.outside="open = false" x-cloak>
|
||||
{{ $content }}
|
||||
<div class="py-1 [&>a]:block [&>a]:px-4 [&>a]:py-2.5 [&>a]:text-sm [&>a]:text-color-base [&>a]:transition-colors [&>a]:duration-150 [&>a:hover]:bg-primary/10 [&>a:hover]:text-primary [&>a:focus]:bg-primary/10 [&>a:focus]:text-primary [&>a:focus]:outline-none">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,11 +5,19 @@
|
||||
'divClass' => '',
|
||||
])
|
||||
<div class="flex items-center {{ $divClass ?? '' }}">
|
||||
{{-- <input type="hidden" name="{{ $name }}" value="0" /> --}}
|
||||
<input type="checkbox" name="{{ $name }}" id="{{ $id ?? $name }}"
|
||||
{{ $attributes->except(['label', 'name', 'id', 'class', 'divClass', 'required']) }}
|
||||
class="form-checkbox size-4 text-primary rounded focus:ring-secondary hover:bg-secondary ring-offset-primary-800 focus:ring-2 bg-background-secondary border-neutral" />
|
||||
<label class="ml-2 text-sm text-primary-100" for="{{ $id ?? $name }}">
|
||||
<div class="relative">
|
||||
<input type="checkbox" name="{{ $name }}" id="{{ $id ?? $name }}"
|
||||
{{ $attributes->except(['label', 'name', 'id', 'class', 'divClass', 'required']) }}
|
||||
class="peer form-checkbox size-5 text-primary rounded border-2 border-neutral bg-background-secondary/50
|
||||
focus:ring-2 focus:ring-primary/30 focus:ring-offset-0 focus:outline-none
|
||||
checked:bg-primary checked:border-primary
|
||||
hover:border-primary/50 transition-all duration-200 cursor-pointer
|
||||
appearance-none" />
|
||||
<svg class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-3 text-white pointer-events-none opacity-0 scale-50 peer-checked:opacity-100 peer-checked:scale-100 transition-all duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<label class="ml-2 text-sm text-color-base cursor-pointer select-none" for="{{ $id ?? $name }}">
|
||||
@if(isset($label))
|
||||
{{ $label }}
|
||||
@else
|
||||
@@ -18,6 +26,6 @@
|
||||
</label>
|
||||
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs">{{ $message }}</p>
|
||||
<p class="text-red-500 text-xs ml-2">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@@ -123,61 +123,77 @@
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
<script>
|
||||
<script data-navigate-track>
|
||||
// Enhanced password strength validation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const passwordInputs = document.querySelectorAll('[data-hs-strong-password]');
|
||||
|
||||
passwordInputs.forEach(function(input) {
|
||||
const config = JSON.parse(input.getAttribute('data-hs-strong-password'));
|
||||
const strengthBar = document.querySelector(config.target);
|
||||
const hints = document.querySelector(config.hints);
|
||||
const warningElement = document.querySelector(config.target.replace('-strength', '-warning'));
|
||||
const minStrengthLevel = config.minStrengthLevel || 3;
|
||||
(function() {
|
||||
// Avoid duplicate initialization
|
||||
const initPasswordStrength = function() {
|
||||
const passwordInputs = document.querySelectorAll('[data-hs-strong-password]:not([data-password-strength-initialized])');
|
||||
|
||||
// Initial setup - create empty bars
|
||||
if (strengthBar) {
|
||||
strengthBar.innerHTML = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'h-2 flex-auto rounded-full mx-1 bg-neutral-300 transition-all duration-300';
|
||||
strengthBar.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
// Input event listener
|
||||
input.addEventListener('input', function() {
|
||||
const strengthLevel = checkPasswordStrength(input.value, strengthBar, hints, warningElement, minStrengthLevel);
|
||||
passwordInputs.forEach(function(input) {
|
||||
// Mark as initialized to prevent duplicate event listeners
|
||||
input.setAttribute('data-password-strength-initialized', 'true');
|
||||
|
||||
// Set custom validity based on strength level
|
||||
if (input.value && strengthLevel < minStrengthLevel) {
|
||||
input.setCustomValidity('Password terlalu lemah! Minimal harus mencapai level orange.');
|
||||
} else {
|
||||
input.setCustomValidity('');
|
||||
const config = JSON.parse(input.getAttribute('data-hs-strong-password'));
|
||||
const strengthBar = document.querySelector(config.target);
|
||||
const hints = document.querySelector(config.hints);
|
||||
const warningElement = document.querySelector(config.target.replace('-strength', '-warning'));
|
||||
const minStrengthLevel = config.minStrengthLevel || 3;
|
||||
|
||||
// Initial setup - create empty bars
|
||||
if (strengthBar) {
|
||||
strengthBar.innerHTML = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'h-2 flex-auto rounded-full mx-1 bg-neutral-300 transition-all duration-300';
|
||||
strengthBar.appendChild(bar);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent form submission if password is too weak
|
||||
const form = input.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const strengthLevel = getCurrentPasswordStrength(input.value);
|
||||
|
||||
// Input event listener
|
||||
input.addEventListener('input', function() {
|
||||
const strengthLevel = checkPasswordStrength(input.value, strengthBar, hints, warningElement, minStrengthLevel);
|
||||
|
||||
// Set custom validity based on strength level
|
||||
if (input.value && strengthLevel < minStrengthLevel) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
|
||||
// Show warning
|
||||
if (warningElement) {
|
||||
warningElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show validation message
|
||||
input.reportValidity();
|
||||
input.setCustomValidity('Password terlalu lemah! Minimal harus mencapai level orange.');
|
||||
} else {
|
||||
input.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent form submission if password is too weak
|
||||
const form = input.closest('form');
|
||||
if (form && !form.hasAttribute('data-password-form-initialized')) {
|
||||
form.setAttribute('data-password-form-initialized', 'true');
|
||||
form.addEventListener('submit', function(e) {
|
||||
const strengthLevel = getCurrentPasswordStrength(input.value);
|
||||
if (input.value && strengthLevel < minStrengthLevel) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
|
||||
// Show warning
|
||||
if (warningElement) {
|
||||
warningElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show validation message
|
||||
input.reportValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run on DOMContentLoaded and Livewire navigation
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPasswordStrength);
|
||||
} else {
|
||||
initPasswordStrength();
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:navigated', initPasswordStrength);
|
||||
})();
|
||||
|
||||
function getCurrentPasswordStrength(password) {
|
||||
const checks = {
|
||||
|
||||
@@ -1,45 +1,51 @@
|
||||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
'options' => [],
|
||||
'selected' => null,
|
||||
'multiple' => false,
|
||||
'required' => false,
|
||||
'divClass' => null,
|
||||
'hideRequiredIndicator' => false,
|
||||
'name',
|
||||
'label' => null,
|
||||
'options' => [],
|
||||
'selected' => null,
|
||||
'multiple' => false,
|
||||
'required' => false,
|
||||
'divClass' => null,
|
||||
'hideRequiredIndicator' => false,
|
||||
])
|
||||
<fieldset class="flex flex-col relative w-full {{ $divClass ?? '' }}" name="{{ $name }}">
|
||||
@if ($label)
|
||||
<legend class="mb-1">
|
||||
<label for="{{ $name }}"
|
||||
class="text-sm font-bold text-base">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
<legend class="mb-2">
|
||||
<label for="{{ $name }}" class="text-sm font-bold text-color-base">
|
||||
{{ $label }}
|
||||
@if ($required && !$hideRequiredIndicator)
|
||||
<span class="text-red-500">*</span>
|
||||
@endif
|
||||
</label>
|
||||
</legend>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="block px-2.5 py-2.5 w-full text-sm text-primary-100 border-neutral rounded-md outline-none focus:outline-none focus:border-secondary transition-all duration-300 ease-in-out">
|
||||
<div class="block w-full text-sm text-color-base space-y-2">
|
||||
@if (count($options) == 0 && $slot)
|
||||
{{ $slot }}
|
||||
{{ $slot }}
|
||||
@else
|
||||
@foreach ($options as $key => $option)
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="radio" id="{{ $name }}_{{ $key }}" name="{{ $name }}"
|
||||
value="{{ gettype($options) == 'array' ? $option : $key }}" {{ ($multiple && $selected ? in_array($key,
|
||||
$selected) : $selected==$option) ? 'checked' : '' }} />
|
||||
<label for="{{ $name }}_{{ $key }}">
|
||||
{{ $option }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
@foreach ($options as $key => $option)
|
||||
<div class="flex items-center gap-3 group">
|
||||
<div class="relative">
|
||||
<input type="radio" id="{{ $name }}_{{ $key }}" name="{{ $name }}"
|
||||
value="{{ gettype($options) == 'array' ? $option : $key }}"
|
||||
{{ ($multiple && $selected ? in_array($key, $selected) : $selected==$option) ? 'checked' : '' }}
|
||||
class="peer form-radio size-5 text-primary border-2 border-neutral bg-background-secondary/50
|
||||
focus:ring-2 focus:ring-primary/30 focus:ring-offset-0 focus:outline-none
|
||||
checked:bg-primary checked:border-primary
|
||||
hover:border-primary/50 transition-all duration-200 cursor-pointer
|
||||
appearance-none rounded-full" />
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-2 bg-white rounded-full pointer-events-none opacity-0 scale-0 peer-checked:opacity-100 peer-checked:scale-100 transition-all duration-200"></div>
|
||||
</div>
|
||||
<label for="{{ $name }}_{{ $key }}" class="text-color-base cursor-pointer select-none group-hover:text-primary transition-colors duration-200">
|
||||
{{ $option }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@error($name)
|
||||
<p class="text-red-500 text-xs">{{ $message }}</p>
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@@ -7,18 +7,36 @@
|
||||
])
|
||||
<div x-data="{ open: {{ $open ? 'true' : 'false' }} }">
|
||||
<template x-teleport="body">
|
||||
<div class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black/50"
|
||||
x-show="open">
|
||||
<!-- Modal inner -->
|
||||
<div class="px-6 py-4 w-full mx-2 md:mx-auto text-left bg-background-secondary rounded shadow-lg {{ $width }}" x-cloak
|
||||
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-90"
|
||||
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-90">
|
||||
{{-- Backdrop with separate fade animation --}}
|
||||
<div class="fixed inset-0 z-30 flex items-center justify-center overflow-auto"
|
||||
x-show="open" x-cloak>
|
||||
|
||||
{{-- Backdrop overlay --}}
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="{{ $closable ? 'open = false' : '' }}"></div>
|
||||
|
||||
{{-- Modal content with scale animation --}}
|
||||
<div class="relative px-6 py-4 w-full mx-2 md:mx-auto text-left bg-background-secondary rounded-xl shadow-2xl border border-neutral/50 {{ $width }}"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300 delay-75"
|
||||
x-transition:enter-start="opacity-0 scale-95 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 scale-95 translate-y-4"
|
||||
@click.stop>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold text-primary-100">{{ $title }}</h2>
|
||||
<h2 class="text-2xl font-semibold text-color-base">{{ $title }}</h2>
|
||||
@if ($closable && !$closeTrigger)
|
||||
<button @click="open = false" class="text-primary-100">
|
||||
<x-ri-close-fill class="size-6" />
|
||||
<button @click="open = false" class="text-color-muted hover:text-color-base hover:bg-neutral/20 p-2 rounded-lg transition-all duration-200">
|
||||
<x-ri-close-fill class="size-5" />
|
||||
</button>
|
||||
@elseif ($closable && $closeTrigger)
|
||||
{{ $closeTrigger }}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('img:not([loading])')
|
||||
.forEach(img => img.loading = 'lazy');
|
||||
});
|
||||
<script data-navigate-track>
|
||||
// Apply lazy loading to images - runs on initial load and Livewire navigation
|
||||
(function() {
|
||||
const applyLazyLoading = () => {
|
||||
document.querySelectorAll('img:not([loading])')
|
||||
.forEach(img => img.loading = 'lazy');
|
||||
};
|
||||
|
||||
// Run immediately if DOM is ready, otherwise wait
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyLazyLoading);
|
||||
} else {
|
||||
applyLazyLoading();
|
||||
}
|
||||
|
||||
// Also run on Livewire navigation
|
||||
document.addEventListener('livewire:navigated', applyLazyLoading);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -28,10 +41,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="w-full px-4 lg:px-8 bg-background-secondary border-b border-neutral flex flex-col lg:flex-row justify-between fixed top-0 z-20 h-auto">
|
||||
<div x-data="{ slideOverOpen: false }"
|
||||
x-init="$watch('slideOverOpen', value => { document.documentElement.style.overflow = value ? 'hidden' : '' })"
|
||||
class="relative z-50 w-full h-auto">
|
||||
<nav x-data="{ scrolled: false, slideOverOpen: false }"
|
||||
x-init="
|
||||
window.addEventListener('scroll', () => { scrolled = window.scrollY > 20 });
|
||||
$watch('slideOverOpen', value => { document.documentElement.style.overflow = value ? 'hidden' : '' });
|
||||
"
|
||||
:class="{
|
||||
'shadow-lg shadow-black/10 border-neutral/80': scrolled,
|
||||
'border-neutral': !scrolled
|
||||
}"
|
||||
class="w-full px-4 lg:px-8 bg-background-secondary/95 backdrop-blur-md border-b flex flex-col lg:flex-row justify-between fixed top-0 z-20 h-auto transition-all duration-300">
|
||||
<div class="relative z-50 w-full h-auto">
|
||||
|
||||
<div class="flex flex-row items-center justify-between w-full h-16">
|
||||
|
||||
@@ -166,9 +186,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
<!-- Mobile slide-over menu (<1024px) -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="slideOverOpen" @keydown.window.escape="slideOverOpen=false" x-cloak class="fixed left-0 right-0 top-20 w-full z-[99]" style="height:calc(100dvh - 5rem);" aria-modal="true">
|
||||
<div x-show="slideOverOpen" x-transition ... @click.away="slideOverOpen = false" class="absolute inset-0 bg-background-secondary/80 backdrop-blur-md border-t border-neutral/50 shadow-2xl overflow-y-auto flex flex-col rounded-t-2xl">
|
||||
<div x-show="slideOverOpen" @keydown.window.escape="slideOverOpen=false" x-cloak class="fixed inset-0 z-[99]" aria-modal="true">
|
||||
{{-- Backdrop with blur and fade --}}
|
||||
<div x-show="slideOverOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="slideOverOpen = false"
|
||||
class="absolute inset-0 bg-black/40 backdrop-blur-sm"></div>
|
||||
|
||||
{{-- Slide-over panel --}}
|
||||
<div x-show="slideOverOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-full"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-full"
|
||||
@click.stop
|
||||
class="absolute left-0 right-0 top-16 bg-background-secondary/95 backdrop-blur-xl border-t border-neutral/50 shadow-2xl overflow-y-auto flex flex-col rounded-t-3xl"
|
||||
style="height:calc(100dvh - 4rem);">
|
||||
<div class="flex flex-col h-full p-6">
|
||||
{{-- Swipe handle indicator --}}
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-12 h-1.5 bg-neutral/50 rounded-full"></div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<x-navigation.sidebar-links />
|
||||
</div>
|
||||
|
||||
82
themes/lucentui/views/components/progress-bar.blade.php
Normal file
82
themes/lucentui/views/components/progress-bar.blade.php
Normal file
@@ -0,0 +1,82 @@
|
||||
@props([
|
||||
'value' => 0,
|
||||
'max' => 100,
|
||||
'color' => 'primary',
|
||||
'size' => 'md',
|
||||
'showLabel' => false,
|
||||
'animated' => true,
|
||||
'gradient' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$percentage = $max > 0 ? min(100, ($value / $max) * 100) : 0;
|
||||
|
||||
$sizeClasses = match($size) {
|
||||
'sm' => 'h-1.5',
|
||||
'lg' => 'h-4',
|
||||
default => 'h-2.5',
|
||||
};
|
||||
|
||||
$colorClasses = match($color) {
|
||||
'success' => 'bg-success',
|
||||
'warning' => 'bg-warning',
|
||||
'error' => 'bg-error',
|
||||
'info' => 'bg-info',
|
||||
default => 'bg-primary',
|
||||
};
|
||||
|
||||
$gradientClasses = match($color) {
|
||||
'success' => 'bg-gradient-to-r from-green-400 to-emerald-500',
|
||||
'warning' => 'bg-gradient-to-r from-yellow-400 to-orange-500',
|
||||
'error' => 'bg-gradient-to-r from-red-400 to-rose-500',
|
||||
'info' => 'bg-gradient-to-r from-blue-400 to-cyan-500',
|
||||
default => 'bg-gradient-to-r from-primary to-secondary',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'w-full']) }}>
|
||||
@if($showLabel)
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-sm font-medium text-color-base">{{ $slot }}</span>
|
||||
<span class="text-sm font-medium text-color-muted">{{ number_format($percentage, 0) }}%</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="w-full bg-neutral/30 rounded-full {{ $sizeClasses }} overflow-hidden">
|
||||
<div
|
||||
class="{{ $gradient ? $gradientClasses : $colorClasses }} {{ $sizeClasses }} rounded-full transition-all duration-500 ease-out {{ $animated ? 'animate-progress-fill' : '' }}"
|
||||
style="width: {{ $percentage }}%"
|
||||
role="progressbar"
|
||||
aria-valuenow="{{ $value }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{ $max }}"
|
||||
>
|
||||
@if($animated && $percentage > 0)
|
||||
<div class="h-full w-full bg-white/20 animate-progress-shine"></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('styles')
|
||||
<style>
|
||||
@keyframes progress-fill {
|
||||
from { width: 0%; }
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-progress-fill {
|
||||
animation: progress-fill 1s ease-out;
|
||||
}
|
||||
|
||||
.animate-progress-shine {
|
||||
animation: progress-shine 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
@endonce
|
||||
@@ -0,0 +1,25 @@
|
||||
{{-- Skeleton loader for invoice card --}}
|
||||
<div class="bg-gradient-to-br from-background-secondary/50 to-background-secondary/30 border border-neutral/50 p-6 rounded-xl shadow-lg
|
||||
flex flex-col md:flex-row items-start md:items-center justify-between gap-4 animate-pulse">
|
||||
|
||||
<div class="flex items-start md:items-center gap-4 flex-grow">
|
||||
{{-- Icon skeleton --}}
|
||||
<div class="p-3 rounded-full flex-shrink-0 bg-blue-500/20 w-12 h-12"></div>
|
||||
|
||||
<div class="flex flex-col flex-grow">
|
||||
{{-- Title skeleton --}}
|
||||
<div class="h-6 bg-neutral/20 rounded-lg w-40 mb-2"></div>
|
||||
{{-- Amount and date skeleton --}}
|
||||
<div class="h-4 bg-neutral/20 rounded w-56 mb-1"></div>
|
||||
{{-- Due date skeleton --}}
|
||||
<div class="h-3 bg-neutral/20 rounded w-36"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-shrink-0 mt-3 md:mt-0">
|
||||
{{-- Status badge skeleton --}}
|
||||
<div class="h-7 bg-neutral/20 rounded-full w-20"></div>
|
||||
{{-- Arrow skeleton --}}
|
||||
<div class="h-6 w-6 bg-neutral/20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
{{-- Skeleton loader for service card --}}
|
||||
<div class="bg-gradient-to-br from-background-secondary/50 to-background-secondary/30 border border-neutral/50 p-6 rounded-xl shadow-lg
|
||||
flex flex-col md:flex-row items-start md:items-center justify-between gap-4 animate-pulse">
|
||||
|
||||
<div class="flex items-start md:items-center gap-4 flex-grow">
|
||||
{{-- Icon skeleton --}}
|
||||
<div class="p-3 rounded-full flex-shrink-0 bg-neutral/20 w-12 h-12"></div>
|
||||
|
||||
<div class="flex flex-col flex-grow">
|
||||
{{-- Title skeleton --}}
|
||||
<div class="h-6 bg-neutral/20 rounded-lg w-48 mb-2"></div>
|
||||
{{-- Description skeleton --}}
|
||||
<div class="h-4 bg-neutral/20 rounded w-64 mb-1"></div>
|
||||
{{-- Date skeleton --}}
|
||||
<div class="h-3 bg-neutral/20 rounded w-32"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-shrink-0 mt-3 md:mt-0">
|
||||
{{-- Status badge skeleton --}}
|
||||
<div class="h-7 bg-neutral/20 rounded-full w-20"></div>
|
||||
{{-- Arrow skeleton --}}
|
||||
<div class="h-6 w-6 bg-neutral/20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +31,14 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:gap-5">
|
||||
{{-- Skeleton loaders shown during loading --}}
|
||||
<div wire:loading.delay class="space-y-3 md:space-y-5">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<x-skeleton.invoice-card />
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove>
|
||||
@forelse ($invoices as $invoice)
|
||||
<a href="{{ route('invoices.show', $invoice) }}" wire:navigate class="block">
|
||||
<div class="bg-gradient-to-br from-background-secondary/50 to-background-secondary/30 hover:bg-secondary/25 border border-neutral/50 p-6 rounded-xl shadow-lg
|
||||
@@ -90,6 +98,7 @@
|
||||
</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $invoices->links() }}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:gap-5 ">
|
||||
{{-- Skeleton loaders shown during loading --}}
|
||||
<div wire:loading.delay class="space-y-3 md:space-y-5">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<x-skeleton.service-card />
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove>
|
||||
@forelse ($services as $service)
|
||||
<a href="{{ route('services.show', $service) }}" wire:navigate class="block">
|
||||
<div class="bg-gradient-to-br from-background-secondary/50 to-background-secondary/30 hover:bg-secondary/25 border border-neutral/50 p-6 rounded-xl shadow-lg
|
||||
@@ -131,6 +139,7 @@
|
||||
</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $services->links() }}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user