diff --git a/src/bag_of_holding.gleam b/src/bag_of_holding.gleam
index c7acf6e..8dc2116 100644
--- a/src/bag_of_holding.gleam
+++ b/src/bag_of_holding.gleam
@@ -1,8 +1,8 @@
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 gleam/dict
-import gleam/dynamic/decode
import gleam/int
import gleam/list
import gleam/result
@@ -404,8 +404,8 @@ fn inventory_view(
const inventory_body_tab_id = "body"
-fn inventory_body_tab(inventory: Inventory) -> Tab(Message) {
- Tab(
+fn inventory_body_tab(inventory: Inventory) -> tabs.Tab(Message) {
+ tabs.Tab(
id: inventory_body_tab_id,
name: "Body",
contents: element.fragment([html.text("body")]),
@@ -414,8 +414,8 @@ fn inventory_body_tab(inventory: Inventory) -> Tab(Message) {
const inventory_backpack_tab_id = "backpack"
-fn inventory_backpack_tab(inventory: Inventory) -> Tab(Message) {
- Tab(
+fn inventory_backpack_tab(inventory: Inventory) -> tabs.Tab(Message) {
+ tabs.Tab(
id: inventory_backpack_tab_id,
name: "Backpack",
contents: element.fragment([html.text("backpack")]),
@@ -424,126 +424,14 @@ fn inventory_backpack_tab(inventory: Inventory) -> Tab(Message) {
const inventory_pouch_tab_id = "pouch"
-fn inventory_pouch_tab(inventory: Inventory) -> Tab(Message) {
- Tab(
+fn inventory_pouch_tab(inventory: Inventory) -> tabs.Tab(Message) {
+ tabs.Tab(
id: inventory_pouch_tab_id,
name: "Pouch",
contents: element.fragment([html.text("pouch")]),
)
}
-type Tab(message) {
- Tab(id: String, name: String, contents: Element(message))
-}
-
-// Based on
-// (2026-08-03)
-/// All tabs must have unique ids.
-fn tabs(
- tabs: List(Tab(message)),
- selected_tab: String,
- selected_message: fn(String) -> message,
-) -> Element(message) {
- let first_tab = list.first(tabs)
- let last_tab = list.last(tabs)
- let #(buttons, panels) =
- tabs_loop(
- tabs:,
- buttons: list.new(),
- panels: list.new(),
- selected_tab:,
- selected_message:,
- prev_tab: Error(Nil),
- first_tab:,
- last_tab:,
- )
-
- html.div([attribute.class("tabs")], [
- html.div([attribute.role("tablist")], buttons),
- ..panels
- ])
-}
-
-fn tabs_loop(
- tabs tabs: List(Tab(message)),
- buttons buttons: List(Element(message)),
- panels panels: List(Element(message)),
- selected_tab selected_tab: String,
- selected_message selected_message: fn(String) -> message,
- prev_tab prev_tab: Result(Tab(message), Nil),
- first_tab first_tab: Result(Tab(message), Nil),
- last_tab last_tab: Result(Tab(message), Nil),
-) {
- case tabs {
- [] -> #(list.reverse(buttons), list.reverse(panels))
- [tab, ..rest] -> {
- let next_tab = list.first(rest)
-
- let selected = tab.id == selected_tab
- let button =
- html.button(
- [
- attribute.id("tab-" <> tab.id),
- attribute.role("tab"),
- attribute.aria_selected(selected),
- attribute.autofocus(selected),
- attribute.tabindex(case selected {
- True -> 0
- False -> -1
- }),
- attribute.aria_controls("tabpanel-" <> tab.id),
- event.on_click(selected_message(tab.id)),
- event.advanced("keydown", {
- use key <- decode.field("key", decode.string)
- let selected_tab = case key {
- "ArrowLeft" -> prev_tab
- "ArrowRight" -> next_tab
- "Home" -> first_tab
- "End" -> last_tab
- _ -> Error(Nil)
- }
- case selected_tab {
- Ok(selected_tab) ->
- decode.success(event.handler(
- dispatch: selected_message(selected_tab.id),
- prevent_default: True,
- stop_propagation: True,
- ))
- Error(Nil) ->
- decode.failure(
- event.handler(selected_message(tab.id), False, False),
- "",
- )
- }
- }),
- ],
- [html.text(tab.name)],
- )
- let panel =
- html.div(
- [
- attribute.id("tabpanel-" <> tab.id),
- attribute.role("tabpanel"),
- attribute.tabindex(0),
- attribute.aria_labelledby("tab-" <> tab.id),
- attribute.classes([#("hidden", !selected)]),
- ],
- [tab.contents],
- )
- tabs_loop(
- tabs: rest,
- buttons: [button, ..buttons],
- panels: [panel, ..panels],
- selected_tab:,
- selected_message:,
- prev_tab: Ok(tab),
- first_tab:,
- last_tab:,
- )
- }
- }
-}
-
fn failed_to_load_view(error: storage.Error) -> Element(Message) {
let error_message = case error {
storage.CouldNotGetStorageManager -> "Could not get storage manager."
diff --git a/src/bag_of_holding/tabs.gleam b/src/bag_of_holding/tabs.gleam
new file mode 100644
index 0000000..6daa91a
--- /dev/null
+++ b/src/bag_of_holding/tabs.gleam
@@ -0,0 +1,118 @@
+import gleam/dynamic/decode
+import gleam/list
+import lustre/attribute
+import lustre/element.{type Element}
+import lustre/element/html
+import lustre/event
+
+pub type Tab(message) {
+ Tab(id: String, name: String, contents: Element(message))
+}
+
+// Based on
+// (2026-08-03)
+/// All tabs must have unique ids.
+pub fn tabs(
+ tabs: List(Tab(message)),
+ selected_tab: String,
+ selected_message: fn(String) -> message,
+) -> Element(message) {
+ let first_tab = list.first(tabs)
+ let last_tab = list.last(tabs)
+ let #(buttons, panels) =
+ tabs_loop(
+ tabs:,
+ buttons: list.new(),
+ panels: list.new(),
+ selected_tab:,
+ selected_message:,
+ prev_tab: Error(Nil),
+ first_tab:,
+ last_tab:,
+ )
+
+ html.div([attribute.class("tabs")], [
+ html.div([attribute.role("tablist")], buttons),
+ ..panels
+ ])
+}
+
+fn tabs_loop(
+ tabs tabs: List(Tab(message)),
+ buttons buttons: List(Element(message)),
+ panels panels: List(Element(message)),
+ selected_tab selected_tab: String,
+ selected_message selected_message: fn(String) -> message,
+ prev_tab prev_tab: Result(Tab(message), Nil),
+ first_tab first_tab: Result(Tab(message), Nil),
+ last_tab last_tab: Result(Tab(message), Nil),
+) {
+ case tabs {
+ [] -> #(list.reverse(buttons), list.reverse(panels))
+ [tab, ..rest] -> {
+ let next_tab = list.first(rest)
+
+ let selected = tab.id == selected_tab
+ let button =
+ html.button(
+ [
+ attribute.id("tab-" <> tab.id),
+ attribute.role("tab"),
+ attribute.aria_selected(selected),
+ attribute.autofocus(selected),
+ attribute.tabindex(case selected {
+ True -> 0
+ False -> -1
+ }),
+ attribute.aria_controls("tabpanel-" <> tab.id),
+ event.on_click(selected_message(tab.id)),
+ event.advanced("keydown", {
+ use key <- decode.field("key", decode.string)
+ let selected_tab = case key {
+ "ArrowLeft" -> prev_tab
+ "ArrowRight" -> next_tab
+ "Home" -> first_tab
+ "End" -> last_tab
+ _ -> Error(Nil)
+ }
+ case selected_tab {
+ Ok(selected_tab) ->
+ decode.success(event.handler(
+ dispatch: selected_message(selected_tab.id),
+ prevent_default: True,
+ stop_propagation: True,
+ ))
+ Error(Nil) ->
+ decode.failure(
+ event.handler(selected_message(tab.id), False, False),
+ "",
+ )
+ }
+ }),
+ ],
+ [html.text(tab.name)],
+ )
+ let panel =
+ html.div(
+ [
+ attribute.id("tabpanel-" <> tab.id),
+ attribute.role("tabpanel"),
+ attribute.tabindex(0),
+ attribute.aria_labelledby("tab-" <> tab.id),
+ attribute.classes([#("hidden", !selected)]),
+ ],
+ [tab.contents],
+ )
+ tabs_loop(
+ tabs: rest,
+ buttons: [button, ..buttons],
+ panels: [panel, ..panels],
+ selected_tab:,
+ selected_message:,
+ prev_tab: Ok(tab),
+ first_tab:,
+ last_tab:,
+ )
+ }
+ }
+}