Last active
October 21, 2024 13:02
-
-
Save owen2345/ac639f8fb9dff35881b29937f2c49224 to your computer and use it in GitHub Desktop.
Custom dropdown for stimulus + bootstrap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// TODO: | |
// - Show above the selected items | |
// - When one item, show the selected value visible | |
import { Controller } from '@hotwired/stimulus'; | |
import I18n from "../misc/translations"; | |
// Sample: | |
// div{ data-controller: 'form-dropdown' } <select /> | |
export default class extends Controller { | |
static values = { | |
sort: { type: Boolean, default: true }, | |
searchGroups: { type: Boolean, default: false }, | |
toggledGroups: { type: Boolean, default: false }, | |
}; | |
declare element: HTMLElement; | |
declare defaultOption: string; | |
declare dropdown: HTMLElement; | |
declare select: HTMLSelectElement; | |
declare sortValue: boolean; | |
declare searchGroupsValue: boolean; | |
declare toggledGroupsValue: boolean; | |
declare toggledGroupClass: string; | |
declare remoteOptionsUrl: string; | |
declare remoteOptionsLoaded: boolean; | |
declare textAlign: string; | |
initialize() { | |
this.toggledGroupClass = 'closed'; | |
this.select = this.element.querySelector('select'); | |
this.textAlign = this.select.classList.contains('text-end') ? 'text-end' : 'text-start'; | |
this.defaultOption = this.select.querySelector('option[value=""]')?.textContent || `-- ${I18n('select')} --`; | |
this.remoteOptionsUrl = this.select.getAttribute('data-remote-url'); | |
this.buildDropdown(); | |
this.bindEvents(); | |
} | |
buildDropdown() { | |
this.element.querySelector('.form-dropdown')?.remove(); | |
const tpl = `<div class="dropdown ${this.select.getAttribute('class')}"> | |
<button class="btn border dropdown-toggle w-100 d-flex justify-content-between align-items-center ${this.textAlign}" type="button" data-bs-toggle="dropdown"> | |
<span class="dropdown-text text-truncate"></span> | |
<span class="caret"></span> | |
</button> | |
<ul class="dropdown-menu"></ul> | |
</div>`; | |
this.select.classList.add('d-none'); | |
this.select.insertAdjacentHTML('afterend', tpl); | |
this.dropdown = this.select.nextElementSibling; | |
this.buildDropdownOptions(); | |
} | |
buildDropdownOptions() { | |
this.dropdown.querySelector('.dropdown-menu').innerHTML = this.dropdownOptions(); | |
this.updateText(); | |
} | |
focusSearch() { | |
setTimeout(() => this.dropdown.querySelector('input[type="search"]')?.focus(), 1); | |
} | |
selectOption(e) { | |
e.preventDefault(); | |
if (this.select.multiple) e.stopPropagation(); | |
const li = e.target.closest('li'); | |
const checkbox = li.querySelector<HTMLInputElement>('input'); | |
// hack: wait for the checkbox to be checked when clicked directly on the checkbox | |
setTimeout(() => { | |
this.updateCheckboxes([checkbox]); | |
if (!this.select.multiple) this.dropdown.blur(); | |
}, 0); | |
} | |
selectAll(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const checkbox = e.target.closest('li').querySelector<HTMLInputElement>('input'); | |
const checkboxes = this.dropdown.querySelectorAll<HTMLInputElement>('li:not(.d-none) input.option-input'); | |
setTimeout(() => { // Fix checked checkbox | |
checkbox.checked = !checkbox.checked; | |
this.updateCheckboxes(checkboxes, checkbox.checked); | |
}, 0); | |
} | |
updateCheckboxes(checkboxes, forcedValue = null) { | |
if (!this.select.multiple) this.dropdown.querySelectorAll('.dropdown-item').forEach((option) => option.classList.remove('active')); | |
checkboxes.forEach((checkbox) => { | |
checkbox.checked = forcedValue !== null ? forcedValue : !checkbox.checked; | |
this.select.querySelector(`option[value="${checkbox.value}"]`).selected = checkbox.checked; | |
const option = checkbox.closest('.dropdown-item'); | |
if (checkbox.checked) option.classList.add('active'); | |
else option.classList.remove('active'); | |
}); | |
this.select.dispatchEvent(new Event('change')); | |
this.updateText(); | |
} | |
updateText() { | |
let text = null; | |
if (this.select.multiple) { | |
const selected = this.element.querySelectorAll('option:checked'); | |
text = selected.length ? `${I18n('selected_items', { qty: selected.length })}` : this.defaultOption; | |
} else { | |
text = this.select.querySelector('option:checked')?.textContent || this.defaultOption; | |
} | |
this.dropdown.querySelector('.dropdown-text').textContent = text; | |
} | |
filterOptions(e) { | |
const query = e.target.value.toLowerCase(); | |
if (this.searchGroupsValue) { | |
this.dropdown.querySelectorAll('.group-item').forEach((group) => { | |
if (group.getAttribute('data-label').toLowerCase().includes(query)) group.classList.remove('d-none'); | |
else group.classList.add('d-none'); | |
}); | |
} else { | |
this.dropdown.querySelectorAll<HTMLInputElement>('input.option-input').forEach((input) => { | |
const li = input.closest('li'); | |
li.querySelector('label').textContent.toLowerCase().includes(query) ? li.classList.remove('d-none') : li.classList.add('d-none'); | |
}); | |
} | |
this.updateDropdownPosition(); | |
} | |
updateDropdownPosition() { | |
document.body.dispatchEvent(new Event('scroll')); | |
} | |
preventClose(e) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
} | |
selectGroup(e) { | |
this.preventClose(e); | |
const li = e.target.closest('li'); | |
const checkbox = li.querySelector<HTMLInputElement>('input.group-input'); | |
const checkboxes = li.querySelectorAll<HTMLInputElement>('li:not(.d-none) input.option-input'); | |
setTimeout(() => { // Fix checked checkbox | |
checkbox.checked = !checkbox.checked; | |
this.updateCheckboxes(checkboxes, checkbox.checked); | |
}, 0); | |
} | |
toggleGroup(e) { | |
this.preventClose(e); | |
const li = e.target.closest('li'); | |
li.classList.toggle(this.toggledGroupClass); | |
} | |
dropdownOptions() { | |
let tpl = ` | |
<li> | |
<input type="search" class="form-control search form-control-smm" placeholder="${I18n('type_here')}" data-action="keyup->form-dropdown#filterOptions search->form-dropdown#filterOptions"/> | |
</li>`; | |
if (this.select.multiple) { | |
tpl += `<li data-action="click->form-dropdown#preventClose"> | |
<div class="dropdown-item fw-bold disabled"> | |
<label style="pointer-events: auto;" data-action="click->form-dropdown#selectAll"><input type="checkbox" class="selectall" /><span class="select-text small fst-italic"> ${I18n('select_all')}</span></label></div> | |
</li>`; | |
} | |
tpl += '<li class="divider"></li>'; | |
if (this.select.querySelector('optgroup')) { | |
this.select.querySelectorAll('optgroup').forEach((optgroup) => { | |
const selectGroup = `<label class="small" style="pointer-events: auto;" data-action="click->form-dropdown#selectGroup"><input type="checkbox" class="group-input"> ${I18n('select_all')}</label>`; | |
tpl += `<li class="group-item ${this.toggledGroupsValue ? this.toggledGroupClass : ''}" data-label="${optgroup.label}"> | |
<div class="dropdown-item disabled d-flex justify-content-between border-top border-bottom align-items-start" data-action="click->form-dropdown#preventClose"> | |
<span class="fw-bold cursor-pointer flex-fill" style="pointer-events: auto;" data-action="click->form-dropdown#toggleGroup"> | |
<span class="carets"> | |
<i class="fa fa-caret-right"></i> | |
<i class="fa fa-caret-down"></i> | |
</span> | |
${optgroup.label} | |
</span> | |
${this.select.multiple ? selectGroup : ''} | |
</div> | |
<ul class="m-0 p-0 group-items"> | |
${this.parseOptions(Array.from(optgroup.querySelectorAll('option')))} | |
</ul> | |
</li>`; | |
}); | |
} else { | |
tpl += this.parseOptions(Array.from(this.select.querySelectorAll('option'))); | |
} | |
return tpl; | |
} | |
parseOptions(options: HTMLOptionElement[]) { | |
let tpl = ''; | |
if (this.sortValue) { | |
options = options.sort((a, b) => b.value.toString() === '' ? 1 : a.textContent.localeCompare(b.textContent)); | |
} | |
options.forEach((option) => { | |
if (option.value === '' && this.select.multiple) return; // skip default option | |
const visibleCheck = this.select.multiple && !option.disabled; | |
tpl += `<li><a class="dropdown-item" href="#" ${option.disabled ? '' : 'data-action="click->form-dropdown#selectOption"'}> | |
<label><input name='options[]' type="${this.select.multiple ? 'checkbox' : 'radio'}" class="option-input ${visibleCheck ? '' : 'd-none'}" value='${option.value}' ${option.selected ? 'checked' : ''}/> | |
${option.textContent}</label> | |
</a> | |
</li>`; | |
}); | |
return tpl; | |
} | |
bindEvents() { // this.select.dispatchEvent(new CustomEvent('updateOptions', detail: { ids: [1, 2, 3], checked: true })); | |
this.select.addEventListener('updateOptions', (e) => { | |
const checkboxes = e.detail.ids.map((id) => this.dropdown.querySelector(`input[value="${id}"]`)); | |
this.updateCheckboxes(checkboxes, e.detail.selected); | |
}); | |
this.select.addEventListener('reload', (ev) => { | |
this.loadRemoteOptions(ev.detail.selected); | |
}); | |
this.dropdown.addEventListener('show.bs.dropdown', () => { | |
if (this.remoteOptionsUrl && !this.remoteOptionsLoaded) { | |
const selectedValues = Array.from(this.select.querySelectorAll('option:checked')).map((option) => option.value); | |
this.loadRemoteOptions(selectedValues); | |
this.remoteOptionsLoaded = true; | |
} | |
this.focusSearch(); | |
}); | |
} | |
loadRemoteOptions(selectedIds = null) { | |
selectedIds = Array.isArray(selectedIds) ? selectedIds : [selectedIds]; | |
this.select.innerHTML = `<option>${I18n('loading')}...</option>`; | |
fetch(this.remoteOptionsUrl).then((response) => response.json()).then((data) => { | |
const options = data.map((option) => `<option value="${option.id}" ${selectedIds.includes(option.id) || selectedIds.includes(option.id.toString()) ? 'selected' : ''}>${option.label}</option>`); | |
this.select.innerHTML = `<option value="">${this.defaultOption}</option> ${options.join('')}`; | |
this.buildDropdownOptions(); | |
this.focusSearch(); | |
this.updateDropdownPosition(); | |
}).catch((error) => console.error('Error:', error)); | |
} | |
// remoteSearch(e) { | |
// | |
// } | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Allows labels to support for required: true and optional: true, info: tooltip msg | |
module LabelWithAsterisk | |
def label(method, text = nil, options = {}, &block) | |
if text.is_a?(Hash) | |
options = text | |
text = nil | |
end | |
extra_label = '' | |
extra_label += ' <span class="required">(*)</span>' if options.delete(:required) | |
extra_label += " <span class='optional'>(#{I18n.t('common.optional')})</span>" if options.delete(:optional) | |
info = options.delete(:info) | |
extra_label += " <i class='fa fa-info-circle align-middle' data-controller='tooltip' title='#{info}'></i>" if info | |
super(method, options) do |label_text| | |
label_text = text if text | |
"#{(block ? block.call(label_text) : label_text)}#{extra_label}".html_safe | |
end | |
end | |
end | |
# Prepend the module to ActionView::Helpers::FormBuilder | |
# Allows for `f.label :name, required: true or f.label :name, optional: true` | |
ActionView::Helpers::FormBuilder.prepend(LabelWithAsterisk) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Controller } from '@hotwired/stimulus'; | |
import { debounce, isInViewport } from '../misc/helpers'; | |
import I18n from '../misc/translations'; | |
// Adds infinite scroll pagination functionality to any panel. | |
// Everytime page scroll is moved to the bottom of the page, | |
// the corresponding pagination-link is automatically clicked. | |
// @example: | |
// .my_huge_panel{ data: { controller: 'infinite-scroll' } } | |
// = <my items here> | |
// = link_to 'Load more', pagy_next_url(pagy), class: "btn btn-sm btn-primary", | |
// 'data-infinite-scroll-target' => 'link' #link to auto trigger the next page | |
// 'data-infinite-scroll-self-scroll-value' => 'true' #to define the scroller (default window) | |
export default class extends Controller { | |
static values = { selfScroll: Boolean }; | |
static targets = ['link']; | |
declare linkTarget: HTMLAnchorElement; | |
declare hasLinkTarget: boolean; | |
declare selfScrollValue: boolean; | |
declare scrollDebounced: () => void; | |
initialize() { | |
this.scrollDebounced = debounce(this.onScroll.bind(this)); | |
this.scroller().addEventListener('scroll', this.scrollDebounced, false); | |
} | |
disconnect() { | |
this.scroller().removeEventListener('scroll', this.scrollDebounced); | |
} | |
scroller() { | |
return this.selfScrollValue ? this.element.parentElement : window; | |
} | |
onScroll() { | |
if (!this.hasLinkTarget) return; | |
if (this.linkTarget.classList.contains('disabled')) return; | |
if (isInViewport(this.linkTarget, 220)) { | |
this.linkTarget.classList.add('disabled'); | |
this.linkTarget.innerHTML = I18n('loading'); | |
this.linkTarget.click(); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
= form_for @record, url: url_for(action: @record.id ? :update : :create), html: { 'data-controller': 'form-validator form-wizard', 'data-turbo-frame': '_top' } do |f| | |
%ol.step-indicator | |
%li | |
.step | |
%i.fa.fa-map-marker | |
%span.caption.hidden-xs.hidden-sm Paso 1: Datos personales | |
%li | |
.step | |
%i.fa.fa-map-marker | |
%span.caption.hidden-xs.hidden-sm Paso 2: Tipo de tarjeta | |
%li | |
.step | |
%i.fa.fa-map-marker | |
%span.caption.hidden-xs.hidden-sm Paso 3: Pago | |
.step-content | |
Step 1 | |
.step-content{ 'data-wizard-submit': 'true' } | |
Last step |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
module TurboConcern | |
extend ActiveSupport::Concern | |
included do | |
layout -> { false if turbo_frame_request? || request.headers['No-Layout'] } | |
around_action :parse_turbo_frame | |
end | |
# TODO: document all headers in readme or another file | |
private | |
def parse_turbo_frame | |
yield | |
is_redirect = response.status == 302 | |
includes_layout = response.body.include?('<html>') | |
return if is_redirect || includes_layout || %w[*/* html].none? { request.format.to_s.include?(_1) } | |
render_turbo_content(response.body) | |
end | |
def render_turbo_content(content, turbo_target = turbo_frame_request_id) # rubocop:disable Metrics/AbcSize | |
turbo_action = request.headers['Turbo-Response-Action'] || 'update' | |
raise 'Invalid turbo action' if %w[update replace append append_all].exclude?(turbo_action) | |
parse_turbo = turbo_target && response.status == 200 && !params[:turbo_response_skip] | |
content = '' if params[:turbo_response_skip] # TODO: use headers instead | |
content = "#{content}#{turbo_flash_response}" | |
content = turbo_stream.send(turbo_action, turbo_target, content) if parse_turbo && !@skip_turbo_response_wrapper | |
response.content_type = 'text/vnd.turbo-stream.html' | |
response.body = content | |
end | |
# @return [String] | |
def turbo_flash_response | |
content = '' | |
return content if @skip_turbo_response_flash | |
flash_messages = render_to_string(partial: '/layouts/flash_messages') | |
# append: allows to persist flash messages | |
content += turbo_stream.send(request.get? ? :append : :update, 'toasts', flash_messages) | |
flash.clear # clear streamed flash messages | |
content | |
end | |
def render_failure(model, run_turbo: false) | |
error = model.is_a?(String) ? model : model.errors.full_messages.join('<br>') | |
return render json: { error: }, status: :unprocessable_entity if request.format == 'json' | |
if request.headers['X-Turbo-Request-Id'].present? | |
flash[:error] = error | |
render inline: '', status: :unprocessable_entity | |
render_turbo_content(response.body) if run_turbo | |
else | |
@msg = error | |
render '/dashboard/error' | |
end | |
end | |
# Frontend controllers can listen the success event and do something | |
def set_turbo_header(key, value) | |
headers['turboResponseValue'] = { key:, value: }.to_json | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$wizard-color-neutral: #ccc !default; | |
$wizard-color-active: #4183D7 !default; | |
$wizard-color-complete: #87D37C !default; | |
$wizard-step-width-height: 64px !default; | |
$wizard-step-font-size: 24px !default; | |
.my-wizard { | |
& > .step-content { | |
padding: 20px 0; | |
display: none; | |
&.active { | |
display: block; | |
} | |
} | |
.step-indicator { | |
border-collapse: separate; | |
display: table; | |
margin-left: 0px; | |
position: relative; | |
table-layout: fixed; | |
text-align: center; | |
vertical-align: middle; | |
padding-left: 0; | |
padding-top: 20px; | |
li { | |
display: table-cell; | |
position: relative; | |
float: none; | |
padding: 0; | |
width: 1%; | |
&:after { | |
background-color: $wizard-color-neutral; | |
content: ""; | |
display: block; | |
height: 1px; | |
position: absolute; | |
width: 100%; | |
top: calc($wizard-step-width-height/2); | |
} | |
&:after { | |
left: 50%; | |
} | |
&:last-child { | |
&:after { | |
display: none; | |
} | |
} | |
&.active { | |
.step { | |
border-color: $wizard-color-active; | |
color: $wizard-color-active; | |
} | |
.caption { | |
color: $wizard-color-active; | |
} | |
} | |
&.complete { | |
&:after { | |
background-color: $wizard-color-complete; | |
} | |
.step { | |
border-color: $wizard-color-complete; | |
color: $wizard-color-complete; | |
} | |
.caption { | |
color: $wizard-color-complete; | |
} | |
} | |
} | |
.step { | |
background-color: #fff; | |
border-radius: 50%; | |
border: 1px solid $wizard-color-neutral; | |
color: $wizard-color-neutral; | |
font-size: $wizard-step-font-size; | |
height: $wizard-step-width-height; | |
line-height: $wizard-step-width-height; | |
margin: 0 auto; | |
position: relative; | |
width: $wizard-step-width-height; | |
z-index: 1; | |
&:hover { | |
cursor: pointer; | |
} | |
} | |
.caption { | |
color: $wizard-color-neutral; | |
padding: 11px 16px; | |
} | |
} | |
.step-controls { | |
text-align: right; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Controller } from '@hotwired/stimulus'; | |
// <div data-controller="form-wizard" data-wizard-submit="Enviar"> | |
// <div class="step-content" data-wizard-submit="true"> // to indicate the submit btn will be shown | |
export default class extends Controller { | |
declare element: HTMLElement; | |
declare indexValue: number; | |
declare steps: HTMLElement[]; | |
initialize() { | |
this.indexValue = 0; | |
this.element.classList.add('my-wizard'); | |
this.element.insertAdjacentHTML('beforeend', '<div class="step-controls text-end"></div>'); | |
this.steps = Array.from(this.element.querySelectorAll('.step-indicator li')); | |
this.steps.forEach((li, index) => { | |
li.addEventListener('click', () => { if (li.classList.contains('active')) this.focusStep(index) }); | |
}); | |
this.focusStep(this.indexValue); | |
} | |
focusStep(index) { | |
this.indexValue = index; | |
this.element.querySelectorAll('.step-content').forEach((step, i) => { | |
step.classList.toggle('active', i === index); | |
}); | |
this.steps.forEach((li, i) => { | |
li.classList.toggle('active', i <= index); | |
}); | |
this.updateControls(); | |
} | |
prevStep() { | |
this.focusStep(this.indexValue - 1); | |
} | |
nextStep() { | |
if (this.invalidStep()) return; | |
this.focusStep(this.indexValue + 1); | |
} | |
submitStep(event) { | |
if (this.invalidStep()) event.preventDefault(); | |
} | |
invalidStep() { | |
const stepContent = this.element.querySelector('.step-content.active'); | |
stepContent.classList.add('was-validated'); | |
const invalidInput = stepContent.querySelector('input:invalid'); | |
if (invalidInput) { | |
invalidInput.focus(); | |
return true; | |
} | |
} | |
updateControls() { | |
const stepContent = this.element.querySelector('.step-content.active'); | |
const leftBtn = `<button class="btn btn-outline-secondary" type="button" data-action="click->form-wizard#prevStep" ${this.indexValue === 0 ? 'disabled' : ''}>Anterior</button>`; | |
let rightBtn = `<button class="btn btn-outline-primary" type="button" data-action="click->form-wizard#nextStep" ${this.indexValue === 2 ? 'disabled' : ''}>Siguiente</button>`; | |
if (this.indexValue === this.steps.length - 1 || stepContent.getAttribute('data-wizard-submit')) | |
rightBtn = `<button class="btn btn-primary" data-action="click->form-wizard#submitStep" type="submit">${ this.element.getAttribute('data-wizard-submit') || 'Enviar' }</button>`; | |
this.element.querySelector('.step-controls').innerHTML = `${leftBtn} ${rightBtn}`; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment