diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 14f6072e..33f07191 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -37,6 +37,7 @@ defmodule LiveUi.Renderer do :field, :field_group, :file_input, + :file_tree_browser, :form_builder, :gauge, :grid, @@ -70,6 +71,7 @@ defmodule LiveUi.Renderer do :tabs, :text, :text_input, + :thread_card, :time_input, :toast, :toggle, @@ -130,7 +132,10 @@ defmodule LiveUi.Renderer do disabled={Map.get(item, :disabled)} {Map.get(item, :attrs, %{})} > - {Map.get(item, :label) || Map.get(item, "label") || ""} + <%= if glyph = Map.get(item, :glyph) || Map.get(item, "glyph") do %> + + <% end %> + {Map.get(item, :label) || Map.get(item, "label") || ""} <% end %> @@ -183,6 +188,11 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:sidebar_item` is a member of `@layer_callout_kinds` (and therefore + # of `@component_kinds`), so the generic fallback below would shadow any later + # `:sidebar_item` clause. This specific clause MUST appear BEFORE the generic + # `@component_kinds` fallback to route to the dedicated SidebarItem component. + # Mirrors the `:thread_card` and `:command_palette` patterns. def render(%{element: %Element{kind: :sidebar_item}} = assigns) do interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) @@ -191,21 +201,19 @@ defmodule LiveUi.Renderer do |> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs)) ~H""" -
  • - -
  • + label={string_value(get_in(@element.attributes, [:item, :label]), "")} + selected?={get_in(@element.attributes, [:item, :selected?]) == true} + avatar_url={get_in(@element.attributes, [:item, :avatar_url])} + item_state={get_in(@element.attributes, [:item, :item_state])} + item_intent={string_optional(get_in(@element.attributes, [:item, :item_intent]))} + tone={style_tone(@element)} + variant={theme_variant(@element)} + state={style_state(@element)} + class={style_class(@element)} + {@style_attrs} + /> """ end @@ -271,6 +279,71 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:thread_card` is a member of `@content_identity_kinds` (and therefore + # of `@component_kinds`), so this specific clause MUST appear BEFORE the generic + # `@component_kinds` fallback to ensure the dedicated ThreadCard component is + # invoked instead of the generic section shell. Mirrors the `:command_palette` + # pattern above. + def render(%{element: %Element{kind: :thread_card}} = assigns) do + interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + + assigns = + assign(assigns, :interaction_attrs, interaction_attrs) + |> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs)) + + ~H""" + + """ + end + + # NOTE: `:composer_inline_ask` is a member of `@layer_callout_kinds` (and + # therefore of `@component_kinds`). This specific clause MUST appear BEFORE + # the generic `@component_kinds` fallback to route to the dedicated + # ComposerInlineAsk Phoenix.Component. Mirrors the `:command_palette` and + # `:thread_card` patterns above. + def render(%{element: %Element{kind: :composer_inline_ask}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) @@ -837,6 +910,30 @@ defmodule LiveUi.Renderer do """ end + def render(%{element: %Element{kind: :file_tree_browser}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: :status}} = assigns) do assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) @@ -1688,7 +1785,46 @@ defmodule LiveUi.Renderer do defp normalize_tree_node(node, source_element, event_target) do node = Map.new(node) node_id = Map.get(node, :id) || Map.get(node, "id") + kind = Map.get(node, :kind) || Map.get(node, "kind") + + case kind do + :sub_group -> + normalize_sub_group_tree_node(node, node_id, source_element, event_target) + + :file_leaf -> + normalize_file_leaf_tree_node(node, node_id, source_element, event_target) + + _ -> + normalize_generic_tree_node(node, node_id, source_element, event_target) + end + end + + # :sub_group — categorical grouping node: renders as role="group" container + # with optional expand state and nested children. + defp normalize_sub_group_tree_node(node, node_id, source_element, event_target) do + children = + node + |> Map.get(:children, Map.get(node, "children", [])) + |> List.wrap() + |> Enum.map(&normalize_tree_node(&1, source_element, event_target)) + + node + |> Map.put(:kind, :sub_group) + |> Map.put(:expanded, Map.get(node, :expanded, Map.get(node, :expanded?))) + |> Map.put(:children, children) + |> maybe_put_item_attrs(collection_item_attrs(source_element, event_target, node_id)) + end + + # :file_leaf — filesystem-path leaf node: renders as a treeitem row + # with glyph token, path, and optional meta. + defp normalize_file_leaf_tree_node(node, node_id, source_element, event_target) do + node + |> Map.put(:kind, :file_leaf) + |> Map.put(:selected, Map.get(node, :selected, Map.get(node, :selected?))) + |> maybe_put_item_attrs(collection_item_attrs(source_element, event_target, node_id)) + end + defp normalize_generic_tree_node(node, node_id, source_element, event_target) do children = node |> Map.get(:children, Map.get(node, "children", [])) diff --git a/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex b/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex new file mode 100644 index 00000000..dd501b21 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/sidebar_item.ex @@ -0,0 +1,111 @@ +defmodule LiveUi.Widgets.SidebarItem do + @moduledoc """ + Native sidebar-item widget. + + Renders a navigable sidebar list item with selected state, intent, and optional + badge children. Extends the base `:sidebar_item` with: + + - `avatar_url` — optional URL for a small avatar image rendered before the label. + When `nil` (default), no image is rendered. The `` is `aria-hidden="true"` + because the label already provides the accessible name. + - `item_state` — optional workflow state atom (`:default`, `:stalled`, `:blocked`, + `:errored`). When `nil` (default), no extra state modifier class is applied. + State is exposed as a BEM modifier class (`live-ui-sidebar-item--state-{value}`) + and as a `data-live-ui-item-state` hook attribute for CSS/JS targeting. + + ## Attributes + + Required: + - `:id` - element identifier + - `:label` - display text for the item + + Optional: + - `:selected?` - boolean; adds `--selected` BEM modifier and `aria-current="page"` (default `false`) + - `:avatar_url` - URL string; when present, renders `` before label (default `nil`) + - `:item_state` - atom; one of `:default | :stalled | :blocked | :errored` (default `nil`) + - `:item_intent` - canonical Interaction intent for click events (default `nil`) + + ## Selector / hook contract + + Root `
  • `: `data-live-ui-widget="sidebar-item"` + `data-live-ui-item-state="{state}"` (omitted when nil). + Button: `.live-ui-sidebar-item-button`. + Avatar (when present): `.live-ui-sidebar-item__avatar` + `aria-hidden="true"`. + State modifier on root: `live-ui-sidebar-item--state-{stalled|blocked|errored}`. + + ## ARIA + + - `aria-current="page"` on the inner button when `selected?` is `true`. + - Avatar `` carries `aria-hidden="true"` since the sibling label text is the + accessible name; no `alt` text needed. + - `:blocked` items remain clickable by default (no `aria-disabled`). Pascal may + add `aria-disabled="true"` in a follow-up once the UX intent is confirmed + (open question #4 in unified_ui #184). + + ## Open questions for Pascal (unified_ui #184) + + 1. **Avatar composition** — currently renders `` inline (smallest delta). If the + `:avatar` canonical widget (ash_ui PR #116) stabilises before this is adopted, prefer + composing via that widget for DRY rendering. + 2. **State enum completeness** — `:stalled | :blocked | :errored` are the audit-required + values. `:loading` / `:in_progress` deferred pending Pascal confirmation. + 3. **`state` vs `tone`** — `item_state` is named to avoid collision with the `LiveUi.Component` + common `:state` assign (style-hook). Pascal may prefer a different name. + 4. **ARIA for `:blocked`** — `aria-disabled="true"` deferred; comp shows visual treatment + but intent unclear (still-clickable vs truly disabled). + 5. **Avatar fallback** — initials fallback for missing `avatar_url` on DM rows is + consumer-side; out of scope for this extension. + """ + + use LiveUi.Component, family: :content, name: :sidebar_item, events: [:click] + + LiveUi.Component.common_attrs() + attr(:label, :string, required: true) + attr(:selected?, :boolean, default: false) + attr(:avatar_url, :string, default: nil) + attr(:item_state, :atom, default: nil) + attr(:item_intent, :string, default: nil) + + slot(:inner_block) + + @impl true + def render(assigns) do + ~H""" +
  • + +
  • + """ + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp state_modifier_class(nil), do: nil + defp state_modifier_class(:default), do: nil + defp state_modifier_class(state), do: "live-ui-sidebar-item--state-#{state}" +end diff --git a/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs b/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs new file mode 100644 index 00000000..4dd898ae --- /dev/null +++ b/packages/live_ui/test/live_ui/sidebar_item_widget_test.exs @@ -0,0 +1,265 @@ +defmodule LiveUi.SidebarItemWidgetTest do + @moduledoc """ + Stage-4 tests for `LiveUi.Widgets.SidebarItem`. + + Verifies: + - `data-live-ui-widget="sidebar-item"` root attribute (true-widget, not component-kind fallback) + - Label renders inside the button + - `aria-current="page"` on the button when `selected?` is `true` + - `live-ui-sidebar-item--selected` BEM modifier class when selected + - Avatar `` rendered with correct class + `aria-hidden="true"` when `avatar_url` present + - Avatar `` omitted when `avatar_url` is `nil` + - `data-live-ui-item-state` hook present when `item_state` is `:stalled`, `:blocked`, `:errored` + - `data-live-ui-item-state` absent when `item_state` is `nil` or `:default` + - BEM state modifier class applied for each non-default state + - Renderer dispatches to `LiveUi.Widgets.SidebarItem` (not generic fallback) + """ + + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias UnifiedIUR.Widgets.Components + + # --------------------------------------------------------------------------- + # Stage-4 Phoenix.Component direct render tests + # --------------------------------------------------------------------------- + + describe "SidebarItem Phoenix.Component" do + test "renders with the true-widget data attribute (not component-kind fallback)" do + html = + render_component(&LiveUi.Widgets.SidebarItem.render/1, %{ + id: "si-1", + label: "Overview", + selected?: false, + avatar_url: nil, + item_state: nil, + item_intent: nil + }) + + assert html =~ ~s(data-live-ui-widget="sidebar-item") + refute html =~ ~s(data-live-ui-component-kind) + end + + test "renders the label text inside the button" do + html = + render_component(&LiveUi.Widgets.SidebarItem.render/1, %{ + id: "si-2", + label: "Design Docs", + selected?: false, + avatar_url: nil, + item_state: nil, + item_intent: nil + }) + + assert html =~ "Design Docs" + assert html =~ "live-ui-sidebar-item-button" + end + + test "renders aria-current=page and --selected class when selected" do + html = + render_component(&LiveUi.Widgets.SidebarItem.render/1, %{ + id: "si-3", + label: "Active Item", + selected?: true, + avatar_url: nil, + item_state: nil, + item_intent: nil + }) + + assert html =~ ~s(aria-current="page") + assert html =~ "live-ui-sidebar-item--selected" + end + + test "omits aria-current and --selected class when not selected" do + html = + render_component(&LiveUi.Widgets.SidebarItem.render/1, %{ + id: "si-4", + label: "Inactive", + selected?: false, + avatar_url: nil, + item_state: nil, + item_intent: nil + }) + + refute html =~ ~s(aria-current) + refute html =~ "live-ui-sidebar-item--selected" + end + + test "renders avatar img with aria-hidden when avatar_url is present" do + html = + render_component(&LiveUi.Widgets.SidebarItem.render/1, %{ + id: "si-5", + label: "Alice DM", + selected?: false, + avatar_url: "https://example.com/alice.png", + item_state: nil, + item_intent: nil + }) + + assert html =~ ~s( maybe_put(:thread_id, option(opts, :thread_id)) + |> maybe_put(:title, option(opts, :title, "")) + |> maybe_put(:reply_count, option(opts, :reply_count, 0)) + |> maybe_put(:seed_quote, option(opts, :seed_quote, "")) + |> maybe_put(:progress_pct, option(opts, :progress_pct)) + |> maybe_put(:last_activity_at, option(opts, :last_activity_at)) + |> maybe_put(:open_intent, option(opts, :open_intent, "open_thread")), + participants: option(opts, :participants, []) + }, + opts + ) + end + @spec segmented_button_group([keyword() | map()], opts()) :: Element.t() def segmented_button_group(options, opts \\ []) when is_list(options) do opts = normalize_opts(opts) @@ -596,6 +622,8 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @valid_sidebar_item_states [:default, :stalled, :blocked, :errored] + @spec sidebar_item( String.t(), [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], @@ -605,6 +633,13 @@ defmodule UnifiedIUR.Widgets.Components do when is_binary(label) and is_list(children) do opts = normalize_opts(opts) + raw_state = option(opts, :item_state) + + if not is_nil(raw_state) and raw_state not in @valid_sidebar_item_states do + raise ArgumentError, + ":item_state must be one of #{inspect(@valid_sidebar_item_states)}, got: #{inspect(raw_state)}" + end + build_component( :sidebar_item, :layer_shell_and_callout, @@ -615,6 +650,8 @@ defmodule UnifiedIUR.Widgets.Components do selected?: option(opts, :selected?, false) } |> maybe_put(:item_intent, option(opts, :item_intent)) + |> maybe_put(:avatar_url, option(opts, :avatar_url)) + |> maybe_put(:item_state, raw_state) }, Map.put(opts, :children, children) ) @@ -662,6 +699,98 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec composer_inline_ask(opts()) :: Element.t() + defdelegate composer_inline_ask(opts \\ []), + to: UnifiedIUR.Widgets.Components.ComposerInlineAsk + + @spec ask_sidebar(opts()) :: Element.t() + @doc """ + Canonical Ask-mode sidebar shell. + + Provides two persistent navigation rails — Recent (chronological query history, + capped at 10) and Saved (pinned or named queries with ★ glyph) — plus a Map + jump affordance at the bottom. + + Replaces `:sidebar_shell` when the operator is in Ask mode. + + ## Required options + + * `:sidebar_id` — root identity string; used as `data-sidebar-id` and ARIA landmark anchor + * `:on_map_jump_event` — canonical Interaction intent string for the Map jump button + + ## Optional options + + * `:recent_items` — list of `%{id, query, last_run_at, status, on_open_event}` maps + * `:saved_items` — list of `%{id, title, query, on_open_event}` maps (+ optional `:cadence`, `:last_run_at`) + * `:active_item_id` — currently-selected item id; `aria-current="true"` on the matching row + * `:on_new_saved_event` — intent string for the "+ new" save action + * `:on_see_all_event` — intent string for "see all ▸" (shown when recent count > 6) + * `:empty_recent_label` — override for empty-rail message (default: `"No recent queries"`) + * `:empty_saved_label` — override for empty-rail message (default: `"No saved queries yet"`) + * `:blocker_count` — non-negative integer badge on Map jump button; 0 hides badge (default: `0`) + + ## Invalid payload cases + + * `:sidebar_id` missing or empty string → raises `ArgumentError` + * `:on_map_jump_event` missing or empty string → raises `ArgumentError` + * `:recent_items` or `:saved_items` not a list → raises `ArgumentError` + * `:blocker_count` negative → raises `ArgumentError` + """ + def ask_sidebar(opts \\ []) do + opts = normalize_opts(opts) + + sidebar_id = option(opts, :sidebar_id) + + unless is_binary(sidebar_id) and sidebar_id != "" do + raise ArgumentError, ":sidebar_id is required and must be a non-empty string" + end + + map_jump_event = option(opts, :on_map_jump_event) + + unless is_binary(map_jump_event) and map_jump_event != "" do + raise ArgumentError, ":on_map_jump_event is required and must be a non-empty string" + end + + recent_items = option(opts, :recent_items, []) + + unless is_list(recent_items) do + raise ArgumentError, ":recent_items must be a list" + end + + saved_items = option(opts, :saved_items, []) + + unless is_list(saved_items) do + raise ArgumentError, ":saved_items must be a list" + end + + blocker_count = option(opts, :blocker_count, 0) + + unless is_integer(blocker_count) and blocker_count >= 0 do + raise ArgumentError, ":blocker_count must be a non-negative integer" + end + + build_component( + :ask_sidebar, + :layer_shell_and_callout, + %{ + ask_sidebar: + %{ + sidebar_id: sidebar_id, + on_map_jump_event: map_jump_event, + recent_items: recent_items, + saved_items: saved_items, + blocker_count: blocker_count, + empty_recent_label: option(opts, :empty_recent_label, "No recent queries"), + empty_saved_label: option(opts, :empty_saved_label, "No saved queries yet") + } + |> maybe_put(:active_item_id, option(opts, :active_item_id)) + |> maybe_put(:on_new_saved_event, option(opts, :on_new_saved_event)) + |> maybe_put(:on_see_all_event, option(opts, :on_see_all_event)) + }, + opts + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs index fe650c6e..21b275ea 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -13,7 +13,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :disclosure, :kicker, :avatar, - :presence_dot + :presence_dot, + :thread_card ] assert Components.form_control_kinds() == [ @@ -41,7 +42,9 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :sidebar_shell, :sidebar_section, :sidebar_item, - :command_palette + :command_palette, + :composer_inline_ask, + :ask_sidebar ] assert Components.redline_code_kinds() == [ @@ -331,4 +334,395 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do assert [%{slot: :default, element: %Element{id: "artifact_repeat:a1:artifact"}}] = repeat.children end + + describe "thread_card constructor" do + test "builds a thread_card element with required fields" do + card = + Components.thread_card( + thread_id: "thread-abc-123", + title: "Design review: wave 3.7", + reply_count: 7, + seed_quote: "What should the progress bar show when there's no active task?" + ) + + assert %Element{kind: :thread_card} = card + + assert card.attributes.component == %{ + family: :content_identity_and_disclosure, + kind: :thread_card + } + + assert card.attributes.thread == %{ + thread_id: "thread-abc-123", + title: "Design review: wave 3.7", + reply_count: 7, + seed_quote: "What should the progress bar show when there's no active task?", + open_intent: "open_thread" + } + + # empty participants list is dropped by merge_attribute/3 (value in [[], nil] guard) + refute Map.has_key?(card.attributes, :participants) + end + + test "includes participants in element attributes" do + participants = [ + %{actor_name: "Pascal", avatar: %{initials: "PC"}}, + %{actor_name: "Matt", avatar: %{initials: "MD"}} + ] + + card = + Components.thread_card( + thread_id: "t-1", + title: "API review", + reply_count: 2, + seed_quote: "LGTM", + participants: participants + ) + + assert card.attributes.participants == participants + end + + test "includes optional progress_pct in thread attrs" do + card = + Components.thread_card( + thread_id: "t-2", + title: "In-flight task", + reply_count: 0, + seed_quote: "Running analysis...", + progress_pct: 0.65 + ) + + assert card.attributes.thread.progress_pct == 0.65 + end + + test "includes last_activity_at in thread attrs" do + ts = ~U[2026-05-18 10:00:00Z] + + card = + Components.thread_card( + thread_id: "t-3", + title: "Old thread", + reply_count: 3, + seed_quote: "Quote", + last_activity_at: ts + ) + + assert card.attributes.thread.last_activity_at == ts + end + + test "accepts custom open_intent" do + card = + Components.thread_card( + thread_id: "t-4", + title: "Custom intent", + reply_count: 0, + seed_quote: "", + open_intent: "navigate_thread" + ) + + assert card.attributes.thread.open_intent == "navigate_thread" + end + + test "defaults reply_count to 0 and seed_quote to empty string" do + card = Components.thread_card(thread_id: "t-5", title: "Minimal") + + assert card.attributes.thread.reply_count == 0 + assert card.attributes.thread.seed_quote == "" + assert card.attributes.thread.title == "Minimal" + end + + test "omits nil optional fields from thread attrs" do + card = + Components.thread_card( + thread_id: "t-6", + title: "No progress", + reply_count: 1, + seed_quote: "Quote" + ) + + refute Map.has_key?(card.attributes.thread, :progress_pct) + refute Map.has_key?(card.attributes.thread, :last_activity_at) + end + end + + describe "ask_sidebar constructor" do + test "builds an ask_sidebar element with required fields" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-main", + on_map_jump_event: "switch_to_map" + ) + + assert %Element{kind: :ask_sidebar} = sidebar + + assert sidebar.attributes.component == %{ + family: :layer_shell_and_callout, + kind: :ask_sidebar + } + + assert sidebar.attributes.ask_sidebar.sidebar_id == "ask-sb-main" + assert sidebar.attributes.ask_sidebar.on_map_jump_event == "switch_to_map" + assert sidebar.attributes.ask_sidebar.recent_items == [] + assert sidebar.attributes.ask_sidebar.saved_items == [] + assert sidebar.attributes.ask_sidebar.blocker_count == 0 + assert sidebar.attributes.ask_sidebar.empty_recent_label == "No recent queries" + assert sidebar.attributes.ask_sidebar.empty_saved_label == "No saved queries yet" + end + + test "accepts recent_items and saved_items lists" do + now = DateTime.utc_now() + + recent = [ + %{ + id: "r-1", + query: "show blockers", + last_run_at: now, + status: :done, + on_open_event: "open_recent" + } + ] + + saved = [ + %{ + id: "s-1", + title: "Weekly blockers", + query: "show blockers", + cadence: "weekly", + last_run_at: now, + on_open_event: "open_saved" + } + ] + + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-2", + on_map_jump_event: "switch_to_map", + recent_items: recent, + saved_items: saved + ) + + assert sidebar.attributes.ask_sidebar.recent_items == recent + assert sidebar.attributes.ask_sidebar.saved_items == saved + end + + test "accepts all optional fields" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-3", + on_map_jump_event: "switch_to_map", + active_item_id: "r-1", + on_new_saved_event: "new_saved_query", + on_see_all_event: "see_all_recent", + empty_recent_label: "Nothing here yet", + empty_saved_label: "No saves", + blocker_count: 3 + ) + + attrs = sidebar.attributes.ask_sidebar + assert attrs.active_item_id == "r-1" + assert attrs.on_new_saved_event == "new_saved_query" + assert attrs.on_see_all_event == "see_all_recent" + assert attrs.empty_recent_label == "Nothing here yet" + assert attrs.empty_saved_label == "No saves" + assert attrs.blocker_count == 3 + end + + test "omits nil optional fields from ask_sidebar attrs" do + sidebar = + Components.ask_sidebar( + sidebar_id: "ask-sb-4", + on_map_jump_event: "switch_to_map" + ) + + refute Map.has_key?(sidebar.attributes.ask_sidebar, :active_item_id) + refute Map.has_key?(sidebar.attributes.ask_sidebar, :on_new_saved_event) + refute Map.has_key?(sidebar.attributes.ask_sidebar, :on_see_all_event) + end + + test "raises when sidebar_id is missing" do + assert_raise ArgumentError, ~r/:sidebar_id/, fn -> + Components.ask_sidebar(on_map_jump_event: "switch_to_map") + end + end + + test "raises when sidebar_id is empty string" do + assert_raise ArgumentError, ~r/:sidebar_id/, fn -> + Components.ask_sidebar(sidebar_id: "", on_map_jump_event: "switch_to_map") + end + end + + test "raises when on_map_jump_event is missing" do + assert_raise ArgumentError, ~r/:on_map_jump_event/, fn -> + Components.ask_sidebar(sidebar_id: "ask-sb-5") + end + end + + test "raises when on_map_jump_event is empty string" do + assert_raise ArgumentError, ~r/:on_map_jump_event/, fn -> + Components.ask_sidebar(sidebar_id: "ask-sb-6", on_map_jump_event: "") + end + end + + test "raises when recent_items is not a list" do + assert_raise ArgumentError, ~r/:recent_items/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-7", + on_map_jump_event: "switch_to_map", + recent_items: "not-a-list" + ) + end + end + + test "raises when saved_items is not a list" do + assert_raise ArgumentError, ~r/:saved_items/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-8", + on_map_jump_event: "switch_to_map", + saved_items: "not-a-list" + ) + end + end + + test "raises when blocker_count is negative" do + assert_raise ArgumentError, ~r/:blocker_count/, fn -> + Components.ask_sidebar( + sidebar_id: "ask-sb-9", + on_map_jump_event: "switch_to_map", + blocker_count: -1 + ) + end + end + end + + describe "mode_nav constructor" do + test "normalizes items and preserves glyph when present" do + element = + Components.mode_nav( + [ + %{value: :map, label: "Map", glyph: "🗺"}, + %{value: :chat, label: "Chat", glyph: "💬"}, + %{value: :ask, label: "Ask"} + ], + id: "mode-nav-glyph", + aria_label: "Application modes", + navigation_intent: :switch_mode + ) + + assert %Element{kind: :mode_nav} = element + assert element.id == "mode-nav-glyph" + + items = get_in(element.attributes, [:navigation, :items]) + assert length(items) == 3 + + [map_item, chat_item, ask_item] = items + assert map_item.label == "Map" + assert map_item.glyph == "🗺" + assert chat_item.label == "Chat" + assert chat_item.glyph == "💬" + assert ask_item.label == "Ask" + refute Map.has_key?(ask_item, :glyph) + end + + test "normalizes items without glyph — backward-compatible" do + element = + Components.mode_nav( + [ + %{value: :workspace, label: "Workspace", current?: true}, + %{value: :settings, label: "Settings"} + ], + id: "mode-nav-no-glyph", + navigation_intent: :switch_mode + ) + + items = get_in(element.attributes, [:navigation, :items]) + assert length(items) == 2 + Enum.each(items, fn item -> refute Map.has_key?(item, :glyph) end) + end + end + + describe "sidebar_item constructor (avatar_url + item_state extension)" do + test "builds a basic sidebar_item with defaults" do + item = Components.sidebar_item("Overview", [], id: "sb-item-overview") + + assert %Element{kind: :sidebar_item} = item + assert item.id == "sb-item-overview" + assert item.attributes.item.label == "Overview" + assert item.attributes.item.selected? == false + refute Map.has_key?(item.attributes.item, :avatar_url) + refute Map.has_key?(item.attributes.item, :item_state) + end + + test "includes avatar_url in item attrs when provided" do + item = + Components.sidebar_item("Alice", [], + id: "sb-item-alice", + avatar_url: "https://example.com/alice.png" + ) + + assert item.attributes.item.avatar_url == "https://example.com/alice.png" + end + + test "omits avatar_url from item attrs when nil" do + item = Components.sidebar_item("Bob", [], id: "sb-item-bob") + refute Map.has_key?(item.attributes.item, :avatar_url) + end + + test "includes item_state in item attrs for each valid state" do + for state <- [:stalled, :blocked, :errored] do + item = + Components.sidebar_item("Item", [], + id: "sb-item-#{state}", + item_state: state + ) + + assert item.attributes.item.item_state == state + end + end + + test "omits item_state from item attrs when nil" do + item = Components.sidebar_item("Clean", [], id: "sb-item-clean") + refute Map.has_key?(item.attributes.item, :item_state) + end + + test "accepts :default as a valid item_state (omitted from attrs since it is the default)" do + # :default is valid (no error), but maybe_put drops it since it is + # conceptually the same as nil — no marker stored. + # The constructor guards against *invalid* atoms, not against :default. + assert %Element{kind: :sidebar_item} = + Components.sidebar_item("Default", [], + id: "sb-item-default", + item_state: :default + ) + end + + test "raises on invalid item_state atom" do + assert_raise ArgumentError, ~r/:item_state/, fn -> + Components.sidebar_item("Item", [], id: "sb-item-bad", item_state: :unknown_state) + end + end + + test "preserves backward-compat: selected? and item_intent still work" do + item = + Components.sidebar_item("Details", [], + id: "sb-item-details", + selected?: true, + item_intent: :open_details + ) + + assert item.attributes.item.selected? == true + assert item.attributes.item.item_intent == :open_details + end + + test "combines avatar_url and item_state together" do + item = + Components.sidebar_item("Stalled DM", [], + id: "sb-item-stalled-dm", + avatar_url: "https://example.com/user.png", + item_state: :stalled + ) + + assert item.attributes.item.avatar_url == "https://example.com/user.png" + assert item.attributes.item.item_state == :stalled + end + end end