Visual improvements to table filter UI

- Looks nicer
- Add / remove buttons work properly

Closes #2798
This commit is contained in:
Simon Willison 2026-06-22 18:19:49 -07:00
commit 86ea1d4722
4 changed files with 411 additions and 86 deletions

View file

@ -609,9 +609,6 @@ button.core[type=button] {
border-color: #007bff;
}
.filter-row {
margin-bottom: 0.6em;
}
.search-row {
margin-bottom: 1.8em;
}
@ -623,72 +620,239 @@ button.core[type=button] {
width: 80px;
}
.select-wrapper {
border: 1px solid #ccc;
width: 120px;
border-radius: 3px;
.filters .search-row {
box-sizing: border-box;
display: grid;
grid-template-columns: max-content minmax(16rem, 1fr);
align-items: center;
gap: 0.6rem;
margin: 0 0 0.45rem;
padding-right: var(--filter-two-icon-space);
}
.filters .search-row label {
width: auto;
padding: 0;
background-color: #fafafa;
position: relative;
color: var(--filter-muted);
font-size: 0.875rem;
font-weight: 600;
}
.filters {
--filter-ink: #0f0f0f;
--filter-paper: #eef6ff;
--filter-muted: #6b6b6b;
--filter-rule: #d8e6f5;
--filter-accent: #1a56db;
--filter-control-border: #bfccd9;
--filter-control-height: 2.125rem;
--filter-control-gap: 0.4rem;
--filter-row-icon-size: 2rem;
--filter-two-icon-space: calc(
(2 * var(--filter-row-icon-size)) + (2 * var(--filter-control-gap))
);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.55rem;
max-width: 760px;
margin: 0 0 1rem;
padding: 0.75rem;
border: 1px solid var(--filter-rule);
border-radius: 8px;
background: var(--filter-paper);
color: var(--filter-ink);
font-size: 0.875rem;
}
.filters .filter-row {
margin-bottom: 0;
}
.filters .filter-controls-row {
display: grid;
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
grid-template-columns:
minmax(8rem, 0.75fr)
minmax(6.5rem, 0.5fr)
minmax(12rem, 1.2fr)
var(--filter-row-icon-size)
var(--filter-row-icon-size);
gap: var(--filter-control-gap);
align-items: center;
}
.filters .filter-actions-row {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.6rem;
}
.select-wrapper {
display: inline-block;
margin-right: 0.3em;
}
.select-wrapper:focus-within {
border: 1px solid black;
width: 120px;
min-width: 0;
}
.select-wrapper.filter-op {
width: 80px;
}
.select-wrapper::after {
content: "\25BE";
position: absolute;
top: 0px;
right: 0.4em;
color: #bbb;
pointer-events: none;
font-size: 1.2em;
padding-top: 0.16em;
.filters .select-wrapper {
width: auto;
}
.select-wrapper select {
padding: 9px 8px;
box-sizing: border-box;
width: 100%;
border: none;
box-shadow: none;
background: transparent;
background-image: none;
-webkit-appearance: none;
-moz-appearance: none;
height: var(--filter-control-height);
border: 1px solid var(--filter-control-border, #ccc);
border-radius: 5px;
background-color: #fff;
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25;
padding: 7px 8px;
}
.select-wrapper select {
font-size: 1em;
font-family: Helvetica, sans-serif;
}
.select-wrapper option {
font-size: 1em;
font-family: Helvetica, sans-serif;
}
.select-wrapper select:focus {
border-color: var(--filter-accent, #000);
box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14);
outline: none;
}
.filters {
font-size: 0.8em;
}
.filters input.filter-value {
width: 200px;
border-radius: 3px;
-webkit-appearance: none;
padding: 9px 4px;
font-size: 16px;
font-family: Helvetica, sans-serif;
box-sizing: border-box;
width: 100%;
min-width: 0;
height: var(--filter-control-height);
border: 1px solid var(--filter-control-border);
border-radius: 5px;
background: #fff;
color: inherit;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25;
padding: 7px 9px;
}
.filters input.filter-value:focus {
border-color: var(--filter-accent);
box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14);
outline: none;
}
.filters input[type=submit] {
border-color: var(--filter-accent);
background: var(--filter-accent);
border-radius: 5px;
font-weight: 500;
padding: 0.55rem 0.85rem;
}
.filters input[type=submit]:hover,
.filters input[type=submit]:focus {
background: #1949b8;
border-color: #1949b8;
}
.filters button.filter-row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: var(--filter-row-icon-size);
height: var(--filter-row-icon-size);
min-width: var(--filter-row-icon-size);
padding: 0;
border: 1px solid var(--filter-rule);
border-radius: 5px;
background: #fff;
color: var(--filter-muted);
font-family: inherit;
font-size: 1.15rem;
font-weight: 600;
line-height: 1;
}
.filters button.filter-row-icon[hidden] {
display: none;
}
.filters button.filter-row-icon:focus-visible {
outline: 3px solid rgba(26, 86, 219, 0.14);
outline-offset: 2px;
}
.filters .filter-row-remove-icon {
display: block;
height: 14px;
width: 14px;
}
.filters button.filter-row-remove:hover,
.filters button.filter-row-remove:focus {
border-color: #c9d5e3;
background: #f8fbff;
color: var(--filter-ink);
}
.filters button.filter-row-add {
border-color: var(--filter-accent);
background: var(--filter-accent);
color: #fff;
}
.filters .filter-row-add-icon {
display: block;
height: 16px;
width: 16px;
}
.filters button.filter-row-add:hover,
.filters button.filter-row-add:focus {
border-color: #1949b8;
background: #1949b8;
color: #fff;
}
.filters button.filter-row-add:focus-visible svg {
color: #fff;
stroke: currentColor;
}
#_search {
font-size: 16px;
}
.filters #_search {
box-sizing: border-box;
width: 100%;
height: var(--filter-control-height);
border: 1px solid var(--filter-control-border);
border-radius: 5px;
background: #fff;
color: var(--filter-ink);
font: inherit;
padding: 7px 9px;
}
.filters #_search:focus {
border-color: var(--filter-accent);
box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14);
outline: none;
}
@ -3021,14 +3185,56 @@ select.table-alter-input {
margin-bottom: 0.35rem;
}
.select-wrapper {
width: 100px;
.filters {
max-width: none;
padding: 0.65rem;
}
.select-wrapper.filter-op {
width: 60px;
.filters .search-row {
grid-template-columns: max-content minmax(0, 1fr);
padding-right: 0;
}
.filters .filter-controls-row {
--filter-value-button-space: 0px;
--filter-remove-button-offset: 0px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters .filter-controls-row-one-button {
--filter-value-button-space: calc(
var(--filter-row-icon-size) + var(--filter-control-gap)
);
}
.filters .filter-controls-row-two-buttons {
--filter-value-button-space: var(--filter-two-icon-space);
--filter-remove-button-offset: calc(
var(--filter-row-icon-size) + var(--filter-control-gap)
);
}
.filters .filter-controls-row .select-wrapper {
grid-row: 1;
grid-column: 1 / 2;
}
.filters .filter-controls-row .select-wrapper.filter-op {
grid-column: 2 / 3;
}
.filters input.filter-value {
width: 140px;
grid-row: 2;
grid-column: 1 / 3;
justify-self: start;
width: calc(100% - var(--filter-value-button-space));
}
.filters button.filter-row-icon {
grid-row: 2;
grid-column: 1 / 3;
justify-self: end;
}
.filters .filter-controls-row-two-buttons button.filter-row-remove:not([hidden]) {
margin-right: var(--filter-remove-button-offset);
}
.filters .filter-actions-row {
justify-content: flex-start;
}
.filters .filter-actions-row input[type=submit] {
flex: 0 1 auto;
}
button.choose-columns-mobile,
button.table-insert-row,

View file

@ -633,32 +633,151 @@ const initDatasetteTable = function (manager) {
});
};
/* Add x buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var x = "✖";
var rows = Array.from(
document.querySelectorAll(manager.selectors.filterRow),
function filterRowSelector(manager) {
return manager.selectors.filterRows || manager.selectors.filterRow;
}
function filterRowsWithControls(manager) {
return Array.from(
document.querySelectorAll(filterRowSelector(manager)),
).filter((el) => el.querySelector(".filter-op"));
rows.forEach((row) => {
var a = document.createElement("a");
a.setAttribute("href", "#");
a.setAttribute("aria-label", "Remove this filter");
a.style.textDecoration = "none";
a.innerText = x;
a.addEventListener("click", (ev) => {
ev.preventDefault();
let row = ev.target.closest("div");
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
ev.target.closest("a").style.display = "none";
});
row.appendChild(a);
}
function filterRowNumberFromName(name) {
var match = name && name.match(/^_filter_column_(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
}
function nextFilterRowNumber(manager) {
return filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
if (!column.value) {
a.style.display = "none";
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1;
}
function setFilterRowNumber(row, number) {
row.querySelector("select").name = `_filter_column_${number}`;
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
}
function resetFilterRow(row) {
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
}
function updateFilterRowButtons(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row, index) => {
var removeButton = row.querySelector(".filter-row-remove");
var addButton = row.querySelector(".filter-row-add");
var column = row.querySelector("select");
if (removeButton) {
removeButton.hidden = index === 0;
}
if (addButton) {
addButton.hidden = index !== rows.length - 1 || !column.value;
}
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
return button && !button.hidden;
}).length;
row.classList.toggle(
"filter-controls-row-has-buttons",
visibleButtonCount > 0,
);
row.classList.toggle(
"filter-controls-row-one-button",
visibleButtonCount === 1,
);
row.classList.toggle(
"filter-controls-row-two-buttons",
visibleButtonCount === 2,
);
});
}
function cloneFilterRow(row) {
var clone = row.cloneNode(true);
clone.querySelector("select").name = "_filter_column";
clone.querySelector(".filter-op select").name = "_filter_op";
clone.querySelector("input.filter-value").name = "_filter_value";
resetFilterRow(clone);
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
return clone;
}
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>`;
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>`;
function addFilterRowButtons(row, manager) {
var removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "filter-row-icon filter-row-remove";
removeButton.setAttribute("aria-label", "Remove this filter");
removeButton.title = "Remove this filter";
removeButton.tabIndex = 0;
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
removeButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
var rows = filterRowsWithControls(manager);
var rowIndex = rows.indexOf(row);
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
row.remove();
updateFilterRowButtons(manager);
if (focusRow) {
var focusTarget =
focusRow.querySelector(".filter-row-add:not([hidden])") ||
focusRow.querySelector("select");
if (focusTarget) {
focusTarget.focus();
}
}
});
row.appendChild(removeButton);
var addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "filter-row-icon filter-row-add";
addButton.setAttribute("aria-label", "Add another filter");
addButton.title = "Add another filter";
addButton.tabIndex = 0;
addButton.innerHTML = FILTER_ADD_ICON_SVG;
addButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
if (row.querySelector("select").name === "_filter_column") {
setFilterRowNumber(row, nextFilterRowNumber(manager));
}
var clone = cloneFilterRow(row);
addFilterRowButtons(clone, manager);
row.parentNode.insertBefore(clone, row.nextSibling);
updateFilterRowButtons(manager);
clone.querySelector("select").focus();
});
row.appendChild(addButton);
row.querySelector("select").addEventListener("change", () => {
updateFilterRowButtons(manager);
});
}
/* Add buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row) => {
addFilterRowButtons(row, manager);
});
updateFilterRowButtons(manager);
}
/* Set up datalist autocomplete for filter values */
@ -687,11 +806,11 @@ function initAutocompleteForFilterValues(manager) {
});
}
createDataLists();
// When any select with name=_filter_column changes, update the datalist
// When any filter column select changes, update the datalist
document.body.addEventListener("change", function (event) {
if (event.target.name === "_filter_column") {
if (event.target.name && event.target.name.startsWith("_filter_column")) {
event.target
.closest(manager.selectors.filterRow)
.closest(filterRowSelector(manager))
.querySelector(".filter-value")
.setAttribute("list", "datalist-" + event.target.value);
}

View file

@ -202,9 +202,9 @@
<h3>3 rows
where characteristic_id = 2
</h3>
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_1">
<option value="">- remove filter -</option>
@ -238,7 +238,7 @@
</select>
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
</div>
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -272,8 +272,8 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="select-wrapper small-screen-only">
<div class="filter-row filter-actions-row">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
<option value="rowid" selected>Sort by rowid</option>
@ -281,8 +281,8 @@
<option value="characteristic_id">Sort by characteristic_id</option>
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply">
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply filters">
</div>
</form>

View file

@ -55,12 +55,12 @@
</h3>
{% endif %}
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %}
{% for column, lookup, value in filters.selections() %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option>
@ -77,7 +77,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div>
{% endfor %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -93,9 +93,9 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="filter-row filter-actions-row">
{% if is_sortable %}
<div class="select-wrapper small-screen-only">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
{% for column in display_columns %}
@ -105,12 +105,12 @@
{% endfor %}
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
{% endif %}
{% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<input type="submit" value="Apply">
<input type="submit" value="Apply filters" tabindex="0">
</div>
</form>