Odoo's default <select> element is functional but often lacks the flexibility required for a polished and modern website design. Customizing it can be cumbersome. This guide provides a detailed, step-by-step process for building a reusable, accessible, and beautifully styled custom selection field for your Odoo 18 website. We'll cover everything from the modern JavaScript widget to the QWeb template, SCSS styling, and even a practical example of dynamic data integration from a Python controller.
1. The Odoo 18 JavaScript Widget: A Modern Approach
Odoo 18 marks a significant shift to a modern ES module system (/** @odoo-module **/), which improves code organization and performance. Our custom widget will extend publicWidget.Widget, which is the foundation for front-end widgets on the Odoo website.
This widget provides the core functionality:
- Initialization (start): It finds all the necessary elements and binds a global click listener to handle closing the dropdown.
- Event Handling: It listens for clicks and keyboard events on the button and list items to toggle the dropdown and navigate options. This ensures full accessibility.
- Selection Logic (_selectOption): This is where the magic happens. When an option is selected, the widget updates the visible label, adds a checkmark icon, and most importantly, updates a hidden <input> field. This hidden input is what allows the form to submit the selected value 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
The QWeb template provides the HTML structure for our widget. It's designed to be a reusable component that you can call from any other QWeb template. The key elements are:
- The outer div with the custom-select-taxsurety 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: To use this template, you'll call it and pass the options dynamically. For instance, if you have a list of statuses from your Python controller, you could do the following:
<t t-call="taxsurety_custom_selection_field.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;
}
}
4. Odoo Module Integration
To tie everything together, you need to define the file structure and update your module's manifest file to ensure Odoo loads the assets correctly.
File Structure Create the following file and directory structure in your custom module:
your_module/
+-- __manifest__.py
+-- controllers/
¦ +-- main.py <-- Optional: For dynamic data
+-- static/
¦ +-- src/
¦ ¦ +-- js/
¦ ¦ ¦ +-- custom_selection_widget.js
¦ ¦ +-- scss/
¦ ¦ ¦ +-- custom_selection.scss
+-- views/
¦ +-- custom_selection_templates.xml
__manifest__.py The assets key is crucial. It tells Odoo to load your JavaScript and SCSS files on the frontend of your website.
{
'name': 'Custom Selection Field Module',
'version': '1.0',
'category': 'Website',
'summary': 'Adds a custom selection field to the website.',
'depends': ['web', 'website'],
'assets': {
'web.assets_frontend': [
'your_module/static/src/scss/custom_selection.scss',
'your_module/static/src/js/custom_selection_widget.js',
],
},
'data': [
'views/custom_selection_templates.xml',
],
}
5. Advanced Usage: Dynamic Data from a Controller
For a real-world scenario, you'll need to fetch data from your Odoo backend. Here's a simple example of a controller that passes a list of records to the template.
Python Controller (controllers/main.py)
from odoo import http
from odoo.http import request
class MyWebsiteController(http.Controller):
@http.route('/my/page', type='http', auth='public', website=True)
def render_my_page(self, **kw):
# Fetch data from the model.
# This could be a list of records, statuses, etc.
statuses = request.env['my.model.name'].search_read([], ['name', 'id'])
# Or, create a simple list of dictionaries.
# statuses = [
# {'id': 'draft', 'name': 'Draft'},
# {'id': 'confirmed', 'name': 'Confirmed'},
# {'id': 'cancelled', 'name': 'Cancelled'},
# ]
return request.render('your_module.my_custom_page_template', {
'statuses': statuses,
'selected_status_id': 'draft', # Example of a pre-selected value
})
QWeb Template (views/my_custom_page_template.xml) This template would render the page, including our custom selection field.
<template id="my_custom_page_template" name="My Custom Page">
<t t-call="website.layout">
<div id="wrap">
<div class="container">
<h1 class="mt-4">My Custom Page</h1>
<form action="/my/page/submit" method="post">
<t t-call="your_module.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="statuses" t-as="state">
<li role="option"
t-att-aria-selected="selected_status_id and state['id'] == selected_status_id and 'selected'"
t-att-data-value="state['id']" t-attf-tabindex="0"
t-esc="state['name']"/>
</t>
</t>
<button type="submit" class="btn btn-primary mt-3">Save</button>
</form>
</div>
</div>
</t>
</template>
Conclusion
By following this comprehensive guide, you can create a powerful, flexible, and visually appealing custom selection field that integrates seamlessly with Odoo 18. This approach not only provides a better user experience but also gives you full control over the styling and behavior of your form elements. This modular design makes the component reusable across your entire Odoo website. Let me know if you would like to further enhance this with features like a search filter or status icons!
To read more about How to Create Advanced Selection Field in Odoo 17 Website, refer to our blog How to Create Advanced Selection Field in Odoo 17 Website