qisti/smart-ui-qisti

Local Smart UI components for the Qisti app.

Maintainers

Package info

github.com/QistiAmal1212/smart-ui-qisti

Language:Blade

pkg:composer/qisti/smart-ui-qisti

Transparency log

Statistics

Installs: 24

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.2.5 2026-08-12 14:34 UTC

README

  1. Include the package in composer
composer require qisti/smart-ui-qisti
  1. Run package installation
composer install
  1. Publish the CSS/Tailwind
php artisan vendor:publish --tag=smartuiqisti-assets
  1. Importing smart ui css into your main css
@import './smartuiqisti/app.css';
  1. Publish blade view
php artisan vendor:publish --tag=smartuiqisti-views
  1. Publish smartui configuration
php artisan vendor:publish --tag=smartuiqisti-config
  1. 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-upload when 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.