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
4 changes: 4 additions & 0 deletions .spec/specs/canonical_widget_components.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Required behavior:
- Supported aliases match `UnifiedUi.WidgetComponents.aliases/0`.
- `thread_card` is a first-class `row_and_artifact` component, not a
content-identity component or baseline navigation primitive.
- `composer_query_preview` is a first-class `layer_shell_and_callout`
component for composer-adjacent query result previews; it does not expose
product-specific Ask/MGQL names or renderer event fields in its canonical
contract.
- Drift is caught by package-boundary tests before release.

### ash_ui.canonical_widget_components.authoring_admission
Expand Down
11 changes: 10 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 @@ -100,7 +100,7 @@ authoring boundaries and normalize before renderer-facing output.
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |

Expand Down Expand Up @@ -134,6 +134,13 @@ such as `subject_id`, `name`, `path`, `progress_pct`, `active_count`,
`attributes.subject`; it does not expose map placement names, route helpers,
LiveView event fields, or application-specific card names.

`composer_query_preview` is the reusable inline preview band for query results
adjacent to a composer. Author generic props such as `composer_id`, `query`,
`preview_state`, `explanation`, `metrics`, `findings`, and
`max_findings_shown`; use canonical interactions for dismiss, open, and save
actions. Keep product mode names, keyboard shortcut hints, route helpers, and
LiveView event fields in the host layer.

You can also author `custom:*` types. They are accepted as widget types, but the
shipped validation/runtime does not automatically give them built-in signal
semantics. Some explicitly supported custom surfaces do have dedicated fallback
Expand Down Expand Up @@ -236,6 +243,7 @@ Signal support is type-specific.
| `form_builder` | `:submit` |
| `diff_banner` | `:change`, `:input` |
| `right_rail` | `:change`, `:toggle`, `:click` |
| `composer_query_preview` | `:click` |

If you declare a signal outside this matrix, authoring validation raises.

Expand Down Expand Up @@ -269,6 +277,7 @@ The current action-binding-capable widgets are:
- `context_selector`
- `file_tree_browser`
- `right_rail`
- `composer_query_preview`

## Compatibility and Normalization Notes

Expand Down
52 changes: 52 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,13 @@ defmodule AshUI.Rendering.IURAdapter do
)
end

defp base_attributes(:composer_query_preview, props) do
props
|> composer_query_preview_opts()
|> IURComponents.composer_query_preview()
|> Map.fetch!(:attributes)
end

defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
Expand Down Expand Up @@ -1054,6 +1061,51 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end

defp composer_query_preview_opts(props) do
preview = props |> fetch(:query_preview, %{}) |> normalize_map()

%{
id: first_present(props, [:_element_id, :id]),
composer_id: first_present(preview, [:composer_id]) || first_present(props, [:composer_id]),
query:
first_present(preview, [:query]) || first_present(props, [:query, :ask_query, :value]),
preview_state:
normalize_existing_atom(
first_present(preview, [:preview_state]) ||
first_present(props, [:preview_state]) ||
:empty
),
explanation:
first_present(preview, [:explanation]) ||
first_present(props, [:explanation, :explain, :summary]),
metrics:
normalize_optional_map(
first_present(preview, [:metrics]) || first_present(props, [:metrics, :meta])
),
findings:
first_present(preview, [:findings]) ||
first_present(props, [:findings, :preview_findings]) ||
[],
max_findings_shown:
first_present(preview, [:max_findings_shown]) ||
first_present(props, [:max_findings_shown]) ||
2,
error_message:
first_present(preview, [:error_message]) || first_present(props, [:error_message]),
loading_label:
first_present(preview, [:loading_label]) || first_present(props, [:loading_label]),
empty_label: first_present(preview, [:empty_label]) || first_present(props, [:empty_label]),
open_label: first_present(preview, [:open_label]) || first_present(props, [:open_label]),
save_label: first_present(preview, [:save_label]) || first_present(props, [:save_label]),
dismiss_intent: first_present(props, [:dismiss_intent]),
open_intent: first_present(props, [:open_intent]),
save_intent: first_present(props, [:save_intent]),
interactions: fetch(props, :interactions),
interaction: fetch(props, :interaction)
}
|> compact_map()
end

