Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion guides/user/UG-0003-widget-types-properties-and-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?` |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/ash_ui/dsl/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule AshUI.DSL.Storage do
"hero",
"badge",
"confidence_indicator",
"diff_banner",
"stat",
"key_value",
"info_list",
Expand Down
28 changes: 28 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
%{
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(<span class="ash-diff-banner__chip ash-diff-banner__chip--#{kind}#{active_class}" data-filter-kind="#{kind}">#{html_escape(count)} #{kind}</span>)
end)

base_html =
if base_label && size != "compact" do
~s(<span class="ash-diff-banner__base">#{base_label}</span>)
else
""
end

"""
<aside class="#{css_classes(["ash-diff-banner", "ash-diff-banner--#{size}", prop_class(iur)])}" data-live-ui-widget="diff-banner" data-active-filter="#{html_attr(active_filter)}"#{style_attr(prop_style(iur))}>
#{base_html}
<div class="ash-diff-banner__chips">
#{chips}
</div>
</aside>
"""
end

defp generate_heex(%{"type" => "ask_sidebar"} = iur, _opts) do
props = iur["props"] || %{}
sidebar_id = escaped_text_prop(props, ["sidebar_id"], "")
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/ash_ui/resources/validations/authoring.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions packages/desktop_ui/lib/desktop_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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)
Expand Down Expand Up @@ -76,6 +76,7 @@ defmodule DesktopUi.Renderer do
:tree_view,
:alert_dialog,
:dialog,
:diff_banner,
:inline_feedback,
:progress,
:status,
Expand Down
29 changes: 29 additions & 0 deletions packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))

Expand Down
6 changes: 3 additions & 3 deletions packages/desktop_ui/lib/desktop_ui/validate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/desktop_ui/lib/desktop_ui/widget.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ defmodule DesktopUi.Widget do
:progress,
:severity,
:streaming,
:paused
:paused,
:active_filter,
:size
],
bindings: [
:value,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: %{}
Expand Down
5 changes: 5 additions & 0 deletions packages/desktop_ui/lib/desktop_ui/widgets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 35 additions & 2 deletions packages/desktop_ui/lib/desktop_ui/widgets/feedback.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading