Skip to content

Conversation

@jdoiro3
Copy link

@jdoiro3 jdoiro3 commented Oct 22, 2025

Note

PR description is still a work in progress.

Motivation

This PR improves choice elements (e.g., ui.select, ui.toggle, ui.radio, etc.) by making the options a type and not an implementation detail. It also improves the typing support for these components, allowing static type checkers to help users avoid bugs, make code more readable and making it easier to utilize custom quasar slotting.

Changes to the ui.select element

Tip

click to expand sections containing full code examples before and after these changes.

Before

from nicegui import ui

if __name__ in {"__main__", "__mp_main__"}:

    @ui.page("/")
    def typing_issues():
        # e.value is Any so type checker can't see that you can't add an int to a str
        my_select = ui.select(
            options=['a', 'b', 'c'],
            multiple=False,
            new_value_mode="toggle",
        ).on_value_change(lambda e: ui.notify(e.value + 100))

        ui.button(
            text="click me", 
            # same here. The select's value attribute is "Any | None" so a static type checker can't help us
            on_click=lambda: ui.notify(my_select.value + 100) if my_select.value else print("no op")
        )

        # when adding new values the event is a str which can cause issues as this example shows.
        ui.select(
            options=[1, 2, 3, 4],
            multiple=False,
            new_value_mode="add-unique",
        ).on_value_change(lambda e: ui.notify(e.value + 100))
        # ^ The on value change event seems fine (we're adding an int to an int); however, when we
        # add a new value it will cause a type error.

        # ui.select options are implementation details and, as a user, you aren't sure how to access 
        # these properties (or extra properties) in custom rendering slots. 
        select = (
            ui.select([
                'person|Person|Do it alone!',
                'people|Couple|To it together!',
                'groups|Team|Team up!',
            ])
            .classes('w-32')
            .on_value_change(lambda e: ui.notify(e.value.split('|')[1]))
            # ^ NOTE: in order to notify the a specific value in our option we
            # have to split a string on a deliminator.
        )
        select.add_slot('option', r'''
        <q-item v-bind="props.itemProps">
            <q-item-section avatar>
                <q-icon :name="props.opt.label.split('|')[0]" />
            </q-item-section>
            <q-item-section>
                <q-item-label>{{ props.opt.label.split('|')[1] }}</q-item-label>
                <q-item-label caption>{{ props.opt.label.split('|')[2] }}</q-item-label>
            </q-item-section>
        </q-item>
        ''')
        select.add_slot('selected-item', r'{{ props.opt.label.split("|")[1] }}')
        # NOTE: in order for this custom slot to work we, as a user, have to know that
        # `ui.select` stores our option values (the hacky strings) in the label property of the
        # Quasar component's option objects.
            
    ui.run()
  • on_value_change's event value is Any so type checkers can't help us when we try to add an int to a str.
  • The select element's value is Any | None so static type checkers can't help us.
  • Even if the options are ints, when adding new values the event is a str which can cause issues.
  • The element's options are an implementation detail and, as a user, it isn't clear how to access these properties (or extra properties) in custom rendering slots.

After

from nicegui import ui
from dataclasses import dataclass


if __name__ in {"__main__", "__mp_main__"}:

    @ui.page("/")
    def typing_issues():
        my_select = ui.select(
            options=['a', 'b', 'c'],
            new_value_mode="toggle",
            new_value_to_option=ui.to_option
        ).on_value_change(lambda e: ui.notify(e.value.value + " wow!" if e.value else 'no value'))

        ui.button(
            text="click me", 
            on_click=lambda: ui.notify(my_select.value.value + " wow!!!!" if my_select.value else 'no value')
        )

        multiple_select = ui.select(
            options=[1, 2, 3, 4],
            new_value_mode="add-unique",
            new_value_to_option=lambda v: ui.to_option(int(v)),
            value=(), # a tuple value means this select can have multiple values
        )
        assert multiple_select.multiple

        ui.button(
            text="click me", 
            on_click=lambda: ui.notify(','.join([str(option.value) for option in multiple_select.value]))
        )


        @dataclass
        class Person(ui.option[str, int]):
            icon: str
            caption: str

        select = (
            ui.select([
                Person(label='Person', value=1, icon='person', caption='Do it alone!'),
                Person(label='Couple', value=2, icon='people', caption='Do it together!'),
                Person(label='Person', value=3, icon='groups', caption='Team up!'),
            ])
            .classes('w-32')
            .props('use-chips')
            .on_value_change(lambda e: ui.notify(e.value.label if e.value else 'No value'))
        )
        select.add_slot(
            'option',
            r'''
            <q-item v-bind="props.itemProps">
                <q-item-section avatar>
                    <q-icon :name="props.opt.icon"></ion-icon>
                </q-item-section>
                <q-item-section>
                    <q-item-label>{{ props.opt.label }}</q-item-label>
                    <q-item-label caption>{{ props.opt.caption }}</q-item-label>
                </q-item-section>
            </q-item>
            '''
        )
            
    ui.run()
  • The ui.select element is now type aware. The first type is the value type and the second is the option type.
