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 @@ -60,6 +60,10 @@ Required behavior:
for searchable collection selection. It is rail-agnostic and composes inside
`right_rail` or any other shell; it does not introduce app-specific rail
names, bundle-specific slots, or renderer event fields.
- `mode_nav` is a first-class `form_control_and_composer` component for
switching application modes. Per-item `glyph` is optional decorative display
metadata, uses a free string value, renders as a leading affordance, and does
not replace the required item label as the accessible name.
- Drift is caught by package-boundary tests before release.

### ash_ui.canonical_widget_components.authoring_admission
Expand Down
4 changes: 4 additions & 0 deletions guides/user/UG-0003-widget-types-properties-and-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ authoring boundaries and normalize before renderer-facing output.
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |

`mode_nav` item descriptors may include an optional `glyph` string. Renderers
treat it as decorative leading display metadata; keep `label` present because it
remains the accessible item name.

`list_repeat` is not a visual list shell. It is a composition behavior for
relationship-owned row templates. Declare the repeat list binding through
`ui_relationships`, keep the row template as an element resource, and use
Expand Down
5 changes: 4 additions & 1 deletion packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,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_value(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_value(item, :label, "")}</span>
</button>
<% end %>
</nav>
Expand Down
78 changes: 78 additions & 0 deletions packages/live_ui/test/live_ui/navigation_widgets_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule LiveUi.NavigationWidgetsTest do
import Phoenix.LiveViewTest

alias LiveUi.Component
alias UnifiedIUR.Widgets.Components
alias UnifiedIUR.Widgets.Navigation

