Base

Sto creando un applicativo molto complesso utilizzando:

Idea e desiderata

Vorrei creare dei componenti blade che:

  • accettano opzioni tramite le direttive blade per definire stile e funzioni (ad esempio se passo ‘color’ ‘primary’ applica le classi tailwind con color primary)
  • utilizzano le classi tailwind
  • usano le funzionalità di alpinejs se necessario (vedi la sezione esempi e il javascript) così da avere diversi micro componenti che posso utilizzare per comporre layout complessi ma dinamici e coerenti graficamente. Bisogna prevedere anche alcuni componenti che utilizzano librerie esterne (come flatpikr, vedi l’esempio 3, o choicesjs, esempio 5) che utilizzano AlpineJs per il frontend e si collegano a livewire. Seguendo le best practices Laravel, vorrei una struttura di cartelle che, partendo da resources/components/main-components rispetti le aree dei componenti; quindi ad esempio, vorrei una cartella resources/components/main-components/btns con i pulsanti, una cartella resources/components/main-components/inputs con le tipologie di input, ecc in base ai componenti che creerai. Ci sono diversi componenti tailwind in HTML che puoi usare per raggruppare le aree, le funzionalità e i diversi tipi di stili grafici da cui puoi prendere esempio nella sottocartella tailwind-components.

Esempi

In altri progetti con stack uguale ho implementato dei componenti simili a quelli che vorrei. Ecco alcuni esempi.

Esempio 1: simple-input.blade.php

@props([
  'name' => 's',
  'container_class' => '',
  'label' => null,
  'help' => null,
  'info' => null,
  'start_icon' => null,
  'end_icon' => null,
])
<div class="flex flex-col items-start justify-start w-full {{ $container_class }}">
 
  <div class="flex flex-row items-center justify-between w-full">
    @isset($label)
    <label for="{{ $name }}" class="block font-medium text-gray-900 text-sm/6 dark:text-gray-200">{{ $label }}</label>
    @else
    <div></div>
    @endisset
 
    @isset($help)
    <div class="text-gray-500 text-xs/6 dark:text-gray-300">{{ $help }}</div>
    @else
    <div></div>
    @endisset
  </div>
  
  <div class="grid w-full grid-cols-1 {{ ($label || $help) ? 'mt-2' : '' }}">
    <input name="{{ $name }}" id="{{ $name }}"
      @class([
        'col-start-1 row-start-1 block w-full border-0 rounded-md bg-white py-1.5 pr-3 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 sm:text-sm/6 dark:bg-transparent dark:text-gray-50',
        'pl-10 sm:pl-9' => $start_icon,
        'pr-10 sm:pr-9' => $end_icon,
      ]) 
      {{ $attributes }} >
    @isset($start_icon)
      @svg($start_icon, 'self-center col-start-1 row-start-1 ml-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 sm:size-4')
    @endisset
    
    @isset($end_icon)
      @svg($end_icon, 'self-center col-start-1 row-start-1 mr-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 justify-self-end sm:size-4')
    @endisset
  </div>
 
  @isset($info)
  <div class="mt-1 text-gray-500 text-sm/6 dark:text-gray-200">{{ $info }}</div>
  @endisset
 
  @error($name)
    <div class="mt-1 text-red-500 text-sm/6 dark:text-red-400">{{ $message }}</div>
  @enderror
 
</div>

Esempio 2: simple-select.blade.php