Screenshot 2025-10-29 at 2 50 36 PM Screenshot 2025-10-29 at 4 02 17 PM
  • Static type checkers can help us more now.
Screenshot 2025-10-29 at 3 45 11 PM
  • We can also now extend an option's type to include extra fields. Here we define a new Person option, containing an icon and caption in addition to the required label field, a str and the value field, an int. And since the options structure is no longer an implementation detail, it's a lot easier to use the various option fields when slotting.
@dataclass
class Person(ui.option[str, int]):
    icon: str
    caption: str

select = (
    ui.select([
        Person(label='Person', value=1, icon='person', caption='Do it alone!'),
        Person(label='Couple', value=2, icon='people', caption='Do it together!'),
        Person(label='Person', value=3, icon='groups', caption='Team up!'),
    ])
    .classes('w-32')
    .props('use-chips')
    .on_value_change(lambda e: ui.notify(e.value.label if e.value else 'No value'))
)
select.add_slot(
    'option',
    r'''
    <q-item v-bind="props.itemProps">
        <q-item-section avatar>
            <q-icon :name="props.opt.icon"></ion-icon>
        </q-item-section>
        <q-item-section>
            <q-item-label>{{ props.opt.label }}</q-item-label>
            <q-item-label caption>{{ props.opt.caption }}</q-item-label>
        </q-item-section>
    </q-item>
    '''
)
  • The types for the ui.select element correctly know that the option is of type Person.
Screenshot 2025-10-29 at 3 11 52 PM Screenshot 2025-10-29 at 3 12 23 PM

Implementation

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • Pytests have been added (or are not necessary).
  • Documentation has been added (or is not necessary).

TODO

  • write detailed PR description, using other PRs by core maintainers as an example of what I need to add.
  • improve backwards compatibility by allowing a dict[L, V] to be passed as options. This will also allow the PR's changes to test code to be minimal.
  • tweak user interactions module slightly based on changes and confirm all tests are passing
  • update docstrings for the various components that have changed.

@evnchn
Copy link
Collaborator

evnchn commented Oct 22, 2025

Imagine me when I opened GitHub and see this

image

Totally thought instinctively that this was some prank PR and wanna close it at first.

🤡 Image

Looking closelier I see it is a recarnation of #4777. That's good! Tell us when it's closer to done. Add oil! 👍

@evnchn evnchn added feature Type/scope: New or intentionally changed behavior in progress Status: Someone is working on it labels Oct 22, 2025
@jdoiro3
Copy link
Author

jdoiro3 commented Oct 22, 2025

haha Yeah, feel free to ignore until it's out of draft, @evnchn. I'm still planning on starting a discussion soon and use these changes as examples of the benefits.

@evnchn
Copy link
Collaborator

evnchn commented Oct 23, 2025

Question: For the typing enhancement on nicegui/binding.py may it lead to general typing improvements across-the-board for all NiceGUI code which uses the binding functionality?
(I hope it is a Yes)

@jdoiro3
Copy link
Author

jdoiro3 commented Oct 23, 2025

Question: For the typing enhancement on nicegui/binding.py may it lead to general typing improvements across-the-board for all NiceGUI code which uses the binding functionality? (I hope it is a Yes)

Yes @evnchn, that's the goal. I'm still learning how all the components work with the BindableProperty class but I think I'm making good progress with general typing improvements for components that inherit from ChoiceElement (e.g., ui.select, ui.toggle, ui.radio, etc.).

I also got the docs site working locally and added an example based on these changes.

Screen.Recording.2025-10-23.at.11.29.27.AM.mov

@jdoiro3 jdoiro3 changed the title Making options richer Making ChoiceElement options richer and improve typing Oct 23, 2025
@jdoiro3 jdoiro3 changed the title Making ChoiceElement options richer and improve typing Making ChoiceElement's options richer and improve typing Oct 23, 2025
@evnchn
Copy link
Collaborator

evnchn commented Oct 24, 2025

26e070b

Unfortunately it has been done at #5304. You need to roll back that commit, and then simply merge your branch with the latest main and you should be good to go.

@evnchn
Copy link
Collaborator

evnchn commented Oct 24, 2025

And also for the changes in nicegui/binding.py, assuming you did look at the pipeline and the code-check passes, then the changes actually make the typing hint better while having mypy happy (so less Anys across the board).

If that is the case then maybe we should prioritize delivering that since the impact is higher.

@jdoiro3 jdoiro3 force-pushed the making-options-richer branch from dcddab6 to d093738 Compare October 25, 2025 15:54
Comment on lines -158 to -170
def test_id_generator(screen: Screen):
@ui.page('/')
def page():
options = {'a': 'A', 'b': 'B', 'c': 'C'}
select = ui.select(options, value='b', new_value_mode='add', key_generator=lambda _: len(options))
ui.label().bind_text_from(select, 'options', lambda v: f'options = {v}')

