diff --git a/.spec/specs/canonical_widget_components.spec.md b/.spec/specs/canonical_widget_components.spec.md index 54834ded..0f342071 100644 --- a/.spec/specs/canonical_widget_components.spec.md +++ b/.spec/specs/canonical_widget_components.spec.md @@ -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 diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md index e95f5d02..91b84ac1 100644 --- a/guides/user/UG-0003-widget-types-properties-and-signals.md +++ b/guides/user/UG-0003-widget-types-properties-and-signals.md @@ -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 diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index d6d6680b..e704ddd9 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -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 %> + + <% end %> + {map_value(item, :label, "")} <% end %> diff --git a/packages/live_ui/test/live_ui/navigation_widgets_test.exs b/packages/live_ui/test/live_ui/navigation_widgets_test.exs index 2bf24398..e9af94e6 100644 --- a/packages/live_ui/test/live_ui/navigation_widgets_test.exs +++ b/packages/live_ui/test/live_ui/navigation_widgets_test.exs @@ -4,6 +4,7 @@ defmodule LiveUi.NavigationWidgetsTest do import Phoenix.LiveViewTest alias LiveUi.Component + alias UnifiedIUR.Widgets.Components alias UnifiedIUR.Widgets.Navigation @moduledoc """ @@ -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() + assert html =~ ~s(Workspace) + 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(Beta) + end + end + describe "canonical navigation rendering" do test "navigation widgets preserve identity in canonical rendering" do # This would be tested through the canonical renderer diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index 3c6d4766..ddba87cc 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -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)) @@ -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 diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs index c78dd9b7..bcbcd41e 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -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", [], diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index 2ad273c4..b3e8cf67 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -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: [] }, %{ diff --git a/specs/contracts/canonical_widget_components_contract.md b/specs/contracts/canonical_widget_components_contract.md index baec3d0b..ad604593 100644 --- a/specs/contracts/canonical_widget_components_contract.md +++ b/specs/contracts/canonical_widget_components_contract.md @@ -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 @@ -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.