qisti / smart-ui-qisti
Local Smart UI components for the Qisti app.
Package info
github.com/QistiAmal1212/smart-ui-qisti
Language:Blade
pkg:composer/qisti/smart-ui-qisti
README
- Include the package in composer
composer require qisti/smart-ui-qisti
- Run package installation
composer install
- Publish the CSS/Tailwind
php artisan vendor:publish --tag=smartuiqisti-assets
- Importing smart ui css into your main css
@import './smartuiqisti/app.css';
- Publish blade view
php artisan vendor:publish --tag=smartuiqisti-views
- Publish smartui configuration
php artisan vendor:publish --tag=smartuiqisti-config
- Use of the component
<x-smartuiqisti::forms.upload-form
label="Supporting Document"
name="documents"
max-files="5"
max-size="5"
accept=".csv,.xls,.xlsx,.zip,.pdf,.png,.jpg,.jpeg"
required
preview-outside
bulk-delete
/>
š¤ Submitting & Validating
The component manages the selected files entirely in the browser, then hands them to your backend one of two ways. Its client-side checks are UX only ā a user can bypass them, so always validate on the server.
Plain Laravel form
Give the field a name. The input is always multiple, so the field posts an array
and Laravel receives name[]:
<form action="{{ route('documents.store') }}" method="POST" enctype="multipart/form-data"> @csrf <x-smartuiqisti::forms.upload-form label="Supporting Document" name="documents" {{-- posts as documents[] --}} max-files="5" max-size="5" required /> @error('documents') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror @error('documents.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror <button type="submit">Submit</button> </form>
public function store(Request $request) { $data = $request->validate([ 'documents' => ['required', 'array', 'max:5'], 'documents.*' => ['file', 'max:5120', 'mimes:pdf,png,jpg,jpeg,csv,xls,xlsx,zip'], ]); foreach ($data['documents'] as $file) { $file->store('documents', 'public'); } }
name defaults to files, so the field posts as files[] if you don't set one. Give
each uploader its own name when a page has more than one.
enctype="multipart/form-data"is required ā without it the browser posts filenames only, and$request->file()comes back empty.
Livewire
Use wire:model; name is ignored because Livewire never does a native form post.
<x-smartuiqisti::forms.upload-form label="Supporting Document" wire:model="documents" max-files="5" max-size="5" required /> @error('documents.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
use Livewire\WithFileUploads; class UploadDocuments extends Component { use WithFileUploads; public $documents = []; public function save() { $this->validate([ 'documents' => ['required', 'array', 'max:5'], 'documents.*' => ['file', 'max:5120', 'mimes:pdf,png,jpg,jpeg'], ]); foreach ($this->documents as $file) { $file->store('documents', 'public'); } $this->reset('documents'); } }
Livewire uploads each file to temporary storage as soon as it is picked, so
$this->documents holds TemporaryUploadedFile instances. Removing, reordering or
auto-compressing a file re-announces the input, so the server side stays in step with
what the user sees.
Clearing the dropzone after a successful save
Just $this->reset('documents'). Nothing else ā no event to dispatch, no JS to call.
The file list you see belongs to the browser: the component holds the real File objects
in memory, so emptying a server property cannot reach them by itself. Rather than making
your component announce it, the dropzone watches the property it is bound to and clears
itself when that property goes from holding files to empty.
Only the transition counts. While Livewire is still uploading a fresh pick the property is legitimately empty for a moment, and treating that as a reset would wipe the selection the user just made.
That means a failed validation leaves everything in place ā the property was never emptied, so there is nothing to clear.
If you ever need to clear it by hand, or you are not on Livewire at all:
suqClearFiles('documents'); // one component; returns how many were cleared suqClearFiles(); // all of them window.dispatchEvent(new CustomEvent('suq:clear', { detail: { id: 'documents' } }));
The id matches the component's data-suq-id, taken from the first of these that exists:
an explicit data-suq-id, then id, then the wire:model value.
Clearing never re-announces the input, so it costs no extra round trip.
Existing files (see below) are not cleared by this ā they belong to the record, not to
the upload. Pass { all: true } if you really want them gone too:
suqClearFiles('documents', { all: true });
āļø Edit forms
Pass the record's saved files to existing-files and they render inside the dropzone
alongside anything newly picked ā one list, one remove button per row, drag-sortable
across both.
Removing a saved file only marks it. Nothing is deleted until the form is submitted, so abandoning the edit changes nothing on disk.
| Attribute | What it does |
|---|---|
existing-files |
The record's saved files. Array of rows, a Collection, or Eloquent models |
existing-name |
Hidden field carrying the surviving ids. Defaults to <name>_existing[] |
order-name |
Hidden field carrying the row order. Defaults to <name>_order[] |
existing-model |
Livewire property receiving the surviving ids. Required on Livewire edit forms |
order-model |
Livewire property receiving the row order |
Each row is read with common column aliases, so most apps can hand over what they already
have: id/uuid/key, name/filename/file_name/original_name, url/path/file_path,
size/file_size, mime/mime_type/type. A relative url goes through asset().
The table it expects
Store the path, the original filename, and the position:
Schema::create('letter_documents', function (Blueprint $table) { $table->id(); $table->foreignId('letter_id')->constrained()->cascadeOnDelete(); $table->string('path'); // where the file lives on the disk $table->string('filename'); // the name to show in the UI $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); });
Hand them back in order, mapping path to a URL:
$existing = $letter->documents()->orderBy('sort_order')->get()->map(fn ($doc) => [ 'id' => $doc->id, 'name' => $doc->filename, 'url' => Storage::disk('public')->url($doc->path), 'size' => Storage::disk('public')->size($doc->path), ]);
Plain Laravel form
<form action="{{ route('letters.update', $letter) }}" method="POST" enctype="multipart/form-data"> @csrf @method('PUT') <x-smartuiqisti::forms.upload-form label="Supporting Document" name="documents" :existing-files="$existing" max-files="5" max-size="5" accept=".pdf,.png,.jpg" /> <button type="submit">Save</button> </form>
Two hidden lists come back with the post. documents_existing[] holds the ids that
survived; documents_order[] describes the final list, one token per row, in order:
| Token | Means |
|---|---|
existing:7 |
the saved file with id 7 |
new:0 |
$request->file('documents')[0] |
new:<n> indexes into the uploaded array, so replaying documents_order[] fills path,
filename and sort_order in a single pass:
$data = $request->validate([ // The user may keep a saved file and upload nothing new ā so "required" has to // mean "at least one of the two", not "at least one upload". 'documents' => ['required_without:documents_existing', 'array'], 'documents.*' => ['file', 'max:5120', 'mimes:pdf,png,jpg'], 'documents_existing' => ['array'], 'documents_existing.*' => ['string'], 'documents_order' => ['array'], 'documents_order.*' => ['string'], ]); $kept = $data['documents_existing'] ?? []; // Scoped to this letter, so a forged id cannot reach another record's rows. $letter->documents()->whereNotIn('id', $kept)->get()->each(function ($doc) { Storage::disk('public')->delete($doc->path); $doc->delete(); }); $uploads = $request->file('documents', []); foreach ($data['documents_order'] ?? [] as $position => $token) { [$kind, $ref] = array_pad(explode(':', $token, 2), 2, null); if ($kind === 'existing') { $letter->documents()->whereKey($ref)->update(['sort_order' => $position]); } elseif ($kind === 'new' && isset($uploads[$ref])) { $letter->documents()->create([ 'path' => $uploads[$ref]->store('documents', 'public'), 'filename' => $uploads[$ref]->getClientOriginalName(), 'sort_order' => $position, ]); } }
The initial hidden lists are rendered server-side, so a post from a browser that never ran the JS keeps every file in its original order rather than deleting them all.
Livewire
<x-smartuiqisti::forms.upload-form label="Supporting Document" wire:model="documents" :existing-files="$existing" existing-model="keptDocuments" order-model="documentOrder" data-suq-id="documents-{{ $letter->id }}" max-files="5" />
public array $documents = []; public array $keptDocuments = []; public array $documentOrder = []; public function mount(Letter $letter) { // Seed both: if the user removes nothing, the browser never writes to these // properties, and empty ones would read as "delete everything". $this->keptDocuments = $letter->documents->pluck('id')->map(strval(...))->all(); $this->documentOrder = array_map(fn ($id) => "existing:{$id}", $this->keptDocuments); }
The component writes both properties itself, deferred, so removals cost no round trip ā
they ride along with your next save(). save() then replays $this->documentOrder
exactly as the controller above does.
existing-model is required here. Livewire never posts the hidden inputs (the
container is wire:ignore), so without it removals never reach the server.
$this->reset('documents') after a save clears the new picks and leaves the saved rows
standing, which is what you want when the page is not redirecting.
Two things to know
Seeding happens once per data-suq-id. After a Livewire save the component will not
notice a changed existing-files ā it is wire:ignore, and re-reading the attribute would
resurrect rows the user just removed. Redirect, or vary data-suq-id, to re-seed. That is
also why the example pins data-suq-id to the record id: with wire:navigate the browser
keeps its state, and two records sharing a generic id would inherit each other's list.
max-files counts both. Saved and new files share one limit. Server-side, subtract the
kept count, since documents only carries uploads:
'documents' => ['array', 'max:'.max(0, 5 - count($request->input('documents_existing', [])))],
No max-files means no limit. Leave the attribute off, and the config key commented
out as it ships, and the field takes any number of files ā a cap exists only where you ask
for one, with max-files="5" or by uncommenting 'max_files' => 5. max-files="0" and an
empty value read the same way. Validate the count on the server either way; the browser
check is UX only.
Watch the units
The component's numbers do not match Laravel's rule units:
| Component | Laravel rule | Note |
|---|---|---|
max-size="5" (MB) |
max:5120 (KB) |
multiply by 1024 |
max-files="5" |
array, max:5 |
applies to the array, not each file |
accept=".pdf,.png" |
mimes:pdf,png |
no leading dots |
accept only filters the file picker's dialog; it is not enforcement. mimes: checks
the file's real content, so keep the two lists in agreement.
PHP's limits win before Laravel's
max-size must fit inside PHP's own upload limits, or the request dies before any
validation rule runs:
; php.ini ā both must be >= your max-size upload_max_filesize = 10M post_max_size = 12M ; >= upload_max_filesize, and covers all files in one post
With max-size="5" on a default upload_max_filesize = 2M, a 3 MB file passes the
component's browser check, then the server answers 413 (or hands Laravel an empty
$request->file()) and your @error messages never appear. post_max_size has to cover
the combined size of every file in the request, so size it against
max-files Ć max-size.
required is not a browser check
The required attribute renders the red asterisk only ā it is never placed on the input,
because a hidden required input makes browsers refuse to submit with "An invalid form
control is not focusable". Enforce it with the required validation rule instead.
šØ Theme & Color
One accent color drives the upload icon, the "Browse files" link, and the tooltip's accent bar and info icon.
| Attribute | Description |
|---|---|
main-color |
Accent color for this instance |
It follows your Tailwind theme by default. Define a primary color in your @theme
block and the component picks it up with no attribute at all:
@theme { --color-primary: #7c3aed; }
Override it per instance when you need to:
<x-smartuiqisti::forms.upload-form main-color="#7c3aed" />
Resolution order: main-color ā --color-primary from your Tailwind theme ā the
package default (#2563eb).
šÆ Typography & Styling
One value sizes the whole field. Every part of it ā the label, the dropzone lines, the
file list, the tooltip, the badges ā is measured in em from a single size on the
container, so size grows and shrinks the field as a whole, not just its text.
| Attribute | Description |
|---|---|
size |
How big the field is. A name, a CSS length, or inherit. Default sm |
<x-smartuiqisti::forms.upload-form size="xl" />
The names, and what they mean:
| Name | Name | ||
|---|---|---|---|
xs |
12px | 3xl |
30px |
sm |
14px (default) | 4xl |
36px |
base |
16px | 5xl |
48px |
lg |
18px | 6xl |
60px |
xl |
20px | 7xl |
72px |
2xl |
24px | 8xl |
96px |
9xl |
128px |
These are the same names Tailwind uses, but the package turns them into CSS itself ā so
they work in any app, with or without Tailwind, and never depend on a build step. A raw CSS
length (13px, 0.9rem, 0.9em) works too, for a size that is not on the scale.
Set it once for every field in the config instead:
'smart-upload' => [ 'size' => 'sm', ],
size="inherit" sets nothing at all, leaving the field at whatever size the page around
it uses. That is the right setting when your app defines a base text size on body or its
form wrapper. It is not the default, because many apps size each field on the tag
(text-xs labels, text-sm controls) and set no base at all ā there the field would inherit
the browser's 16px and tower over the inputs beside it.
Tailwind users only: a full utility such as size="text-[13px]" or
size="text-sm md:text-base" is passed through to the container's class, for variants the
scale above cannot express. Tailwind does not scan config/ for class names, so add the
file to your sources (@source "../../config/smartuiqisti.php"; in v4, content in v3) if
you put one there. A name never needs this.
Only weight, muting and spacing are applied beyond this, to keep the hierarchy readable.
To restyle anything further, target the package's classes in your own CSS ā they are stable and safe to override:
| Class | Element |
|---|---|
.smartuiqisti-upload-label |
Label above the field |
.smartuiqisti-upload-drop |
Drop area (background, border, padding) |
.smartuiqisti-upload-icon-circle |
Circled upload icon |
.smartuiqisti-upload-inner-title |
"Drag and drop to upload file" |
.smartuiqisti-upload-inner-sub-title |
Max-size line |
.smartuiqisti-upload-inner-accepted-file-type-text |
Accepted-types line |
.smartuiqisti-upload-inner-browse-file-button |
"Browse files" link |
There are no class attributes ā styling lives in your CSS, not in the markup.
Never remove the
suq-*classes (suq-drop,suq-input,suq-preview,suq-error-msg, ā¦). The JavaScript uses them to find its parts.
š¼ļø Upload Icon
| Attribute | Description |
|---|---|
hidden-icon |
Render the dropzone without the icon |
replace-icon |
Swap the default glyph for your own markup |
replace-icon takes four kinds of value and works out which is which:
| Value | Renders as |
|---|---|
'<svg ā¦>ā¦</svg>' |
the markup, inline |
"img/upload.svg" |
<img> through asset() |
"/storage/x.png", "https://ā¦", "data:ā¦" |
<img> with the URL as given |
"icons.upload" |
the Blade view (also tries components.icons.upload) |
"š" |
plain text |
{{-- No icon at all --}} <x-smartuiqisti::forms.upload-form hidden-icon /> {{-- Inline SVG --}} <x-smartuiqisti::forms.upload-form replace-icon='<svg width="26" height="26" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>' /> {{-- An image in public/ --}} <x-smartuiqisti::forms.upload-form replace-icon="img/upload.svg" /> {{-- Any URL, including asset()/Storage::url() output --}} <x-smartuiqisti::forms.upload-form replace-icon="{{ asset('img/upload.png') }}" /> {{-- A Blade view or icon component --}} <x-smartuiqisti::forms.upload-form replace-icon="icons.upload" /> {{-- Or just text --}} <x-smartuiqisti::forms.upload-form replace-icon="š" />
A relative image path is resolved with asset(); absolute URLs, root-relative paths and
data: URIs pass through untouched. Images are fitted to the circle at 26Ć26 with
object-fit: contain.
A view is rendered in isolation with an empty attribute bag, so an icon component using
{{ $attributes }} still works and this component's own attributes never leak onto your
icon.
A replaced icon is rendered without the circle ā the circle exists only to frame the
built-in glyph. Images are sized at 44Ć44 with object-fit: contain, and emoji or text
icons render at 2.25em, so they read at a sensible size on their own. An SVG using
currentColor still inherits the accent color.
Markup values are rendered unescaped so SVG works. Pass author-written markup only ā never user input.
š Requirement Text Display
| Attribute | Description |
|---|---|
tooltip-requirement |
Move the max-size and accepted-types text into a tooltip beside the label, keeping the dropzone clean |
<x-smartuiqisti::forms.upload-form label="Supporting Document" max-files="5" max-size="5" tooltip-requirement />
By default both lines render inside the dropzone. With tooltip-requirement an info icon
appears next to the label instead, showing a compact panel on hover or keyboard focus.
The file-count limit is intentionally never shown up front ā file forms don't announce their own arity. It appears as an error only if the user exceeds it.
š¤ AI & File Optimization
| Attribute | Description |
|---|---|
ai-enable |
Enable AI-powered extraction & scanning |
auto-compress |
Automatically compress large files before upload |
<x-smartuiqisti::forms.upload-form ai-enable auto-compress />
āļø App-wide Defaults
Set the values you use everywhere once in config/smartuiqisti.php, instead of repeating
attributes on every field:
php artisan vendor:publish --tag=smartuiqisti-config
Each component has its own section, so components added later never share a key:
return [ 'smart-upload' => [ 'accept' => '.pdf,.png,.jpg,.jpeg', 'max_files' => 5, // leave it out for unlimited 'max_size' => 10, // MB 'main_color' => '', // empty inherits --color-primary 'size' => 'sm', // xsā¦9xl, a CSS length, or 'inherit' 'icon' => 'img/upload.svg', 'hidden_icon' => false, 'tooltip_requirement'=> true, 'preview_outside' => false, 'bulk_delete' => false, 'auto_compress' => false, ], ];
Published a config before the sections existed? It keeps working ā a flat key is still read as a fallback. Move it under
smart-uploadwhen convenient.
Every field then picks these up with no attributes at all:
<x-smartuiqisti::forms.upload-form label="Supporting Document" required />
An attribute on the tag always wins over the config, so you can still override any single field:
<x-smartuiqisti::forms.upload-form label="Passport" max-size="2" />
required, disable and ai-enable are deliberately not configurable ā they describe a
particular field, not an app-wide default.
License
smart-ui-qisti is open-source software licensed under the MIT license.