@props([
  'name' => 's',
  'options' => [],
  'container_class' => '',
  'label' => null,
  'help' => null,
  'info' => null,
  'start_icon' => null,
  'end_icon' => null,
])
<div class="flex flex-col items-start justify-start w-full {{ $container_class }}">
 
  <div class="flex flex-row items-center justify-between w-full">
    @isset($label)
    <label for="{{ $name }}" class="block font-medium text-gray-900 text-sm/6 dark:text-gray-200">{{ $label }}</label>
    @else
    <div></div>
    @endisset
 
    @isset($help)
    <div class="text-gray-500 text-xs/6 dark:text-gray-300">{{ $help }}</div>
    @else
    <div></div>
    @endisset
  </div>
  
  <div class="grid w-full grid-cols-1 {{ ($label || $help) ? 'mt-2' : '' }}">
    <select name="{{ $name }}" id="{{ $name }}"
      @class([
        'col-start-1 row-start-1 block w-full border-0 rounded-md bg-white py-1.5 pr-3 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 sm:text-sm/6 dark:bg-transparent dark:text-gray-50',
        'pl-10 sm:pl-9' => $start_icon,
        'pr-10 sm:pr-9' => $end_icon,
      ]) 
      {{ $attributes }}>
      @foreach ($options as $opt)
        <option class="dark:bg-gray-800 dark:text-gray-200" 
          value="{{ $opt['val'] ?? '' }}" {{ $opt['val'] ? ($opt['val'] == $attributes->get('value') ? 'selected' : '') : '' }}>{{ $opt['label'] ?? '' }}</option>
      @endforeach
    </select>
    @isset($start_icon)
      @svg($start_icon, 'self-center col-start-1 row-start-1 ml-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 sm:size-4')
    @endisset
    
    @isset($end_icon)
      @svg($end_icon, 'self-center col-start-1 row-start-1 mr-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 justify-self-end sm:size-4')
    @endisset
  </div>
 
  @isset($info)
  <div class="mt-1 text-gray-500 text-sm/6 dark:text-gray-200">{{ $info }}</div>
  @endisset
 
  @error($name)
    <div class="mt-1 text-red-500 text-sm/6 dark:text-red-400">{{ $message }}</div>
  @enderror
 
</div>

Esempio 3: flatpickr-input.blade.php

@props([
// Proprietà per il campo generico
'name' => 's',
'container_class' => '',
'label' => null,
'help' => null,
'info' => null,
'start_icon' => null,
'end_icon' => null,
// Proprietà specifiche per Flatpickr
'format' => 'Y-m-d',
'options' => [],
])
 
<div class="flex flex-col items-start justify-start w-full {{ $container_class }}">
  <!-- Header con label e help -->
  <div class="flex flex-row items-center justify-between w-full">
    @isset($label)
    <label for="{{ $name }}" class="block font-medium text-gray-900 text-sm/6 dark:text-gray-200">
      {{ $label }}
    </label>
    @else
    <div></div>
    @endisset
 
    @isset($help)
    <div class="text-gray-500 text-xs/6 dark:text-gray-300">{{ $help }}</div>
    @else
    <div></div>
    @endisset
  </div>
 
  <!-- Input con Flatpickr integrato -->
  <div class="grid w-full grid-cols-1 mt-2"
    x-data="flatpickrComponent(
      @entangle($attributes->wire('model')).live, 
      {{ json_encode($options) }}, 
      '{{ $format }}'
    )"
    x-init="init()">
    <input name="{{ $name }}" id="{{ $name }}"
      @class([ 
        'col-start-1 row-start-1 block w-full border-0 rounded-md bg-white py-1.5 pr-3 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 sm:text-sm/6 dark:bg-transparent dark:text-gray-50', 
        'pl-10 sm:pl-9'=> $start_icon,
        'pr-10 sm:pr-9' => $end_icon,
      ])
      x-ref="input"
      type="text"
      {{ $attributes->except(['wire:model']) }}
    >
 
    @isset($start_icon)
      @svg($start_icon, 'self-center col-start-1 row-start-1 ml-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 sm:size-4')
    @endisset
 
    @isset($end_icon)
      @svg($end_icon, 'self-center col-start-1 row-start-1 mr-3 text-gray-400 dark:text-gray-200 pointer-events-none size-5 justify-self-end sm:size-4')
    @endisset
  </div>
 
  <!-- Info sotto l'input -->
  @isset($info)
  <div class="mt-1 text-gray-500 text-sm/6 dark:text-gray-200">{{ $info }}</div>
  @endisset
 
  <!-- Messaggio di errore -->
  @error($name)
  <div class="mt-1 text-red-500 text-sm/6 dark:text-red-400">{{ $message }}</div>
  @enderror
</div>

Esempio 4: file-drop.blade.php

@props([
'name' => 'file', // Nome dell'input
'accept' => '*', // Tipo di file accettati (e.g. "image/*", ".pdf", etc.)
'multiple' => false, // Consentire il caricamento multiplo
'placeholder' => 'Trascina o clicca per caricare il file',
'icon' => 'heroicon-m-arrow-down-tray',
])
 
<div x-data="{ 
      fileName: '',
      updateFileName() {
        // Se l'utente seleziona un solo file, mostriamo il nome
        // Se ne seleziona di più, mostriamo un contatore
        let files = this.$refs.file.files;
        if (!files.length) {
          this.fileName = '';
        } else if (files.length === 1) {
          this.fileName = files[0].name;
        } else {
          this.fileName = files.length + ' file selezionati';
        }
      }
    }" class="z-40 w-full p-4 mt-1 text-gray-400 bg-white border border-gray-300 rounded-md shadow-sm dark:bg-gray-800 dark:border-gray-700">
  <div x-ref="dnd" class="relative text-gray-400 border-2 border-gray-200 border-dashed rounded cursor-pointer">
    <input x-ref="file" type="file" name="{{ $name }}" accept="{{ $accept }}"
      @dragover="$refs.dnd.classList.add('bg-indigo-50')" @dragleave="$refs.dnd.classList.remove('bg-indigo-50')"
      @drop="$refs.dnd.classList.remove('bg-indigo-50')" @change="updateFileName()"
      class="absolute inset-0 z-40 w-full h-full p-0 m-0 outline-none opacity-0 cursor-pointer" {{ $multiple
      ? 'multiple' : '' }} {{ $attributes }} />
    <div class="flex flex-col items-center justify-center py-10 text-center">
      @svg($icon, 'w-12 h-12 text-gray-400')
      <p class="mt-1">{{ $placeholder }}</p>
      <p class="mt-1">{{ $slot }}</p>
      <p class="mt-2 text-gray-900 dark:text-gray-300" x-text="fileName"></p>
    </div>
  </div>
</div>

Esempio 5: choicesjs-select.blade.php

@props([
  'options'    => [],                // Array di opzioni: ogni elemento deve avere 'label' e 'val'
  'inline'     => false,             // Se true il select viene reso inline
  'size'       => 'm',               // 's' oppure 'm'
  'mode'       => 'normal',          // 'normal' o 'borderless'
  'name'       => 's',               // Nome e id dell'elemento
  'toEntangle' => 's',               // Nome della proprietà Livewire da entanglare
  'multiple'   => false,             // Se true il select è multiplo
  'disabled'   => false,             // Se true il select è disabilitato
  'chOptions'  => [],                // Opzioni aggiuntive per Choices.js
  'label'      => null,              // Testo della label (opzionale)
  'info'       => null,              // Info da visualizzare sotto il select (opzionale)
])
@php
  // Converte le opzioni in JSON; se necessario puoi aggiungere flag come JSON_UNESCAPED_UNICODE
  $optionsJson = json_encode($options);
  $optionsJson = str_replace("'", "\'", $optionsJson);
  $optionsJson = str_replace('"', "'", $optionsJson);
@endphp
 
<div class="flex flex-col items-start w-full {{ $attributes->get('container_class') }} {{ $disabled ? 'choiches-disabled' : '' }}">
  <div wire:ignore x-data="choicesSelect({
      multiple: {{ $multiple ? 'true' : 'false' }},
      options: {!! $optionsJson !!},
      value: @entangle($toEntangle).live,
      chOptions: {{ json_encode($chOptions) }},
    })" class="w-full">
    @if($label)
      <label for="{{ $name }}" class="block mb-2 text-sm text-gray-900 dark:text-gray-200">
        {{ $label }}
      </label>
    @endif
    <select name="{{ $name }}" id="{{ $name }}" x-ref="select" {{ $multiple ? 'multiple' : '' }}
      @class([
        'mt-2 sm:text-sm focus:outline-none',
        'pl-3 pr-10 border-gray-300 rounded-md' => (!$mode || $mode == 'normal'),
        'border-0 bg-transparent'            => $mode == 'borderless',
        'py-1 text-base'                      => (!$size || $size == 'm'),
        'py-1 text-sm'                        => $size == 's',
        'block w-full'                        => !$inline,
        'inline'                              => $inline,
      ])>
      {{-- Non è necessario generare le <option> qui, Choices.js le popola via JS --}}
    </select>
  </div>
 
  @if($info)
    <div class="mt-1 text-sm text-gray-500 dark:text-gray-200">{{ $info }}</div>
  @endif
 
  @error($name)
    <div class="mt-1 text-sm text-red-500 dark:text-red-400">{{ $message }}</div>
  @enderror
</div>

