Support for <button> items in action menus

Closes #2782

Animated demo: https://github.com/simonw/datasette/pull/2781#issuecomment-4703303274
This commit is contained in:
Simon Willison 2026-06-14 15:58:37 -07:00
commit 4ce2888e79
7 changed files with 179 additions and 20 deletions

View file

@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request):
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Links for the row actions menu"""
"""Items for the row actions menu"""
@hookspec
def table_actions(datasette, actor, database, table, request):
"""Links for the table actions menu"""
"""Items for the table actions menu"""
@hookspec
def view_actions(datasette, actor, database, view, request):
"""Links for the view actions menu"""
"""Items for the view actions menu"""
@hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Links for the query and stored query actions menu"""
"""Items for the query and stored query actions menu"""
@hookspec
def database_actions(datasette, actor, database, request):
"""Links for the database actions menu"""
"""Items for the database actions menu"""
@hookspec
def homepage_actions(datasette, actor, request):
"""Links for the homepage actions menu"""
"""Items for the homepage actions menu"""
@hookspec

View file

@ -2061,18 +2061,32 @@ svg.dropdown-menu-icon {
.dropdown-menu a:link,
.dropdown-menu a:visited,
.dropdown-menu a:hover,
.dropdown-menu a:focus
.dropdown-menu a:active {
.dropdown-menu a:focus,
.dropdown-menu a:active,
.dropdown-menu button.action-menu-button {
text-decoration: none;
display: block;
padding: 4px 8px 2px 8px;
color: #222;
white-space: nowrap;
}
.dropdown-menu a:hover {
.dropdown-menu button.action-menu-button {
appearance: none;
background: none;
border: none;
box-sizing: border-box;
cursor: pointer;
font: inherit;
text-align: left;
width: 100%;
}
.dropdown-menu a:hover,
.dropdown-menu button.action-menu-button:hover,
.dropdown-menu button.action-menu-button:focus {
background-color: #eee;
}
.dropdown-menu .dropdown-description {
display: block;
margin: 0;
color: #666;
font-size: 0.8em;

View file

@ -15,14 +15,22 @@
<div class="hook"></div>
<ul role="menu">
{% for link in action_links %}
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<p class="dropdown-description">{{ link.description }}</p>
{% endif %}</a>
<li role="none">
{% if link.get("type") == "button" %}
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</button>
{% else %}
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</details>
</div>
{% endif %}
{% endif %}

View file

@ -288,6 +288,14 @@ element that wraps the HTML for that row. Datasette uses this attribute to find
the element to remove after a delete, or replace after an edit. Any edit or
delete controls should be rendered inside that same element.
The ``_action_menu.html`` template renders the action menus used by database,
table, query and row pages. Plugin-provided actions can be link dictionaries
with ``href`` and ``label`` keys, or button dictionaries using ``{"type":
"button", "label": "...", "attrs": {...}}`` for JavaScript-backed interactions.
Both shapes can include an optional ``description`` key. Custom
``_action_menu.html`` templates should preserve support for both link and button
action items.
.. _custom_pages:
Custom pages

View file

@ -1909,7 +1909,80 @@ Action hooks
Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.
Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
Each of these hooks should return a list of menu items, with optional ``"description": "..."`` keys describing each action in more detail.
The most common action item is a link to another page:
.. code-block:: python
{
"href": datasette.urls.path("/-/custom-action"),
"label": "Custom action",
"description": "Run this action on a separate page.",
}
Plugins can also return button actions for JavaScript-backed interactions:
.. code-block:: python
{
"type": "button",
"label": "Open custom dialog",
"description": "Show a dialog without leaving this page.",
"attrs": {
"aria-label": "Open custom dialog",
"data-plugin-action": "open-custom-dialog",
},
}
These are rendered as ``<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1">``. The optional ``attrs`` dictionary is added to the button, and is useful for ``data-*`` attributes that your plugin's JavaScript can use to attach event handlers.
Here is a minimal plugin example that adds a button to a table page and loads JavaScript to handle clicks on that button:
.. code-block:: python
from datasette import hookimpl
@hookimpl
def table_actions(datasette, database, table):
return [
{
"type": "button",
"label": "Show table name",
"description": "Open a JavaScript-powered plugin action.",
"attrs": {
"aria-label": "Show table name",
"data-plugin-action": "show-table-name",
"data-database": database,
"data-table": table,
},
}
]
@hookimpl
def extra_js_urls(datasette):
return [
datasette.urls.static_plugins(
"datasette_show_table",
"show-table.js",
)
]
The ``static/show-table.js`` file in that plugin could look like this:
.. code-block:: javascript
document.addEventListener("click", (event) => {
const button = event.target.closest(
"button[data-plugin-action='show-table-name']"
);
if (!button) {
return;
}
alert(`${button.dataset.database}.${button.dataset.table}`);
});
They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.

View file

@ -357,15 +357,30 @@ def menu_links(datasette, actor, request):
@hookimpl
def table_actions(datasette, database, table, actor):
def table_actions(datasette, database, table, actor, request):
if actor:
return [
actions = [
{
"href": datasette.urls.instance(),
"label": f"Database: {database}",
},
{"href": datasette.urls.instance(), "label": f"Table: {table}"},
]
if request.args.get("_button"):
actions.append(
{
"type": "button",
"label": "Plugin button",
"description": "Runs JavaScript from a plugin",
"attrs": {
"aria-label": "Plugin button for {}".format(table),
"data-plugin-action": "plugin-button",
"data-database": database,
"data-table": table,
},
}
)
return actions
@hookimpl

View file

@ -1062,6 +1062,7 @@ async def test_hook_menu_links(ds_client):
async def test_hook_table_actions(ds_client):
response = await ds_client.get("/fixtures/facetable")
assert get_actions_links(response.text) == []
assert get_actions_buttons(response.text) == []
response_2 = await ds_client.get("/fixtures/facetable?_bot=1&_hello=BOB")
assert ">Table actions<" in response_2.text
assert sorted(
@ -1071,6 +1072,23 @@ async def test_hook_table_actions(ds_client):
{"label": "From async BOB", "href": "/", "description": None},
{"label": "Table: facetable", "href": "/", "description": None},
]
response_3 = await ds_client.get("/fixtures/facetable?_bot=1&_button=1")
assert get_actions_buttons(response_3.text) == [
{
"label": "Plugin button",
"description": "Runs JavaScript from a plugin",
"attrs": {
"aria-label": "Plugin button for facetable",
"class": ["button-as-link", "action-menu-button"],
"data-database": "fixtures",
"data-plugin-action": "plugin-button",
"data-table": "facetable",
"role": "menuitem",
"tabindex": "-1",
"type": "button",
},
}
]
@pytest.mark.asyncio
@ -1098,15 +1116,38 @@ def get_actions_links(html):
links = []
for a_el in details.select("a"):
description = None
if a_el.find("p") is not None:
description = a_el.find("p").text.strip()
a_el.find("p").extract()
description_el = a_el.find(class_="dropdown-description")
if description_el is not None:
description = description_el.text.strip()
description_el.extract()
label = a_el.text.strip()
href = a_el["href"]
links.append({"label": label, "href": href, "description": description})
return links
def get_actions_buttons(html):
soup = Soup(html, "html.parser")
details = soup.find("details", {"class": "actions-menu-links"})
if details is None:
return []
buttons = []
for button_el in details.select("button.action-menu-button"):
description = None
description_el = button_el.find(class_="dropdown-description")
if description_el is not None:
description = description_el.text.strip()
description_el.extract()
buttons.append(
{
"label": button_el.text.strip(),
"description": description,
"attrs": dict(button_el.attrs),
}
)
return buttons
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_url",