diff --git a/assets/css/app.css b/assets/css/app.css index e2d3c98..0adf49b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -677,6 +677,234 @@ html { line-height: 1.4; } +.category-picker { + position: relative; + width: 100%; + min-width: 0; + margin-bottom: 0.5rem; +} + +.category-picker-ui { + display: none; +} + +.category-picker.is-enhanced .category-picker-native { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.category-picker.is-enhanced .category-picker-ui { + display: block; +} + +.category-picker-trigger { + display: flex; + width: 100%; + min-height: 3rem; + cursor: pointer; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.65rem 0.85rem; + border: var(--border) solid var(--color-base-300); + border-radius: var(--radius-field); + color: var(--color-base-content); + background: var(--color-base-100); + text-align: start; + transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease; +} + +.category-picker-trigger:hover { + border-color: color-mix(in oklab, var(--color-base-content) 35%, var(--color-base-300)); +} + +.category-picker-trigger:focus-visible, +.category-picker:focus-within .category-picker-trigger[aria-expanded="true"] { + border-color: var(--color-success); + outline: 2px solid color-mix(in oklab, var(--color-success) 35%, transparent); + outline-offset: 1px; +} + +.category-picker-error .category-picker-trigger { + border-color: var(--color-error); +} + +.category-picker-placeholder { + color: color-mix(in oklab, var(--color-base-content) 48%, transparent); +} + +.category-picker-panel { + position: absolute; + z-index: 40; + inset-block-start: calc(100% + 0.4rem); + inset-inline: 0; + overflow: hidden; + border: 1px solid var(--color-base-300); + border-radius: 0.9rem; + background: var(--color-base-100); + box-shadow: 0 1rem 2.5rem color-mix(in oklab, var(--color-base-content) 18%, transparent); +} + +.category-picker-search { + display: flex; + align-items: center; + gap: 0.65rem; + margin: 0.75rem; + padding: 0 0.75rem; + border: 1px solid var(--color-base-300); + border-radius: 0.65rem; + background: var(--color-base-200); +} + +.category-picker-search:focus-within { + border-color: var(--color-success); + outline: 2px solid color-mix(in oklab, var(--color-success) 28%, transparent); +} + +.category-picker-search input { + width: 100%; + min-width: 0; + height: 2.75rem; + border: 0; + outline: 0; + color: var(--color-base-content); + background: transparent; +} + +.category-picker-options { + max-height: min(26rem, 55vh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 0.5rem 0.65rem; +} + +.category-picker-group + .category-picker-group { + margin-top: 0.45rem; + padding-top: 0.45rem; + border-top: 1px solid var(--color-base-300); +} + +.category-picker-group-label { + padding: 0.45rem 0.65rem 0.35rem; + color: color-mix(in oklab, var(--color-base-content) 62%, transparent); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.category-picker-option { + display: flex; + width: 100%; + cursor: pointer; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 0.65rem; + border-radius: 0.6rem; + color: var(--color-base-content); + background: transparent; + text-align: start; +} + +.category-picker-option:hover, +.category-picker-option:focus-visible { + outline: 0; + background: var(--color-base-200); +} + +.category-picker-option[aria-selected="true"] { + color: var(--color-success); + background: color-mix(in oklab, var(--color-success) 10%, var(--color-base-100)); +} + +.category-picker-option span { + display: grid; + min-width: 0; + gap: 0.15rem; +} + +.category-picker-option strong { + font-size: 0.9rem; + line-height: 1.3; +} + +.category-picker-option small { + display: -webkit-box; + overflow: hidden; + color: color-mix(in oklab, var(--color-base-content) 58%, transparent); + font-size: 0.76rem; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.category-picker-check { + color: var(--color-success); +} + +.category-picker-empty { + padding: 1.5rem 1rem; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + text-align: center; +} + +.category-proposal-kind { + position: relative; + display: flex; + min-height: 5.5rem; + cursor: pointer; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--color-base-300); + border-radius: 0.85rem; + background: var(--color-base-100); + transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; +} + +.category-proposal-kind:hover { + border-color: color-mix(in oklab, var(--color-success) 55%, var(--color-base-300)); +} + +.category-proposal-kind:focus-within { + outline: 2px solid color-mix(in oklab, var(--color-success) 35%, transparent); + outline-offset: 2px; +} + +.category-proposal-kind.is-selected { + border-color: var(--color-success); + color: var(--color-success); + background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100)); +} + +.category-proposal-kind input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.category-proposal-kind > span { + display: grid; + gap: 0.2rem; +} + +.category-proposal-kind strong { + color: var(--color-base-content); + font-size: 0.9rem; +} + +.category-proposal-kind small { + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + font-size: 0.76rem; + line-height: 1.35; +} + .request-location-workspace { padding: 1.25rem; border: 1px solid var(--color-base-300); diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 08f9c0c..08c0415 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -776,6 +776,172 @@ export const mountStaticAidMaps = root => { } export const Hooks = { + SearchableCategoryPicker: { + mounted() { + this.refresh = () => { + this.el.classList.add("is-enhanced") + this.source = this.el.querySelector("[data-category-picker-source]") + this.trigger = this.el.querySelector("[data-category-picker-trigger]") + this.label = this.el.querySelector("[data-category-picker-label]") + this.panel = this.el.querySelector("[data-category-picker-panel]") + this.search = this.el.querySelector("[data-category-picker-search]") + this.options = [...this.el.querySelectorAll("[data-category-picker-option]")] + this.groups = [...this.el.querySelectorAll("[data-category-picker-group]")] + this.empty = this.el.querySelector("[data-category-picker-empty]") + if (this.source) { + this.source.hidden = true + this.source.tabIndex = -1 + this.source.setAttribute("aria-hidden", "true") + } + this.syncSelection() + } + + this.isOpen = () => this.panel && !this.panel.hidden + + this.open = () => { + if (!this.panel || !this.trigger || !this.search) return + this.panel.hidden = false + this.trigger.setAttribute("aria-expanded", "true") + this.search.setAttribute("aria-expanded", "true") + this.search.focus() + this.search.select() + } + + this.close = ({restoreFocus = false} = {}) => { + if (!this.panel || !this.trigger || !this.search) return + this.panel.hidden = true + this.trigger.setAttribute("aria-expanded", "false") + this.search.setAttribute("aria-expanded", "false") + this.search.value = "" + this.filter("") + if (restoreFocus) this.trigger.focus() + } + + this.filter = rawQuery => { + const query = rawQuery.trim().toLocaleLowerCase() + let visibleCount = 0 + + this.groups.forEach(group => { + let groupVisible = false + const groupMatches = (group.dataset.searchText || "").includes(query) + + group.querySelectorAll("[data-category-picker-option]").forEach(option => { + const visible = query === "" || + groupMatches || + (option.dataset.searchText || "").includes(query) + option.hidden = !visible + groupVisible ||= visible + if (visible) visibleCount += 1 + }) + + group.hidden = !groupVisible + }) + + if (this.empty) this.empty.hidden = visibleCount !== 0 + } + + this.syncSelection = () => { + if (!this.source || !this.trigger || !this.label) return + + const selected = this.options.find(option => option.dataset.value === this.source.value) + const prompt = this.source.options[0]?.textContent?.trim() || "" + + this.label.textContent = selected?.dataset.label || prompt + this.label.classList.toggle("category-picker-placeholder", !selected) + + this.options.forEach(option => { + const active = option === selected + option.setAttribute("aria-selected", String(active)) + option.querySelector(".category-picker-check")?.classList.toggle("invisible", !active) + }) + } + + this.select = option => { + if (!this.source) return + + const value = option.dataset.value || "" + const changed = this.source.value !== value + this.source.value = value + this.syncSelection() + this.close({restoreFocus: true}) + + if (changed) { + this.source.dispatchEvent(new Event("input", {bubbles: true})) + } + } + + this.onClick = event => { + const trigger = event.target.closest("[data-category-picker-trigger]") + + if (trigger && this.el.contains(trigger)) { + event.preventDefault() + this.isOpen() ? this.close() : this.open() + return + } + + const option = event.target.closest("[data-category-picker-option]") + if (option && this.el.contains(option)) { + event.preventDefault() + this.select(option) + } + } + + this.onInput = event => { + if (!event.target.matches("[data-category-picker-search]")) return + event.stopPropagation() + this.filter(event.target.value) + } + + this.onKeydown = event => { + if ( + event.target.matches("[data-category-picker-trigger]") && + ["ArrowDown", "Enter", " "].includes(event.key) + ) { + event.preventDefault() + this.open() + return + } + + if (!event.target.matches("[data-category-picker-search]")) return + + if (event.key === "Escape") { + event.preventDefault() + this.close({restoreFocus: true}) + return + } + + if (event.key === "ArrowDown") { + event.preventDefault() + this.options.find(option => !option.hidden && !option.closest("section")?.hidden)?.focus() + } + } + + this.onDocumentPointerDown = event => { + if (this.isOpen() && !this.el.contains(event.target)) this.close() + } + + this.el.addEventListener("click", this.onClick) + this.el.addEventListener("input", this.onInput) + this.el.addEventListener("keydown", this.onKeydown) + document.addEventListener("pointerdown", this.onDocumentPointerDown) + this.refresh() + }, + updated() { + this.refresh() + }, + destroyed() { + if (this.source) { + this.source.hidden = false + this.source.removeAttribute("aria-hidden") + this.source.removeAttribute("tabindex") + } + this.el.removeEventListener("click", this.onClick) + this.el.removeEventListener("input", this.onInput) + this.el.removeEventListener("keydown", this.onKeydown) + document.removeEventListener("pointerdown", this.onDocumentPointerDown) + } + }, + AccessibleDisclosure: { mounted() { this.summary = this.el.querySelector(":scope > summary") diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index dba51b5..0acc341 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -27,9 +27,8 @@ defmodule WhoNeedHelp.Catalog do def list_proposal_parents(mode) when mode in [:help, :activity] do Category - |> where([category], category.mode == ^mode) + |> where([category], category.mode == ^mode and is_nil(category.parent_id)) |> order_by([category], asc: category.sort_order, asc: category.slug) - |> preload(:parent) |> Repo.all() end @@ -469,7 +468,7 @@ defmodule WhoNeedHelp.Catalog do case attrs["parent_id"] do parent_id when parent_id not in [nil, ""] -> with {:ok, parent_id} <- cast_id(parent_id), - %Category{} = parent <- Repo.get(Category, parent_id) do + %Category{parent_id: nil} = parent <- Repo.get(Category, parent_id) do {:ok, Map.put(attrs, "mode", to_string(parent.mode))} else _invalid_or_missing -> {:error, :invalid_parent} diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex index 550036d..8ff944a 100644 --- a/lib/who_need_help/catalog/category_proposal.ex +++ b/lib/who_need_help/catalog/category_proposal.ex @@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do field :proposed_name, :string field :reason, :string field :mode, Ecto.Enum, values: [:help, :activity], default: :help + field :kind, Ecto.Enum, values: [:category, :subcategory], virtual: true field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open belongs_to :proposer, WhoNeedHelp.Accounts.User belongs_to :parent, WhoNeedHelp.Catalog.Category @@ -24,11 +25,14 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do end def changeset(proposal, attrs) do + attrs = normalize_kind(attrs) + proposal - |> cast(attrs, [:proposed_name, :reason, :mode, :parent_id]) - |> validate_required([:proposed_name, :reason, :mode]) + |> cast(attrs, [:proposed_name, :reason, :mode, :kind, :parent_id]) + |> validate_required([:proposed_name, :reason, :mode, :kind]) |> validate_length(:proposed_name, min: 2, max: 80) |> validate_length(:reason, min: 5, max: 500) + |> validate_parent_for_kind() end def moderation_changeset(proposal, attrs) do @@ -37,4 +41,34 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do |> validate_required([:status, :reviewed_by_id, :reviewed_at]) |> validate_length(:moderation_note, max: 1_000) end + + defp normalize_kind(attrs) when is_map(attrs) do + string_attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end) + + kind = + case string_attrs["kind"] do + kind when kind in ["category", "subcategory", :category, :subcategory] -> + kind + + _missing_or_invalid -> + if string_attrs["parent_id"] in [nil, ""], do: "category", else: "subcategory" + end + + Map.put(string_attrs, "kind", kind) + end + + defp validate_parent_for_kind(changeset) do + case get_field(changeset, :kind) do + :category -> + put_change(changeset, :parent_id, nil) + + :subcategory -> + validate_required(changeset, [:parent_id], + message: "choose the main category for this subcategory" + ) + + _invalid_or_missing -> + changeset + end + end end diff --git a/lib/who_need_help_web/components/core_components.ex b/lib/who_need_help_web/components/core_components.ex index 0084db2..2162580 100644 --- a/lib/who_need_help_web/components/core_components.ex +++ b/lib/who_need_help_web/components/core_components.ex @@ -30,6 +30,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do use Gettext, backend: WhoNeedHelpWeb.Gettext alias Phoenix.LiveView.JS + alias WhoNeedHelp.Catalog.Category @doc """ Renders flash notices. @@ -176,6 +177,207 @@ defmodule WhoNeedHelpWeb.CoreComponents do """ end + @doc """ + Renders an accessible searchable category picker. + + Categories with a parent are grouped under that parent. Active standalone + categories remain selectable, so community-approved top-level categories do + not require a separate rendering path. + """ + attr :field, Phoenix.HTML.FormField, required: true + attr :categories, :list, required: true + attr :label, :string, required: true + attr :prompt, :string, required: true + attr :search_placeholder, :string, required: true + attr :empty_message, :string, required: true + attr :id, :string, default: nil + + def category_picker(assigns) do + locale = Gettext.get_locale(WhoNeedHelpWeb.Gettext) + field = assigns.field + selected_id = if field.value in [nil, ""], do: "", else: to_string(field.value) + groups = category_picker_groups(assigns.categories, locale) + + selected_label = + groups + |> Enum.flat_map(& &1.options) + |> Enum.find_value(assigns.prompt, fn option -> + if option.id == selected_id, do: option.path + end) + + errors = + if Phoenix.Component.used_input?(field), + do: Enum.map(field.errors, &translate_error(&1)), + else: [] + + assigns = + assigns + |> assign(:picker_id, assigns.id || "#{field.id}-picker") + |> assign(:field_id, field.id) + |> assign(:field_name, field.name) + |> assign(:selected_id, selected_id) + |> assign(:selected_label, selected_label) + |> assign(:groups, groups) + |> assign(:errors, errors) + + ~H""" +
params}, socket) do mode = proposal_mode(params["mode"]) + kind = proposal_kind(params["kind"]) + params = if kind == :category, do: Map.put(params, "parent_id", ""), else: params form = %CategoryProposal{} @@ -40,6 +42,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do socket |> assign(:form, form) |> assign(:proposal_mode, mode) + |> assign(:proposal_kind, kind) |> assign(:parent_categories, Catalog.list_proposal_parents(mode))} end @@ -91,20 +94,29 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do defp load(socket) do proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope) mode = socket.assigns[:proposal_mode] || :help + kind = socket.assigns[:proposal_kind] || :subcategory socket |> assign(:page_title, gettext("Category proposals")) |> assign(:proposals, proposals.entries) |> assign(:proposals_cursor, proposals.next_cursor) |> assign(:proposal_mode, mode) + |> assign(:proposal_kind, kind) |> assign(:parent_categories, Catalog.list_proposal_parents(mode)) - |> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{mode: mode}))) + |> assign( + :form, + to_form(Catalog.change_proposal(%CategoryProposal{mode: mode, kind: kind})) + ) end defp proposal_mode("activity"), do: :activity defp proposal_mode(:activity), do: :activity defp proposal_mode(_mode), do: :help + defp proposal_kind("category"), do: :category + defp proposal_kind(:category), do: :category + defp proposal_kind(_kind), do: :subcategory + @impl true def render(assigns) do ~H""" @@ -127,33 +139,73 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6" > - <.input - field={@form[:proposed_name]} - label={gettext("Proposed category")} - placeholder={gettext("Bicycle puncture")} - /> <.input field={@form[:mode]} type="select" - label={gettext("Mode")} + label={gettext("Where should it appear?")} options={[ {gettext("Urgent help"), "help"}, {gettext("Social activity"), "activity"} ]} /> - <.input +
+ <.category_picker + :if={@proposal_kind == :subcategory} + id="category-proposal-parent-picker" field={@form[:parent_id]} - type="select" - label={gettext("Parent (optional)")} - prompt={gettext("Top level")} - options={ - Enum.map( - @parent_categories, - &{WhoNeedHelp.Catalog.category_path( - &1, - Gettext.get_locale(WhoNeedHelpWeb.Gettext) - ), &1.id} - ) + categories={@parent_categories} + label={gettext("Main category")} + prompt={gettext("Choose the main category")} + search_placeholder={gettext("Search main categories")} + empty_message={gettext("No matching main category")} + /> + <.input + field={@form[:proposed_name]} + label={ + if @proposal_kind == :subcategory, + do: gettext("Subcategory name"), + else: gettext("Category name") + } + placeholder={ + if @proposal_kind == :subcategory, + do: gettext("Bicycle puncture"), + else: gettext("Home help") } /> <.input @@ -183,6 +235,18 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do do: gettext("Urgent help"), else: gettext("Social activity")} + + {if proposal.parent, + do: + gettext("Subcategory of %{category}", + category: + WhoNeedHelp.Catalog.Category.name( + proposal.parent, + Gettext.get_locale(WhoNeedHelpWeb.Gettext) + ) + ), + else: gettext("Main category")} +{proposal.reason}
{gettext("by %{name}", name: proposal.proposer.display_name)} diff --git a/lib/who_need_help_web/live/request_live/new.ex b/lib/who_need_help_web/live/request_live/new.ex index fa6e955..f341e15 100644 --- a/lib/who_need_help_web/live/request_live/new.ex +++ b/lib/who_need_help_web/live/request_live/new.ex @@ -296,21 +296,20 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
Enum.find(&(&1.parent_id == roadside_parent.id)) + + assert {:error, :invalid_parent} = + Catalog.propose(context.requester_scope, %{ + "proposed_name" => "Too deeply nested", + "parent_id" => roadside_child.id, + "reason" => "This proposal must not create a third taxonomy level." + }) end test "tracking derives movement and proximity from browser accuracy envelopes", context do diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index a4f7070..b28c5e2 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -1413,6 +1413,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do category_proposal: %{ proposed_name: "Chain repair", mode: "help", + kind: "category", reason: "A reusable category for urgent roadside bicycle chain repair." } ) @@ -1442,6 +1443,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']") assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']") + assert has_element?(view, "#category-proposal-form input[value='subcategory'][checked]") + assert has_element?(view, "#category-proposal-parent-picker") view |> element("#proposal-vote-#{proposal.id}") @@ -1469,6 +1472,28 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do "#category-proposal-form option[value='#{activity_parent.id}']" ) end + + view + |> form("#category-proposal-form", category_proposal: %{kind: "category"}) + |> render_change() + + refute has_element?(view, "#category-proposal-parent-picker") + end + + test "request category picker exposes searchable grouped categories", %{conn: conn} do + Catalog.seed_defaults() + + {:ok, view, _html} = live(conn, ~p"/requests/new") + + assert has_element?( + view, + "#help_request_category_id-picker[phx-hook='SearchableCategoryPicker']" + ) + + assert has_element?(view, "[data-category-picker-search]") + assert has_element?(view, "[data-category-picker-group]", "Roadside help") + assert has_element?(view, "[data-category-picker-option]", "Bicycle roadside help") + assert has_element?(view, "a[href='/categories/proposals']", "Propose a category") end test "review submission updates both participant pages and hides the submitted form" do