@moduledoc """
Expand Down Expand Up @@ -274,6 +275,83 @@ defmodule LiveUi.NavigationWidgetsTest do
end
end

describe "mode_nav glyph rendering" do
test "renders mode_nav items without glyph by default" do
element =
Components.mode_nav(
[
%{value: :map, label: "Map"},
%{value: :chat, label: "Chat"}
],
id: "mode-nav-no-glyph",
aria_label: "Application modes"
)

html = render_component(&LiveUi.Renderer.render/1, %{element: element})

assert html =~ "Map"
assert html =~ "Chat"
refute html =~ "live-ui-mode-nav-item__glyph"
refute html =~ "aria-hidden"
end

test "renders mode_nav item glyph with aria-hidden when glyph is provided" do
element =
Components.mode_nav(
[
%{value: :map, label: "Map", glyph: "M"},
%{value: :chat, label: "Chat", glyph: "C"},
%{value: :ask, label: "Ask"}
],
id: "mode-nav-with-glyph",
aria_label: "Application modes"
)

html = render_component(&LiveUi.Renderer.render/1, %{element: element})

assert html =~ "M"
assert html =~ "C"
assert html =~ "Map"
assert html =~ "Chat"
assert html =~ "Ask"
assert html =~ ~s(class="live-ui-mode-nav-item__glyph")
assert html =~ ~s(aria-hidden="true")
end

test "glyph span wraps glyph value and label is in separate span" do
element =
Components.mode_nav(
[%{value: :workspace, label: "Workspace", glyph: "W"}],
id: "mode-nav-glyph-structure"
)

html = render_component(&LiveUi.Renderer.render/1, %{element: element})

assert html =~ ~s(<span class="live-ui-mode-nav-item__glyph" aria-hidden="true">W</span>)
assert html =~ ~s(<span class="live-ui-mode-nav-item__label">Workspace</span>)
end

test "items without glyph do not render glyph span" do
element =
Components.mode_nav(
[
%{value: :a, label: "Alpha", glyph: "A"},
%{value: :b, label: "Beta"}
],
id: "mode-nav-mixed-glyph"
)

html = render_component(&LiveUi.Renderer.render/1, %{element: element})

# Alpha has a glyph
assert html =~ ~s(aria-hidden="true")
assert html =~ "A"
assert html =~ "Alpha"
# Beta label still rendered in label span
assert html =~ ~s(<span class="live-ui-mode-nav-item__label">Beta</span>)
end
end

describe "canonical navigation rendering" do
test "navigation widgets preserve identity in canonical rendering" do
# This would be tested through the canonical renderer
Expand Down
23 changes: 22 additions & 1 deletion packages/unified_iur/lib/unified_iur/widgets/components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@ defmodule UnifiedIUR.Widgets.Components do
%{
navigation:
%{
items: normalize_maps(items)
items: normalize_mode_nav_items(items)
}
|> maybe_put(:aria_label, option(opts, :aria_label))
|> maybe_put(:navigation_intent, option(opts, :navigation_intent))
Expand Down Expand Up @@ -1715,6 +1715,27 @@ defmodule UnifiedIUR.Widgets.Components do
end)
end

defp normalize_mode_nav_items(items) when is_list(items) do
Enum.map(items, &normalize_mode_nav_item/1)
end

defp normalize_mode_nav_items(_items), do: []

defp normalize_mode_nav_item(item) do
item = normalize_map(item)

item
|> Map.delete("glyph")
|> maybe_put(:glyph, normalize_mode_nav_glyph!(option(item, :glyph)))
end

defp normalize_mode_nav_glyph!(nil), do: nil
defp normalize_mode_nav_glyph!(glyph) when is_binary(glyph), do: glyph

defp normalize_mode_nav_glyph!(glyph) do
raise ArgumentError, "mode_nav item :glyph must be a string, got: #{inspect(glyph)}"
end

defp normalize_maps(values) when is_list(values) do
Enum.map(values, &normalize_map/1)
end
Expand Down
65 changes: 65 additions & 0 deletions packages/unified_iur/test/unified_iur/widgets/components_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,71 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
repeat.children
end

describe "mode_nav constructor" do
test "normalizes items and preserves glyph when present" do
element =
Components.mode_nav(
[
%{value: :map, label: "Map", glyph: "M"},
%{value: :chat, label: "Chat", glyph: "C"},
%{value: :ask, label: "Ask"}
],
id: "mode-nav-glyph",
aria_label: "Application modes",
navigation_intent: :switch_mode
)

assert %Element{kind: :mode_nav} = element
assert element.id == "mode-nav-glyph"

items = get_in(element.attributes, [:navigation, :items])
assert length(items) == 3

[map_item, chat_item, ask_item] = items
assert map_item.label == "Map"
assert map_item.glyph == "M"
assert chat_item.label == "Chat"
assert chat_item.glyph == "C"
assert ask_item.label == "Ask"
refute Map.has_key?(ask_item, :glyph)
end

test "normalizes items without glyph as backward-compatible mode_nav items" do
element =
Components.mode_nav(
[
%{value: :workspace, label: "Workspace", current?: true},
%{value: :settings, label: "Settings"}
],
id: "mode-nav-no-glyph",
navigation_intent: :switch_mode
)

items = get_in(element.attributes, [:navigation, :items])
assert length(items) == 2
Enum.each(items, fn item -> refute Map.has_key?(item, :glyph) end)
end

test "normalizes string-keyed glyph onto the canonical atom key" do
element =
Components.mode_nav(
[
%{"value" => "map", "label" => "Map", "glyph" => "M"}
],
id: "mode-nav-string-glyph"
)

assert [%{glyph: "M"} = item] = get_in(element.attributes, [:navigation, :items])
refute Map.has_key?(item, "glyph")
end

test "rejects non-string glyph values" do
assert_raise ArgumentError, ~r/mode_nav item :glyph must be a string/, fn ->
Components.mode_nav([%{value: :map, label: "Map", glyph: :map}])
end
end
end

test "represents collapsible sidebar sections with semantic change interactions" do
section =
Components.sidebar_section("Docs", [],
Expand Down
2 changes: 1 addition & 1 deletion packages/unified_ui/lib/unified_ui/widget_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ defmodule UnifiedUi.WidgetComponents do
kind: :mode_nav,
family: :form_control_and_composer,
summary:
"Navigation control for switching application modes with labeled items and shortcuts.",
"Navigation control for switching application modes with labeled items and optional decorative glyphs.",
aliases: []
},
%{
Expand Down
3 changes: 3 additions & 0 deletions specs/contracts/canonical_widget_components_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Ash UI MUST adopt these canonical widget-component kinds:
| `code_block_syntax_highlighted` | redline and code | - |
| `chat_composer` | form control and composer | - |
| `collection_picker` | form control and composer | - |
| `mode_nav` | form control and composer | - |
| `list_repeat` | composition behavior | `repeat`, `ui_relationship_repeat` |

## Requirements
Expand Down Expand Up @@ -88,6 +89,8 @@ Ash UI MUST map component props into the canonical attribute namespaces expected
Acceptance criteria:

- Component attributes match `UnifiedIUR.Widgets.Components` constructor output where practical.
- `mode_nav` item descriptors may include optional `glyph` as a free string
decorative display affordance; labels remain the accessible item names.
- Component family metadata is preserved when required by the upstream contract.
- Ash-owned resource metadata stays under Ash-owned metadata keys.
- Unknown passthrough props do not overwrite canonical namespaces.
Expand Down
Loading