Skip to content
Draft
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
36 changes: 36 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,42 @@ defmodule AshUI.Rendering.IURAdapter do
defp base_attributes(:canvas, props), do: %{canvas: Map.drop(props, attachment_prop_keys())}
defp base_attributes(:form_builder, props), do: %{form: Map.drop(props, attachment_prop_keys())}
defp base_attributes(:field_group, props), do: %{group: Map.drop(props, attachment_prop_keys())}

defp base_attributes(:needs_you_section = kind, props) do
component_attributes(
kind,
:workflow_progress_and_status,
%{
section:
compact_map(%{
title: first_present(props, [:title, :label]),
empty_state_text: first_present(props, [:empty_state_text]),
max_visible: fetch(props, :max_visible)
})
},
props
)
end

defp base_attributes(:blocker_row = kind, props) do
component_attributes(
kind,
:row_and_artifact,
%{
blocker:
compact_map(%{
row_id: first_present(props, [:row_id, :id]),
ask_text: first_present(props, [:ask_text, :text, :label]),
scope_label: first_present(props, [:scope_label, :scope]),
scope_intent: first_present(props, [:scope_intent]) || :jump_to_blocker,
severity: normalize_existing_atom(first_present(props, [:severity]) || :info)
}),
actor: normalize_map(fetch(props, :actor))
},
props
)
end

defp base_attributes(kind, props), do: %{kind => Map.drop(props, attachment_prop_keys())}

defp style_attributes(props) do
Expand Down
82 changes: 82 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,88 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end

defp generate_heex(%{"type" => "needs_you_section"} = iur, opts) do
props = iur["props"] || %{}
section = normalize_item(prop(props, "section", %{}))
title = escaped_text_prop(section, ["title", "label"], "Needs you")
empty_state_text = escaped_text_prop(section, "empty_state_text", "You're all caught up.")
max_visible = numeric_value(section, "max_visible", 5)
children = List.wrap(iur["children"] || [])

visible_children = Enum.take(children, max_visible)
overflow_count = length(children) - max_visible

rows_html = generate_children(visible_children, opts)