defp workflow_progress_status_card_opts(props) do
%{
subject_id: first_present(props, [:subject_id]),
Expand Down
91 changes: 91 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,97 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end

defp generate_heex(%{"type" => "composer_query_preview"} = iur, _opts) do
props = iur["props"] || %{}
preview = props |> prop("query_preview", props) |> normalize_item()
composer_id = escaped_text_prop(preview, "composer_id", iur["id"] || "")
query = escaped_text_prop(preview, "query", "")
preview_state = escaped_text_prop(preview, "preview_state", "empty")
explanation = escaped_text_prop(preview, "explanation")
error_message = escaped_text_prop(preview, "error_message", "Query failed. Try again.")
loading_label = escaped_text_prop(preview, "loading_label", "Searching")
empty_label = escaped_text_prop(preview, "empty_label", "No results for this query.")
metrics = preview |> prop("metrics", %{}) |> normalize_item()
findings = List.wrap(prop(preview, "findings", []))
max_findings_shown = max(numeric_count(prop(preview, "max_findings_shown", 2)), 1)

metrics_html =
if metrics == %{} do
""
else
results_count = escaped_text_prop(metrics, "results_count")
duration_ms = prop(metrics, "duration_ms")
sources_visited = escaped_text_prop(metrics, "sources_visited")

duration =
case duration_ms do
value when is_integer(value) or is_float(value) ->
:erlang.float_to_binary(value / 1000, decimals: 2) <> "s"

value when not is_nil(value) ->
html_escape(value)

_other ->
nil
end

"""
<div class="ash-composer-query-preview__metrics" aria-label="Query statistics">
#{if results_count != "", do: "<span><strong>#{results_count}</strong> results</span>", else: ""}
#{if duration, do: "<span>#{duration}</span>", else: ""}
#{if sources_visited != "", do: "<span>#{sources_visited} sources</span>", else: ""}
</div>
"""
end

findings_html =
findings
|> Enum.take(max_findings_shown)
|> Enum.map_join(fn finding ->
finding = normalize_item(finding)
id = escaped_text_prop(finding, ["id", "finding_id"], "")
n = escaped_text_prop(finding, "n", "")
snippet = escaped_text_prop(finding, "snippet", "")
confidence = escaped_text_prop(finding, "confidence", "")

"""
<li class="ash-composer-query-preview__finding" data-result-id="#{id}">
<span class="ash-composer-query-preview__finding-rank">##{n}</span>
<span class="ash-composer-query-preview__finding-snippet">#{snippet}</span>
<span class="ash-composer-query-preview__finding-confidence">#{confidence}</span>
</li>
"""
end)

body_html =
case preview_state do
"loading" ->
~s(<div class="ash-composer-query-preview__loading" aria-busy="true">#{loading_label}</div>)

"ready" ->
"""
#{if explanation, do: "<div class=\"ash-composer-query-preview__explanation\">#{explanation}</div>", else: ""}
#{metrics_html}
#{if findings_html != "", do: "<ul class=\"ash-composer-query-preview__findings\" aria-label=\"Query preview results\">#{findings_html}</ul>", else: ""}
"""

"error" ->
~s(<div class="ash-composer-query-preview__error" role="alert">#{error_message}</div>)

_other ->
~s(<div class="ash-composer-query-preview__empty">#{empty_label}</div>)
end

"""
<section class="#{css_classes(["ash-composer-query-preview", prop_class(iur)])}" data-live-ui-widget="composer-query-preview" data-composer-id="#{composer_id}" data-preview-state="#{preview_state}" role="region" aria-label="Query preview: #{query}" aria-live="polite"#{style_attr(prop_style(iur))}>
<header class="ash-composer-query-preview__header">
<span class="ash-composer-query-preview__query"><q>#{query}</q></span>
</header>
#{body_html}
</section>
"""
end

defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do
props = iur["props"] || %{}
active? = truthy_prop(props, "active?", truthy_prop(props, "active", false))
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 @@ -40,6 +40,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"context_selector",
"file_tree_browser",
"right_rail",
"composer_query_preview",
"workflow_progress_status_card"
])
@signal_capabilities %{
Expand All @@ -60,6 +61,7 @@ defmodule AshUI.Resources.Validations.Authoring do
"context_selector" => [:change, :input],
"file_tree_browser" => [:change, :input, :toggle],
"right_rail" => [:change, :toggle, :click],
"composer_query_preview" => [:click],
"workflow_progress_status_card" => [:click, :change]
}

Expand Down
100 changes: 100 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defmodule LiveUi.Renderer do
:cluster_dashboard,
:column,
:command_palette,
:composer_query_preview,
:confidence_indicator,
:content,
:context_selector,
Expand Down Expand Up @@ -740,6 +741,48 @@ defmodule LiveUi.Renderer do
"""
end

# NOTE: `:composer_query_preview` is a canonical layer-shell component. Keep
# this native renderer clause before the generic component fallback so action
# buttons receive canonical interaction transport.
def render(%{element: %Element{kind: :composer_query_preview}} = assigns) do
preview = query_preview_attributes(assigns.element)

assigns =
assigns
|> assign(:preview, preview)
|> assign(
:action_attrs,
query_preview_action_attrs(assigns.element, Map.get(assigns, :event_target), preview)
)
|> assign(:style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.ComposerQueryPreview.component
id={element_id(@element, "composer-query-preview")}
composer_id={string_value(map_value(@preview, :composer_id), "")}
query={string_value(map_value(@preview, :query), "")}
preview_state={map_value(@preview, :preview_state, :empty)}
explanation={string_optional(map_value(@preview, :explanation))}
metrics={map_value(@preview, :metrics)}
findings={List.wrap(map_value(@preview, :findings, []))}
max_findings_shown={integer_value(map_value(@preview, :max_findings_shown), 2)}
error_message={string_optional(map_value(@preview, :error_message))}
loading_label={string_value(map_value(@preview, :loading_label), "Searching")}
empty_label={string_value(map_value(@preview, :empty_label), "No results for this query.")}
open_label={string_value(map_value(@preview, :open_label), "Open query")}
save_label={string_value(map_value(@preview, :save_label), "Save query")}
dismiss_attrs={Map.get(@action_attrs, :dismiss, %{})}
open_attrs={Map.get(@action_attrs, :open, %{})}
save_attrs={Map.get(@action_attrs, :save, %{})}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down Expand Up @@ -2332,6 +2375,63 @@ defmodule LiveUi.Renderer do
end
end

defp query_preview_attributes(%Element{} = element) do
element.attributes
|> Map.get(:query_preview, Map.get(element.attributes, "query_preview", %{}))
|> case do
preview when is_map(preview) -> preview
preview when is_list(preview) -> Map.new(preview)
_other -> %{}
end
end

defp query_preview_action_attrs(%Element{} = element, event_target, preview) do
query = map_value(preview, :query)

%{
dismiss:
query_preview_interaction_attrs(
element,
event_target,
primary_interaction(element, :close),
query
),
open:
query_preview_interaction_attrs(
element,
event_target,
primary_interaction(element, :open),
query
),
save:
query_preview_interaction_attrs(
element,
event_target,
primary_interaction(element, :command),
query
)
}
end

defp query_preview_interaction_attrs(
%Element{} = element,
event_target,
%Interaction{} = interaction,
query
)
when not is_nil(event_target) do
%{
:"phx-click" => "canonical_interaction",
:"phx-target" => event_target,
:"phx-value-interaction" => encode_interaction(interaction),
:"phx-value-element_id" => element_id(element, "composer-query-preview"),
:"phx-value-widget" => "composer_query_preview",
:"phx-value-query" => to_string(query || "")
}
end

defp query_preview_interaction_attrs(_element, _event_target, _interaction, _query), do: %{}

defp right_rail_panels(rail) do
rail
|> map_value(:panels, [])
Expand Down
Loading
Loading