Enable Dark Mode!
overview-of-building-a-custom-dynamic-selection-field-component-for-odoo-17-website.jpg
By: Aswin AK

Overview of Building a Custom Dynamic Selection Field Component for Odoo 17

Technical Odoo 17 Website&E-commerce

Odoo’s built-in <select> element works well functionally but falls short when it comes to delivering a sleek, modern web design. Customizing it within Odoo’s framework can also be challenging. This guide walks you through a complete, step-by-step approach to creating a reusable, visually appealing, and accessible custom selection component for your Odoo 17 website. You’ll learn how to build it from scratch—starting with a modern JavaScript widget, a QWeb template for rendering, SCSS styling for a refined look, and finally, integrating it with dynamic data sourced from a Python controller.

1. The Odoo 17 JavaScript Widget: A Modern Approach

Our custom widget will be built by extending publicWidget.Widget, the core class that powers front-end widgets in Odoo’s website framework. This approach allows the component to seamlessly integrate with Odoo’s existing structure, handle DOM interactions efficiently, and support dynamic behavior directly within website pages.

This widget provides the core functionality:

  • Initialization (start): In this step, the widget identifies all the needed elements and sets up a global click event to close the dropdown when the user clicks anywhere outside it.
  • Event Handling: The widget listens for both mouse clicks and keyboard actions on the button and list items. This allows users to open or close the dropdown and move between options easily, ensuring it’s fully accessible for everyone.
  • Selection Logic (_selectOption): This is the key part of the widget. When a user picks an option, it updates the displayed label, shows a checkmark next to the selected item, and updates a hidden <input> field. This hidden field ensures that the selected value is included when the form is submitted to the Odoo backend.
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
/**
 * Custom selection widget for the Odoo website portal.
 * This widget provides an accessible and styled dropdown menu
 * to replace the default HTML select element.
 */
publicWidget.registry.PortalCustomSelection = publicWidget.Widget.extend({
    selector: '.custom-select-dropdown',
    events: {
        'click .select-button': '_onToggleDropdown',
        'click .select-dropdown li': '_onSelectOption',
        'keydown .select-button': '_onButtonKeydown',
        'keydown .select-dropdown li': '_onOptionKeydown',
    },
    /**
     * Initializes the widget and sets up event listeners.
     * @override
     */
    start() {
        this.$button = this.$el.find('.select-button');
        this.$dropdownArea = this.$el.find('.dropdown-area');
        this.$dropdown = this.$el.find('.select-dropdown');
        this.$options = this.$el.find('.select-dropdown li');
        this.$selectedValue = this.$el.find('.selected-value');
        // Bind the document click event handler to the widget instance
        this._onDocumentClick = this._onDocumentClick.bind(this);
        document.addEventListener('click', this._onDocumentClick);
        
        return this._super(...arguments);
    },
    /**
     * Cleans up the event listeners when the widget is destroyed.
     * @override
     */
    destroy() {
        document.removeEventListener('click', this._onDocumentClick);
        this._super(...arguments);
    },
    /**
     * Closes the dropdown if the user clicks outside of the widget.
     * @param {Event} ev
     */
    _onDocumentClick(ev) {
        if (!this.el.contains(ev.target)) {
            this._toggleDropdown(false);
        }
    },
    /**
     * Toggles the dropdown visibility on button click.
     * @param {Event} ev
     */
    _onToggleDropdown(ev) {
        ev.preventDefault();
        const isOpen = this.$dropdownArea.hasClass('hidden');
        this._toggleDropdown(isOpen);
    },
    /**
     * Handles keyboard navigation on the button.
     * @param {Event} ev
     */
    _onButtonKeydown(ev) {
        if (ev.key === 'Enter' || ev.key === ' ') {
            ev.preventDefault();
            this._onToggleDropdown(ev);
        }
    },
    /**
     * Handles keyboard navigation and selection within the dropdown options.
     * @param {Event} ev
     */
    _onOptionKeydown(ev) {
        const $current = $(ev.currentTarget);
        const index = this.$options.index($current);
        if (ev.key === 'Enter' || ev.key === ' ') {
            ev.preventDefault();
            this._selectOption($current);
        } else if (ev.key === 'ArrowDown') {
            ev.preventDefault();
            const $next = this.$options.eq((index + 1) % this.$options.length);
            $next.focus();
        } else if (ev.key === 'ArrowUp') {
            ev.preventDefault();
            const $prev = this.$options.eq((index - 1 + this.$options.length) % this.$options.length);
            $prev.focus();
        } else if (ev.key === 'Escape') {
            ev.preventDefault();
            this._toggleDropdown(false);
            this.$button.focus();
        }
    },
    /**
     * Handles option selection on click.
     * @param {Event} ev
     */
    _onSelectOption(ev) {
        const $option = $(ev.currentTarget);
        this._selectOption($option);
    },
    /**
     * Shows or hides the dropdown and updates accessibility attributes.
     * @param {boolean} show
     */
    _toggleDropdown(show) {
        this.$dropdownArea.toggleClass('hidden', !show);
        this.$button.attr('aria-expanded', show);
        this.$el.toggleClass('border-primary', show);
        if (show) {
            this.$options.first().focus();
        }
    },
    /**
     * Selects an option, updates the hidden input, and closes the dropdown.
     * @param {jQuery} $option
     */
    _selectOption($option) {
        const value = $option.data('value');
        const label = $option.text().replace(/\s*<i.*?>.*?<\/i>\s*/g, '');
        
        this.$options.find('.fa-check').remove();
        $option.prepend('<i class="fa-solid fa-check me-1"></i>');
        this.$options.removeClass('selected').attr('aria-selected', 'false');
        $option.addClass('selected').attr('aria-selected', 'true');
        
        this.$selectedValue.text(label);
        const $input = this.$('.dropdown-value-input');
        $input.val(value).trigger('change');
        this._toggleDropdown(false);
        this.$button.focus();
    },
});

2. The QWeb Template: Reusable and Dynamic

This template defines the HTML layout of the widget. It’s built as a reusable component, meaning you can easily include it in any other QWeb template. The main parts of the template include:

  • The outer div with the custom-select-dropdown class, which is the selector for our JavaScript widget.
  • The input type="hidden", which holds the actual value that will be submitted with the form.
  • The button, which is the visible part of the dropdown.
  • The ul and li tags, which will be populated with options.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
    <template id="custom_dropdown_template" name="Custom Dropdown">
        <div class="custom-select-dropdown" t-attf-id="dropdown-#{dropdown_id or 'default'}">
            <input type="hidden"
                   t-att-name="dropdown_name"
                   t-att-id="input_id"
                   class="dropdown-value-input"
                   t-att-value="selected_value or ''" t-att-required="required"/>
            <button type="button"
                    class="select-button"
                    aria-expanded="false"
                    aria-haspopup="listbox">
                <span class="selected-value">
                    <t t-esc="selected_label or 'Select an option'"/>
                </span>
                <i class="arrow fa-solid fa-caret-down"></i>
            </button>
            <div class="dropdown-area hidden">
                <ul class="select-dropdown" role="listbox">
                    <t t-out="0"/>
                </ul>
            </div>
        </div>
    </template>
</odoo>

Practical Usage in a Template: You can render this template by passing the options dynamically. For example, if your Python controller returns a list of statuses, you can loop through them in your QWeb template to generate the dropdown options like this:

<t t-call="your_module_name.custom_dropdown_template">
                    <t t-set="dropdown_id" t-value="'statusSelect'"/>
                    <t t-set="selected_label" t-value="'Select option'"/>
                    <t t-set="input_id" t-value="'select_state'"/>
                    <t t-set="required" t-value="True"/>
                    <t t-set="dropdown_name" t-value="'state'"/>
                    <t t-foreach="selections" t-as="selection">
                      <li role="option"
                          t-att-aria-selected="item_obj and selection[0] == item_obj.state and 'selected'"
                          t-att-data-value="selection[0]"
                          t-attf-tabindex="0"
                          t-esc="selection[1]"/>
                    </t>
                </t>

3. The SCSS Styling: A Visual Upgrade

Your provided SCSS uses a clean, modern approach. It's fully responsive and includes a focus on accessibility with clear focus states and transitions. You can use this SCSS directly in your Odoo module.

.custom-select-dropdown {
  position: relative;
  display: inline-block;
  width: 100%;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
  .select-button {
      width: 100%;
      padding: 8px 12px;
      background-color: #ffffff;
      border: 1px solid #d0d5dd;
      border-radius: 6px;
      font-size: 14px;
      color: #344054;
      text-align: left;
      cursor: pointer;
      display: flex;
      justify-content: space-between;
      align-items: center;
      transition: border-color 0.2s ease, box-shadow 0.2s ease;
      min-height: 40px;
      box-sizing: border-box;
    }
  .select-button:hover {
      border-color: #b0b7c3;
    }
  .select-button:focus {
      outline: none;
      border-color: #6366f1;
      box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
    }
  .selected-value {
      flex: 1;
      text-overflow: ellipsis;
      overflow: hidden;
      white-space: nowrap;
      color: #374151;
      font-weight: 400;
    }
  .arrow {
      color: #6b7280;
      font-size: 12px;
      margin-left: 8px;
      transition: transform 0.2s ease;
    }
  .select-button[aria-expanded="true"] .arrow {
      transform: rotate(180deg);
    }
  .dropdown-area {
      position: absolute;
      top: 100%;
      left: 0;
      right: 0;
      z-index: 1000;
      background: #ffffff;
      border: 1px solid #e5e7eb;
      border-radius: 6px;
      box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
      margin-top: 2px;
      max-height: 200px;
      overflow-y: auto;
    }
  .dropdown-area.hidden {
      display: none;
    }
  .select-dropdown {
      list-style: none;
      padding: 4px 0;
      margin: 0;
    }
  .select-dropdown li {
      padding: 8px 12px;
      cursor: pointer;
      font-size: 14px;
      color: #374151;
      display: flex;
      align-items: center;
      transition: background-color 0.15s ease;
    }
  .select-dropdown li:hover {
      background-color: #f3f4f6;
    }
  .select-dropdown li.selected {
      background-color: #eff6ff;
      color: #1d4ed8;
      position: relative;
    }
  .select-dropdown li:active {
      background-color: #e5e7eb;
    }
    /* Status-specific styling if you want to add status indicators */
  .select-dropdown li[data-status="confirmed"] {
      color: #059669;
    }
  .select-dropdown li[data-status="tentative"] {
      color: #d97706;
    }
  .select-dropdown li[data-status="cancelled"] {
      color: #dc2626;
    }
  .select-dropdown li[data-status="pending"] {
      color: #7c3aed;
    }
    /* Responsive adjustments */
    @media (max-width: 768px) {
      .custom-select-taxsurety {
        width: 100%;
      }
    }
    /* Focus states for accessibility */
  .select-dropdown li:focus {
      outline: none;
      //background-color: #f3f4f6;
    }
    /* Scrollbar styling for dropdown */
  .dropdown-area::-webkit-scrollbar {
      width: 6px;
    }
  .dropdown-area::-webkit-scrollbar-track {
      background: #f1f5f9;
      border-radius: 3px;
    }
  .dropdown-area::-webkit-scrollbar-thumb {
      background: #cbd5e1;
      border-radius: 3px;
    }
  .dropdown-area::-webkit-scrollbar-thumb:hover {
      background: #94a3b8;
    }
}

Conclusion

By following this step-by-step guide, you’ll be able to build a flexible, stylish, and fully functional custom selection field that works perfectly with Odoo 17. This method enhances the overall user experience while giving you complete control over the look and behavior of your form elements. Its modular structure also makes it easy to reuse across different pages of your Odoo website.

 If you’d like, we can take it a step further by adding features such as a search filter or status icons to make it even more interactive and user-friendly!

To read more about How to Build a Custom Dynamic Selection Field Component for Odoo 18 Website, refer to our blog How to Build a Custom Dynamic Selection Field Component for Odoo 18 Website.


If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message