When developing web interfaces in Odoo 16, you might find that the standard HTML select elements don't always provide the user experience you're looking for. Whether it's styling limitations, accessibility concerns, or the need for more interactive behaviors, creating a custom selection field can be the perfect solution.
In this tutorial, we'll walk through building a fully-featured, accessible custom dropdown selection field that integrates seamlessly with Odoo's website framework.
Why Build a Custom Selection Field?
Standard HTML select elements have several limitations:
- Limited styling options - Browser-native selects are notoriously difficult to style consistently
- Poor mobile experience - Default selects can be clunky on touch devices
- Accessibility gaps - While functional, they may not provide the best screen reader experience
- Lack of customization - Adding icons, status indicators, or complex content is challenging
Our custom solution addresses all these issues while maintaining full keyboard navigation and screen reader compatibility.
The Architecture
Our implementation consists of three main components:
- JavaScript Widget - Handles all interactions and behavior
- XML Template - Provides the HTML structure
- CSS Styling - Creates the visual appearance
Let's break down each component:
JavaScript Widget Implementation
The core functionality is built using Odoo's public widget system:
odoo.define('custom_selection_field.selection', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
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',
},
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');
this._onDocumentClick = this._onDocumentClick.bind(this);
document.addEventListener('click', this._onDocumentClick);
return this._super(...arguments);
},
destroy() {
document.removeEventListener('click', this._onDocumentClick);
this._super(...arguments);
},
_onDocumentClick(ev) {
if (!this.el.contains(ev.target)) {
this._toggleDropdown(false);
}
},
_onToggleDropdown(ev) {
ev.preventDefault();
const isOpen = this.$dropdownArea.hasClass('hidden');
this._toggleDropdown(isOpen);
},
_onButtonKeydown(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
this._onToggleDropdown(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();
}
},
_onSelectOption(ev) {
const $option = $(ev.currentTarget);
this._selectOption($option);
},
_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();
}
},
_selectOption($option) {
const value = $option.data('value');
const label = $option.text();
console.log($option, "option")
//<i class="fa-solid fa-check"></i>
// Visual update
// Remove existing check icons from all options
this.$options.find('.fa-check').remove();
// Prepend check icon
$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);
// Update hidden input value
// this.$('.dropdown-value-input').val(value);
const $input = this.$('.dropdown-value-input');
$input.val(value).trigger('change'); //TODO: triggers external listeners
this._toggleDropdown(false);
this.$button.focus();
},
});
});
Key Features Implemented:
- Event Handling
- Click events for button and options
- Keyboard navigation (Enter, Space, Arrow keys, Escape)
- Outside click detection to close dropdown
- Accessibility Support
- Proper ARIA attributes (aria-expanded, aria-selected, role="listbox")
- Screen reader friendly focus management
- Visual Feedback
- Check icons for selected items
- Dynamic styling based on state
- Smooth transitions and hover effects
XML Template Structure
The template provides a clean, semantic HTML structure:
<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"
class="dropdown-value-input" t-att-value="selected_value"/>
<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>
The template is highly configurable through Odoo's template variables, allowing you to customize:
- Field names and IDs
- Default selected values
- Required field validation
- Dynamic option lists
SCSS Styling
The styling creates a modern, professional appearance that's consistent across different browsers and devices:
.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;
}
/* 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;
}
Design Highlights:
- Modern aesthetics with subtle shadows and rounded corners
- Responsive design that works on all screen sizes
- Focus states with clear visual indicators
- Hover effects for better user feedback
- Status-specific styling for different option types
- Custom scrollbar styling for the dropdown
How to Use the Component
Using the custom selection field in your Odoo templates is straightforward:
Note: The variable ‘selections’ is based on your data
<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-data-value="selection[0]"
t-attf-tabindex="0"
t-esc="selection[1]"/>
</t>
</t>
Advanced Features
1. Status-Based Styling
The component includes built-in support for status-based styling:
- Confirmed items appear in green
- Pending items in purple
- Cancelled items in red
- Tentative items in orange
2. Integration with Forms
The hidden input field ensures seamless integration with existing Odoo forms and controllers. When an option is selected, it automatically triggers change events for external listeners.
3. Keyboard Navigation
Full keyboard support includes:
- Tab to focus the button
- Enter/Space to open dropdown
- Arrow keys to navigate options
- Escape to close and return focus
- Enter/Space on options to select
4. Mobile Optimization
The component is fully responsive and provides an excellent touch experience on mobile devices.
Benefits of This Approach
- Better User Experience - Smooth animations, clear feedback, and intuitive interactions
- Accessibility Compliant - Meets WCAG guidelines for keyboard navigation and screen readers
- Highly Customizable - Easy to extend with new features and styling
- Framework Integration - Works seamlessly with Odoo's existing form handling
- Cross-Browser Compatible - Consistent appearance and behavior across all modern browsers
Extending the Component
The modular design makes it easy to extend with additional features:
- Search functionality - Add a filter input inside the dropdown
- Multi-select support - Allow selection of multiple options
- Async loading - Load options dynamically from server
- Grouping - Organize options into categories
- Custom icons - Add icons or images to options
Performance Considerations
The component is optimized for performance:
- Event delegation minimizes memory usage
- CSS transitions provide smooth animations without JavaScript
- Lazy loading of dropdown content reduces initial page load
- Proper cleanup in the destroy() method prevents memory leaks
Conclusion
This custom selection field component demonstrates how to create powerful, accessible UI components within the Odoo framework. By combining Odoo's widget system with modern web standards, we've created a reusable component that provides an excellent user experience while maintaining full compatibility with Odoo's form handling.
The implementation showcases several important concepts:
- Proper event handling and cleanup
- Accessibility best practices
- Modern CSS techniques
- Template-driven customization
- Framework integration patterns
Whether you're building customer portals, internal tools, or public websites, this custom selection field can be adapted to meet your specific requirements while providing a professional, polished user interface.
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.