diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex index 0b263d90..fd2a6b3e 100644 --- a/lib/ash_ui/rendering/iur_adapter.ex +++ b/lib/ash_ui/rendering/iur_adapter.ex @@ -624,6 +624,31 @@ defmodule AshUI.Rendering.IURAdapter do defp base_attributes(:command_palette, props), do: %{command_palette: Map.drop(props, attachment_prop_keys())} + defp base_attributes(:ask_sidebar = kind, props) do + component_attributes( + kind, + :layer_shell_and_callout, + %{ + ask_sidebar: + compact_map(%{ + sidebar_id: first_present(props, [:sidebar_id, :id_key]), + on_map_jump_event: first_present(props, [:on_map_jump_event, :map_jump_event]), + recent_items: fetch(props, :recent_items, []), + saved_items: fetch(props, :saved_items, []), + blocker_count: fetch(props, :blocker_count, 0), + empty_recent_label: + first_present(props, [:empty_recent_label]) || "No recent queries", + empty_saved_label: + first_present(props, [:empty_saved_label]) || "No saved queries yet" + }) + |> maybe_put(:active_item_id, first_present(props, [:active_item_id])) + |> maybe_put(:on_new_saved_event, first_present(props, [:on_new_saved_event])) + |> maybe_put(:on_see_all_event, first_present(props, [:on_see_all_event])) + }, + props + ) + end + defp base_attributes(:list, props), do: %{list: Map.drop(props, attachment_prop_keys())} defp base_attributes(:table, props), do: %{table: Map.drop(props, attachment_prop_keys())} defp base_attributes(:tree_view, props), do: %{tree: Map.drop(props, attachment_prop_keys())} diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index abdb81b8..d8b5d197 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -670,6 +670,101 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ end + defp generate_heex(%{"type" => "ask_sidebar"} = iur, _opts) do + props = iur["props"] || %{} + sidebar_id = text_prop(props, ["sidebar_id"], "ask-sidebar") + on_map_jump_event = text_prop(props, ["on_map_jump_event"], "") + recent_items = prop(props, "recent_items", []) + saved_items = prop(props, "saved_items", []) + active_item_id = prop(props, "active_item_id", nil) + on_new_saved_event = text_prop(props, ["on_new_saved_event"], nil) + on_see_all_event = text_prop(props, ["on_see_all_event"], nil) + empty_recent_label = escaped_text_prop(props, ["empty_recent_label"], "No recent queries") + empty_saved_label = escaped_text_prop(props, ["empty_saved_label"], "No saved queries yet") + blocker_count_raw = prop(props, "blocker_count", 0) + + blocker_count = + case blocker_count_raw do + n when is_integer(n) -> n + n when is_float(n) -> round(n) + s when is_binary(s) -> String.to_integer(s) + _ -> 0 + end + + recent_items_html = + Enum.map_join(recent_items, "\n", fn item -> + item = normalize_item(item) + item_id = text_prop(item, ["id", "item_id"], "") + item_label = escaped_text_prop(item, ["label", "title"], "") + is_active = active_item_id != nil and item_id == active_item_id + + active_attr = + if is_active, + do: + " aria-current=\"true\" class=\"ash-ask-sidebar__item ash-ask-sidebar__item--active\"", + else: " class=\"ash-ask-sidebar__item\"" + + "#{item_label}" + end) + + saved_items_html = + Enum.map_join(saved_items, "\n", fn item -> + item = normalize_item(item) + item_id = text_prop(item, ["id", "item_id"], "") + item_label = escaped_text_prop(item, ["label", "title"], "") + is_active = active_item_id != nil and item_id == active_item_id + + active_attr = + if is_active, + do: + " aria-current=\"true\" class=\"ash-ask-sidebar__item ash-ask-sidebar__item--active\"", + else: " class=\"ash-ask-sidebar__item\"" + + "#{item_label}" + end) + + see_all_btn = + if on_see_all_event && on_see_all_event != "", + do: + "", + else: "" + + new_saved_btn = + if on_new_saved_event && on_new_saved_event != "", + do: + "", + else: "" + + map_jump_btn = + if on_map_jump_event != "" do + badge = + if blocker_count > 0, + do: + "#{blocker_count}", + else: "" + + "" + else + "" + end + + """ + + """ + end + defp generate_heex(%{"type" => "sticky_frosted_header"} = iur, opts) do props = iur["props"] || %{} title = escaped_text_prop(props, ["title", "label"], "") diff --git a/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex b/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex new file mode 100644 index 00000000..3dcddf5b --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/ask_sidebar.ex @@ -0,0 +1,314 @@ +defmodule LiveUi.Widgets.AskSidebar do + @moduledoc """ + Native Ask-mode sidebar shell widget. + + The Ask-mode sidebar replaces `:sidebar_shell` while the operator is in Ask + mode. It provides two persistent navigation rails: + + - **Recent** — chronological query history, capped at 10, with relative + timestamps and a running-status indicator. + - **Saved** — pinned or named queries with a ★ glyph, optional cadence label, + and a "+ new" action. + + A **Map jump** affordance at the bottom lets the operator switch back to Map + mode without losing the Ask surface. It carries an optional blocker-count badge. + + The widget is a pure display surface — all state is host-managed. No internal + state; all transitions are driven by parent assigns. + + ## Required attributes + + * `:sidebar_id` — root identity string; used as `data-sidebar-id` and as the + suffix for scoped ARIA heading ids. + * `:on_map_jump_event` — canonical Interaction intent string for the Map jump + button (emitted as `data-live-ui-intent`). + + ## Optional attributes + + * `:recent_items` — list of `%{id, query, last_run_at, status, on_open_event}` + maps. Renderer caps display at 10. + * `:saved_items` — list of `%{id, title, query, on_open_event}` maps. Optional + per-item keys: `:cadence`, `:last_run_at`. + * `:active_item_id` — id of the currently-open item; row gets `aria-current="true"`. + * `:on_new_saved_event` — intent string for the "+ new" save button in the Saved rail. + * `:on_see_all_event` — intent string for "see all ▸" (visible when `recent_items` + exceeds 6). + * `:empty_recent_label` — message when Recent rail is empty + (default `"No recent queries"`). + * `:empty_saved_label` — message when Saved rail is empty + (default `"No saved queries yet"`). + * `:blocker_count` — non-negative integer badge on the Map jump button; `0` + hides the badge (default `0`). + + ## Selector / hook contract + + Root: `data-live-ui-widget="ask-sidebar"` + `data-sidebar-id="{id}"`. + Recent rail: `.live-ui-ask-sidebar__section[aria-labelledby="ask-recent-h-{id}"]`. + Saved rail: `.live-ui-ask-sidebar__section[aria-labelledby="ask-saved-h-{id}"]`. + Active item: `.live-ui-ask-sidebar__item--active` + `aria-current="true"`. + Running status: `.live-ui-ask-sidebar__status-running`. + Map jump: `.live-ui-ask-sidebar__map-jump-btn`. + Blocker badge: `.live-ui-ask-sidebar__blocker-badge`. + + ## ARIA + + - Root `