You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@myfaces.apache.org by hn...@apache.org on 2023/02/10 20:31:53 UTC

[myfaces-tobago] 01/02: feat: tc:selectOneList

This is an automated email from the ASF dual-hosted git repository.

hnoeth pushed a commit to branch tobago-5.x
in repository https://gitbox.apache.org/repos/asf/myfaces-tobago.git

commit 11906d7dca37abd4772e574c79628ec9db5eacce
Author: Henning Noeth <hn...@apache.org>
AuthorDate: Tue Feb 7 20:52:28 2023 +0100

    feat: tc:selectOneList
    
    * implement tc:selectOneList according to tc:selectManyList
    * add demo
    * make a typescript base class for Select[One/Many]List
    * fix: preselection if filter is active
    * fix: don't open dropdown menu if component is disabled
    * adjust the behavior of selectManyList to act like selectOneList
    ** remove value of filter input if leaving component
    ** use input event for filterInput instead of keyup
    ** initial focus doesn't open dropdownMenu like the regular tc:selectOneChoice component
    
    Issue: TOBAGO-2179
---
 .../myfaces/tobago/component/RendererTypes.java    |   2 +
 .../org/apache/myfaces/tobago/component/Tags.java  |   2 +
 .../component/AbstractUISelectOneList.java         |  36 +++
 .../renderkit/renderer/SelectOneListRenderer.java  | 212 +++++++++++++++++
 .../component/SelectOneListTagDeclaration.java     |  78 ++++++
 .../tobago/renderkit/html/HtmlElements.java        |   1 +
 .../example/demo/SelectOneListController.java      | 138 +++++++++++
 .../45-selectOneList/SelectOneList.xhtml           |  87 +++++++
 tobago-theme/src/main/scss/_tobago.scss            | 262 +++++++++++----------
 .../src/main/resources/META-INF/tobago-config.xml  |   5 +
 .../src/main/ts/tobago-all.ts                      |   1 +
 .../src/main/ts/tobago-select-list-base.ts         | 230 ++++++++++++++++++
 .../src/main/ts/tobago-select-many-list.ts         | 238 +++----------------
 .../src/main/ts/tobago-select-one-list.ts          | 171 ++++++++++++++
 14 files changed, 1126 insertions(+), 337 deletions(-)

diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/component/RendererTypes.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/component/RendererTypes.java
index d77b7b1c31..df34cb4c5b 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/component/RendererTypes.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/component/RendererTypes.java
@@ -73,6 +73,7 @@ public enum RendererTypes {
   SelectManyShuttle,
   SelectOneChoice,
   SelectOneRadio,
+  SelectOneList,
   SelectOneListbox,
   SelectReference,
   Separator,
@@ -147,6 +148,7 @@ public enum RendererTypes {
   public static final String SELECT_MANY_SHUTTLE = "SelectManyShuttle";
   public static final String SELECT_ONE_CHOICE = "SelectOneChoice";
   public static final String SELECT_ONE_RADIO = "SelectOneRadio";
+  public static final String SELECT_ONE_LIST = "SelectOneList";
   public static final String SELECT_ONE_LISTBOX = "SelectOneListbox";
   public static final String SELECT_REFERENCE = "SelectReference";
   public static final String SEPARATOR = "Separator";
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/component/Tags.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/component/Tags.java
index 9b6050a801..eb2624ec9b 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/component/Tags.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/component/Tags.java
@@ -80,6 +80,7 @@ public enum Tags {
   selectManyShuttle,
   selectOneChoice,
   selectOneRadio,
+  selectOneList,
   selectOneListbox,
   selectReference,
   separator,
@@ -153,6 +154,7 @@ public enum Tags {
   public static final String SELECT_MANY_SHUTTLE = "selectManyShuttle";
   public static final String SELECT_ONE_CHOICE = "selectOneChoice";
   public static final String SELECT_ONE_RADIO = "selectOneRadio";
+  public static final String SELECT_ONE_LIST = "selectOneList";
   public static final String SELECT_ONE_LISTBOX = "selectOneListbox";
   public static final String SELECT_REFERENCE = "selectReference";
   public static final String SEPARATOR = "separator";
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/component/AbstractUISelectOneList.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/component/AbstractUISelectOneList.java
new file mode 100644
index 0000000000..7b73c5f615
--- /dev/null
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/component/AbstractUISelectOneList.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.myfaces.tobago.internal.component;
+
+import org.apache.myfaces.tobago.component.SupportFieldId;
+import org.apache.myfaces.tobago.util.ComponentUtils;
+
+import javax.faces.context.FacesContext;
+
+public abstract class AbstractUISelectOneList extends AbstractUISelectOneBase implements SupportFieldId {
+
+  @Override
+  public String getFieldId(final FacesContext facesContext) {
+    return getClientId(facesContext) + ComponentUtils.SUB_SEPARATOR + "field";
+  }
+
+  public abstract String getFilter();
+  public abstract boolean isExpanded();
+}
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/SelectOneListRenderer.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/SelectOneListRenderer.java
new file mode 100644
index 0000000000..655712cf31
--- /dev/null
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/SelectOneListRenderer.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.myfaces.tobago.internal.renderkit.renderer;
+
+import org.apache.myfaces.tobago.internal.component.AbstractUISelectOneList;
+import org.apache.myfaces.tobago.internal.util.HtmlRendererUtils;
+import org.apache.myfaces.tobago.internal.util.SelectItemUtils;
+import org.apache.myfaces.tobago.renderkit.css.BootstrapClass;
+import org.apache.myfaces.tobago.renderkit.css.CssItem;
+import org.apache.myfaces.tobago.renderkit.css.TobagoClass;
+import org.apache.myfaces.tobago.renderkit.html.Arias;
+import org.apache.myfaces.tobago.renderkit.html.CustomAttributes;
+import org.apache.myfaces.tobago.renderkit.html.DataAttributes;
+import org.apache.myfaces.tobago.renderkit.html.HtmlAttributes;
+import org.apache.myfaces.tobago.renderkit.html.HtmlElements;
+import org.apache.myfaces.tobago.renderkit.html.HtmlInputTypes;
+import org.apache.myfaces.tobago.util.ComponentUtils;
+import org.apache.myfaces.tobago.webapp.TobagoResponseWriter;
+
+import javax.faces.context.FacesContext;
+import javax.faces.model.SelectItem;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class SelectOneListRenderer<T extends AbstractUISelectOneList> extends SelectOneRendererBase<T> {
+
+  @Override
+  protected void encodeBeginField(FacesContext facesContext, T component) throws IOException {
+    final TobagoResponseWriter writer = getResponseWriter(facesContext);
+
+    final String clientId = component.getClientId(facesContext);
+    final String fieldId = component.getFieldId(facesContext);
+    final String filterId = clientId + ComponentUtils.SUB_SEPARATOR + "filter";
+    final String selectedId = clientId + ComponentUtils.SUB_SEPARATOR + "selected";
+    final List<SelectItem> items = SelectItemUtils.getItemList(facesContext, component);
+    final boolean disabled = !items.iterator().hasNext() || component.isDisabled() || component.isReadonly();
+    final String filter = component.getFilter();
+    final boolean expanded = component.isExpanded();
+    final String title = HtmlRendererUtils.getTitleFromTipAndMessages(facesContext, component);
+    final Integer tabIndex = component.getTabIndex();
+
+    encodeHiddenSelect(facesContext, component, items, clientId, selectedId, disabled);
+
+    writer.startElement(HtmlElements.DIV);
+    writer.writeClassAttribute(
+        expanded ? BootstrapClass.LIST_GROUP : BootstrapClass.DROPDOWN,
+        expanded ? BootstrapClass.borderColor(ComponentUtils.getMaximumSeverity(component)) : null);
+
+    encodeSelectField(facesContext, component,
+        clientId, fieldId, filterId, filter, disabled, expanded, title, tabIndex);
+    encodeOptions(facesContext, component, items, clientId, expanded, disabled);
+
+    writer.endElement(HtmlElements.DIV);
+  }
+
+  private void encodeHiddenSelect(
+      final FacesContext facesContext, final T component, final List<SelectItem> items,
+      final String clientId, final String selectedId, final boolean disabled) throws IOException {
+    final TobagoResponseWriter writer = getResponseWriter(facesContext);
+
+    writer.startElement(HtmlElements.SELECT);
+    writer.writeIdAttribute(selectedId);
+    writer.writeNameAttribute(clientId);
+    writer.writeAttribute(HtmlAttributes.DISABLED, disabled);
+    writer.writeAttribute(HtmlAttributes.REQUIRED, component.isRequired());
+    writer.writeClassAttribute(BootstrapClass.D_NONE);
+
+    final String submittedValue = (String) component.getSubmittedValue();
+    renderSelectItems(component, null, items, component.getValue(), submittedValue, writer, facesContext);
+    writer.endElement(HtmlElements.SELECT);
+  }
+
+  private void encodeSelectField(
+      final FacesContext facesContext, final T component,
+      final String clientId, final String fieldId, final String filterId, final String filter, final boolean disabled,
+      final boolean expanded, final String title, final Integer tabIndex) throws IOException {
+    final TobagoResponseWriter writer = getResponseWriter(facesContext);
+
+    writer.startElement(HtmlElements.DIV);
+    writer.writeIdAttribute(fieldId);
+    writer.writeNameAttribute(clientId);
+    HtmlRendererUtils.writeDataAttributes(facesContext, writer, component);
+    writer.writeClassAttribute(
+        expanded ? BootstrapClass.FORM_CONTROL : BootstrapClass.FORM_SELECT,
+        TobagoClass.SELECT__FIELD,
+        expanded ? BootstrapClass.LIST_GROUP_ITEM : BootstrapClass.DROPDOWN_TOGGLE,
+        expanded ? null : BootstrapClass.borderColor(ComponentUtils.getMaximumSeverity(component)),
+        component.getCustomClass());
+    writer.writeAttribute(HtmlAttributes.TITLE, title, true);
+    writer.writeAttribute(Arias.EXPANDED, Boolean.FALSE.toString(), false);
+    writer.writeAttribute(HtmlAttributes.DISABLED, disabled);
+
+    writer.startElement(HtmlElements.SPAN);
+    writer.endElement(HtmlElements.SPAN);
+
+    writer.startElement(HtmlElements.INPUT);
+    writer.writeAttribute(HtmlAttributes.TYPE, HtmlInputTypes.TEXT);
+    writer.writeIdAttribute(filterId);
+    writer.writeClassAttribute(TobagoClass.FILTER, BootstrapClass.FORM_CONTROL);
+    writer.writeAttribute(HtmlAttributes.AUTOCOMPLETE, "off", false);
+    writer.writeAttribute(HtmlAttributes.READONLY, filter == null || filter.isEmpty());
+    writer.writeAttribute(HtmlAttributes.DISABLED, disabled);
+    writer.writeAttribute(HtmlAttributes.TABINDEX, tabIndex);
+    renderFocus(clientId, component.isFocus(), component.isError(), facesContext, writer);
+
+    writer.endElement(HtmlElements.INPUT);
+
+    writer.endElement(HtmlElements.DIV);
+  }
+
+  private void encodeOptions(
+      final FacesContext facesContext, final T component, final List<SelectItem> items,
+      final String clientId, final boolean expanded, final boolean disabled) throws IOException {
+    final TobagoResponseWriter writer = getResponseWriter(facesContext);
+
+    writer.startElement(HtmlElements.DIV);
+    writer.writeClassAttribute(
+        TobagoClass.OPTIONS,
+        expanded ? BootstrapClass.LIST_GROUP_ITEM : BootstrapClass.DROPDOWN_MENU);
+    writer.writeNameAttribute(clientId);
+
+    writer.startElement(HtmlElements.TABLE);
+    writer.writeClassAttribute(BootstrapClass.TABLE, BootstrapClass.TABLE_HOVER, BootstrapClass.TABLE_SM);
+    writer.startElement(HtmlElements.TBODY);
+
+    final Object value = component.getValue();
+    final Object submittedValue = component.getSubmittedValue();
+    for (SelectItem item : items) {
+      Object itemValue = item.getValue();
+      // when using selectItem tag with a literal value: use the converted value
+      if (itemValue instanceof String && value != null && !(value instanceof String)) {
+        itemValue = ComponentUtils.getConvertedValue(facesContext, component, (String) itemValue);
+      }
+      final String formattedValue = getFormattedValue(facesContext, (T) component, itemValue);
+      final boolean contains;
+      if (submittedValue != null) {
+        contains = submittedValue.equals(formattedValue);
+      } else {
+        contains = value != null && value.equals(itemValue);
+      }
+      writer.startElement(HtmlElements.TR);
+      writer.writeAttribute(DataAttributes.VALUE, formattedValue, true);
+      writer.writeClassAttribute(
+          contains ? BootstrapClass.TABLE_PRIMARY : null,
+          disabled || item.isDisabled() ? TobagoClass.DISABLED : null);
+
+      writer.startElement(HtmlElements.TD);
+      writer.writeAttribute(HtmlAttributes.VALUE, formattedValue, true);
+      writer.writeText(item.getLabel());
+      writer.endElement(HtmlElements.TD);
+      writer.endElement(HtmlElements.TR);
+    }
+
+    writer.endElement(HtmlElements.TBODY);
+    writer.endElement(HtmlElements.TABLE);
+    writer.endElement(HtmlElements.DIV);
+  }
+
+  @Override
+  protected void encodeEndField(FacesContext facesContext, T component) throws IOException {
+    final TobagoResponseWriter writer = getResponseWriter(facesContext);
+
+    encodeBehavior(writer, facesContext, component);
+  }
+
+  @Override
+  public HtmlElements getComponentTag() {
+    return HtmlElements.TOBAGO_SELECT_ONE_LIST;
+  }
+
+  @Override
+  protected CssItem[] getComponentCss(final FacesContext facesContext, final T component) {
+    final List<SelectItem> items = SelectItemUtils.getItemList(facesContext, component);
+    final boolean disabled = !items.iterator().hasNext() || component.isDisabled() || component.isReadonly();
+
+    List<CssItem> cssItems = new ArrayList<>();
+    if (disabled) {
+      cssItems.add(TobagoClass.DISABLED);
+    }
+    return cssItems.toArray(new CssItem[0]);
+  }
+
+  @Override
+  protected void writeAdditionalAttributes(FacesContext facesContext, TobagoResponseWriter writer, T input)
+      throws IOException {
+    super.writeAdditionalAttributes(facesContext, writer, input);
+    writer.writeAttribute(CustomAttributes.FILTER, input.getFilter(), true);
+  }
+
+  @Override
+  protected String getFieldId(FacesContext facesContext, T component) {
+    return component.getFieldId(facesContext);
+  }
+}
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/taglib/component/SelectOneListTagDeclaration.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/taglib/component/SelectOneListTagDeclaration.java
new file mode 100644
index 0000000000..eceb39fc85
--- /dev/null
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/taglib/component/SelectOneListTagDeclaration.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.myfaces.tobago.internal.taglib.component;
+
+import org.apache.myfaces.tobago.apt.annotation.Behavior;
+import org.apache.myfaces.tobago.apt.annotation.Preliminary;
+import org.apache.myfaces.tobago.apt.annotation.Tag;
+import org.apache.myfaces.tobago.apt.annotation.UIComponentTag;
+import org.apache.myfaces.tobago.component.ClientBehaviors;
+import org.apache.myfaces.tobago.component.RendererTypes;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasAutoSpacing;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasBinding;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasConverter;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasConverterMessage;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasDecorationPosition;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasFilter;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasHelp;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasId;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasLabel;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasLabelLayout;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasRequiredMessageForSelect;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasTabIndex;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasTip;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasValidator;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasValidatorMessage;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasValue;
+import org.apache.myfaces.tobago.internal.taglib.declaration.HasValueChangeListener;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsDisabled;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsExpanded;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsFocus;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsReadonly;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsRendered;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsRequiredForSelect;
+import org.apache.myfaces.tobago.internal.taglib.declaration.IsVisual;
+
+import javax.faces.component.UISelectMany;
+
+/**
+ * Render a single selection option list.
+ */
+@Preliminary
+@Tag(name = "selectOneList")
+@UIComponentTag(
+    uiComponent = "org.apache.myfaces.tobago.component.UISelectOneList",
+    uiComponentFacesClass = "javax.faces.component.UISelectOne",
+    componentFamily = UISelectMany.COMPONENT_FAMILY,
+    rendererType = RendererTypes.SELECT_ONE_LIST,
+    allowedChildComponenents = {"javax.faces.SelectItem", "javax.faces.SelectItems"},
+    behaviors = {
+        @Behavior(name = ClientBehaviors.CHANGE, isDefault = true),
+        @Behavior(name = ClientBehaviors.INPUT),
+        @Behavior(name = ClientBehaviors.CLICK),
+        @Behavior(name = ClientBehaviors.DBLCLICK),
+        @Behavior(name = ClientBehaviors.FOCUS),
+        @Behavior(name = ClientBehaviors.BLUR)})
+public interface SelectOneListTagDeclaration
+    extends HasId, IsDisabled, IsRendered, HasBinding, HasTip, HasHelp, IsReadonly, HasConverter, IsRequiredForSelect,
+    HasLabel, HasValidator, HasValue, HasValueChangeListener, HasLabelLayout, HasValidatorMessage, HasConverterMessage,
+    HasRequiredMessageForSelect, HasTabIndex, IsFocus, IsVisual, HasAutoSpacing, HasFilter, IsExpanded,
+    HasDecorationPosition {
+}
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
index 727dc64f3d..253a13f0f5 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
@@ -176,6 +176,7 @@ public enum HtmlElements {
   TOBAGO_SELECT_MANY_LISTBOX("tobago-select-many-listbox"),
   TOBAGO_SELECT_MANY_SHUTTLE("tobago-select-many-shuttle"),
   TOBAGO_SELECT_ONE_CHOICE("tobago-select-one-choice"),
+  TOBAGO_SELECT_ONE_LIST("tobago-select-one-list"),
   TOBAGO_SELECT_ONE_LISTBOX("tobago-select-one-listbox"),
   TOBAGO_SELECT_ONE_RADIO("tobago-select-one-radio"),
   TOBAGO_SEPARATOR("tobago-separator"),
diff --git a/tobago-example/tobago-example-demo/src/main/java/org/apache/myfaces/tobago/example/demo/SelectOneListController.java b/tobago-example/tobago-example-demo/src/main/java/org/apache/myfaces/tobago/example/demo/SelectOneListController.java
new file mode 100644
index 0000000000..f807a1673d
--- /dev/null
+++ b/tobago-example/tobago-example-demo/src/main/java/org/apache/myfaces/tobago/example/demo/SelectOneListController.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.myfaces.tobago.example.demo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.PostConstruct;
+import javax.enterprise.context.SessionScoped;
+import javax.faces.application.FacesMessage;
+import javax.faces.context.FacesContext;
+import javax.inject.Inject;
+import javax.inject.Named;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.Serializable;
+import java.lang.invoke.MethodHandles;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+@SessionScoped
+@Named
+public class SelectOneListController implements Serializable {
+
+  private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  @Inject
+  private AstroData astroData;
+
+  private List<SolarObject> planets;
+  private SolarObject selected1;
+  private SolarObject selected2;
+  private SolarObject selected3;
+  private SolarObject selected4;
+  private List<String> names;
+  private String selected5;
+
+  private String filterType;
+
+  @PostConstruct
+  public void init() {
+    planets = astroData.getSatellites("Sun");
+
+    names = new ArrayList<>();
+    try (BufferedReader reader = new BufferedReader(new InputStreamReader(
+        Thread.currentThread().getContextClassLoader().getResourceAsStream(
+            "org/apache/myfaces/tobago/example/demo/names.txt"), StandardCharsets.UTF_8))) {
+      String line;
+      while ((line = reader.readLine()) != null) {
+        line = line.trim();
+        if (!line.startsWith("#") && line.length() > 0) {
+          names.add(line);
+        }
+      }
+    } catch (Exception e) {
+      LOG.error("Can't load names", e);
+    }
+  }
+
+  public List<SolarObject> getPlanets() {
+    return planets;
+  }
+
+  public SolarObject getSelected1() {
+    return selected1;
+  }
+
+  public void setSelected1(SolarObject selected1) {
+    this.selected1 = selected1;
+  }
+
+  public SolarObject getSelected2() {
+    return selected2;
+  }
+
+  public void setSelected2(SolarObject selected2) {
+    this.selected2 = selected2;
+  }
+
+  public SolarObject getSelected3() {
+    return selected3;
+  }
+
+  public void setSelected3(SolarObject selected3) {
+    this.selected3 = selected3;
+  }
+
+  public SolarObject getSelected4() {
+    return selected4;
+  }
+
+  public void setSelected4(SolarObject selected4) {
+    this.selected4 = selected4;
+  }
+
+  public List<String> getNames() {
+    if (names.size() < 1) {
+      FacesContext.getCurrentInstance().addMessage(null,
+          new FacesMessage(FacesMessage.SEVERITY_ERROR, "Names not loaded!", null));
+      return new ArrayList<>();
+    }
+    return names;
+  }
+
+  public String getSelected5() {
+    return selected5;
+  }
+
+  public void setSelected5(String selected5) {
+    this.selected5 = selected5;
+  }
+
+  public String getFilterType() {
+    return filterType;
+  }
+
+  public void setFilterType(String filterType) {
+    this.filterType = filterType;
+  }
+}
diff --git a/tobago-example/tobago-example-demo/src/main/webapp/content/030-select/45-selectOneList/SelectOneList.xhtml b/tobago-example/tobago-example-demo/src/main/webapp/content/030-select/45-selectOneList/SelectOneList.xhtml
new file mode 100644
index 0000000000..1798beb163
--- /dev/null
+++ b/tobago-example/tobago-example-demo/src/main/webapp/content/030-select/45-selectOneList/SelectOneList.xhtml
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+-->
+
+<ui:composition template="/main.xhtml"
+                xmlns="http://www.w3.org/1999/xhtml"
+                xmlns:tc="http://myfaces.apache.org/tobago/component"
+                xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
+                xmlns:f="http://xmlns.jcp.org/jsf/core">
+
+  <!-- todo -->
+  <tc:badge markup="warning"
+            value="Preliminary feature, may be subject to change!"/><br/>
+
+  <tc:section label="not expanded / no filter">
+    <tc:selectOneList id="selected1" value="#{selectOneListController.selected1}">
+      <tc:selectItems value="#{selectOneListController.planets}"
+                      var="planet" itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectOneList>
+  </tc:section>
+
+  <tc:section label="expanded / no filter">
+    <tc:selectOneList id="selected2" value="#{selectOneListController.selected2}" expanded="true">
+      <tc:selectItems value="#{selectOneListController.planets}"
+                      var="planet" itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectOneList>
+    <tc:button label="Submit"/>
+  </tc:section>
+
+  <tc:section label="not expanded / with filter">
+    <tc:selectOneList id="selected3" value="#{selectOneListController.selected3}" filter="contains">
+      <tc:selectItems value="#{selectOneListController.planets}"
+                      var="planet" itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectOneList>
+    <tc:button label="Submit"/>
+  </tc:section>
+
+  <tc:section label="expanded / with filter:">
+    <tc:selectOneList id="selected4" value="#{selectOneListController.selected4}" filter="contains" expanded="true">
+      <tc:selectItems value="#{selectOneListController.planets}"
+                      var="planet" itemLabel="#{planet.name}" itemValue="#{planet}"/>
+    </tc:selectOneList>
+    <tc:button label="Submit"/>
+  </tc:section>
+
+  <tc:section id="filter" label="Filter types: contains, startsWith, containsExact, startsWithExact">
+    <tc:selectOneRadio label="Filter type" value="#{selectOneListController.filterType}">
+      <tc:selectItem itemValue="#{null}" itemLabel="please select"/>
+      <tc:selectItem itemValue="contains"/>
+      <tc:selectItem itemValue="startsWith"/>
+      <tc:selectItem itemValue="containsExact"/>
+      <tc:selectItem itemValue="startsWithExact"/>
+      <f:ajax render="filter" execute="filter"/>
+    </tc:selectOneRadio>
+    <tc:selectOneList id="selected5" value="#{selectOneListController.selected5}"
+                       filter="#{selectOneListController.filterType}" expanded="true">
+      <tc:selectItems value="#{selectOneListController.names}"
+                      var="name" itemLabel="#{name}" itemValue="#{name}"/>
+      <tc:style maxHeight="300px"/>
+    </tc:selectOneList>
+
+    Dependent of the type of the filter:
+    Ignores case.
+    Ignores acute (é), grave (è), circumflex (â, î or ô), tilde (ñ), umlaut and
+    dieresis (ü or ï), and cedilla (ç).
+  </tc:section>
+
+  <tc:section label="Filter types: custom">
+    todo
+  </tc:section>
+
+</ui:composition>
diff --git a/tobago-theme/src/main/scss/_tobago.scss b/tobago-theme/src/main/scss/_tobago.scss
index a050c989e5..3440fae36c 100644
--- a/tobago-theme/src/main/scss/_tobago.scss
+++ b/tobago-theme/src/main/scss/_tobago.scss
@@ -195,6 +195,137 @@ $form-select-disabled-color: rgba($form-select-color, $tobago-form-disabled-alph
   }
 }
 
+@mixin selectListBase() {
+  display: block;
+
+  &.tobago-label-container {
+    .dropdown, .list-group {
+      flex: 1 0 0;
+    }
+  }
+
+  &.tobago-focus {
+    .dropdown .tobago-select-field {
+      @include formControlFocus();
+      @include selectListBorderColorShadow("danger", $danger);
+      @include selectListBorderColorShadow("warning", $warning);
+      @include selectListBorderColorShadow("info", $info);
+    }
+
+    .list-group {
+      --bs-list-group-color: #{$input-focus-color};
+      --bs-list-group-bg: #{$input-focus-bg};
+      --bs-list-group-border-color: #{$input-focus-border-color};
+
+      @include formControlFocusShadows();
+      @include selectListBorderColorShadow("danger", $danger);
+      @include selectListBorderColorShadow("warning", $warning);
+      @include selectListBorderColorShadow("info", $info);
+    }
+  }
+
+  &.tobago-disabled {
+    .tobago-select-field {
+      color: $form-select-disabled-color;
+      background-color: $form-select-disabled-bg;
+      border-color: $form-select-disabled-border-color;
+    }
+  }
+
+  .list-group {
+    height: inherit;
+    max-height: inherit;
+    @include box-shadow($form-select-box-shadow);
+    @include transition($form-select-transition);
+
+    &.border-danger, &.border-warning, &.border-info {
+      .list-group-item {
+        border-color: inherit;
+      }
+    }
+
+    .list-group-item.tobago-select-field {
+      border-bottom-color: $list-group-border-color;
+    }
+  }
+
+  .tobago-select-field {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+
+    &.dropdown-toggle::after {
+      content: none;
+    }
+
+    &.list-group-item.form-control {
+      border-bottom-left-radius: 0;
+      border-bottom-right-radius: 0;
+
+      padding: $form-select-padding-y $form-select-indicator-padding $form-select-padding-y $form-select-padding-x;
+    }
+
+    .tobago-filter {
+      margin: 0;
+      border: 0;
+      border-radius: 0;
+      padding: 0;
+      flex: 1 0 0;
+      min-width: 8rem;
+      background-color: transparent;
+
+      &:focus {
+        box-shadow: none;
+      }
+
+      &:read-only {
+        width: 0;
+        min-width: 0;
+      }
+    }
+  }
+
+  .tobago-options {
+    &.list-group-item {
+      padding: 0;
+      overflow-y: auto;
+    }
+
+    .table {
+      margin-bottom: 0;
+
+      tr {
+        cursor: pointer;
+
+        &.tobago-disabled {
+          color: $form-select-disabled-color;
+          background-color: $form-select-disabled-bg;
+          border-color: $form-select-disabled-border-color;
+          cursor: initial;
+          pointer-events: none;
+        }
+
+        &.tobago-preselect {
+          --bs-table-accent-bg: var(--bs-table-hover-bg);
+          color: var(--bs-table-hover-color);
+        }
+
+        &:last-of-type {
+          td {
+            border-bottom-width: 0;
+          }
+        }
+      }
+    }
+  }
+}
+
+@mixin selectListBorderColorShadow($name, $color) {
+  &.border-#{$name} {
+    box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba($color, $input-btn-focus-color-opacity);
+  }
+}
+
 @mixin tobagoTreeNodeToggle() {
   .tobago-toggle:not(.invisible) {
     cursor: pointer;
@@ -1194,6 +1325,10 @@ tobago-select-one-choice {
   }
 }
 
+tobago-select-one-list {
+  @include selectListBase();
+}
+
 tobago-select-one-listbox {
   display: block;
 
@@ -1216,81 +1351,9 @@ tobago-select-one-radio {
 }
 
 tobago-select-many-list {
-  display: block;
-
-  &.tobago-label-container {
-    .dropdown, .list-group {
-      flex: 1 0 0;
-    }
-  }
-
-  &.tobago-focus {
-    @mixin borderColorShadow($name, $color) {
-      &.border-#{$name} {
-        box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba($color, $input-btn-focus-color-opacity);
-      }
-    }
-
-    .dropdown .tobago-select-field {
-      @include formControlFocus();
-      @include borderColorShadow("danger", $danger);
-      @include borderColorShadow("warning", $warning);
-      @include borderColorShadow("info", $info);
-    }
-
-    .list-group {
-      --bs-list-group-color: #{$input-focus-color};
-      --bs-list-group-bg: #{$input-focus-bg};
-      --bs-list-group-border-color: #{$input-focus-border-color};
-
-      @include formControlFocusShadows();
-      @include borderColorShadow("danger", $danger);
-      @include borderColorShadow("warning", $warning);
-      @include borderColorShadow("info", $info);
-    }
-  }
-
-  &.tobago-disabled {
-    .tobago-select-field {
-      color: $form-select-disabled-color;
-      background-color: $form-select-disabled-bg;
-      border-color: $form-select-disabled-border-color;
-    }
-  }
-
-  .list-group {
-    height: inherit;
-    max-height: inherit;
-    @include box-shadow($form-select-box-shadow);
-    @include transition($form-select-transition);
-
-    &.border-danger, &.border-warning, &.border-info {
-      .list-group-item {
-        border-color: inherit;
-      }
-    }
-
-    .list-group-item.tobago-select-field {
-      border-bottom-color: $list-group-border-color;
-    }
-  }
+  @include selectListBase();
 
   .tobago-select-field {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-
-    &.dropdown-toggle::after {
-      content: none;
-    }
-
-    &.list-group-item.form-control {
-      border-bottom-left-radius: 0;
-      border-bottom-right-radius: 0;
-
-      padding: $form-select-padding-y $form-select-indicator-padding $form-select-padding-y $form-select-padding-x;
-    }
-
     .btn-group + .tobago-filter {
       margin-left: 0.25rem;
     }
@@ -1298,59 +1361,6 @@ tobago-select-many-list {
     .btn-group {
       margin-right: 0.25rem;
     }
-
-    .tobago-filter {
-      margin: 0;
-      border: 0;
-      border-radius: 0;
-      padding: 0;
-      flex: 1 0 0;
-      min-width: 8rem;
-      background-color: transparent;
-
-      &:focus {
-        box-shadow: none;
-      }
-
-      &:read-only {
-        width: 0;
-        min-width: 0;
-      }
-    }
-  }
-
-  .tobago-options {
-    &.list-group-item {
-      padding: 0;
-      overflow-y: auto;
-    }
-
-    .table {
-      margin-bottom: 0;
-
-      tr {
-        cursor: pointer;
-
-        &.tobago-disabled {
-          color: $form-select-disabled-color;
-          background-color: $form-select-disabled-bg;
-          border-color: $form-select-disabled-border-color;
-          cursor: initial;
-          pointer-events: none;
-        }
-
-        &.tobago-preselect {
-          --bs-table-accent-bg: var(--bs-table-hover-bg);
-          color: var(--bs-table-hover-color);
-        }
-
-        &:last-of-type {
-          td {
-            border-bottom-width: 0;
-          }
-        }
-      }
-    }
   }
 }
 
diff --git a/tobago-theme/tobago-theme-standard/src/main/resources/META-INF/tobago-config.xml b/tobago-theme/tobago-theme-standard/src/main/resources/META-INF/tobago-config.xml
index 9280f8d7b7..548a17ebfc 100644
--- a/tobago-theme/tobago-theme-standard/src/main/resources/META-INF/tobago-config.xml
+++ b/tobago-theme/tobago-theme-standard/src/main/resources/META-INF/tobago-config.xml
@@ -113,6 +113,11 @@
           <attribute name="messagePosition" default="buttonRight"/>
           <attribute name="helpPosition" default="buttonRight"/>
         </tag>
+        <tag name="selectOneList">
+          <attribute name="labelLayout" default="flexLeft"/>
+          <attribute name="messagePosition" default="buttonRight"/>
+          <attribute name="helpPosition" default="buttonRight"/>
+        </tag>
         <tag name="selectOneListbox">
           <attribute name="labelLayout" default="flexLeft"/>
           <attribute name="messagePosition" default="buttonRight"/>
diff --git a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-all.ts b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-all.ts
index d9f7acd722..2addc09525 100644
--- a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-all.ts
+++ b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-all.ts
@@ -41,6 +41,7 @@ import "./tobago-select-many-checkbox";
 import "./tobago-select-many-listbox";
 import "./tobago-select-many-shuttle";
 import "./tobago-select-one-choice";
+import "./tobago-select-one-list";
 import "./tobago-select-one-listbox";
 import "./tobago-select-one-radio";
 import "./tobago-sheet";
diff --git a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-list-base.ts b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-list-base.ts
new file mode 100644
index 0000000000..6059a0026f
--- /dev/null
+++ b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-list-base.ts
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {createPopper, Instance} from "@popperjs/core";
+
+export class SelectListBase extends HTMLElement {
+  protected readonly CssClass = {
+    D_NONE: "d-none",
+    DROPDOWN_MENU: "dropdown-menu",
+    SHOW: "show",
+    TABLE_ACTIVE: "table-active",
+    TABLE_PRIMARY: "table-primary",
+    TOBAGO_DISABLED: "tobago-disabled",
+    TOBAGO_FOCUS: "tobago-focus",
+    TOBAGO_PRESELECT: "tobago-preselect",
+    TOBAGO_OPTIONS: "tobago-options"
+  };
+
+  protected readonly Key = {
+    ARROW_DOWN: "ArrowDown",
+    ARROW_UP: "ArrowUp",
+    BACKSPACE: "Backspace",
+    ENTER: "Enter",
+    ESCAPE: "Escape",
+    SPACE: " ",
+    TAB: "Tab"
+  };
+
+  private popper: Instance;
+
+  get disabled(): boolean {
+    return this.classList.contains(this.CssClass.TOBAGO_DISABLED);
+  }
+
+  get hiddenSelect(): HTMLSelectElement {
+    return this.querySelector("select");
+  }
+
+  get hiddenOptions(): NodeListOf<HTMLOptionElement> {
+    return this.hiddenSelect.querySelectorAll<HTMLOptionElement>("option");
+  }
+
+  get selectField(): HTMLDivElement {
+    return this.querySelector(".tobago-select-field");
+  }
+
+  get filter(): string {
+    return this.getAttribute("filter");
+  }
+
+  get filterEnabled(): boolean {
+    return this.filter?.length > 0;
+  }
+
+  get filterInput(): HTMLInputElement {
+    return this.querySelector(".tobago-filter");
+  }
+
+  get dropdownMenu(): HTMLDivElement {
+    const root = this.getRootNode() as ShadowRoot | Document;
+    return root.querySelector(`.dropdown-menu[name='${this.id}']`);
+  }
+
+  get menuStore(): HTMLDivElement {
+    const root = this.getRootNode() as ShadowRoot | Document;
+    return root.querySelector(".tobago-page-menuStore");
+  }
+
+  get tbody(): HTMLElement {
+    const root = this.getRootNode() as ShadowRoot | Document;
+    return root.querySelector(`.tobago-options[name='${this.id}'] tbody`);
+  }
+
+  get rows(): NodeListOf<HTMLTableRowElement> {
+    return this.tbody.querySelectorAll<HTMLTableRowElement>("tr");
+  }
+
+  get enabledRows(): NodeListOf<HTMLTableRowElement> {
+    return this.tbody.querySelectorAll<HTMLTableRowElement>("tr:not(." + this.CssClass.D_NONE + ")");
+  }
+
+  get preselectedRow(): HTMLTableRowElement {
+    return this.tbody.querySelector<HTMLTableRowElement>("." + this.CssClass.TOBAGO_PRESELECT);
+  }
+
+  connectedCallback(): void {
+    if (this.dropdownMenu) {
+      this.popper = createPopper(this.selectField, this.dropdownMenu, {});
+      window.addEventListener("resize", this.resizeEvent.bind(this));
+    }
+  }
+
+  protected focusEvent(): void {
+    if (!this.hiddenSelect.disabled) {
+      if (!this.classList.contains(this.CssClass.TOBAGO_FOCUS)) {
+        this.setFocus(true);
+      }
+    }
+  }
+
+  protected setFocus(focus: boolean): void {
+    if (focus) {
+      this.classList.add(this.CssClass.TOBAGO_FOCUS);
+    } else {
+      this.classList.remove(this.CssClass.TOBAGO_FOCUS);
+    }
+  }
+
+  protected preselectNextTableRow(): void {
+    const rows = this.enabledRows;
+    const index = this.preselectIndex(rows);
+    if (index >= 0) {
+      if (index + 1 < rows.length) {
+        rows.item(index).classList.remove(this.CssClass.TOBAGO_PRESELECT);
+        this.preselect(rows.item(index + 1));
+      } else {
+        rows.item(rows.length - 1).classList.remove(this.CssClass.TOBAGO_PRESELECT);
+        this.preselect(rows.item(0));
+      }
+    } else if (rows.length > 0) {
+      this.preselect(rows.item(0));
+    }
+  }
+
+  protected preselectPreviousTableRow(): void {
+    const rows = this.enabledRows;
+    const index = this.preselectIndex(rows);
+    if (index >= 0) {
+      if ((index - 1) >= 0) {
+        rows.item(index).classList.remove(this.CssClass.TOBAGO_PRESELECT);
+        this.preselect(rows.item(index - 1));
+      } else {
+        rows.item(0).classList.remove(this.CssClass.TOBAGO_PRESELECT);
+        this.preselect(rows.item(rows.length - 1));
+      }
+    } else if (rows.length > 0) {
+      this.preselect(rows.item(rows.length - 1));
+    }
+  }
+
+  protected removePreselection(): void {
+    this.preselectedRow?.classList.remove(this.CssClass.TOBAGO_PRESELECT);
+  }
+
+  protected isPartOfSelectField(element: Element): boolean {
+    if (element) {
+      if (this.selectField.id === element.id) {
+        return true;
+      } else {
+        return element.parentElement ? this.isPartOfSelectField(element.parentElement) : false;
+      }
+    } else {
+      return false;
+    }
+  }
+
+  protected isPartOfTobagoOptions(element: Element): boolean {
+    if (element) {
+      if (element.classList.contains(this.CssClass.TOBAGO_OPTIONS)
+          && this.id === element.getAttribute("name")) {
+        return true;
+      } else {
+        return element.parentElement ? this.isPartOfTobagoOptions(element.parentElement) : false;
+      }
+    } else {
+      return false;
+    }
+  }
+
+  protected showDropdown(): void {
+    if (this.dropdownMenu && !this.dropdownMenu.classList.contains(this.CssClass.SHOW)) {
+      this.selectField.classList.add(this.CssClass.SHOW);
+      this.selectField.ariaExpanded = "true";
+      this.dropdownMenu.classList.add(this.CssClass.SHOW);
+      this.updateDropdownMenuWidth();
+      this.popper.update();
+    }
+  }
+
+  protected hideDropdown(): void {
+    if (this.dropdownMenu?.classList.contains(this.CssClass.SHOW)) {
+      this.selectField.classList.remove(this.CssClass.SHOW);
+      this.selectField.ariaExpanded = "false";
+      this.dropdownMenu.classList.remove(this.CssClass.SHOW);
+    }
+  }
+
+  private preselect(row: HTMLTableRowElement): void {
+    row.classList.add(this.CssClass.TOBAGO_PRESELECT);
+    if (!this.dropdownMenu) {
+      row.scrollIntoView({block: "center"});
+    }
+
+    this.filterInput.disabled = false;
+    this.filterInput.focus({preventScroll: true});
+  }
+
+  private preselectIndex(rows: NodeListOf<HTMLTableRowElement>): number {
+    for (let i = 0; i < rows.length; i++) {
+      if (rows.item(i).classList.contains(this.CssClass.TOBAGO_PRESELECT)) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  private resizeEvent(event: UIEvent): void {
+    this.updateDropdownMenuWidth();
+  }
+
+  private updateDropdownMenuWidth(): void {
+    if (this.dropdownMenu) {
+      this.dropdownMenu.style.width = `${this.selectField.offsetWidth}px`;
+    }
+  }
+}
diff --git a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-many-list.ts b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-many-list.ts
index 8058b29db2..f92709d9ce 100644
--- a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-many-list.ts
+++ b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-many-list.ts
@@ -16,83 +16,19 @@
  */
 
 import {TobagoFilterRegistry} from "./tobago-filter-registry";
-import {createPopper, Instance} from "@popperjs/core";
-
-class SelectManyList extends HTMLElement {
-  private popper: Instance;
-
-  private readonly CssClass = {
-    DROPDOWN_MENU: "dropdown-menu",
-    SHOW: "show",
-    TABLE_ACTIVE: "table-active",
-    TABLE_PRIMARY: "table-primary",
-    TOBAGO_DISABLED: "tobago-disabled",
-    TOBAGO_FOCUS: "tobago-focus",
-    TOBAGO_PRESELECT: "tobago-preselect",
-    TOBAGO_OPTIONS: "tobago-options"
-  };
-
-  private readonly Key = {
-    ARROW_DOWN: "ArrowDown",
-    ARROW_UP: "ArrowUp",
-    ENTER: "Enter",
-    ESCAPE: "Escape",
-    SPACE: " ",
-    TAB: "Tab"
-  };
+import {SelectListBase} from "./tobago-select-list-base";
 
+class SelectManyList extends SelectListBase {
   constructor() {
     super();
   }
 
-  get hiddenSelect(): HTMLSelectElement {
-    return this.querySelector("select");
-  }
-
-  get selectField(): HTMLDivElement {
-    return this.querySelector(".tobago-select-field");
-  }
-
   get badgeCloseButtons(): NodeListOf<HTMLButtonElement> {
     return this.selectField.querySelectorAll("button.btn.badge");
   }
 
-  get filter(): string {
-    return this.getAttribute("filter");
-  }
-
-  get filterInput(): HTMLInputElement {
-    return this.querySelector(".tobago-filter");
-  }
-
-  get dropdownMenu(): HTMLDivElement {
-    const root = this.getRootNode() as ShadowRoot | Document;
-    return root.querySelector(`.dropdown-menu[name='${this.id}']`);
-  }
-
-  get menuStore(): HTMLDivElement {
-    const root = this.getRootNode() as ShadowRoot | Document;
-    return root.querySelector(".tobago-page-menuStore");
-  }
-
-  get tbody(): HTMLElement {
-    const root = this.getRootNode() as ShadowRoot | Document;
-    return root.querySelector(`.tobago-options[name='${this.id}'] tbody`);
-  }
-
-  get enabledRows(): NodeListOf<HTMLTableRowElement> {
-    return this.tbody.querySelectorAll<HTMLTableRowElement>("tr:not(.tobago-disabled)");
-  }
-
-  get preselectedRow(): HTMLTableRowElement {
-    return this.tbody.querySelector<HTMLTableRowElement>("." + this.CssClass.TOBAGO_PRESELECT);
-  }
-
   connectedCallback(): void {
-    if (this.dropdownMenu) {
-      this.popper = createPopper(this.selectField, this.dropdownMenu, {});
-      window.addEventListener("resize", this.resizeEvent.bind(this));
-    }
+    super.connectedCallback();
     document.addEventListener("click", this.clickEvent.bind(this));
     this.filterInput.addEventListener("focus", this.focusEvent.bind(this));
     this.filterInput.addEventListener("blur", this.blurEvent.bind(this));
@@ -105,10 +41,8 @@ class SelectManyList extends HTMLElement {
 
     this.initList();
 
-    // init filter
     if (this.filter) {
-      const input = this.filterInput;
-      input.addEventListener("keyup", this.filterEvent.bind(this));
+      this.filterInput.addEventListener("input", this.filterEvent.bind(this));
     }
 
     // handle autofocus; trigger focus event
@@ -179,7 +113,7 @@ class SelectManyList extends HTMLElement {
       row.classList.remove(this.CssClass.TABLE_PRIMARY);
     }
 
-    if (!this.classList.contains(this.CssClass.TOBAGO_DISABLED) && !this.filter) {
+    if (!this.disabled && !this.filter) {
       // disable input field to prevent focus.
       if (this.badgeCloseButtons.length > 0 && this.filterInput.id === document.activeElement.id) {
         this.badgeCloseButtons.item(this.badgeCloseButtons.length - 1).focus();
@@ -212,30 +146,31 @@ class SelectManyList extends HTMLElement {
       this.querySelectorAll("tr").forEach(row => {
         const itemValue = row.dataset.tobagoValue;
         if (filterFunction(itemValue, searchString)) {
-          row.classList.remove("d-none");
+          row.classList.remove(this.CssClass.D_NONE);
         } else {
-          row.classList.add("d-none");
+          row.classList.add(this.CssClass.D_NONE);
         }
       });
     }
   }
 
   private clickEvent(event: MouseEvent): void {
-    if (this.isDeleted(event.target as Element)) {
-      // do nothing, this is probably a removed badge
-    } else if (this.isPartOfSelectField(event.target as Element)
-        || this.isPartOfTobagoOptions(event.target as Element)) {
+    if (!this.disabled) {
+      if (this.isDeleted(event.target as Element)) {
+        // do nothing, this is probably a removed badge
+      } else if (this.isPartOfSelectField(event.target as Element)
+          || this.isPartOfTobagoOptions(event.target as Element)) {
+
+        if (!this.filterInput.disabled) {
+          this.filterInput.focus();
+        } else if (this.badgeCloseButtons.length > 0) {
+          this.badgeCloseButtons[0].focus();
+        }
+        this.showDropdown();
 
-      if (!this.filterInput.disabled) {
-        this.filterInput.focus();
-      } else if (this.badgeCloseButtons.length > 0) {
-        this.badgeCloseButtons[0].focus();
+      } else {
+        this.leaveComponent();
       }
-      this.showDropdown();
-
-    } else {
-      this.hideDropdown();
-      this.setFocus(false);
     }
   }
 
@@ -273,146 +208,27 @@ class SelectManyList extends HTMLElement {
     }
   }
 
-  private preselectNextTableRow(): void {
-    const rows = this.enabledRows;
-    const index = this.preselectIndex(rows);
-    if (index >= 0) {
-      if (index + 1 < rows.length) {
-        rows.item(index).classList.remove(this.CssClass.TOBAGO_PRESELECT);
-        this.preselect(rows.item(index + 1));
-      } else {
-        rows.item(rows.length - 1).classList.remove(this.CssClass.TOBAGO_PRESELECT);
-        this.preselect(rows.item(0));
-      }
-    } else if (rows.length > 0) {
-      this.preselect(rows.item(0));
-    }
-  }
-
-  private preselectPreviousTableRow(): void {
-    const rows = this.enabledRows;
-    const index = this.preselectIndex(rows);
-    if (index >= 0) {
-      if ((index - 1) >= 0) {
-        rows.item(index).classList.remove(this.CssClass.TOBAGO_PRESELECT);
-        this.preselect(rows.item(index - 1));
-      } else {
-        rows.item(0).classList.remove(this.CssClass.TOBAGO_PRESELECT);
-        this.preselect(rows.item(rows.length - 1));
-      }
-    } else if (rows.length > 0) {
-      this.preselect(rows.item(rows.length - 1));
-    }
-  }
-
-  private preselect(row: HTMLTableRowElement): void {
-    row.classList.add(this.CssClass.TOBAGO_PRESELECT);
-    if (!this.dropdownMenu) {
-      row.scrollIntoView({block: "center"});
-    }
-
-    this.filterInput.disabled = false;
-    this.filterInput.focus({preventScroll: true});
-  }
-
-  private removePreselection(): void {
-    this.preselectedRow?.classList.remove(this.CssClass.TOBAGO_PRESELECT);
-  }
-
-  private preselectIndex(rows: NodeListOf<HTMLTableRowElement>): number {
-    for (let i = 0; i < rows.length; i++) {
-      if (rows.item(i).classList.contains(this.CssClass.TOBAGO_PRESELECT)) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  private isPartOfSelectField(element: Element): boolean {
-    if (element) {
-      if (this.selectField.id === element.id) {
-        return true;
-      } else {
-        return element.parentElement ? this.isPartOfSelectField(element.parentElement) : false;
-      }
-    } else {
-      return false;
-    }
-  }
-
-  private isPartOfTobagoOptions(element: Element): boolean {
-    if (element) {
-      if (element.classList.contains(this.CssClass.TOBAGO_OPTIONS)
-          && this.id === element.getAttribute("name")) {
-        return true;
-      } else {
-        return element.parentElement ? this.isPartOfTobagoOptions(element.parentElement) : false;
-      }
-    } else {
-      return false;
-    }
+  private leaveComponent(): void {
+    this.setFocus(false);
+    this.filterInput.value = null;
+    this.filterInput.dispatchEvent(new Event("input"));
+    this.hideDropdown();
   }
 
   private isDeleted(element: Element): boolean {
     return element.closest("html") === null;
   }
 
-  private showDropdown(): void {
-    if (this.dropdownMenu && !this.dropdownMenu.classList.contains(this.CssClass.SHOW)) {
-      this.selectField.classList.add(this.CssClass.SHOW);
-      this.selectField.ariaExpanded = "true";
-      this.dropdownMenu.classList.add(this.CssClass.SHOW);
-      this.updateDropdownMenuWidth();
-      this.popper.update();
-    }
-  }
-
-  private hideDropdown(): void {
-    if (this.dropdownMenu?.classList.contains(this.CssClass.SHOW)) {
-      this.selectField.classList.remove(this.CssClass.SHOW);
-      this.selectField.ariaExpanded = "false";
-      this.dropdownMenu.classList.remove(this.CssClass.SHOW);
-    }
-  }
-
-  private resizeEvent(event: UIEvent): void {
-    this.updateDropdownMenuWidth();
-  }
-
-  private updateDropdownMenuWidth(): void {
-    if (this.dropdownMenu) {
-      this.dropdownMenu.style.width = `${this.selectField.offsetWidth}px`;
-    }
-  }
-
-  private focusEvent(): void {
-    if (!this.hiddenSelect.disabled) {
-      if (!this.classList.contains(this.CssClass.TOBAGO_FOCUS)) {
-        this.setFocus(true);
-        this.showDropdown();
-      }
-    }
-  }
-
   private blurEvent(event: FocusEvent): void {
     if (event.relatedTarget !== null) {
       //relatedTarget is the new focused element; null indicate a mouseclick or an inactive browser window
       if (!this.isPartOfSelectField(event.relatedTarget as Element)
           && !this.isPartOfTobagoOptions(event.relatedTarget as Element)) {
-        this.setFocus(false);
-        this.hideDropdown();
+        this.leaveComponent();
       }
     }
   }
 
-  private setFocus(focus: boolean): void {
-    if (focus) {
-      this.classList.add(this.CssClass.TOBAGO_FOCUS);
-    } else {
-      this.classList.remove(this.CssClass.TOBAGO_FOCUS);
-    }
-  }
-
   private initList() {
     const tbody = this.tbody;
     tbody.addEventListener("click", this.select.bind(this));
diff --git a/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-one-list.ts b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-one-list.ts
new file mode 100644
index 0000000000..39a65efed2
--- /dev/null
+++ b/tobago-theme/tobago-theme-standard/src/main/ts/tobago-select-one-list.ts
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {TobagoFilterRegistry} from "./tobago-filter-registry";
+import {SelectListBase} from "./tobago-select-list-base";
+
+class SelectOneList extends SelectListBase {
+  constructor() {
+    super();
+  }
+
+  get spanText(): string {
+    return this.selectField.querySelector("span").textContent;
+  }
+
+  set spanText(text: string) {
+    this.selectField.querySelector("span").textContent = text;
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    document.addEventListener("click", this.clickEvent.bind(this));
+    this.filterInput.addEventListener("focus", this.focusEvent.bind(this));
+    this.filterInput.addEventListener("blur", this.blurEvent.bind(this));
+    this.selectField.addEventListener("keydown", this.keydownEvent.bind(this));
+    this.tbody.addEventListener("click", this.select.bind(this));
+
+    this.sync();
+
+    if (this.filter) {
+      this.filterInput.addEventListener("input", this.filterEvent.bind(this));
+    }
+
+    if (document.activeElement.id === this.filterInput.id) {
+      this.focusEvent();
+    }
+  }
+
+  select(event: MouseEvent): void {
+    const target = <HTMLElement>event.target;
+    const row = target.closest("tr");
+    this.selectRow(row);
+  }
+
+  selectRow(row: HTMLTableRowElement): void {
+    const itemValue = row.dataset.tobagoValue;
+    const select = this.hiddenSelect;
+    const option: HTMLOptionElement = select.querySelector(`[value="${itemValue}"]`);
+    option.selected = true;
+    this.filterInput.value = null;
+    this.sync();
+  }
+
+  sync() {
+    this.rows.forEach((row) => {
+      if (row.dataset.tobagoValue === this.hiddenSelect.value) {
+        this.spanText = this.hiddenSelect.value;
+        row.classList.add(this.CssClass.TABLE_PRIMARY); // highlight list row
+      } else {
+        row.classList.remove(this.CssClass.TABLE_PRIMARY); // remove highlight list row
+      }
+    });
+  }
+
+  filterEvent(event: Event): void {
+    const input = event.currentTarget as HTMLInputElement;
+    const searchString = input.value;
+    if (searchString !== null) {
+      this.spanText = null;
+      this.showDropdown();
+    }
+    const filterFunction = TobagoFilterRegistry.get(this.filter);
+    // XXX todo: if filterFunction not found?
+    if (filterFunction != null) {
+      this.querySelectorAll("tr").forEach(row => {
+        const itemValue = row.dataset.tobagoValue;
+        if (filterFunction(itemValue, searchString)) {
+          row.classList.remove(this.CssClass.D_NONE);
+        } else {
+          row.classList.add(this.CssClass.D_NONE);
+        }
+      });
+    }
+  }
+
+  private clickEvent(event: MouseEvent): void {
+    if (!this.disabled) {
+      if (this.isPartOfSelectField(event.target as Element) || this.isPartOfTobagoOptions(event.target as Element)) {
+        if (!this.filterInput.disabled) {
+          this.filterInput.focus();
+        }
+        this.showDropdown();
+      } else {
+        this.leaveComponent();
+      }
+    }
+  }
+
+  private keydownEvent(event: KeyboardEvent) {
+    switch (event.key) {
+      case this.Key.ESCAPE:
+        this.hideDropdown();
+        this.removePreselection();
+        break;
+      case this.Key.ARROW_DOWN:
+        event.preventDefault();
+        this.showDropdown();
+        this.preselectNextTableRow();
+        break;
+      case this.Key.ARROW_UP:
+        event.preventDefault();
+        this.showDropdown();
+        this.preselectPreviousTableRow();
+        break;
+      case this.Key.BACKSPACE:
+        if (this.filterInput.value.length === 0) {
+          this.spanText = null;
+          this.filterInput.dispatchEvent(new Event("input"));
+        }
+        break;
+      case this.Key.ENTER:
+      case this.Key.SPACE:
+        if (this.preselectedRow) {
+          event.preventDefault();
+          const row = this.tbody.querySelector<HTMLTableRowElement>("." + this.CssClass.TOBAGO_PRESELECT);
+          this.selectRow(row);
+        } else if (document.activeElement.id === this.filterInput.id) {
+          this.showDropdown();
+        }
+        break;
+    }
+  }
+
+  private leaveComponent(): void {
+    this.setFocus(false);
+    this.filterInput.value = null;
+    this.filterInput.dispatchEvent(new Event("input"));
+    this.spanText = this.hiddenSelect.value;
+    this.hideDropdown();
+  }
+
+  private blurEvent(event: FocusEvent): void {
+    if (event.relatedTarget !== null) {
+      //relatedTarget is the new focused element; null indicate a mouseclick or an inactive browser window
+      if (!this.isPartOfSelectField(event.relatedTarget as Element)
+          && !this.isPartOfTobagoOptions(event.relatedTarget as Element)) {
+        this.leaveComponent();
+      }
+    }
+  }
+}
+
+document.addEventListener("tobago.init", function (event: Event): void {
+  if (window.customElements.get("tobago-select-one-list") == null) {
+    window.customElements.define("tobago-select-one-list", SelectOneList);
+  }
+});