From 74a470a3fe40d14c8c4f3f22e212bcfab3f74bf8 Mon Sep 17 00:00:00 2001 From: Gavin Morrow Date: Tue, 4 Aug 2026 07:48:52 -0400 Subject: [PATCH] Menu bar --- assets/style.css | 8 ++ src/bag_of_holding.gleam | 111 +++++++--------- src/bag_of_holding/ui/menu.gleam | 169 +++++++++++++++++++++++++ src/bag_of_holding/{ => ui}/tabs.gleam | 0 4 files changed, 225 insertions(+), 63 deletions(-) create mode 100644 src/bag_of_holding/ui/menu.gleam rename src/bag_of_holding/{ => ui}/tabs.gleam (100%) diff --git a/assets/style.css b/assets/style.css index 3697a5d..299e0f5 100644 --- a/assets/style.css +++ b/assets/style.css @@ -3,6 +3,14 @@ body { background-color: var(--background-color); } +[role="menu"] { + display: none; +} + +[role="menu"].open { + display: block; +} + .tabs { --border-color: #888; --border-selected-width: 2px; diff --git a/src/bag_of_holding.gleam b/src/bag_of_holding.gleam index 8dc2116..59d032a 100644 --- a/src/bag_of_holding.gleam +++ b/src/bag_of_holding.gleam @@ -1,7 +1,7 @@ import bag_of_holding/inventory.{type Inventory, Inventory} import bag_of_holding/storage.{type Storage, Storage} -import bag_of_holding/tabs.{tabs} -import gleam/bool +import bag_of_holding/ui/menu +import bag_of_holding/ui/tabs.{tabs} import gleam/dict import gleam/int import gleam/list @@ -30,6 +30,7 @@ type Model { type LoadedModel { LoadedModel( selected_inventory: Result(String, Nil), + menu_open: Bool, selected_tab: String, storage: Storage, creating_inventory: Bool, @@ -44,6 +45,7 @@ type Message { PersistFailed(error: storage.Error) UserSelectedInventory(name: String) + UserChangedMenuState(open: Bool) UserChangedTab(new_tab_id: String) UserClickedCreateNewInventory @@ -73,6 +75,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { [inventory] -> Ok(inventory) _ -> Error(Nil) }, + menu_open: False, selected_tab: inventory_body_tab_id, storage:, creating_inventory: False, @@ -99,6 +102,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ), effect.none(), ) + Loaded(model), UserChangedMenuState(open:) -> #( + Loaded(LoadedModel(..model, menu_open: open)), + effect.none(), + ) Loaded(model), UserChangedTab(new_tab_id:) -> #( Loaded(LoadedModel(..model, selected_tab: new_tab_id)), effect.none(), @@ -224,6 +231,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { _, NoOp | _, UserClickedCreateNewInventory(..) | _, UserSelectedInventory(..) + | _, UserChangedMenuState(..) | _, UserChangedTab(..) | _, InventoryAdded(..) | _, UserClickedDeleteInventory(..) @@ -250,6 +258,7 @@ fn loading_view() -> Element(Message) { fn loaded_view(model: LoadedModel) -> Element(Message) { let LoadedModel( selected_inventory:, + menu_open:, selected_tab:, storage: Storage(inventories:), creating_inventory:, @@ -259,71 +268,47 @@ fn loaded_view(model: LoadedModel) -> Element(Message) { let inventory_names = dict.keys(inventories) |> list.filter_map(fn(name) { - use <- bool.guard( - when: deleting_inventories |> set.contains(name), - return: Error(Nil), - ) - Ok(html.option( - [ - attribute.value(name), - attribute.selected(selected_inventory == Ok(name)), - ], - name, - )) + case set.contains(name, in: deleting_inventories) { + False -> Ok(name) + True -> Error(Nil) + } }) - let inventory_name_options = case inventory_names { - [] -> [ - html.option([attribute.value("")], "--No inventories found--"), - ] - _ -> [ - html.option( - [ - attribute.value(""), - attribute.selected(selected_inventory == Error(Nil)), - ], - "--Select an inventory--", - ), - ..inventory_names - ] - } html.div([], [ - html.section([], [ - html.button( - [ - event.on_click(UserClickedCreateNewInventory), - attribute.disabled(creating_inventory), - ], - [ - html.text("Create new inventory"), - ], - ), - html.button( - case model.selected_inventory { - Ok(inventory) -> [ - event.on_click(UserClickedDeleteInventory(inventory:)), - attribute.disabled(False), - ] - Error(Nil) -> [attribute.disabled(True)] - }, - [ - html.text("Delete current inventory"), - ], - ), - html.div([], [ - html.label([attribute.for("inventory-select")], [ - html.text("Choose which inventory to view: "), - ]), - html.select( - [ - attribute.id("inventory-select"), - attribute.name("inventory"), - event.on_change(UserSelectedInventory), - ], - inventory_name_options, + menu.popup_menu( + "Inventory options", + html.text("v"), + [event.on_click(UserChangedMenuState(!menu_open))], + menu_open, + [ + menu.button( + label: "Create new inventory", + on_click: case creating_inventory { + False -> Ok(UserClickedCreateNewInventory) + True -> Error(Nil) + }, ), - ]), - ]), + menu.button( + label: "Delete current inventory", + on_click: result.map(selected_inventory, UserClickedDeleteInventory), + ), + menu.radio( + label: "Inventories", + on_select: fn(selected_inventory) { + UserSelectedInventory(selected_inventory) + }, + options: inventory_names + |> list.map(fn(inventory) { + menu.RadioItem( + label: inventory, + value: inventory, + checked: Ok(inventory) == selected_inventory, + disabled: False, + ) + }), + ), + ], + ), case selected_inventory |> result.try(dict.get(model.storage.inventories, _)) { diff --git a/src/bag_of_holding/ui/menu.gleam b/src/bag_of_holding/ui/menu.gleam new file mode 100644 index 0000000..492a299 --- /dev/null +++ b/src/bag_of_holding/ui/menu.gleam @@ -0,0 +1,169 @@ +import gleam/list +import lustre/attribute.{type Attribute} +import lustre/element.{type Element} +import lustre/element/html +import lustre/event + +// Checkbox is left unimplemented because it's currently unneeded. +pub opaque type Item(message) { + Button(label: String, on_click: Result(message, Nil), tabindex: Int) + Submenu( + label: String, + items: List(Item(message)), + open: Bool, + on_change: Result(fn(Bool) -> message, Nil), + tabindex: Int, + ) + Radio( + label: String, + options: List(RadioItem(message)), + on_select: fn(String) -> message, + ) + + Group(label: String, items: List(Item(message))) + Separator +} + +pub type RadioItem(message) { + RadioItem(label: String, value: String, checked: Bool, disabled: Bool) +} + +pub fn popup_menu( + id: String, + label: Element(message), + button_attributes: List(Attribute(message)), + open: Bool, + items: List(Item(message)), +) -> Element(message) { + // let items = case items { + // [] -> [] + // [Button(..) as item, ..items] -> [Button(..item, tabindex: 0), ..items] + // [Submenu(..) as item, ..items] -> [Submenu(..item, tabindex: 0), ..items] + // [Radio(..) as item, ..items] + // | [Group(..) as item, ..items] + // | [Separator as item, ..items] -> [item, ..items] + // } + let items = list.map(items, item_elem) + html.div([], [ + html.button( + [ + attribute.id(id), + attribute.aria_haspopup("true"), + attribute.aria_expanded(open), + attribute.aria_controls(id <> "-menu"), + ..button_attributes + ], + [label], + ), + html.menu( + [ + attribute.id(id <> "-menu"), + attribute.role("menu"), + attribute.aria_labelledby(id), + attribute.tabindex(-1), + attribute.classes([#("open", open)]), + ], + items, + ), + ]) +} + +pub fn button( + label label: String, + on_click on_click: Result(message, Nil), +) -> Item(message) { + Button(label:, on_click:, tabindex: -1) +} + +pub fn submenu( + label label: String, + items items: List(Item(message)), + open open: Bool, + on_change on_change: Result(fn(Bool) -> message, Nil), +) -> Item(message) { + Submenu(label:, items:, open:, on_change:, tabindex: -1) +} + +pub fn radio( + label label: String, + options options: List(RadioItem(message)), + on_select on_select: fn(String) -> message, +) -> Item(message) { + Radio(label:, options:, on_select:) +} + +fn item_elem(item: Item(message)) -> Element(message) { + case item { + Button(label:, on_click:, tabindex:) -> + html.li([attribute.role("none")], [ + html.button( + [ + attribute.role("menuitem"), + attribute.tabindex(tabindex), + conditional_on_click(on_click), + ], + [html.text(label)], + ), + ]) + Submenu(label:, items:, open:, on_change:, tabindex:) -> + html.li([attribute.role("none")], [ + html.button( + [ + attribute.role("menuitem"), + attribute.tabindex(tabindex), + attribute.aria_haspopup("true"), + attribute.aria_expanded(open), + conditional_on_click(case on_change { + Ok(on_change) -> Ok(on_change(!open)) + Error(Nil) -> Error(Nil) + }), + ], + [html.text(label)], + ), + html.menu( + [attribute.role("menu"), attribute.aria_label(label)], + list.map(items, item_elem), + ), + ]) + + Radio(label:, options:, on_select:) -> + html.li([attribute.role("none")], [ + html.ul( + [attribute.role("group"), attribute.aria_label(label)], + list.map(options, fn(radio_item) { + let RadioItem(label:, value:, checked:, disabled:) = radio_item + html.li( + [ + attribute.role("menuitemcheckbox"), + attribute.aria_checked(case checked { + True -> "true" + False -> "false" + }), + attribute.aria_disabled(disabled), + conditional_on_click(case disabled { + False -> Ok(on_select(value)) + True -> Error(Nil) + }), + ], + [html.text(label)], + ) + }), + ), + ]) + Group(label:, items:) -> + html.li([attribute.role("none")], [ + html.ul( + [attribute.role("group"), attribute.aria_label(label)], + list.map(items, item_elem), + ), + ]) + Separator -> html.li([attribute.role("separator")], []) + } +} + +fn conditional_on_click(message: Result(message, Nil)) -> Attribute(message) { + case message { + Ok(message) -> event.on_click(message) + Error(Nil) -> attribute.aria_disabled(True) + } +} diff --git a/src/bag_of_holding/tabs.gleam b/src/bag_of_holding/ui/tabs.gleam similarity index 100% rename from src/bag_of_holding/tabs.gleam rename to src/bag_of_holding/ui/tabs.gleam -- 2.51.2