streamlit-listview 1.0.0__py3-none-any.whl
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.
- streamlit_listview/__init__.py +542 -0
- streamlit_listview/_options.py +354 -0
- streamlit_listview/frontend/build/index-BHPEB0f5.js +63 -0
- streamlit_listview/frontend/build/index-Br2crP58.css +2 -0
- streamlit_listview/py.typed +0 -0
- streamlit_listview/pyproject.toml +12 -0
- streamlit_listview-1.0.0.dist-info/METADATA +353 -0
- streamlit_listview-1.0.0.dist-info/RECORD +12 -0
- streamlit_listview-1.0.0.dist-info/WHEEL +5 -0
- streamlit_listview-1.0.0.dist-info/licenses/LICENSE +201 -0
- streamlit_listview-1.0.0.dist-info/licenses/NOTICE +64 -0
- streamlit_listview-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Iterable, Literal
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
|
|
7
|
+
from ._options import (
|
|
8
|
+
_id_key,
|
|
9
|
+
_validate_id,
|
|
10
|
+
map_selection,
|
|
11
|
+
normalize_options,
|
|
12
|
+
sort_items,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_component = st.components.v2.component(
|
|
16
|
+
"streamlit_listview.listview",
|
|
17
|
+
js="index-*.js",
|
|
18
|
+
css="index-*.css",
|
|
19
|
+
html='<div class="listview-root"></div>',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_SELECTION_MODES = ("single", "multi")
|
|
23
|
+
_LABEL_VISIBILITIES = ("visible", "hidden", "collapsed")
|
|
24
|
+
_SORT_MODES = ("items", "groups", "both")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_real_int(value: Any) -> bool:
|
|
28
|
+
"""True for genuine ints, rejecting bool (a subclass of int)."""
|
|
29
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_int_at_least(value: Any, minimum: int) -> bool:
|
|
33
|
+
"""True for a genuine int at or above ``minimum``.
|
|
34
|
+
|
|
35
|
+
THE int-parameter contract, shared by ``_validate_int`` and by ``width`` (whose
|
|
36
|
+
message differs only because it also accepts ``"stretch"``). Widening what
|
|
37
|
+
counts as an int — a numpy integer from ``df.shape[0]``, say — has to widen it
|
|
38
|
+
for every numeric parameter at once, which two copies of the predicate could
|
|
39
|
+
not guarantee.
|
|
40
|
+
"""
|
|
41
|
+
return _is_real_int(value) and value >= minimum
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_bool(name: str, value: Any) -> None:
|
|
45
|
+
"""Reject a non-bool value for one flag parameter.
|
|
46
|
+
|
|
47
|
+
The flags differ only in their name, so one check serves them all — see
|
|
48
|
+
``_validate_str`` on why the message text is spelled out rather than derived.
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(value, bool):
|
|
51
|
+
raise ValueError(f"{name} must be a bool, got {type(value).__name__}.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_str(name: str, value: Any, *, allow_none: bool) -> None:
|
|
55
|
+
"""Reject a non-str value for one text parameter.
|
|
56
|
+
|
|
57
|
+
The text params differ only in their name and in whether None is a legal
|
|
58
|
+
"not shown", so one check serves them all. The message wording is part of the
|
|
59
|
+
public contract (the suite matches on it), hence the fixed " or None" suffix
|
|
60
|
+
rather than anything derived from the type.
|
|
61
|
+
"""
|
|
62
|
+
if allow_none and value is None:
|
|
63
|
+
return
|
|
64
|
+
if not isinstance(value, str):
|
|
65
|
+
expected = "a str or None" if allow_none else "a str"
|
|
66
|
+
raise ValueError(f"{name} must be {expected}, got {type(value).__name__}.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_int(name: str, value: Any, *, minimum: int, allow_none: bool) -> None:
|
|
70
|
+
"""Reject a non-int or out-of-range value for one numeric parameter.
|
|
71
|
+
|
|
72
|
+
The numeric params differ only in their name, their floor and whether None
|
|
73
|
+
means "unset", so one check serves them all — see `_validate_str` on why the
|
|
74
|
+
message text is spelled out rather than derived.
|
|
75
|
+
"""
|
|
76
|
+
if allow_none and value is None:
|
|
77
|
+
return
|
|
78
|
+
if not _is_int_at_least(value, minimum):
|
|
79
|
+
expected = (
|
|
80
|
+
f"None or an int >= {minimum}" if allow_none else f"an int >= {minimum}"
|
|
81
|
+
)
|
|
82
|
+
raise ValueError(f"{name} must be {expected}, got {value!r}.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_params(
|
|
86
|
+
*,
|
|
87
|
+
label: Any,
|
|
88
|
+
help: Any,
|
|
89
|
+
placeholder: Any,
|
|
90
|
+
search_placeholder: Any,
|
|
91
|
+
selection_mode: str,
|
|
92
|
+
label_visibility: str,
|
|
93
|
+
width: Any,
|
|
94
|
+
height: Any,
|
|
95
|
+
item_height: Any,
|
|
96
|
+
content_font_size: Any,
|
|
97
|
+
max_selections: Any,
|
|
98
|
+
enable_search: Any,
|
|
99
|
+
pin_search: Any,
|
|
100
|
+
collapsible_groups: Any,
|
|
101
|
+
accept_new_options: Any,
|
|
102
|
+
show_grid_lines: Any,
|
|
103
|
+
disabled: Any,
|
|
104
|
+
format_func: Any,
|
|
105
|
+
on_change: Any,
|
|
106
|
+
select_all: Any,
|
|
107
|
+
sort: Any,
|
|
108
|
+
sort_ascending: Any,
|
|
109
|
+
collapsed_groups: Any,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Validate scalar parameters per spec §7. Raises ValueError."""
|
|
112
|
+
# The four text params are handed to the frontend verbatim — `label` and
|
|
113
|
+
# `help` as Markdown source, the two placeholders as <input>/empty-state text.
|
|
114
|
+
# A non-str `label` reaches escapeLabelSource, which calls .replace() on it and
|
|
115
|
+
# throws during React render; like the collapsed_groups shape below, that
|
|
116
|
+
# crashes the whole widget with nothing to show for it on the Python side (no
|
|
117
|
+
# traceback, just an empty component). So the type is checked here.
|
|
118
|
+
#
|
|
119
|
+
# None-ness deliberately mirrors the signature defaults *and* the frontend's
|
|
120
|
+
# ListviewData contract: `label: string` and `search_placeholder: string` are
|
|
121
|
+
# non-nullable there, while `help: string | null` and
|
|
122
|
+
# `placeholder: string | null` use null to mean "not shown". Only wrong types
|
|
123
|
+
# are rejected; which params accept None is unchanged.
|
|
124
|
+
_validate_str("label", label, allow_none=False)
|
|
125
|
+
_validate_str("help", help, allow_none=True)
|
|
126
|
+
_validate_str("placeholder", placeholder, allow_none=True)
|
|
127
|
+
_validate_str("search_placeholder", search_placeholder, allow_none=False)
|
|
128
|
+
|
|
129
|
+
if selection_mode not in _SELECTION_MODES:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"selection_mode must be one of {_SELECTION_MODES!r}, got {selection_mode!r}."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if label_visibility not in _LABEL_VISIBILITIES:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"label_visibility must be one of "
|
|
137
|
+
f"{_LABEL_VISIBILITIES!r}, got {label_visibility!r}."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if width != "stretch" and not _is_int_at_least(width, 1):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f'width must be "stretch" or an int >= 1, got {width!r}.'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
_validate_int("height", height, minimum=100, allow_none=False)
|
|
146
|
+
_validate_int("item_height", item_height, minimum=1, allow_none=True)
|
|
147
|
+
_validate_int("content_font_size", content_font_size, minimum=1, allow_none=True)
|
|
148
|
+
_validate_int("max_selections", max_selections, minimum=1, allow_none=True)
|
|
149
|
+
|
|
150
|
+
if (
|
|
151
|
+
max_selections is not None
|
|
152
|
+
and selection_mode == "single"
|
|
153
|
+
and max_selections != 1
|
|
154
|
+
):
|
|
155
|
+
raise ValueError(
|
|
156
|
+
'max_selections must be None or 1 when selection_mode="single", '
|
|
157
|
+
f"got {max_selections!r}."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# The eight flag params are forwarded to the frontend verbatim and judged by
|
|
161
|
+
# JS truthiness there, so a non-bool never fails loudly on its own:
|
|
162
|
+
# enable_search="no" ENABLES the search box, accept_new_options="false"
|
|
163
|
+
# ENABLES adding, and a truthy non-JSON-serializable value blanks the whole
|
|
164
|
+
# widget (Streamlit's serializer swallows the TypeError — see
|
|
165
|
+
# collapsed_groups below). The type checks run before the flag cross-checks
|
|
166
|
+
# so a wrong type is named as such, never reported as a spurious
|
|
167
|
+
# combination error.
|
|
168
|
+
_validate_bool("enable_search", enable_search)
|
|
169
|
+
_validate_bool("pin_search", pin_search)
|
|
170
|
+
_validate_bool("collapsible_groups", collapsible_groups)
|
|
171
|
+
_validate_bool("accept_new_options", accept_new_options)
|
|
172
|
+
_validate_bool("show_grid_lines", show_grid_lines)
|
|
173
|
+
_validate_bool("disabled", disabled)
|
|
174
|
+
_validate_bool("select_all", select_all)
|
|
175
|
+
_validate_bool("sort_ascending", sort_ascending)
|
|
176
|
+
|
|
177
|
+
if pin_search and not enable_search:
|
|
178
|
+
raise ValueError("pin_search=True requires enable_search=True.")
|
|
179
|
+
|
|
180
|
+
if selection_mode == "single" and select_all:
|
|
181
|
+
raise ValueError('select_all=True not allowed for selection_mode="single"')
|
|
182
|
+
|
|
183
|
+
if sort is not None and sort not in _SORT_MODES:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"sort must be one of {_SORT_MODES!r} or None, got {sort!r}."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# collapsed_groups: None | "all" | list[str]. Validate the shape here so a
|
|
189
|
+
# common mistake (passing a bare group name instead of a one-element list,
|
|
190
|
+
# e.g. collapsed_groups="Germany") is rejected at the boundary rather than
|
|
191
|
+
# forwarded to the frontend, where it would reach `collapsedGroups.filter`
|
|
192
|
+
# and throw, crashing the whole widget at render time.
|
|
193
|
+
if collapsed_groups is not None and collapsed_groups != "all":
|
|
194
|
+
if not (
|
|
195
|
+
isinstance(collapsed_groups, list)
|
|
196
|
+
and all(isinstance(name, str) for name in collapsed_groups)
|
|
197
|
+
):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
'collapsed_groups must be None, "all", or a list of group-name '
|
|
200
|
+
f"strings, got {collapsed_groups!r}."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if format_func is not None and not callable(format_func):
|
|
204
|
+
raise ValueError("format_func must be callable or None.")
|
|
205
|
+
|
|
206
|
+
if on_change is not None and not callable(on_change):
|
|
207
|
+
raise ValueError("on_change must be callable or None.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_default_ids(
|
|
211
|
+
default: Any,
|
|
212
|
+
selection_mode: str,
|
|
213
|
+
id_to_original: dict[Any, Any],
|
|
214
|
+
accept_new_options: bool,
|
|
215
|
+
) -> list[Any]:
|
|
216
|
+
"""Resolve the `default` argument to a list of option ids.
|
|
217
|
+
|
|
218
|
+
single: a scalar (non-list) default d -> [d] (or [] when None).
|
|
219
|
+
multi: a list default -> that list (or [] when None).
|
|
220
|
+
Shape mismatches raise ValueError. Every id must pass the same boundary gate
|
|
221
|
+
as an option id (``_validate_id``: exactly str or int, bool rejected, ints
|
|
222
|
+
within the JS safe range) — a default is seeded straight into the frontend
|
|
223
|
+
selection, so it crosses the same JSON/``String(id)`` boundary. When
|
|
224
|
+
accept_new_options is False, every resolved id must additionally name a row
|
|
225
|
+
of the options, membership judged by ``_id_key`` rendered row identity.
|
|
226
|
+
"""
|
|
227
|
+
if selection_mode == "single":
|
|
228
|
+
if isinstance(default, (list, tuple, set)):
|
|
229
|
+
raise ValueError(
|
|
230
|
+
'default must not be a list when selection_mode="single"; '
|
|
231
|
+
f"got {default!r}."
|
|
232
|
+
)
|
|
233
|
+
default_ids: list[Any] = [] if default is None else [default]
|
|
234
|
+
else: # multi
|
|
235
|
+
if default is None:
|
|
236
|
+
default_ids = []
|
|
237
|
+
elif isinstance(default, list):
|
|
238
|
+
default_ids = list(default)
|
|
239
|
+
else:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
'default must be a list when selection_mode="multi"; '
|
|
242
|
+
f"got {default!r}."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# The unhashable check runs BEFORE the shared gate so the natural way to hit
|
|
246
|
+
# it — round-tripping what listview() returned (default=OPTS[0], where
|
|
247
|
+
# OPTS[0] is a dict option) — gets this targeted hint instead of the gate's
|
|
248
|
+
# generic "must be a str or int". Then every id goes through the same
|
|
249
|
+
# boundary gate as an option id, regardless of accept_new_options: an
|
|
250
|
+
# unknown-but-unrepresentable id is not a "new option", it is an id the
|
|
251
|
+
# JSON/String(id) boundary cannot carry (a bool desyncs the UI from the
|
|
252
|
+
# value, a Decimal blanks the widget, 2**53 + 1 rounds — see _validate_id).
|
|
253
|
+
for did in default_ids:
|
|
254
|
+
try:
|
|
255
|
+
hash(did)
|
|
256
|
+
except TypeError as exc:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"default id is unhashable: {did!r}. Pass the option's id, not "
|
|
259
|
+
"the option itself — a dict option comes back whole in the "
|
|
260
|
+
"selection, but only its 'id' can seed the default."
|
|
261
|
+
) from exc
|
|
262
|
+
_validate_id(did, kind="default")
|
|
263
|
+
|
|
264
|
+
# De-duplicate while preserving order, first occurrence winning: a repeated
|
|
265
|
+
# default id is a single selection, so it must not inflate the max_selections
|
|
266
|
+
# count (checked by the caller) nor seed a duplicate id into the frontend
|
|
267
|
+
# selection.
|
|
268
|
+
#
|
|
269
|
+
# Keyed by _id_key — the rendered row identity — and NOT by Python equality,
|
|
270
|
+
# which is a weaker notion of "the same row" than the frontend's: with
|
|
271
|
+
# accept_new_options a default may name one row two ways (`["1", 1]`), and the
|
|
272
|
+
# frontend renders and counts that as the one row it is. Keying by equality let
|
|
273
|
+
# the pair through as two entries, so `max_selections=1` rejected a default
|
|
274
|
+
# that selects a single row, and reconciliation had to collapse a duplicate the
|
|
275
|
+
# producer should never have emitted. First-wins matches the survivor the
|
|
276
|
+
# frontend keeps. The same collapse holds with accept_new_options off: the
|
|
277
|
+
# membership check below judges by this same key, so a default spelling one
|
|
278
|
+
# option's row two ways (`["1", 1]` against the int option 1) is a single
|
|
279
|
+
# valid selection there too, not two entries.
|
|
280
|
+
by_key: dict[str, Any] = {}
|
|
281
|
+
for did in default_ids:
|
|
282
|
+
by_key.setdefault(_id_key(did), did)
|
|
283
|
+
default_ids = list(by_key.values())
|
|
284
|
+
|
|
285
|
+
if default_ids:
|
|
286
|
+
# Membership is a row-identity question, so it is asked by _id_key like
|
|
287
|
+
# every other one: a default spelling a row differently than its option
|
|
288
|
+
# ("1" for the int option 1) names a row the frontend renders, so it is
|
|
289
|
+
# accepted — while raw Python ==, besides refusing that spelling, also
|
|
290
|
+
# admitted ids that merely compare equal without rendering as any row
|
|
291
|
+
# (Decimal('7') == 7 seeded an id the payload cannot even carry).
|
|
292
|
+
# Gated on default_ids: option_by_key is a full pass over the options,
|
|
293
|
+
# which the common default=None call must not pay on every rerun.
|
|
294
|
+
option_by_key = {_id_key(item_id): item_id for item_id in id_to_original}
|
|
295
|
+
if not accept_new_options:
|
|
296
|
+
for did in default_ids:
|
|
297
|
+
if _id_key(did) not in option_by_key:
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"default id {did!r} is not present in options "
|
|
300
|
+
"(set accept_new_options=True to allow unknown defaults)."
|
|
301
|
+
)
|
|
302
|
+
# Ship each matched default in the OPTION's own spelling, not the
|
|
303
|
+
# caller's. The seed is both the mount display and the V2 `default=`
|
|
304
|
+
# state, and the frontend canonicalizes the selection to the option's
|
|
305
|
+
# spelling on its first render — so a default that named the row
|
|
306
|
+
# differently ("1" for the int option 1) was corrected by a write-back
|
|
307
|
+
# that is indistinguishable from a click: an extra rerun and a spurious
|
|
308
|
+
# on_change at mount, with no user action (and on EVERY remount of an
|
|
309
|
+
# unkeyed instance). Canonicalizing here keeps the seed byte-equal to
|
|
310
|
+
# what the frontend would commit, so mount stays quiet. An id with no
|
|
311
|
+
# matching row (accept_new_options) is kept as given.
|
|
312
|
+
default_ids = [option_by_key.get(_id_key(did), did) for did in default_ids]
|
|
313
|
+
|
|
314
|
+
return default_ids
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _persisted_selection(key: str | None) -> list | None:
|
|
318
|
+
"""The selection persisted in ``st.session_state[key]``, or None.
|
|
319
|
+
|
|
320
|
+
A keyed V2 component's element id drops the container, so moving the widget
|
|
321
|
+
between ``st.sidebar`` and the main body keeps the Python-side widget state
|
|
322
|
+
while the frontend remounts from scratch. The remounted frontend seeds from
|
|
323
|
+
this value (shipped as ``data["state_selection"]``) when it is present,
|
|
324
|
+
falling back to ``default_ids`` — without the channel, such a move re-seeded
|
|
325
|
+
the rows from ``default`` while ``listview()`` kept returning the persisted
|
|
326
|
+
selection, and the next click silently overwrote it.
|
|
327
|
+
|
|
328
|
+
Best-effort by design: anything short of a well-shaped state degrades to
|
|
329
|
+
None ("nothing persisted; seed from default_ids"). That covers no ``key``,
|
|
330
|
+
a first run where the widget state does not exist yet (KeyError), and an
|
|
331
|
+
environment with no Streamlit runtime at all — the unit suite imports real
|
|
332
|
+
streamlit without a script run context, where the read hits the bare-mode
|
|
333
|
+
mock session state and raises KeyError too. The shape check (a dict with a
|
|
334
|
+
list under "selection" whose every element passes ``_validate_id``) guards
|
|
335
|
+
against a caller having overwritten the key with arbitrary user state — the
|
|
336
|
+
key is user-writable, and an app that pre-seeds it (or assigns the selection
|
|
337
|
+
from data) can put a Decimal, a date or a numpy int in the list. Those ids
|
|
338
|
+
cross the same JSON/``String(id)`` boundary as an option id, and an
|
|
339
|
+
unrepresentable one blanks the widget with no Python error (see
|
|
340
|
+
``_validate_id``), so the list is trusted whole or not at all: filtering
|
|
341
|
+
single elements would be a fourth way for the rendered rows to disagree
|
|
342
|
+
with the returned value.
|
|
343
|
+
"""
|
|
344
|
+
if key is None:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
state = st.session_state[key]
|
|
348
|
+
except Exception:
|
|
349
|
+
# A missing key raises KeyError; session-state access can also raise
|
|
350
|
+
# StreamlitAPIException for keys it refuses to serve. Every failure
|
|
351
|
+
# means the same thing here: nothing persisted.
|
|
352
|
+
return None
|
|
353
|
+
if not isinstance(state, dict):
|
|
354
|
+
return None
|
|
355
|
+
selection = state.get("selection")
|
|
356
|
+
if not isinstance(selection, list):
|
|
357
|
+
return None
|
|
358
|
+
try:
|
|
359
|
+
for item_id in selection:
|
|
360
|
+
_validate_id(item_id, kind="persisted")
|
|
361
|
+
except ValueError:
|
|
362
|
+
return None
|
|
363
|
+
# A copy: the V2 presenter hands out a live write-through view of the
|
|
364
|
+
# widget state, and the payload must not alias it.
|
|
365
|
+
return list(selection)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def listview(
|
|
369
|
+
label: str,
|
|
370
|
+
options: Iterable[Any] | None = None,
|
|
371
|
+
*,
|
|
372
|
+
selection_mode: Literal["single", "multi"] = "single",
|
|
373
|
+
default: Any | list[Any] | None = None,
|
|
374
|
+
format_func: Callable[[Any], str] | None = None,
|
|
375
|
+
enable_search: bool = False,
|
|
376
|
+
pin_search: bool = False,
|
|
377
|
+
search_placeholder: str = "Search",
|
|
378
|
+
collapsible_groups: bool = False,
|
|
379
|
+
collapsed_groups: list[str] | Literal["all"] | None = None,
|
|
380
|
+
sort: Literal["items", "groups", "both"] | None = None,
|
|
381
|
+
sort_ascending: bool = True,
|
|
382
|
+
accept_new_options: bool = False,
|
|
383
|
+
max_selections: int | None = None,
|
|
384
|
+
select_all: bool = False,
|
|
385
|
+
placeholder: str | None = None,
|
|
386
|
+
height: int = 300,
|
|
387
|
+
item_height: int | None = None,
|
|
388
|
+
content_font_size: int | None = None,
|
|
389
|
+
width: Literal["stretch"] | int = "stretch",
|
|
390
|
+
show_grid_lines: bool = True,
|
|
391
|
+
help: str | None = None,
|
|
392
|
+
disabled: bool = False,
|
|
393
|
+
label_visibility: Literal["visible", "hidden", "collapsed"] = "visible",
|
|
394
|
+
on_change: Callable[..., None] | None = None,
|
|
395
|
+
args: tuple | None = None,
|
|
396
|
+
kwargs: dict | None = None,
|
|
397
|
+
key: str | None = None,
|
|
398
|
+
) -> dict | str | int | list | None:
|
|
399
|
+
"""Display a scrollable, selectable listview widget.
|
|
400
|
+
|
|
401
|
+
See the design spec for full behavior. `key` is strongly recommended for
|
|
402
|
+
any dynamic app so that frontend-local UI state and the selection survive
|
|
403
|
+
parameter changes (an unkeyed instance is remounted and reset).
|
|
404
|
+
|
|
405
|
+
`item_height` is a *minimum* row height in pixels (CSS ``min-height``), not a
|
|
406
|
+
fixed height: a row always grows to fit its content, so labels are never
|
|
407
|
+
clipped at any value. ``None`` (the default) lets each row size to its
|
|
408
|
+
content. Because it is only a floor, values below a row's natural one-line
|
|
409
|
+
height have no visible effect — the content height wins — so it is mainly
|
|
410
|
+
useful to make rows *taller* (e.g. roomier touch targets). The natural floor
|
|
411
|
+
is computed by the browser at layout time and tracks the active theme's font
|
|
412
|
+
size, which is why no fixed minimum is enforced here beyond ``>= 1``.
|
|
413
|
+
|
|
414
|
+
``content_font_size`` sets the font size (px) of the list *content* — the
|
|
415
|
+
option rows and the group headers — which in turn lowers each row's natural
|
|
416
|
+
height for a more compact list. ``None`` (the default) keeps the theme size
|
|
417
|
+
(``base × 0.875``, theme-tracked). It does **not** change the widget label,
|
|
418
|
+
search box, add-option input, or placeholder (those stay at the native
|
|
419
|
+
theme size), and it composes with ``item_height``: the font sets the natural
|
|
420
|
+
height, while ``item_height`` remains a floor that wins when larger.
|
|
421
|
+
|
|
422
|
+
``show_grid_lines`` (default ``True``) draws divider lines between option
|
|
423
|
+
rows; set it ``False`` for a more compact, line-free list (group headers
|
|
424
|
+
keep their own separator).
|
|
425
|
+
|
|
426
|
+
``format_func`` maps an option to its display label and receives **the option
|
|
427
|
+
as passed** — the dict for dict options, the scalar itself for ``str``/``int``
|
|
428
|
+
options — so a label can be built from fields the option carries (e.g.
|
|
429
|
+
``lambda o: f"{o['text']} ({o['type']})"``). It is consulted only for options
|
|
430
|
+
without a *usable* ``label``; an explicit one wins outright. Mind that
|
|
431
|
+
``{"label": None}`` and ``{"label": nan}`` count as unusable and do reach the
|
|
432
|
+
formatter — that is the ``df.to_dict("records")`` NULL-cell shape. ``None``
|
|
433
|
+
(the default) means no formatter: the label is ``str(id)``. The option handed
|
|
434
|
+
over is your own object, not a copy; treat it as read-only.
|
|
435
|
+
"""
|
|
436
|
+
_validate_params(
|
|
437
|
+
label=label,
|
|
438
|
+
help=help,
|
|
439
|
+
placeholder=placeholder,
|
|
440
|
+
search_placeholder=search_placeholder,
|
|
441
|
+
selection_mode=selection_mode,
|
|
442
|
+
label_visibility=label_visibility,
|
|
443
|
+
width=width,
|
|
444
|
+
height=height,
|
|
445
|
+
item_height=item_height,
|
|
446
|
+
content_font_size=content_font_size,
|
|
447
|
+
max_selections=max_selections,
|
|
448
|
+
enable_search=enable_search,
|
|
449
|
+
pin_search=pin_search,
|
|
450
|
+
collapsible_groups=collapsible_groups,
|
|
451
|
+
accept_new_options=accept_new_options,
|
|
452
|
+
show_grid_lines=show_grid_lines,
|
|
453
|
+
disabled=disabled,
|
|
454
|
+
format_func=format_func,
|
|
455
|
+
on_change=on_change,
|
|
456
|
+
select_all=select_all,
|
|
457
|
+
sort=sort,
|
|
458
|
+
sort_ascending=sort_ascending,
|
|
459
|
+
collapsed_groups=collapsed_groups,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
items, id_to_original = normalize_options(options, format_func)
|
|
463
|
+
items = sort_items(items, sort, sort_ascending)
|
|
464
|
+
|
|
465
|
+
# No options and no way to add any -> nothing is selectable: default the
|
|
466
|
+
# placeholder prompt to "No options to select" and disable the widget
|
|
467
|
+
# (matching st.multiselect). An explicit placeholder is kept as-is.
|
|
468
|
+
if not items and not accept_new_options:
|
|
469
|
+
disabled = True
|
|
470
|
+
if placeholder is None:
|
|
471
|
+
placeholder = "No options to select"
|
|
472
|
+
|
|
473
|
+
default_ids = _resolve_default_ids(
|
|
474
|
+
default, selection_mode, id_to_original, accept_new_options
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if (
|
|
478
|
+
selection_mode == "multi"
|
|
479
|
+
and max_selections is not None
|
|
480
|
+
and len(default_ids) > max_selections
|
|
481
|
+
):
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"default has {len(default_ids)} options but max_selections is "
|
|
484
|
+
f"{max_selections}; reduce the default or raise max_selections."
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
data = {
|
|
488
|
+
"label": label,
|
|
489
|
+
"help": help,
|
|
490
|
+
"items": items,
|
|
491
|
+
"selection_mode": selection_mode,
|
|
492
|
+
"default_ids": default_ids,
|
|
493
|
+
"state_selection": _persisted_selection(key),
|
|
494
|
+
"enable_search": enable_search,
|
|
495
|
+
"pin_search": pin_search,
|
|
496
|
+
"search_placeholder": search_placeholder,
|
|
497
|
+
"collapsible_groups": collapsible_groups,
|
|
498
|
+
"collapsed_groups": collapsed_groups,
|
|
499
|
+
"accept_new_options": accept_new_options,
|
|
500
|
+
"max_selections": max_selections,
|
|
501
|
+
"placeholder": placeholder,
|
|
502
|
+
"height": height,
|
|
503
|
+
"item_height": item_height,
|
|
504
|
+
"content_font_size": content_font_size,
|
|
505
|
+
"width": width,
|
|
506
|
+
"show_grid_lines": show_grid_lines,
|
|
507
|
+
"select_all": select_all,
|
|
508
|
+
"disabled": disabled,
|
|
509
|
+
"label_visibility": label_visibility,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if on_change is None:
|
|
513
|
+
on_selection_change = lambda: None # noqa: E731
|
|
514
|
+
else:
|
|
515
|
+
cb_args = args or ()
|
|
516
|
+
cb_kwargs = kwargs or {}
|
|
517
|
+
|
|
518
|
+
def on_selection_change() -> None:
|
|
519
|
+
on_change(*cb_args, **cb_kwargs)
|
|
520
|
+
|
|
521
|
+
result = _component(
|
|
522
|
+
data=data,
|
|
523
|
+
default={"selection": default_ids},
|
|
524
|
+
on_selection_change=on_selection_change,
|
|
525
|
+
key=key,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
selected_ids = result.get("selection")
|
|
529
|
+
if not isinstance(selected_ids, list):
|
|
530
|
+
# Guard against a tampered/buggy frontend returning a non-list (the V2
|
|
531
|
+
# state is typed Id[] in TypeScript, but that is not a runtime
|
|
532
|
+
# guarantee). Treat anything that is not a list as "no selection"
|
|
533
|
+
# rather than iterating it — a returned string would otherwise be
|
|
534
|
+
# split into per-character ids.
|
|
535
|
+
selected_ids = []
|
|
536
|
+
return map_selection(
|
|
537
|
+
selected_ids,
|
|
538
|
+
id_to_original,
|
|
539
|
+
selection_mode,
|
|
540
|
+
accept_new_options,
|
|
541
|
+
max_selections,
|
|
542
|
+
)
|