diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md index b6aa03ae..6148d972 100644 --- a/guides/user/UG-0003-widget-types-properties-and-signals.md +++ b/guides/user/UG-0003-widget-types-properties-and-signals.md @@ -64,6 +64,7 @@ These are the types currently accepted by `ui_element` validation. | `hero` | Large intro section | `eyebrow`, `title`, `message` | | `badge` | Small status label | `presentation`, `text` or `label` or `content` | | `confidence_indicator` | Confidence score or quality meter | `value`, `thresholds`, `label`, `show_numeric?`, `show_glyph?`, `size` | +| `diff_banner` | Comparison summary and filter chips | `new_count`, `changed_count`, `removed_count`, `active_filter`, `base_label`, `size` | | `stat` | Metric card | `title`, `value`, `message` | | `key_value` | Definition row | `label` or `title`, `value`, `description` | | `info_list` | Simple list of labeled values | `items`, optional `ordered?` | @@ -232,6 +233,7 @@ Signal support is type-specific. | `switch` | `:change`, `:toggle` | | `slider` | `:change`, `:input` | | `form_builder` | `:submit` | +| `diff_banner` | `:change`, `:input` | | `right_rail` | `:change`, `:toggle`, `:click` | If you declare a signal outside this matrix, authoring validation raises. @@ -262,6 +264,7 @@ The current action-binding-capable widgets are: - `runtime_form_shell` - `segmented_button_group` - `chat_composer` +- `diff_banner` - `context_selector` - `right_rail` @@ -298,7 +301,7 @@ you want to inspect the maintained behavior for each major family: | layout and navigation | `row`, `column`, `grid`, `menu`, `tabs`, `context_selector`, `command_palette` | relationship-first composition with exact and custom navigation shells | | display and inspection | `viewport`, `scroll_bar`, `split_pane`, `canvas` | example-only display shells that keep state changes on nested public widgets | | overlays and data surfaces | `dialog`, `alert_dialog`, `table`, `tree_view`, `markdown_viewer`, `log_viewer` | composed review shells, explicit custom boundaries, and seeded screen persistence | -| feedback and operational surfaces | `status`, `progress`, `sparkline`, `bar_chart`, `cluster_dashboard` | normalized status treatment, custom chart shells, and runtime-rich operational review stories | +| feedback and operational surfaces | `status`, `progress`, `diff_banner`, `confidence_indicator`, `sparkline`, `bar_chart`, `cluster_dashboard` | normalized status treatment, comparison feedback, custom chart shells, and runtime-rich operational review stories | If a directory name differs from the canonical Ash UI type, treat the example directory as the review handle and the canonical type as the runtime authoring diff --git a/lib/ash_ui/dsl/storage.ex b/lib/ash_ui/dsl/storage.ex index 9c0f4070..4bbbfb84 100644 --- a/lib/ash_ui/dsl/storage.ex +++ b/lib/ash_ui/dsl/storage.ex @@ -27,6 +27,7 @@ defmodule AshUI.DSL.Storage do "hero", "badge", "confidence_indicator", + "diff_banner", "stat", "key_value", "info_list", diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex index 00381903..d43e5bbd 100644 --- a/lib/ash_ui/rendering/iur_adapter.ex +++ b/lib/ash_ui/rendering/iur_adapter.ex @@ -693,6 +693,22 @@ defmodule AshUI.Rendering.IURAdapter do defp base_attributes(:inline_feedback, props), do: %{feedback: Map.drop(props, attachment_prop_keys())} + defp base_attributes(:diff_banner, props) do + %{ + diff: + compact_map(%{ + new_count: normalize_count(fetch(props, :new_count, 0)), + changed_count: normalize_count(fetch(props, :changed_count, 0)), + removed_count: normalize_count(fetch(props, :removed_count, 0)), + base_label: first_present(props, [:base_label]), + active_filter: normalize_existing_atom(first_present(props, [:active_filter])) || :all, + show_filter_chips?: boolean_present(props, [:show_filter_chips?], true), + size: normalize_existing_atom(first_present(props, [:size])) || :default, + filter_intent: first_present(props, [:filter_intent, :selection_intent]) + }) + } + end + defp base_attributes(:confidence_indicator, props) do confidence = %{ @@ -1067,6 +1083,18 @@ defmodule AshUI.Rendering.IURAdapter do defp context_selector_multiple?(value) when is_integer(value), do: value > 1 defp context_selector_multiple?(_value), do: false + defp normalize_count(value) when is_integer(value) and value >= 0, do: value + defp normalize_count(value) when is_float(value) and value >= 0, do: round(value) + + defp normalize_count(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed >= 0 -> parsed + _other -> 0 + end + end + + defp normalize_count(_value), do: 0 + defp normalize_optional_map(nil), do: nil defp normalize_optional_map(value) do diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index 6b9fb9e5..afff5ab8 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -756,6 +756,50 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ end + defp generate_heex(%{"type" => "diff_banner"} = iur, _opts) do + props = iur["props"] || %{} + diff = if is_map(prop(props, "diff")), do: prop(props, "diff", %{}), else: props + new_count = prop(diff, "new_count", 0) + changed_count = prop(diff, "changed_count", 0) + removed_count = prop(diff, "removed_count", 0) + + total_count = + numeric_count(new_count) + numeric_count(changed_count) + numeric_count(removed_count) + + active_filter = text_prop(diff, "active_filter", "all") + size = text_prop(diff, "size", "default") + base_label = escaped_text_prop(diff, ["base_label", "base"]) + + chips = + [ + {"all", total_count}, + {"new", new_count}, + {"changed", changed_count}, + {"removed", removed_count} + ] + |> Enum.map_join("\n ", fn {kind, count} -> + active_class = if active_filter == kind, do: " ash-diff-banner__chip--active", else: "" + + ~s(#{html_escape(count)} #{kind}) + end) + + base_html = + if base_label && size != "compact" do + ~s(#{base_label}) + else + "" + end + + """ + + """ + end + defp generate_heex(%{"type" => "ask_sidebar"} = iur, _opts) do props = iur["props"] || %{} sidebar_id = escaped_text_prop(props, ["sidebar_id"], "") @@ -2973,6 +3017,18 @@ defmodule AshUI.Rendering.LiveUIAdapter do defp confidence_number(_value, default), do: default + defp numeric_count(value) when is_integer(value), do: value + defp numeric_count(value) when is_float(value), do: round(value) + + defp numeric_count(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, _rest} -> parsed + :error -> 0 + end + end + + defp numeric_count(_value), do: 0 + defp metric_model(props) do case prop(props, "model", %{}) do %{} = model -> model diff --git a/lib/ash_ui/resources/validations/authoring.ex b/lib/ash_ui/resources/validations/authoring.ex index c85e2dfa..b8b7fbb6 100644 --- a/lib/ash_ui/resources/validations/authoring.ex +++ b/lib/ash_ui/resources/validations/authoring.ex @@ -35,6 +35,7 @@ defmodule AshUI.Resources.Validations.Authoring do "runtime_form_shell", "segmented_button_group", "chat_composer", + "diff_banner", "context_selector", "right_rail", "workflow_progress_status_card" @@ -53,6 +54,7 @@ defmodule AshUI.Resources.Validations.Authoring do "runtime_form_shell" => [:submit], "segmented_button_group" => [:change, :input], "chat_composer" => [:change, :input, :submit], + "diff_banner" => [:change, :input], "context_selector" => [:change, :input], "right_rail" => [:change, :toggle, :click], "workflow_progress_status_card" => [:click, :change] diff --git a/packages/desktop_ui/lib/desktop_ui/renderer.ex b/packages/desktop_ui/lib/desktop_ui/renderer.ex index ddf3a647..5e31e4df 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer.ex @@ -23,7 +23,7 @@ defmodule DesktopUi.Renderer do end @doc """ - All 47 canonical IUR widget kinds supported by the desktop_ui renderer. + All 48 canonical IUR widget kinds supported by the desktop_ui renderer. Each kind has dedicated native widget mapping, draw kind handling, and SDL3 rendering implementation. """ @@ -33,7 +33,7 @@ defmodule DesktopUi.Renderer do # Input (10) # Navigation (5) # Data (7) - # Feedback (6) + # Feedback (7) # Operational (7) # Visualization (6) # Layout & Structure (3) @@ -76,6 +76,7 @@ defmodule DesktopUi.Renderer do :tree_view, :alert_dialog, :dialog, + :diff_banner, :inline_feedback, :progress, :status, diff --git a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex index 53ac7a53..4903396d 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex @@ -819,6 +819,27 @@ defmodule DesktopUi.Renderer.Mapper do )} end + defp map_element(%Element{type: :widget, kind: kind} = element) + when kind in [:diff_banner, "diff_banner"] do + diff = attr(element, :diff) || %{} + + {:ok, + DesktopUi.Widgets.diff_banner( + element.id, + Keyword.merge( + base_opts(element), + new_count: map_attr(diff, :new_count, 0), + changed_count: map_attr(diff, :changed_count, 0), + removed_count: map_attr(diff, :removed_count, 0), + base_label: map_attr(diff, :base_label, nil), + active_filter: map_attr(diff, :active_filter, :all), + show_filter_chips?: map_attr(diff, :show_filter_chips?, true), + size: map_attr(diff, :size, :default), + on_filter: diff_banner_filter_payload(element, diff) + ) + )} + end + defp map_element(%Element{type: :widget, kind: kind} = element) when kind in [:bar_chart, "bar_chart"] do {:ok, @@ -1489,6 +1510,14 @@ defmodule DesktopUi.Renderer.Mapper do (is_integer(max_selections) and max_selections > 1) end + defp diff_banner_filter_payload(element, diff) do + interaction_payload(element, :selection) || + case map_attr(diff, :filter_intent, nil) do + nil -> nil + intent -> %{family: :selection, intent: intent, mapping: %{selected_value: :filter}} + end + end + defp map_attr(map, key, default) when is_map(map), do: Map.get(map, key, Map.get(map, to_string(key), default)) diff --git a/packages/desktop_ui/lib/desktop_ui/validate.ex b/packages/desktop_ui/lib/desktop_ui/validate.ex index 75e190fb..d972615c 100644 --- a/packages/desktop_ui/lib/desktop_ui/validate.ex +++ b/packages/desktop_ui/lib/desktop_ui/validate.ex @@ -82,7 +82,7 @@ defmodule DesktopUi.Validate do :renderer_supports_all_iur_kinds, iur_widget_coverage_complete?(), %{ - expected_count: 47, + expected_count: 48, actual_count: length(DesktopUi.Renderer.supported_kinds()), supported_kinds: DesktopUi.Renderer.supported_kinds() } @@ -822,10 +822,10 @@ defmodule DesktopUi.Validate do defp traceability_includes_direct_prefix?(_result, _prefix), do: false - # Verifies that all 47 canonical IUR widget kinds are supported by the renderer. + # Verifies that all 48 canonical IUR widget kinds are supported by the renderer. # Returns true when the renderer supports the complete set of IUR widget kinds. defp iur_widget_coverage_complete? do - expected_count = 47 + expected_count = 48 actual_count = length(DesktopUi.Renderer.supported_kinds()) actual_count >= expected_count and diff --git a/packages/desktop_ui/lib/desktop_ui/widget.ex b/packages/desktop_ui/lib/desktop_ui/widget.ex index bae667bb..4a1b1af3 100644 --- a/packages/desktop_ui/lib/desktop_ui/widget.ex +++ b/packages/desktop_ui/lib/desktop_ui/widget.ex @@ -80,7 +80,9 @@ defmodule DesktopUi.Widget do :progress, :severity, :streaming, - :paused + :paused, + :active_filter, + :size ], bindings: [ :value, @@ -137,7 +139,13 @@ defmodule DesktopUi.Widget do :max_selections, :multiple, :multiple?, - :label_prefix + :label_prefix, + :new_count, + :changed_count, + :removed_count, + :base_label, + :show_filter_chips?, + :chips ], styles: [ :fg, @@ -264,7 +272,7 @@ defmodule DesktopUi.Widget do def family_for(kind) when kind in [:menu, :tabs, :breadcrumbs, :list, :context_selector], do: :navigation - def family_for(kind) when kind in [:status, :confidence_indicator], do: :feedback + def family_for(kind) when kind in [:status, :diff_banner, :confidence_indicator], do: :feedback def family_for(_kind), do: :content defp normalize_map(nil), do: %{} diff --git a/packages/desktop_ui/lib/desktop_ui/widgets.ex b/packages/desktop_ui/lib/desktop_ui/widgets.ex index c100adff..16efd0cc 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets.ex @@ -315,6 +315,11 @@ defmodule DesktopUi.Widgets do Feedback.progress(id, opts) end + @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t() + def diff_banner(id, opts \\ []) do + Feedback.diff_banner(id, opts) + end + @spec sparkline(String.t() | atom(), keyword()) :: Widget.t() def sparkline(id, opts \\ []) do Feedback.sparkline(id, opts) diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex b/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex index b90c8bb6..8ab7f8f0 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex @@ -7,7 +7,16 @@ defmodule DesktopUi.Widgets.Feedback do @spec kinds() :: [atom()] def kinds do - [:alert_dialog, :dialog, :inline_feedback, :progress, :sparkline, :status, :toast] + [ + :alert_dialog, + :dialog, + :diff_banner, + :inline_feedback, + :progress, + :sparkline, + :status, + :toast + ] end @spec sparkline(String.t() | atom(), keyword()) :: Widget.t() @@ -55,7 +64,8 @@ defmodule DesktopUi.Widgets.Feedback do auto_hide: Keyword.get(opts, :auto_hide, true), timeout_ms: Keyword.get(opts, :timeout_ms, 3_000) }, - events: events(close: Keyword.get(opts, :on_close), dismiss: Keyword.get(opts, :on_dismiss)), + events: + events(close: Keyword.get(opts, :on_close), dismiss: Keyword.get(opts, :on_dismiss)), styles: styles(opts) ) end @@ -167,6 +177,29 @@ defmodule DesktopUi.Widgets.Feedback do ) end + @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t() + def diff_banner(id, opts \\ []) do + Widget.new(:diff_banner, + id: id, + metadata: metadata(opts, role: :radiogroup), + state: + state(opts, + active_filter: Keyword.get(opts, :active_filter, :all), + size: Keyword.get(opts, :size, :default) + ), + attributes: %{ + new_count: Keyword.get(opts, :new_count, 0), + changed_count: Keyword.get(opts, :changed_count, 0), + removed_count: Keyword.get(opts, :removed_count, 0), + base_label: Keyword.get(opts, :base_label), + show_filter_chips?: Keyword.get(opts, :show_filter_chips?, true), + chips: Keyword.get(opts, :chips, []) + }, + events: events(filter: Keyword.get(opts, :on_filter)), + styles: styles(opts) + ) + end + defp metadata(opts, defaults), do: defaults |> Keyword.merge(Keyword.get(opts, :metadata, [])) |> Map.new() diff --git a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs index 8fca12b5..58be0c90 100644 --- a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs +++ b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs @@ -8,7 +8,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do @moduletag :phase_eleven - @iur_widget_count 47 + @iur_widget_count 48 @all_iur_kinds MapSet.new([ # Foundational (13) @@ -50,9 +50,10 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do :stat, :table, :tree_view, - # Feedback (6) + # Feedback (7) :alert_dialog, :dialog, + :diff_banner, :inline_feedback, :progress, :status, @@ -281,7 +282,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do test "canonical mapper supports all feedback and advanced widget kinds" do supported = Renderer.supported_kinds() |> MapSet.new() - feedback_kinds = [:status, :inline_feedback, :progress] + feedback_kinds = [:status, :inline_feedback, :progress, :diff_banner] advanced_kinds = [:stream_widget, :supervision_tree_viewer, :log_viewer, :process_monitor] Enum.each(feedback_kinds ++ advanced_kinds, fn kind -> @@ -292,17 +293,17 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do end describe "11.5 Mapper coverage and diagnostics scenarios" do - test "canonical mapper handles all 46 IUR widget kinds without fallback" do + test "canonical mapper handles all 48 IUR widget kinds without fallback" do supported = Renderer.supported_kinds() |> MapSet.new() # All IUR widget kinds should be supported missing_kinds = MapSet.difference(@all_iur_kinds, supported) assert MapSet.size(missing_kinds) == 0, - "Expected all 47 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}" + "Expected all 48 IUR kinds to be supported, but missing: #{inspect(MapSet.to_list(missing_kinds))}" end - test "renderer.supported_kinds returns at least 47 kinds" do + test "renderer.supported_kinds returns at least 48 kinds" do count = length(Renderer.supported_kinds()) assert count >= @iur_widget_count, diff --git a/packages/desktop_ui/test/desktop_ui/renderer_test.exs b/packages/desktop_ui/test/desktop_ui/renderer_test.exs index 26e5d0ee..2cdead05 100644 --- a/packages/desktop_ui/test/desktop_ui/renderer_test.exs +++ b/packages/desktop_ui/test/desktop_ui/renderer_test.exs @@ -67,6 +67,27 @@ defmodule DesktopUi.RendererTest do assert widget.attributes.label == "Match confidence" end + test "renderer maps diff_banner as a feedback widget" do + element = + Feedback.diff_banner( + id: "ask-diff", + new_count: 3, + changed_count: 5, + removed_count: 1, + active_filter: :changed, + filter_intent: :filter_diff + ) + + assert {:ok, widget} = DesktopUi.Renderer.render(element) + assert widget.kind == :diff_banner + assert widget.family == :feedback + assert widget.metadata.role == :radiogroup + assert widget.attributes.new_count == 3 + assert widget.attributes.changed_count == 5 + assert widget.attributes.removed_count == 1 + assert widget.state.active_filter == :changed + end + test "renderer maps context_selector as a navigation widget" do element = Navigation.context_selector( diff --git a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex index f99952ae..c2b0227b 100644 --- a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex +++ b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex @@ -616,6 +616,25 @@ defmodule ElmUi.Renderer.Canonical do )} end + defp do_render(%Element{type: :widget, kind: kind} = element) + when kind in [:diff_banner, "diff_banner"] do + diff = map_attr(element, :diff) + + {:ok, + Widgets.diff_banner( + element.id, + Keyword.merge(base_opts(element), + new_count: map_get(diff, :new_count, 0), + changed_count: map_get(diff, :changed_count, 0), + removed_count: map_get(diff, :removed_count, 0), + base_label: map_get(diff, :base_label), + active_filter: map_get(diff, :active_filter, :all), + show_filter_chips?: map_get(diff, :show_filter_chips?, true), + size: map_get(diff, :size, :default) + ) + )} + end + defp do_render(%Element{type: :widget, kind: kind} = element) when kind in [:gauge, "gauge"] do {:ok, diff --git a/packages/elm_ui/lib/elm_ui/widget.ex b/packages/elm_ui/lib/elm_ui/widget.ex index b0e55a48..84f39bb0 100644 --- a/packages/elm_ui/lib/elm_ui/widget.ex +++ b/packages/elm_ui/lib/elm_ui/widget.ex @@ -224,7 +224,9 @@ defmodule ElmUi.Widget do do: :data def family_for(kind) when kind in [:markdown_viewer, :log_viewer], do: :document - def family_for(kind) when kind in [:status, :progress, :inline_feedback], do: :feedback + + def family_for(kind) when kind in [:status, :progress, :inline_feedback, :diff_banner], + do: :feedback def family_for(kind) when kind in [:gauge, :sparkline, :bar_chart, :line_chart, :canvas], diff --git a/packages/elm_ui/lib/elm_ui/widgets.ex b/packages/elm_ui/lib/elm_ui/widgets.ex index 2bd2cc74..3526f6e8 100644 --- a/packages/elm_ui/lib/elm_ui/widgets.ex +++ b/packages/elm_ui/lib/elm_ui/widgets.ex @@ -400,6 +400,11 @@ defmodule ElmUi.Widgets do Feedback.inline_feedback(id, message, opts) end + @spec diff_banner(String.t() | atom(), keyword()) :: Widget.t() + def diff_banner(id, opts \\ []) do + Feedback.diff_banner(id, opts) + end + @spec gauge(String.t() | atom(), keyword()) :: Widget.t() def gauge(id, opts \\ []) do Visualization.gauge(id, opts) diff --git a/packages/elm_ui/lib/elm_ui/widgets/feedback.ex b/packages/elm_ui/lib/elm_ui/widgets/feedback.ex index 212f02d3..fbecf1a7 100644 --- a/packages/elm_ui/lib/elm_ui/widgets/feedback.ex +++ b/packages/elm_ui/lib/elm_ui/widgets/feedback.ex @@ -6,7 +6,7 @@ defmodule ElmUi.Widgets.Feedback do alias ElmUi.Widgets.Builder - @kinds [:status, :progress, :inline_feedback] + @kinds [:status, :progress, :inline_feedback, :diff_banner] @spec kinds() :: [atom()] def kinds, do: @kinds @@ -67,4 +67,27 @@ defmodule ElmUi.Widgets.Feedback do metadata: Builder.metadata(opts, %{native_surface: :feedback}) ) end + + @spec diff_banner(String.t() | atom(), keyword() | map()) :: ElmUi.Widget.t() + def diff_banner(id, opts \\ []) do + opts = Builder.options(Map.put(Builder.options(opts), :id, id)) + + Builder.widget(:diff_banner, + id: id, + attributes: %{ + new_count: Builder.option(opts, :new_count, 0), + changed_count: Builder.option(opts, :changed_count, 0), + removed_count: Builder.option(opts, :removed_count, 0), + base_label: Builder.option(opts, :base_label), + show_filter_chips?: Builder.option(opts, :show_filter_chips?, true) + }, + state: %{ + active_filter: Builder.option(opts, :active_filter, :all), + size: Builder.option(opts, :size, :default) + }, + events: Builder.events(opts, on_filter: :selection), + styles: Builder.styles(opts), + metadata: Builder.metadata(opts, %{native_surface: :feedback}) + ) + end end diff --git a/packages/elm_ui/test/elm_ui/renderer_test.exs b/packages/elm_ui/test/elm_ui/renderer_test.exs index e11ffc74..a38b1d28 100644 --- a/packages/elm_ui/test/elm_ui/renderer_test.exs +++ b/packages/elm_ui/test/elm_ui/renderer_test.exs @@ -2,6 +2,7 @@ defmodule ElmUi.RendererTest do use ExUnit.Case, async: true alias UnifiedIUR.Element + alias UnifiedIUR.Widgets.Feedback alias UnifiedIUR.Widgets.Navigation alias ElmUi.Renderer.Error alias ElmUi.Widget @@ -46,6 +47,25 @@ defmodule ElmUi.RendererTest do assert [%{label: "Workspace", items: [%{label: "All workspaces"}]}] = widget.attributes.groups end + test "renderer maps diff_banner as a native feedback widget" do + element = + Feedback.diff_banner( + id: "ask-diff", + new_count: 3, + changed_count: 5, + removed_count: 1, + active_filter: :new + ) + + assert {:ok, %Widget{} = widget} = ElmUi.Renderer.render(element) + assert widget.kind == :diff_banner + assert widget.family == :feedback + assert widget.attributes.new_count == 3 + assert widget.attributes.changed_count == 5 + assert widget.attributes.removed_count == 1 + assert widget.state.active_filter == :new + end + test "renderer rejects unsupported canonical kinds with structured diagnostics" do element = Element.new(:widget, :timeline, id: :timeline_root, attributes: %{title: "Unsupported"}) diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 38b82f47..174a61d0 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -36,6 +36,7 @@ defmodule LiveUi.Renderer do :context_selector, :context_menu, :date_input, + :diff_banner, :dialog, :disclosure, :field, @@ -336,6 +337,35 @@ defmodule LiveUi.Renderer do """ end + def render(%{element: %Element{kind: :diff_banner}} = assigns) do + diff = diff_banner_attributes(assigns.element) + + assigns = + assigns + |> assign(:diff, diff) + |> assign(:chips, diff_banner_chips(assigns.element, Map.get(assigns, :event_target))) + |> assign(:style_attrs, style_rest(assigns.element)) + + ~H""" + + """ + end + def render(%{element: %Element{kind: :context_selector}} = assigns) do context_selector = context_selector_attributes(assigns.element) @@ -1911,6 +1941,75 @@ defmodule LiveUi.Renderer do "Segmented control" end + defp diff_banner_attributes(%Element{} = element) do + element.attributes + |> Map.get(:diff, Map.get(element.attributes, "diff", %{})) + |> normalize_panel() + end + + defp diff_banner_chips(%Element{} = element, event_target) do + diff = diff_banner_attributes(element) + + [ + {:all, "all", diff_banner_total(diff)}, + {:new, "new", map_value(diff, :new_count, 0)}, + {:changed, "changed", map_value(diff, :changed_count, 0)}, + {:removed, "removed", map_value(diff, :removed_count, 0)} + ] + |> Enum.map(fn {kind, label, count} -> + %{ + kind: kind, + label: label, + count: count, + attrs: diff_banner_chip_attrs(element, event_target, kind) + } + end) + end + + defp diff_banner_total(diff) do + map_value(diff, :new_count, 0) + map_value(diff, :changed_count, 0) + + map_value(diff, :removed_count, 0) + end + + defp diff_banner_chip_attrs(%Element{} = element, event_target, filter) do + case {diff_banner_filter_interaction(element), event_target, filter} do + {%Interaction{} = interaction, target, selected_filter} when not is_nil(target) -> + %{ + :"phx-click" => "canonical_interaction", + :"phx-target" => target, + :"phx-value-interaction" => encode_interaction(interaction), + :"phx-value-element_id" => element_id(element, "diff-banner"), + :"phx-value-widget" => "diff_banner", + :"phx-value-filter" => to_string(selected_filter), + :"phx-value-value" => to_string(selected_filter), + :"phx-value-selected_value" => to_string(selected_filter) + } + + _ -> + %{} + end + end + + defp diff_banner_filter_interaction(%Element{} = element) do + primary_control_interaction(element) || + case diff_banner_filter_intent(element) do + nil -> + nil + + intent -> + Interaction.selection( + intent: intent, + element_id: element_id(element, "diff-banner"), + mapping: %{selected_value: :filter} + ) + end + end + + defp diff_banner_filter_intent(%Element{} = element) do + diff = diff_banner_attributes(element) + map_value(diff, :filter_intent, map_value(diff, :selection_intent)) + end + defp context_selector_attributes(%Element{} = element) do element.attributes |> Map.get(:context_selector, Map.get(element.attributes, "context_selector", %{})) diff --git a/packages/live_ui/lib/live_ui/widgets/diff_banner.ex b/packages/live_ui/lib/live_ui/widgets/diff_banner.ex new file mode 100644 index 00000000..50d38048 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/diff_banner.ex @@ -0,0 +1,107 @@ +defmodule LiveUi.Widgets.DiffBanner do + @moduledoc """ + Native comparison banner widget. + """ + + use LiveUi.Component, + family: :feedback, + name: :diff_banner, + events: [:selection] + + LiveUi.Component.common_attrs() + attr(:new_count, :integer, default: 0) + attr(:changed_count, :integer, default: 0) + attr(:removed_count, :integer, default: 0) + attr(:base_label, :string, default: nil) + attr(:active_filter, :any, default: :all) + attr(:show_filter_chips?, :boolean, default: true) + attr(:size, :any, default: :default) + attr(:chips, :list, default: []) + + @impl true + def render(assigns) do + chips = if assigns.chips == [], do: default_chips(assigns), else: assigns.chips + + assigns = + assigns + |> assign(:chips, chips) + |> assign(:size_name, to_string(assigns.size)) + + ~H""" + + """ + end + + defp default_chips(assigns) do + [ + %{kind: :all, count: total_count(assigns), label: "all"}, + %{kind: :new, count: assigns.new_count, label: "new"}, + %{kind: :changed, count: assigns.changed_count, label: "changed"}, + %{kind: :removed, count: assigns.removed_count, label: "removed"} + ] + end + + defp total_count(assigns), do: assigns.new_count + assigns.changed_count + assigns.removed_count + + defp chip_kind(chip), do: chip |> fetch(:kind, :all) |> to_string() + defp chip_label(chip), do: "#{fetch(chip, :count, 0)} #{fetch(chip, :label, chip_kind(chip))}" + defp chip_attrs(chip), do: fetch(chip, :attrs, %{}) + + defp chip_active?(chip, active_filter) do + chip_kind(chip) == to_string(active_filter) + end + + defp fetch(source, key, default) when is_map(source), do: Map.get(source, key, default) + defp fetch(source, key, default) when is_list(source), do: Keyword.get(source, key, default) + defp fetch(_source, _key, default), do: default +end diff --git a/packages/live_ui/lib/live_ui/widgets/feedback.ex b/packages/live_ui/lib/live_ui/widgets/feedback.ex index 50c2b0c3..5e615713 100644 --- a/packages/live_ui/lib/live_ui/widgets/feedback.ex +++ b/packages/live_ui/lib/live_ui/widgets/feedback.ex @@ -8,6 +8,7 @@ defmodule LiveUi.Widgets.Feedback do LiveUi.Widgets.Progress, LiveUi.Widgets.Gauge, LiveUi.Widgets.InlineFeedback, + LiveUi.Widgets.DiffBanner, LiveUi.Widgets.ConfidenceIndicator, LiveUi.Widgets.Sparkline, LiveUi.Widgets.BarChart, diff --git a/packages/live_ui/test/live_ui/renderer_test.exs b/packages/live_ui/test/live_ui/renderer_test.exs index 47ab0965..9bcd9cd0 100644 --- a/packages/live_ui/test/live_ui/renderer_test.exs +++ b/packages/live_ui/test/live_ui/renderer_test.exs @@ -439,4 +439,32 @@ defmodule LiveUi.RendererTest do assert html =~ ~s(data-selected="true") assert html =~ ~s(live-ui-workflow-progress-status-card--selected) end + + test "renderer maps canonical diff_banner through native feedback boundary" do + element = + Feedback.diff_banner( + id: "ask-diff", + new_count: 3, + changed_count: 5, + removed_count: 1, + active_filter: :new, + filter_intent: :filter_diff + ) + + html = + render_component(&LiveUi.Renderer.render/1, %{ + element: element, + event_target: %Phoenix.LiveComponent.CID{cid: 1} + }) + + assert html =~ ~s(data-live-ui-widget-boundary="diff_banner") + assert html =~ ~s(data-live-ui-widget="diff-banner") + assert html =~ ~s(data-active-filter="new") + assert html =~ "9 all" + assert html =~ "3 new" + assert html =~ ~s(phx-click="canonical_interaction") + assert html =~ ~s(phx-value-widget="diff_banner") + assert html =~ ~s(phx-value-selected_value="new") + refute html =~ "live_ui_interaction" + end end diff --git a/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs b/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs new file mode 100644 index 00000000..8f2daa2c --- /dev/null +++ b/packages/live_ui/test/live_ui/widgets/diff_banner_test.exs @@ -0,0 +1,70 @@ +defmodule LiveUi.Widgets.DiffBannerTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias LiveUi.Component + + describe "metadata" do + test "is a feedback widget component" do + metadata = Component.metadata(LiveUi.Widgets.DiffBanner) + + assert metadata.mountable? + assert metadata.component_module == LiveUi.Widgets.DiffBanner.Component + assert metadata.family == :feedback + assert metadata.name == :diff_banner + assert LiveUi.Widgets.DiffBanner in LiveUi.Widgets.Feedback.modules() + end + end + + describe "rendering" do + test "renders all filter chips including all" do + html = + render_component(&LiveUi.Widgets.DiffBanner.component/1, %{ + id: "diff-banner", + new_count: 3, + changed_count: 5, + removed_count: 1, + active_filter: :all + }) + + assert html =~ ~s(data-live-ui-widget-boundary="diff_banner") + assert html =~ ~s(data-live-ui-widget="diff-banner") + assert html =~ "9 all" + assert html =~ "3 new" + assert html =~ "5 changed" + assert html =~ "1 removed" + assert html =~ ~s(data-filter-kind="all") + assert html =~ ~s(role="radiogroup") + assert html =~ ~s(role="radio") + assert html =~ ~s(aria-checked="true") + end + + test "renders static chips without radio semantics" do + html = + render_component(&LiveUi.Widgets.DiffBanner.component/1, %{ + id: "static-diff-banner", + new_count: 0, + changed_count: 2, + removed_count: 0, + show_filter_chips?: false + }) + + refute html =~ ~s(role="radiogroup") + refute html =~ ~s(role="radio") + assert html =~ "live-ui-diff-banner__chip--static" + end + + test "hides base label in compact mode" do + html = + render_component(&LiveUi.Widgets.DiffBanner.component/1, %{ + id: "compact-diff-banner", + base_label: "Compared to last run", + size: :compact + }) + + refute html =~ "Compared to last run" + assert html =~ ~s(data-live-ui-size="compact") + end + end +end diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index 649d90fe..ba313e12 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -755,6 +755,16 @@ defmodule UnifiedIUR.Fixtures do title: "Success", severity: :success )}, + {:content, + Feedback.diff_banner( + id: "diff-banner-widget", + new_count: 3, + changed_count: 2, + removed_count: 1, + base_label: "Compared to last run", + active_filter: :all, + filter_intent: :filter_diff + )}, {:content, Feedback.confidence_indicator(0.87, id: "confidence-score", diff --git a/packages/unified_iur/lib/unified_iur/widgets.ex b/packages/unified_iur/lib/unified_iur/widgets.ex index 3371ef13..1734cb2a 100644 --- a/packages/unified_iur/lib/unified_iur/widgets.ex +++ b/packages/unified_iur/lib/unified_iur/widgets.ex @@ -33,7 +33,14 @@ defmodule UnifiedIUR.Widgets do ] @navigation_kinds [:menu, :tabs, :context_selector] @data_view_kinds [:list, :table, :tree_view, :stat, :key_value, :info_list] - @feedback_kinds [:status, :progress, :gauge, :inline_feedback, :confidence_indicator] + @feedback_kinds [ + :status, + :progress, + :gauge, + :inline_feedback, + :diff_banner, + :confidence_indicator + ] @advanced_kinds [ :stream_widget, :log_viewer, diff --git a/packages/unified_iur/lib/unified_iur/widgets/feedback.ex b/packages/unified_iur/lib/unified_iur/widgets/feedback.ex index c6d3e6da..2f4fef7e 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/feedback.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/feedback.ex @@ -8,7 +8,9 @@ defmodule UnifiedIUR.Widgets.Feedback do alias UnifiedIUR.Element alias UnifiedIUR.Metadata - @kinds [:status, :progress, :gauge, :inline_feedback, :confidence_indicator] + @kinds [:status, :progress, :gauge, :inline_feedback, :diff_banner, :confidence_indicator] + @diff_filters [:all, :new, :changed, :removed] + @diff_sizes [:default, :compact] @spec kinds() :: [atom()] def kinds do @@ -106,6 +108,43 @@ defmodule UnifiedIUR.Widgets.Feedback do ) end + @spec diff_banner(keyword() | map()) :: Element.t() + def diff_banner(opts \\ []) do + opts = normalize_opts(opts) + + Element.new(:widget, :diff_banner, + id: option(opts, :id), + metadata: normalize_metadata(opts), + attributes: + %{ + diff: + %{} + |> Map.put( + :new_count, + normalize_non_negative_count!(option(opts, :new_count, 0), :new_count) + ) + |> Map.put( + :changed_count, + normalize_non_negative_count!(option(opts, :changed_count, 0), :changed_count) + ) + |> Map.put( + :removed_count, + normalize_non_negative_count!(option(opts, :removed_count, 0), :removed_count) + ) + |> Map.put(:active_filter, normalize_diff_filter!(option(opts, :active_filter, :all))) + |> Map.put(:show_filter_chips?, option(opts, :show_filter_chips?, true)) + |> Map.put(:size, normalize_diff_size!(option(opts, :size, :default))) + |> maybe_put(:base_label, option(opts, :base_label)) + |> maybe_put( + :filter_intent, + option(opts, :filter_intent, option(opts, :selection_intent)) + ) + } + |> Attachment.merge(opts, component: :diff_banner), + children: [] + ) + end + @spec confidence_indicator(number(), keyword() | map()) :: Element.t() def confidence_indicator(value, opts \\ []) when is_number(value) do opts = normalize_opts(opts) @@ -153,6 +192,48 @@ defmodule UnifiedIUR.Widgets.Feedback do Map.get(opts, key, Map.get(opts, Atom.to_string(key), default)) end + defp normalize_non_negative_count!(value, _field) when is_integer(value) and value >= 0, + do: value + + defp normalize_non_negative_count!(value, field) do + raise ArgumentError, + "diff_banner :#{field} must be a non-negative integer, got: #{inspect(value)}" + end + + defp normalize_diff_filter!(value) when value in @diff_filters, do: value + + defp normalize_diff_filter!(value) when is_binary(value) do + value + |> String.to_existing_atom() + |> normalize_diff_filter!() + rescue + ArgumentError -> + raise ArgumentError, + "diff_banner :active_filter must be one of #{inspect(@diff_filters)}, got: #{inspect(value)}" + end + + defp normalize_diff_filter!(value) do + raise ArgumentError, + "diff_banner :active_filter must be one of #{inspect(@diff_filters)}, got: #{inspect(value)}" + end + + defp normalize_diff_size!(value) when value in @diff_sizes, do: value + + defp normalize_diff_size!(value) when is_binary(value) do + value + |> String.to_existing_atom() + |> normalize_diff_size!() + rescue + ArgumentError -> + raise ArgumentError, + "diff_banner :size must be one of #{inspect(@diff_sizes)}, got: #{inspect(value)}" + end + + defp normalize_diff_size!(value) do + raise ArgumentError, + "diff_banner :size must be one of #{inspect(@diff_sizes)}, got: #{inspect(value)}" + end + defp normalize_thresholds!(thresholds) when is_list(thresholds) do thresholds |> Enum.into(%{}) diff --git a/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs b/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs new file mode 100644 index 00000000..ff345031 --- /dev/null +++ b/packages/unified_iur/test/unified_iur/widgets/diff_banner_test.exs @@ -0,0 +1,86 @@ +defmodule UnifiedIUR.Widgets.DiffBannerTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias UnifiedIUR.Widgets.Feedback + + describe "diff_banner/1" do + test "builds a baseline feedback element with defaults" do + element = Feedback.diff_banner() + + assert %Element{kind: :diff_banner, type: :widget} = element + + assert %{ + diff: %{ + new_count: 0, + changed_count: 0, + removed_count: 0, + active_filter: :all, + show_filter_chips?: true, + size: :default + } + } = element.attributes + + refute Map.has_key?(element.attributes, :component) + end + + test "builds a banner with counts, base label, and filter intent" do + element = + Feedback.diff_banner( + id: "ask-diff", + new_count: 4, + changed_count: 7, + removed_count: 2, + base_label: "Compared to last run", + active_filter: :changed, + filter_intent: :filter_diff + ) + + assert element.id == "ask-diff" + assert get_in(element.attributes, [:diff, :new_count]) == 4 + assert get_in(element.attributes, [:diff, :changed_count]) == 7 + assert get_in(element.attributes, [:diff, :removed_count]) == 2 + assert get_in(element.attributes, [:diff, :base_label]) == "Compared to last run" + assert get_in(element.attributes, [:diff, :active_filter]) == :changed + assert get_in(element.attributes, [:diff, :filter_intent]) == :filter_diff + end + + test "accepts every filter including all" do + for filter <- [:all, :new, :changed, :removed] do + element = Feedback.diff_banner(active_filter: filter) + assert get_in(element.attributes, [:diff, :active_filter]) == filter + end + end + + test "accepts compact and default sizes" do + for size <- [:default, :compact] do + element = Feedback.diff_banner(size: size) + assert get_in(element.attributes, [:diff, :size]) == size + end + end + + test "rejects invalid counts" do + assert_raise ArgumentError, ~r/:new_count must be a non-negative integer/, fn -> + Feedback.diff_banner(new_count: -1) + end + + assert_raise ArgumentError, ~r/:changed_count must be a non-negative integer/, fn -> + Feedback.diff_banner(changed_count: -1) + end + + assert_raise ArgumentError, ~r/:removed_count must be a non-negative integer/, fn -> + Feedback.diff_banner(removed_count: -1) + end + end + + test "rejects invalid filter and size values" do + assert_raise ArgumentError, ~r/:active_filter must be one of/, fn -> + Feedback.diff_banner(active_filter: :stale) + end + + assert_raise ArgumentError, ~r/:size must be one of/, fn -> + Feedback.diff_banner(size: :large) + end + end + end +end diff --git a/packages/unified_iur/test/unified_iur/widgets_test.exs b/packages/unified_iur/test/unified_iur/widgets_test.exs index 9097c8d0..2739b57f 100644 --- a/packages/unified_iur/test/unified_iur/widgets_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets_test.exs @@ -39,7 +39,7 @@ defmodule UnifiedIUR.WidgetsTest do assert Widgets.data_view_kinds() == Data.kinds() - assert [:status, :progress, :gauge, :inline_feedback, :confidence_indicator] == + assert [:status, :progress, :gauge, :inline_feedback, :diff_banner, :confidence_indicator] == Widgets.feedback_kinds() assert Widgets.feedback_kinds() == Feedback.kinds() diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs index 7acbb669..8de418d1 100644 --- a/test/ash_ui/phase_31_package_boundary_test.exs +++ b/test/ash_ui/phase_31_package_boundary_test.exs @@ -77,6 +77,12 @@ defmodule AshUI.Phase31PackageBoundaryTest do refute :context_selector in AshUI.WidgetComponents.kinds() end + test "baseline feedback primitives stay out of the widget-component catalog" do + refute :diff_banner in UnifiedUi.WidgetComponents.kinds() + refute :diff_banner in UnifiedIUR.Widgets.Components.kinds() + refute :diff_banner in AshUI.WidgetComponents.kinds() + end + test "unknown names return the upstream catalog diagnostic" do assert {:error, diagnostic} = AshUI.WidgetComponents.canonical_kind(:not_in_catalog) diff --git a/test/ash_ui/phase_31_runtime_adapter_test.exs b/test/ash_ui/phase_31_runtime_adapter_test.exs index 0f020c42..6543e3c4 100644 --- a/test/ash_ui/phase_31_runtime_adapter_test.exs +++ b/test/ash_ui/phase_31_runtime_adapter_test.exs @@ -119,6 +119,31 @@ defmodule AshUI.Phase31RuntimeAdapterTest do refute Map.has_key?(canonical.attributes, :component) end + test "IUR adapter routes diff_banner props into baseline feedback attributes" do + assert {:ok, canonical} = + IUR.new(:diff_banner, + id: "ask-diff", + props: %{ + new_count: 3, + changed_count: 5, + removed_count: 1, + active_filter: :new, + filter_intent: :filter_diff + } + ) + |> IURAdapter.to_canonical() + + assert canonical.kind == :diff_banner + assert canonical.type == :widget + assert %{diff: diff} = canonical.attributes + assert diff.new_count == 3 + assert diff.changed_count == 5 + assert diff.removed_count == 1 + assert diff.active_filter == :new + assert diff.filter_intent == :filter_diff + refute Map.has_key?(canonical.attributes, :component) + end + test "live_ui_adapter fallback renders confidence_indicator with meter semantics" do assert {:ok, canonical} = IUR.new(:screen, @@ -182,6 +207,35 @@ defmodule AshUI.Phase31RuntimeAdapterTest do assert heex =~ ~s(data-context-value="all") assert heex =~ ~s(aria-selected="true") end + + test "live_ui_adapter fallback renders diff_banner with stable filter chips" do + assert {:ok, canonical} = + IUR.new(:screen, + id: "diff-screen", + name: "diff_test", + children: [ + IUR.new(:diff_banner, + id: "ask-diff", + props: %{ + new_count: 4, + changed_count: 7, + removed_count: 2, + active_filter: :changed + } + ) + ] + ) + |> IURAdapter.to_canonical() + + assert {:ok, heex} = LiveUIAdapter.render(canonical, force_fallback: true) + assert heex =~ "ash-diff-banner" + assert heex =~ ~s(data-live-ui-widget="diff-banner") + assert heex =~ ~s(data-active-filter="changed") + assert heex =~ "13 all" + assert heex =~ "4 new" + assert heex =~ "7 changed" + assert heex =~ "2 removed" + end end defp canonical_component do diff --git a/test/ash_ui/resource/diff_banner_test.exs b/test/ash_ui/resource/diff_banner_test.exs new file mode 100644 index 00000000..1df22c64 --- /dev/null +++ b/test/ash_ui/resource/diff_banner_test.exs @@ -0,0 +1,33 @@ +defmodule AshUI.Resource.DiffBannerTest do + use ExUnit.Case, async: true + + alias AshUI.DSL.Storage + alias AshUI.Resources.Validations.Authoring + + describe "diff_banner authoring admission" do + test "admits diff_banner as a baseline feedback widget type" do + assert Storage.valid_widget_type?("diff_banner") + assert Storage.canonical_widget_type(:diff_banner) == {:ok, "diff_banner"} + + assert %{type: :diff_banner} = + Authoring.validate_element_definition!(%{ + type: :diff_banner, + props: %{new_count: 3, changed_count: 2, removed_count: 1} + }) + end + + test "allows diff_banner filter actions" do + definition = %{type: :diff_banner, props: %{filter_intent: :filter_diff}} + + action = + Authoring.validate_action_definition!(%{ + id: :filter_diff, + signal: :change, + source: %{resource: "Demo.Finding", action: "filter"}, + target: "diff_filter" + }) + + assert :ok = Authoring.validate_element_authority!(definition, [], [action]) + end + end +end