dcc.Input with suggestions based on text similarity

I am trying to implement a dcc.Input component with suggestions based on text similarity. The goal is for suggestion to still appear even if a user misspells a word, for example. The minimal working example (which is modified from here) below illustrates my problem.

from dash import ALL, callback, Dash, dcc, html, Input, Output
from difflib import get_close_matches

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?'
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash()

app.layout = html.Div([

    html.Datalist(
        id='datalist-questions',
        children=[html.Option(value=question) for question in questions]
    ),

    dcc.Input(id='input-questions',
              type='text',
              list='datalist-questions',
              value='',
              placeholder='Type question here...',
              style={'width': '40%'}
    )

])


@callback(
    Output('datalist-questions', 'children'),
    Input('input-questions', 'value'))
def provide_search_results(text):

    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-'*10)
        print(text)
        print(matches)

        return [html.Option(value=match) for match in matches]


if __name__ == '__main__':
    app.run_server(debug=True)

If the user types “Why is the”, then they see the possible questions based on matching text:

text-with-possible-questions

However, if the user misspells one of the words (in this case “thee” instead of “the”), the suggestions disappear despite there still being matching text:

misspelled-text-with-suggestions

The goal would be for there to still be suggestions based on text similarity.

I googled “python dash html datalist with fuzzy match” which led me to this stackoverflow question, where I learned that the behavior I am trying to achieve is not possible with html.Datalist.

Therefore, my question is: Is there some other component/pattern that would achieve what I am trying to do?

I just tried using dcc.Dropdown and using the search_value to update the options but that also doesn’t work…

Hi, maybe the dbc.tooltip? Or do you want the options to be selectable?

I would like the options to be selectable

Maybe you could return html.Button() into a Div? What do you want to happen, once the suggestion has been clicked?

@callback(
    Output('dummy_div', 'children'),
    Input('input-questions', 'value'))
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return [html.Button(match) for match in matches]

I could imagine using pattern matching callbacks for the suggestion click…

EDIT: something like this quick&dirty prototype

from dash import callback, Dash, dcc, html, Input, Output, ALL, State, ctx
from dash.exceptions import PreventUpdate
from difflib import get_close_matches

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?'
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash()

app.layout = html.Div([

    html.Datalist(
        id='datalist-questions',
        children=[html.Option(value=question) for question in questions]
    ),

    dcc.Input(
        id='input-questions',
        type='text',
        list='datalist-questions',
        value='',
        placeholder='Type question here...',
        style={'width': '40%'}
    ),
    html.Div(id='dummy'),
    html.Div(id={'type': 'selected', 'index': 0})

])


@callback(
    Output('dummy', 'children'),
    Input('input-questions', 'value')
)
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return html.Div(
            children=[
                html.Button(
                    children=match,
                    id={'type': 'btn', 'index': idx},
                    style={
                        'display': 'block',
                        'width': '400px'
                    }
                ) for idx, match in enumerate(matches)
            ],
            className='vstack'
        )


@callback(
    Output({'type': 'selected', 'index': ALL}, 'children'),
    Input({'type': 'btn', 'index': ALL}, 'n_clicks'),
    State({'type': 'btn', 'index': ALL}, 'children'),
    prevent_initial_callback=True
)
def do_something(click, child):
    if not click:
        raise PreventUpdate
    idx = ctx.triggered_id['index']
    return [child[idx]]


if __name__ == '__main__':
    app.run_server(debug=True)

Thank you for the example! This is useful to see some possibilities. I know this is just a prototype, but I really was hoping for something like a dcc.Dropdown (looks better than stacked buttons) where the options are provided by some type of text similarity check, and if the user clicks on that option it populates the value attribute of the dcc.Dropdown. I’m also wanting that if a user clicks into the textbox of the dropdown for the first time, they see all available options (like you do with a dcc.Dropdown). They should also see all options if they erase whatever is in the search field.

I took your example and modified it a little bit, but can’t product the intended affect. If you comment out the second callback below, then the user will see all options when the app starts up instead of first having to click into the text box. However, they do see all options if they erase all the text in the text box, which is desired.

If you uncomment the second callback, then there some type of chain reaction where if the user types in “Why is the” it will autocomplete to “Why is the sky blue?”. That’s not desired.

