diff --git a/assets/css/app.css b/assets/css/app.css index 6aeda90b..838377da 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -133,99 +133,8 @@ } } -/* bootstrap-multiselect overrides */ -button.multiselect.dropdown-toggle { - display: block; - width: 100%; - background: #080d19; - border: 1px solid #1c3050; - border-radius: .375rem; - color: #e2e8f0; - padding: .5rem .75rem; - font-family: 'JetBrains Mono', monospace; - font-size: .875rem; - text-align: left; - cursor: pointer; - transition: border-color .15s, box-shadow .15s; -} - -button.multiselect.dropdown-toggle:hover, -button.multiselect.dropdown-toggle:focus { - border-color: #38bdf8; - box-shadow: 0 0 0 3px rgba(56, 189, 248, .2); - outline: none; -} - -.multiselect-container.dropdown-menu { - display: block; - position: relative; - z-index: 10; - width: 100%; - background: #080d19; - border: 1px solid #1c3050; - border-radius: .375rem; - margin-top: 2px; - padding: 4px 0; - list-style: none; - max-height: 200px; - overflow-y: auto; -} - -.multiselect-container > li > a { - display: block; - padding: 0; - color: #e2e8f0; - text-decoration: none; -} - -.multiselect-container > li > a > label { - display: block; - padding: 5px 12px 5px 36px; - cursor: pointer; - color: #e2e8f0; - font-size: .875rem; - font-family: 'DM Sans', sans-serif; - margin: 0; -} - -.multiselect-container > li > a > label:hover { - background: #1c3050; - color: #38bdf8; -} - -.multiselect-container > li > a > label > input[type=checkbox] { - margin-right: 8px; - accent-color: #34d399; -} - -.input-group-addon { - background: #0d1628; - border: 1px solid #1c3050; - color: #94a3b8; - padding: .375rem .5rem; - font-size: .875rem; -} - -.multiselect-container .input-group input.form-control { - background: #080d19; - border: 1px solid #1c3050; - border-left: 0; - color: #e2e8f0; - padding: .375rem .5rem; - font-size: .875rem; -} - -button.btn.btn-default.multiselect-clear-filter { - background: #0d1628; - border: 1px solid #1c3050; - color: #94a3b8; - padding: .25rem .5rem; - border-radius: .25rem; - cursor: pointer; -} - -/* Pure CSS toggles — replaces bootstrap-toggle entirely */ -#generator input[type=checkbox] { +/* Pure CSS toggles */ +#generator input[type=checkbox]:not(.ms-check) { appearance: none; -webkit-appearance: none; position: relative; @@ -241,7 +150,7 @@ button.btn.btn-default.multiselect-clear-filter { transition: background .2s, border-color .2s; } -#generator input[type=checkbox]::after { +#generator input[type=checkbox]:not(.ms-check)::after { content: ''; position: absolute; width: 18px; @@ -253,12 +162,12 @@ button.btn.btn-default.multiselect-clear-filter { transition: left .2s, background .2s; } -#generator input[type=checkbox]:checked { +#generator input[type=checkbox]:not(.ms-check):checked { background: #34d399; border-color: #34d399; } -#generator input[type=checkbox]:checked::after { +#generator input[type=checkbox]:not(.ms-check):checked::after { left: 22px; background: #fff; } diff --git a/package.json b/package.json index a1197b9d..ee9c4d0d 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "dependencies": { - "@bower_components/bootstrap-multiselect": "davidstutz/bootstrap-multiselect#0.9.13", -"@bower_components/font-awesome": "FortAwesome/Font-Awesome#^4.5.0", - "@bower_components/jquery": "jquery/jquery-dist#^2.2.2", - "@bower_components/jquery-ui": "components/jqueryui#^1.11.4" + "@bower_components/font-awesome": "FortAwesome/Font-Awesome#^4.5.0" }, "devDependencies": { "tailwindcss": "^3.4", diff --git a/public/css/app.css b/public/css/app.css index ce565483..aa48bc36 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -643,15 +643,27 @@ video { .block { display: block; } +.inline-block { + display: inline-block; +} .flex { display: flex; } .grid { display: grid; } +.h-10 { + height: 2.5rem; +} +.w-auto { + width: auto; +} .max-w-screen-xl { max-width: 1280px; } +.cursor-pointer { + cursor: pointer; +} .list-inside { list-style-position: inside; } @@ -714,10 +726,6 @@ video { padding-left: 1rem; padding-right: 1rem; } -.px-6 { - padding-left: 1.5rem; - padding-right: 1.5rem; -} .py-4 { padding-top: 1rem; padding-bottom: 1rem; @@ -730,130 +738,25 @@ video { padding-top: 2rem; padding-bottom: 2rem; } -.text-3xl { - font-size: 1.875rem; - line-height: 2.25rem; -} .text-sm { font-size: 0.875rem; line-height: 1.25rem; } -.font-extrabold { - font-weight: 800; -} .font-normal { font-weight: 400; } -.tracking-tight { - letter-spacing: -0.025em; -} .text-\[\#94a3b8\] { --tw-text-opacity: 1; color: rgb(148 163 184 / var(--tw-text-opacity, 1)); } -.text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)); -} .no-underline { text-decoration-line: none; } @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap'); -/* bootstrap-multiselect overrides */ -button.multiselect.dropdown-toggle { - display: block; - width: 100%; - background: #080d19; - border: 1px solid #1c3050; - border-radius: .375rem; - color: #e2e8f0; - padding: .5rem .75rem; - font-family: 'JetBrains Mono', monospace; - font-size: .875rem; - text-align: left; - cursor: pointer; - transition: border-color .15s, box-shadow .15s; -} - -button.multiselect.dropdown-toggle:hover, -button.multiselect.dropdown-toggle:focus { - border-color: #38bdf8; - box-shadow: 0 0 0 3px rgba(56, 189, 248, .2); - outline: none; -} - -.multiselect-container.dropdown-menu { - display: block; - position: relative; - z-index: 10; - width: 100%; - background: #080d19; - border: 1px solid #1c3050; - border-radius: .375rem; - margin-top: 2px; - padding: 4px 0; - list-style: none; - max-height: 200px; - overflow-y: auto; -} - -.multiselect-container > li > a { - display: block; - padding: 0; - color: #e2e8f0; - text-decoration: none; -} - -.multiselect-container > li > a > label { - display: block; - padding: 5px 12px 5px 36px; - cursor: pointer; - color: #e2e8f0; - font-size: .875rem; - font-family: 'DM Sans', sans-serif; - margin: 0; -} - -.multiselect-container > li > a > label:hover { - background: #1c3050; - color: #38bdf8; -} - -.multiselect-container > li > a > label > input[type=checkbox] { - margin-right: 8px; - accent-color: #34d399; -} - -.input-group-addon { - background: #0d1628; - border: 1px solid #1c3050; - color: #94a3b8; - padding: .375rem .5rem; - font-size: .875rem; -} - -.multiselect-container .input-group input.form-control { - background: #080d19; - border: 1px solid #1c3050; - border-left: 0; - color: #e2e8f0; - padding: .375rem .5rem; - font-size: .875rem; -} - -button.btn.btn-default.multiselect-clear-filter { - background: #0d1628; - border: 1px solid #1c3050; - color: #94a3b8; - padding: .25rem .5rem; - border-radius: .25rem; - cursor: pointer; -} - -/* Pure CSS toggles — replaces bootstrap-toggle entirely */ -#generator input[type=checkbox] { +/* Pure CSS toggles */ +#generator input[type=checkbox]:not(.ms-check) { -moz-appearance: none; appearance: none; -webkit-appearance: none; @@ -870,7 +773,7 @@ button.btn.btn-default.multiselect-clear-filter { transition: background .2s, border-color .2s; } -#generator input[type=checkbox]::after { +#generator input[type=checkbox]:not(.ms-check)::after { content: ''; position: absolute; width: 18px; @@ -882,12 +785,12 @@ button.btn.btn-default.multiselect-clear-filter { transition: left .2s, background .2s; } -#generator input[type=checkbox]:checked { +#generator input[type=checkbox]:not(.ms-check):checked { background: #34d399; border-color: #34d399; } -#generator input[type=checkbox]:checked::after { +#generator input[type=checkbox]:not(.ms-check):checked::after { left: 22px; background: #fff; } diff --git a/public/js/main-form.js b/public/js/main-form.js index e4f4799e..0a811b65 100644 --- a/public/js/main-form.js +++ b/public/js/main-form.js @@ -1,146 +1,63 @@ -$(doMainFormMagic) +document.addEventListener('DOMContentLoaded', doMainFormMagic) -/** - * Generator form JS handles - */ function doMainFormMagic () { - /** - * Enable/disable form elements based on checkboxes - */ - [ - 'postgres', - 'mysql', - 'mariadb', - 'elasticsearch' - ].forEach(function (value) { - var optionsDiv = $('#' + value + '-options') - var optionsFields = optionsDiv.find('input') - var switchNode = $('#project_' + value + 'Options_has' + ucfirst(value)) - - var disableOptions = function () { - if (switchNode.prop('checked') == false) { - optionsDiv.addClass('disabled') - optionsFields.prop('disabled', true) - } + // Enable/disable form elements based on checkboxes + const toggleServices = [ + { optionsDiv: '#postgres-options', switchId: '#project_postgresOptions_hasPostgres' }, + { optionsDiv: '#mysql-options', switchId: '#project_mysqlOptions_hasMysql' }, + { optionsDiv: '#mariadb-options', switchId: '#project_mariadbOptions_hasMariadb' }, + { optionsDiv: '#elasticsearch-options', switchId: '#project_elasticsearchOptions_hasElasticsearch' }, + ] + + toggleServices.forEach(({ optionsDiv: divId, switchId }) => { + const optionsDiv = document.querySelector(divId) + const optionsFields = optionsDiv.querySelectorAll('input') + const switchNode = document.querySelector(switchId) + + const toggle = () => { + const on = switchNode.checked + optionsDiv.classList.toggle('disabled', !on) + optionsFields.forEach(el => el.disabled = !on) } - // Disable on page load - disableOptions() - - var enableOptions = function () { - optionsDiv.removeClass('disabled') - optionsFields.prop('disabled', false) - } - - // Toggle on checkbox changes - switchNode.on('change', function () { - if (switchNode.prop('checked') == true) { - enableOptions() - } else { - disableOptions() - } - }) + toggle() + switchNode.addEventListener('change', toggle) }) // Select PHP extensions based on service choices - let checkboxPrefix = 'project_' - let extensionServices = [] - let extensionMultiSelects = $('[id^=project_phpOptions_phpExtensions]') - extensionServices['hasRedis'] = 'Redis' - extensionServices['hasMemcached'] = 'Memcached' - extensionServices['mysqlOptions_hasMysql'] = 'MySQL' - extensionServices['mariadbOptions_hasMariadb'] = 'MySQL' - extensionServices['postgresOptions_hasPostgres'] = 'PostgreSQL' - - for (var key in extensionServices) { - var value = extensionServices[key] - var checkboxId = '#' + checkboxPrefix + key - - $(checkboxId) - .data('multiselect', extensionMultiSelects) - .data('value', value) - .change(function () { - $(this).data('multiselect').multiselect('select', $(this).data('value')) - }) + const extensionServices = { + hasRedis: 'Redis', + hasMemcached: 'Memcached', + mysqlOptions_hasMysql: 'MySQL', + mariadbOptions_hasMariadb: 'MySQL', + postgresOptions_hasPostgres: 'PostgreSQL', } - // PHP extension multiselect - extensionMultiSelects.each(function (index) { - $(this).multiselect({ - enableCaseInsensitiveFiltering: true, - maxHeight: 200, - buttonWidth: '100%', - dropUp: true, - onDropdownHide: function (event) { - event.preventDefault() + const phpExtensionsData = JSON.parse( + document.getElementById('php-extensions-data').textContent + ) + const msEl = document.querySelector('[id^=project_phpOptions_phpExtensions]') + const ms = new PHPDockerMultiSelect(msEl) + + Object.entries(extensionServices).forEach(([key, value]) => { + document.querySelector('#project_' + key).addEventListener('change', function () { + if (this.checked) { + ms.selectByText(value) } }) - - // Hide all but the first one - if (index !== 0) { - $(this).parents('.form-group').hide() - } }) - /*** UGLY HACK ***/ - // Open multiselects - $('button.multiselect').trigger("click") - - // Unfortunately, the previous "click" on the multiselects makes the page scroll on load - // Negate - $(window).scrollTop(0) - - /*** END OF UGLY HACK ***/ - - // Focus on the first form field - $('form:not(.filter) :input:visible:enabled:first').on('focus') - - /** - * Change multiselect based on php version chosen - */ - let phpVersionSelector = $('#project_phpOptions_version') - phpVersionSelector.on('change', function () { - extensionMultiSelects.parents('.form-group').hide() - - let chosenVersion = '85' - switch ($(this).val()) { - case '8.1': - chosenVersion = '81' - break - - case '8.2': - chosenVersion = '82' - break - - case '8.3': - chosenVersion = '83' - break - - case '8.4': - chosenVersion = '84' - break - } - - extensionMultiSelects.filter('[id$=' + chosenVersion + ']').parents('.form-group').show() - }) - - const form = $('#generator') - const hiddenFieldId = 'hidden-phpversion' - - phpVersionSelector.on('change',function () { - let hiddenField = $('#' + hiddenFieldId) - if (hiddenField.length) { - hiddenField.val(phpVersionSelector.val()) - } + // Change multiselect based on php version chosen + document.querySelector('#project_phpOptions_version').addEventListener('change', function () { + ms.setOptions(phpExtensionsData[this.value] ?? []) }) // Analytics - form.on('submit', function (event) { - $('input[type=checkbox]').each(function () { - gtag('send', 'event', 'builder-form', 'builder-choices', $(this).attr('name'), $(this).is(':checked')) + document.querySelector('#generator').addEventListener('submit', function () { + document.querySelectorAll('input[type=checkbox]').forEach(function (el) { + gtag('send', 'event', 'builder-form', 'builder-choices', el.name, el.checked) }) gtag('send', 'event', 'builder-form', 'form-submission') }) - } diff --git a/public/js/multiselect.js b/public/js/multiselect.js new file mode 100644 index 00000000..75f7e136 --- /dev/null +++ b/public/js/multiselect.js @@ -0,0 +1,143 @@ +'use strict' + +class PHPDockerMultiSelect { + constructor (selectEl) { + this._select = selectEl + this._options = Array.from(selectEl.options) + this._wrapper = null + this._button = null + this._dropdown = null + this._filterInput = null + this._list = null + this._isOpen = false + + selectEl.style.display = 'none' + this._build() + this._updateLabel() + + document.addEventListener('click', (e) => { + if (!this._wrapper.contains(e.target)) { + this.close() + } + }) + } + + _build () { + const wrapper = document.createElement('div') + wrapper.className = 'ms-wrapper relative' + + const button = document.createElement('button') + button.type = 'button' + button.className = 'form-input text-left cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap' + button.innerHTML = '' + button.addEventListener('click', (e) => { + e.preventDefault() + this._isOpen ? this.close() : this.open() + }) + + const dropdown = document.createElement('div') + dropdown.className = 'absolute top-full w-full mt-1 z-10 bg-[#080d19] border border-[#1c3050] rounded-md hidden' + + const filterInput = document.createElement('input') + filterInput.type = 'text' + filterInput.placeholder = 'Filter…' + filterInput.className = 'w-full text-[#e2e8f0] text-sm outline-none' + filterInput.style.cssText = 'background:#0d1628; border:1px solid #1c3050; border-radius:0.375rem; padding:0.5rem 0.75rem; margin:0.5rem; width:calc(100% - 1rem); box-sizing:border-box;' + filterInput.addEventListener('input', () => this._render(filterInput.value)) + + const list = document.createElement('ul') + list.className = 'list-none m-0' + list.style.cssText = 'height:12rem; overflow-y:auto; padding:0.5rem;' + + dropdown.appendChild(filterInput) + dropdown.appendChild(list) + wrapper.appendChild(button) + wrapper.appendChild(dropdown) + + this._select.insertAdjacentElement('afterend', wrapper) + + this._wrapper = wrapper + this._button = button + this._dropdown = dropdown + this._filterInput = filterInput + this._list = list + + this._render('') + } + + _render (filter) { + const lowerFilter = filter.toLowerCase() + this._list.innerHTML = '' + + this._options.forEach((option) => { + if (lowerFilter && !option.text.toLowerCase().includes(lowerFilter)) { + return + } + + const li = document.createElement('li') + const label = document.createElement('label') + label.className = 'flex items-center gap-3 px-3 cursor-pointer text-sm text-[#e2e8f0] hover:bg-[#1c3050] hover:text-[#38bdf8]' + label.style.cssText = 'padding-top:0.375rem; padding-bottom:0.375rem;' + + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.value = option.value + checkbox.checked = option.selected + // no class — CSS rule `#generator input[type=checkbox]:not(.ms-check)` applies the toggle style + + checkbox.addEventListener('change', () => { + option.selected = checkbox.checked + this._updateLabel() + this._select.dispatchEvent(new Event('change', { bubbles: true })) + }) + + label.appendChild(checkbox) + label.appendChild(document.createTextNode(option.text)) + li.appendChild(label) + this._list.appendChild(li) + }) + } + + _updateLabel () { + const selected = this._options.filter(o => o.selected).map(o => o.text) + const labelEl = this._button.querySelector('.ms-label') + labelEl.textContent = selected.length > 0 ? selected.join(', ') : 'None selected' + } + + selectByText (text) { + const lower = text.toLowerCase() + this._options.forEach(option => { + if (option.text.toLowerCase() === lower) { + option.selected = true + } + }) + this._render(this._filterInput.value) + this._updateLabel() + } + + open () { + this._dropdown.classList.remove('hidden') + this._filterInput.focus() + this._isOpen = true + } + + close () { + this._dropdown.classList.add('hidden') + this._isOpen = false + } + + setOptions (names) { + // Rebuild native