overflow_html =
if overflow_count > 0 do
~s(<button type="button" class="ash-needs-you-section__more" data-live-ui-intent="expand_needs_you_section">show #{overflow_count} more</button>)
else
""
end

item_count = length(children)

count_html =
if children != [] do
"<span class=\"ash-needs-you-section__count\" aria-hidden=\"true\">(#{item_count})</span>"
else
""
end

list_or_empty_html =
if children == [] do
~s(<p class="ash-needs-you-section__empty">#{empty_state_text}</p>)
else
~s(<ul class="ash-needs-you-section__list" role="list">#{rows_html}</ul>#{overflow_html})
end

"""
<section class="#{css_classes(["ash-needs-you-section", prop_class(iur)])}"#{style_attr(prop_style(iur))}>
<header class="ash-needs-you-section__header">
<h2 class="ash-needs-you-section__title">#{title}</h2>#{count_html}
</header>
#{list_or_empty_html}
</section>
"""
end

defp generate_heex(%{"type" => "blocker_row"} = iur, _opts) do
props = iur["props"] || %{}
blocker = normalize_item(prop(props, "blocker", %{}))
actor = normalize_item(prop(props, "actor", %{}))

ask_text = escaped_text_prop(blocker, ["ask_text", "text", "label"], "")
scope_label = escaped_text_prop(blocker, ["scope_label", "scope"], "")
severity = escaped_text_prop(blocker, "severity", "info")
row_id = html_attr(prop(blocker, "row_id", prop(iur, "id", "")))

actor_initials = escaped_text_prop(actor, ["initials"], "")
actor_name = escaped_text_prop(actor, ["actor_name", "name"], "")
actor_image = prop(actor, "image_source")

aria_label = "#{ask_text} — #{scope_label}"

avatar_html =
if actor_image do
~s(<span class="ash-blocker-row__avatar" aria-hidden="true"><img src="#{html_attr(actor_image)}" alt="" class="ash-blocker-row__avatar-image" /></span>)
else
~s(<span class="ash-blocker-row__avatar" aria-hidden="true"><span class="ash-blocker-row__avatar-initials">#{actor_initials}</span></span>)
end

"""
<button type="button" class="#{css_classes(["ash-blocker-row", "ash-blocker-row--#{severity}", prop_class(iur)])}" data-row-id="#{row_id}" data-severity="#{severity}" aria-label="#{aria_label}"#{style_attr(prop_style(iur))}>
#{avatar_html}
<span class="ash-blocker-row__actor-name" aria-hidden="true">#{actor_name}</span>
<div class="ash-blocker-row__body">
<span class="ash-blocker-row__ask">#{ask_text}</span>
<span class="ash-blocker-row__scope">#{scope_label}</span>
</div>
<span class="ash-blocker-row__jump" aria-hidden="true">jump</span>
</button>
"""
end

defp generate_heex(%{"type" => "artifact_row"} = iur, opts) do
props = iur["props"] || %{}
title = escaped_text_prop(props, ["title", "label"], "Artifact")
Expand Down
63 changes: 63 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,69 @@ defmodule LiveUi.Renderer do
"""
end

# NOTE: `:needs_you_section` and `:blocker_row` are members of `@component_kinds`
# (via `@workflow_kinds` and `@row_artifact_kinds`). Place these dedicated clauses
# BEFORE the generic `@component_kinds` fallback to avoid shadowing.
def render(%{element: %Element{kind: :needs_you_section}} = 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"""
<LiveUi.Widgets.NeedsYouSection.component
id={element_id(@element, "needs-you-section")}
title={string_value(get_in(@element.attributes, [:section, :title]), "Needs you")}
empty_state_text={
string_value(
get_in(@element.attributes, [:section, :empty_state_text]),
"You're all caught up."
)
}
max_visible={integer_value(get_in(@element.attributes, [:section, :max_visible]), 5)}
items={child_elements(@element, :default)}
event_target={@event_target}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: :blocker_row}} = 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))

# Use element id, falling back to a prefixed row_id so multiple rows in the
# same section don't collide on the LiveComponent id requirement.
row_id_attr = string_value(get_in(assigns.element.attributes, [:blocker, :row_id]), "row")
component_id = element_id(assigns.element, "blocker-row-#{row_id_attr}")
assigns = assign(assigns, :component_id, component_id)

~H"""
<LiveUi.Widgets.BlockerRow.component
id={@component_id}
row_id={string_value(get_in(@element.attributes, [:blocker, :row_id]), "")}
ask_text={string_value(get_in(@element.attributes, [:blocker, :ask_text]), "")}
scope_label={string_value(get_in(@element.attributes, [:blocker, :scope_label]), "")}
severity={string_value(get_in(@element.attributes, [:blocker, :severity]), "info")}
actor={get_in(@element.attributes, [:actor]) || %{}}
interaction_attrs={@interaction_attrs}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

# NOTE: `:command_palette` is a member of `@layer_callout_kinds` (and therefore
# of `@component_kinds`), so the generic fallback below would shadow any later
# `:command_palette` clause. Keep this specific clause BEFORE the generic
Expand Down
33 changes: 31 additions & 2 deletions packages/live_ui/lib/live_ui/widgets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,38 @@ defmodule LiveUi.Widgets do
| :data
| :operational
| :display
| :workflow
| :row_and_artifact

@type widget_module :: module()

@spec families() :: [family()]
def families do
[:content, :input, :navigation, :feedback, :layout, :overlay, :data, :operational, :display]
[
:content,
:input,
:navigation,
:feedback,
:layout,
:overlay,
:data,
:operational,
:display,
:workflow,
:row_and_artifact
]
end

@spec modules() :: [widget_module()]
def modules do
foundational_modules() ++
input_modules() ++
navigation_modules() ++ advanced_modules() ++ overlay_modules() ++ display_modules()
navigation_modules() ++
advanced_modules() ++
overlay_modules() ++
display_modules() ++
workflow_modules() ++
row_and_artifact_modules()
end

@spec metadata() :: [LiveUi.Component.Metadata.t()]
Expand Down Expand Up @@ -63,6 +82,16 @@ defmodule LiveUi.Widgets do
LiveUi.Widgets.Display.modules()
end

@spec workflow_modules() :: [widget_module()]
def workflow_modules do
LiveUi.Widgets.Workflow.modules()
end

@spec row_and_artifact_modules() :: [widget_module()]
def row_and_artifact_modules do
LiveUi.Widgets.RowAndArtifact.modules()
end

@spec namespace() :: module()
def namespace, do: __MODULE__
end
86 changes: 86 additions & 0 deletions packages/live_ui/lib/live_ui/widgets/blocker_row.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule LiveUi.Widgets.BlockerRow do
@moduledoc """
Single actionable blocker row for use inside `needs_you_section`.

Renders as a `<button>` (full keyboard and screen-reader semantics) with:
- Actor display: initials or image avatar (decorative, `aria-hidden`)
- Body: `ask_text` + `scope_label` as the accessible button label
- Jump affordance: visual arrow (aria-hidden)

Severity is expressed via `data-severity` + a modifier class. The attribute
is also useful as a CSS hook for host-level style overrides.

ARIA: `aria-label` is constructed from `"{ask_text} — {scope_label}"` so
screen-readers hear the full context. The avatar and jump arrow are both
decorative.

Open questions flagged in PR for Pascal review:
- Row family: `:row_and_artifact` (current) vs `:content_identity_and_disclosure`?
- Avatar inline vs child IUR: current draft inlines actor.avatar attrs inline.
- Severity granularity: `[:info, :warn, :critical]` vs workflow-specific atoms?
"""

use LiveUi.Component,
family: :row_and_artifact,
name: :blocker_row,
events: [:click]

LiveUi.Component.common_attrs()
attr(:row_id, :string, required: true)
attr(:ask_text, :string, required: true)
attr(:scope_label, :string, required: true)
attr(:severity, :string, default: "info")
attr(:actor, :map, default: %{})
attr(:interaction_attrs, :map, default: %{})

@impl true
def render(assigns) do
actor_initials =
get_in(assigns.actor, [:initials]) || get_in(assigns.actor, ["initials"]) || ""

actor_image =
get_in(assigns.actor, [:image_source]) || get_in(assigns.actor, ["image_source"])

actor_name =
get_in(assigns.actor, [:actor_name]) || get_in(assigns.actor, ["actor_name"]) || "Actor"

aria_label = "#{assigns.ask_text} — #{assigns.scope_label}"

assigns =
assign(assigns, :actor_initials, actor_initials)
|> assign(:actor_image, actor_image)
|> assign(:actor_name, actor_name)
|> assign(:aria_label, aria_label)

~H"""
<button
id={@id}
type="button"
data-live-ui-widget="blocker-row"
data-row-id={@row_id}
data-severity={@severity}
data-live-ui-tone={@tone}
data-live-ui-variant={@variant}
data-live-ui-state={@state}
class={["live-ui-blocker-row", "live-ui-blocker-row--#{@severity}", @class]}
aria-label={@aria_label}
{@interaction_attrs}
{@rest}
>
<span class="live-ui-blocker-row__avatar" aria-hidden="true">
<%= if @actor_image do %>
<img src={@actor_image} alt="" class="live-ui-blocker-row__avatar-image" />
<% else %>
<span class="live-ui-blocker-row__avatar-initials">{@actor_initials}</span>
<% end %>
</span>
<span class="live-ui-blocker-row__actor-name" aria-hidden="true">{@actor_name}</span>
<div class="live-ui-blocker-row__body">
<span class="live-ui-blocker-row__ask">{@ask_text}</span>
<span class="live-ui-blocker-row__scope">{@scope_label}</span>
</div>
<span class="live-ui-blocker-row__jump" aria-hidden="true">jump</span>
</button>
"""
end
end
Loading