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.
|
- **`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()`
|
- **`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()`
|
- **`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)
|
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||||
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, wired by `games/static/js/search_select.js`
|
- **`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.
|
**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`
|
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||||
- **Custom JS** in `games/static/js/`:
|
- **Custom JS** in `games/static/js/`:
|
||||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
||||||
- `selectable_filter.js` — SelectableFilter widget interaction
|
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||||
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
|
|
||||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ from common.components.domain import (
|
|||||||
from common.components.filters import (
|
from common.components.filters import (
|
||||||
FilterBar,
|
FilterBar,
|
||||||
PurchaseFilterBar,
|
PurchaseFilterBar,
|
||||||
SelectableFilter,
|
|
||||||
SessionFilterBar,
|
SessionFilterBar,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,6 +108,5 @@ __all__ = [
|
|||||||
"_resolve_name_with_icon",
|
"_resolve_name_with_icon",
|
||||||
"FilterBar",
|
"FilterBar",
|
||||||
"PurchaseFilterBar",
|
"PurchaseFilterBar",
|
||||||
"SelectableFilter",
|
|
||||||
"SessionFilterBar",
|
"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
|
from typing import NamedTuple
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from common.components.search_select import FilterSelect
|
|||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
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]
|
selected: list[str]
|
||||||
excluded: list[str]
|
excluded: list[str]
|
||||||
@@ -84,20 +84,6 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
|||||||
return bool(field.get("value", False))
|
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 ────────────────────────────────────────────────────
|
# ── FilterSelect adapters ────────────────────────────────────────────────────
|
||||||
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
|
# 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
|
# 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)
|
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:
|
def _find_label(options: list[tuple[str, str]], value: str) -> str:
|
||||||
for v, label in options:
|
for v, label in options:
|
||||||
if str(v) == str(value):
|
if str(v) == str(value):
|
||||||
|
|||||||
+1
-1
@@ -299,7 +299,7 @@ class MultiCriterion(_Criterion):
|
|||||||
class ChoiceCriterion(_Criterion):
|
class ChoiceCriterion(_Criterion):
|
||||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
"""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.
|
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);
|
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 {
|
@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 {
|
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;
|
appearance: none;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
*
|
*
|
||||||
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
|
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||||
* each widget guarded with el._ssInit.
|
* el._ssInit.
|
||||||
*
|
*
|
||||||
* The pill / option class strings below are kept byte-identical to the Python
|
* The pill / option class strings below are kept byte-identical to the Python
|
||||||
* Pill / SearchSelect / FilterSelect components so Tailwind generates the classes
|
* 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.
|
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||||
// widgets (parallel to readSelectableFilters) expose data-included /
|
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||||
// data-excluded / data-modifier for the filter bar to read.
|
// bar to read.
|
||||||
window.readSearchSelect = function (form) {
|
window.readSearchSelect = function (form) {
|
||||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||||
var pills = container.querySelector("[data-ss-pills]");
|
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,
|
content,
|
||||||
title="Manage games",
|
title="Manage games",
|
||||||
scripts=ModuleScript("range_slider.js")
|
scripts=ModuleScript("range_slider.js")
|
||||||
+ ModuleScript("selectable_filter.js")
|
+ ModuleScript("search_select.js")
|
||||||
+ ModuleScript("filter_bar.js"),
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
content,
|
content,
|
||||||
title="Manage purchases",
|
title="Manage purchases",
|
||||||
scripts=ModuleScript("range_slider.js")
|
scripts=ModuleScript("range_slider.js")
|
||||||
+ ModuleScript("selectable_filter.js")
|
+ ModuleScript("search_select.js")
|
||||||
+ ModuleScript("filter_bar.js"),
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
content,
|
content,
|
||||||
title="Manage sessions",
|
title="Manage sessions",
|
||||||
scripts=ModuleScript("range_slider.js")
|
scripts=ModuleScript("range_slider.js")
|
||||||
+ ModuleScript("selectable_filter.js")
|
+ ModuleScript("search_select.js")
|
||||||
+ ModuleScript("filter_bar.js"),
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user