Remove the bespoke SelectableFilter widget
FilterSelect fully replaces it: delete SelectableFilter and its _selectable_* helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-* rules in input.css (rebuilt base.css). The three list views load search_select.js instead of selectable_filter.js. Drop the SelectableFilter export and refresh docs/comments that referenced it. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -64,8 +64,8 @@ docs/ — Additional documentation
|
||||
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
|
||||
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
|
||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()`, `SelectableFilter()` (clickable include/exclude chips)
|
||||
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||
|
||||
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
|
||||
|
||||
@@ -118,8 +118,7 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||
- **Custom JS** in `games/static/js/`:
|
||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
||||
- `selectable_filter.js` — SelectableFilter widget interaction
|
||||
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
|
||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||
|
||||
### Deployment
|
||||
|
||||
@@ -59,7 +59,6 @@ from common.components.domain import (
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SelectableFilter,
|
||||
SessionFilterBar,
|
||||
)
|
||||
|
||||
@@ -109,6 +108,5 @@ __all__ = [
|
||||
"_resolve_name_with_icon",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SelectableFilter",
|
||||
"SessionFilterBar",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Stash-style filter bars and the SelectableFilter widget."""
|
||||
"""Stash-style filter bars, built from FilterSelect widgets."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -12,7 +12,7 @@ from common.components.search_select import FilterSelect
|
||||
|
||||
|
||||
class FilterChoice(NamedTuple):
|
||||
"""Parsed state of a SelectableFilter widget from a filter JSON blob."""
|
||||
"""Parsed include/exclude/modifier state of a filter field from filter JSON."""
|
||||
|
||||
selected: list[str]
|
||||
excluded: list[str]
|
||||
@@ -84,20 +84,6 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
||||
return bool(field.get("value", False))
|
||||
|
||||
|
||||
def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]:
|
||||
"""Return (value, label) pairs for a SelectableFilter from model rows.
|
||||
|
||||
Uses values_list for efficiency (only fetches needed columns),
|
||||
but unpacks each row into readable local variables.
|
||||
"""
|
||||
options: list[tuple[str, str]] = []
|
||||
for object_id, object_name in model_class.objects.order_by(order_by).values_list(
|
||||
"id", order_by
|
||||
):
|
||||
options.append((str(object_id), object_name))
|
||||
return options
|
||||
|
||||
|
||||
# ── FilterSelect adapters ────────────────────────────────────────────────────
|
||||
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
|
||||
# option set; model-backed fields fetch from a search endpoint and only resolve
|
||||
@@ -742,189 +728,6 @@ def FilterBar(
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def _selectable_filter_tag(
|
||||
value: str, label: str, *, excluded: bool = False
|
||||
) -> SafeText:
|
||||
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
|
||||
checkmark = "\u2717" if excluded else "\u2713"
|
||||
css = "sf-tag sf-excluded" if excluded else "sf-tag"
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", css),
|
||||
("data-value", value),
|
||||
("data-type", "exclude" if excluded else "include"),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", "sf-tag-text")],
|
||||
children=[f"{checkmark} {label}"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("class", "sf-remove"),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["\u00d7"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
|
||||
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", "sf-modifier-tag active"),
|
||||
("data-modifier", modifier),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
|
||||
"""A modifier choice in the SelectableFilter dropdown list."""
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("class", "sf-option sf-modifier-option"),
|
||||
("data-modifier", modifier),
|
||||
("data-label", label),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", "sf-option-label")],
|
||||
children=[label],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _selectable_filter_option(value: str, label: str) -> SafeText:
|
||||
"""An option row with include (+) and exclude (\u2212) buttons."""
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("class", "sf-option"),
|
||||
("data-value", value),
|
||||
("data-label", label),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", "sf-option-label")],
|
||||
children=[label],
|
||||
),
|
||||
Span(
|
||||
attributes=[("class", "sf-option-buttons")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("class", "sf-btn-include"),
|
||||
("data-action", "include"),
|
||||
("title", "Include"),
|
||||
],
|
||||
children=["+"],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("class", "sf-btn-exclude"),
|
||||
("data-action", "exclude"),
|
||||
("title", "Exclude"),
|
||||
],
|
||||
children=["\u2212"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def SelectableFilter(
|
||||
field_name: str,
|
||||
options: list[tuple[str, str]],
|
||||
selected: list[str] | None = None,
|
||||
excluded: list[str] | None = None,
|
||||
modifier: str = "",
|
||||
nullable: bool = True,
|
||||
) -> "SafeText":
|
||||
"""Stash-style selectable filter with search, include/exclude, modifier tags."""
|
||||
selected = selected or []
|
||||
excluded = excluded or []
|
||||
|
||||
modifier_options = [("NOT_NULL", "(Any)")]
|
||||
if nullable:
|
||||
modifier_options.append(("IS_NULL", "(None)"))
|
||||
|
||||
active_modifier_tag = ""
|
||||
inactive_modifier_options: list[SafeText] = []
|
||||
for modifier_value, modifier_label in modifier_options:
|
||||
if modifier == modifier_value:
|
||||
active_modifier_tag = _selectable_filter_modifier_tag(
|
||||
modifier_value, modifier_label
|
||||
)
|
||||
else:
|
||||
inactive_modifier_options.append(
|
||||
_selectable_filter_modifier_option(modifier_value, modifier_label)
|
||||
)
|
||||
|
||||
selected_tags: list[SafeText] = []
|
||||
for value in selected:
|
||||
selected_tags.append(
|
||||
_selectable_filter_tag(value, _find_label(options, value), excluded=False)
|
||||
)
|
||||
for value in excluded:
|
||||
selected_tags.append(
|
||||
_selectable_filter_tag(value, _find_label(options, value), excluded=True)
|
||||
)
|
||||
|
||||
option_rows: list[SafeText] = []
|
||||
for value, label in options:
|
||||
option_rows.append(_selectable_filter_option(value, label))
|
||||
|
||||
selected_area_children: list[SafeText] = []
|
||||
if active_modifier_tag:
|
||||
selected_area_children.append(active_modifier_tag)
|
||||
selected_area_children.extend(selected_tags)
|
||||
|
||||
options_area_children: list[SafeText] = []
|
||||
options_area_children.extend(inactive_modifier_options)
|
||||
options_area_children.extend(option_rows)
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("class", "sf-container"),
|
||||
("data-selectable-filter", field_name),
|
||||
*([("data-modifier", modifier)] if modifier else []),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "sf-selected")],
|
||||
children=selected_area_children,
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("class", "sf-search"),
|
||||
("placeholder", "Search\u2026"),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "sf-options")],
|
||||
children=options_area_children,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _find_label(options: list[tuple[str, str]], value: str) -> str:
|
||||
for v, label in options:
|
||||
if str(v) == str(value):
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ class MultiCriterion(_Criterion):
|
||||
class ChoiceCriterion(_Criterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by SelectableFilter widgets for status, ownership_type, etc.
|
||||
Used by FilterSelect widgets for status, ownership_type, etc.
|
||||
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
|
||||
"""
|
||||
|
||||
|
||||
@@ -232,48 +232,3 @@ textarea:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* SelectableFilter widget styling */
|
||||
.sf-container {
|
||||
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
|
||||
}
|
||||
.sf-selected {
|
||||
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
|
||||
}
|
||||
.sf-tag {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
|
||||
}
|
||||
.sf-tag.sf-excluded {
|
||||
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
|
||||
}
|
||||
.sf-remove {
|
||||
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
|
||||
}
|
||||
.sf-modifier-tag {
|
||||
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
|
||||
}
|
||||
.sf-search {
|
||||
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
|
||||
&:focus {
|
||||
@apply ring-0 outline-hidden;
|
||||
}
|
||||
}
|
||||
.sf-options {
|
||||
@apply max-h-40 overflow-y-auto p-1 text-body;
|
||||
}
|
||||
.sf-option {
|
||||
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
|
||||
}
|
||||
.sf-option-label {
|
||||
@apply truncate;
|
||||
}
|
||||
.sf-option-buttons {
|
||||
@apply flex gap-1 ml-2 shrink-0;
|
||||
}
|
||||
.sf-btn-include,
|
||||
.sf-btn-exclude {
|
||||
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
|
||||
}
|
||||
.sf-modifier-option {
|
||||
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -4429,171 +4429,6 @@ form input:disabled, select:disabled, textarea:disabled {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.sf-container {
|
||||
border-radius: var(--radius-base);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
}
|
||||
.sf-selected {
|
||||
display: flex;
|
||||
min-height: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--spacing) * 1);
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
.sf-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 1);
|
||||
border-radius: var(--radius);
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-heading);
|
||||
}
|
||||
.sf-tag.sf-excluded {
|
||||
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
|
||||
}
|
||||
color: var(--color-red-600);
|
||||
text-decoration-line: line-through;
|
||||
text-decoration-color: var(--color-red-400);
|
||||
}
|
||||
.sf-remove {
|
||||
margin-left: calc(var(--spacing) * 1);
|
||||
cursor: pointer;
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-body);
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-heading);
|
||||
}
|
||||
}
|
||||
}
|
||||
.sf-modifier-tag {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border-radius: var(--radius);
|
||||
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
||||
}
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-amber-600);
|
||||
}
|
||||
.sf-search {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
border-top-style: var(--tw-border-style);
|
||||
border-top-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: transparent;
|
||||
padding: calc(var(--spacing) * 2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-heading);
|
||||
&:focus {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.sf-options {
|
||||
max-height: calc(var(--spacing) * 40);
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--spacing) * 1);
|
||||
color: var(--color-body);
|
||||
}
|
||||
.sf-option {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--radius);
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-neutral-secondary-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
.sf-option-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sf-option-buttons {
|
||||
margin-left: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.sf-btn-include, .sf-btn-exclude {
|
||||
display: flex;
|
||||
height: calc(var(--spacing) * 5);
|
||||
width: calc(var(--spacing) * 5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
.sf-modifier-option {
|
||||
cursor: pointer;
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-body);
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-neutral-secondary-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
|
||||
appearance: none;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
||||
* each widget guarded with el._ssInit.
|
||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||
* el._ssInit.
|
||||
*
|
||||
* The pill / option class strings below are kept byte-identical to the Python
|
||||
* Pill / SearchSelect / FilterSelect components so Tailwind generates the classes
|
||||
@@ -496,8 +496,8 @@
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets (parallel to readSelectableFilters) expose data-included /
|
||||
// data-excluded / data-modifier for the filter bar to read.
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
window.readSearchSelect = function (form) {
|
||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||
var pills = container.querySelector("[data-ss-pills]");
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* SelectableFilter widget — Stash-style choice filter with search,
|
||||
* include/exclude buttons, and modifier tags (Any / None).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
|
||||
if (el._sfInit) return;
|
||||
el._sfInit = true;
|
||||
initWidget(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector(".sf-search");
|
||||
var options = container.querySelector(".sf-options");
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
if (!search || !options || !selectedArea) return;
|
||||
|
||||
// ── Search ──
|
||||
search.addEventListener("input", function () {
|
||||
var q = search.value.toLowerCase();
|
||||
options.querySelectorAll(".sf-option").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ── Include / Exclude clicks ──
|
||||
options.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("button");
|
||||
if (btn) {
|
||||
var action = btn.getAttribute("data-action");
|
||||
var itemEl = btn.closest(".sf-option");
|
||||
if (!itemEl) return;
|
||||
var value = itemEl.getAttribute("data-value");
|
||||
var label = itemEl.getAttribute("data-label");
|
||||
if (!value) return;
|
||||
if (action === "include") addTag(container, value, label, "include");
|
||||
else if (action === "exclude") addTag(container, value, label, "exclude");
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on modifier option (not a button)
|
||||
var modOption = e.target.closest(".sf-modifier-option");
|
||||
if (modOption) {
|
||||
var modVal = modOption.getAttribute("data-modifier");
|
||||
setModifier(container, modVal);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Remove selected tag ──
|
||||
selectedArea.addEventListener("click", function (e) {
|
||||
var removeBtn = e.target.closest(".sf-remove");
|
||||
if (removeBtn) {
|
||||
removeBtn.closest(".sf-tag").remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on active modifier tag → deselect it
|
||||
var modTag = e.target.closest(".sf-modifier-tag");
|
||||
if (modTag) {
|
||||
clearModifier(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Add a tag to the selected area and clear modifier. */
|
||||
function addTag(container, value, label, type) {
|
||||
clearModifier(container);
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Check if already present
|
||||
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
|
||||
if (existing) {
|
||||
if (existing.getAttribute("data-type") !== type) {
|
||||
existing.setAttribute("data-type", type);
|
||||
existing.classList.toggle("sf-excluded", type === "exclude");
|
||||
var text = existing.querySelector(".sf-tag-text");
|
||||
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
|
||||
tag.setAttribute("data-value", value);
|
||||
tag.setAttribute("data-type", type);
|
||||
tag.innerHTML =
|
||||
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
|
||||
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
|
||||
selectedArea.appendChild(tag);
|
||||
}
|
||||
|
||||
/** Set a modifier (Any / None) — clears all tags. */
|
||||
function setModifier(container, modVal) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
|
||||
// Clear all tags
|
||||
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Clear existing modifier tag
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
|
||||
// Add new modifier tag
|
||||
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "sf-modifier-tag active";
|
||||
tag.setAttribute("data-modifier", modVal);
|
||||
tag.textContent = label;
|
||||
selectedArea.appendChild(tag);
|
||||
|
||||
container.setAttribute("data-modifier", modVal);
|
||||
}
|
||||
|
||||
/** Clear any active modifier, removing the tag. */
|
||||
function clearModifier(container) {
|
||||
var selectedArea = container.querySelector(".sf-selected");
|
||||
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
|
||||
// Read selections for form submission
|
||||
window.readSelectableFilters = function (form) {
|
||||
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
|
||||
var modifier = container.getAttribute("data-modifier");
|
||||
var modTag = container.querySelector(".sf-modifier-tag.active");
|
||||
if (modTag) modifier = modTag.getAttribute("data-modifier");
|
||||
|
||||
var included = [];
|
||||
var excluded = [];
|
||||
container.querySelectorAll(".sf-tag").forEach(function (tag) {
|
||||
var val = tag.getAttribute("data-value");
|
||||
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
|
||||
else included.push(val);
|
||||
});
|
||||
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
})();
|
||||
+1
-1
@@ -149,7 +149,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
content,
|
||||
title="Manage games",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("selectable_filter.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
content,
|
||||
title="Manage purchases",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("selectable_filter.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
content,
|
||||
title="Manage sessions",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("selectable_filter.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user