screen.open('/')
screen.find_by_tag('input').send_keys(Keys.BACKSPACE + 'd')
screen.wait(0.5)
screen.find_by_tag('input').send_keys(Keys.ENTER)
screen.should_contain("options = {'a': 'A', 'b': 'B', 'c': 'C', 3: 'd'}")
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense that I deleted this test but I probably should add tests for new_value_to_option.

@jdoiro3 jdoiro3 force-pushed the making-options-richer branch from 41357fd to 246e155 Compare October 29, 2025 13:55
@jdoiro3 jdoiro3 changed the title Making ChoiceElement's options richer and improve typing Improve ChoiceElement's options by making them richer and typed better Oct 29, 2025
@jdoiro3 jdoiro3 marked this pull request as ready for review October 29, 2025 14:06
@jdoiro3
Copy link
Author

jdoiro3 commented Oct 29, 2025

And also for the changes in nicegui/binding.py, assuming you did look at the pipeline and the code-check passes, then the changes actually make the typing hint better while having mypy happy (so less Anys across the board).

If that is the case then maybe we should prioritize delivering that since the impact is higher.

@evnchn, I'd consider working on a separate PR just for that if that's what you guys want.

I've also taken this PR out of draft so people can start looking at it; however, I still need to make a few more tweaks/changes for it to be "ready" (see the TODO section I added to the description).

…ot tests/test_user_simulation.py udpated and passing based on the changes.
@evnchn
Copy link
Collaborator

evnchn commented Oct 29, 2025

separate PR

I mean cherry-pick changes later, which should be moderately easy to do. Also it's more of a last resort - that really happens when the rest of this PR's code doesn't get an OK...

taken this PR out of draft so people can start looking at it

May not be good, a bit noisy at times...

@jdoiro3 jdoiro3 marked this pull request as draft October 29, 2025 17:55
@jdoiro3
Copy link
Author

jdoiro3 commented Oct 29, 2025

separate PR

I mean cherry-pick changes later, which should be moderately easy to do. Also it's more of a last resort - that really happens when the rest of this PR's code doesn't get an OK...

taken this PR out of draft so people can start looking at it

May not be good, a bit noisy at times...

@evnchn, I put it back in the "draft" state based on your comment. I'm currently working on the PR description since all tests are now passing and I updated/added doc examples. Would you mind letting me know if what I have on the PR description right now clear to you? I'd really like to make it as clear as possible.

@evnchn
Copy link
Collaborator

evnchn commented Oct 30, 2025

@jdoiro3 I see you made the call signature, at one point, into ui.radio([ui.option('A', 1), ui.option('B', 2), ui.option('C', 3)]).

That doesn't seem very Pythonic to me, may be a bit clumsy to write. If possible, let's avoid it?


On trying to resolve that, I'm checking where else in our code we have "better typing" and I find the Event system quite well-written on that regard.

https://nicegui.io/documentation/event

image

P = ParamSpec('P')

Would that help in this PR? Maybe we can have ui.radio[int, str](...) if it is unable to infer that it's an int and a str from ui.radio({1: 'A', 2: 'B', 3: 'C'})?

@jdoiro3
Copy link
Author

jdoiro3 commented Nov 23, 2025

I'm not sure this PR is going to work out. I can't figure out how to get the typing working properly for ui.select and also maintain backwards compatibility for the signature, where it can accept a list or a dict as well.

I do think allowing users to pass an Option type is a good feature to allow users to more easily customize how these options render in the UI. I also think getting the typing right (or as good as it can be) for this component would be a worthwhile endeavor, @evnchn.

@evnchn
Copy link
Collaborator

evnchn commented Dec 6, 2025

If, worst case scenario, we cannot make it work without a breaking change, then it has to wait for 4.0. But I don't think people will like that release that way...

I'm a bit occupied right now but I think we can take another shot later.

@falkoschindler
Copy link
Contributor

@jdoiro3 Thank you for the effort put into this PR and the exploration of improving type safety for choice elements.

Considering the discussion and the code changes, I'd like to suggest closing this PR for the following reasons:

  • Breaking changes: The PR fundamentally changes the .value semantics, which would break existing code across the board. Users would need to rewrite their choice element interactions.

  • Trade-offs: While better typing is valuable, the implementation adds verbosity to common use cases. The current simple API (options=['a', 'b', 'c'] or options={'key': 'label'}) serves most users well.

  • Stale state: The PR has unresolved merge conflicts and hasn't seen activity since November.

If there's interest in typed options in the future, a backward-compatible approach that doesn't change existing behavior would be preferable.

Thanks again for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior in progress Status: Someone is working on it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants