Skip to content

Instantly share code, notes, and snippets.

@owen2345
Last active October 21, 2024 13:02
Show Gist options
  • Save owen2345/ac639f8fb9dff35881b29937f2c49224 to your computer and use it in GitHub Desktop.
Save owen2345/ac639f8fb9dff35881b29937f2c49224 to your computer and use it in GitHub Desktop.
Custom dropdown for stimulus + bootstrap
// 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) {
//
// }
}
# 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)
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();
}
}
}
= 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
# 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
$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;
}
}
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