diff --git a/assets/modal-example.png b/assets/modal-example.png new file mode 100644 index 0000000..81a4ce9 Binary files /dev/null and b/assets/modal-example.png differ diff --git a/lib/component/input.ex b/lib/component/input.ex index 1779331..a13d548 100644 --- a/lib/component/input.ex +++ b/lib/component/input.ex @@ -7,9 +7,9 @@ defmodule Orange.Component.Input do ## Attributes - `:on_submit` - A callback triggered when input is submitted. This attribute is required. - - `:submit_key` - The keyboard key that will trigger submission. This attribute is option and defaults to :enter. See `Orange.Terminal.KeyEvent` for supported values. + - `:submit_key` - The keyboard key that will trigger submission. This attribute is optional and defaults to :enter. See `Orange.Terminal.KeyEvent` for supported values. - `:on_exit` - A callback triggered when input is exited. This attribute is optional. - - `:exit_key` - The keyboard key that will unfocus the input. This attribute is option and defaults to :esc. See `Orange.Terminal.KeyEvent` for supported values. + - `:exit_key` - The keyboard key that will unfocus the input. This attribute is optional and defaults to :esc. See `Orange.Terminal.KeyEvent` for supported values. > #### Info {: .info} > > The `:submit_key` and `:exit_key` can not be `:backspace` as it is reserved for deleting characters. diff --git a/lib/component/modal.ex b/lib/component/modal.ex new file mode 100644 index 0000000..eea9809 --- /dev/null +++ b/lib/component/modal.ex @@ -0,0 +1,76 @@ +defmodule Orange.Component.Modal do + @moduledoc """ + An modal/dialog component which renders an overlay in the middle of the viewport. + + ## Attributes + + - `:open` - Whether the modal is open or not. This attribute is required. + - `:offset_x` - The offset from the left and right edge of the screen. This attribute is required. + - `:offset_y` - The offset from the top and bottom edge of the screen. This attribute is required. + > #### Info {: .info} + > + > When the offset_x/y is too big for the terminal size, the modal will not be rendered. + + - `:children` - The content of the modal. This attribute is optional. + + ## Examples + + defmodule Example do + @behaviour Orange.Component + + import Orange.Macro + + @impl true + def init(_attrs), do: %{state: %{search_value: ""}} + + @impl true + def render(state, _attrs, update) do + modal_content = + rect do + "foo" + "bar" + end + + rect do + line do + "Displaying modal..." + end + + { + Orange.Component.Modal, + offset_x: 8, + offset_y: 4, + children: modal_content, + open: true + } + end + end + end + + ![rendered result](assets/modal-example.png) + """ + + @behaviour Orange.Component + + import Orange.Macro + + @impl true + def init(_attrs), do: %{state: nil} + + @impl true + def render(_state, attrs, _update) do + {width, height} = terminal_impl().terminal_size() + + offset_x = attrs[:offset_x] + offset_y = attrs[:offset_y] + + # Plus 2 for the border + if attrs[:open] && width > offset_x * 2 + 2 && height > offset_y * 2 + 2 do + rect position: {:fixed, offset_y, offset_x, offset_y, offset_x}, style: [border: true] do + attrs[:children] + end + end + end + + defp terminal_impl(), do: Application.get_env(:orange, :terminal, Orange.Terminal) +end diff --git a/mix.exs b/mix.exs index 6e3caef..eb23232 100644 --- a/mix.exs +++ b/mix.exs @@ -40,7 +40,8 @@ defmodule Orange.MixProject do Components: [ Orange.Component, Orange.Component.VerticalScrollableRect, - Orange.Component.Input + Orange.Component.Input, + Orange.Component.Modal ], Events: [ Orange.Terminal.KeyEvent diff --git a/test/component/modal_test.exs b/test/component/modal_test.exs new file mode 100644 index 0000000..93a7064 --- /dev/null +++ b/test/component/modal_test.exs @@ -0,0 +1,143 @@ +defmodule Orange.Component.ModalTest do + use ExUnit.Case + import Mox + + alias Orange.Renderer.Buffer + alias Orange.{Terminal, RuntimeTestHelper} + + setup_all do + Mox.defmock(Orange.MockTerminal, for: Terminal) + Application.put_env(:orange, :terminal, Orange.MockTerminal) + + :ok + end + + setup :set_mox_from_context + setup :verify_on_exit! + + test ":open is true" do + RuntimeTestHelper.setup_mock_terminal(Orange.MockTerminal, + terminal_size: {20, 15} + ) + + buffer = RuntimeTestHelper.dry_render_once({__MODULE__.Modal, open: true}) + + assert Buffer.to_string(buffer) === """ + Displaying modal...- + -------------------- + -------------------- + -------------------- + ----┌──────────┐---- + ----│foo-------│---- + ----│bar-------│---- + ----│----------│---- + ----│----------│---- + ----│----------│---- + ----└──────────┘---- + -------------------- + -------------------- + -------------------- + --------------------\ + """ + end + + test ":open is false" do + RuntimeTestHelper.setup_mock_terminal(Orange.MockTerminal, + terminal_size: {20, 15} + ) + + buffer = RuntimeTestHelper.dry_render_once({__MODULE__.Modal, open: false}) + + assert Buffer.to_string(buffer) === """ + Displaying modal...- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + --------------------\ + """ + end + + test "offset_x is too big for width" do + RuntimeTestHelper.setup_mock_terminal(Orange.MockTerminal, + terminal_size: {20, 6} + ) + + buffer = + RuntimeTestHelper.dry_render_once({__MODULE__.Modal, open: true, offset_x: 10, offset_y: 1}) + + assert Buffer.to_string(buffer) === """ + Displaying modal...- + -------------------- + -------------------- + -------------------- + -------------------- + --------------------\ + """ + end + + test "offset_y is too big for height" do + RuntimeTestHelper.setup_mock_terminal(Orange.MockTerminal, + terminal_size: {20, 12} + ) + + buffer = RuntimeTestHelper.dry_render_once({__MODULE__.Modal, open: true, offset_y: 6}) + + assert Buffer.to_string(buffer) === """ + Displaying modal...- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + -------------------- + --------------------\ + """ + end + + defmodule Modal do + @behaviour Orange.Component + + import Orange.Macro + alias Orange.Component + + @impl true + def init(_attrs), do: %{state: nil} + + @impl true + def render(_state, attrs, _update) do + offset_x = Keyword.get(attrs, :offset_x, 4) + offset_y = Keyword.get(attrs, :offset_y, 4) + + modal_content = + rect do + "foo" + "bar" + end + + rect style: [width: "100%", height: "100%"] do + line do + "Displaying modal..." + end + + { + Component.Modal, + offset_x: offset_x, offset_y: offset_y, children: modal_content, open: attrs[:open] + } + end + end + end +end