├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── form-components.php ├── dist ├── form-components.js ├── form-components.js.map └── manifest.json ├── resources ├── css │ ├── addons.css │ ├── checkbox-group.css │ ├── choice.css │ ├── custom-select.css │ ├── errors.css │ ├── file-upload.css │ ├── filepond.css │ ├── flatpickr.css │ ├── form-group.css │ ├── index.css │ ├── input.css │ ├── label.css │ ├── quill.css │ ├── select.css │ ├── switch-toggle.css │ ├── tree-select.css │ └── variables.css ├── js │ ├── components │ │ ├── custom-select.js │ │ ├── date-picker.js │ │ ├── filepond.js │ │ ├── index.js │ │ ├── quill.js │ │ ├── switch-toggle.js │ │ └── tree-select.js │ ├── directives │ │ ├── form-group.js │ │ ├── index.js │ │ └── textarea-resize.js │ ├── index.js │ ├── mixins │ │ ├── select.js │ │ ├── selectContext.js │ │ ├── selectMagic.js │ │ └── selectPopper.js │ ├── tailwind-plugins │ │ ├── dark-mode.js │ │ ├── switch-toggle.js │ │ └── util │ │ │ ├── addDarkVariant.js │ │ │ └── darkModeSelector.js │ └── util │ │ ├── customSelectContext.js │ │ └── treeSelectContext.js ├── lang │ └── en │ │ └── messages.php └── views │ ├── components │ ├── choice │ │ ├── checkbox-group.blade.php │ │ ├── checkbox-or-radio.blade.php │ │ ├── partials │ │ │ └── label.blade.php │ │ └── switch-toggle.blade.php │ ├── files │ │ ├── file-pond.blade.php │ │ ├── file-upload.blade.php │ │ └── partials │ │ │ ├── custom-progress-bar.blade.php │ │ │ ├── file-input.blade.php │ │ │ ├── native-progress-bar.blade.php │ │ │ └── upload-progress.blade.php │ ├── form-error.blade.php │ ├── form-group.blade.php │ ├── form.blade.php │ ├── inputs │ │ ├── custom-select-option.blade.php │ │ ├── custom-select.blade.php │ │ ├── date-picker.blade.php │ │ ├── input.blade.php │ │ ├── partials │ │ │ ├── attributes.blade.php │ │ │ ├── leading-addon.blade.php │ │ │ ├── no-options.blade.php │ │ │ ├── password-toggle.blade.php │ │ │ ├── select-loader.blade.php │ │ │ ├── select-option.blade.php │ │ │ └── trailing-addon.blade.php │ │ ├── password.blade.php │ │ ├── select.blade.php │ │ ├── textarea.blade.php │ │ ├── timezone-select.blade.php │ │ ├── tree-select-option.blade.php │ │ └── tree-select.blade.php │ ├── label.blade.php │ └── rich-text │ │ └── quill.blade.php │ ├── livewire │ ├── custom-select │ │ └── custom-select.blade.php │ └── tree-select │ │ └── tree-select.blade.php │ └── partials │ ├── form-group-label.blade.php │ ├── leading-addons.blade.php │ ├── select │ └── select-trigger.blade.php │ ├── timezone-select-custom.blade.php │ ├── timezone-select-native.blade.php │ └── trailing-addons.blade.php └── src ├── Components ├── BladeComponent.php ├── Choice │ ├── Checkbox.php │ ├── CheckboxGroup.php │ ├── Radio.php │ └── SwitchToggle.php ├── Files │ ├── FilePond.php │ └── FileUpload.php ├── Form.php ├── FormError.php ├── FormGroup.php ├── Inputs │ ├── CustomSelect.php │ ├── CustomSelectOption.php │ ├── DatePicker.php │ ├── Email.php │ ├── Input.php │ ├── Password.php │ ├── Select.php │ ├── Textarea.php │ ├── TimezoneSelect.php │ ├── TreeSelect.php │ └── TreeSelectOption.php ├── Label.php ├── Livewire │ ├── Concerns │ │ ├── HandlesSelectOptions.php │ │ └── HasCustomSelectProperties.php │ ├── CustomSelect.php │ └── TreeSelect.php └── RichText │ └── Quill.php ├── Concerns ├── AcceptsFiles.php ├── GetsSelectOptionProperties.php ├── HandlesValidationErrors.php ├── HasAddons.php ├── HasExtraAttributes.php └── HasModels.php ├── Controllers ├── Concerns │ └── CanPretendToBeAFile.php └── FormComponentsJavaScriptAssets.php ├── Dto └── QuillOptions.php ├── Facades └── FormComponents.php ├── FormComponents.php ├── FormComponentsServiceProvider.php └── Support ├── FormComponentsTagCompiler.php ├── TimeZoneRegion.php ├── TimeZoneRegionEnum.php └── Timezone.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Randall Wilk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Package Abandonment: Since I now use packages like Filament for my UI needs, I no longer have an interest or incentive to maintain this package. 2 | 3 | # Form Components for Laravel 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/rawilk/laravel-form-components.svg?style=flat-square)](https://packagist.org/packages/rawilk/laravel-form-components) 6 | ![Tests](https://github.com/rawilk/laravel-form-components/workflows/Tests/badge.svg?style=flat-square) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/rawilk/laravel-form-components.svg?style=flat-square)](https://packagist.org/packages/rawilk/laravel-form-components) 8 | [![PHP from Packagist](https://img.shields.io/packagist/php-v/rawilk/laravel-form-components?style=flat-square)](https://packagist.org/packages/rawilk/laravel-form-components) 9 | [![License](https://img.shields.io/github/license/rawilk/laravel-form-components?style=flat-square)](https://github.com/rawilk/laravel-form-components/blob/main/LICENSE.md) 10 | 11 | ![social image](https://banners.beyondco.de/Form%20Components%20for%20Laravel.png?theme=light&packageManager=composer+require&packageName=rawilk%2Flaravel-form-components&pattern=diagonalStripes&style=style_1&description=Form+components+built+for+tailwind+%26+Livewire&md=1&showWatermark=0&fontSize=100px&images=code) 12 | 13 | Form Components for Laravel provides common form components to help build forms faster using Tailwind CSS. Supports validation, old form values, and wire:model. 14 | 15 | ## Installation 16 | 17 | You can install the package via composer: 18 | 19 | ```bash 20 | composer require rawilk/laravel-form-components 21 | ``` 22 | 23 | You can publish the config file with: 24 | 25 | ```bash 26 | php artisan vendor:publish --tag="form-components-config" 27 | ``` 28 | 29 | You can view the default configuration here: https://github.com/rawilk/laravel-form-components/blob/main/config/form-components.php 30 | 31 | You can publish the package's views with this command: 32 | 33 | ```bash 34 | php artisan vendor:publish --tag="form-components-views" 35 | ``` 36 | 37 | If you want to override the package's language lines, you can publish them with this command: 38 | 39 | ```bash 40 | php artisan vendor:publish --tag="form-components-translations" 41 | ``` 42 | 43 | ## Documentation 44 | 45 | For more documentation, please visit: https://randallwilk.dev/docs/laravel-form-components 46 | 47 | ## Demo 48 | 49 | For a demo of some of the components, please visit: https://laravel-form-components.randallwilk.dev 50 | 51 | ## Testing 52 | 53 | ```bash 54 | composer test 55 | ``` 56 | 57 | ## Changelog 58 | 59 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 60 | 61 | ## Contributing 62 | 63 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 64 | 65 | ## Security 66 | 67 | Please review [my security policy](../../security) on how to report security vulnerabilities. 68 | 69 | ## Credits 70 | 71 | - [Randall Wilk](https://github.com/rawilk) 72 | - [All Contributors](../../contributors) 73 | 74 | This package is also heavily inspired by [Laravel Form Components](https://github.com/protonemedia/laravel-form-components) and [Blade UI Kit](https://blade-ui-kit.com/). 75 | A lot of inspiration for some JS components is taken from [Alpine Headless Components](https://alpinejs.dev/components#headless). 76 | 77 | ## Alternatives 78 | 79 | This package was created to satisfy my own needs and preferences, and relies on TailwindCSS, TailwindUI, and AlpineJS for styling and functionality. You can always 80 | try one of these alternatives if your needs differ: 81 | 82 | - [Blade UI Kit](https://blade-ui-kit.com/) 83 | - [Laravel Form Components](https://github.com/protonemedia/laravel-form-components) 84 | 85 | ## Disclaimer 86 | 87 | This package is not affiliated with, maintained, authorized, endorsed or sponsored by Laravel, TailwindCSS, Laravel Livewire, Alpine.js, or any of its affiliates. 88 | 89 | ## License 90 | 91 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 92 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rawilk/laravel-form-components", 3 | "description": "Set of Blade components for TailwindCSS forms.", 4 | "keywords": [ 5 | "rawilk", 6 | "laravel-form-components", 7 | "Tailwind", 8 | "form", 9 | "Laravel", 10 | "Blade", 11 | "date picker", 12 | "toggle", 13 | "flatpickr", 14 | "custom-select", 15 | "filepond" 16 | ], 17 | "homepage": "https://github.com/rawilk/laravel-form-components", 18 | "license": "MIT", 19 | "authors": [ 20 | { 21 | "name": "Randall Wilk", 22 | "email": "randall@randallwilk.dev", 23 | "homepage": "https://randallwilk.dev", 24 | "role": "Developer" 25 | } 26 | ], 27 | "require": { 28 | "php": "^8.0|^8.1|^8.2", 29 | "illuminate/filesystem": "^9.0|^10.0", 30 | "illuminate/support": "^9.0|^10.0", 31 | "illuminate/view": "^9.0|^10.0", 32 | "spatie/laravel-package-tools": "^1.14" 33 | }, 34 | "require-dev": { 35 | "blade-ui-kit/blade-heroicons": "^2.0", 36 | "laravel/pint": "^1.5", 37 | "livewire/livewire": "^2.8", 38 | "orchestra/testbench": "^7.0|^8.0", 39 | "pestphp/pest": "^1.22", 40 | "pestphp/pest-plugin-laravel": "^1.4", 41 | "pestphp/pest-plugin-parallel": "^1.2", 42 | "sinnbeck/laravel-dom-assertions": "^1.3", 43 | "spatie/laravel-ray": "^1.25" 44 | }, 45 | "suggest": { 46 | "blade-ui-kit/blade-heroicons": "Required for the default icons used in this package", 47 | "livewire/livewire": "Consider livewire for handling your form submissions" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "Rawilk\\FormComponents\\": "src" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Rawilk\\FormComponents\\Tests\\": "tests" 57 | } 58 | }, 59 | "scripts": { 60 | "post-autoload-dump": [ 61 | "@php ./vendor/bin/testbench package:discover --ansi" 62 | ], 63 | "test": "vendor/bin/pest -p", 64 | "format": "vendor/bin/pint --dirty" 65 | }, 66 | "config": { 67 | "sort-packages": true, 68 | "allow-plugins": { 69 | "pestphp/pest-plugin": true 70 | } 71 | }, 72 | "extra": { 73 | "laravel": { 74 | "providers": [ 75 | "Rawilk\\FormComponents\\FormComponentsServiceProvider" 76 | ], 77 | "aliases": { 78 | "FormComponents": "Rawilk\\FormComponents\\Facades\\FormComponents" 79 | } 80 | } 81 | }, 82 | "minimum-stability": "dev", 83 | "prefer-stable": true 84 | } 85 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | {"/form-components.js":"/form-components.js?id=ef6f45e2d9d18992ce4f"} -------------------------------------------------------------------------------- /resources/css/addons.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .leading-addon, 3 | .trailing-addon { 4 | @apply inline-flex 5 | items-center 6 | px-3 7 | border 8 | sm:text-sm; 9 | 10 | background-color: var(--leading-addon-background-color); 11 | color: var(--leading-addon-color); 12 | border-color: var(--input-border-color); 13 | } 14 | 15 | .leading-addon { 16 | @apply border-r-0; 17 | 18 | border-top-left-radius: var(--input-border-radius); 19 | border-bottom-left-radius: var(--input-border-radius); 20 | } 21 | 22 | .leading-addon + :is(.leading-addon, .form-text, .custom-select__button) { 23 | @apply rounded-l-none; 24 | } 25 | 26 | .leading-icon { 27 | @apply absolute 28 | inset-y-0 29 | left-0 30 | pl-3 31 | flex 32 | items-center; 33 | 34 | color: var(--leading-icon-color); 35 | } 36 | 37 | :is(.leading-icon, .trailing-icon):not(button):not([role="button"]):not(:has(button, [role="button"])) { 38 | @apply pointer-events-none; 39 | } 40 | 41 | .leading-icon-container, 42 | .trailing-icon-container { 43 | @apply h-5 w-5; 44 | } 45 | 46 | .clear-button { 47 | @apply relative 48 | h-6 49 | w-6 50 | rounded-full 51 | transition-colors 52 | flex 53 | items-center 54 | justify-center 55 | bg-transparent 56 | focus:outline-none 57 | focus:ring-0; 58 | } 59 | 60 | .clear-button:is(:hover, :focus) { 61 | background-color: var(--clear-button-hover-bg); 62 | } 63 | 64 | .clear-button:is(:hover, :focus) svg { 65 | color: var(--clear-button-hover-color); 66 | } 67 | 68 | .clear-button svg { 69 | @apply h-5 w-5; 70 | } 71 | 72 | .leading-icon + :is(.form-text, .custom-select__button) { 73 | @apply pl-10 !important; 74 | } 75 | 76 | .leading-addon svg { 77 | @apply h-5 w-5; 78 | } 79 | 80 | .leading-icon, 81 | .trailing-icon { 82 | z-index: 3; 83 | } 84 | 85 | .leading-icon svg { 86 | @apply max-w-full; 87 | } 88 | 89 | .inline-addon { 90 | @apply absolute 91 | inset-y-0 92 | left-0 93 | pl-3 94 | flex 95 | items-center 96 | sm:text-sm 97 | sm:leading-5; 98 | 99 | color: var(--input-color); 100 | } 101 | 102 | .inline-addon + :is(.form-text, .custom-select__button) { 103 | padding-left: var(--inline-addon-pl) !important; 104 | } 105 | 106 | @screen sm { 107 | .inline-addon + :is(.form-text, .custom-select__button) { 108 | --inline-addon-pl: theme('spacing.14'); 109 | } 110 | } 111 | 112 | .trailing-addon { 113 | @apply border-l-0; 114 | } 115 | 116 | :is(.form-text, .custom-select__button):has(+ .trailing-addon) { 117 | @apply rounded-r-none; 118 | @apply z-[2]; 119 | } 120 | 121 | .trailing-addon:last-of-type { 122 | border-top-right-radius: var(--input-border-radius); 123 | border-bottom-right-radius: var(--input-border-radius); 124 | } 125 | 126 | .trailing-icon { 127 | @apply absolute 128 | inset-y-0 129 | right-0 130 | pr-3 131 | flex 132 | items-center; 133 | 134 | color: var(--leading-icon-color); 135 | } 136 | 137 | .form-text:has(+ .trailing-icon) { 138 | @apply pr-10 !important; 139 | } 140 | 141 | .trailing-inline-addon { 142 | @apply absolute 143 | inset-y-0 144 | right-0 145 | pr-3 146 | flex 147 | items-center 148 | sm:text-sm 149 | sm:leading-5; 150 | 151 | color: var(--input-color); 152 | } 153 | 154 | .form-text:has(+ .trailing-inline-addon) { 155 | padding-right: var(--trailing-inline-addon-pr) !important; 156 | } 157 | 158 | /* sizing */ 159 | .has-leading-icon.form-input--lg .form-text { 160 | @apply pl-11 !important; 161 | } 162 | 163 | .has-trailing-icon.form-input--lg .form-text { 164 | @apply pr-11 !important; 165 | } 166 | 167 | :is(.has-leading-icon, .has-trailing-icon).form-input--lg :is(.leading-icon-container, .trailing-icon-container) { 168 | @apply h-6 w-6; 169 | } 170 | 171 | /* error states */ 172 | .leading-icon:has(+ .input-error), 173 | .input-error + .trailing-icon { 174 | color: var(--input-error-leading-icon-color); 175 | } 176 | 177 | .inline-addon:has(+ .input-error), 178 | .input-error + .trailing-inline-addon { 179 | color: var(--input-error-color); 180 | } 181 | } 182 | 183 | /* sizing */ 184 | @layer utilities { 185 | .form-input--sm :is(.leading-addon, .inline-addon, .trailing-addon, .trailing-inline-addon) { 186 | @apply sm:text-xs; 187 | } 188 | 189 | .form-input--lg :is(.leading-addon, .inline-addon, .trailing-addon, .trailing-inline-addon) { 190 | @apply sm:text-base; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /resources/css/checkbox-group.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .form-checkbox-group--inline { 3 | @apply grid 4 | items-start; 5 | 6 | grid-template-columns: repeat(var(--fc-checkbox-grid-cols), minmax(0, 1fr)); 7 | gap: var(--fc-checkbox-grid-gap); 8 | } 9 | 10 | .form-checkbox-group--stacked { 11 | @apply space-y-4; 12 | } 13 | } 14 | 15 | @layer utilities { 16 | .form-checkbox-group--stacked.form-checkbox-group--lg { 17 | @apply space-y-6; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/css/choice.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .choice-container { 3 | @apply relative 4 | flex 5 | items-start; 6 | } 7 | 8 | .choice-container--label-left { 9 | @apply py-4; 10 | } 11 | 12 | .choice-input { 13 | @apply flex 14 | h-5 15 | items-center; 16 | } 17 | 18 | .choice-input--right { 19 | @apply ml-3; 20 | } 21 | 22 | .form-choice { 23 | @apply border; 24 | 25 | width: var(--choice-size); 26 | height: var(--choice-size); 27 | border-color: var(--input-border-color); 28 | color: var(--choice-color); 29 | background-color: var(--choice-bg); 30 | } 31 | 32 | .form-choice:focus { 33 | --tw-ring-color: var(--choice-ring-color); 34 | } 35 | 36 | .form-checkbox { 37 | @apply rounded; 38 | } 39 | 40 | .choice-label { 41 | @apply ml-3 text-sm; 42 | } 43 | 44 | .choice-label--left { 45 | @apply ml-0 46 | min-w-0 47 | flex-1; 48 | } 49 | 50 | .choice-label label { 51 | font-weight: var(--choice-label-font-weight); 52 | color: var(--choice-label-color); 53 | } 54 | 55 | .choice-description { 56 | color: var(--choice-description-color); 57 | } 58 | 59 | .form-choice:is([disabled], [readonly], [disabled]:hover, [readonly]:hover) { 60 | color: var(--choice-color); 61 | background-color: var(--choice-disabled-bg); 62 | border-color: var(--input-disabled-border-color); 63 | 64 | @apply cursor-not-allowed opacity-[.40]; 65 | } 66 | 67 | .form-choice:is([disabled]:checked, [readonly]:checked) { 68 | background-color: currentColor; 69 | } 70 | 71 | .choice-label label[data-disabled="true"] { 72 | @apply cursor-not-allowed opacity-75; 73 | } 74 | 75 | .form-choice:checked { 76 | background-color: currentColor; 77 | } 78 | 79 | .form-choice:is(:checked, :checked:hover) { 80 | border-color: transparent; 81 | } 82 | } 83 | 84 | /* sizing */ 85 | @layer utilities { 86 | .form-choice--sm .form-choice { 87 | width: var(--choice-size); 88 | height: var(--choice-size); 89 | } 90 | 91 | .form-choice--md .form-choice { 92 | @apply h-6 w-6; 93 | } 94 | 95 | .form-choice--md .choice-label { 96 | @apply -mt-1; 97 | } 98 | 99 | .form-choice--lg .form-choice { 100 | @apply h-8 w-8; 101 | } 102 | 103 | .form-choice--lg .choice-label { 104 | @apply text-base -mt-2; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /resources/css/custom-select.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .custom-select__button-container { 3 | @apply flex 4 | relative 5 | shadow-sm; 6 | 7 | border-radius: var(--input-border-radius); 8 | } 9 | 10 | .custom-select__button { 11 | @apply flex-1 12 | flex 13 | items-center justify-between 14 | block 15 | gap-2 16 | w-full 17 | border 18 | shadow-none 19 | text-left 20 | sm:text-sm; 21 | 22 | background-color: var(--input-background-color); 23 | padding: var(--input-padding-y) var(--input-padding-x); 24 | border-radius: var(--input-border-radius); 25 | border-color: var(--input-border-color); 26 | color: var(--input-color); 27 | } 28 | 29 | .custom-select__button:is(:active, :focus, .open) { 30 | @apply ring-1; 31 | --tw-ring-color: var(--input-focus-border-color); 32 | border-color: var(--input-focus-border-color); 33 | } 34 | 35 | .custom-select:is([disabled], [readonly]) .custom-select__button { 36 | @apply cursor-not-allowed; 37 | 38 | background-color: var(--input-disabled-bg-color); 39 | border-color: var(--input-disabled-border-color); 40 | } 41 | 42 | .custom-select:is([disabled], [readonly]) .custom-select__button:is(:active, :focus) { 43 | @apply ring-0 outline-none; 44 | } 45 | 46 | .custom-select__button-icon { 47 | @apply h-5 w-5 shrink-0; 48 | } 49 | 50 | .custom-select__button-container:has(+ [data-popper-placement="top-start"]) .custom-select__button-icon { 51 | @apply rotate-180; 52 | } 53 | 54 | .custom-select__clear { 55 | @apply shrink-0; 56 | } 57 | 58 | .custom-select__clear:is(:hover, :focus) { 59 | @apply relative 60 | h-6 61 | w-6 62 | rounded-full 63 | transition-colors; 64 | 65 | color: var(--clear-button-hover-color); 66 | background-color: var(--clear-button-hover-bg); 67 | } 68 | 69 | .custom-select__clear svg { 70 | @apply h-5 w-5; 71 | } 72 | 73 | .custom-select__button-content { 74 | @apply flex-1 w-0 truncate; 75 | } 76 | 77 | .custom-select__menu { 78 | @apply absolute 79 | mt-2 80 | z-[--custom-select-menu-z-index] 81 | origin-top-right 82 | max-w-full 83 | w-full 84 | border 85 | shadow-md 86 | overflow-hidden 87 | outline-none; 88 | 89 | background-color: var(--custom-select-menu-bg); 90 | border-color: var(--custom-select-menu-border-color); 91 | border-radius: var(--custom-select-menu-border-radius); 92 | } 93 | 94 | .custom-select__menu-content { 95 | @apply overflow-x-hidden 96 | overflow-y-auto; 97 | 98 | max-height: var(--custom-select-max-menu-height); 99 | } 100 | 101 | .custom-select__option { 102 | @apply flex 103 | items-center 104 | justify-between 105 | cursor-default 106 | gap-2 107 | w-full 108 | px-4 109 | py-2 110 | text-sm 111 | transition-colors 112 | focus:outline-none; 113 | 114 | color: var(--custom-select-menu-color); 115 | } 116 | 117 | .custom-select__option--active { 118 | @apply cursor-pointer; 119 | 120 | background-color: var(--custom-select-option-active-bg); 121 | color: var(--custom-select-option-active-color); 122 | } 123 | 124 | .custom-select__option--selected { 125 | font-weight: var(--custom-select-option-selected-font-weight); 126 | } 127 | 128 | .custom-select__option--selected:not(.custom-select__option--active) { 129 | background-color: var(--custom-select-option-selected-bg); 130 | color: var(--custom-select-option-selected-color); 131 | } 132 | 133 | .custom-select__option:is(.custom-select__option--disabled, [disabled]) { 134 | @apply opacity-50 cursor-not-allowed; 135 | } 136 | 137 | .custom-select__selected-icon svg { 138 | @apply h-5 w-5; 139 | } 140 | 141 | .custom-select__selected-icon { 142 | color: var(--custom-select-selected-icon-color); 143 | } 144 | 145 | .custom-select__option--active .custom-select__selected-icon { 146 | color: var(--custom-select-selected-icon-hover-color); 147 | } 148 | 149 | .custom-select__search { 150 | @apply w-full 151 | sticky 152 | top-0 153 | py-2 154 | px-2.5 155 | border-b 156 | sm:text-sm 157 | z-[2]; 158 | 159 | background-color: var(--custom-select-menu-bg); 160 | border-color: var(--custom-select-search-border-color); 161 | } 162 | 163 | .custom-select__search input { 164 | @apply w-full 165 | border-0 166 | bg-transparent 167 | focus:outline-none 168 | focus:ring-0; 169 | 170 | color: var(--input-color); 171 | } 172 | 173 | .custom-select__search input::placeholder { 174 | color: var(--input-placeholder-color); 175 | } 176 | 177 | .custom-select__button-tokens { 178 | @apply flex 179 | flex-wrap 180 | gap-1; 181 | } 182 | 183 | .custom-select__button-token { 184 | @apply inline-flex 185 | rounded-full 186 | px-2.5 187 | py-1 188 | break-all 189 | transition-colors 190 | gap-2 191 | items-center 192 | justify-between 193 | max-w-full 194 | text-xs; 195 | 196 | background-color: var(--custom-select-button-token-bg); 197 | color: var(--custom-select-button-token-color); 198 | } 199 | 200 | .custom-select__button-token.custom-select__button-token--deleteable { 201 | @apply pr-1.5; 202 | } 203 | 204 | .custom-select__button-token:not(.disabled):is(:hover, :focus) { 205 | @apply cursor-pointer outline-none; 206 | 207 | background-color: var(--custom-select-button-token-hover-bg); 208 | } 209 | 210 | .custom-select__button-token-delete svg { 211 | @apply h-3.5 w-3.5 opacity-75 transition-opacity; 212 | } 213 | 214 | .custom-select__button-token.disabled { 215 | @apply opacity-75; 216 | } 217 | 218 | .group:is(:hover, :focus) .custom-select__button-token-delete svg { 219 | @apply opacity-100; 220 | } 221 | 222 | .custom-select__opt-group { 223 | @apply text-sm pointer-events-none border-b; 224 | 225 | font-weight: var(--custom-select-opt-group-font-weight); 226 | color: var(--custom-select-opt-group-color); 227 | background-color: var(--custom-select-opt-group-bg); 228 | border-color: var(--custom-select-opt-group-border-color); 229 | } 230 | 231 | .custom-select__no-results { 232 | @apply text-sm py-2 px-2.5; 233 | 234 | color: var(--custom-select-menu-color); 235 | } 236 | } 237 | 238 | @layer utilities { 239 | /* sizing */ 240 | .custom-select__button--sm { 241 | padding: var(--input-padding-y-sm) var(--input-padding-x-sm); 242 | @apply sm:text-xs; 243 | } 244 | 245 | .custom-select__button--sm .custom-select__button-token { 246 | @apply py-0.5; 247 | } 248 | 249 | .custom-select__button--md { 250 | padding: var(--input-padding-y) var(--input-padding-x); 251 | @apply sm:text-sm; 252 | } 253 | 254 | .custom-select__button--lg { 255 | padding: var(--input-padding-y-lg) var(--input-padding-x-lg); 256 | @apply sm:text-base; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /resources/css/errors.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .has-error label { 3 | color: var(--input-error-label-color); 4 | } 5 | 6 | .form-error { 7 | @apply mt-1 8 | text-sm; 9 | 10 | color: var(--input-error-message-color); 11 | } 12 | 13 | .input-error, 14 | .has-error :is(.form-text, .form-select, .custom-select__button, .file-upload__input) { 15 | --input-border-color: var(--input-error-border-color); 16 | --input-color: var(--input-error-color); 17 | --input-placeholder-color: var(--input-error-placeholder-color); 18 | --input-background-color: var(--input-error-bg-color); 19 | --input-focus-border-color: var(--input-error-focus-border-color); 20 | } 21 | 22 | .has-error .file-upload__input::file-selector-button { 23 | --file-upload-button-bg: var(--file-upload-button-error-bg); 24 | --file-upload-button-color: var(--file-upload-button-error-color); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /resources/css/file-upload.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .file-upload { 3 | @apply flex 4 | items-center 5 | space-x-5; 6 | } 7 | 8 | .file-upload__input { 9 | @apply block 10 | w-full 11 | border 12 | shadow-none 13 | cursor-pointer 14 | sm:text-sm; 15 | 16 | border-color: var(--input-border-color); 17 | border-radius: var(--input-border-radius); 18 | background-color: var(--input-background-color); 19 | color: var(--input-color); 20 | } 21 | 22 | .file-upload__input:is(:focus, :active):not(:is([disabled], [readonly])) { 23 | @apply ring-1 outline-none; 24 | --tw-ring-color: var(--input-focus-border-color); 25 | border-color: var(--input-focus-border-color); 26 | } 27 | 28 | .file-upload__input::file-selector-button { 29 | @apply cursor-pointer; 30 | border-radius: var(--input-border-radius); 31 | @apply rounded-r-none border-0 mr-4; 32 | 33 | padding: var(--input-padding-y) var(--input-padding-x); 34 | color: var(--file-upload-button-color); 35 | background-color: var(--file-upload-button-bg); 36 | font-weight: var(--file-upload-button-font-weight); 37 | } 38 | 39 | .file-upload__input:is([disabled], [readonly]) { 40 | @apply cursor-not-allowed; 41 | 42 | background-color: var(--input-disabled-bg-color); 43 | border-color: var(--input-disabled-border-color); 44 | } 45 | 46 | .file-upload__input:is([disabled], [readonly])::file-selector-button { 47 | @apply cursor-not-allowed opacity-50; 48 | 49 | background-color: var(--file-upload-button-disabled-bg); 50 | } 51 | 52 | .file-upload__badge { 53 | @apply inline-flex 54 | items-center 55 | px-2.5 56 | py-0.5 57 | rounded-full 58 | text-xs 59 | font-medium; 60 | 61 | background-color: var(--file-upload-badge-bg); 62 | color: var(--file-upload-badge-color); 63 | } 64 | 65 | .file-upload__percent { 66 | @apply text-right; 67 | } 68 | 69 | .file-upload__percent > span { 70 | @apply text-xs 71 | font-semibold 72 | inline-block; 73 | 74 | color: var(--file-upload-percent-color); 75 | } 76 | 77 | .file-upload__progress--native { 78 | @apply overflow-hidden; 79 | } 80 | 81 | .file-upload__progress--native progress { 82 | -webkit-appearance: none; 83 | -moz-appearance: none; 84 | @apply appearance-none 85 | block 86 | border-0 87 | rounded 88 | mb-4 89 | p-0 90 | w-full; 91 | 92 | height: var(--file-upload-progress-height); 93 | } 94 | 95 | /* For now, only webkit browsers seem to be able to style the non-filled in part of the progress bar */ 96 | .file-upload__progress--native progress::-webkit-progress-bar { 97 | @apply rounded; 98 | 99 | background-color: var(--file-upload-progress-bg); 100 | } 101 | 102 | /* We cannot combine the selectors for the browser prefixes because it breaks each browser's progress bar for some reason... */ 103 | .file-upload__progress--native progress::-webkit-progress-value { 104 | @apply rounded-l; 105 | 106 | background-color: var(--file-upload-progress-filled-bg); 107 | } 108 | 109 | .file-upload__progress--native progress::-moz-progress-bar { 110 | @apply rounded-l; 111 | 112 | background-color: var(--file-upload-progress-filled-bg); 113 | } 114 | 115 | .file-upload__progress--native progress::-ms-fill { 116 | @apply rounded-l; 117 | 118 | background-color: var(--file-upload-progress-filled-bg); 119 | } 120 | 121 | /* non native progress bar */ 122 | .file-upload__progress { 123 | @apply overflow-hidden 124 | mb-4 125 | text-xs 126 | flex 127 | rounded; 128 | 129 | height: var(--file-upload-progress-height); 130 | background-color: var(--file-upload-progress-bg); 131 | } 132 | 133 | .file-upload__progress > div { 134 | @apply shadow-none 135 | flex 136 | flex-col 137 | justify-center 138 | whitespace-nowrap 139 | text-white; 140 | 141 | background-color: var(--file-upload-progress-filled-bg); 142 | } 143 | } 144 | 145 | /* sizing */ 146 | @layer utilities { 147 | .file-upload__input--sm { 148 | @apply sm:text-xs; 149 | } 150 | 151 | .file-upload__input--sm::file-selector-button { 152 | padding: var(--input-padding-y-sm) var(--input-padding-x-sm); 153 | @apply sm:text-xs; 154 | } 155 | 156 | .file-upload__input--md { 157 | @apply sm:text-sm; 158 | } 159 | 160 | .file-upload__input--md::file-selector-button { 161 | padding: var(--input-padding-y) var(--input-padding-x); 162 | @apply sm:text-sm; 163 | } 164 | 165 | .file-upload__input--lg { 166 | @apply sm:text-base; 167 | } 168 | 169 | .file-upload__input--lg::file-selector-button { 170 | padding: var(--input-padding-y-lg) var(--input-padding-x-lg); 171 | @apply sm:text-base; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /resources/css/filepond.css: -------------------------------------------------------------------------------- 1 | /* filepond overrides */ 2 | .filepond--root { 3 | @apply cursor-pointer; 4 | } 5 | 6 | .filepond--panel-root { 7 | @apply max-w-full transition; 8 | 9 | border-style: var(--filepond-border-style); 10 | border-width: var(--filepond-border-width); 11 | border-color: var(--input-border-color); 12 | border-radius: var(--input-border-radius); 13 | background-color: var(--filepond-bg); 14 | 15 | pointer-events: unset; 16 | } 17 | 18 | .filepond--root:hover .filepond--panel-root { 19 | background-color: var(--filepond-hover-bg); 20 | } 21 | 22 | .filepond--label-action { 23 | @apply transition-colors hover:opacity-75 focus:opacity-75; 24 | 25 | color: var(--filepond-label-link-color); 26 | text-decoration-color: var(--filepond-label-link-color); 27 | } 28 | 29 | .fc-filepond--desc { 30 | @apply cursor-pointer; 31 | 32 | color: var(--filepond-desc-color); 33 | } 34 | 35 | .fc-filepond--sub-desc { 36 | @apply text-xs !important; 37 | } 38 | 39 | /* error states */ 40 | :is(.has-error, .input-error) .filepond--panel-root { 41 | border-color: var(--input-error-border-color); 42 | } 43 | 44 | :is(.has-error, .input-error) .filepond--root:hover .filepond--panel-root { 45 | background-color: var(--input-error-bg-color); 46 | } 47 | -------------------------------------------------------------------------------- /resources/css/flatpickr.css: -------------------------------------------------------------------------------- 1 | /* flatpickr overrides */ 2 | 3 | .flatpickr-calendar { 4 | width: 345px !important; 5 | } 6 | 7 | .flatpickr-calendar.open { 8 | max-height: 650px; 9 | } 10 | 11 | .flatpickr-months { 12 | @apply relative py-3 px-2 items-center; 13 | 14 | background-color: var(--flatpickr-header-bg); 15 | color: var(--flatpickr-header-color); 16 | } 17 | 18 | .flatpickr-months .flatpickr-prev-month, 19 | .flatpickr-months .flatpickr-next-month { 20 | @apply absolute transition rounded-full top-1/2 transform -translate-y-1/2 !important; 21 | } 22 | 23 | .flatpickr-months .flatpickr-prev-month.flatpickr-prev-month { 24 | @apply left-1 !important; 25 | } 26 | 27 | .flatpickr-months .flatpickr-next-month.flatpickr-next-month { 28 | @apply right-1 !important; 29 | } 30 | 31 | .flatpickr-months .flatpickr-prev-month:hover svg, 32 | .flatpickr-months .flatpickr-next-month:hover svg { 33 | fill: var(--flatpickr-nav-button-hover-color) !important; 34 | } 35 | 36 | .flatpickr-months .flatpickr-prev-month svg, 37 | .flatpickr-months .flatpickr-next-month svg { 38 | fill: var(--flatpickr-header-color); 39 | opacity: .8; 40 | } 41 | 42 | .flatpickr-months .flatpickr-prev-month:hover, 43 | .flatpickr-months .flatpickr-next-month:hover { 44 | background-color: var(--flatpickr-nav-button-hover-bg); 45 | } 46 | 47 | .flatpickr-months .flatpickr-month { 48 | @apply flex w-full; 49 | } 50 | 51 | .flatpickr-months .flatpickr-month .flatpickr-current-month { 52 | @apply w-full relative; 53 | left: unset; 54 | transform: unset; 55 | color: var(--flatpickr-header-color); 56 | } 57 | 58 | .flatpickr-months .flatpickr-month .flatpickr-current-month .flatpickr-monthDropdown-months { 59 | @apply pl-0 pr-1 mr-1; 60 | } 61 | 62 | .flatpickr-current-month .numInputWrapper span.arrowUp:after { 63 | border-bottom-color: rgba(255, 255, 255, .9); 64 | } 65 | 66 | .flatpickr-current-month .numInputWrapper span.arrowDown:after { 67 | border-top-color: rgba(255, 255, 255, .9); 68 | } 69 | 70 | .flatpickr-innerContainer { 71 | @apply p-4; 72 | } 73 | 74 | .flatpickr-day.today:not(.selected) { 75 | @apply border-none rounded-full; 76 | 77 | color: var(--flatpickr-current-day-color); 78 | background-color: var(--flatpickr-current-day-bg); 79 | } 80 | -------------------------------------------------------------------------------- /resources/css/form-group.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .form-group--inline { 3 | @apply sm:grid 4 | sm:grid-cols-3 5 | sm:gap-4 6 | sm:items-start; 7 | } 8 | 9 | .form-group--mb { 10 | @apply mb-5 last:mb-0; 11 | } 12 | 13 | .form-group--border { 14 | @apply sm:pt-5 15 | sm:border-t 16 | first:sm:pt-0 17 | first:sm:border-none; 18 | 19 | border-color: var(--form-group-border-color); 20 | } 21 | 22 | .form-group__label-container:has(.form-group__hint) { 23 | @apply flex justify-between; 24 | } 25 | 26 | .form-group__content { 27 | @apply mt-1; 28 | } 29 | 30 | .form-group__content--inline { 31 | @apply sm:mt-0 sm:col-span-2; 32 | } 33 | 34 | .form-group--inline .form-group__label-container .form-group__hint { 35 | @apply inline-block sm:hidden; 36 | } 37 | 38 | .form-group__label-container .form-group__hint { 39 | @apply ml-2; 40 | } 41 | 42 | .form-group__label-container--inline:not(.form-group__label-container--checkbox-group) label { 43 | @apply sm:mt-px sm:pt-2; 44 | } 45 | 46 | .form-group__hint { 47 | font-size: var(--form-group-hint-text-size); 48 | line-height: var(--form-group-hint-line-height); 49 | color: var(--form-group-hint-color); 50 | } 51 | 52 | .form-group__hint--inline { 53 | @apply mt-1 54 | hidden 55 | sm:block; 56 | } 57 | 58 | .form-help { 59 | @apply mt-2; 60 | 61 | font-size: var(--form-group-hint-text-size); 62 | line-height: var(--form-group-hint-line-height); 63 | color: var(--form-group-hint-color); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /resources/css/index.css: -------------------------------------------------------------------------------- 1 | @import 'variables.css'; 2 | @import 'form-group.css'; 3 | @import 'checkbox-group.css'; 4 | @import 'custom-select.css'; 5 | @import 'file-upload.css'; 6 | @import 'filepond.css'; 7 | @import 'flatpickr.css'; 8 | @import 'quill.css'; 9 | @import 'label.css'; 10 | @import 'input.css'; 11 | @import 'choice.css'; 12 | @import 'select.css'; 13 | @import 'switch-toggle.css'; 14 | @import 'tree-select.css'; 15 | @import 'addons.css'; 16 | @import 'errors.css'; 17 | -------------------------------------------------------------------------------- /resources/css/input.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | /* clears the 'X' from Internet Explorer */ 3 | input.busy[type="search"]::-ms-clear, 4 | input.busy[type="search"]::-ms-reveal { 5 | display: none; 6 | width: 0; 7 | height: 0; 8 | } 9 | 10 | /* clears the 'X' from chrome */ 11 | input.busy[type="search"]::-webkit-search-decoration, 12 | input.busy[type="search"]::-webkit-search-cancel-button, 13 | input.busy[type="search"]::-webkit-search-results-button, 14 | input.busy[type="search"]::-webkit-search-results-decoration { 15 | display: none; 16 | } 17 | 18 | .form-text-container { 19 | @apply flex 20 | relative 21 | shadow-sm; 22 | 23 | border-radius: var(--input-border-radius); 24 | } 25 | 26 | .form-text { 27 | @apply flex-1 28 | block 29 | w-full 30 | border 31 | shadow-none 32 | sm:text-sm; 33 | 34 | background-color: var(--input-background-color); 35 | padding: var(--input-padding-y) var(--input-padding-x); 36 | border-radius: var(--input-border-radius); 37 | border-color: var(--input-border-color); 38 | color: var(--input-color); 39 | } 40 | 41 | .form-text::placeholder { 42 | color: var(--input-placeholder-color); 43 | } 44 | 45 | .form-text:focus { 46 | --tw-ring-color: var(--input-focus-border-color); 47 | border-color: var(--input-focus-border-color); 48 | } 49 | 50 | .form-text:is([disabled], [readonly]) { 51 | background-color: var(--input-disabled-bg-color); 52 | border-color: var(--input-disabled-border-color); 53 | } 54 | } 55 | 56 | /* sizing */ 57 | @layer utilities { 58 | .form-input--sm .form-text { 59 | padding: var(--input-padding-y-sm) var(--input-padding-x-sm); 60 | @apply sm:text-xs; 61 | } 62 | 63 | .form-input--md .form-text { 64 | padding: var(--input-padding-y) var(--input-padding-x); 65 | @apply sm:text-sm; 66 | } 67 | 68 | .form-input--lg .form-text { 69 | padding: var(--input-padding-y-lg) var(--input-padding-x-lg); 70 | @apply sm:text-base; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /resources/css/label.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .form-label { 3 | @apply block; 4 | 5 | font-size: var(--label-text-size); 6 | font-weight: var(--label-font-weight); 7 | line-height: var(--label-line-height); 8 | color: var(--label-color); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /resources/css/quill.css: -------------------------------------------------------------------------------- 1 | /* quill style overrides */ 2 | 3 | /* errors */ 4 | .has-error :is(.ql-container, .ql-toolbar).ql-snow { 5 | border-color: var(--input-error-border-color); 6 | } 7 | -------------------------------------------------------------------------------- /resources/css/select.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .form-select[multiple] { 3 | background-image: none; 4 | overflow: auto; 5 | } 6 | 7 | .form-select { 8 | @apply pr-[--select-padding-right]; 9 | } 10 | 11 | .form-select + :is(.trailing-inline-addon, .trailing-icon) { 12 | @apply right-5; 13 | } 14 | 15 | .form-select:has(+ .trailing-icon) { 16 | @apply pr-12; 17 | } 18 | } 19 | 20 | /* sizing */ 21 | @layer utilities { 22 | .form-input--sm .form-select { 23 | @apply pr-[--select-padding-right-sm]; 24 | } 25 | 26 | .form-input--md .form-select { 27 | @apply pr-[--select-padding-right]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /resources/css/switch-toggle.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .switch-toggle-container { 3 | @apply relative 4 | inline-flex 5 | items-center 6 | cursor-pointer; 7 | } 8 | 9 | .switch-toggle__label { 10 | @apply text-sm; 11 | 12 | font-weight: var(--switch-toggle-label-font-weight); 13 | color: var(--switch-toggle-label-color); 14 | } 15 | 16 | .switch-toggle__label--left { 17 | @apply mr-3; 18 | } 19 | 20 | .switch-toggle__label--right { 21 | @apply ml-3; 22 | } 23 | 24 | .switch-toggle { 25 | @apply relative 26 | rounded-full; 27 | 28 | background-color: var(--switch-toggle-bg); 29 | width: var(--switch-toggle-width); 30 | height: var(--switch-toggle-height); 31 | } 32 | 33 | .switch-toggle:after { 34 | @apply absolute 35 | bg-white 36 | border 37 | border-slate-300 38 | rounded-full 39 | transition-all; 40 | content: ''; 41 | top: 2px; 42 | left: 2px; 43 | 44 | width: var(--switch-toggle-circle-size); 45 | height: var(--switch-toggle-circle-size); 46 | } 47 | 48 | .peer:focus ~ .switch-toggle { 49 | @apply outline-none 50 | ring-4; 51 | 52 | --tw-ring-color: var(--switch-toggle-ring-color); 53 | } 54 | 55 | .peer:checked ~ .switch-toggle { 56 | background-color: var(--switch-toggle-bg-checked); 57 | } 58 | 59 | .peer:checked ~ .switch-toggle:after { 60 | @apply translate-x-full 61 | border-white; 62 | } 63 | 64 | .peer:disabled ~ .switch-toggle { 65 | @apply cursor-not-allowed opacity-50; 66 | } 67 | 68 | .peer:disabled ~ .switch-toggle__label { 69 | @apply cursor-not-allowed opacity-75; 70 | 71 | color: var(--switch-toggle-disabled-label-color); 72 | } 73 | 74 | .switch-toggle__icon { 75 | @apply absolute 76 | hidden 77 | z-[1]; 78 | 79 | top: 5px; 80 | width: var(--switch-toggle-icon-size); 81 | height: var(--switch-toggle-icon-size); 82 | } 83 | 84 | .switch-toggle__icon--off { 85 | @apply inline-block; 86 | left: 5px; 87 | } 88 | 89 | .switch-toggle__icon--on { 90 | right: 5px; 91 | color: var(--switch-toggle-bg-checked); 92 | } 93 | 94 | .peer:checked ~ .switch-toggle .switch-toggle__icon--off { 95 | @apply hidden; 96 | } 97 | 98 | .peer:checked ~ .switch-toggle .switch-toggle__icon--on { 99 | @apply inline-block; 100 | } 101 | 102 | .switch-toggle--short { 103 | height: var(--switch-toggle-short-height); 104 | } 105 | 106 | .switch-toggle--short:after { 107 | top: -3px; 108 | left: -1px; 109 | } 110 | 111 | .peer:focus ~ .switch-toggle--short { 112 | @apply ring-2 ring-offset-4; 113 | } 114 | 115 | .peer:checked ~ .switch-toggle--short:after { 116 | left: 5px; 117 | @apply border-slate-300; 118 | } 119 | } 120 | 121 | @layer utilities { 122 | .switch-toggle--sm { 123 | width: var(--switch-toggle-sm-width); 124 | height: var(--switch-toggle-sm-height); 125 | } 126 | 127 | .switch-toggle--sm:after { 128 | width: var(--switch-toggle-sm-circle-size); 129 | height: var(--switch-toggle-sm-circle-size); 130 | } 131 | 132 | .switch-toggle--sm .switch-toggle__icon { 133 | top: 4px; 134 | width: var(--switch-toggle-sm-icon-size); 135 | height: var(--switch-toggle-sm-icon-size); 136 | } 137 | 138 | .switch-toggle--sm .switch-toggle__icon--off { 139 | left: 4px; 140 | } 141 | 142 | .switch-toggle--sm .switch-toggle__icon--on { 143 | right: 4px; 144 | } 145 | 146 | .switch-toggle--md { 147 | width: var(--switch-toggle-width); 148 | height: var(--switch-toggle-height); 149 | } 150 | 151 | .switch-toggle--md:after { 152 | width: var(--switch-toggle-circle-size); 153 | height: var(--switch-toggle-circle-size); 154 | } 155 | 156 | .switch-toggle--lg { 157 | width: var(--switch-toggle-lg-width); 158 | height: var(--switch-toggle-lg-height); 159 | } 160 | 161 | .switch-toggle--lg:after { 162 | width: var(--switch-toggle-lg-circle-size); 163 | height: var(--switch-toggle-lg-circle-size); 164 | @apply top-0.5; 165 | left: 3px; 166 | } 167 | 168 | .peer:checked ~ .switch-toggle--lg:after { 169 | left: 5px; 170 | } 171 | 172 | .switch-toggle--lg .switch-toggle__icon { 173 | top: 6px; 174 | width: var(--switch-toggle-lg-icon-size); 175 | height: var(--switch-toggle-lg-icon-size); 176 | } 177 | 178 | .switch-toggle--lg .switch-toggle__icon--off { 179 | left: 6px; 180 | } 181 | 182 | .switch-toggle--lg .switch-toggle__icon--on { 183 | right: 6px; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /resources/css/tree-select.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .tree-select__has-child-icon { 3 | @apply w-4 4 | h-4 5 | flex 6 | items-center 7 | justify-center 8 | opacity-50 9 | hover:opacity-100; 10 | } 11 | 12 | .tree-select__has-child-icon svg { 13 | @apply h-4 w-4; 14 | } 15 | 16 | .tree-select__has-child-icon.expanded svg { 17 | @apply transform 18 | rotate-90; 19 | } 20 | 21 | .tree-select__option + .tree-select__children .tree-select__option { 22 | padding-left: calc((var(--level) * theme('spacing.5')) + theme('spacing.5')); 23 | } 24 | 25 | .tree-select__children { 26 | @apply relative; 27 | } 28 | 29 | .tree-select__children:before { 30 | @apply absolute 31 | top-0 32 | bottom-5 33 | left-0 34 | border-l 35 | transition-colors 36 | z-[2]; 37 | 38 | content: ''; 39 | border-style: var(--tree-select-child-border-style); 40 | border-color: var(--tree-select-child-border-color); 41 | } 42 | 43 | .tree-select__option + .tree-select__children:before { 44 | left: calc((var(--level) * theme('spacing.6')) + theme('spacing.6')); 45 | } 46 | 47 | .tree-select__children .tree-select__option:before { 48 | @apply absolute 49 | transition-colors 50 | border-t; 51 | 52 | content: ''; 53 | border-style: var(--tree-select-child-border-style); 54 | width: 1rem; 55 | left: calc(((var(--level) - 1) * theme('spacing.6')) + theme('spacing.6')); 56 | border-color: var(--tree-select-child-border-color); 57 | } 58 | 59 | .tree-select__option:not(:has(+ .tree-select__children)):before { 60 | width: 1.6rem; 61 | } 62 | 63 | .tree-select__children:hover:before, 64 | .tree-select__children.group:hover :is(.tree-select__children, .tree-select__option):before, 65 | .tree-select__children:has(:is(.custom-select__option--active, .custom-select__option--selected)) :is(.tree-select__children, .tree-select__option):before, 66 | .tree-select__children:has(:is(.custom-select__option--active, .custom-select__option--selected)):before { 67 | border-color: var(--tree-select-child-hover-border-color); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /resources/js/components/custom-select.js: -------------------------------------------------------------------------------- 1 | import { generateContext } from '../util/customSelectContext'; 2 | import selectPopper from '../mixins/selectPopper'; 3 | import { 4 | buttonDirective, 5 | clearButtonDirective, 6 | labelDirective, 7 | optionsDirective, 8 | optionDirective, 9 | searchDirective, 10 | selectData, 11 | tokenDirective, 12 | } from '../mixins/select'; 13 | import { rootMagic, optionMagic } from '../mixins/selectMagic'; 14 | 15 | export default function (Alpine) { 16 | Alpine.data('customSelect', config => { 17 | return { 18 | ...selectPopper, 19 | 20 | ...selectData(config.__el, Alpine, config), 21 | 22 | __generateContext(el, Alpine, config) { 23 | return generateContext({ 24 | multiple: this.__isMultiple, 25 | orientation: this.__orientation, 26 | __wire: config.__wire, 27 | __wireSearch: Alpine.bound(el, 'livewire-search'), 28 | __config: config.__config ?? {}, 29 | Alpine, 30 | }); 31 | }, 32 | } 33 | }); 34 | 35 | Alpine.directive('custom-select', (el, directive, { cleanup }) => { 36 | switch (directive.value) { 37 | case 'button': 38 | handleButton(el, Alpine); 39 | break; 40 | case 'label': 41 | handleLabel(el, Alpine); 42 | break; 43 | case 'clear': 44 | handleClearButton(el, Alpine); 45 | break; 46 | case 'options': 47 | handleOptions(el, Alpine); 48 | break; 49 | case 'option': 50 | handleOption(el, Alpine); 51 | 52 | // We need to notify the context that the option has left the DOM. 53 | cleanup(() => { 54 | const parent = el.closest('[x-data]'); 55 | 56 | parent && Alpine.$data(parent).__context.destroyItem(el); 57 | }); 58 | 59 | break; 60 | case 'search': 61 | handleSearch(el, Alpine); 62 | break; 63 | case 'token': 64 | handleToken(el, Alpine); 65 | break; 66 | 67 | default: 68 | throw new Error(`Unknown custom-select directive value: ${directive.value}`); 69 | } 70 | }); 71 | 72 | Alpine.magic('customSelect', el => { 73 | return rootMagic(el, Alpine); 74 | }); 75 | 76 | Alpine.magic('customSelectOption', el => { 77 | return optionMagic( 78 | el, 79 | Alpine, 80 | (data, context, optionEl) => { 81 | return { 82 | get isOptGroup() { 83 | return Alpine.bound(optionEl, 'is-opt-group'); 84 | }, 85 | }; 86 | }, 87 | () => { 88 | return { 89 | isOptGroup: false, 90 | }; 91 | }, 92 | ); 93 | }); 94 | } 95 | 96 | function handleLabel(el, Alpine) { 97 | Alpine.bind(el, { 98 | ...labelDirective(el, Alpine), 99 | }); 100 | } 101 | 102 | function handleButton(el, Alpine) { 103 | Alpine.bind(el, { 104 | ...buttonDirective(el, Alpine), 105 | }); 106 | } 107 | 108 | function handleSearch(el, Alpine) { 109 | Alpine.bind(el, { 110 | ...searchDirective(el, Alpine), 111 | }); 112 | } 113 | 114 | function handleOptions(el, Alpine) { 115 | Alpine.bind(el, { 116 | ...optionsDirective(el, Alpine), 117 | }); 118 | } 119 | 120 | function handleOption(el, Alpine) { 121 | Alpine.bind(el, { 122 | ...optionDirective(el, Alpine, 'custom'), 123 | }); 124 | } 125 | 126 | function handleToken(el, Alpine) { 127 | Alpine.bind(el, { 128 | ...tokenDirective(el, Alpine), 129 | }); 130 | } 131 | 132 | function handleClearButton(el, Alpine) { 133 | Alpine.bind(el, { 134 | ...clearButtonDirective(el, Alpine, 'custom'), 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /resources/js/components/filepond.js: -------------------------------------------------------------------------------- 1 | export default function (Alpine) { 2 | Alpine.data('filepond', ({ __value, __this, __wireModel, options, __config, id }) => { 3 | return { 4 | __ready: false, 5 | __pond: null, 6 | __processingFiles: false, 7 | __value, 8 | 9 | init() { 10 | if (typeof window.FilePond?.create !== 'function') { 11 | throw new Error(`filepond requires FilePond to be loaded. See https://pqina.nl/filepond/docs/getting-started/installation/javascript/`); 12 | } 13 | 14 | queueMicrotask(() => { 15 | this.__ready = true; 16 | 17 | let pondOptions = { ...options }; 18 | 19 | if (__this && __wireModel) { 20 | pondOptions.server = { 21 | process: (fieldName, file, metadata, load, error, progress, abort, transfer, options) => { 22 | __this.upload(__wireModel, file, load, error, progress); 23 | }, 24 | revert: (filename, load) => { 25 | __this.removeUpload(__wireModel, filename, load); 26 | }, 27 | }; 28 | 29 | // To prevent our wire:model watcher from pre-maturely removing files from 30 | // filepond, we need to tell our component we are still processing. 31 | pondOptions.onaddfilestart = () => this.__processingFiles = true; 32 | 33 | pondOptions.onprocessfiles = () => this.__processingFiles = false; 34 | } 35 | 36 | if (__this) { 37 | // Listen for livewire components to emit a file-pond-clear event. 38 | __this.on('file-pond-clear', (desiredId) => this.__clear(desiredId)); 39 | } 40 | 41 | pondOptions = { ...pondOptions, ...__config(this, options, pondOptions) }; 42 | 43 | this.__pond = window.FilePond.create(this.$refs.input, pondOptions); 44 | }); 45 | 46 | this.$watch('__value', newValue => { 47 | if (! this.__ready) { 48 | return; 49 | } 50 | 51 | if (options.allowMultiple) { 52 | // If filepond is processing files, we shouldn't do anything. 53 | if (this.__processingFiles) { 54 | return; 55 | } 56 | 57 | // If the new value is null or undefined, we'll just remove everything from filepond. 58 | if (! newValue) { 59 | return this.__clear(); 60 | } 61 | 62 | // Remove files from filepond that are not present in the new value. 63 | const serverIds = Array.isArray(newValue) ? newValue : JSON.parse(String(newValue).split('livewire-files:')[1]); 64 | 65 | this.__pond.getFiles().forEach(f => { 66 | if (! serverIds.includes(f.serverId)) { 67 | this.__pond.removeFile(f.id); 68 | } 69 | }); 70 | 71 | return; 72 | } 73 | 74 | if (! newValue) { 75 | this.__clear(); 76 | } 77 | }); 78 | }, 79 | 80 | __clear(eventId) { 81 | if (! eventId || (eventId === id)) { 82 | clearFilepond(this.__pond, options.allowMultiple); 83 | } 84 | }, 85 | }; 86 | }); 87 | } 88 | 89 | function clearFilepond(instance, allowMultiple) { 90 | if (allowMultiple) { 91 | instance.getFiles().forEach(file => instance.removeFile(file.id)); 92 | } else { 93 | instance.removeFile(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /resources/js/components/index.js: -------------------------------------------------------------------------------- 1 | import customSelect from './custom-select'; 2 | import datePicker from './date-picker'; 3 | import filepond from './filepond'; 4 | import quill from './quill'; 5 | import switchToggle from './switch-toggle'; 6 | import treeSelect from './tree-select'; 7 | 8 | document.addEventListener('alpine:init', () => { 9 | customSelect(Alpine); 10 | datePicker(Alpine); 11 | filepond(Alpine); 12 | quill(Alpine); 13 | treeSelect(Alpine); 14 | Alpine.data('switchToggle', switchToggle); 15 | }); 16 | -------------------------------------------------------------------------------- /resources/js/components/quill.js: -------------------------------------------------------------------------------- 1 | export default function (Alpine) { 2 | Alpine.data('quill', ({ __value, options, __config, onTextChange, onInit }) => { 3 | return { 4 | __ready: false, 5 | __value, 6 | __quill: undefined, 7 | 8 | init() { 9 | if (typeof window.Quill !== 'function') { 10 | throw new Error(`quill requires Quill to be loaded. See https://quilljs.com/docs/installation/`); 11 | } 12 | 13 | queueMicrotask(() => { 14 | this.__ready = true; 15 | 16 | this.__quill = new window.Quill(this.$refs.quill, this.__quillOptions()); 17 | 18 | this.__quill.root.innerHTML = this.__value; 19 | 20 | this.__quill.on('text-change', () => { 21 | if (typeof onTextChange === 'function') { 22 | const result = onTextChange(this); 23 | 24 | if (result === false) { 25 | return; 26 | } 27 | } 28 | 29 | this.__value = this.__quill.root.innerHTML; 30 | 31 | this.$dispatch('input', this.__value); 32 | }); 33 | 34 | if (options.autofocus) { 35 | this.$nextTick(() => { 36 | this.focus(); 37 | }); 38 | } 39 | 40 | if (typeof onInit === 'function') { 41 | onInit(this); 42 | } 43 | }); 44 | }, 45 | 46 | focus() { 47 | if (! this.__ready) { 48 | return; 49 | } 50 | 51 | this.__quill.focus(); 52 | }, 53 | 54 | __quillOptions() { 55 | let config = __config(this, options); 56 | let toolbarHandlers = {}; 57 | let modules = {}; 58 | 59 | if (config.hasOwnProperty('toolbarHandlers')) { 60 | toolbarHandlers = config.toolbarHandlers; 61 | delete config.toolbarHandlers; 62 | } 63 | 64 | if (config.hasOwnProperty('modules')) { 65 | modules = config.modules; 66 | delete config.modules; 67 | } 68 | 69 | return { 70 | theme: options.theme, 71 | readOnly: options.readOnly, 72 | placeholder: options.placeholder, 73 | modules: { 74 | toolbar: { 75 | container: options.toolbar, 76 | handlers: toolbarHandlers, 77 | }, 78 | ...modules, 79 | }, 80 | ...config, 81 | }; 82 | }, 83 | }; 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /resources/js/components/switch-toggle.js: -------------------------------------------------------------------------------- 1 | export default options => ({ 2 | value: false, 3 | onValue: true, 4 | offValue: false, 5 | ...options, 6 | 7 | get isPressed() { 8 | if (Array.isArray(this.value)) { 9 | return this.value.includes(this.onValue); 10 | } 11 | 12 | return this.value === this.onValue; 13 | }, 14 | 15 | toggle() { 16 | if (Array.isArray(this.value)) { 17 | this.isPressed 18 | ? this.value.splice(this.value.indexOf(this.onValue), 1) 19 | : this.value.push(this.onValue); 20 | } else { 21 | this.value = this.isPressed ? this.offValue : this.onValue; 22 | } 23 | 24 | this.$dispatch('input', this.value); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /resources/js/components/tree-select.js: -------------------------------------------------------------------------------- 1 | import { generateContext } from '../util/treeSelectContext'; 2 | import selectPopper from '../mixins/selectPopper'; 3 | import { 4 | buttonDirective, 5 | clearButtonDirective, 6 | labelDirective, 7 | optionsDirective, 8 | optionDirective, 9 | searchDirective, 10 | selectData, 11 | tokenDirective, 12 | } from '../mixins/select'; 13 | import { rootMagic, optionMagic } from '../mixins/selectMagic'; 14 | 15 | export default function (Alpine) { 16 | Alpine.data('treeSelect', config => { 17 | return { 18 | ...selectPopper, 19 | 20 | ...selectData(config.__el, Alpine, config), 21 | 22 | __type: 'tree', 23 | 24 | __generateContext(el, Alpine, config) { 25 | return generateContext({ 26 | multiple: this.__isMultiple, 27 | orientation: this.__orientation, 28 | __wire: config.__wire, 29 | __wireSearch: Alpine.bound(el, 'livewire-search'), 30 | __config: config.__config ?? {}, 31 | Alpine, 32 | }); 33 | }, 34 | }; 35 | }); 36 | 37 | Alpine.directive('tree-select', (el, directive, { cleanup }) => { 38 | switch (directive.value) { 39 | case 'button': 40 | handleButton(el, Alpine); 41 | break; 42 | case 'label': 43 | handleLabel(el, Alpine); 44 | break; 45 | case 'clear': 46 | handleClearButton(el, Alpine); 47 | break; 48 | case 'options': 49 | handleOptions(el, Alpine); 50 | break; 51 | case 'option': 52 | handleOption(el, Alpine); 53 | 54 | // We need to notify the context that the option has left the DOM. 55 | cleanup(() => { 56 | const parent = el.closest('[x-data]'); 57 | 58 | parent && Alpine.$data(parent).__context.destroyItem(el); 59 | }); 60 | 61 | break; 62 | case 'search': 63 | handleSearch(el, Alpine); 64 | break; 65 | case 'token': 66 | handleToken(el, Alpine); 67 | break; 68 | case 'child-toggle': 69 | handleChildToggle(el, Alpine); 70 | break; 71 | case 'children': 72 | handleChildren(el, Alpine); 73 | break; 74 | 75 | default: 76 | throw new Error(`Unknown tree-select directive: ${directive.value}`); 77 | } 78 | }); 79 | 80 | Alpine.magic('treeSelect', el => { 81 | return rootMagic( 82 | el, 83 | Alpine, 84 | data => { 85 | return { 86 | get hasExpandableOptions() { 87 | return Object.keys(data.__context.expandableEls).length > 0; 88 | }, 89 | }; 90 | }, 91 | ); 92 | }); 93 | 94 | Alpine.magic('treeSelectOption', el => { 95 | return optionMagic( 96 | el, 97 | Alpine, 98 | (data, context, optionEl) => { 99 | return { 100 | get hasChildren() { 101 | return optionEl.__children && optionEl.__children.length > 0; 102 | }, 103 | get isExpanded() { 104 | return context.isExpandedEl(optionEl); 105 | }, 106 | }; 107 | }, 108 | () => { 109 | return { 110 | hasChildren: false, 111 | }; 112 | }, 113 | ); 114 | }); 115 | } 116 | 117 | function handleButton(el, Alpine) { 118 | Alpine.bind(el, { 119 | ...buttonDirective(el, Alpine), 120 | }); 121 | } 122 | 123 | function handleLabel(el, Alpine) { 124 | Alpine.bind(el, { 125 | ...labelDirective(el, Alpine), 126 | }); 127 | } 128 | 129 | function handleClearButton(el, Alpine) { 130 | Alpine.bind(el, { 131 | ...clearButtonDirective(el, Alpine, 'tree'), 132 | }); 133 | } 134 | 135 | function handleOptions(el, Alpine) { 136 | Alpine.bind(el, { 137 | ...optionsDirective(el, Alpine), 138 | }); 139 | } 140 | 141 | function handleOption(el, Alpine) { 142 | Alpine.bind(el, { 143 | ...optionDirective(el, Alpine, 'tree'), 144 | 145 | 'data-tree-select-option': 'true', 146 | ':role'() { return 'option' }, 147 | 148 | 'x-init'() { 149 | const initCallback = () => { 150 | let value = Alpine.bound(el, 'value'); 151 | let disabled = Alpine.bound(el, 'disabled'); 152 | 153 | el.__level = Alpine.bound(el, 'level', 0); 154 | 155 | el.__optionKey = this.$data.__context.initItem(el, value, disabled); 156 | 157 | const childrenField = this.$data.__config.childrenField; 158 | if (value?.hasOwnProperty(childrenField)) { 159 | el.__children = value[childrenField]; 160 | } 161 | }; 162 | 163 | // Our $customSelectOption magic only seems to work with queueMicrotask on initial page load, 164 | // so if our component says it's ready, we'll just run the code to initialize the option right away. 165 | if (this.$data.__ready) { 166 | initCallback(); 167 | } else { 168 | queueMicrotask(initCallback); 169 | } 170 | }, 171 | }); 172 | } 173 | 174 | function handleSearch(el, Alpine) { 175 | Alpine.bind(el, { 176 | ...searchDirective(el, Alpine), 177 | }); 178 | } 179 | 180 | function handleToken(el, Alpine) { 181 | Alpine.bind(el, { 182 | ...tokenDirective(el, Alpine), 183 | }); 184 | } 185 | 186 | function handleChildToggle(el, Alpine) { 187 | Alpine.bind(el, { 188 | 'x-init'() { 189 | if (el.tagName.toLowerCase() !== 'button') { 190 | el.setAttribute('role', 'button'); 191 | } 192 | }, 193 | '@click.stop.prevent'() { 194 | let optionEl = Alpine.findClosest(el, i => i.__optionKey); 195 | 196 | optionEl && this.$data.__context.toggleExpandedEl(optionEl); 197 | }, 198 | }); 199 | } 200 | 201 | // We are using this directive to hide/show the children of an option because it is out of the scope 202 | // of where the $treeSelectOption magic will pick up on the state of the option. 203 | function handleChildren(el, Alpine) { 204 | Alpine.bind(el, { 205 | 'data-tree-select-children': 'true', 206 | 'x-data'() { 207 | return { 208 | __optionEl: undefined, 209 | init() { 210 | try { 211 | this.__optionEl = el.parentNode.querySelector('[data-tree-select-option="true"]'); 212 | } catch (e) {} 213 | }, 214 | get __isExpanded() { 215 | return this.__optionEl && this.$data.__context.isExpandedEl(this.__optionEl); 216 | } 217 | }; 218 | }, 219 | 'x-show'() { return this.$data.__isExpanded }, 220 | }); 221 | } 222 | -------------------------------------------------------------------------------- /resources/js/directives/form-group.js: -------------------------------------------------------------------------------- 1 | export default function (Alpine) { 2 | Alpine.directive('form-group', (el, directive) => { 3 | if (directive.value === 'label') { 4 | handleLabel(el, Alpine); 5 | } else { 6 | handleRoot(el, Alpine); 7 | } 8 | }); 9 | } 10 | 11 | function handleRoot(el, Alpine) { 12 | Alpine.bind(el, { 13 | 'x-id'() { return ['fc-label'] }, 14 | }); 15 | } 16 | 17 | function handleLabel(el, Alpine) { 18 | Alpine.bind(el, { 19 | '@click'() { 20 | const group = el.closest('[x-form-group]'); 21 | if (! group) { 22 | return; 23 | } 24 | 25 | // Check if there is a custom select in the form group. 26 | const customSelectButton = group.querySelector('[data-custom-select-button="true"]'); 27 | if (customSelectButton) { 28 | customSelectButton.focus({ preventScroll: true }); 29 | 30 | return; 31 | } 32 | 33 | // Check if there is a quill editor in the form group. 34 | const quill = group.querySelector('.quill-wrapper'); 35 | if (quill) { 36 | Alpine.$data(quill).focus(); 37 | } 38 | }, 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /resources/js/directives/index.js: -------------------------------------------------------------------------------- 1 | import formGroup from './form-group'; 2 | import textareaResize from './textarea-resize'; 3 | 4 | document.addEventListener('alpine:init', () => { 5 | formGroup(Alpine); 6 | Alpine.plugin(textareaResize); 7 | }); 8 | -------------------------------------------------------------------------------- /resources/js/directives/textarea-resize.js: -------------------------------------------------------------------------------- 1 | const resize = el => { 2 | if (! el.style.minHeight) { 3 | el.style.minHeight = `${el.scrollHeight}px`; 4 | } 5 | 6 | el.style.height = 'auto'; 7 | el.style.height = `${el.scrollHeight}px`; 8 | }; 9 | 10 | const isHidden = el => { 11 | if (el.style.display === 'none') { 12 | return true; 13 | } 14 | 15 | return ! el.offsetParent; 16 | }; 17 | 18 | export default Alpine => { 19 | Alpine.directive('textarea-resize', (el, {}, { cleanup }) => { 20 | // We should not attempt to resize the textarea when it is not visible, 21 | // otherwise the height will be set to 0. 22 | if (! isHidden(el)) { 23 | resize(el); 24 | } 25 | 26 | const inputHandler = () => resize(el); 27 | 28 | el.addEventListener('input', inputHandler); 29 | 30 | cleanup(() => { 31 | el.removeEventListener('input', inputHandler); 32 | }); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /resources/js/index.js: -------------------------------------------------------------------------------- 1 | import './components'; 2 | import './directives'; 3 | -------------------------------------------------------------------------------- /resources/js/mixins/selectMagic.js: -------------------------------------------------------------------------------- 1 | export const rootMagic = (el, Alpine, callback, stubCallback) => { 2 | let data = Alpine.$data(el); 3 | 4 | if (typeof stubCallback !== 'function') { 5 | stubCallback = () => ({}); 6 | } 7 | 8 | if (typeof callback !== 'function') { 9 | callback = () => ({}); 10 | } 11 | 12 | if (! data.__ready) { 13 | return { 14 | isDisabled: false, 15 | isOpen: false, 16 | selected: null, 17 | active: null, 18 | selectedObject: null, 19 | ...stubCallback(data), 20 | }; 21 | } 22 | 23 | return { 24 | get isOpen() { 25 | return data.__isOpen; 26 | }, 27 | get isDisabled() { 28 | return data.__isDisabled; 29 | }, 30 | get isSearchable() { 31 | return data.__searchable; 32 | }, 33 | get selected() { 34 | return data.__value; 35 | }, 36 | get active() { 37 | return data.__context.active; 38 | }, 39 | get selectedObject() { 40 | return data.__richValue; 41 | }, 42 | get hasValue() { 43 | if (data.__isMultiple) { 44 | return data.__value && data.__value.length > 0; 45 | } 46 | 47 | return !! data.__value; 48 | }, 49 | get shouldShowClear() { 50 | // If the input is disabled or readonly, we can't clear. 51 | if (data.__isDisabled) { 52 | return false; 53 | } 54 | 55 | // If the select is not marked as optional, at least one value is required. 56 | if (data.__config.optional === false) { 57 | return false; 58 | } 59 | 60 | // If multi-select and minSelected is a number and at least 1, then we can't clear. 61 | if (data.__isMultiple && ! Number.isNaN(data.__config.minSelected) && data.__config.minSelected > 0) { 62 | return false; 63 | } 64 | 65 | return data.__isClearable && this.hasValue; 66 | }, 67 | get canSelectMore() { 68 | if (! data.__isMultiple) { 69 | return true; 70 | } 71 | 72 | // If maxSelected is not a number or less than one, then we can select as many as we want. 73 | if (Number.isNaN(data.__config.maxSelected) || data.__config.maxSelected < 1) { 74 | return true; 75 | } 76 | 77 | return data.__config.maxSelected > data.__value.length; 78 | }, 79 | get canDeselectOptions() { 80 | if (data.__isDisabled) { 81 | return false; 82 | } 83 | 84 | return data.__context.canRemoveOptions(); 85 | }, 86 | get hasOptions() { 87 | // We access searchableQuery here, so a change to it will trigger this getter to re-evaluate. 88 | data.__context.searchableQuery; 89 | 90 | return data.$refs.__options && 91 | data.$refs.__options.querySelectorAll('[role="option"]:not([data-hidden])').length > 0; 92 | }, 93 | get hasSearch() { 94 | return !! data.__context.searchableQuery; 95 | }, 96 | 97 | ...callback(data), 98 | }; 99 | }; 100 | 101 | export const optionMagic = (el, Alpine, callback, stubCallback) => { 102 | if (typeof stubCallback !== 'function') { 103 | stubCallback = () => ({}); 104 | } 105 | 106 | if (typeof callback !== 'function') { 107 | callback = () => ({}); 108 | } 109 | 110 | let data = Alpine.$data(el); 111 | 112 | let stub = { 113 | isDisabled: false, 114 | isSelected: false, 115 | isActive: false, 116 | ...stubCallback(data), 117 | }; 118 | 119 | if (! data.__ready) { 120 | return stub; 121 | } 122 | 123 | let optionEl = Alpine.findClosest(el, i => i.__optionKey); 124 | 125 | if (! optionEl) { 126 | return stub; 127 | } 128 | 129 | let context = data.__context; 130 | 131 | return { 132 | get isActive() { 133 | return context.isActiveEl(optionEl); 134 | }, 135 | get isSelected() { 136 | return context.isSelectedEl(optionEl); 137 | }, 138 | get isDisabled() { 139 | return context.isDisabledEl(optionEl); 140 | }, 141 | 142 | ...callback(data, context, optionEl), 143 | }; 144 | }; 145 | -------------------------------------------------------------------------------- /resources/js/mixins/selectPopper.js: -------------------------------------------------------------------------------- 1 | export default { 2 | __createPopper: undefined, 3 | __popper: undefined, 4 | 5 | __resetPopper() { 6 | if (this.__popper) { 7 | this.__popper.destroy(); 8 | this.__popper = null; 9 | } 10 | }, 11 | 12 | __popperConfig() { 13 | return { 14 | placement: 'bottom-start', 15 | strategy: this.__fixed ? 'fixed' : 'absolute', 16 | modifiers: [ 17 | { 18 | name: 'offset', 19 | options: { 20 | offset: [0, 10], 21 | }, 22 | }, 23 | { 24 | name: 'preventOverflow', 25 | options: { 26 | boundariesElement: this.$root, 27 | }, 28 | }, 29 | ], 30 | }; 31 | }, 32 | 33 | __initPopper() { 34 | this.__resetPopper(); 35 | this.__popper = this.__createPopper(this.$root, this.$refs.__options, this.__popperConfig()); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /resources/js/tailwind-plugins/switch-toggle.js: -------------------------------------------------------------------------------- 1 | module.exports = function ({ addComponents, theme, config }) { 2 | const colors = config('theme.colors', {}), 3 | expectedVariants = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'], 4 | toggles = {}; 5 | 6 | for (const colorName in colors) { 7 | const color = colors[colorName]; 8 | 9 | if (typeof color !== 'object') { 10 | continue; 11 | } 12 | 13 | if (! expectedVariants.every(key => Object.keys(color).includes(key))) { 14 | continue; 15 | } 16 | 17 | toggles[`.switch-toggle--${colorName}`] = { 18 | '--switch-toggle-bg-checked': color['600'], 19 | '--switch-toggle-ring-color': color['300'], 20 | '--switch-toggle-dark-ring-color': color['800'], 21 | }; 22 | } 23 | 24 | addComponents(toggles); 25 | }; 26 | -------------------------------------------------------------------------------- /resources/js/tailwind-plugins/util/addDarkVariant.js: -------------------------------------------------------------------------------- 1 | module.exports = (rootObject, selector, darkModeSelector, styles) => { 2 | if (darkModeSelector === '@media (prefers-color-scheme: dark)') { 3 | if (! rootObject.hasOwnProperty(selector)) { 4 | rootObject[selector] = {}; 5 | } 6 | 7 | rootObject[selector][darkModeSelector] = styles; 8 | 9 | return; 10 | } 11 | 12 | if (! rootObject.hasOwnProperty(darkModeSelector)) { 13 | rootObject[darkModeSelector] = {}; 14 | } 15 | 16 | rootObject[darkModeSelector][selector] = styles; 17 | }; 18 | -------------------------------------------------------------------------------- /resources/js/tailwind-plugins/util/darkModeSelector.js: -------------------------------------------------------------------------------- 1 | module.exports = darkMode => { 2 | // Example: { darkMode: ['class', '.is-dark'] } 3 | if (Array.isArray(darkMode)) { 4 | return darkMode[0] === 'class' 5 | ? darkMode[1] ?? '.dark' 6 | : '@media (prefers-color-scheme: dark)'; 7 | } 8 | 9 | return darkMode === 'class' 10 | ? '.dark' 11 | : '@media (prefers-color-scheme: dark)'; 12 | }; 13 | -------------------------------------------------------------------------------- /resources/js/util/customSelectContext.js: -------------------------------------------------------------------------------- 1 | import selectContext from '../mixins/selectContext'; 2 | 3 | export const generateContext = ({ multiple, orientation, __wire, __wireSearch, __config, Alpine }) => { 4 | return { 5 | ...selectContext(Alpine), 6 | 7 | /** 8 | * Select configuration. 9 | */ 10 | __multiple: multiple, 11 | __orientation: orientation, 12 | __wire, 13 | __wireSearch, 14 | __config, 15 | 16 | /** 17 | * Getters that don't work in the mixin for some reason... 18 | */ 19 | 20 | get isScrollingTo() { 21 | return this.scrollingCount > 0; 22 | }, 23 | 24 | get nonDisabledOrderedKeys() { 25 | return this.orderedKeys.filter(i => ! this.isDisabled(i)); 26 | }, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /resources/js/util/treeSelectContext.js: -------------------------------------------------------------------------------- 1 | import selectContext from '../mixins/selectContext'; 2 | import { keyByValue } from '../mixins/selectContext'; 3 | 4 | export const generateContext = ({ multiple, orientation, __wire, __wireSearch, __config, Alpine }) => { 5 | return { 6 | ...selectContext(Alpine), 7 | 8 | /** 9 | * Select configuration. 10 | */ 11 | 12 | __multiple: multiple, 13 | __orientation: orientation, 14 | __wire, 15 | __wireSearch, 16 | __config, 17 | 18 | /** 19 | * Tree select specific configuration. 20 | */ 21 | 22 | expandableEls: {}, 23 | expandedKeys: [], 24 | 25 | __itemBooted(el, value, disabled, key) { 26 | // We need to wait for the option to finish initializing before we can check 27 | // for the presence of children. 28 | queueMicrotask(() => { 29 | if (el.__children?.length) { 30 | this.expandableEls[key] = el; 31 | } 32 | }); 33 | }, 34 | 35 | __itemDestroyed(el, key) { 36 | if (this.expandableEls[key]) { 37 | delete this.expandableEls[key]; 38 | } 39 | 40 | if (this.expandedKeys.includes(key)) { 41 | this.expandedKeys.splice(this.expandedKeys.indexOf(key), 1); 42 | } 43 | }, 44 | 45 | isExpandedEl(el) { 46 | const key = keyByValue(this.elsByKey, el); 47 | 48 | if (! key) { 49 | return; 50 | } 51 | 52 | return this.expandedKeys.includes(key); 53 | }, 54 | 55 | toggleExpandedEl(el) { 56 | const key = keyByValue(this.elsByKey, el); 57 | 58 | if (! key) { 59 | return; 60 | } 61 | 62 | this.toggleExpanded(key); 63 | }, 64 | 65 | toggleExpanded(key) { 66 | if (this.expandedKeys.includes(key)) { 67 | this.expandedKeys.splice(this.expandedKeys.indexOf(key), 1); 68 | } else { 69 | this.expandedKeys.push(key); 70 | } 71 | }, 72 | 73 | expandChildren(key) { 74 | if (! this.expandedKeys.includes(key)) { 75 | this.expandedKeys.push(key); 76 | } 77 | }, 78 | 79 | collapseChildren(key) { 80 | if (this.expandedKeys.includes(key)) { 81 | this.expandedKeys.splice(this.expandedKeys.indexOf(key), 1); 82 | } 83 | }, 84 | 85 | __activateByKeyEvent(e) { 86 | if (! this.hasActive()) { 87 | return; 88 | } 89 | 90 | switch (e.key) { 91 | case ['ArrowRight', 'ArrowDown'][this.__orientation === 'vertical' ? 0 : 1]: 92 | e.preventDefault(); 93 | e.stopPropagation(); 94 | 95 | if (this.expandableEls[this.activeKey]) { 96 | this.expandChildren(this.activeKey); 97 | } 98 | 99 | return false; 100 | case ['ArrowLeft', 'ArrowUp'][this.__orientation === 'vertical' ? 0 : 1]: 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | 104 | if (this.expandableEls[this.activeKey]) { 105 | this.collapseChildren(this.activeKey); 106 | } 107 | 108 | return false; 109 | } 110 | }, 111 | 112 | /** 113 | * Getters that don't work in the mixin for some reason... 114 | */ 115 | 116 | get isScrollingTo() { 117 | return this.scrollingCount > 0; 118 | }, 119 | 120 | get nonDisabledOrderedKeys() { 121 | return this.orderedKeys.filter(i => ! this.isDisabled(i)); 122 | }, 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /resources/lang/en/messages.php: -------------------------------------------------------------------------------- 1 | 'Upload a file or drag and drop', 5 | 'file_upload_processing' => 'Processing...', 6 | 'custom_select_filter_placeholder' => 'Search...', 7 | 'custom_select_clear_button' => 'Clear selected', 8 | 'custom_select_placeholder' => 'Select an option', 9 | 'custom_select_empty_text' => 'No options available...', 10 | 'custom_select_no_results' => 'No results found...', 11 | 'date_picker_placeholder' => 'Y-m-d', 12 | 'timezone_select_placeholder' => 'Select a timezone', 13 | 'optional' => 'Optional', 14 | 'date_picker_toggle_icon_title' => 'Select a date', 15 | 'date_picker_clear_button' => 'Clear selected', 16 | 'password_show_toggle_title' => 'Show', 17 | 'password_hide_toggle_title' => 'Hide', 18 | ]; 19 | -------------------------------------------------------------------------------- /resources/views/components/choice/checkbox-group.blade.php: -------------------------------------------------------------------------------- 1 |
class($classes()) }} 2 | @unless ($stacked) style="--fc-checkbox-grid-cols: {{ $gridCols }};" @endunless 3 | > 4 | {{ $slot }} 5 |
6 | -------------------------------------------------------------------------------- /resources/views/components/choice/checkbox-or-radio.blade.php: -------------------------------------------------------------------------------- 1 | @aware(['inputSize' => null]) 2 | 3 |
4 | @includeWhen($labelLeft, 'form-components::components.choice.partials.label') 5 | 6 |
$labelLeft, 9 | ])> 10 | except(['aria-describedby', 'type'])->class($inputClass()) }} 12 | type="{{ $type }}" 13 | @if ($name) name="{{ $name }}" @endif 14 | @if ($id) id="{{ $id }}" @endif 15 | @if ($hasErrorsAndShow($name)) 16 | aria-invalid="true" 17 | @endif 18 | {!! $ariaDescribedBy() !!} 19 | {{ $extraAttributes ?? '' }} 20 | @if ($value) value="{{ $value }}" @endif 21 | @checked($checked && ! $hasBoundModel()) 22 | /> 23 |
24 | 25 | @includeWhen(! $labelLeft, 'form-components::components.choice.partials.label') 26 |
27 | -------------------------------------------------------------------------------- /resources/views/components/choice/partials/label.blade.php: -------------------------------------------------------------------------------- 1 | @if ($label || $description || ! $slot->isEmpty()) 2 |
$labelLeft, 5 | ])> 6 | @if ($label || ! $slot->isEmpty()) 7 | 13 | @endif 14 | 15 | @if ($description) 16 | @if ($inlineDescription) 17 | 18 | @if ($label || ! $slot->isEmpty()) 19 | {{ $label ?? $slot }} 20 | @endif 21 | {{ $description }} 22 | 23 | @else 24 |

{{ $description }}

25 | @endif 26 | @endif 27 |
28 | @endif 29 | -------------------------------------------------------------------------------- /resources/views/components/choice/switch-toggle.blade.php: -------------------------------------------------------------------------------- 1 |
whereStartsWith('x-model') }} 17 | @endif 18 | > 19 | 75 |
76 | -------------------------------------------------------------------------------- /resources/views/components/files/file-pond.blade.php: -------------------------------------------------------------------------------- 1 |
$hasErrorsAndShow($name), 'cursor-not-allowed' => $disabled])> 2 |
whereStartsWith('x-model') }} 27 | x-modelable="__value" 28 | @endif 29 | > 30 | {{-- this input will be completely wiped away once we initialize filepond --}} 31 | 40 |
41 |
42 | -------------------------------------------------------------------------------- /resources/views/components/files/file-upload.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {{ $slot }} 3 | 4 | {{-- input/upload progress --}} 5 |
16 | @include('form-components::components.files.partials.file-input') 17 | @includeWhen($canShowUploadProgress(), 'form-components::components.files.partials.upload-progress') 18 | 19 | {{ $afterInput ?? '' }} 20 |
21 | 22 | {{ $after ?? '' }} 23 |
24 | -------------------------------------------------------------------------------- /resources/views/components/files/partials/custom-progress-bar.blade.php: -------------------------------------------------------------------------------- 1 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /resources/views/components/files/partials/file-input.blade.php: -------------------------------------------------------------------------------- 1 | except('aria-describedby')->class($inputClass()) }} 14 | 15 | {{ $extraAttributes ?? '' }} 16 | /> 17 | -------------------------------------------------------------------------------- /resources/views/components/files/partials/native-progress-bar.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /resources/views/components/files/partials/upload-progress.blade.php: -------------------------------------------------------------------------------- 1 |
7 |
8 |
9 | {{ __('form-components::messages.file_upload_processing') }} 10 |
11 | 12 |
13 | 14 |
15 |
16 | 17 | {{-- progress bar --}} 18 | @includeWhen($useNativeProgressBar, 'form-components::components.files.partials.native-progress-bar') 19 | @includeWhen(! $useNativeProgressBar, 'form-components::components.files.partials.custom-progress-bar') 20 |
21 | -------------------------------------------------------------------------------- /resources/views/components/form-error.blade.php: -------------------------------------------------------------------------------- 1 | @error($name, $bag) 2 | <{{ $tag }} {{ $attributes->merge(['class' => 'form-error', 'id' => "{$inputId}-error"]) }}> 3 | @if ($slot->isEmpty()) 4 | {{ $message }} 5 | @else 6 | {{ $slot }} 7 | @endif 8 | 9 | @enderror 10 | -------------------------------------------------------------------------------- /resources/views/components/form-group.blade.php: -------------------------------------------------------------------------------- 1 |
$marginBottom, 4 | 'form-group--border' => $border && $inline, 5 | ])> 6 |
7 |
class($groupClass()) }}> 8 | @include('form-components::partials.form-group-label') 9 | 10 |
$inline, 13 | config('form-components.defaults.form_group.content_class'), 14 | ])> 15 | {{ $slot }} 16 | 17 | @if ($hasErrorsAndShow($name)) 18 | 19 | @endif 20 | 21 | @if ($inline && $hint) 22 | 25 | {{ $hint }} 26 | 27 | @endif 28 | 29 | @if ($helpText) 30 |

