Repository: spacetelescope/jdaviz

Issue:
Generalize filtering to the subset dropdown component
**Reporter:** pllim

As a developer, sometimes I want to have a custom filter for the Subset dropdown menu for a plugin. For example, in Simple Aperture Plugin, it would not make sense to let user select annulus Subset for the aperture but it is okay for background, and both selections use the same Subset dropdown menu machinery. While there are built-in filters that filter by "spatial" or "spectral", what I really need is a custom one-off filter callback and I can somehow plug into the machinery but only in that plugin for that one option.

@kecnry , *"... this is more involved than I had hoped."*

xref https://github.com/spacetelescope/jdaviz/pull/2276#discussion_r1248082434

[🐱](https://jira.stsci.edu/browse/JDAT-3576)

 --- 

 DISCLAIMER: This issue was autocreated by the Jdaviz Issue Creation Bot on behalf of the reporter. If any information is incorrect, please contact [Duy Nguyen](https://github.com/duytnguyendtn)

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 9bb6c63a..484bde6a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -63,7 +63,7 @@ Imviz
 - Simple Aperture Photometry plugin: Custom annulus background options are removed.
   Please draw/load annulus as you would with other region shapes, then select it
   in the plugin from Subset dropdown for the background. Using annulus region as
-  aperture is not supported. [#2276]
+  aperture is not supported. [#2276, #2287]
 
 Mosviz
 ^^^^^^
diff --git a/docs/dev/ui_style_guide.rst b/docs/dev/ui_style_guide.rst
index af2edf79..ca43d7bb 100644
--- a/docs/dev/ui_style_guide.rst
+++ b/docs/dev/ui_style_guide.rst
@@ -14,28 +14,37 @@ try to adhere to the following principles:
   ``j-tray-plugin`` stylesheet (``jdaviz/components/tray_plugin.vue``).
 * Each item should be wrapped in a ``v-row``, but avoid any unnecessary additional wrapping-components
   (``v-card-*``, ``v-container``, etc).
-* Only use ``v-col`` components (within the ``<v-row class="row-no-outside-padding">``) if multiple
+* Only use ``v-col`` components (within a ``<v-row class="row-no-outside-padding">``) if multiple
   components are necessary in a single row.  Always emphasize readability at the default/minimum
   width of the plugin tray, rather than using columns that result in a ton of text overflow.
-* Action buttons should have ``color="primary"`` if it loads something into the plugin, or
-  ``color="accent"`` if applying something to the viewers/apps/data.
+* Use ``<v-row justify="end">`` to align content to the right (such as action buttons).
+* Action buttons should use ``<v-btn text></v-btn>`` with ``color="accent"`` if applying something
+  to the viewers/apps/data, and ``color="primary"`` otherwise.
 * To remove vertical padding from rows (i.e., two successive buttons stacked vertically), use
   ``<v-row class="row-min-bottom-padding">``.
-* Use ``<v-row justify="end">`` to align content to the right (such as action buttons).
 * Use new ``<j-plugin-section-header>Header Text</j-plugin-section-header>`` to separate content
   within a plugin (instead of nested cards, ``v-card-subtitle``, etc).
+* Plugin settings should use ``<v-expansion-panels popout>`` immediately at the top of the plugin.
+  Optional "sections" of a plugin or editing dynamically-created components should use
+  ``<v-expansion-panels accordion>`` to make most use of horizontal and vertical space.
 * Number entries should use a ``<v-text-field type="number" v-model="traitlet_name">`` component
   *unless* requiring support for scientific notation (in which case
   ``<v-text-field @change="python_method">`` can be used with stripping invalid characters and
   type-casting in python).  To handle emptying the input component without raising a traceback,
-  use an ``IntHandleEmpty`` traitlet instead, along with form-validation (see below) and/or
-  checks on the python-side to handle the case of an empty string.
+  use an ``IntHandleEmpty`` or ``FloatHandleEmpty`` traitlet instead, along with form-validation
+  (see below) and/or checks on the python-side to handle the case of an empty string.
 * Use form validation wherever possible, and disable action buttons if the relevant validation
   does not pass.  This is preferred to raising errors through snackbars after pressing an action
   button.  To do this, wrap the relevant section in a ``<v-form v-model="form_valid_section_name">``,
   create a ``form_valid_section_name = Bool(False).tag(sync=True)`` in the python class for the
   plugin, add rules to any relevant inputs, and set ``:disabled="!form_valid_section_name"`` to any
   action buttons.
+* When validation requires checking multiple inputs simultaneously, the validation error message
+  can be displayed as its own element using ``<v-row v-if="condition"><span class="v-messages v-messages__message text--secondary">
+  <b style="color: red !important">WARNING:</b> warning message</span></v-row>``.
+  This should be positioned where the error makes most sense in the flow of the input elements
+  and/or immediately above the action button to explain why it is disabled.
+  For warnings/errors that require more attention, use a ``<v-alert>`` instead.
 * Select input elements should default whenever possible (not start as empty), and self-hide if only
   one valid option. Whenever possible, inputs should use form validation rules with red text
   explaining the error and disabling action buttons. When one selection/check makes others
diff --git a/jdaviz/components/plugin_add_results.vue b/jdaviz/components/plugin_add_results.vue
index 2660f84d..693da081 100644
--- a/jdaviz/components/plugin_add_results.vue
+++ b/jdaviz/components/plugin_add_results.vue
@@ -55,7 +55,8 @@
     <v-row justify="end">
       <j-tooltip :tooltipcontent="label_overwrite ? action_tooltip+' and replace existing entry' : action_tooltip">
         <v-btn :disabled="label_invalid_msg.length > 0 || action_disabled"
-          color="accent" text
+          text
+          color="accent"
           @click="$emit('click:action')"
         >{{action_label}}{{label_overwrite ? ' (Overwrite)' : ''}}
         </v-btn>
diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.vue b/jdaviz/configs/default/plugins/export_plot/export_plot.vue
index 67408e44..23444531 100644
--- a/jdaviz/configs/default/plugins/export_plot/export_plot.vue
+++ b/jdaviz/configs/default/plugins/export_plot/export_plot.vue
@@ -12,26 +12,26 @@
     />
 
     <div v-if="viewer_selected">
-      <v-list>
-       <v-list-item>
+      <v-row justify="end" class="row-min-bottom-padding">
         <v-btn
+         text
          color="primary"
          @click="() => save_figure('png')"
          :disabled="movie_recording"
         >
          Export to PNG
         </v-btn>
-       </v-list-item>
-       <v-list-item>
+      </v-row>
+      <v-row justify="end">
         <v-btn
+         text
          color="primary"
          @click="() => save_figure('svg')"
          :disabled="movie_recording"
         >
          Export to SVG
         </v-btn>
-       </v-list-item>
-      </v-list>
+      </v-row>
 
       <v-row v-if="config==='cubeviz' && viewer_selected!=='spectrum-viewer'">
         <v-expansion-panels accordion>
@@ -95,9 +95,25 @@
                 </v-col>
               </v-row>
               <v-row v-if="movie_msg===''" justify='end'>
+                <v-tooltip top v-if="movie_recording">
+                  <template v-slot:activator="{ on, attrs }">
+                    <v-btn
+                     color="primary"
+                     icon
+                     @click="interrupt_recording"
+                     v-bind="attrs"
+                     v-on="on"
+                     :disabled="!movie_recording">
+                      <v-icon>stop</v-icon>
+                    </v-btn>
+                  </template>
+                  <span>Interrupt recording and delete movie file</span>
+                </v-tooltip>
+
                 <v-tooltip top>
                   <template v-slot:activator="{ on, attrs }">
                     <v-btn
+                     text
                      color="primary"
                      @click="() => save_movie('mp4')"
                      v-bind="attrs"
@@ -109,22 +125,6 @@
                   </template>
                   <span>Start movie recording</span>
                 </v-tooltip>
-                <div v-if="movie_recording">
-                  <v-tooltip top>
-                    <template v-slot:activator="{ on, attrs }">
-                      <v-btn
-                       color="primary"
-                       icon
-                       @click="interrupt_recording"
-                       v-bind="attrs"
-                       v-on="on"
-                       :disabled="!movie_recording">
-                        <v-icon>stop</v-icon>
-                      </v-btn>
-                    </template>
-                    <span>Interrupt recording and delete movie file</span>
-                  </v-tooltip>
-                </div>
               </v-row>
             </v-expansion-panel-content>
           </v-expansion-panel>
diff --git a/jdaviz/configs/default/plugins/line_lists/line_lists.py b/jdaviz/configs/default/plugins/line_lists/line_lists.py
index d324ea42..f27b3eaa 100644
--- a/jdaviz/configs/default/plugins/line_lists/line_lists.py
+++ b/jdaviz/configs/default/plugins/line_lists/line_lists.py
@@ -513,10 +513,8 @@ class LineListTool(PluginTemplateMixin):
             list_contents[row["listname"]]["lines"].append(temp_dict)
             tmp_names_rest.append(row["name_rest"])
 
-        self.loaded_lists = []
-        self.loaded_lists = loaded_lists
-        self.list_contents = {}
-        self.list_contents = list_contents
+        self.send_state('loaded_lists')
+        self.send_state('list_contents')
 
         self._viewer.plot_spectral_lines(tmp_names_rest)
         self.update_line_mark_dict()
@@ -654,9 +652,9 @@ class LineListTool(PluginTemplateMixin):
         for line in lc[listname]["lines"]:
             line["show"] = True
             self._viewer.spectral_lines.loc[line["name_rest"]]["show"] = True
-        # Trick traitlets into updating
-        self.list_contents = {}
+
         self.list_contents = lc
+        self.send_state('list_contents')
 
         self._viewer.plot_spectral_lines()
         self.update_line_mark_dict()
@@ -665,14 +663,12 @@ class LineListTool(PluginTemplateMixin):
         """
         Toggle all lines in list to be hidden
         """
-        lc = self.list_contents
         name_rests = []
-        for line in lc[listname]["lines"]:
+        for line in self.list_contents[listname]["lines"]:
             line["show"] = False
             name_rests.append(line["name_rest"])
-        # Trick traitlets into updating
-        self.list_contents = {}
-        self.list_contents = lc
+
+        self.send_state('list_contents')
 
         self._viewer.erase_spectral_lines(name_rest=name_rests)
         self.update_line_mark_dict()
@@ -686,14 +682,12 @@ class LineListTool(PluginTemplateMixin):
                                            sender=self, color="error")
             self.hub.broadcast(warn_message)
             return
-        lc = self.list_contents
-        for listname in lc:
-            for line in lc[listname]["lines"]:
+        for listname in self.list_contents:
+            for line in self.list_contents[listname]["lines"]:
                 line["show"] = True
         self._viewer.spectral_lines["show"] = True
-        # Trick traitlets into updating
-        self.list_contents = {}
-        self.list_contents = lc
+
+        self.send_state('list_contents')
 
         self._viewer.plot_spectral_lines()
         self.update_line_mark_dict()
@@ -707,13 +701,11 @@ class LineListTool(PluginTemplateMixin):
                                            sender=self, color="error")
             self.hub.broadcast(warn_message)
             return
-        lc = self.list_contents
-        for listname in lc:
-            for line in lc[listname]["lines"]:
+        for listname in self.list_contents:
+            for line in self.list_contents[listname]["lines"]:
                 line["show"] = False
-        # Trick traitlets into updating
-        self.list_contents = {}
-        self.list_contents = lc
+
+        self.send_state('list_contents')
 
         self._viewer.erase_spectral_lines()
         self.update_line_mark_dict()
@@ -798,11 +790,10 @@ class LineListTool(PluginTemplateMixin):
         color = data['color']
         if "listname" in data:
             listname = data["listname"]
-            # force a copy so that the change is picked up by traitlets
-            lc = self.list_contents[listname].copy()
-            lc["color"] = color
 
-            for line in lc["lines"]:
+            self.list_contents[listname]["color"] = color
+
+            for line in self.list_contents[listname]["lines"]:
                 line["colors"] = color
                 # Update the astropy table entry
                 name_rest = line["name_rest"]
@@ -811,7 +802,7 @@ class LineListTool(PluginTemplateMixin):
                 if name_rest in self.line_mark_dict:
                     self.line_mark_dict[name_rest].colors = [color]
 
-            self.list_contents = {**self.list_contents, listname: lc}
+            self.send_state('list_contents')
 
         elif "linename" in data:
             pass
diff --git a/jdaviz/configs/default/plugins/model_fitting/model_fitting.py b/jdaviz/configs/default/plugins/model_fitting/model_fitting.py
index e7ccc533..4b2bd3eb 100644
--- a/jdaviz/configs/default/plugins/model_fitting/model_fitting.py
+++ b/jdaviz/configs/default/plugins/model_fitting/model_fitting.py
@@ -132,7 +132,7 @@ class ModelFitting(PluginTemplateMixin, DatasetSelectMixin,
                                                'spatial_subset_items',
                                                'spatial_subset_selected',
                                                default_text='Entire Cube',
-                                               allowed_type='spatial')
+                                               filters=['is_spatial'])
         else:
             self.spatial_subset = None
 
@@ -241,10 +241,7 @@ class ModelFitting(PluginTemplateMixin, DatasetSelectMixin,
 
             m["parameters"] = temp_params
 
-        # Trick traitlets into updating the displayed values
-        component_models = self.component_models
-        self.component_models = []
-        self.component_models = component_models
+        self.send_state('component_models')
 
     def _update_parameters_from_QM(self):
         """
@@ -283,10 +280,7 @@ class ModelFitting(PluginTemplateMixin, DatasetSelectMixin,
                 temp_params += temp_param
             m["parameters"] = temp_params
 
-        # Trick traitlets into updating the displayed values
-        component_models = self.component_models
-        self.component_models = []
-        self.component_models = component_models
+        self.send_state('component_models')
 
     def _update_initialized_parameters(self):
         # If the user changes a parameter value, we need to change it in the
diff --git a/jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue b/jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue
index e3d0080d..5ad447cd 100644
--- a/jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue
+++ b/jdaviz/configs/default/plugins/subset_plugin/subset_plugin.vue
@@ -88,11 +88,11 @@
       </v-row>
     </div>
 
-      <v-row justify="end" no-gutters>
-        <j-tooltip v-if="can_simplify" tooltipcontent="Convert composite subset to use only add mode to connect subregions">
-          <v-btn color="primary" text @click="simplify_subset">Simplify</v-btn>
-        </j-tooltip>
-        <v-btn color="primary" text @click="update_subset">Update</v-btn>
-      </v-row>
+    <v-row justify="end">
+      <j-tooltip v-if="can_simplify" tooltipcontent="Convert composite subset to use only add mode to connect subregions">
+        <v-btn color="primary" text @click="simplify_subset">Simplify</v-btn>
+      </j-tooltip>
+      <v-btn color="primary" text @click="update_subset">Update</v-btn>
+    </v-row>
   </j-tray-plugin>
 </template>
diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
index 9b078450..b8e0d77f 100644
--- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
+++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py
@@ -55,14 +55,14 @@ class SimpleAperturePhotometry(PluginTemplateMixin, DatasetSelectMixin, TableMix
                                    'subset_items',
                                    'subset_selected',
                                    default_text=None,
-                                   allowed_type='spatial')
+                                   filters=['is_spatial', 'is_not_composite', 'is_not_annulus'])
 
         self.bg_subset = SubsetSelect(self,
                                       'bg_subset_items',
                                       'bg_subset_selected',
                                       default_text='Manual',
                                       manual_options=['Manual'],
-                                      allowed_type='spatial')
+                                      filters=['is_spatial', 'is_not_composite'])
 
         headers = ['xcenter', 'ycenter', 'sky_center',
                    'sum', 'sum_aper_area',
diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
index defadb32..62c50113 100644
--- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
+++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue
@@ -18,7 +18,7 @@
         :selected.sync="subset_selected"
         :show_if_single_entry="true"
         label="Aperture"
-        hint="Select aperture region for photometry."
+        hint="Select aperture region for photometry (cannot be an annulus or composite subset)."
       />
 
       <div v-if="subset_selected">
@@ -27,7 +27,7 @@
           :selected.sync="bg_subset_selected"
           :show_if_single_entry="true"
           label="Background"
-          hint="Select subset region for background calculation."
+          hint="Select subset region for background calculation (cannot be a composite subset)."
         />
 
         <v-row v-if="subset_selected === bg_subset_selected">
diff --git a/jdaviz/configs/mosviz/plugins/slit_overlay/slit_overlay.py b/jdaviz/configs/mosviz/plugins/slit_overlay/slit_overlay.py
index 400e72a5..3ec07341 100644
--- a/jdaviz/configs/mosviz/plugins/slit_overlay/slit_overlay.py
+++ b/jdaviz/configs/mosviz/plugins/slit_overlay/slit_overlay.py
@@ -152,7 +152,6 @@ class SlitOverlay(PluginTemplateMixin):
             # We need to do the following instead of just removing directly on
             # the marks otherwise traitlets doesn't register a change in the
             # marks.
-            marks = image_figure.marks.copy()
-            marks.remove(self._slit_overlay_mark)
-            image_figure.marks = marks
+            image_figure.marks.remove(self._slit_overlay_mark)
+            image_figure.send_state('marks')
             self._slit_overlay_mark = None
diff --git a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py
index 5365e7cb..7aa9ecf3 100644
--- a/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py
+++ b/jdaviz/configs/specviz/plugins/line_analysis/line_analysis.py
@@ -130,7 +130,7 @@ class LineAnalysis(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelect
                                       'continuum_subset_items',
                                       'continuum_subset_selected',
                                       default_text='Surrounding',
-                                      allowed_type='spectral')
+                                      filters=['is_spectral'])
 
         # when accessing the selected data, access the spectrum-viewer version
         self.dataset._viewers = [self._default_spectrum_viewer_reference_name]
@@ -142,7 +142,7 @@ class LineAnalysis(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelect
                                                'spatial_subset_items',
                                                'spatial_subset_selected',
                                                default_text=SPATIAL_DEFAULT_TEXT,
-                                               allowed_type='spatial')
+                                               filters=['is_spatial'])
         else:
             self.spatial_subset = None
 
diff --git a/jdaviz/configs/specviz2d/plugins/spectral_extraction/spectral_extraction.vue b/jdaviz/configs/specviz2d/plugins/spectral_extraction/spectral_extraction.vue
index 638385be..70f1b1c0 100644
--- a/jdaviz/configs/specviz2d/plugins/spectral_extraction/spectral_extraction.vue
+++ b/jdaviz/configs/specviz2d/plugins/spectral_extraction/spectral_extraction.vue
@@ -159,7 +159,7 @@
       </div>
 
       <v-row>
-        <v-expansion-panels popout>
+        <v-expansion-panels accordion>
           <v-expansion-panel>
             <v-expansion-panel-header v-slot="{ open }">
               <span style="padding: 6px">Export Trace</span>
@@ -273,7 +273,7 @@
       </v-row>
 
       <v-row>
-        <v-expansion-panels popout>
+        <v-expansion-panels accordion>
           <v-expansion-panel>
             <v-expansion-panel-header v-slot="{ open }">
               <span style="padding: 6px">Export Background Image</span>
@@ -297,7 +297,7 @@
         </v-expansion-panels>
       </v-row>
       <v-row>
-        <v-expansion-panels popout>
+        <v-expansion-panels accordion>
           <v-expansion-panel>
             <v-expansion-panel-header v-slot="{ open }">
               <span style="padding: 6px">Export Background Spectrum</span>
@@ -321,7 +321,7 @@
         </v-expansion-panels>
       </v-row>
       <v-row>
-        <v-expansion-panels popout>
+        <v-expansion-panels accordion>
           <v-expansion-panel>
             <v-expansion-panel-header v-slot="{ open }">
               <span style="padding: 6px">Export Background-Subtracted Image</span>
diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py
index 1b8fece4..ca06dbad 100644
--- a/jdaviz/core/template_mixin.py
+++ b/jdaviz/core/template_mixin.py
@@ -14,6 +14,7 @@ from glue.core.message import (DataCollectionAddMessage,
                                SubsetCreateMessage,
                                SubsetDeleteMessage,
                                SubsetUpdateMessage)
+from glue.core.roi import CircularAnnulusROI
 from glue.core.subset import RoiSubsetState
 from glue_jupyter.bqplot.image import BqplotImageView
 from glue_jupyter.widgets.linked_dropdown import get_choices as _get_glue_choices
@@ -745,7 +746,7 @@ class LayerSelect(SelectPluginComponent):
     """
     def __init__(self, plugin, items, selected, viewer,
                  multiselect=None,
-                 default_text=None, manual_options=[], allowed_type=None,
+                 default_text=None, manual_options=[],
                  default_mode='first'):
         """
         Parameters
@@ -937,8 +938,7 @@ class SubsetSelect(SelectPluginComponent):
 
     * create (empty) traitlets in the plugin
     * register with all the automatic logic in the plugin's init by passing the string names
-      of the respective traitlets.  Pass ``allowed_type='spectral'`` or ``allowed_type='spatial'``
-      to only support spectral or spatial subsets, respectively.
+      of the respective traitlets.
     * use component in plugin template (see below)
     * refer to properties above based on the interally stored reference to the
       instantiated object of this component
@@ -956,7 +956,7 @@ class SubsetSelect(SelectPluginComponent):
 
     """
     def __init__(self, plugin, items, selected, selected_has_subregions=None,
-                 viewers=None, default_text=None, manual_options=[], allowed_type=None,
+                 viewers=None, default_text=None, manual_options=[], filters=[],
                  default_mode='default_text'):
         """
         Parameters
@@ -975,27 +975,23 @@ class SubsetSelect(SelectPluginComponent):
         default_text : str or None
             the text to show for no selection.  If not provided or None, no entry will be provided
             in the dropdown for no selection.
-        manual_options: list
+        manual_options : list
             list of options to provide that are not automatically populated by subsets.  If
             ``default`` text is provided but not in ``manual_options`` it will still be included as
             the first item in the list.
-        allowed_type : str or None
-            whether to filter to 'spatial' or 'spectral' types of subsets.  If not provided or None,
-            will include both entries.
+        filters : list
+            list of strings (for built-in filters) or callables to filter to only valid options.
         """
         super().__init__(plugin,
                          items=items,
                          selected=selected,
+                         filters=filters,
                          selected_has_subregions=selected_has_subregions,
                          viewers=viewers,
                          default_text=default_text,
                          manual_options=manual_options,
                          default_mode=default_mode)
 
-        if allowed_type not in [None, 'spatial', 'spectral']:
-            raise ValueError("allowed_type must be None, 'spatial', or 'spectral'")
-        self._allowed_type = allowed_type
-
         if selected_has_subregions is not None:
             self.selected_has_subregions = False
 
@@ -1029,14 +1025,29 @@ class SubsetSelect(SelectPluginComponent):
         if self.selected not in self.labels:
             self._apply_default_selection()
 
-    def _update_subset(self, subset, attribute=None):
-        if self._allowed_type is not None and _subset_type(subset) != self._allowed_type:
-            return
+    def _is_valid_item(self, subset):
+        def is_spectral(subset):
+            return _subset_type(subset) == 'spectral'
 
+        def is_spatial(subset):
+            return _subset_type(subset) == 'spatial'
+
+        def is_not_composite(subset):
+            return not hasattr(subset.subset_state, 'state1')
+
+        def is_not_annulus(subset):
+            # this will be considered "not an annulus" if it is composite, even
+            # if that composite subset contains an annulus
+            return (not is_not_composite(subset)
+                    or not isinstance(subset.subset_state.roi, CircularAnnulusROI))
+
+        return super()._is_valid_item(subset, locals())
+
+    def _update_subset(self, subset, attribute=None):
         if subset.label not in self.labels:
             # NOTE: this logic will need to be revisited if generic renaming of subsets is added
             # see https://github.com/spacetelescope/jdaviz/pull/1175#discussion_r829372470
-            if subset.label.startswith('Subset'):
+            if subset.label.startswith('Subset') and self._is_valid_item(subset):
                 # NOTE: += will not trigger traitlet update
                 self.items = self.items + [self._subset_to_dict(subset)]  # noqa
         else:
@@ -1084,10 +1095,12 @@ class SubsetSelect(SelectPluginComponent):
     @property
     def selected_subset_mask(self):
         get_data_kwargs = {'data_label': self.plugin.dataset.selected}
-        if self._allowed_type:
-            get_data_kwargs[f'{self._allowed_type}_subset'] = self.selected
+        if 'is_spectral' in self.filters:
+            get_data_kwargs['spectral_subset'] = self.selected
+        elif 'is_spatial' in self.filters:
+            get_data_kwargs['spatial_subset'] = self.selected
 
-        if self.app.config == 'cubeviz' and self._allowed_type == 'spectral':
+        if self.app.config == 'cubeviz' and 'is_spectral' in self.filters:
             viewer_ref = getattr(self.plugin,
                                  '_default_spectrum_viewer_reference_name',
                                  self.viewers[0].reference_id)
@@ -1143,7 +1156,7 @@ class SpectralSubsetSelectMixin(VuetifyTemplate, HubListener):
                                             'spectral_subset_selected_has_subregions',
                                             viewers=[spectrum_viewer],
                                             default_text='Entire Spectrum',
-                                            allowed_type='spectral')
+                                            filters=['is_spectral'])
 
 
 class SpatialSubsetSelectMixin(VuetifyTemplate, HubListener):
@@ -1180,7 +1193,7 @@ class SpatialSubsetSelectMixin(VuetifyTemplate, HubListener):
                                            'spatial_subset_selected',
                                            'spatial_subset_selected_has_subregions',
                                            default_text='No Subset',
-                                           allowed_type='spatial')
+                                           filters=['is_spatial'])
 
 
 class DatasetSpectralSubsetValidMixin(VuetifyTemplate, HubListener):
@@ -1460,6 +1473,8 @@ class DatasetSelect(SelectPluginComponent):
             the name of the items traitlet defined in ``plugin``
         selected : str
             the name of the selected traitlet defined in ``plugin``
+        filters : list
+            list of strings (for built-in filters) or callables to filter to only valid options.
         default_text : str or None
             the text to show for no selection.  If not provided or None, no entry will be provided
             in the dropdown for no selection.


Test patch:
diff --git a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py
index 14c6e02c..121a77ed 100644
--- a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py
+++ b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py
@@ -224,6 +224,10 @@ def test_annulus_background(imviz_helper):
         PixCoord(x=20.5, y=37.5), inner_radius=20.5, outer_radius=30.5)
     imviz_helper.load_regions([annulus_2])
 
+    # Subset 4 (annulus) should be available for the background but not the aperture
+    assert 'Subset 4' not in phot_plugin.subset.choices
+    assert 'Subset 4' in phot_plugin.bg_subset.choices
+
     phot_plugin.subset_selected = 'Subset 3'
     phot_plugin.bg_subset_selected = 'Subset 4'