diff --git a/packages/desktop_ui/lib/desktop_ui/widgets/components.ex b/packages/desktop_ui/lib/desktop_ui/widgets/components.ex index 0b8884e4..6ccf9fcc 100644 --- a/packages/desktop_ui/lib/desktop_ui/widgets/components.ex +++ b/packages/desktop_ui/lib/desktop_ui/widgets/components.ex @@ -16,7 +16,8 @@ defmodule DesktopUi.Widgets.Components do @form_control_kinds [ :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :mode_nav ] @row_artifact_kinds [:list_item_multi_column, :artifact_row] @@ -25,13 +26,19 @@ defmodule DesktopUi.Widgets.Components do :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, - :meter_thin + :meter_thin, + :unread_badge ] @layer_callout_kinds [ :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette ] @redline_code_kinds [:redline_inline, :code_block_syntax_highlighted] @@ -55,6 +62,13 @@ defmodule DesktopUi.Widgets.Components do sticky_frosted_header: :layer_callout, slide_over_panel: :layer_callout, event_callout: :layer_callout, + top_strip: :layer_callout, + sidebar_shell: :layer_callout, + sidebar_section: :layer_callout, + sidebar_item: :layer_callout, + command_palette: :layer_callout, + mode_nav: :form_control, + unread_badge: :workflow_progress, redline_inline: :redline_code, code_block_syntax_highlighted: :redline_code, list_repeat: :composition_behavior @@ -70,7 +84,10 @@ defmodule DesktopUi.Widgets.Components do :pipeline_stepper_horizontal, :workflow_stage_list_vertical, :slide_over_panel, - :event_callout + :event_callout, + :mode_nav, + :sidebar_item, + :command_palette ] @kinds @content_identity_kinds ++ @@ -532,6 +549,150 @@ defmodule DesktopUi.Widgets.Components do ) end + @spec mode_nav(String.t() | atom(), [keyword() | map()], keyword() | map()) :: Widget.t() + def mode_nav(id, items, opts \\ []) when is_list(items) do + opts = options(opts) + + component_widget( + :mode_nav, + id, + %{ + navigation: + %{items: normalize_maps(items)} + |> maybe_put(:aria_label, option(opts, :aria_label)) + |> maybe_put(:navigation_intent, option(opts, :navigation_intent)) + }, + [], + opts, + on_navigate: :navigation + ) + end + + @spec unread_badge(String.t() | atom(), non_neg_integer(), keyword() | map()) :: Widget.t() + def unread_badge(id, count, opts \\ []) when is_integer(count) and count >= 0 do + opts = options(opts) + + component_widget( + :unread_badge, + id, + %{status: %{count: count, threshold: option(opts, :threshold, 99)}}, + [], + opts + ) + end + + @spec top_strip(String.t() | atom(), [Widget.t() | map() | keyword()], keyword() | map()) :: + Widget.t() + def top_strip(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :top_strip, + id, + %{ + shell: %{ + position: :top, + brand: option(opts, :brand, ""), + context: option(opts, :context, ""), + theme: option(opts, :theme, :light), + pane_open?: option(opts, :pane_open?, false) + } + }, + children, + opts + ) + end + + @spec sidebar_shell(String.t() | atom(), [Widget.t() | map() | keyword()], keyword() | map()) :: + Widget.t() + def sidebar_shell(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_shell, + id, + %{shell: %{position: :side, collapsed?: option(opts, :collapsed?, false)}}, + children, + opts + ) + end + + @spec sidebar_section( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: Widget.t() + def sidebar_section(id, label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_section, + id, + %{ + section: + %{label: label} + |> maybe_put(:action_glyph, option(opts, :action_glyph)) + |> maybe_put(:action_label, option(opts, :action_label)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + }, + children, + opts + ) + end + + @spec sidebar_item( + String.t() | atom(), + String.t(), + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: Widget.t() + def sidebar_item(id, label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_item, + id, + %{ + item: + %{label: label, selected?: option(opts, :selected?, false)} + |> maybe_put(:item_intent, option(opts, :item_intent)) + }, + children, + opts, + on_select: :selection, + on_click: :click + ) + end + + @spec command_palette( + String.t() | atom(), + [keyword() | map()], + [Widget.t() | map() | keyword()], + keyword() | map() + ) :: Widget.t() + def command_palette(id, items \\ [], children \\ [], opts \\ []) + when is_list(items) and is_list(children) do + opts = options(opts) + + component_widget( + :command_palette, + id, + %{ + palette: + %{open?: option(opts, :open?, false), items: normalize_maps(items)} + |> maybe_put(:filter_intent, option(opts, :filter_intent)) + |> maybe_put(:select_intent, option(opts, :select_intent)) + }, + children, + opts, + on_filter: :filter, + on_select: :select + ) + end + @spec from_iur(atom(), String.t() | atom(), map(), keyword() | map()) :: Widget.t() def from_iur(kind, id, attributes, opts \\ []) when is_atom(kind) and is_map(attributes) do opts = options(opts) diff --git a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs index ebde1a59..7197b27e 100644 --- a/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs +++ b/packages/desktop_ui/test/desktop_ui/phase_eleven_integration_test.exs @@ -8,7 +8,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do @moduletag :phase_eleven - @iur_widget_count 77 + @iur_widget_count 83 @all_iur_kinds MapSet.new([ # Foundational (13) @@ -77,7 +77,7 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do :stack, # Container (1) :window, - # Canonical widget components (20) + # Canonical widget components (27) :inline_rich_text_heading, :disclosure, :kicker, @@ -86,15 +86,22 @@ defmodule DesktopUi.PhaseElevenIntegrationTest do :segmented_button_group, :runtime_form_shell, :chat_composer, + :mode_nav, :list_item_multi_column, :artifact_row, :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, :meter_thin, + :unread_badge, :sticky_frosted_header, :slide_over_panel, :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette, :redline_inline, :code_block_syntax_highlighted, :list_repeat diff --git a/packages/elm_ui/lib/elm_ui/widgets/components.ex b/packages/elm_ui/lib/elm_ui/widgets/components.ex index ea703584..014be66a 100644 --- a/packages/elm_ui/lib/elm_ui/widgets/components.ex +++ b/packages/elm_ui/lib/elm_ui/widgets/components.ex @@ -19,7 +19,8 @@ defmodule ElmUi.Widgets.Components do @form_control_kinds [ :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :mode_nav ] @row_artifact_kinds [ @@ -31,13 +32,19 @@ defmodule ElmUi.Widgets.Components do :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, - :meter_thin + :meter_thin, + :unread_badge ] @layer_callout_kinds [ :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette ] @redline_code_kinds [ @@ -67,6 +74,13 @@ defmodule ElmUi.Widgets.Components do sticky_frosted_header: :layer_callout, slide_over_panel: :layer_callout, event_callout: :layer_callout, + top_strip: :layer_callout, + sidebar_shell: :layer_callout, + sidebar_section: :layer_callout, + sidebar_item: :layer_callout, + command_palette: :layer_callout, + mode_nav: :form_control, + unread_badge: :workflow_progress, redline_inline: :redline_code, code_block_syntax_highlighted: :redline_code, list_repeat: :composition_behavior @@ -590,6 +604,162 @@ defmodule ElmUi.Widgets.Components do ) end + @spec mode_nav(String.t() | atom(), [keyword() | map()], keyword() | map()) :: ElmUi.Widget.t() + def mode_nav(id, items, opts \\ []) when is_list(items) do + opts = options(opts) + + component_widget( + :mode_nav, + id, + %{ + navigation: + %{items: normalize_maps(items)} + |> maybe_put(:aria_label, Builder.option(opts, :aria_label)) + |> maybe_put(:navigation_intent, Builder.option(opts, :navigation_intent)) + }, + [], + opts, + on_navigate: :navigation + ) + end + + @spec unread_badge(String.t() | atom(), non_neg_integer(), keyword() | map()) :: + ElmUi.Widget.t() + def unread_badge(id, count, opts \\ []) when is_integer(count) and count >= 0 do + opts = options(opts) + + component_widget( + :unread_badge, + id, + %{ + status: %{ + count: count, + threshold: Builder.option(opts, :threshold, 99) + } + }, + [], + opts + ) + end + + @spec top_strip( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: ElmUi.Widget.t() + def top_strip(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :top_strip, + id, + %{ + shell: %{ + position: :top, + brand: Builder.option(opts, :brand, ""), + context: Builder.option(opts, :context, ""), + theme: Builder.option(opts, :theme, :light), + pane_open?: Builder.option(opts, :pane_open?, false) + } + }, + children, + opts + ) + end + + @spec sidebar_shell( + String.t() | atom(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: ElmUi.Widget.t() + def sidebar_shell(id, children \\ [], opts \\ []) when is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_shell, + id, + %{shell: %{position: :side, collapsed?: Builder.option(opts, :collapsed?, false)}}, + children, + opts + ) + end + + @spec sidebar_section( + String.t() | atom(), + String.t(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: ElmUi.Widget.t() + def sidebar_section(id, label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_section, + id, + %{ + section: + %{label: label} + |> maybe_put(:action_glyph, Builder.option(opts, :action_glyph)) + |> maybe_put(:action_label, Builder.option(opts, :action_label)) + |> maybe_put(:action_intent, Builder.option(opts, :action_intent)) + }, + children, + opts + ) + end + + @spec sidebar_item( + String.t() | atom(), + String.t(), + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: ElmUi.Widget.t() + def sidebar_item(id, label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = options(opts) + + component_widget( + :sidebar_item, + id, + %{ + item: + %{label: label, selected?: Builder.option(opts, :selected?, false)} + |> maybe_put(:item_intent, Builder.option(opts, :item_intent)) + }, + children, + opts, + on_select: :selection, + on_click: :click + ) + end + + @spec command_palette( + String.t() | atom(), + [keyword() | map()], + [ElmUi.Widget.t() | map() | keyword()], + keyword() | map() + ) :: ElmUi.Widget.t() + def command_palette(id, items \\ [], children \\ [], opts \\ []) + when is_list(items) and is_list(children) do + opts = options(opts) + + component_widget( + :command_palette, + id, + %{ + palette: + %{open?: Builder.option(opts, :open?, false), items: normalize_maps(items)} + |> maybe_put(:filter_intent, Builder.option(opts, :filter_intent)) + |> maybe_put(:select_intent, Builder.option(opts, :select_intent)) + }, + children, + opts, + on_filter: :filter, + on_select: :select + ) + end + @spec from_iur( atom(), String.t() | atom() | nil, diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 27d05339..764c5ab9 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -578,7 +578,11 @@ defmodule LiveUi.Renderer do """ end - def render(%{element: %Element{kind: :command_palette}} = assigns) do + def render( + %{ + element: %Element{kind: :command_palette, attributes: %{command_palette: _}} + } = assigns + ) do assigns = assigns |> assign( @@ -1480,6 +1484,172 @@ defmodule LiveUi.Renderer do """ end + def render(%{element: %Element{kind: :top_strip}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + <%= for child <- child_elements(@element) do %> + <.render element={child} event_target={@event_target} /> + <% end %> + + """ + end + + def render(%{element: %Element{kind: :mode_nav}} = assigns) do + assigns = + assign( + assigns, + :nav_attrs, + interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + + ~H""" + + """ + end + + def render(%{element: %Element{kind: :sidebar_shell}} = assigns) do + assigns = assign(assigns, :style_attrs, style_rest(assigns.element)) + + ~H""" + + <%= for child <- child_elements(@element) do %> + <.render element={child} event_target={@event_target} /> + <% end %> + + """ + end + + def render(%{element: %Element{kind: :sidebar_section}} = assigns) do + assigns = + assign( + assigns, + :action_attrs, + interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + + ~H""" + + <%= for child <- child_elements(@element) do %> + <.render element={child} event_target={@event_target} /> + <% end %> + + """ + end + + def render(%{element: %Element{kind: :sidebar_item}} = assigns) do + assigns = + assign( + assigns, + :item_attrs, + interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + + ~H""" + + <%= for child <- child_elements(@element) do %> + <.render element={child} event_target={@event_target} /> + <% end %> + + """ + end + + def render(%{element: %Element{kind: :unread_badge}} = assigns) do + ~H""" + + """ + end + + def render( + %{element: %Element{kind: :command_palette, attributes: %{component: %{family: _}}}} = + assigns + ) do + assigns = + assigns + |> assign( + :filter_attrs, + direct_change_interaction_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + |> assign( + :select_attrs, + interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + ) + + ~H""" + + <%= for child <- child_elements(@element) do %> + <.render element={child} event_target={@event_target} /> + <% end %> + + """ + end + def render(assigns) do ~H"""
diff --git a/packages/live_ui/lib/live_ui/widgets/components.ex b/packages/live_ui/lib/live_ui/widgets/components.ex index dbe87b8a..cc1cb186 100644 --- a/packages/live_ui/lib/live_ui/widgets/components.ex +++ b/packages/live_ui/lib/live_ui/widgets/components.ex @@ -32,7 +32,8 @@ defmodule LiveUi.Widgets.Components do @form_control_modules [ LiveUi.Widgets.Components.SegmentedButtonGroup, LiveUi.Widgets.Components.RuntimeFormShell, - LiveUi.Widgets.Components.ChatComposer + LiveUi.Widgets.Components.ChatComposer, + LiveUi.Widgets.Components.ModeNav ] @row_artifact_modules [ @@ -44,13 +45,19 @@ defmodule LiveUi.Widgets.Components do LiveUi.Widgets.Components.PipelineStepperHorizontal, LiveUi.Widgets.Components.SegmentedProgressBar, LiveUi.Widgets.Components.WorkflowStageListVertical, - LiveUi.Widgets.Components.MeterThin + LiveUi.Widgets.Components.MeterThin, + LiveUi.Widgets.Components.UnreadBadge ] @layer_callout_modules [ LiveUi.Widgets.Components.StickyFrostedHeader, LiveUi.Widgets.Components.SlideOverPanel, - LiveUi.Widgets.Components.EventCallout + LiveUi.Widgets.Components.EventCallout, + LiveUi.Widgets.Components.TopStrip, + LiveUi.Widgets.Components.SidebarShell, + LiveUi.Widgets.Components.SidebarSection, + LiveUi.Widgets.Components.SidebarItem, + LiveUi.Widgets.Components.CommandPalette ] @redline_code_modules [ @@ -232,6 +239,62 @@ defmodule LiveUi.Widgets.Components do slots: [:inner_block], events: [], local_state_keys: [] + }, + mode_nav: %{ + module: LiveUi.Widgets.Components.ModeNav, + family: :form_control, + assigns: [:items, :aria_label, :nav_attrs], + slots: [], + events: [:navigation], + local_state_keys: [] + }, + unread_badge: %{ + module: LiveUi.Widgets.Components.UnreadBadge, + family: :workflow_progress, + assigns: [:count, :threshold], + slots: [], + events: [], + local_state_keys: [] + }, + top_strip: %{ + module: LiveUi.Widgets.Components.TopStrip, + family: :layer_callout, + assigns: [:brand, :context, :theme, :pane_open], + slots: [:inner_block], + events: [], + local_state_keys: [] + }, + sidebar_shell: %{ + module: LiveUi.Widgets.Components.SidebarShell, + family: :layer_callout, + assigns: [:collapsed], + slots: [:inner_block], + events: [], + local_state_keys: [] + }, + sidebar_section: %{ + module: LiveUi.Widgets.Components.SidebarSection, + family: :layer_callout, + assigns: [:label, :action_glyph, :action_label, :action_attrs], + slots: [:inner_block], + events: [], + local_state_keys: [] + }, + sidebar_item: %{ + module: LiveUi.Widgets.Components.SidebarItem, + family: :layer_callout, + assigns: [:label, :selected, :item_attrs], + slots: [:inner_block], + events: [], + local_state_keys: [] + }, + command_palette: %{ + module: LiveUi.Widgets.Components.CommandPalette, + family: :layer_callout, + assigns: [:open, :items, :filter_attrs, :select_attrs], + slots: [:inner_block], + events: [:filter, :select], + local_state_keys: [] } } diff --git a/packages/live_ui/lib/live_ui/widgets/components/form_control.ex b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex index 6786b345..6da96078 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/form_control.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/form_control.ex @@ -102,6 +102,55 @@ defmodule LiveUi.Widgets.Components.RuntimeFormShell do end end +defmodule LiveUi.Widgets.Components.ModeNav do + @moduledoc """ + Native navigation control for switching application modes with labeled items and shortcuts. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :mode_nav, + assigns: [:items, :aria_label, :nav_attrs], + events: [:navigation] + + LiveUi.Component.common_attrs() + attr(:items, :list, default: []) + attr(:aria_label, :string, default: nil) + attr(:nav_attrs, :map, default: %{}) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :mode_nav, :form_control) + ) + + ~H""" + + """ + end +end + defmodule LiveUi.Widgets.Components.ChatComposer do @moduledoc """ Native chat composer with message text and tool slots. diff --git a/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex b/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex index 7b5ee6f7..21618c31 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/layer_text.ex @@ -210,6 +210,239 @@ defmodule LiveUi.Widgets.Components.CodeBlockSyntaxHighlighted do end end +defmodule LiveUi.Widgets.Components.TopStrip do + @moduledoc """ + Native top shell strip with brand identity, context, theme controls, and mode navigation. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :top_strip, + slots: [:inner_block], + assigns: [:brand, :context, :theme, :pane_open] + + LiveUi.Component.common_attrs() + attr(:brand, :string, default: "") + attr(:context, :string, default: "") + attr(:theme, :string, default: "light") + attr(:pane_open, :boolean, default: false) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :top_strip, :layer_callout, %{ + "data-live-ui-shell-position" => "top", + "data-live-ui-theme" => assigns.theme, + "data-live-ui-pane-open" => assigns.pane_open + }) + ) + + ~H""" +
+ <%= @brand %> + <%= @context %> +
<%= render_slot(@inner_block) %>
+
+ """ + end +end + +defmodule LiveUi.Widgets.Components.SidebarShell do + @moduledoc """ + Native side navigation shell with collapsible state and section children. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :sidebar_shell, + slots: [:inner_block], + assigns: [:collapsed] + + LiveUi.Component.common_attrs() + attr(:collapsed, :boolean, default: false) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :sidebar_shell, :layer_callout, %{ + "data-live-ui-shell-position" => "side", + "data-live-ui-collapsed" => assigns.collapsed + }) + ) + + ~H""" + + """ + end +end + +defmodule LiveUi.Widgets.Components.SidebarSection do + @moduledoc """ + Native labeled section group inside a sidebar shell with optional action and item children. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :sidebar_section, + slots: [:inner_block], + assigns: [:label, :action_glyph, :action_label, :action_attrs] + + LiveUi.Component.common_attrs() + attr(:label, :string, required: true) + attr(:action_glyph, :string, default: nil) + attr(:action_label, :string, default: nil) + attr(:action_attrs, :map, default: %{}) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :sidebar_section, :layer_callout) + ) + + ~H""" +
+
+ <%= @label %> + +
+
    <%= render_slot(@inner_block) %>
+
+ """ + end +end + +defmodule LiveUi.Widgets.Components.SidebarItem do + @moduledoc """ + Native navigable sidebar item with selected state, intent, and optional badge children. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :sidebar_item, + slots: [:inner_block], + assigns: [:label, :selected, :item_attrs] + + LiveUi.Component.common_attrs() + attr(:label, :string, required: true) + attr(:selected, :boolean, default: false) + attr(:item_attrs, :map, default: %{}) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :sidebar_item, :layer_callout, %{ + "data-live-ui-selected" => assigns.selected + }) + ) + + ~H""" +
  • + +
  • + """ + end +end + +defmodule LiveUi.Widgets.Components.CommandPalette do + @moduledoc """ + Native keyboard-driven command palette overlay with open state, filterable items, and children. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :command_palette, + slots: [:inner_block], + assigns: [:open, :items, :filter_attrs, :select_attrs], + events: [:filter, :select] + + LiveUi.Component.common_attrs() + attr(:open, :boolean, default: false) + attr(:items, :list, default: []) + attr(:filter_attrs, :map, default: %{}) + attr(:select_attrs, :map, default: %{}) + slot(:inner_block) + + @impl true + def render(assigns) do + assigns = + assign( + assigns, + :component_attrs, + Support.component_attrs(assigns, :command_palette, :layer_callout, %{ + "data-live-ui-palette-open" => assigns.open + }) + ) + + ~H""" + + """ + end +end + defmodule LiveUi.Widgets.Components.ListRepeat do @moduledoc """ Native wrapper for already-hydrated list-repeat child output. diff --git a/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex b/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex index 17bfb480..677a97e7 100644 --- a/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex +++ b/packages/live_ui/lib/live_ui/widgets/components/row_workflow.ex @@ -295,3 +295,47 @@ defmodule LiveUi.Widgets.Components.MeterThin do """ end end + +defmodule LiveUi.Widgets.Components.UnreadBadge do + @moduledoc """ + Native compact unread count badge with threshold capping and hidden-when-zero behavior. + """ + + alias LiveUi.Widgets.Components.Support + + use LiveUi.Component, + family: :components, + name: :unread_badge, + assigns: [:count, :threshold] + + LiveUi.Component.common_attrs() + attr(:count, :integer, default: 0) + attr(:threshold, :integer, default: 99) + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:display_count, display_count(assigns.count, assigns.threshold)) + |> assign( + :component_attrs, + Support.component_attrs(assigns, :unread_badge, :workflow_progress, %{ + "data-live-ui-unread-count" => assigns.count + }) + ) + + ~H""" + 0} + id={@id} + role="status" + aria-label={"#{@count} unread"} + class={@class} + {@component_attrs} + ><%= @display_count %> + """ + end + + defp display_count(count, threshold) when count > threshold, do: "#{threshold}+" + defp display_count(count, _threshold), do: to_string(count) +end diff --git a/packages/live_ui/test/live_ui/widget_components_shell_primitives_test.exs b/packages/live_ui/test/live_ui/widget_components_shell_primitives_test.exs new file mode 100644 index 00000000..87004099 --- /dev/null +++ b/packages/live_ui/test/live_ui/widget_components_shell_primitives_test.exs @@ -0,0 +1,235 @@ +defmodule LiveUi.WidgetComponentsShellPrimitivesTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + defp slot_text(slot, text) do + %{__slot__: slot, inner_block: fn _, _ -> text end} + end + + test "top_strip renders shell header with brand, context, and theme attrs" do + html = + render_component(&LiveUi.Widgets.Components.TopStrip.component/1, %{ + id: "top-strip", + brand: "Ariston", + context: "Workspace", + theme: "dark", + pane_open: false, + inner_block: [slot_text(:inner_block, "nav-content")] + }) + + assert html =~ ~s(data-live-ui-shell-position="top") + assert html =~ ~s(data-live-ui-theme="dark") + assert html =~ "Ariston" + assert html =~ "Workspace" + assert html =~ "nav-content" + end + + test "top_strip omits brand span when brand is empty" do + html = + render_component(&LiveUi.Widgets.Components.TopStrip.component/1, %{ + id: "strip-no-brand", + brand: "", + pane_open: false, + inner_block: [slot_text(:inner_block, "")] + }) + + refute html =~ "data-live-ui-strip-brand" + end + + test "mode_nav renders navigation with item labels and current state" do + html = + render_component(&LiveUi.Widgets.Components.ModeNav.component/1, %{ + id: "mode-nav", + aria_label: "App modes", + items: [ + %{value: "workspace", label: "Workspace", current?: true}, + %{value: "map", label: "Map", shortcut: "m"} + ] + }) + + assert html =~ ~s(role="navigation") + assert html =~ ~s(aria-label="App modes") + assert html =~ ~s(data-live-ui-nav-value="workspace") + assert html =~ ~s(aria-current="page") + assert html =~ "Workspace" + assert html =~ "Map" + assert html =~ ~s(data-live-ui-nav-shortcut="m") + end + + test "mode_nav item without current? has aria-current false" do + html = + render_component(&LiveUi.Widgets.Components.ModeNav.component/1, %{ + id: "mode-nav-2", + items: [%{value: "docs", label: "Docs"}] + }) + + assert html =~ ~s(aria-current="false") + refute html =~ ~s(aria-current="page") + end + + test "sidebar_shell renders nav with collapsed data attribute" do + html = + render_component(&LiveUi.Widgets.Components.SidebarShell.component/1, %{ + id: "sidebar", + collapsed: true, + inner_block: [slot_text(:inner_block, "sections")] + }) + + assert html =~ ~s(data-live-ui-shell-position="side") + assert html =~ ~s(data-live-ui-collapsed="true") + assert html =~ ~s(aria-hidden="true") + assert html =~ "sections" + end + + test "sidebar_shell when expanded has aria-hidden false" do + html = + render_component(&LiveUi.Widgets.Components.SidebarShell.component/1, %{ + id: "sidebar-open", + collapsed: false, + inner_block: [slot_text(:inner_block, "")] + }) + + assert html =~ ~s(aria-hidden="false") + end + + test "sidebar_section renders labeled section with action button" do + html = + render_component(&LiveUi.Widgets.Components.SidebarSection.component/1, %{ + id: "section", + label: "Specs", + action_label: "Add spec", + action_glyph: "+", + action_attrs: %{"phx-click" => "add_spec"}, + inner_block: [slot_text(:inner_block, "items")] + }) + + assert html =~ ~s(data-live-ui-shell-section) + assert html =~ "Specs" + assert html =~ ~s(aria-label="Add spec") + assert html =~ "+" + assert html =~ ~s(phx-click="add_spec") + assert html =~ "items" + end + + test "sidebar_section without action renders no action button" do + html = + render_component(&LiveUi.Widgets.Components.SidebarSection.component/1, %{ + id: "section-no-action", + label: "Repos", + inner_block: [slot_text(:inner_block, "")] + }) + + refute html =~ "data-live-ui-section-action" + end + + test "sidebar_item renders selected item with aria-current page" do + html = + render_component(&LiveUi.Widgets.Components.SidebarItem.component/1, %{ + id: "item-selected", + label: "metagraph", + selected: true, + inner_block: [slot_text(:inner_block, "")] + }) + + assert html =~ ~s(data-live-ui-shell-item) + assert html =~ ~s(aria-current="page") + assert html =~ "metagraph" + end + + test "sidebar_item renders unselected item with aria-current false" do + html = + render_component(&LiveUi.Widgets.Components.SidebarItem.component/1, %{ + id: "item-unselected", + label: "ariston-ui", + selected: false, + inner_block: [slot_text(:inner_block, "")] + }) + + assert html =~ ~s(aria-current="false") + refute html =~ ~s(aria-current="page") + end + + test "sidebar_item renders badge children in slot" do + html = + render_component(&LiveUi.Widgets.Components.SidebarItem.component/1, %{ + id: "item-with-badge", + label: "inbox", + selected: false, + inner_block: [slot_text(:inner_block, "3")] + }) + + assert html =~ "inbox" + assert html =~ "3" + end + + test "unread_badge renders count with role status" do + html = + render_component(&LiveUi.Widgets.Components.UnreadBadge.component/1, %{ + id: "badge", + count: 5, + threshold: 99 + }) + + assert html =~ ~s(role="status") + assert html =~ ~s(data-live-ui-unread-count="5") + assert html =~ ~s(aria-label="5 unread") + assert html =~ "5" + end + + test "unread_badge caps display at threshold" do + html = + render_component(&LiveUi.Widgets.Components.UnreadBadge.component/1, %{ + id: "badge-overflow", + count: 150, + threshold: 99 + }) + + assert html =~ "99+" + assert html =~ ~s(data-live-ui-unread-count="150") + end + + test "unread_badge renders nothing when count is zero" do + html = + render_component(&LiveUi.Widgets.Components.UnreadBadge.component/1, %{ + id: "badge-zero", + count: 0 + }) + + refute html =~ ~s(role="status") + refute html =~ "0" + end + + test "command_palette renders overlay with open state and items" do + html = + render_component(&LiveUi.Widgets.Components.CommandPalette.component/1, %{ + id: "palette", + open: true, + items: [ + %{id: "goto-spec", label: "Go to spec", active: true}, + %{id: "new-doc", label: "New document", active: false} + ], + inner_block: [slot_text(:inner_block, "")] + }) + + assert html =~ ~s(role="dialog") + assert html =~ ~s(aria-modal="true") + assert html =~ ~s(aria-hidden="false") + assert html =~ ~s(data-live-ui-palette-open="true") + assert html =~ ~s(data-live-ui-palette-item="goto-spec") + assert html =~ "Go to spec" + assert html =~ "New document" + end + + test "command_palette with open false has aria-hidden true" do + html = + render_component(&LiveUi.Widgets.Components.CommandPalette.component/1, %{ + id: "palette-closed", + open: false, + items: [] + }) + + assert html =~ ~s(aria-hidden="true") + assert html =~ ~s(data-live-ui-palette-open="false") + end +end diff --git a/packages/unified-ui/lib/unified_ui/widget_components.ex b/packages/unified-ui/lib/unified_ui/widget_components.ex index b349c710..513bd4c0 100644 --- a/packages/unified-ui/lib/unified_ui/widget_components.ex +++ b/packages/unified-ui/lib/unified_ui/widget_components.ex @@ -166,6 +166,52 @@ defmodule UnifiedUi.WidgetComponents do summary: "Composition behavior that repeats a child template over rows from a list binding.", aliases: [:ui_relationship_repeat, :repeat] + }, + %{ + kind: :top_strip, + family: :layer_shell_and_callout, + summary: + "Top shell strip with brand, context, theme controls, and mode navigation children.", + aliases: [] + }, + %{ + kind: :mode_nav, + family: :form_control_and_composer, + summary: + "Navigation control for switching application modes with labeled items and shortcuts.", + aliases: [] + }, + %{ + kind: :sidebar_shell, + family: :layer_shell_and_callout, + summary: "Side navigation shell with collapsible state and sidebar section children.", + aliases: [] + }, + %{ + kind: :sidebar_section, + family: :layer_shell_and_callout, + summary: + "Labeled section group inside a sidebar shell with optional action and item children.", + aliases: [] + }, + %{ + kind: :sidebar_item, + family: :layer_shell_and_callout, + summary: "Navigable sidebar item with selected state, intent, and optional badge children.", + aliases: [] + }, + %{ + kind: :unread_badge, + family: :workflow_progress_and_status, + summary: "Compact unread count badge with threshold capping.", + aliases: [] + }, + %{ + kind: :command_palette, + family: :layer_shell_and_callout, + summary: + "Keyboard-driven command palette overlay with open state, filterable items, and children.", + aliases: [] } ] diff --git a/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs index 31f96ee1..6243fc52 100644 --- a/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs +++ b/packages/unified-ui/test/unified_ui/widget_components_catalog_test.exs @@ -25,19 +25,26 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do form_control_and_composer: [ :runtime_form_shell, :segmented_button_group, - :chat_composer + :chat_composer, + :mode_nav ], row_and_artifact: [:list_item_multi_column, :artifact_row], workflow_progress_and_status: [ :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, - :meter_thin + :meter_thin, + :unread_badge ], layer_shell_and_callout: [ :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette ], redline_and_code: [:redline_inline, :code_block_syntax_highlighted], composition_behavior: [:list_repeat] diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex index a80c32fe..dcd38c4a 100644 --- a/packages/unified_iur/lib/unified_iur/fixtures.ex +++ b/packages/unified_iur/lib/unified_iur/fixtures.ex @@ -1055,6 +1055,51 @@ defmodule UnifiedIUR.Fixtures do hydrated?: true, row_count: 0, children: [] + )}, + {:content, + Components.mode_nav( + [ + %{value: :workspace, label: "Workspace", current?: true}, + %{value: :settings, label: "Settings"} + ], + id: "component-mode-nav", + aria_label: "Application modes", + navigation_intent: :switch_mode + )}, + {:content, Components.unread_badge(5, id: "component-unread-badge", threshold: 99)}, + {:content, + Components.top_strip( + [], + id: "component-top-strip", + brand: "Ariston", + context: "Workspace" + )}, + {:content, + Components.sidebar_shell( + [ + Components.sidebar_section( + "Docs", + [ + Components.sidebar_item("Overview", [], id: "sidebar-item-overview"), + Components.sidebar_item("Details", [], + id: "sidebar-item-details", + selected?: true + ) + ], + id: "sidebar-section-docs" + ) + ], + id: "component-sidebar-shell" + )}, + {:content, + Components.command_palette( + [ + %{id: "cmd-1", label: "Open Spec"}, + %{id: "cmd-2", label: "New Doc"} + ], + [], + id: "component-command-palette", + open?: false )} ], id: "component-safety-fixture" diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex index bab658d8..983a4910 100644 --- a/packages/unified_iur/lib/unified_iur/widgets/components.ex +++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex @@ -35,7 +35,8 @@ defmodule UnifiedIUR.Widgets.Components do @form_control_kinds [ :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :mode_nav ] @row_artifact_kinds [ @@ -47,13 +48,19 @@ defmodule UnifiedIUR.Widgets.Components do :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, - :meter_thin + :meter_thin, + :unread_badge ] @layer_callout_kinds [ :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette ] @redline_code_kinds [ @@ -503,6 +510,158 @@ defmodule UnifiedIUR.Widgets.Components do ) end + @spec top_strip( + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: Element.t() + def top_strip(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :top_strip, + :layer_shell_and_callout, + %{ + shell: %{ + position: :top, + brand: option(opts, :brand, ""), + context: option(opts, :context, ""), + theme: option(opts, :theme, :light), + pane_open?: option(opts, :pane_open?, false) + } + }, + Map.put(opts, :children, children) + ) + end + + @spec mode_nav([keyword() | map()], opts()) :: Element.t() + def mode_nav(items, opts \\ []) when is_list(items) do + opts = normalize_opts(opts) + + build_component( + :mode_nav, + :form_control_and_composer, + %{ + navigation: + %{ + items: normalize_maps(items) + } + |> maybe_put(:aria_label, option(opts, :aria_label)) + |> maybe_put(:navigation_intent, option(opts, :navigation_intent)) + |> maybe_put(:navigation_value_key, option(opts, :navigation_value_key)) + }, + opts + ) + end + + @spec sidebar_shell( + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: Element.t() + def sidebar_shell(children \\ [], opts \\ []) when is_list(children) do + opts = normalize_opts(opts) + + build_component( + :sidebar_shell, + :layer_shell_and_callout, + %{ + shell: %{ + position: :side, + collapsed?: option(opts, :collapsed?, false) + } + }, + Map.put(opts, :children, children) + ) + end + + @spec sidebar_section( + String.t(), + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: Element.t() + def sidebar_section(label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :sidebar_section, + :layer_shell_and_callout, + %{ + section: + %{label: label} + |> maybe_put(:action_glyph, option(opts, :action_glyph)) + |> maybe_put(:action_label, option(opts, :action_label)) + |> maybe_put(:action_intent, option(opts, :action_intent)) + }, + Map.put(opts, :children, children) + ) + end + + @spec sidebar_item( + String.t(), + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: Element.t() + def sidebar_item(label, children \\ [], opts \\ []) + when is_binary(label) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :sidebar_item, + :layer_shell_and_callout, + %{ + item: + %{ + label: label, + selected?: option(opts, :selected?, false) + } + |> maybe_put(:item_intent, option(opts, :item_intent)) + }, + Map.put(opts, :children, children) + ) + end + + @spec unread_badge(non_neg_integer(), opts()) :: Element.t() + def unread_badge(count, opts \\ []) when is_integer(count) and count >= 0 do + opts = normalize_opts(opts) + + build_component( + :unread_badge, + :workflow_progress_and_status, + %{ + status: %{ + count: count, + threshold: option(opts, :threshold, 99) + } + }, + opts + ) + end + + @spec command_palette( + [keyword() | map()], + [Element.t() | Element.Child.t() | {atom(), Element.t()} | map()], + opts() + ) :: Element.t() + def command_palette(items \\ [], children \\ [], opts \\ []) + when is_list(items) and is_list(children) do + opts = normalize_opts(opts) + + build_component( + :command_palette, + :layer_shell_and_callout, + %{ + palette: + %{ + open?: option(opts, :open?, false), + items: normalize_maps(items) + } + |> maybe_put(:filter_intent, option(opts, :filter_intent)) + |> maybe_put(:select_intent, option(opts, :select_intent)) + }, + Map.put(opts, :children, children) + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) 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 ea046a1d..fe650c6e 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -19,7 +19,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do assert Components.form_control_kinds() == [ :segmented_button_group, :runtime_form_shell, - :chat_composer + :chat_composer, + :mode_nav ] assert Components.row_artifact_kinds() == [:list_item_multi_column, :artifact_row] @@ -28,13 +29,19 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, - :meter_thin + :meter_thin, + :unread_badge ] assert Components.layer_callout_kinds() == [ :sticky_frosted_header, :slide_over_panel, - :event_callout + :event_callout, + :top_strip, + :sidebar_shell, + :sidebar_section, + :sidebar_item, + :command_palette ] assert Components.redline_code_kinds() == [