streamlit-sortable-multiselect 0.7.5__tar.gz → 0.7.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {streamlit_sortable_multiselect-0.7.5/streamlit_sortable_multiselect.egg-info → streamlit_sortable_multiselect-0.7.6}/PKG-INFO +108 -5
  2. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/README.md +107 -4
  3. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/pyproject.toml +1 -1
  4. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect/__init__.py +178 -23
  5. streamlit_sortable_multiselect-0.7.6/streamlit_sortable_multiselect/frontend/build/assets/index-B3sxegjX.js +67 -0
  6. streamlit_sortable_multiselect-0.7.6/streamlit_sortable_multiselect/frontend/build/assets/index-CJc6AEWC.css +1 -0
  7. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect/frontend/build/index.html +2 -2
  8. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6/streamlit_sortable_multiselect.egg-info}/PKG-INFO +108 -5
  9. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect.egg-info/SOURCES.txt +2 -2
  10. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/tests/test_wrapper.py +136 -4
  11. streamlit_sortable_multiselect-0.7.5/streamlit_sortable_multiselect/frontend/build/assets/index-DGpJ50qI.css +0 -1
  12. streamlit_sortable_multiselect-0.7.5/streamlit_sortable_multiselect/frontend/build/assets/index-RH2LvVA2.js +0 -67
  13. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/LICENSE +0 -0
  14. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/MANIFEST.in +0 -0
  15. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/setup.cfg +0 -0
  16. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect.egg-info/dependency_links.txt +0 -0
  17. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect.egg-info/requires.txt +0 -0
  18. {streamlit_sortable_multiselect-0.7.5 → streamlit_sortable_multiselect-0.7.6}/streamlit_sortable_multiselect.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamlit-sortable-multiselect
3
- Version: 0.7.5
3
+ Version: 0.7.6
4
4
  Summary: A Streamlit custom component for selecting and reordering multiple string values.
5
5
  Author: Kentaro Aoki
6
6
  License-Expression: MIT
@@ -101,8 +101,12 @@ st.write(selected)
101
101
  | `disabled` | `bool` | `False` | Disables searching, selecting, removing, dragging, and move buttons. |
102
102
  | `show_move_buttons` | `bool` | `True` | Shows up/down buttons on selected items. Drag sorting remains available unless `disabled=True`. |
103
103
  | `show_numbers` | `bool` | `False` | Shows 1-based position numbers before selected item labels. |