Again, thank you for you help! I just wish they was something that looked as nice as dcc.Dropdown (or dcc.Input) and you had better control over the options.

from dash import callback, Dash, dcc, html, Input, Output, ALL, State, ctx
from dash.exceptions import PreventUpdate
from difflib import get_close_matches

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?',
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash()

app.layout = html.Div([

    dcc.Input(
        id='input-questions',
        type='text',
        value='',
        autoComplete='off',
        placeholder='Type question here...',
        style={'width': '40%'}
    ),
    html.Div(id='dummy'),
    html.Div(id={'type': 'selected', 'index': 0})

])


@callback(
    Output('dummy', 'children'),
    Input('input-questions', 'value')
)
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return html.Div(
            children=[
                html.Button(
                    children=match,
                    id={'type': 'btn', 'index': idx},
                    style={
                        'display': 'block',
                        'width': '400px'
                    }
                ) for idx, match in enumerate(matches)
            ],
            className='vstack'
        )

    else:
        return html.Div(
            children=[
                html.Button(
                    children=question,
                    id={'type': 'btn', 'index': idx},
                    style={
                        'display': 'block',
                        'width': '400px'
                    }
                ) for idx, question in enumerate(questions)
            ],
            className='vstack'
        )


@callback(
    Output('input-questions', 'value'),
    Input({'type': 'btn', 'index': ALL}, 'n_clicks'),
    State({'type': 'btn', 'index': ALL}, 'children'),
    prevent_initial_callback=True
)
def do_something(click, child):
    if not click:
        raise PreventUpdate
    idx = ctx.triggered_id['index']
    return child[idx]


if __name__ == '__main__':
    app.run_server(debug=True)

HI @alex-syk, I did not know that a drop down would fit your needs. Actually I still don’t fully understand what you are aiming for…

Another try:

from dash import callback, Dash, dcc, html, Input, Output
from dash.exceptions import PreventUpdate
from difflib import get_close_matches

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?'
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash(
    __name__,
    suppress_callback_exceptions=True
)

app.layout = html.Div([

    html.Datalist(
        id='datalist-questions',
        children=[html.Option(value=question) for question in questions]
    ),

    dcc.Input(
        id='input-questions',
        type='text',
        list='datalist-questions',
        value='',
        placeholder='Type question here...',
        style={'width': '40%'}
    ),
    html.Div(id='drop_down_container'),
    html.Div(id='selection_container')

])


@callback(
    Output('drop_down_container', 'children'),
    Input('input-questions', 'value')
)
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return html.Div(
                dcc.Dropdown(
                    id='drop',
                    options=[match for match in matches]
                ),
                style={'width': '30%'}
            )


@callback(
    Output('selection_container', 'children'),
    Input('drop', 'value'),
    prevent_initial_callback=True
)
def do_something(value):
    if not value:
        raise PreventUpdate

    return html.Div(
        children=f'You selected: {value}',
        style={'color': 'red'}
    )


if __name__ == '__main__':
    app.run_server(debug=True)

Hi @alex-syk I think I found a better solution than my previous post.

Since the DropDownMenu from dash-bootstrap-components allows dash components as lables since version 1.4, I put a dcc.Input() there. In a callback I create a list of DropdownMenuItem() corresponding to the similar text.

from dash import callback, Dash, dcc, html, Input, Output, ALL, ctx, State
from dash.exceptions import PreventUpdate
from difflib import get_close_matches
import dash_bootstrap_components as dbc

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?'
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash(
    __name__,
    suppress_callback_exceptions=True,
    external_stylesheets=[dbc.themes.LUX]
)

app.layout = html.Div(
    [
        html.Div(
            id='datalist-questions',
            children=[f'{question}\n\n' for question in questions]
        ),
        dbc.DropdownMenu(
            id='drop',
            label=dcc.Input(
                id='input-questions',
                type='text',
                list='datalist-questions',
                value='',
                placeholder='Type question here...',
                style={'width': '400px'}
            ),
            children=[],
        ),
        html.Div(id={'type': 'selection_container', 'index': 0})
    ],
)


@callback(
    Output('drop', 'children'),
    Input('input-questions', 'value'),
    prevent_initial_call=True
)
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return [
            dbc.DropdownMenuItem(
                id={'type': 'selection', 'index': idx},
                children=match
            ) for idx, match in enumerate(matches)
        ]


@callback(
    Output({'type': 'selection_container', 'index': ALL}, 'children'),
    Input({'type': 'selection', 'index': ALL}, 'n_clicks'),
    State({'type': 'selection', 'index': ALL}, 'children'),
    prevent_initial_callback=True
)
def do_something(clicks, select):
    # if nothing has been clicked
    if not clicks:
        raise PreventUpdate

    # get trigger index
    idx = ctx.triggered_id['index']
    if not clicks[idx]:
        raise PreventUpdate

    return [html.Div(
        children=f'You selected: {select[idx]}',
        style={'color': 'red'}
    )]


if __name__ == '__main__':
    app.run_server(debug=True)

creates:
drop

1 Like

Wow - that’s slick! What a great example :slight_smile:

Nice use of the difflib library. I didn’t even know that existed, and it’s a built-in Python library! :sunglasses:

1 Like

Wow! Thank you again for your help! Sorry for not being very clear earlier. For reference, I took your example and modified it just a little bit to get my desired behavior. The goal is that after a user clicks a question from the dropdown or completely types out a question, that will trigger other parts of the app to appear and trigger other logic. Right now, I followed your example of just showing the question. I don’t know if the way I modified it is good or not, but at least I’m headed in the right direction from your help.

I have a question related to this app about saved browser data (I don’t know if this should be its own question). If I use Google Chrome, I will see questions from saved browser data, for example:

two-dropdowns-2

Do you know what causes this? I thought it might have to do with the persistence, persisted_props, and persistence_type properties of dcc.Input? For reference if I switch to a different browser, it gives me the option to delete the saved data. But regardless of browser choice, I don’t want the user to see previous questions or have to delete saved browser data.

different-browser

Here is the modified code:

from dash import callback, Dash, dcc, html, Input, Output, ALL, ctx, State
from dash.exceptions import PreventUpdate
from difflib import get_close_matches
import dash_bootstrap_components as dbc

questions = ['Why is the sky blue?',
             'Why is the grass green?',
             'Who is that walking over there?',
             'Who is that running right here?',
             'What is your name?',
             'What is your hair style?',
             'What is your eye color?']

app = Dash(
    __name__,
    suppress_callback_exceptions=True,
    external_stylesheets=[dbc.themes.LUX]
)

app.layout = html.Div(
    [
        dbc.DropdownMenu(
            id='drop',
            label=dcc.Input(
                id='input-questions',
                type='text',
                value='',
                placeholder='Type question here...',
                style={'width': '400px'}
            ),
            children=[
                dbc.DropdownMenuItem(
                    id={'type': 'selection', 'index': idx},
                    children=question
                ) for idx, question in enumerate(questions)
            ],
        ),
        html.Div(id='selection')
    ],
)


@callback(
    Output('drop', 'children'),
    Input('input-questions', 'value'),
    prevent_initial_call=True
)
def provide_search_results(text):
    if text:
        matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)

        print('-' * 10)
        print(text)
        print(matches)

        return [
            dbc.DropdownMenuItem(
                id={'type': 'selection', 'index': idx},
                children=match
            ) for idx, match in enumerate(matches)
        ]

    # List all questions in the dropdown when app first loads and if user completely erases the text
    else:
        return [
            dbc.DropdownMenuItem(
                id={'type': 'selection', 'index': idx},
                children=question
            ) for idx, question in enumerate(questions)
        ]


@callback(
    Output('input-questions', 'value'),
    Input({'type': 'selection', 'index': ALL}, 'n_clicks'),
    State({'type': 'selection', 'index': ALL}, 'children'),
    prevent_initial_callback=True
)
def update_input(clicks, select):  # If the user selects a question from the dropdown, the question should appear in the input

    # If nothing has been clicked
    if all(click is None for click in clicks):
        raise PreventUpdate

    # Get trigger index
    idx = ctx.triggered_id['index']
    if not clicks[idx]:
        raise PreventUpdate

    return select[idx]

@callback(
    Output('selection', 'children'),
    Input('input-questions', 'value'))
def show_question(text):

    # If the user selects a question from the dropdown or completely types out a question, proceed with other app logic...
    if any(text == question for question in questions):
        return text

if __name__ == '__main__':
    app.run_server(debug=True)```