{{ $helpText }}

31 | @endif 32 | 33 | {{ $after ?? '' }} 34 |
35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /resources/views/components/form.blade.php: -------------------------------------------------------------------------------- 1 |
16 | @unless (in_array($method, ['HEAD', 'GET', 'OPTIONS'], true)) 17 | @csrf 18 | @endunless 19 | 20 | @if ($spoofMethod) 21 | @method($method) 22 | @endif 23 | 24 | {{ $slot }} 25 |
26 | -------------------------------------------------------------------------------- /resources/views/components/inputs/custom-select-option.blade.php: -------------------------------------------------------------------------------- 1 | @aware([ 2 | 'valueField' => 'value', 3 | 'labelField' => 'label', 4 | 'disabledField' => 'disabled', 5 | 'childrenField' => 'children', 6 | 'optionSelectedIcon' => null, 7 | ]) 8 | 9 | @if ($level === 0 && $hasChildren($childrenField)) 10 |
  • class('custom-select__option custom-select__opt-group') }} 14 | > 15 | 16 | @isset($optionTemplate) 17 | {{ $optionTemplate }} 18 | @else 19 | {{ $optionLabel($labelField) }} 20 | @endisset 21 | 22 |
  • 23 | 24 | @foreach ($optionChildren($childrenField) as $child) 25 | 26 | @isset($optionTemplate) 27 | {{ $optionTemplate }} 28 | @endisset 29 | 30 | @endforeach 31 | @else 32 |
  • class('custom-select__option') }} 36 | x-bind:class="{ 37 | {{-- $customSelectOption magic doesn't play nicely with class binding on ajax refresh for some reason, we just use __context for now... --}} 38 | 'custom-select__option--active': __context.isActiveEl($el), 39 | 'custom-select__option--selected': __context.isSelectedEl($el), 40 | 'custom-select__option--disabled': __context.isDisabledEl($el) || (! $customSelect.canSelectMore && ! __context.isSelectedEl($el)), 41 | }" 42 | > 43 | 44 | @isset($optionTemplate) 45 | {{ $optionTemplate }} 46 | @else 47 | {{ $optionLabel($labelField) }} 48 | @endisset 49 | 50 | 51 | @if ($optionSelectedIcon) 52 | {{-- if we don't render this in a conditional that checks if the element is connected to the DOM, Alpine will throw an error from x-show... --}} 53 | 60 | @endif 61 |
  • 62 | @endif 63 | -------------------------------------------------------------------------------- /resources/views/components/inputs/custom-select.blade.php: -------------------------------------------------------------------------------- 1 |
    except(['class'])->whereDoesntStartWith(['x-model', 'wire:model']) }} 31 | {{ $extraAttributes ?? '' }} 32 | 33 | @if ($hasXModel()) 34 | {{ $attributes->whereStartsWith('x-model') }} 35 | x-modelable="__value" 36 | @endif 37 | > 38 | {{-- trigger --}} 39 | @include('form-components::partials.select.select-trigger', ['type' => 'custom']) 40 | 41 | {{-- menu --}} 42 |
    49 | @if ($searchable) 50 | 56 | @endif 57 | 58 |
      69 | @foreach ($options as $option) 70 | 71 | @isset($optionTemplate) 72 | {{ $optionTemplate }} 73 | @endisset 74 | 75 | @endforeach 76 | 77 | 82 | 83 | {{ $slot }} 84 |
    85 | 86 | @if ($livewireSearch) 87 | 88 | @endif 89 |
    90 |
    91 | -------------------------------------------------------------------------------- /resources/views/components/inputs/date-picker.blade.php: -------------------------------------------------------------------------------- 1 |
    whereStartsWith('x-model') }} 19 | x-modelable="__value" 20 | @endif 21 | 22 | class="date-picker-root" 23 | > 24 |
    34 | @if ($toggleIcon) 35 | {{ $before ?? '' }} 36 | 41 | 42 | 43 | @endif 44 | @includeWhen(! $toggleIcon, 'form-components::partials.leading-addons') 45 | 46 | except(['type', 'aria-describedby'])->whereDoesntStartWith(['wire:model', 'x-model'])->class($inputClass()) }} 51 | x-date-picker:input 52 | wire:ignore 53 | 54 | {{ $extraAttributes ?? '' }} 55 | /> 56 | 57 | @if ($isClearable()) 58 |
    59 |
    60 | 69 |
    70 |
    71 | {{ $after ?? '' }} 72 | @endif 73 | @includeWhen(! $isClearable(), 'form-components::partials.trailing-addons') 74 |
    75 | 76 | {{ $end ?? '' }} 77 |
    78 | -------------------------------------------------------------------------------- /resources/views/components/inputs/input.blade.php: -------------------------------------------------------------------------------- 1 |
    2 | @include('form-components::partials.leading-addons') 3 | 4 | 10 | 11 | @include('form-components::partials.trailing-addons') 12 |
    13 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/attributes.blade.php: -------------------------------------------------------------------------------- 1 | {{ $attributes->except('aria-describedby')->class($inputClass()) }} 2 | @if ($name) name="{{ $name }}" @endif 3 | @if ($id) id="{{ $id }}" @endif 4 | @if ($hasErrorsAndShow($name)) 5 | aria-invalid="true" 6 | @endif 7 | {!! $ariaDescribedBy() !!} 8 | {{ $extraAttributes ?? '' }} 9 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/leading-addon.blade.php: -------------------------------------------------------------------------------- 1 | class('leading-addon') }}> 2 | {{ $slot }} 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/no-options.blade.php: -------------------------------------------------------------------------------- 1 |
  • class('custom-select__no-results') }} 3 | role="presentation" 4 | data-placeholder="true" 5 | wire:ignore 6 | > 7 | {{ $slot }} 8 |
  • 9 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/password-toggle.blade.php: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/select-loader.blade.php: -------------------------------------------------------------------------------- 1 | @props(['target' => 'handleSearch']) 2 | 3 | 12 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/select-option.blade.php: -------------------------------------------------------------------------------- 1 | @if ($optionIsOptGroup($option)) 2 | 3 | @foreach ($optionChildren($option) as $child) 4 | @include('form-components::components.inputs.partials.select-option', ['option' => $child]) 5 | @endforeach 6 | 7 | @else 8 | 15 | @endif 16 | -------------------------------------------------------------------------------- /resources/views/components/inputs/partials/trailing-addon.blade.php: -------------------------------------------------------------------------------- 1 | class('trailing-addon') }}> 2 | {{ $slot }} 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/inputs/password.blade.php: -------------------------------------------------------------------------------- 1 |
    6 | @include('form-components::partials.leading-addons') 7 | 8 | 19 | 20 | @includeWhen($showToggle, 'form-components::components.inputs.partials.password-toggle') 21 | 22 | @includeWhen(! $showToggle, 'form-components::partials.trailing-addons') 23 |
    24 | -------------------------------------------------------------------------------- /resources/views/components/inputs/select.blade.php: -------------------------------------------------------------------------------- 1 |
    2 | @include('form-components::partials.leading-addons') 3 | 4 | 16 | 17 | @include('form-components::partials.trailing-addons') 18 |
    19 | -------------------------------------------------------------------------------- /resources/views/components/inputs/textarea.blade.php: -------------------------------------------------------------------------------- 1 | {{-- we add an x-data directive to the container to ensure our resize directive is in an alpine scope so it will run --}} 2 |
    3 | @include('form-components::partials.leading-addons') 4 | 5 | 19 | 20 | @include('form-components::partials.trailing-addons') 21 |
    22 | -------------------------------------------------------------------------------- /resources/views/components/inputs/timezone-select.blade.php: -------------------------------------------------------------------------------- 1 | @includeWhen(! $useCustomSelect, 'form-components::partials.timezone-select-native') 2 | @includeWhen($useCustomSelect, 'form-components::partials.timezone-select-custom') 3 | -------------------------------------------------------------------------------- /resources/views/components/inputs/tree-select-option.blade.php: -------------------------------------------------------------------------------- 1 | @aware([ 2 | 'valueField' => 'value', 3 | 'labelField' => 'label', 4 | 'disabledField' => 'disabled', 5 | 'childrenField' => 'children', 6 | 'optionSelectedIcon' => null, 7 | 'hasChildIcon' => false, 8 | ]) 9 | 10 |
  • 11 |
    class('custom-select__option tree-select__option') }} 16 | style="--level: {{ $level }};" 17 | :disabled="{{ \Illuminate\Support\Js::from($optionIsDisabled($disabledField)) }}" 18 | x-bind:class="{ 19 | {{-- $treeSelectOption magic doesn't play nicely with class binding on ajax refresh for some reason, we just use __context for now... --}} 20 | 'custom-select__option--active': __context.isActiveEl($el), 21 | 'custom-select__option--selected': __context.isSelectedEl($el), 22 | 'custom-select__option--disabled': __context.isDisabledEl($el) || (! $treeSelect.canSelectMore && ! __context.isSelectedEl($el)), 23 | }" 24 | > 25 |
    26 | @if ($hasChildIcon) 27 | 38 | @endif 39 | 40 | 41 | @isset($optionTemplate) 42 | {{ $optionTemplate }} 43 | @else 44 | {{ $optionLabel($labelField) }} 45 | @endisset 46 | 47 |
    48 | 49 | @if ($optionSelectedIcon) 50 | {{-- if we don't render this in a conditional that checks if the element is connected to the DOM, Alpine will throw an error from x-show... --}} 51 | 58 | @endif 59 |
    60 | 61 | @if ($hasChildren($childrenField)) 62 | 74 | @endif 75 |
  • 76 | -------------------------------------------------------------------------------- /resources/views/components/inputs/tree-select.blade.php: -------------------------------------------------------------------------------- 1 |
    except(['class'])->whereDoesntStartWith(['x-model', 'wire:model']) }} 31 | {{ $extraAttributes ?? '' }} 32 | 33 | @if ($hasXModel()) 34 | {{ $attributes->whereStartsWith('x-model') }} 35 | x-modelable="__value" 36 | @endif 37 | > 38 | {{-- trigger --}} 39 | @include('form-components::partials.select.select-trigger', ['type' => 'tree']) 40 | 41 | {{-- menu --}} 42 |
    49 | @if ($searchable) 50 | 56 | @endif 57 | 58 |
      69 | @foreach ($options as $option) 70 | 71 | @isset($optionTemplate) 72 | {{ $optionTemplate }} 73 | @endisset 74 | 75 | @endforeach 76 | 77 | 82 | 83 | {{ $slot }} 84 |
    85 | 86 | @if ($livewireSearch) 87 | 88 | @endif 89 |
    90 |
    91 | -------------------------------------------------------------------------------- /resources/views/components/label.blade.php: -------------------------------------------------------------------------------- 1 | @if ($hasLabel($slot)) 2 | 13 | @endif 14 | -------------------------------------------------------------------------------- /resources/views/components/rich-text/quill.blade.php: -------------------------------------------------------------------------------- 1 |
    class($containerClass()) }} 32 | 33 | @if ($hasXModel()) 34 | x-modelable="__value" 35 | {{ $attributes->whereStartsWith('x-model') }} 36 | @endif 37 | > 38 | @if ($name) 39 | 40 | @endif 41 | 42 |
    48 |
    49 |
    50 |
    51 | -------------------------------------------------------------------------------- /resources/views/livewire/custom-select/custom-select.blade.php: -------------------------------------------------------------------------------- 1 |
    11 | 44 |
    45 | -------------------------------------------------------------------------------- /resources/views/livewire/tree-select/tree-select.blade.php: -------------------------------------------------------------------------------- 1 |
    11 | 45 |
    46 | -------------------------------------------------------------------------------- /resources/views/partials/form-group-label.blade.php: -------------------------------------------------------------------------------- 1 | @if ($label !== false || ! is_null($hint)) 2 |
    $isCheckboxGroup, 5 | 'form-group__label-container--inline' => $inline, 6 | config('form-components.defaults.form_group.label_container_class'), 7 | ])> 8 | @unless ($label === false) 9 | 13 | {{ $label }} 14 | 15 | @endunless 16 | 17 | @unless (is_null($hint)) 18 | 22 | {{ $hint }} 23 | 24 | @endunless 25 |
    26 | @endif 27 | -------------------------------------------------------------------------------- /resources/views/partials/leading-addons.blade.php: -------------------------------------------------------------------------------- 1 | {{ $before ?? '' }} 2 | @if ($leadingAddon ?? false) 3 | attributes->class('leading-addon') }}> 4 | {!! $leadingAddon !!} 5 | 6 | @elseif ($inlineAddon ?? false) 7 |
    attributes->class('inline-addon') }}> 8 | {!! $inlineAddon !!} 9 |
    10 | @elseif ($leadingIcon ?? false) 11 |
    attributes->class('leading-icon') }}> 12 | 13 | @if (is_string($leadingIcon)) 14 | 15 | @else 16 | {!! $leadingIcon !!} 17 | @endif 18 | 19 |
    20 | @endif 21 | -------------------------------------------------------------------------------- /resources/views/partials/select/select-trigger.blade.php: -------------------------------------------------------------------------------- 1 |
    2 | @include('form-components::partials.leading-addons') 3 | 4 | 64 | 65 | @include('form-components::partials.trailing-addons') 66 |
    67 | -------------------------------------------------------------------------------- /resources/views/partials/timezone-select-custom.blade.php: -------------------------------------------------------------------------------- 1 | 27 | {{-- slot is meant for passing in slotted addons --}} 28 | {{ $slot }} 29 | 30 | -------------------------------------------------------------------------------- /resources/views/partials/timezone-select-native.blade.php: -------------------------------------------------------------------------------- 1 |
    2 | @include('form-components::partials.leading-addons') 3 | 4 | 12 | 13 | @include('form-components::partials.trailing-addons') 14 |
    15 | -------------------------------------------------------------------------------- /resources/views/partials/trailing-addons.blade.php: -------------------------------------------------------------------------------- 1 | @if ($trailingAddon ?? false) 2 | attributes->class('trailing-addon') }}> 3 | {!! $trailingAddon !!} 4 | 5 | @elseif ($trailingInlineAddon ?? false) 6 |
    attributes->class('trailing-inline-addon') }}> 7 | {!! $trailingInlineAddon !!} 8 |
    9 | @elseif ($trailingIcon ?? false) 10 |
    attributes->class('trailing-icon') }}> 11 | 12 | @if (is_string($trailingIcon)) 13 | 14 | @else 15 | {!! $trailingIcon !!} 16 | @endif 17 | 18 |
    19 | @endif 20 | {{ $after ?? '' }} 21 | -------------------------------------------------------------------------------- /src/Components/BladeComponent.php: -------------------------------------------------------------------------------- 1 | map([Str::class, 'kebab']) 25 | ->implode('.'); 26 | 27 | $fullName = collect(explode('.', str_replace(['/', '\\'], '.', static::class))) 28 | ->map([Str::class, 'kebab']) 29 | ->implode('.'); 30 | 31 | if (str($fullName)->startsWith($namespace)) { 32 | return (string) str($fullName)->substr(strlen($namespace) + 1); 33 | } 34 | 35 | return $fullName; 36 | } 37 | 38 | /** 39 | * Ensures we always have an instance of ComponentSlot for merging attributes in slots. 40 | * Useful when the "slot" may not always be provided to the component but we 41 | * need some default attributes always present. 42 | */ 43 | public function componentSlot(mixed $slot): ComponentSlot 44 | { 45 | return $slot instanceof ComponentSlot 46 | ? $slot 47 | : new ComponentSlot; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Components/Choice/Checkbox.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 40 | 41 | if ($name) { 42 | $this->checked = (bool) old($name, $checked); 43 | } 44 | 45 | $this->size = $size ?? config('form-components.defaults.choice.size'); 46 | $this->inlineDescription = $this->inlineDescription ?? config('form-components.defaults.choice.inline_description', false); 47 | $this->labelLeft = $labelLeft ?? config('form-components.defaults.choice.label_left', false); 48 | 49 | $this->setExtraAttributes($extraAttributes); 50 | } 51 | 52 | public function inputClass(): string 53 | { 54 | return Arr::toCssClasses([ 55 | 'form-choice', 56 | $this->type === 'checkbox' ? 'form-checkbox' : 'form-radio', 57 | ]); 58 | } 59 | 60 | public function containerClass(?string $inputSize = null): string 61 | { 62 | // Input size from parent has priority over individual defined size. 63 | if ($inputSize) { 64 | $this->size = $inputSize; 65 | } 66 | 67 | return Arr::toCssClasses([ 68 | 'choice-container', 69 | 'choice-container--label-left' => $this->labelLeft, 70 | config('form-components.defaults.choice.container_class', ''), 71 | "form-choice--{$this->size}" => $this->size, 72 | $this->containerClass, 73 | ]); 74 | } 75 | 76 | public function ariaDescribedBy(): ?string 77 | { 78 | $describedBy = $this->validationAriaDescribedBy(); 79 | 80 | if ($this->description && ! Str::contains($describedBy, "{$this->id}-description")) { 81 | $describedBy = is_null($describedBy) 82 | ? "aria-describedby=\"{$this->id}-description\"" 83 | : Str::replaceLast('"', " {$this->id}-description\"", $describedBy); 84 | } 85 | 86 | return $describedBy; 87 | } 88 | 89 | public function isDisabled(): bool 90 | { 91 | return $this->attributes->offsetExists('disabled') 92 | && $this->attributes->get('disabled') !== false; 93 | } 94 | 95 | public static function getName(): string 96 | { 97 | return 'choice.checkbox-or-radio'; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Components/Choice/CheckboxGroup.php: -------------------------------------------------------------------------------- 1 | inputSize = $inputSize ?? config('form-components.defaults.choice.size'); 18 | } 19 | 20 | public function classes(): string 21 | { 22 | return Arr::toCssClasses([ 23 | 'form-checkbox-group', 24 | 'form-checkbox-group--inline' => ! $this->stacked, 25 | 'form-checkbox-group--stacked' => $this->stacked, 26 | "form-checkbox-group--{$this->inputSize}" => $this->inputSize, 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Components/Choice/Radio.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 41 | 42 | $this->size = $size ?? config('form-components.defaults.switch_toggle.size'); 43 | $this->onIcon = $onIcon ?? config('form-components.defaults.switch_toggle.on_icon'); 44 | $this->offIcon = $offIcon ?? config('form-components.defaults.switch_toggle.off_icon'); 45 | 46 | $this->setExtraAttributes($extraAttributes); 47 | } 48 | 49 | public function switchClass(): string 50 | { 51 | return Arr::toCssClasses([ 52 | 'switch-toggle peer', 53 | "switch-toggle--{$this->size}" => $this->size && ! $this->short, 54 | "switch-toggle--{$this->color}" => $this->color, 55 | 'switch-toggle--short' => $this->short, 56 | config('form-components.defaults.switch_toggle.input_class'), 57 | $this->attributes->only('class')->get('class'), 58 | ]); 59 | } 60 | 61 | public function containerClass(): string 62 | { 63 | return Arr::toCssClasses([ 64 | 'switch-toggle-container', 65 | 'cursor-not-allowed' => $this->disabled, 66 | config('form-components.defaults.switch_toggle.container_class'), 67 | $this->containerClass, 68 | ]); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Components/Files/FilePond.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 38 | 39 | $this->type = $type; 40 | $this->allowDrop = $allowDrop ?? config('form-components.defaults.file_pond.allow_drop', true); 41 | $this->maxFiles = $maxFiles ?? config('form-components.defaults.file_pond.max_files'); 42 | $this->options = $options ?? config('form-components.defaults.file_pond.options', []); 43 | 44 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 45 | 46 | $this->setExtraAttributes($extraAttributes); 47 | } 48 | 49 | public function options(): array 50 | { 51 | $label = array_filter([ 52 | __('form-components::messages.filepond_instructions'), 53 | $this->description, 54 | ]); 55 | 56 | if (isset($label[1])) { 57 | $label[1] = '' . $label[1] . ''; 58 | } 59 | 60 | $defaultOptions = [ 61 | 'allowMultiple' => $this->multiple, 62 | 'allowDrop' => $this->allowDrop, 63 | 'disabled' => $this->disabled, 64 | 'credits' => false, // Hide powered by footer 65 | ] + array_filter([ 66 | 'maxFiles' => $this->multiple && $this->maxFiles ? $this->maxFiles : null, 67 | 'name' => $this->name, 68 | 'acceptedFileTypes' => $this->accepts(), // Only works if the file type validation plugin is installed 69 | 'labelIdle' => '' . implode('
    ', $label) . '
    ', 70 | ]); 71 | 72 | return array_merge($defaultOptions, $this->options); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Components/Files/FileUpload.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 40 | $this->type = $type; 41 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 42 | $this->size = $size ?? config('form-components.defaults.input.size', 'md'); 43 | 44 | $this->displayUploadProgress = $displayUploadProgress ?? config('form-components.defaults.file_upload.display_upload_progress', true); 45 | $this->useNativeProgressBar = $useNativeProgressBar ?? config('form-components.defaults.file_upload.use_native_progress_bar', false); 46 | 47 | $this->setExtraAttributes($extraAttributes); 48 | } 49 | 50 | public function inputClass(): string 51 | { 52 | return Arr::toCssClasses([ 53 | 'file-upload__input', 54 | config('form-components.defaults.file_upload.input_class'), 55 | "file-upload__input--{$this->size}" => $this->size, 56 | ]); 57 | } 58 | 59 | public function containerClass(): string 60 | { 61 | return Arr::toCssClasses([ 62 | 'file-upload', 63 | config('form-components.defaults.file_upload.container_class'), 64 | $this->containerClass, 65 | ]); 66 | } 67 | 68 | public function canShowUploadProgress(): bool 69 | { 70 | if (! is_null($this->canShowUploadProgress)) { 71 | return $this->canShowUploadProgress; 72 | } 73 | 74 | return $this->canShowUploadProgress = match (true) { 75 | ! $this->displayUploadProgress => false, 76 | ! $this->hasWireModel() => false, 77 | default => true, 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Components/Form.php: -------------------------------------------------------------------------------- 1 | method = strtoupper($this->method); 26 | $this->spoofMethod = in_array($this->method, ['PUT', 'PATCH', 'DELETE'], true); 27 | } 28 | 29 | public function hasError(string $bag = 'default'): bool 30 | { 31 | $errors = View::shared('errors', fn () => session()->get('errors', new ViewErrorBag)); 32 | 33 | return $errors->getBag($bag)->isNotEmpty(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Components/FormError.php: -------------------------------------------------------------------------------- 1 | name = str_replace(['[', ']'], ['.', ''], $name); 16 | $this->inputId = $inputId ?? $this->name; 17 | $this->tag = $tag ?? config('form-components.defaults.form_error.tag'); 18 | } 19 | 20 | public function messages(ViewErrorBag $errors): array 21 | { 22 | $bag = $errors->getBag($this->bag); 23 | 24 | return $bag->has($this->name) ? $bag->get($this->name) : []; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Components/FormGroup.php: -------------------------------------------------------------------------------- 1 | inputId = $inputId ?? $name; 28 | 29 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 30 | $this->inline = $inline ?? config('form-components.defaults.form_group.inline', false); 31 | $this->marginBottom = $marginBottom ?? config('form-components.defaults.form_group.margin_bottom', true); 32 | $this->border = $border ?? config('form-components.defaults.form_group.border', true); 33 | 34 | if ($optional && ! $hint) { 35 | $this->hint = __(config('form-components.optional_hint_text')); 36 | } 37 | } 38 | 39 | public function groupClass(): string 40 | { 41 | return Arr::toCssClasses([ 42 | 'has-error' => $this->hasErrorsAndShow($this->name), 43 | config('form-components.defaults.form_group.class'), 44 | 'form-group--inline' => $this->inline, 45 | ]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Components/Inputs/CustomSelect.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 63 | $this->value = $name ? old($name, $value) : $value; 64 | 65 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 66 | 67 | $this->size = $size ?? config('form-components.defaults.input.size'); 68 | 69 | $this->clearable = $clearable ?? config('form-components.defaults.custom_select.clearable', true); 70 | $this->optional = $optional ?? config('form-components.defaults.custom_select.optional', true); 71 | $this->minSelected = $minSelected ?? config('form-components.defaults.custom_select.min_selected'); 72 | $this->maxSelected = $maxSelected ?? config('form-components.defaults.custom_select.max_selected'); 73 | $this->buttonIcon = $buttonIcon ?? config('form-components.defaults.custom_select.button_icon'); 74 | $this->searchable = $searchable ?? config('form-components.defaults.custom_select.searchable', true); 75 | $this->clearIcon = $clearIcon ?? config('form-components.defaults.custom_select.clear_icon'); 76 | $this->optionSelectedIcon = $optionSelectedIcon ?? config('form-components.defaults.custom_select.option_selected_icon'); 77 | $this->placeholder = $placeholder ?? __('form-components::messages.custom_select_placeholder'); 78 | $this->noResultsText = $noResultsText ?? __('form-components::messages.custom_select_no_results'); 79 | $this->noOptionsText = $noOptionsText ?? __('form-components::messages.custom_select_empty_text'); 80 | 81 | $this->valueField = $valueField ?? config('form-components.defaults.global.value_field', 'id'); 82 | $this->labelField = $labelField ?? config('form-components.defaults.global.label_field', 'name'); 83 | $this->selectedLabelField = $selectedLabelField ?? config('form-components.defaults.global.selected_label_field') ?? $this->labelField; 84 | $this->disabledField = $disabledField ?? config('form-components.defaults.global.disabled_field', 'disabled'); 85 | $this->childrenField = $childrenField ?? config('form-components.defaults.global.children_field', 'children'); 86 | 87 | if ($multiple && ! is_iterable($this->value)) { 88 | $this->value = array_filter([$this->value]); 89 | } 90 | 91 | $this->setExtraAttributes($extraAttributes); 92 | 93 | // We do not support inline trailing addons or icons for selects. 94 | $this->leadingAddon = $leadingAddon; 95 | $this->leadingIcon = $leadingIcon; 96 | $this->inlineAddon = $inlineAddon; 97 | $this->trailingAddon = $trailingAddon; 98 | 99 | $this->options = $this->normalizeOptions($options); 100 | } 101 | 102 | public function buttonClass(): string 103 | { 104 | return Arr::toCssClasses([ 105 | 'form-input', 106 | 'custom-select__button', 107 | "custom-select__button--{$this->size}" => $this->size, 108 | 'input-error' => $this->hasErrorsAndShow($this->name), 109 | config('form-components.defaults.custom_select.input_class'), 110 | ]); 111 | } 112 | 113 | public function menuClass(): string 114 | { 115 | return Arr::toCssClasses([ 116 | 'custom-select__menu', 117 | config('form-components.defaults.custom_select.menu_class'), 118 | ]); 119 | } 120 | 121 | public function containerClass(): string 122 | { 123 | return Arr::toCssClasses([ 124 | 'relative custom-select', 125 | config('form-components.defaults.custom_select.container_class'), 126 | $this->containerClass, 127 | ]); 128 | } 129 | 130 | protected function normalizeOptions(array|Collection $options): Collection 131 | { 132 | return collect($options) 133 | ->map(function ($value, $key) { 134 | // If the key is not numeric, we're going to assume this is the value. 135 | if (! is_numeric($key)) { 136 | return [ 137 | $this->valueField => $key, 138 | $this->labelField => $value, 139 | ]; 140 | } 141 | 142 | // If the value is a simple value, we need to convert it to an array. 143 | if (! is_iterable($value) && ! $value instanceof Model) { 144 | return [ 145 | $this->valueField => $value, 146 | $this->labelField => $value, 147 | ]; 148 | } 149 | 150 | return $value; 151 | }); 152 | } 153 | 154 | public function config(): Js 155 | { 156 | return Js::from([ 157 | 'valueField' => $this->valueField, 158 | 'by' => $this->valueField, 159 | 'labelField' => $this->labelField, 160 | 'selectedLabelField' => $this->selectedLabelField, 161 | 'disabledField' => $this->disabledField, 162 | 'childrenField' => $this->childrenField, 163 | 'optional' => $this->optional, 164 | 'minSelected' => $this->minSelected, 165 | 'maxSelected' => $this->maxSelected, 166 | ]); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Components/Inputs/CustomSelectOption.php: -------------------------------------------------------------------------------- 1 | value) { 40 | return $this->optionVariable; 41 | } 42 | 43 | return Js::from($this->value); 44 | } 45 | 46 | public function optionLabel(string $labelField): mixed 47 | { 48 | if ($this->value instanceof Model) { 49 | return $this->value->{$labelField}; 50 | } 51 | 52 | if (is_iterable($this->value)) { 53 | return Arr::get($this->value, $labelField); 54 | } 55 | 56 | return $this->value; 57 | } 58 | 59 | public function optionIsDisabled(string $disabledField): bool 60 | { 61 | $isDisabled = $this->value instanceof Model 62 | ? $this->value->{$disabledField} 63 | : Arr::get($this->value, $disabledField, false); 64 | 65 | return is_bool($isDisabled) ? $isDisabled : false; 66 | } 67 | 68 | public function optionChildren(string $childrenField): array|Collection 69 | { 70 | if (! is_null($this->children)) { 71 | return $this->children; 72 | } 73 | 74 | $children = $this->value instanceof Model 75 | ? $this->value->{$childrenField} 76 | : Arr::get($this->value, $childrenField, []); 77 | 78 | return $this->children = is_iterable($children) ? $children : []; 79 | } 80 | 81 | public function hasChildren(string $childrenField): bool 82 | { 83 | if (! is_null($this->hasChildren)) { 84 | return $this->hasChildren; 85 | } 86 | 87 | $children = $this->optionChildren($childrenField); 88 | 89 | return $children instanceof Collection 90 | ? $this->hasChildren = $children->isNotEmpty() 91 | : $this->hasChildren = ! empty($children); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Components/Inputs/DatePicker.php: -------------------------------------------------------------------------------- 1 | ensureModeIsValid($mode); 61 | 62 | $this->clickOpens = $clickOpens ?? config('form-components.defaults.date_picker.click_opens', false); 63 | $this->allowInput = $allowInput ?? config('form-components.defaults.date_picker.allow_input', true); 64 | $this->enableTime = $enableTime ?? config('form-components.defaults.date_picker.enable_time', false); 65 | $this->format = $format ?? config('form-components.date_picker.format', null); 66 | $this->clearable = $clearable ?? config('form-components.defaults.date_picker.clearable', true); 67 | $this->placeholder = $placeholder ?? __(config('form-components.defaults.date_picker.placeholder')); 68 | 69 | $this->toggleIcon = $toggleIcon ?? config('form-components.defaults.date_picker.toggle_icon'); 70 | $this->clearIcon = $clearIcon ?? config('form-components.defaults.date_picker.clear_icon'); 71 | } 72 | 73 | public function options(): array 74 | { 75 | $defaultOptions = [ 76 | 'clickOpens' => $this->clickOpens, 77 | 'allowInput' => $this->allowInput, 78 | 'enableTime' => $this->enableTime, 79 | 'mode' => $this->mode, 80 | ]; 81 | 82 | if ($this->format) { 83 | $defaultOptions['dateFormat'] = $this->format; 84 | } 85 | 86 | return array_merge($defaultOptions, $this->options); 87 | } 88 | 89 | public function isClearable(): bool 90 | { 91 | return $this->clearable && $this->clearIcon; 92 | } 93 | 94 | protected function hasLeadingAddon(): bool 95 | { 96 | return $this->toggleIcon || $this->leadingAddon; 97 | } 98 | 99 | protected function hasTrailingIcon(): bool 100 | { 101 | return $this->isClearable() || $this->trailingIcon; 102 | } 103 | 104 | protected function ensureModeIsValid(string $mode): void 105 | { 106 | throw_unless( 107 | in_array($mode, ['single', 'range', 'multiple']), 108 | new InvalidArgumentException("Invalid date picker mode: {$mode}.") 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Components/Inputs/Email.php: -------------------------------------------------------------------------------- 1 | type = 'email'; 12 | 13 | return parent::render(); 14 | } 15 | 16 | public static function getName(): string 17 | { 18 | return 'inputs.input'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Components/Inputs/Input.php: -------------------------------------------------------------------------------- 1 | id = $this->id ?? $this->name; 49 | $this->value = $name ? old($name, $value) : $value; 50 | $this->size = $size ?? config('form-components.defaults.input.size', 'md'); 51 | 52 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 53 | 54 | if (is_iterable($this->value) && $this->jsonEncodeArrayValues) { 55 | $this->value = json_encode($this->value); 56 | } 57 | 58 | $this->setExtraAttributes($extraAttributes); 59 | 60 | $this->leadingAddon = $leadingAddon; 61 | $this->leadingIcon = $leadingIcon; 62 | $this->inlineAddon = $inlineAddon; 63 | $this->trailingAddon = $trailingAddon; 64 | $this->trailingInlineAddon = $trailingInlineAddon; 65 | $this->trailingIcon = $trailingIcon; 66 | } 67 | 68 | public function inputClass(): string 69 | { 70 | return Arr::toCssClasses([ 71 | 'form-text', 72 | config('form-components.defaults.input.input_class'), 73 | 'input-error' => $this->hasErrorsAndShow($this->name), 74 | ]); 75 | } 76 | 77 | public function containerClass(): string 78 | { 79 | return Arr::toCssClasses([ 80 | 'form-text-container', 81 | config('form-components.defaults.input.container_class'), 82 | "form-input--{$this->size}" => $this->size, 83 | $this->getAddonClass(), 84 | $this->containerClass, 85 | ]); 86 | } 87 | 88 | public function render() 89 | { 90 | return function (array $data) { 91 | $this->setSlotAddonAttributes($data); 92 | 93 | return "form-components::components.{$this::getName()}"; 94 | }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Components/Inputs/Password.php: -------------------------------------------------------------------------------- 1 | showToggle = $showToggle ?? config('form-components.defaults.password.show_toggle', true); 55 | $this->showPasswordIcon = $showPasswordIcon ?? config('form-components.defaults.password.show_icon', 'heroicon-m-eye'); 56 | $this->hidePasswordIcon = $hidePasswordIcon ?? config('form-components.defaults.password.hide_icon', 'heroicon-m-eye-slash'); 57 | } 58 | 59 | public function inputClass(): string 60 | { 61 | return Arr::toCssClasses([ 62 | parent::inputClass(), 63 | 'password-toggleable' => $this->showToggle, 64 | ]); 65 | } 66 | 67 | public function containerClass(): string 68 | { 69 | return Arr::toCssClasses([ 70 | parent::containerClass(), 71 | 'password-toggleable-container' => $this->showToggle, 72 | ]); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Components/Inputs/Select.php: -------------------------------------------------------------------------------- 1 | jsonEncodeArrayValues = false; 43 | 44 | parent::__construct( 45 | name: $name, 46 | id: $id, 47 | value: $value, 48 | containerClass: $containerClass, 49 | size: $size, 50 | showErrors: $showErrors, 51 | extraAttributes: $extraAttributes, 52 | leadingAddon: $leadingAddon, 53 | leadingIcon: $leadingIcon, 54 | inlineAddon: $inlineAddon, 55 | trailingAddon: $trailingAddon, 56 | trailingInlineAddon: $trailingInlineAddon, 57 | trailingIcon: $trailingIcon, 58 | ); 59 | 60 | $this->valueField = $valueField ?? config('form-components.defaults.global.value_field', 'id'); 61 | $this->labelField = $labelField ?? config('form-components.defaults.global.label_field', 'name'); 62 | $this->disabledField = $disabledField ?? config('form-components.defaults.global.disabled_field', 'disabled'); 63 | $this->childrenField = $childrenField ?? config('form-components.defaults.global.children_field', 'children'); 64 | 65 | $this->options = $this->normalizeOptions($options); 66 | } 67 | 68 | /** 69 | * We are not using a strict comparison so that numeric key values can be shown 70 | * as "selected" too. e.g. 1 == '1' 71 | * 72 | * @see: https://github.com/rawilk/laravel-form-components/issues/11 73 | */ 74 | public function isSelected($value): bool 75 | { 76 | if ($this->value == $value) { 77 | return true; 78 | } 79 | 80 | return is_array($this->value) && in_array($value, $this->value, false); 81 | } 82 | 83 | public function inputClass(): string 84 | { 85 | return Arr::toCssClasses([ 86 | 'form-text form-select', 87 | config('form-components.defaults.select.input_class', ''), 88 | 'input-error' => $this->hasErrorsAndShow($this->name), 89 | ]); 90 | } 91 | 92 | protected function normalizeOptions(array|Collection $options): Collection 93 | { 94 | return collect($options) 95 | ->map(function ($value, $key) { 96 | // If the key is not numeric, we're going to assume this is the value. 97 | if (! is_numeric($key)) { 98 | return [ 99 | $this->valueField => $key, 100 | $this->labelField => $value, 101 | ]; 102 | } 103 | 104 | // If the value is a simple value, we need to convert it to an array. 105 | if (! is_iterable($value) && ! $value instanceof Model) { 106 | return [ 107 | $this->valueField => $value, 108 | $this->labelField => $value, 109 | ]; 110 | } 111 | 112 | return $value; 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Components/Inputs/Textarea.php: -------------------------------------------------------------------------------- 1 | autoResize = $autoResize ?? config('form-components.defaults.textarea.auto_resize', true); 50 | } 51 | 52 | public function inputClass(): string 53 | { 54 | return Arr::toCssClasses([ 55 | parent::inputClass(), 56 | 'overflow-hidden' => $this->autoResize, 57 | ]); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Components/Inputs/TimezoneSelect.php: -------------------------------------------------------------------------------- 1 | only = $only ?? config('form-components.timezone_subset', false); 69 | $this->useCustomSelect = $useCustomSelect ?? config('form-components.defaults.timezone_select.use_custom_select', true); 70 | 71 | $this->placeholder = $placeholder ?? __('form-components::messages.timezone_select_placeholder'); 72 | 73 | $this->options = app('fc-timezone')->only($this->only)->allMapped(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Components/Inputs/TreeSelect.php: -------------------------------------------------------------------------------- 1 | hasChildIcon = $hasChildIcon ?? config('form-components.defaults.tree_select.has_child_icon'); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Components/Inputs/TreeSelectOption.php: -------------------------------------------------------------------------------- 1 | for)); 16 | } 17 | 18 | public function hasLabel($slot): bool 19 | { 20 | return ! $slot->isEmpty() 21 | || $this->fallback(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Components/Livewire/Concerns/HandlesSelectOptions.php: -------------------------------------------------------------------------------- 1 | search = $search; 24 | } 25 | 26 | public function options(?string $search = null) 27 | { 28 | return collect(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Components/Livewire/Concerns/HasCustomSelectProperties.php: -------------------------------------------------------------------------------- 1 | defer) { 30 | return; 31 | } 32 | 33 | $this->emitUp("{$this->name}Updated", $this->value); 34 | } 35 | 36 | public function updateValue(mixed $value): void 37 | { 38 | $this->value = $value; 39 | } 40 | 41 | protected function getListeners(): array 42 | { 43 | return array_merge($this->listeners, [ 44 | "{$this->name}Refresh" => '$refresh', 45 | "{$this->name}Updated" => 'updateValue', 46 | ]); 47 | } 48 | 49 | public function render(): View 50 | { 51 | return view($this->view, [ 52 | 'options' => $this->options($this->search), 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Components/Livewire/TreeSelect.php: -------------------------------------------------------------------------------- 1 | id = $id ?? $name; 32 | 33 | $this->showErrors = $showErrors ?? config('form-components.defaults.global.show_errors', true); 34 | 35 | $this->autoFocus = $autoFocus ?? config('form-components.defaults.rich-text.quill.auto_focus', false); 36 | 37 | $this->quillOptions = $quillOptions ?? QuillOptions::defaults(); 38 | } 39 | 40 | public function containerClass(): string 41 | { 42 | return Arr::toCssClasses([ 43 | 'quill-wrapper', 44 | 'has-error' => $this->hasErrorsAndShow($this->name), 45 | ]); 46 | } 47 | 48 | public function options(): Js 49 | { 50 | return Js::from([ 51 | 'autofocus' => $this->autoFocus, 52 | 'theme' => $this->quillOptions->theme, 53 | 'readOnly' => $this->readonly, 54 | 'placeholder' => $this->placeholder, 55 | 'toolbar' => $this->quillOptions->getToolbar(), 56 | ]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Concerns/AcceptsFiles.php: -------------------------------------------------------------------------------- 1 | type) { 16 | 'audio' => 'audio/*', 17 | 'image' => 'image/*', 18 | 'video' => 'video/*', 19 | 'pdf' => '.pdf', 20 | 'csv' => '.csv', 21 | 'spreadsheet', 'excel' => '.csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 22 | 'text' => 'text/plain', 23 | 'html' => 'text/html', 24 | default => null, 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Concerns/GetsSelectOptionProperties.php: -------------------------------------------------------------------------------- 1 | optionProperty($option, $childrenField ?? $this->childrenField, []); 21 | } 22 | 23 | public function optionLabel($option, ?string $labelField = null, ?string $valueField = null) 24 | { 25 | return $this->optionProperty( 26 | $option, 27 | $labelField ?? $this->labelField, 28 | $this->optionValue($option, $valueField) 29 | ); 30 | } 31 | 32 | public function optionSelectedLabel($option, ?string $selectedLabelField = null, ?string $labelField = null, ?string $valueField = null) 33 | { 34 | return $this->optionProperty( 35 | $option, 36 | $selectedLabelField ?? $this->selectedLabelField, 37 | $this->optionLabel($option, $labelField, $valueField) 38 | ); 39 | } 40 | 41 | public function optionValue($option, ?string $valueField = null) 42 | { 43 | return $this->optionProperty($option, $valueField ?? $this->valueField); 44 | } 45 | 46 | public function optionIsDisabled($option, ?string $disabledField = null): bool 47 | { 48 | $disabled = $this->optionProperty($option, $disabledField ?? $this->disabledField, false); 49 | 50 | return is_bool($disabled) ? $disabled : false; 51 | } 52 | 53 | public function optionIsOptGroup($option, ?string $childrenField = null): bool 54 | { 55 | // We will consider an option an "opt group" if it has children. 56 | $children = $this->optionChildren($option, $childrenField); 57 | 58 | return $children instanceof Collection 59 | ? $children->isNotEmpty() 60 | : ! empty($children); 61 | } 62 | 63 | protected function optionProperty($option, $field, $default = null) 64 | { 65 | if (is_array($option)) { 66 | return Arr::get($option, $field, $default); 67 | } 68 | 69 | if ($option instanceof Model) { 70 | $value = $option->{$field}; 71 | 72 | return is_null($value) ? $default : $value; 73 | } 74 | 75 | // We have a simple array of options, so just return the "value". 76 | return $option; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Concerns/HandlesValidationErrors.php: -------------------------------------------------------------------------------- 1 | hasErrorsAndShow($this->name); 19 | $hasDefinedAriaDescribedBy = $this->attributes->offsetExists('aria-describedby'); 20 | 21 | if ($hasError && $hasDefinedAriaDescribedBy) { 22 | return "aria-describedby=\"{$this->attributes->get('aria-describedby')} {$this->id}-error\""; 23 | } 24 | 25 | if ($hasError) { 26 | return "aria-describedby=\"{$this->id}-error\""; 27 | } 28 | 29 | return $hasDefinedAriaDescribedBy 30 | ? "aria-describedby=\"{$this->attributes->get('aria-describedby')}\"" 31 | : null; 32 | } 33 | 34 | public function hasErrorsAndShow(?string $name = null, string $bag = 'default'): bool 35 | { 36 | return $this->showErrors && $this->hasError($name, $bag); 37 | } 38 | 39 | public function hasError(?string $name = null, string $bag = 'default'): bool 40 | { 41 | $errors = View::shared('errors', fn () => session()->get('errors', new ViewErrorBag)); 42 | 43 | $name = str_replace(['[', ']'], ['.', ''], (string) $name); 44 | 45 | return $errors->getBag($bag)->has($name); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Concerns/HasAddons.php: -------------------------------------------------------------------------------- 1 | leadingAddonClass(), 29 | $this->trailingAddonClass(), 30 | ]); 31 | } 32 | 33 | protected function leadingAddonClass(): ?string 34 | { 35 | if ($this->hasLeadingAddon()) { 36 | return 'has-leading-addon'; 37 | } 38 | 39 | if ($this->inlineAddon) { 40 | return 'has-inline-addon'; 41 | } 42 | 43 | return $this->leadingIcon ? 'has-leading-icon' : null; 44 | } 45 | 46 | protected function trailingAddonClass(): ?string 47 | { 48 | if ($this->trailingAddon) { 49 | return 'has-trailing-addon'; 50 | } 51 | 52 | if ($this->trailingInlineAddon) { 53 | return 'has-trailing-inline-addon'; 54 | } 55 | 56 | return $this->hasTrailingIcon() ? 'has-trailing-icon' : null; 57 | } 58 | 59 | /** 60 | * When certain props are set via slot instead of a prop 61 | * (e.g. instead of leading-addon="") 62 | * we need to set them in the render method as they don't get set in the constructor. 63 | */ 64 | protected function setSlotAddonAttributes(array $data): void 65 | { 66 | $slots = [ 67 | 'leadingAddon', 68 | 'inlineAddon', 69 | 'leadingIcon', 70 | 'trailingAddon', 71 | 'trailingInlineAddon', 72 | 'trailingIcon', 73 | ]; 74 | 75 | foreach ($data['__laravel_slots'] ?? [] as $slot => $slotValue) { 76 | if (in_array($slot, $slots, true)) { 77 | $this->{$slot} = $slotValue; 78 | } 79 | } 80 | } 81 | 82 | protected function hasTrailingIcon(): bool 83 | { 84 | return (bool) $this->trailingIcon; 85 | } 86 | 87 | protected function hasLeadingAddon(): bool 88 | { 89 | return (bool) $this->leadingAddon; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Concerns/HasExtraAttributes.php: -------------------------------------------------------------------------------- 1 | extraAttributes = is_iterable($attributes) 21 | ? $this->getExtraAttributesFromIterable($attributes) 22 | : $this->getExtraAttributesFromString($attributes); 23 | } 24 | 25 | private function getExtraAttributesFromIterable(array|Collection $attributes): HtmlString 26 | { 27 | $attributes = collect($attributes) 28 | ->filter() 29 | ->map(fn ($value, $key) => "{$key}=\"{$value}\"") 30 | ->implode(PHP_EOL); 31 | 32 | return new HtmlString($attributes); 33 | } 34 | 35 | private function getExtraAttributesFromString(string|HtmlString $attributes): HtmlString 36 | { 37 | return $attributes instanceof HtmlString ? $attributes : new HtmlString($attributes); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Concerns/HasModels.php: -------------------------------------------------------------------------------- 1 | hasWireModel() || $this->hasXModel(); 17 | } 18 | 19 | public function hasWireModel(): bool 20 | { 21 | if ($this->hasWireModel !== null) { 22 | return $this->hasWireModel; 23 | } 24 | 25 | return $this->hasWireModel = $this->attributes->hasStartsWith('wire:model'); 26 | } 27 | 28 | public function hasXModel(): bool 29 | { 30 | if ($this->hasXModel !== null) { 31 | return $this->hasXModel; 32 | } 33 | 34 | return $this->hasXModel = $this->attributes->hasStartsWith('x-model'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Controllers/Concerns/CanPretendToBeAFile.php: -------------------------------------------------------------------------------- 1 | matchesCache($lastModified)) { 17 | return response()->make('', 304, [ 18 | 'Expires' => $this->httpDate($expires), 19 | 'Cache-Control' => $cacheControl, 20 | ]); 21 | } 22 | 23 | return response()->file($file, [ 24 | 'Content-Type' => 'application/javascript; charset=utf-8', 25 | 'Expires' => $this->httpDate($expires), 26 | 'Cache-Control' => $cacheControl, 27 | 'Last-Modified' => $this->httpDate($lastModified), 28 | ]); 29 | } 30 | 31 | protected function matchesCache($lastModified): bool 32 | { 33 | $ifModifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? ''; 34 | 35 | return @strtotime($ifModifiedSince) === $lastModified; 36 | } 37 | 38 | protected function httpDate($timestamp): string 39 | { 40 | return sprintf('%s GMT', gmdate('D, d M Y H:i:s', $timestamp)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Controllers/FormComponentsJavaScriptAssets.php: -------------------------------------------------------------------------------- 1 | pretendResponseIsFile(__DIR__ . '/../../dist/form-components.js'); 16 | } 17 | 18 | public function maps() 19 | { 20 | return $this->pretendResponseIsFile(__DIR__ . '/../../dist/form-components.js.map'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Facades/FormComponents.php: -------------------------------------------------------------------------------- 1 | '] : []; 18 | 19 | $html[] = $this->javaScriptAssets($options); 20 | 21 | return implode(PHP_EOL, $html); 22 | } 23 | 24 | private function javaScriptAssets(array $options = []): string 25 | { 26 | $assetsUrl = config('form-components.asset_url') ?: rtrim($options['asset_url'] ?? '', '/'); 27 | $nonce = $this->getNonce($options); 28 | 29 | $manifest = json_decode(file_get_contents(__DIR__ . '/../dist/manifest.json'), true); 30 | $versionedFileName = $manifest['/form-components.js']; 31 | 32 | $fullAssetPath = "{$assetsUrl}/form-components{$versionedFileName}"; 33 | 34 | return << 36 | HTML; 37 | } 38 | 39 | private function getNonce(array $options): string 40 | { 41 | if (isset($options['nonce'])) { 42 | return "nonce=\"{$options['nonce']}\""; 43 | } 44 | 45 | // If there is a csp package installed, i.e. spatie/laravel-csp, we'll check for the existence of the helper function. 46 | if (function_exists('csp_nonce') && $nonce = csp_nonce()) { 47 | return "nonce=\"{$nonce}\""; 48 | } 49 | 50 | if (function_exists('cspNonce') && $nonce = cspNonce()) { 51 | return "nonce=\"{$nonce}\""; 52 | } 53 | 54 | // Lastly, we'll check for the existence of a csp nonce from Vite. 55 | if (class_exists(Vite::class) && $nonce = Vite::cspNonce()) { 56 | return "nonce=\"{$nonce}\""; 57 | } 58 | 59 | return ''; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/FormComponentsServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-form-components') 26 | ->hasConfigFile() 27 | ->hasTranslations() 28 | ->hasViews(); 29 | } 30 | 31 | public function packageBooted(): void 32 | { 33 | $this->bootBladeComponents(); 34 | $this->bootDirectives(); 35 | $this->bootMacros(); 36 | $this->bootRoutes(); 37 | } 38 | 39 | public function packageRegistered(): void 40 | { 41 | $this->app->singleton('form-components', FormComponents::class); 42 | $this->registerTimezone(); 43 | } 44 | 45 | private function bootBladeComponents(): void 46 | { 47 | // Register all components under a single namespace. 48 | Blade::componentNamespace('Rawilk\\FormComponents\\Components', 'form-components'); 49 | 50 | // Register aliases for certain components. 51 | $this->callAfterResolving(BladeCompiler::class, function (BladeCompiler $blade) { 52 | $prefix = config('form-components.prefix', ''); 53 | 54 | foreach (config('form-components.components', []) as $alias => $component) { 55 | $blade->component($component, $alias, $prefix); 56 | } 57 | }); 58 | } 59 | 60 | private function bootDirectives(): void 61 | { 62 | // Our custom tag compiler will allow us to use self-closing tags instead of a directive, 63 | // i.e. instead of @fcScripts. 64 | if (method_exists($this->app['blade.compiler'], 'precompiler')) { 65 | $this->app['blade.compiler']->precompiler(function ($string) { 66 | return app(FormComponentsTagCompiler::class)->compile($string); 67 | }); 68 | } 69 | 70 | Blade::directive('fcScripts', function (string $expression) { 71 | return ""; 72 | }); 73 | } 74 | 75 | private function bootMacros(): void 76 | { 77 | if (! ComponentAttributeBag::hasMacro('hasStartsWith')) { 78 | ComponentAttributeBag::macro('hasStartsWith', function ($key) { 79 | return (bool) $this->whereStartsWith($key)->first(); 80 | }); 81 | } 82 | } 83 | 84 | private function bootRoutes(): void 85 | { 86 | Route::get('/form-components/form-components.js', [FormComponentsJavaScriptAssets::class, 'source']); 87 | Route::get('/form-components/form-components.js.map', [FormComponentsJavaScriptAssets::class, 'maps']); 88 | } 89 | 90 | private function registerTimezone(): void 91 | { 92 | if (config('form-components.enable_timezone')) { 93 | $this->app->singleton('fc-timezone', fn () => new Timezone); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Support/FormComponentsTagCompiler.php: -------------------------------------------------------------------------------- 1 | compileFormComponentsSelfClosingTags($value); 14 | } 15 | 16 | protected function compileFormComponentsSelfClosingTags($value): array|string|null 17 | { 18 | $pattern = "/ 19 | < 20 | \s* 21 | fc\:([\w\-\:\.]*) 22 | \s* 23 | (? 24 | (?: 25 | \s+ 26 | [\w\-:.@]+ 27 | ( 28 | = 29 | (?: 30 | \\\"[^\\\"]*\\\" 31 | | 32 | \'[^\']*\' 33 | | 34 | [^\'\\\"=<>]+ 35 | ) 36 | )? 37 | )* 38 | \s* 39 | ) 40 | \/?> 41 | /x"; 42 | 43 | return preg_replace_callback($pattern, function (array $matches) { 44 | $component = $matches[1]; 45 | 46 | if ($component === 'javaScript' || $component === 'scripts') { 47 | return '@fcScripts'; 48 | } 49 | 50 | return ''; 51 | }, $value); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Support/TimeZoneRegion.php: -------------------------------------------------------------------------------- 1 | 'GMT', 14 | 'UTC' => 'UTC', 15 | ]; 16 | 17 | protected static array $regions = [ 18 | TimeZoneRegion::AFRICA => DateTimeZone::AFRICA, 19 | TimeZoneRegion::AMERICA => DateTimeZone::AMERICA, 20 | TimeZoneRegion::ANTARCTICA => DateTimeZone::ANTARCTICA, 21 | TimeZoneRegion::ARCTIC => DateTimeZone::ARCTIC, 22 | TimeZoneRegion::ASIA => DateTimeZone::ASIA, 23 | TimeZoneRegion::ATLANTIC => DateTimeZone::ATLANTIC, 24 | TimeZoneRegion::AUSTRALIA => DateTimeZone::AUSTRALIA, 25 | TimeZoneRegion::EUROPE => DateTimeZone::EUROPE, 26 | TimeZoneRegion::INDIAN => DateTimeZone::INDIAN, 27 | TimeZoneRegion::PACIFIC => DateTimeZone::PACIFIC, 28 | ]; 29 | 30 | /* 31 | * Specify a subset of regions to return. 32 | */ 33 | protected bool|array|string|null $only; 34 | 35 | protected array $timezones; 36 | 37 | public function __construct() 38 | { 39 | $this->only(config('form-components.timezone_subset', false)); 40 | } 41 | 42 | public function only(array|string|bool|null $only): self 43 | { 44 | if (is_string($only)) { 45 | $only = [$only]; 46 | } 47 | 48 | $this->only = $only; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * @deprecated Use allMapped() instead. 55 | */ 56 | public function all(): array 57 | { 58 | if (! empty($this->timezones) && $this->only === config('form-components.timezone_subset', false)) { 59 | return $this->timezones; 60 | } 61 | 62 | $timezones = []; 63 | 64 | if ($this->shouldIncludeRegion(TimeZoneRegion::GENERAL)) { 65 | $timezones[TimeZoneRegion::GENERAL] = self::$generalTimezones; 66 | } 67 | 68 | foreach ($this->regionsToInclude() as $region => $regionCode) { 69 | $regionTimezones = DateTimeZone::listIdentifiers($regionCode); 70 | $timezones[$region] = []; 71 | 72 | foreach ($regionTimezones as $timezone) { 73 | $format = $this->format($timezone); 74 | 75 | if ($format === false) { 76 | continue; 77 | } 78 | 79 | $timezones[$region][$timezone] = $format; 80 | } 81 | } 82 | 83 | // reset to default options 84 | $this->only = false; 85 | 86 | return $this->timezones = $timezones; 87 | } 88 | 89 | /** 90 | * Return the requested timezones but mapped to be compatible with our selects. 91 | * 92 | * @version 8.0.0 93 | */ 94 | public function allMapped(): array 95 | { 96 | if (! empty($this->timezones) && $this->only === config('form-components.timezone_subset', false)) { 97 | return $this->timezones; 98 | } 99 | 100 | $timezones = []; 101 | 102 | if ($this->shouldIncludeRegion(TimeZoneRegion::GENERAL)) { 103 | $timezones[] = [ 104 | 'id' => TimeZoneRegion::GENERAL, 105 | 'name' => TimeZoneRegion::GENERAL, 106 | 'children' => array_values(array_map(function ($timezone) { 107 | return [ 108 | 'id' => $timezone, 109 | 'name' => $timezone, 110 | ]; 111 | }, self::$generalTimezones)), 112 | ]; 113 | } 114 | 115 | foreach ($this->regionsToInclude() as $region => $regionCode) { 116 | $regionTimezones = DateTimeZone::listIdentifiers($regionCode); 117 | $model = [ 118 | 'id' => $region, 119 | 'name' => $region, 120 | 'children' => [], 121 | ]; 122 | 123 | foreach ($regionTimezones as $timezone) { 124 | $format = $this->format($timezone); 125 | 126 | if ($format === false) { 127 | continue; 128 | } 129 | 130 | $model['children'][] = [ 131 | 'id' => $timezone, 132 | 'name' => $format, 133 | ]; 134 | } 135 | 136 | $timezones[] = $model; 137 | } 138 | 139 | // Reset to default options. 140 | $this->only = false; 141 | 142 | return $this->timezones = $timezones; 143 | } 144 | 145 | protected function format(string $timezone): bool|string 146 | { 147 | $time = new DateTime('', new DateTimeZone($timezone)); 148 | $offset = $this->normalizeOffset($timezone, $time->format('P')); 149 | 150 | if ($offset === false) { 151 | return false; 152 | } 153 | 154 | $timezone = str_replace( 155 | ['St_', '_'], 156 | ['St.', ' '], 157 | $timezone 158 | ); 159 | 160 | return "(GMT/UTC {$offset}) {$timezone}"; 161 | } 162 | 163 | /** 164 | * This is only here because automated tests are returning different 165 | * timezone offsets for certain timezones than when tests are 166 | * ran locally. This may need to be addressed in the future... 167 | */ 168 | private function normalizeOffset(string $timezone, $offset): bool|string 169 | { 170 | return match ($timezone) { 171 | 'Africa/Juba' => '+02:00', 172 | 'Europe/Volgograd' => '+03:00', 173 | 'Australia/Currie' => false, 174 | default => $offset, 175 | }; 176 | } 177 | 178 | protected function regionsToInclude(): array 179 | { 180 | if ($this->only === false) { 181 | return self::$regions; 182 | } 183 | 184 | return array_filter(self::$regions, fn ($region) => $this->shouldIncludeRegion($region), ARRAY_FILTER_USE_KEY); 185 | } 186 | 187 | protected function shouldIncludeRegion(string $region): bool 188 | { 189 | if ($this->only === false) { 190 | return true; 191 | } 192 | 193 | return in_array($region, $this->only, true); 194 | } 195 | } 196 | --------------------------------------------------------------------------------