diff --git a/README.md b/README.md index 72600bc..cd743c3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,81 @@ The default modern color palette includes: - **Warning**: Amber (#f59e0b) - **Danger**: Modern red (#ef4444) +## Autocomplete + Create Field (Bootstrap 5) + +This package includes reusable backend traits and a Bootstrap 5 field partial for "search and create if missing" flows. + +### 1) Controller traits + +Use one or both traits from: + +- `ByTIC\AdminBase\Library\Controllers\Traits\HasAutocompleteSearchTrait` +- `ByTIC\AdminBase\Library\Controllers\Traits\HasAutocompleteCreateTrait` + +You must implement: + +- `autocompleteSearchFetchRecords(string $query, int $limit): iterable` +- `autocompleteCreatePersistRecord(string $name): mixed` + +Optional extension points: + +- duplicate lookup: `autocompleteCreateFindDuplicateRecord(string $name): mixed` +- request parameter names (`q`, `limit`, `name`) +- limits, error messages, and record formatting + +### 2) Response contract + +Search success: + +```json +{ + "success": true, + "data": [{"id": 1, "label": "Example"}], + "meta": {"query": "exa", "limit": 10, "count": 1} +} +``` + +Create success: + +```json +{ + "success": true, + "data": {"id": 1, "label": "Example"}, + "meta": {"created": true} +} +``` + +Error: + +```json +{ + "success": false, + "error": {"code": "duplicate", "message": "...", "details": {}} +} +``` + +### 3) View partial + +Use field partial: + +- `/abstract/modules/item-form/fields/autocomplete_create` + +Expected variables: + +- required: `field`, `searchUrl` +- optional: `createUrl`, `selectedId`, `selectedLabel`, `label`, `placeholder`, `minChars`, `limit`, `required`, `readonly` + +### 4) Frontend behavior + +The Bootstrap 5 admin bundle now includes an autocomplete component that: + +- debounces search requests +- renders results dropdown +- supports keyboard navigation and selection +- shows inline "create new" when no results +- creates and selects new record via AJAX +- handles Turbo navigation lifecycle cleanup + #### UI INSPIRATION https://coreui.io/ https://github.com/coreui/coreui-free-bootstrap-admin-template diff --git a/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss new file mode 100644 index 0000000..c00b250 --- /dev/null +++ b/resources/themes/bootstrap5/assets/sass/ui/_autocomplete-create.scss @@ -0,0 +1,45 @@ +.btc-autocomplete-create-field { + .btc-autocomplete-create-menu { + max-height: 260px; + overflow-y: auto; + margin-top: .125rem; + border: 1px solid var(--bs-border-color); + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .12); + } + + .btc-autocomplete-create-option.active, + .btc-autocomplete-create-option:active { + background-color: var(--bs-primary); + color: var(--bs-white); + } + + .btc-autocomplete-create-new { + border-top: 1px solid var(--bs-border-color-translucent); + font-weight: 500; + } + + .btc-autocomplete-create-feedback { + margin-top: .35rem; + } + + .btc-autocomplete-create-reset { + min-width: 2.5rem; + } + + &.is-loading { + .btc-autocomplete-create-input { + background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, color-mix(in srgb, var(--bs-primary) 10%, transparent) 50%, rgba(255, 255, 255, 0) 100%); + background-size: 200% 100%; + animation: btc-autocomplete-loading 1s linear infinite; + } + } +} + +@keyframes btc-autocomplete-loading { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} diff --git a/resources/themes/bootstrap5/assets/sass/ui/core.scss b/resources/themes/bootstrap5/assets/sass/ui/core.scss index e15ca3d..830d4f7 100644 --- a/resources/themes/bootstrap5/assets/sass/ui/core.scss +++ b/resources/themes/bootstrap5/assets/sass/ui/core.scss @@ -1,7 +1,7 @@ @import "cards"; +@import "autocomplete-create"; @import "content"; @import "content-header"; @import "header"; @import "sidebar"; @import "modern-enhancements"; - diff --git a/resources/themes/bootstrap5/assets/scripts/admin/app.js b/resources/themes/bootstrap5/assets/scripts/admin/app.js index ee3ea06..07a4eb1 100644 --- a/resources/themes/bootstrap5/assets/scripts/admin/app.js +++ b/resources/themes/bootstrap5/assets/scripts/admin/app.js @@ -7,6 +7,7 @@ import { TopMenuComponent } from './components/top-menu'; import { PanelComponent } from './components/panel'; import { ThemeComponent } from './components/theme'; import { AjaxComponent, applyPageOption } from './components/ajax'; +import { AutocompleteCreateComponent } from './components/AutocompleteCreate'; /** * ByticAdminApp @@ -34,6 +35,7 @@ class ByticAdminApp { .register(new TopMenuComponent()) .register(new PanelComponent()) .register(new ThemeComponent()) + .register(new AutocompleteCreateComponent()) .register(new AjaxComponent()); } diff --git a/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js new file mode 100644 index 0000000..b5a3ee4 --- /dev/null +++ b/resources/themes/bootstrap5/assets/scripts/admin/components/AutocompleteCreate.js @@ -0,0 +1,438 @@ +import $ from 'jquery'; +import { BaseComponent } from './base-component'; + +const FIELD_SELECTOR = '[data-autocomplete-create-field="true"]'; +const DEBOUNCE_DELAY = 250; +const BLUR_CLOSE_DELAY = 200; + +function parseIntOrDefault(value, fallback) { + var parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function coerceToBoolean(value) { + return value === true || value === 'true' || value === 1 || value === '1'; +} + +function getConfig($field) { + var dataset = $field.data(); + + return { + searchUrl: dataset.searchUrl || '', + searchMethod: (dataset.searchMethod || 'GET').toUpperCase(), + createUrl: dataset.createUrl || '', + createMethod: (dataset.createMethod || 'POST').toUpperCase(), + queryParam: dataset.queryParam || 'q', + limitParam: dataset.limitParam || 'limit', + nameParam: dataset.nameParam || 'name', + minChars: parseIntOrDefault(dataset.minChars, 2), + limit: parseIntOrDefault(dataset.limit, 10), + debounceDelay: parseIntOrDefault(dataset.debounceDelay, DEBOUNCE_DELAY), + blurCloseDelay: parseIntOrDefault(dataset.blurCloseDelay, BLUR_CLOSE_DELAY), + messageEmpty: dataset.messageEmpty || 'No matches found', + messageCreate: dataset.messageCreate || 'Create', + messageLoading: dataset.messageLoading || 'Loading', + messageError: dataset.messageError || 'Something went wrong', + readonly: coerceToBoolean(dataset.readonly), + disabled: coerceToBoolean(dataset.disabled), + }; +} + +function getState($field) { + var state = $field.data('btcAutocompleteCreateState'); + if (!state) { + state = { + debounceTimer: null, + activeXhr: null, + activeIndex: -1, + items: [], + }; + $field.data('btcAutocompleteCreateState', state); + } + return state; +} + +function getElements($field) { + return { + input: $field.find('.btc-autocomplete-create-input'), + idInput: $field.find('.btc-autocomplete-create-id'), + resetBtn: $field.find('.btc-autocomplete-create-reset'), + menu: $field.find('.btc-autocomplete-create-menu'), + feedback: $field.find('.btc-autocomplete-create-feedback'), + }; +} + +function clearFeedback($elements) { + $elements.feedback.text('').addClass('d-none'); +} + +function showFeedback($elements, message) { + $elements.feedback.text(message).removeClass('d-none'); +} + +function openMenu($elements) { + $elements.menu.addClass('show'); +} + +function closeMenu($elements) { + $elements.menu.removeClass('show').empty(); +} + +function normalizeItems(response) { + if (!response || response.success !== true || !Array.isArray(response.data)) { + return []; + } + + return response.data + .map(function (item) { + if (!item) { + return null; + } + return { + id: item.id, + label: item.label || item.name || item.title || '', + }; + }) + .filter(function (item) { + return item && item.label !== ''; + }); +} + +function setSelected($field, selectedId, selectedLabel) { + var $elements = getElements($field); + + $elements.idInput.val(selectedId || ''); + $elements.input.val(selectedLabel || ''); + $elements.resetBtn.toggleClass('d-none', !(selectedLabel || selectedId)); + + clearFeedback($elements); + closeMenu($elements); +} + +function setLoading($field, loading) { + var $elements = getElements($field); + $field.toggleClass('is-loading', loading); + $elements.input.attr('aria-busy', loading ? 'true' : 'false'); +} + +function renderMenu($field, items, query) { + var config = getConfig($field); + var state = getState($field); + var $elements = getElements($field); + + state.items = items; + state.activeIndex = -1; + + if (items.length > 0) { + var rows = items.map(function (item, index) { + var $button = $('', { + type: 'button', + class: 'dropdown-item btc-autocomplete-create-option', + }); + $button.attr('data-index', String(index)); + $button.attr('data-id', String(item.id)); + $button.text(item.label); + return $button.prop('outerHTML'); + }); + $elements.menu.html(rows.join('')); + openMenu($elements); + return; + } + + var html = '' + $('').text(config.messageEmpty).html() + ''; + if (config.createUrl) { + var $createButton = $('', { + type: 'button', + class: 'dropdown-item btc-autocomplete-create-new', + }); + $createButton.attr('data-name', query); + $createButton.append(''); + $createButton.append(document.createTextNode(config.messageCreate + ' "' + query + '"')); + html += $createButton.prop('outerHTML'); + } + $elements.menu.html(html); + openMenu($elements); +} + +function searchRecords($field, query) { + var config = getConfig($field); + var state = getState($field); + var $elements = getElements($field); + + if (!config.searchUrl) { + showFeedback($elements, config.messageError); + return; + } + + if (state.activeXhr && typeof state.activeXhr.abort === 'function') { + state.activeXhr.abort(); + } + + setLoading($field, true); + clearFeedback($elements); + + var data = {}; + data[config.queryParam] = query; + data[config.limitParam] = config.limit; + + state.activeXhr = $.ajax({ + url: config.searchUrl, + type: config.searchMethod, + dataType: 'json', + data: data, + }) + .done(function (response) { + var items = normalizeItems(response); + renderMenu($field, items, query); + }) + .fail(function () { + showFeedback($elements, config.messageError); + closeMenu($elements); + }) + .always(function () { + state.activeXhr = null; + setLoading($field, false); + }); +} + +function createRecord($field, name) { + var config = getConfig($field); + var state = getState($field); + var $elements = getElements($field); + + if (!config.createUrl) { + return; + } + + if (state.activeXhr && typeof state.activeXhr.abort === 'function') { + state.activeXhr.abort(); + } + + setLoading($field, true); + clearFeedback($elements); + + var data = {}; + data[config.nameParam] = name; + + state.activeXhr = $.ajax({ + url: config.createUrl, + type: config.createMethod, + dataType: 'json', + data: data, + }) + .done(function (response) { + if (!response || response.success !== true || !response.data) { + showFeedback($elements, config.messageError); + return; + } + + var item = { + id: response.data.id, + label: response.data.label || name, + }; + setSelected($field, item.id, item.label); + }) + .fail(function (xhr) { + var message = config.messageError; + if (xhr && xhr.responseJSON && xhr.responseJSON.error && xhr.responseJSON.error.message) { + message = xhr.responseJSON.error.message; + } + showFeedback($elements, message); + }) + .always(function () { + state.activeXhr = null; + setLoading($field, false); + }); +} + +function highlightOption($field, nextIndex) { + var state = getState($field); + var $elements = getElements($field); + var $options = $elements.menu.find('.btc-autocomplete-create-option'); + + if ($options.length === 0) { + return; + } + + if (nextIndex < 0) { + nextIndex = $options.length - 1; + } + if (nextIndex >= $options.length) { + nextIndex = 0; + } + + state.activeIndex = nextIndex; + $options.removeClass('active'); + $options.eq(nextIndex).addClass('active'); +} + +export class AutocompleteCreateComponent extends BaseComponent { + constructor() { + super(); + this._mounted = false; + } + + onSetup(/* app */) { + if (this._mounted) { + return; + } + + this._bindHandlers(); + this._mounted = true; + } + + onInit(/* app */) { + $(FIELD_SELECTOR).each(function () { + var $field = $(this); + var $elements = getElements($field); + $elements.resetBtn.toggleClass('d-none', !$elements.input.val() && !$elements.idInput.val()); + }); + } + + onBeforeCache(/* app */) { + $(FIELD_SELECTOR).each(function () { + var $field = $(this); + var state = getState($field); + var $elements = getElements($field); + + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + state.debounceTimer = null; + } + if (state.activeXhr && typeof state.activeXhr.abort === 'function') { + state.activeXhr.abort(); + } + + closeMenu($elements); + clearFeedback($elements); + setLoading($field, false); + }); + } + + _bindHandlers() { + var self = this; + + $(document) + .off('input.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input') + .on('input.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function () { + var $input = $(this); + var $field = $input.closest(FIELD_SELECTOR); + var config = getConfig($field); + var state = getState($field); + var $elements = getElements($field); + var query = ($input.val() || '').toString().trim(); + + if (config.readonly || config.disabled) { + return; + } + + $elements.idInput.val(''); + $elements.resetBtn.toggleClass('d-none', query.length < 1); + + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + } + + if (query.length < config.minChars) { + closeMenu($elements); + clearFeedback($elements); + return; + } + + state.debounceTimer = setTimeout(function () { + searchRecords($field, query); + }, config.debounceDelay); + }); + + $(document) + .off('keydown.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input') + .on('keydown.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function (event) { + var $field = $(this).closest(FIELD_SELECTOR); + var state = getState($field); + var $elements = getElements($field); + + if (!$elements.menu.hasClass('show')) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + highlightOption($field, state.activeIndex + 1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + highlightOption($field, state.activeIndex - 1); + return; + } + if (event.key === 'Enter' && state.activeIndex > -1) { + event.preventDefault(); + $elements.menu.find('.btc-autocomplete-create-option').eq(state.activeIndex).trigger('click'); + return; + } + if (event.key === 'Escape') { + closeMenu($elements); + } + }); + + $(document) + .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-option') + .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-option', function () { + var $option = $(this); + var $field = $option.closest(FIELD_SELECTOR); + var index = parseIntOrDefault($option.data('index'), -1); + var state = getState($field); + var item = state.items[index]; + + if (!item) { + return; + } + + setSelected($field, item.id, item.label); + }); + + $(document) + .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-new') + .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-new', function () { + var $action = $(this); + var $field = $action.closest(FIELD_SELECTOR); + var $elements = getElements($field); + var name = ($action.data('name') || $elements.input.val() || '').toString().trim(); + if (!name) { + return; + } + createRecord($field, name); + }); + + $(document) + .off('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-reset') + .on('click.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-reset', function () { + var $field = $(this).closest(FIELD_SELECTOR); + setSelected($field, '', ''); + }); + + $(document) + .off('click.colorAdminAutocompleteCreateOutside') + .on('click.colorAdminAutocompleteCreateOutside', function (event) { + $(FIELD_SELECTOR).each(function () { + var $field = $(this); + var $elements = getElements($field); + if (!$(event.target).closest($field).length) { + closeMenu($elements); + } + }); + }); + + $(document) + .off('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input') + .on('blur.colorAdminAutocompleteCreate', FIELD_SELECTOR + ' .btc-autocomplete-create-input', function () { + var $field = $(this).closest(FIELD_SELECTOR); + var config = getConfig($field); + window.setTimeout(function () { + closeMenu(getElements($field)); + }, config.blurCloseDelay); + }); + } +} + +export default AutocompleteCreateComponent; diff --git a/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php new file mode 100644 index 0000000..8ac71fd --- /dev/null +++ b/resources/themes/bootstrap5/views/abstract/modules/item-form/fields/autocomplete_create.php @@ -0,0 +1,98 @@ +item ?? ($this->clone ?? null)); +$field = $field ?? 'record_id'; +$label = $label ?? ucfirst(str_replace('_', ' ', $field)); +$readonly = (bool)($readonly ?? false); +$required = (bool)($required ?? false); + +$searchUrl = $searchUrl ?? ''; +$createUrl = $createUrl ?? ''; +$searchMethod = strtoupper((string)($searchMethod ?? 'GET')); +$createMethod = strtoupper((string)($createMethod ?? 'POST')); + +$queryParam = $queryParam ?? 'q'; +$limitParam = $limitParam ?? 'limit'; +$nameParam = $nameParam ?? 'name'; +$minChars = (int)($minChars ?? 2); +$limit = (int)($limit ?? 10); +$debounceDelay = (int)($debounceDelay ?? 250); +$blurCloseDelay = (int)($blurCloseDelay ?? 200); +$disabled = (bool)($disabled ?? false); + +$selectedId = $selectedId ?? ($item && isset($item->{$field}) ? $item->{$field} : ''); +$selectedLabel = $selectedLabel ?? ($item && isset($item->{$field . '_label'}) ? $item->{$field . '_label'} : ''); +$placeholder = $placeholder ?? translator()->trans('Search or create'); + +$messages = array_merge( + [ + 'empty' => translator()->trans('No matches found'), + 'create' => translator()->trans('Create'), + 'loading' => translator()->trans('Loading'), + 'error' => translator()->trans('Something went wrong'), + ], + $messages ?? [] +); +?> +