Javascript

 
document.addEventListener('alpine:init', () => {
  Alpine.data('flatpickrComponent', (entangledValue, userOptions, format) => ({
    // The 'Value' property is linked to the Livewire data
    value: entangledValue,
    fp: null,
    init() {
      // Default Flatpickr options
      let defaultOptions = {
        dateFormat: format,
        onChange: (selectedDates, dateStr, instance) => {
          // If the flatpickr is not in single mode, return an array of formatted dates
          if (instance.config.mode !== 'single') {
            this.value = selectedDates.map(date => instance.formatDate(date, format));
          } else {
            this.value = dateStr;
          }
        }
      };
 
      // Add the default options with those provided by the user
      let fpOptions = Object.assign({}, defaultOptions, userOptions);
 
      // Init Flatpickr
      this.fp = flatpickr(this.$refs.input, fpOptions);
 
      // If there is an initial value, set it in Flatpickr
      if (this.value && this.value !== '') {
        // In Range mode, an array is expected (or a string formatted in a certain way);
        // If the value is already an array passes directly, otherwise it passes it as a string.
        this.fp.setDate(this.value, false);
      }
 
      // Watcher: If the entangled value is updated (for example by clicking "Edit")
      // Update the Flatpickr application.
      this.$watch('value', (newValue) => {
        if (this.fp) {
          // In non single mode Newvalue should be an array, otherwise a string.
          // The false parameter prevents to trigger the callback onchange.
          this.fp.setDate(newValue, false);
        }
      });
    }
  }));
 
  Alpine.data('choicesSelect', ({ multiple, options, value, chOptions }) => ({
    multiple: multiple === 'true' || multiple === true,
    options: options,   // Array di opzioni, es. [{ val: '1', label: 'Opzione 1' }, ...]
    choices: null,
    value: value,
    init() {
      this.$nextTick(() => {
        if (!this.$refs.select) return;
 
        const defaultOptions = {
          loadingText: 'Caricamento...',
          noResultsText: 'Nessun risultato trovato',
          noChoicesText: 'Nessuna opzione disponibile',
          itemSelectText: 'Seleziona',
          addItemText: (value) => {
            return `Premi Invio per aggiugere <b>"${value}"</b>`;
          },
          allowHTML: true,
          removeItems: true,
          removeItemButton: true,
          shouldSort: false,
          classNames: {
            containerInner: [
              'choices__inner',
              'rounded-md',
              'border-0',
              'px-2',
              'py-1.5',
              'text-gray-900',
              'shadow-sm',
              'ring-1',
              'ring-inset',
              'ring-gray-300',
              'sm:text-sm/6',
              'bg-transparent',
              'dark:bg-transparent',
              'dark:text-gray-50',
            ],
            listDropdown: [
              'choices__list--dropdown',
              'dark:bg-gray-800',
              'dark:text-gray-200'
            ],
            input: [
              'choices__input',
              'py-0'
            ]
          }
        };
 
        // Merge the default options with those provided by the user
        const chOptionsMerged = Object.assign({}, defaultOptions, chOptions);
 
        // Inizializza Choices.js con alcune impostazioni di default e classi personalizzate
        this.choices = new Choices(this.$refs.select, chOptionsMerged);
        // Quando l'utente cambia la selezione, aggiorna il valore entangled
        this.$refs.select.addEventListener('change', () => {
          this.value = this.choices.getValue(true);
        });
        // Inizializza le opzioni nel widget
        this.refresh();
        // (Opzionale) Se vuoi ascoltare eventi Livewire per aggiornare le opzioni, ad esempio:
        Livewire.on(`choice-refresh-${this.$refs.select.getAttribute('id')}`, () => {
          this.refresh();
        });
 
        this.$watch('value', (newValue) => {
          this.refresh();
        });
      });
    },
    refresh() {
      if (this.choices) {
        // Svuota le opzioni esistenti
        this.choices.clearStore();
 
        // Crea una copia delle opzioni originali
        let updatedOptions = [...this.options];
 
        // Se il componente è in modalità multipla, assicurati che ogni valore selezionato sia presente in updatedOptions
        if (this.multiple && this.value) {
          // check if the value is an array
          if (!Array.isArray(this.value)) {
            this.value = [this.value];
          } else {
            this.value.forEach(val => {
              if (!updatedOptions.find(opt => opt.val === val)) {
                updatedOptions.push({ val: val, label: val });
              }
            });
          }
        } else {
          if (this.value && !updatedOptions.find(opt => opt.val === this.value)) {
            updatedOptions.push({ val: this.value, label: this.value });
          }
        }
 
        // Mappa le opzioni in un formato comprensibile a Choices.js
        const mapped = updatedOptions.map(opt => ({
          value: opt.val,
          label: opt.label,
          selected: this.multiple
            ? (this.value ? this.value.includes(opt.val) : false)
            : this.value == opt.val
        }));
 
        // Aggiorna Choices.js con le opzioni combinate
        this.choices.setChoices(mapped, 'value', 'label', true);
      }
    }
  }));
});