104
- | `base_color` | `str \| None` | `None` | Background color applied to all selected items. Accepts CSS color values such as `"#eef2ff"` or `"lightblue"`. |
105
- | `order_colors` | `Mapping[int, str] \| None` | `None` | Per-position selected item background colors. Keys are 1-based positions, for example `{1: "#fee2e2", 2: "#dcfce7"}`. These override `base_color` for matching positions. |
104
+ | `base_color` | `str \| Mapping[str, str] \| None` | `None` | Fallback color for every selected item. See [Colors](#colors). |
105
+ | `order_colors` | `Mapping[int, str \| Mapping[str, str]] \| None` | `None` | Per-position colors. Positive keys count from the top (`1` is first), negative keys count from the bottom (`-1` is last), for example `{1: "#fee2e2", -1: "#dcfce7"}`. |
106
+ | `value_colors` | `Mapping[str, str \| Mapping[str, str]] \| None` | `None` | Per-value colors keyed by option value, for example `{"python": "#3776ab"}`. These follow an item as it is reordered. |
107
+ | `color_palette` | `Sequence[str \| Mapping[str, str]] \| None` | `None` | Colors cycled across selected positions. Position 1 uses the first entry and the palette repeats for longer selections. |
108
+ | `color_priority` | `Sequence[str] \| None` | `None` | Ranking of the color sources `"value"`, `"option"`, `"order"`, `"palette"`, and `"base"`. Omitted sources keep their default rank. |
109
+ | `tooltip_color` | `str \| Mapping[str, str] \| None` | `None` | Color of the tooltip shown for labels that do not fit. `None` uses the built-in dark tooltip. See [Labels](#labels). |
106
110
  | `max_selections` | `int \| None` | `None` | Maximum number of selected items. `None` means no limit. Use `0` to prevent any selections. |
107
111
  | `max_selections_placeholder` | `str` | `"Selection limit reached"` | Placeholder shown when `max_selections` has been reached. This takes precedence over `placeholder` and `no_options_placeholder`. |
108
112
  | `empty_message` | `str` | `"No items selected"` | Message shown where the selected list appears when no items are selected. |
@@ -116,6 +120,7 @@ st.write(selected)
116
120
  | `suggestions_label_path` | `str` | `"label"` | Dot-separated path to each suggestion's display label. |
117
121
  | `suggestions_value_path` | `str` | `"value"` | Dot-separated path to each suggestion's returned value. |
118
122
  | `suggestions_icon_url_path` | `str \| None` | `"icon_url"` | Optional dot-separated path to each suggestion's icon URL. `None` disables API icons. |
123
+ | `suggestions_color_path` | `str \| None` | `None` | Optional dot-separated path to each suggestion's color. The value may be a CSS color string or a mapping of color fields. `None` disables API colors. |
119
124
  | `suggestions_headers` | `Mapping[str, str] \| None` | `None` | HTTP headers sent with suggestions requests. Header values are visible to browser users and must not contain secrets. |
120
125
  | `suggestions_min_chars` | `int` | `1` | Minimum trimmed query length before requesting suggestions. Use `0` to allow an empty query. |
121
126
  | `suggestions_debounce_ms` | `int` | `300` | Delay in milliseconds between the latest input and the API request. |
@@ -126,10 +131,106 @@ st.write(selected)
126
131
  Option dictionaries use this shape:
127
132
 
128
133
  ```python
129
- {"label": "Python", "value": "python", "icon_url": "https://www.python.org/static/favicon.ico"}
134
+ {
135
+ "label": "Python",
136
+ "value": "python",
137
+ "icon_url": "https://www.python.org/static/favicon.ico",
138
+ "color": "#3776ab",
139
+ }
140
+ ```
141
+
142
+ `icon_url` and `color` may be omitted. The returned value is always the `value`, not the display `label`.
143
+
144
+ ## Colors
145
+
146
+ Anywhere a color is accepted, pass either a CSS color string or a mapping that sets
147
+ any subset of `background`, `text`, and `border`:
148
+
149
+ ```python
150
+ "#fee2e2"
151
+ {"background": "#111827", "text": "#facc15", "border": "#f74c00"}
152
+ ```
153
+
154
+ A string sets the background. When `text` is not set, the component picks black or
155
+ white for readability against the resolved background.
156
+
157
+ Colors come from five sources. Each source contributes a color, and the highest
158
+ ranked source that sets a field wins that field, so a value color that sets only
159
+ `text` still inherits the background from `base_color`:
160
+
161
+ | Source | Set with | Keyed by |
162
+ | --- | --- | --- |
163
+ | `value` | `value_colors` | Option value. Follows the item as it is reordered. |
164
+ | `option` | `color` in an option dictionary, or `suggestions_color_path` | The option itself. |
165
+ | `order` | `order_colors` | Selected position. `1` is first, `-1` is last. |
166
+ | `palette` | `color_palette` | Selected position, cycling through the palette. |
167
+ | `base` | `base_color` | Every selected item. |
168
+
169
+ The default ranking is the table order. Reorder it with `color_priority`; sources you
170
+ leave out keep their default rank, so `color_priority=["order"]` only promotes
171
+ position colors above everything else.
172
+
173
+ When a positive and a negative `order_colors` key address the same slot, such as `1`
174
+ and `-3` in a three item list, the positive key wins. Every color must set at least
175
+ one field: `None` and `{}` are rejected rather than ignored, so a mistyped color
176
+ surfaces as an error instead of silently doing nothing.
177
+
178
+ ```python
179
+ selected = sortable_multiselect(
180
+ "Podium",
181
+ options=[
182
+ {"label": "Python", "value": "python", "color": "#3776ab"},
183
+ {"label": "Rust", "value": "rust"},
184
+ {"label": "Go", "value": "go"},
185
+ ],
186
+ default=["python", "rust", "go"],
187
+ # Gold, silver, and bronze by position, ranked above each option's own color.
188
+ order_colors={
189
+ 1: {"background": "#fde68a", "border": "#f59e0b"},
190
+ 2: {"background": "#e5e7eb", "border": "#9ca3af"},
191
+ -1: {"background": "#fed7aa", "border": "#ea580c"},
192
+ },
193
+ color_priority=["order"],
194
+ )
130
195
  ```
131
196
 
132
- `icon_url` may be omitted. The returned value is always the `value`, not the display `label`.
197
+ Options that resolve to a color show a small swatch in the options dropdown.
198
+
199
+ Because an option's `value` is its id, `value_colors` binds a color to an item rather
200
+ than to a slot. `examples/color_by_id.py` puts both side by side: two lists holding the
201
+ same items, one colored by position and one colored by id. Dragging an item in each
202
+ shows position colors staying with the slot while id colors travel with the item.
203
+
204
+ ## Labels
205
+
206
+ Option labels in the dropdown are shown on one line and cut off with an ellipsis when
207
+ they do not fit. Hovering a cut-off label shows the full text in a tooltip anchored to
208
+ that label, and labels that fit are left without one. Selected item labels wrap onto
209
+ multiple lines instead of being cut off, so they only get a tooltip if surrounding
210
+ styles clip them.
211
+
212
+ The tooltip is drawn by the component rather than by the browser's native `title`, so
213
+ it is styled with the rest of the component, appears after a short delay instead of
214
+ about a second, and flips above or below the label to stay inside the component frame.
215
+ It disappears as soon as the pointer leaves, the list scrolls, or the row it points at
216
+ changes. Screen readers are unaffected either way: a clipped label is cut off visually
217
+ but its full text is always in the DOM.
218
+
219
+ `tooltip_color` restyles it, using the same color form as everything above. A string
220
+ sets the background and the text follows for contrast; a mapping may also set `text`
221
+ and `border`. Because the component renders inside an iframe, this argument is the only
222
+ way to change the tooltip: styles from the surrounding app cannot reach it.
223
+
224
+ ```python
225
+ tooltip_color="#1e3a8a" # white text follows
226
+ tooltip_color={"background": "#ffffff", "text": "#111827", "border": "#d1d5db"}
227
+ ```
228
+
229
+ A light tooltip needs a `border` to separate it from the content behind it. The arrow
230
+ picks up both the background and the border, so it stays attached either way.
231
+
232
+ `examples/tooltip_labels.py` shows the same options in a narrow list and a wide one,
233
+ so the conditional behavior is visible side by side.
133
234
 
134
235
  ## API Suggestions
135
236
 
@@ -190,6 +291,8 @@ Run the example app:
190
291
 
191
292
  ```bash
192
293
  streamlit run examples/basic.py
294
+ streamlit run examples/color_by_id.py
295
+ streamlit run examples/tooltip_labels.py
193
296
  streamlit run examples/api_suggestions.py
194
297
  ```
195
298
 
@@ -73,8 +73,12 @@ st.write(selected)
73
73
  | `disabled` | `bool` | `False` | Disables searching, selecting, removing, dragging, and move buttons. |
74
74
  | `show_move_buttons` | `bool` | `True` | Shows up/down buttons on selected items. Drag sorting remains available unless `disabled=True`. |
75
75
  | `show_numbers` | `bool` | `False` | Shows 1-based position numbers before selected item labels. |
76
- | `base_color` | `str \| None` | `None` | Background color applied to all selected items. Accepts CSS color values such as `"#eef2ff"` or `"lightblue"`. |
77
- | `order_colors` | `Mapping[int, str] \| None` | `None` | Per-position selected item background colors. Keys are 1-based positions, for example `{1: "#fee2e2", 2: "#dcfce7"}`. These override `base_color` for matching positions. |
76
+ | `base_color` | `str \| Mapping[str, str] \| None` | `None` | Fallback color for every selected item. See [Colors](#colors). |
77
+ | `order_colors` | `Mapping[int, str \| Mapping[str, str]] \| None` | `None` | Per-position colors. Positive keys count from the top (`1` is first), negative keys count from the bottom (`-1` is last), for example `{1: "#fee2e2", -1: "#dcfce7"}`. |
78
+ | `value_colors` | `Mapping[str, str \| Mapping[str, str]] \| None` | `None` | Per-value colors keyed by option value, for example `{"python": "#3776ab"}`. These follow an item as it is reordered. |
79
+ | `color_palette` | `Sequence[str \| Mapping[str, str]] \| None` | `None` | Colors cycled across selected positions. Position 1 uses the first entry and the palette repeats for longer selections. |
80
+ | `color_priority` | `Sequence[str] \| None` | `None` | Ranking of the color sources `"value"`, `"option"`, `"order"`, `"palette"`, and `"base"`. Omitted sources keep their default rank. |
81
+ | `tooltip_color` | `str \| Mapping[str, str] \| None` | `None` | Color of the tooltip shown for labels that do not fit. `None` uses the built-in dark tooltip. See [Labels](#labels). |
78
82
  | `max_selections` | `int \| None` | `None` | Maximum number of selected items. `None` means no limit. Use `0` to prevent any selections. |
79
83
  | `max_selections_placeholder` | `str` | `"Selection limit reached"` | Placeholder shown when `max_selections` has been reached. This takes precedence over `placeholder` and `no_options_placeholder`. |
80
84
  | `empty_message` | `str` | `"No items selected"` | Message shown where the selected list appears when no items are selected. |
@@ -88,6 +92,7 @@ st.write(selected)
88
92
  | `suggestions_label_path` | `str` | `"label"` | Dot-separated path to each suggestion's display label. |
89
93
  | `suggestions_value_path` | `str` | `"value"` | Dot-separated path to each suggestion's returned value. |
90
94
  | `suggestions_icon_url_path` | `str \| None` | `"icon_url"` | Optional dot-separated path to each suggestion's icon URL. `None` disables API icons. |
95
+ | `suggestions_color_path` | `str \| None` | `None` | Optional dot-separated path to each suggestion's color. The value may be a CSS color string or a mapping of color fields. `None` disables API colors. |
91
96
  | `suggestions_headers` | `Mapping[str, str] \| None` | `None` | HTTP headers sent with suggestions requests. Header values are visible to browser users and must not contain secrets. |
92
97
  | `suggestions_min_chars` | `int` | `1` | Minimum trimmed query length before requesting suggestions. Use `0` to allow an empty query. |
93
98
  | `suggestions_debounce_ms` | `int` | `300` | Delay in milliseconds between the latest input and the API request. |
@@ -98,10 +103,106 @@ st.write(selected)
98
103
  Option dictionaries use this shape:
99
104
 
100
105
  ```python
101
- {"label": "Python", "value": "python", "icon_url": "https://www.python.org/static/favicon.ico"}
106
+ {
107
+ "label": "Python",
108
+ "value": "python",
109
+ "icon_url": "https://www.python.org/static/favicon.ico",
110
+ "color": "#3776ab",
111
+ }
112
+ ```
113
+
114
+ `icon_url` and `color` may be omitted. The returned value is always the `value`, not the display `label`.
115
+
116
+ ## Colors
117
+
118
+ Anywhere a color is accepted, pass either a CSS color string or a mapping that sets
119
+ any subset of `background`, `text`, and `border`:
120
+
121
+ ```python
122
+ "#fee2e2"
123
+ {"background": "#111827", "text": "#facc15", "border": "#f74c00"}
124
+ ```
125
+
126
+ A string sets the background. When `text` is not set, the component picks black or
127
+ white for readability against the resolved background.
128
+
129
+ Colors come from five sources. Each source contributes a color, and the highest
130
+ ranked source that sets a field wins that field, so a value color that sets only
131
+ `text` still inherits the background from `base_color`:
132
+
133
+ | Source | Set with | Keyed by |
134
+ | --- | --- | --- |
135
+ | `value` | `value_colors` | Option value. Follows the item as it is reordered. |
136
+ | `option` | `color` in an option dictionary, or `suggestions_color_path` | The option itself. |
137
+ | `order` | `order_colors` | Selected position. `1` is first, `-1` is last. |
138
+ | `palette` | `color_palette` | Selected position, cycling through the palette. |
139
+ | `base` | `base_color` | Every selected item. |
140
+
141
+ The default ranking is the table order. Reorder it with `color_priority`; sources you
142
+ leave out keep their default rank, so `color_priority=["order"]` only promotes
143
+ position colors above everything else.
144
+
145
+ When a positive and a negative `order_colors` key address the same slot, such as `1`
146
+ and `-3` in a three item list, the positive key wins. Every color must set at least
147
+ one field: `None` and `{}` are rejected rather than ignored, so a mistyped color
148
+ surfaces as an error instead of silently doing nothing.
149
+
150
+ ```python
151
+ selected = sortable_multiselect(
152
+ "Podium",
153
+ options=[
154
+ {"label": "Python", "value": "python", "color": "#3776ab"},
155
+ {"label": "Rust", "value": "rust"},
156
+ {"label": "Go", "value": "go"},
157
+ ],
158
+ default=["python", "rust", "go"],
159
+ # Gold, silver, and bronze by position, ranked above each option's own color.
160
+ order_colors={
161
+ 1: {"background": "#fde68a", "border": "#f59e0b"},
162
+ 2: {"background": "#e5e7eb", "border": "#9ca3af"},
163
+ -1: {"background": "#fed7aa", "border": "#ea580c"},
164
+ },
165
+ color_priority=["order"],
166
+ )
102
167
  ```
103
168
 
104
- `icon_url` may be omitted. The returned value is always the `value`, not the display `label`.
169
+ Options that resolve to a color show a small swatch in the options dropdown.
170
+
171
+ Because an option's `value` is its id, `value_colors` binds a color to an item rather
172
+ than to a slot. `examples/color_by_id.py` puts both side by side: two lists holding the
173
+ same items, one colored by position and one colored by id. Dragging an item in each
174
+ shows position colors staying with the slot while id colors travel with the item.
175
+
176
+ ## Labels
177
+
178
+ Option labels in the dropdown are shown on one line and cut off with an ellipsis when
179
+ they do not fit. Hovering a cut-off label shows the full text in a tooltip anchored to
180
+ that label, and labels that fit are left without one. Selected item labels wrap onto
181
+ multiple lines instead of being cut off, so they only get a tooltip if surrounding
182
+ styles clip them.
183
+
184
+ The tooltip is drawn by the component rather than by the browser's native `title`, so
185
+ it is styled with the rest of the component, appears after a short delay instead of
186
+ about a second, and flips above or below the label to stay inside the component frame.
187
+ It disappears as soon as the pointer leaves, the list scrolls, or the row it points at
188
+ changes. Screen readers are unaffected either way: a clipped label is cut off visually
189
+ but its full text is always in the DOM.
190
+
191
+ `tooltip_color` restyles it, using the same color form as everything above. A string
192
+ sets the background and the text follows for contrast; a mapping may also set `text`
193
+ and `border`. Because the component renders inside an iframe, this argument is the only
194
+ way to change the tooltip: styles from the surrounding app cannot reach it.
195
+
196
+ ```python
197
+ tooltip_color="#1e3a8a" # white text follows
198
+ tooltip_color={"background": "#ffffff", "text": "#111827", "border": "#d1d5db"}
199
+ ```
200
+
201
+ A light tooltip needs a `border` to separate it from the content behind it. The arrow
202
+ picks up both the background and the border, so it stays attached either way.
203
+
204
+ `examples/tooltip_labels.py` shows the same options in a narrow list and a wide one,
205
+ so the conditional behavior is visible side by side.
105
206
 
106
207
  ## API Suggestions
107
208
 
@@ -162,6 +263,8 @@ Run the example app:
162
263
 
163
264
  ```bash
164
265
  streamlit run examples/basic.py
266
+ streamlit run examples/color_by_id.py
267
+ streamlit run examples/tooltip_labels.py
165
268
  streamlit run examples/api_suggestions.py
166
269
  ```
167
270
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamlit-sortable-multiselect"
7
- version = "0.7.5"
7
+ version = "0.7.6"
8
8
  description = "A Streamlit custom component for selecting and reordering multiple string values."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -4,18 +4,29 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from pathlib import Path
7
- from typing import Any, Iterable, Mapping, Sequence, cast
7
+ from typing import Any, Iterable, Mapping, Sequence, Union, cast
8
8
  from urllib.parse import urlparse
9
9
 
10
10
  import streamlit.components.v1 as components
11
11
 
12
- __version__ = "0.7.5"
13
- __all__ = ["sortable_multiselect"]
12
+ __version__ = "0.7.6"
13
+ __all__ = ["COLOR_FIELDS", "COLOR_SOURCES", "ColorValue", "sortable_multiselect"]
14
14
 
15
15
  _COMPONENT_NAME = "streamlit_sortable_multiselect"
16
16
  _DEV_SERVER_URL = "http://localhost:5173"
17
17
  _RELEASE = os.environ.get("STREAMLIT_SORTABLE_MULTISELECT_DEV") != "1"
18
18
 
19
+ #: A color is either a CSS color string (used as the background) or a mapping
20
+ #: that sets any subset of the fields in :data:`COLOR_FIELDS`.
21
+ ColorValue = Union[str, Mapping[str, str]]
22
+
23
+ #: Styleable parts of a selected item.
24
+ COLOR_FIELDS = ("background", "text", "border")
25
+
26
+ #: Color sources, in default precedence order. Each source contributes a color
27
+ #: spec, and the highest-priority source that sets a field wins that field.
28
+ COLOR_SOURCES = ("value", "option", "order", "palette", "base")
29
+
19
30
 
20
31
  def _declare_component():
21
32
  if _RELEASE:
@@ -40,11 +51,52 @@ def _validate_string_sequence(name: str, values: Sequence[str] | None) -> list[s
40
51
  return result
41
52
 
42
53
 
43
- def _normalize_options(options: Sequence[str | Mapping[str, Any]]) -> list[dict[str, str | None]]:
54
+ def _normalize_color(name: str, color: ColorValue | None) -> dict[str, str] | None:
55
+ """Return a color as a field mapping, or None when nothing is set."""
56
+ if color is None:
57
+ return None
58
+ if isinstance(color, str):
59
+ if not color:
60
+ raise ValueError(f"{name} must not be an empty color string.")
61
+ return {"background": color}
62
+ if not isinstance(color, Mapping):
63
+ raise TypeError(
64
+ f"{name} must be a CSS color string or a mapping with any of: "
65
+ f"{', '.join(COLOR_FIELDS)}."
66
+ )
67
+
68
+ spec: dict[str, str] = {}
69
+ for field, field_value in color.items():
70
+ if field not in COLOR_FIELDS:
71
+ raise ValueError(
72
+ f"{name} contains an unknown color field {field!r}. "
73
+ f"Use any of: {', '.join(COLOR_FIELDS)}."
74
+ )
75
+ if not isinstance(field_value, str):
76
+ raise TypeError(f"{name} color fields must be strings.")
77
+ if not field_value:
78
+ raise ValueError(f"{name} color fields must not be empty.")
79
+ spec[field] = field_value
80
+ return spec or None
81
+
82
+
83
+ def _require_color(name: str, color: ColorValue | None) -> dict[str, str]:
84
+ """Return a color that must actually set something."""
85
+ if color is None:
86
+ raise TypeError(f"{name} must be a color, not None.")
87
+ spec = _normalize_color(name, color)
88
+ if spec is None:
89
+ raise ValueError(f"{name} must set at least one of: {', '.join(COLOR_FIELDS)}.")
90
+ return spec
91
+
92
+
93
+ def _normalize_options(
94
+ options: Sequence[str | Mapping[str, Any]],
95
+ ) -> list[dict[str, Any]]:
44
96
  if isinstance(options, str) or not isinstance(options, Iterable):
45
97
  raise TypeError("options must be a sequence of strings or option dictionaries.")
46
98
 
47
- normalized_options: list[dict[str, str | None]] = []
99
+ normalized_options: list[dict[str, Any]] = []
48
100
  for option in options:
49
101
  if isinstance(option, str):
50
102
  normalized_options.append({"label": option, "value": option, "icon_url": None})
@@ -63,26 +115,94 @@ def _normalize_options(options: Sequence[str | Mapping[str, Any]]) -> list[dict[
63
115
  if icon_url is not None and not isinstance(icon_url, str):
64
116
  raise TypeError("option icon_url must be a string or None.")
65
117
 
66
- normalized_options.append({"label": label, "value": value, "icon_url": icon_url})
118
+ normalized_option: dict[str, Any] = {
119
+ "label": label,
120
+ "value": value,
121
+ "icon_url": icon_url,
122
+ }
123
+ # Omitted rather than sent as null: with tens of thousands of options this
124
+ # key alone is a fifth of the payload the browser has to receive.
125
+ if "color" in option:
126
+ normalized_option["color"] = _require_color(
127
+ f"option color for {value!r}", option["color"]
128
+ )
129
+ normalized_options.append(normalized_option)
67
130
 
68
131
  return normalized_options
69
132
 
70
133
 
71
- def _validate_order_colors(order_colors: Mapping[int, str] | None) -> dict[int, str]:
134
+ def _validate_order_colors(
135
+ order_colors: Mapping[int, ColorValue] | None,
136
+ ) -> dict[int, dict[str, str]]:
72
137
  if order_colors is None:
73
138
  return {}
74
139
  if not isinstance(order_colors, Mapping):
75
- raise TypeError("order_colors must be a mapping of 1-based positions to color strings.")
140
+ raise TypeError("order_colors must be a mapping of positions to colors.")
76
141
 
77
- result: dict[int, str] = {}
142
+ result: dict[int, dict[str, str]] = {}
78
143
  for position, color in order_colors.items():
79
- if not isinstance(position, int):
144
+ if isinstance(position, bool) or not isinstance(position, int):
80
145
  raise TypeError("order_colors keys must be integers.")
81
- if position < 1:
82
- raise ValueError("order_colors keys must be 1 or greater.")
83
- if not isinstance(color, str):
84
- raise TypeError("order_colors values must be strings.")
85
- result[position] = color
146
+ if position == 0:
147
+ raise ValueError(
148
+ "order_colors keys must be non-zero integers. "
149
+ "Positive keys count from the top, negative keys count from the bottom."
150
+ )
151
+ result[position] = _require_color(f"order_colors[{position}]", color)
152
+ return result
153
+
154
+
155
+ def _validate_value_colors(
156
+ value_colors: Mapping[str, ColorValue] | None,
157
+ ) -> dict[str, dict[str, str]]:
158
+ if value_colors is None:
159
+ return {}
160
+ if not isinstance(value_colors, Mapping):
161
+ raise TypeError("value_colors must be a mapping of option values to colors.")
162
+
163
+ result: dict[str, dict[str, str]] = {}
164
+ for value, color in value_colors.items():
165
+ if not isinstance(value, str):
166
+ raise TypeError("value_colors keys must be strings.")
167
+ result[value] = _require_color(f"value_colors[{value!r}]", color)
168
+ return result
169
+
170
+
171
+ def _validate_color_palette(
172
+ color_palette: Sequence[ColorValue] | None,
173
+ ) -> list[dict[str, str]]:
174
+ if color_palette is None:
175
+ return []
176
+ if isinstance(color_palette, (str, Mapping)) or not isinstance(color_palette, Iterable):
177
+ raise TypeError("color_palette must be a sequence of colors.")
178
+
179
+ return [
180
+ _require_color(f"color_palette[{index}]", color)
181
+ for index, color in enumerate(color_palette)
182
+ ]
183
+
184
+
185
+ def _validate_color_priority(color_priority: Sequence[str] | None) -> list[str]:
186
+ if color_priority is None:
187
+ return list(COLOR_SOURCES)
188
+ if isinstance(color_priority, str) or not isinstance(color_priority, Iterable):
189
+ raise TypeError("color_priority must be a sequence of color source names.")
190
+
191
+ result: list[str] = []
192
+ for source in color_priority:
193
+ if not isinstance(source, str):
194
+ raise TypeError("color_priority must contain only strings.")
195
+ if source not in COLOR_SOURCES:
196
+ raise ValueError(
197
+ f"color_priority contains an unknown color source {source!r}. "
198
+ f"Use any of: {', '.join(COLOR_SOURCES)}."
199
+ )
200
+ if source in result:
201
+ raise ValueError(f"color_priority must not repeat {source!r}.")
202
+ result.append(source)
203
+
204
+ # Sources the caller left out still apply, ranked below the listed ones.
205
+ result.extend(source for source in COLOR_SOURCES if source not in result)
86
206
  return result
87
207
 
88
208
 
@@ -173,8 +293,8 @@ def sortable_multiselect(
173
293
  disabled: bool = False,
174
294
  show_move_buttons: bool = True,
175
295
  show_numbers: bool = False,
176
- base_color: str | None = None,
177
- order_colors: Mapping[int, str] | None = None,
296
+ base_color: ColorValue | None = None,
297
+ order_colors: Mapping[int, ColorValue] | None = None,
178
298
  max_selections: int | None = None,
179
299
  max_selections_placeholder: str = "Selection limit reached",
180
300
  empty_message: str = "No items selected",
@@ -193,6 +313,11 @@ def sortable_multiselect(
193
313
  suggestions_debounce_ms: int = 300,
194
314
  suggestions_loading_message: str = "Loading suggestions...",
195
315
  suggestions_error_message: str = "Failed to load suggestions",
316
+ value_colors: Mapping[str, ColorValue] | None = None,
317
+ color_palette: Sequence[ColorValue] | None = None,
318
+ color_priority: Sequence[str] | None = None,
319
+ tooltip_color: ColorValue | None = None,
320
+ suggestions_color_path: str | None = None,
196
321
  key: str | None = None,
197
322
  ) -> list[str]:
198
323
  """Select multiple string values and return them in user-defined order.
@@ -202,7 +327,8 @@ def sortable_multiselect(
202
327
  label:
203
328
  Text label rendered above the component.
204
329
  options:
205
- Available string values, or dictionaries with label, value, and optional icon_url.
330
+ Available string values, or dictionaries with label, value, and optional
331
+ icon_url and color.
206
332
  default:
207
333
  Initially selected values, in the desired initial order.
208
334
  placeholder:
@@ -214,9 +340,11 @@ def sortable_multiselect(
214
340
  show_numbers:
215
341
  Show 1-based numbers before selected items.
216
342
  base_color:
217
- Background color applied to selected items.
343
+ Fallback color for every selected item. A CSS color string sets the background;
344
+ a mapping may set any of "background", "text", and "border".
218
345
  order_colors:
219
- Per-position background colors keyed by 1-based selected item position.
346
+ Per-position colors keyed by selected item position. Positive keys count from
347
+ the top (1 is first), negative keys count from the bottom (-1 is last).
220
348
  max_selections:
221
349
  Maximum number of selected items. None means no limit.
222
350
  max_selections_placeholder:
@@ -253,6 +381,20 @@ def sortable_multiselect(
253
381
  Text shown while suggestions are loading.
254
382
  suggestions_error_message:
255
383
  Text shown when suggestions cannot be loaded.
384
+ value_colors:
385
+ Per-value colors keyed by option value. These follow an item as it is reordered.
386
+ color_palette:
387
+ Colors cycled across selected positions. Position 1 uses the first entry, and
388
+ the palette repeats once there are more items than entries.
389
+ color_priority:
390
+ Ranking of the color sources "value", "option", "order", "palette", and "base".
391
+ Earlier sources win, field by field. Omitted sources keep their default rank.
392
+ tooltip_color:
393
+ Color of the tooltip shown for labels that do not fit. A string sets the
394
+ background and the text follows for contrast; a mapping may also set "text"
395
+ and "border". None uses the built-in dark tooltip.
396
+ suggestions_color_path:
397
+ Optional dot-separated path to each suggestion's color. None disables API colors.
256
398
  key:
257
399
  Optional Streamlit component key.
258
400
  """
@@ -276,13 +418,18 @@ def sortable_multiselect(
276
418
  raise TypeError("show_move_buttons must be a bool.")
277
419
  if not isinstance(show_numbers, bool):
278
420
  raise TypeError("show_numbers must be a bool.")
279
- if base_color is not None and not isinstance(base_color, str):
280
- raise TypeError("base_color must be a string or None.")
281
421
 
422
+ base_color_value = None if base_color is None else _require_color("base_color", base_color)
423
+ tooltip_color_value = (
424
+ None if tooltip_color is None else _require_color("tooltip_color", tooltip_color)
425
+ )
282
426
  option_items = _normalize_options(options)
283
427
  option_values = [option["value"] for option in option_items]
284
428
  default_values = _validate_string_sequence("default", default)
285
429
  order_color_values = _validate_order_colors(order_colors)
430
+ value_color_values = _validate_value_colors(value_colors)
431
+ color_palette_values = _validate_color_palette(color_palette)
432
+ color_priority_values = _validate_color_priority(color_priority)
286
433
  max_selection_count = _validate_max_selections(max_selections)
287
434
  icon_size_value = _validate_icon_size(icon_size)
288
435
  options_max_height_value = _validate_options_max_height(options_max_height)
@@ -301,6 +448,9 @@ def sortable_multiselect(
301
448
  suggestions_icon_url_path_value = _validate_optional_non_empty_string(
302
449
  "suggestions_icon_url_path", suggestions_icon_url_path
303
450
  )
451
+ suggestions_color_path_value = _validate_optional_non_empty_string(
452
+ "suggestions_color_path", suggestions_color_path
453
+ )
304
454
  suggestions_headers_value = _normalize_suggestions_headers(suggestions_headers)
305
455
  suggestions_min_chars_value = _validate_non_negative_int(
306
456
  "suggestions_min_chars", suggestions_min_chars
@@ -351,8 +501,12 @@ def sortable_multiselect(
351
501
  disabled=disabled,
352
502
  show_move_buttons=show_move_buttons,
353
503
  show_numbers=show_numbers,
354
- base_color=base_color,
504
+ base_color=base_color_value,
355
505
  order_colors=order_color_values,
506
+ value_colors=value_color_values,
507
+ color_palette=color_palette_values,
508
+ color_priority=color_priority_values,
509
+ tooltip_color=tooltip_color_value,
356
510
  max_selections=max_selection_count,
357
511
  max_selections_placeholder=max_selections_placeholder,
358
512
  empty_message=empty_message,
@@ -366,6 +520,7 @@ def sortable_multiselect(
366
520
  suggestions_label_path=suggestions_label_path_value,
367
521
  suggestions_value_path=suggestions_value_path_value,
368
522
  suggestions_icon_url_path=suggestions_icon_url_path_value,
523
+ suggestions_color_path=suggestions_color_path_value,
369
524
  suggestions_headers=suggestions_headers_value,
370
525
  suggestions_min_chars=suggestions_min_chars_value,
371
526
  suggestions_debounce_ms=suggestions_debounce_ms_value,