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
166 changes: 151 additions & 15 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ defmodule LiveUi.Renderer do
:field,
:field_group,
:file_input,
:file_tree_browser,
:form_builder,
:gauge,
:grid,
Expand Down Expand Up @@ -70,6 +71,7 @@ defmodule LiveUi.Renderer do
:tabs,
:text,
:text_input,
:thread_card,
:time_input,
:toast,
:toggle,
Expand Down Expand Up @@ -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 %>
<span class="live-ui-mode-nav-item__glyph" aria-hidden="true">{glyph}</span>
<% end %>
<span class="live-ui-mode-nav-item__label">{Map.get(item, :label) || Map.get(item, "label") || ""}</span>
</button>
<% end %>
</nav>
Expand Down Expand Up @@ -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))

Expand All @@ -191,21 +201,19 @@ defmodule LiveUi.Renderer do
|> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs))

~H"""
<li
<LiveUi.Widgets.SidebarItem.component
id={element_id(@element, "sidebar-item")}
class={["live-ui-sidebar-item", if(get_in(@element.attributes, [:item, :selected?]), do: "live-ui-sidebar-item--selected"), style_class(@element)]}
>
<button
class="live-ui-sidebar-item-button"
aria-current={if get_in(@element.attributes, [:item, :selected?]), do: "page"}
{@interaction_attrs}
>
{get_in(@element.attributes, [:item, :label]) || ""}
<%= for child <- child_elements(@element, :default) do %>
<.render element={child} event_target={@event_target} />
<% end %>
</button>
</li>
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

Expand Down Expand Up @@ -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"""
<LiveUi.Widgets.ThreadCard.component
id={element_id(@element, "thread-card")}
thread_id={string_value(get_in(@element.attributes, [:thread, :thread_id]), "")}
title={string_value(get_in(@element.attributes, [:thread, :title]), "")}
reply_count={integer_value(get_in(@element.attributes, [:thread, :reply_count]), 0)}
seed_quote={string_value(get_in(@element.attributes, [:thread, :seed_quote]), "")}
participants={get_in(@element.attributes, [:participants]) || []}
progress_pct={get_in(@element.attributes, [:thread, :progress_pct])}
last_activity_at={get_in(@element.attributes, [:thread, :last_activity_at])}
open_intent={string_value(get_in(@element.attributes, [:thread, :open_intent]), "open_thread")}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
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"""
<LiveUi.Widgets.ComposerInlineAsk.component
id={element_id(@element, "composer-inline-ask")}
composer_id={string_value(get_in(@element.attributes, [:ask_preview, :composer_id]), "")}
ask_query={string_value(get_in(@element.attributes, [:ask_preview, :ask_query]), "")}
preview_state={get_in(@element.attributes, [:ask_preview, :preview_state]) || :empty}
on_dismiss={string_value(get_in(@element.attributes, [:ask_preview, :on_dismiss]), "dismiss")}
on_open_in_ask={string_value(get_in(@element.attributes, [:ask_preview, :on_open_in_ask]), "open_in_ask")}
on_save_query={string_value(get_in(@element.attributes, [:ask_preview, :on_save_query]), "save_query")}
explain={string_optional(get_in(@element.attributes, [:ask_preview, :explain]))}
meta={get_in(@element.attributes, [:ask_preview, :meta])}
preview_findings={get_in(@element.attributes, [:ask_preview, :preview_findings]) || []}
max_findings_shown={integer_value(get_in(@element.attributes, [:ask_preview, :max_findings_shown]), 2)}
error_message={string_optional(get_in(@element.attributes, [:ask_preview, :error_message]))}
event_target={string_optional(get_in(@element.attributes, [:ask_preview, :event_target]))}
loading_label={string_value(get_in(@element.attributes, [:ask_preview, :loading_label]), "Querying…")}
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 @@ -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"""
<LiveUi.Widgets.FileTreeBrowser.component
id={element_id(@element, "file-tree-browser")}
tree_id={string_value(get_in(@element.attributes, [:file_tree, :tree_id]), "ftb")}
root_label={string_value(get_in(@element.attributes, [:file_tree, :root_label]), "")}
nodes={get_in(@element.attributes, [:file_tree, :nodes]) || []}
selected_path={string_optional(get_in(@element.attributes, [:file_tree, :selected_path]))}
on_select={string_optional(get_in(@element.attributes, [:file_tree, :on_select]))}
on_toggle={string_optional(get_in(@element.attributes, [:file_tree, :on_toggle]))}
default_expanded={boolean_default(get_in(@element.attributes, [:file_tree, :default_expanded]), true)}
depth_indent_px={integer_value(get_in(@element.attributes, [:file_tree, :depth_indent_px]), 12)}
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: :status}} = assigns) do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down Expand Up @@ -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", []))
Expand Down
111 changes: 111 additions & 0 deletions packages/live_ui/lib/live_ui/widgets/sidebar_item.ex
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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 `<img aria-hidden="true">` 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 `<li>`: `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 `<img>` 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 `<img>` 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"""
<li
id={@id}
data-live-ui-widget="sidebar-item"
data-live-ui-item-state={if @item_state && @item_state != :default, do: to_string(@item_state)}
class={[
"live-ui-sidebar-item",
if(@selected?, do: "live-ui-sidebar-item--selected"),
state_modifier_class(@item_state),
@class
]}
{@rest}
>
<button
class="live-ui-sidebar-item-button"
aria-current={if @selected?, do: "page"}
data-live-ui-intent={@item_intent}
>
<%= if @avatar_url do %>
<img
class="live-ui-sidebar-item__avatar"
src={@avatar_url}
aria-hidden="true"
/>
<% end %>
{@label}
<%= render_slot(@inner_block) %>
</button>
</li>
"""
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
Loading
Loading