Componenti TailwindCss

Elenco di componenti TailwindCss in HTML divisi per sezioni.

How to use examples

Bring your own JavaScript

All of the components in Tailwind Plus are provided in three formats: React, Vue, and vanilla HTML.

The vanilla HTML examples do not include any JavaScript and are designed for people who prefer to write any necessary JavaScript themselves, or who want to integrate with a framework other than React or Vue.

The vast majority of components don’t need JavaScript at all and are completely ready to go out of the box, but things that are interactive like dropdowns, dialogs, etc. require you to write some JS to make them work the way you’d expect.

In these situations we’ve provided some simple comments in the HTML to explain things like what classes you need to use for different states (like a toggle switch being on or off), or what classes we recommend for transitioning elements on to or off of the screen (like a dialog opening).

Dynamic classes

When an element needs different classes applied based on some state (like a toggle being on or off), we list the classes for each state in a comment directly above the element:

<!-- On: "bg-indigo-600", Off: "bg-gray-200" -->
<span
  aria-checked="false"
  class="focus:shadow-outline relative inline-block h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none"
  role="checkbox"
  tabindex="0"
>
  <!-- On: "translate-x-5", Off: "translate-x-0" -->
  <span
    aria-hidden="true"
    class="inline-block size-5 translate-x-0 transform rounded-full bg-white shadow transition duration-200 ease-in-out"
  ></span>
</span>

The HTML we provide is always pre-configured for one of the defined states, and the classes that you need to change when switching states are always at the very beginning of the class list so they are easy to find.

As an example, to adapt this HTML for Alpine.js, you can conditionally apply the correct classes using the :class directive based on some state you’ve declared in x-data:

<span
  x-data="{ isOn: false }"
  @click="isOn = !isOn"
  :aria-checked="isOn"
  :class="{'bg-indigo-600': isOn, 'bg-gray-200': !isOn }"
  class="focus:shadow-outline relative inline-block h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none"
  role="checkbox"
  tabindex="0"
>
  <span
    aria-hidden="true"
    :class="{'translate-x-5': isOn, 'translate-x-0': !isOn }"
    class="inline-block size-5 translate-x-0 transform rounded-full bg-white shadow transition duration-200 ease-in-out"
  ></span>
</span>

We’ve included a basic click handler here to demonstrate the general idea, but please reference the WAI-ARIA Authoring Practices when building components like this to ensure you implement all of the necessary keyboard interactions and properly manage any required ARIA attributes.

Transitions

For elements that should be dynamically shown or hidden (like the panel on a dropdown), we include the recommended transition styles in a comment directly above the dynamic element:

<div class="relative ...">
  <button type="button" class="...">Options</button>
 
  <!--
    Show/hide this element based on the dropdown state
 
    Entering: "transition ease-out duration-100 transform"
      From: "opacity-0 scale-95"
      To: "opacity-100 scale-100"
    Closing: "transition ease-in duration-75 transform"
      From: "opacity-100 scale-100"
      To: "opacity-0 scale-95"
  -->
  <div class="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg">
    <div class="rounded-md bg-white shadow-xs">
      <!-- Snipped  -->
    </div>
  </div>
</div>

As an example, to adapt this HTML for Alpine.js you would use the x-transition directive to apply the right classes at each point in the transition lifecycle:

<div x-data="{ isOpen: false }" class="relative ...">
  <button type="button" @click="isOpen = !isOpen" class="...">Options</button>
 
  <div
    x-show="isOpen"
    x-transition:enter="transition ease-out duration-100 transform"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="transition ease-in duration-75 transform"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    class="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
  >
    <div class="rounded-md bg-white shadow-xs">
      <!-- Snipped  -->
    </div>
  </div>
</div>

We’ve included a basic click handler here to demonstrate the general idea, but please reference the WAI-ARIA Authoring Practices when building components like this to ensure you implement all of the necessary keyboard interactions and properly manage any required ARIA attributes.

Creating partials/components

Since the vanilla HTML examples included in Tailwind Plus can’t take advantage of things like loops, there is a lot of repetition that wouldn’t actually be there in a real-world project where the HTML was being generated from some dynamic data source. We might give you a list component with 5 list items for example that have all the utilities duplicated on each one, whereas in your project you’ll actually be generating those list items by looping over an array.

When adapting our examples for your own projects, we recommend creating reusable template partials or JS components as needed to manage any duplication.

Learn more about this in the “Using components” documentation on the Tailwind CSS website.