diff --git a/docs/docs/ui/agent-guide.md b/docs/docs/ui/agent-guide.md index 96d27ae1..889bef80 100644 --- a/docs/docs/ui/agent-guide.md +++ b/docs/docs/ui/agent-guide.md @@ -119,6 +119,7 @@ They are **not** fine as the public way apps collect domain input when a form co | Date / time | `DatePicker` / `TimePicker` / `DateTimePicker` (+ `.form`) | | Files | `FileDropZone` (+ `.form`) | | Color seed | `ColorPicker` (+ `.form`) | +| Action / nav popup menu | `DropdownMenu` (or `TopBar.item.dropdown`) — not a hand-rolled overlay | | Page shell | `HolyGrail` / `CenteredCard` (`layout`) | --- diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala index b88d71d2..7b5ecdde 100644 --- a/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala @@ -65,6 +65,7 @@ object UIMain extends PageApp[UIMain.Env] { P.showcase.pages.FormAllPage, P.showcase.pages.ModalPage, P.showcase.pages.DrawerPage, + P.showcase.pages.DropdownMenuPage, P.showcase.pages.TooltipPage, P.showcase.pages.TablePage, P.showcase.pages.FeedPage, diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala index c96e40ce..cda4c518 100644 --- a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala @@ -56,6 +56,7 @@ object ShowcaseLayout { navItem("All form fields", FormAllPage, currentPath), navItem("Modal", ModalPage, currentPath), navItem("Drawer", DrawerPage, currentPath), + navItem("Dropdown menu", DropdownMenuPage, currentPath), navItem("Tooltips", TooltipPage, currentPath), navItem("Table", TablePage, currentPath), navItem("Feed", FeedPage, currentPath), diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala new file mode 100644 index 00000000..76b975d0 --- /dev/null +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala @@ -0,0 +1,73 @@ +package oxygen.example.ui.page.showcase.pages + +import oxygen.example.ui.page.showcase.ShowcaseLayout +import oxygen.ui.web.* +import oxygen.ui.web.component.* +import oxygen.ui.web.create.{*, given} + +object DropdownMenuPage extends ShowcaseLayout.SimplePage { + override val path: Seq[String] = Seq("showcase", "overlays", "dropdown-menu") + override def pageTitle: String = "Dropdown menu" + + private def toast(msg: String) = PageMessages.add(PageMessage.info(msg)) + + /** A TopBar with left nav dropdown + right user-menu dropdown (disabled item + separator). */ + private def demoBar: TopBar.Const = + TopBar.empty + .surface + .barHeight(48.px) + .left( + TopBar.item("Home").onClickPush(ShowcaseHubPage.nav()), + TopBar.item.dropdown("showcase-products", "Products")( + TopBar.menuItem("Overview").withIcon(Icon.grid).onSelect(toast("Products → Overview")), + TopBar.menuItem("Pricing").withIcon(Icon.tag).onSelect(toast("Products → Pricing")), + TopBar.menuSeparator, + TopBar.menuItem("Enterprise (soon)").disabled, + ), + ) + .right( + TopBar.item.dropdownWithIcon("showcase-user", Icon.user, "Jane")( + TopBar.menuItem("Profile").withIcon(Icon.user).onSelect(toast("Profile")), + TopBar.menuItem("Settings").withIcon(Icon.settings).onSelect(toast("Settings")), + TopBar.menuSeparator, + TopBar.menuItem("Sign out").withIcon(Icon.logOut).onSelect(toast("Signed out")), + ), + ) + + /** A standalone DropdownMenu (reusable outside TopBar — e.g. SideBar / overflow menus). */ + private def standalone: Widget = + DropdownMenu("showcase-standalone", Icon.moreHorizontal.md, span("Actions")) + .items( + DropdownMenu.item("Rename").withIcon(Icon.edit).onSelect(toast("Rename")), + DropdownMenu.item("Duplicate").withIcon(Icon.copy).onSelect(toast("Duplicate")), + DropdownMenu.separator, + DropdownMenu.item("Delete").withIcon(Icon.trash).onSelect(toast("Delete")), + DropdownMenu.item("Archived (soon)").disabled, + ) + .trigger( + padding := css(S.spacing._2, S.spacing._3), + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + backgroundColor := S.color.bg.layerOne, + color := S.color.fg.default, + ) + + override def body: Widget = + fragment( + ShowcaseLayout.note( + "Click a trigger to open. Keyboard: Enter/Space/↓ open; ↑/↓ move (wrap); Home/End; " + + "Enter/Space select; Esc closes and restores focus. Outside-click closes.", + ), + h3("In a TopBar"), + div( + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + overflow.visible, + marginBottom := S.spacing._6, + demoBar, + ), + h3("Standalone (reusable panel)"), + p(color := S.color.fg.moderate, fontSize := S.fontSize._2, "Same component TopBar uses — drop it anywhere."), + standalone, + ) +} diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala new file mode 100644 index 00000000..363e31ec --- /dev/null +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala @@ -0,0 +1,290 @@ +package oxygen.ui.web.component + +import org.scalajs.dom +import org.scalajs.dom.HTMLElement +import oxygen.predef.core.* +import oxygen.ui.web.* +import oxygen.ui.web.create.{*, given} +import oxygen.ui.web.service.Window +import zio.* +import zio.http.URL + +/** + * Action / nav popup menu (OXY-152; reserved by the `component` package TODO). + * + * A click-to-open menu of typed [[DropdownMenu.Item]]s. Owns its own open/closed state internally + * (a per-instance [[PageLocalState]] keyed by a caller-supplied stable `id`), so it drops in anywhere + * as a plain stateless widget — no need to thread a `Boolean` through page state. Reused by + * [[TopBar]] dropdown items and available for SideBar / overflow menus. + * + * Behaviour: + * - opens on click / `Enter` / `Space` / `ArrowDown` on the trigger, + * - closes on outside-click (transparent scrim), `Escape` (restores focus to trigger), `Tab`, or select, + * - `ArrowUp`/`ArrowDown` roving (wraps), `Home`/`End`, `Enter`/`Space` activate the focused item. + * + * A11y: trigger `role=button` + `aria-haspopup=menu` + `aria-expanded`; panel `role=menu`; items + * `role=menuitem` (+ `aria-disabled`). Styles live in [[oxygen.ui.web.create.OxygenStyleSheet.DropdownMenu]] + * (`O.DropdownMenu`), included via [[oxygen.ui.web.defaults.coreOxygenStyleSheets]]. + * + * {{{ + * DropdownMenu("nav-products", "Products") + * .items( + * DropdownMenu.item("Overview").onClickPush(OverviewPage.nav()), + * DropdownMenu.item("Pricing").withIcon(Icon.tag).onClickPush(PricingPage.nav()), + * DropdownMenu.separator, + * DropdownMenu.item("Coming soon").disabled, + * ) + * }}} + * + * NOTE (v1): one flat level (no nested submenus); positioned `absolute` under the trigger (an ancestor + * with `overflow:hidden` could clip — a portal/`fixed` variant is future work). Trigger opens on click, + * not hover. + */ +final case class DropdownMenu[-Env, +Action]( + private val _id: String, + private val _trigger: Growable[Widget], + private val _items: Seq[DropdownMenu.Item[Env, Action]], + private val _align: DropdownMenu.Align, + private val _showCaret: Boolean, + private val _ariaLabel: Option[String], +) extends PWidget.Deferred[Env, Action, Any, Nothing] { + + def items[Env2 <: Env, Action2 >: Action]( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): DropdownMenu[Env2, Action2] = + copy(_items = _items ++ addItems) + + def trigger(mods: Widget*): DropdownMenu[Env, Action] = copy(_trigger = _trigger ++ Growable.many(mods)) + + def align(a: DropdownMenu.Align): DropdownMenu[Env, Action] = copy(_align = a) + def alignStart: DropdownMenu[Env, Action] = align(DropdownMenu.Align.Start) + def alignEnd: DropdownMenu[Env, Action] = align(DropdownMenu.Align.End) + + def caret: DropdownMenu[Env, Action] = copy(_showCaret = true) + def noCaret: DropdownMenu[Env, Action] = copy(_showCaret = false) + + def ariaLabel(label: String): DropdownMenu[Env, Action] = copy(_ariaLabel = label.some) + + override protected def build: PWidget[Env, Action, Any, Nothing] = + DropdownMenu.openStateFor(_id).attach { st => + val isOpen: Boolean = st.renderTimeValue + val ddId: String = _id + + val triggerNode: WidgetEAS[Env, Action, Boolean] = + div( + O.DropdownMenu.Trigger, + id := DropdownMenu.triggerElemId(ddId), + Widget.raw.htmlAttr("role", "button"), + Widget.raw.htmlAttr("aria-haspopup", "menu"), + Widget.raw.htmlAttr("aria-expanded", isOpen.toString), + Widget.raw.htmlAttr("tabindex", "0"), + _ariaLabel.map(Widget.raw.htmlAttr("aria-label", _)).getOrElse(Widget.empty), + Widget.fragment(_trigger.to[Seq]), + Widget.when(_showCaret)(span(O.DropdownMenu.Caret, Icon.chevronDown.sm)), + onClick.s[Boolean].handle { s => + if isOpen then s.set(false) + else DropdownMenu.openAndFocus(s, ddId) + }, + onKeyDown.es[Boolean].handle { (s, e) => + e.key match { + case "Enter" | " " | "ArrowDown" => + e.preventDefault() + DropdownMenu.openAndFocus(s, ddId) + case "Escape" => + s.set(false) + case _ => + ZIO.unit + } + }, + ) + + div( + O.DropdownMenu.optMods(_.Open -> isOpen), + triggerNode, + Widget.when(isOpen)( + fragment( + div( + O.DropdownMenu.Scrim, + onClick.s[Boolean].handle(_.set(false)), + ), + div( + O.DropdownMenu.Panel.optMods( + _.AlignStart -> (_align == DropdownMenu.Align.Start), + _.AlignEnd -> (_align == DropdownMenu.Align.End), + ), + Widget.raw.htmlAttr(DropdownMenu.panelDataAttr, ddId), + Widget.raw.htmlAttr("role", "menu"), + onKeyDown.es[Boolean].handle { (s, e) => DropdownMenu.panelKeyDown(s, e, ddId) }, + Widget.foreach(_items.toList) { item => DropdownMenu.renderItem(item) }, + ), + ), + ), + ) + } + +} +object DropdownMenu { + + enum Align { case Start, End } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Entry points + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * @param id stable identifier for this menu's open/closed state — must be unique + stable per call site + * (used to key the internal [[PageLocalState]] across re-renders). + */ + def apply(id: String, triggerContent: Widget*): DropdownMenu[Any, Nothing] = + new DropdownMenu(id, Growable.many(triggerContent), Nil, Align.Start, _showCaret = true, None) + + /** Label + optional caret trigger. */ + def apply(id: String): DropdownMenu[Any, Nothing] = apply(id, Widget.empty) + + val item: Item.Const = Item.empty + def item(label: String): Item.Const = Item.empty.label(label) + + val separator: Item.Const = Item.empty.copy(_isSeparator = true) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Item + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * One menu entry: a label (+ optional leading icon), or a [[separator]]. Activation is one of + * `onClickPush` (navigate), `onSelectAction` (raise an action), or `onSelect` (arbitrary effect). + */ + final case class Item[-Env, +Action] private[DropdownMenu] ( + private[DropdownMenu] val _label: String, + private[DropdownMenu] val _icon: Option[Icon], + private[DropdownMenu] val _isDisabled: Boolean, + private[DropdownMenu] val _isSeparator: Boolean, + private[DropdownMenu] val _onSelect: RaiseHandler[Any, Action] => ZIO[Env & Scope, UIError, Unit], + ) { + + def label(text: String): Item[Env, Action] = copy(_label = text) + def withIcon(icon: Icon): Item[Env, Action] = copy(_icon = icon.some) + def disabled: Item[Env, Action] = copy(_isDisabled = true) + def disabled(value: Boolean): Item[Env, Action] = copy(_isDisabled = value) + + def onClickPush(nav: RoutablePage.Navigate): Item[Env, Action] = copy(_onSelect = _ => nav.push) + def onClickPush(page: RoutablePage[?])(params: page.PageParams): Item[Env, Action] = onClickPush(page.navigate(params)) + def onClickPush(page: RoutablePage[?])(using ev: Unit <:< page.PageParams): Item[Env, Action] = onClickPush(page)(ev(())) + def onClickPush(url: => URL): Item[Env, Action] = copy(_onSelect = _ => Window.location.assign(url.encode)) + + def onClickReplace(nav: RoutablePage.Navigate): Item[Env, Action] = copy(_onSelect = _ => nav.replace) + def onClickReplace(page: RoutablePage[?])(params: page.PageParams): Item[Env, Action] = onClickReplace(page.navigate(params)) + def onClickReplace(page: RoutablePage[?])(using ev: Unit <:< page.PageParams): Item[Env, Action] = onClickReplace(page)(ev(())) + + /** Raise a page-level action when selected. */ + def onSelectAction[Action2 >: Action](action: Action2): Item[Env, Action2] = + new Item[Env, Action2](_label, _icon, _isDisabled, _isSeparator, _.raiseAction(action)) + + /** Run an arbitrary effect when selected. */ + def onSelect[Env2 <: Env](effect: => ZIO[Env2 & Scope, UIError, Unit]): Item[Env2, Action] = + new Item[Env2, Action](_label, _icon, _isDisabled, _isSeparator, _ => effect) + + } + object Item { + type Const = Item[Any, Nothing] + val empty: Item.Const = Item("", None, _isDisabled = false, _isSeparator = false, _ => ZIO.unit) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Internal state registry + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + // scala.js is single-threaded; a plain mutable map memoizes one PageLocalState per stable id + // (state itself is scoped per page instance internally). Keeps `id`-keyed state stable across re-renders. + private val openStates: scala.collection.mutable.Map[String, PageLocalState[Boolean]] = + scala.collection.mutable.Map.empty + + private def openStateFor(id: String): PageLocalState[Boolean] = + openStates.getOrElseUpdate(id, new PageLocalState[Boolean](s"DropdownMenu[$id]")(false) {}) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Rendering + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private def renderItem[Env, Action](item: Item[Env, Action]): WidgetEAS[Env, Action, Boolean] = + if item._isSeparator then + div(O.DropdownMenu.Separator, Widget.raw.htmlAttr("role", "separator")) + else + div( + O.DropdownMenu.Item.optMods(_.Disabled -> item._isDisabled), + Widget.raw.htmlAttr("role", "menuitem"), + Widget.raw.htmlAttr("tabindex", "-1"), + Widget.when(item._isDisabled)(Widget.raw.htmlAttr("aria-disabled", "true")), + item._icon.map(icon => span(O.DropdownMenu.ItemIcon, icon.sm)).getOrElse(Widget.empty), + span(item._label), + Widget.when(!item._isDisabled)( + onClick.as[Action, Boolean].handle { (s, rh) => item._onSelect(rh) *> s.set(false) }, + ), + ) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Keyboard + focus (driven by real DOM events → safe without a mount hook) + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private val panelDataAttr: String = "data-oxy-dd" + private def triggerElemId(id: String): String = s"oxy-dd-trigger-$id" + + private def openAndFocus(st: WidgetState[Boolean], id: String): UIO[Unit] = + st.set(true) *> ZIO.succeed { afterRender(() => focusItem(id, 0)) } + + private def panelKeyDown(st: WidgetState[Boolean], e: dom.KeyboardEvent, id: String): UIO[Unit] = + e.key match { + case "ArrowDown" => e.preventDefault(); ZIO.succeed(moveFocus(id, +1)) + case "ArrowUp" => e.preventDefault(); ZIO.succeed(moveFocus(id, -1)) + case "Home" => e.preventDefault(); ZIO.succeed(focusItem(id, 0)) + case "End" => e.preventDefault(); ZIO.succeed(focusItem(id, Int.MaxValue)) + case "Enter" | " " => + e.preventDefault() + ZIO.succeed(clickActive()) + case "Escape" => + st.set(false) *> ZIO.succeed(focusTrigger(id)) + case "Tab" => + st.set(false) + case _ => + ZIO.unit + } + + private def afterRender(f: () => Unit): Unit = + dom.window.requestAnimationFrame(_ => f()): Unit + + private def enabledItems(id: String): Seq[HTMLElement] = { + val nodes = dom.document.querySelectorAll(s"[$panelDataAttr='$id'] [role='menuitem']:not([aria-disabled='true'])") + (0 until nodes.length).map(i => nodes(i).asInstanceOf[HTMLElement]) + } + + /** Focus item at `idx` (clamped); `Int.MaxValue` = last. */ + private def focusItem(id: String, idx: Int): Unit = { + val items = enabledItems(id) + if items.nonEmpty then items(idx.max(0).min(items.size - 1)).focus() + } + + private def moveFocus(id: String, delta: Int): Unit = { + val items = enabledItems(id) + if items.nonEmpty then { + val active = dom.document.activeElement + val current = items.indexWhere(_ eq active) + val next = if current < 0 then (if delta > 0 then 0 else items.size - 1) else Math.floorMod(current + delta, items.size) + items(next).focus() + } + } + + private def clickActive(): Unit = + dom.document.activeElement match { + case el: HTMLElement => el.click() + case _ => () + } + + private def focusTrigger(id: String): Unit = + afterRender { () => + dom.document.getElementById(triggerElemId(id)) match { + case el: HTMLElement => el.focus() + case _ => () + } + } + +} diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala index 3599e872..43caebfc 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala @@ -123,7 +123,7 @@ final case class TopBar[-Env, +Action, -StateGet, +StateSet <: StateGet]( bar( shrinkSection(_left.map(_.withBarColors(c))*), growSection, - shrinkSection(_right.map(_.withBarColors(c))*), + shrinkSection(_right.map(_.withBarColors(c, alignEnd = true))*), ) } @@ -135,19 +135,89 @@ object TopBar extends WidgetTypes[TopBar] { val item: TopBar.Item.Const = TopBar.Item.empty + /** A menu entry for a [[Item.dropdown]] (delegates to [[DropdownMenu.item]]). */ + def menuItem(label: String): DropdownMenu.Item.Const = DropdownMenu.item(label) + + /** A separator line inside a [[Item.dropdown]] menu. */ + val menuSeparator: DropdownMenu.Item.Const = DropdownMenu.separator + final case class Item[-Env, +Action, -StateGet, +StateSet <: StateGet] private ( private val _children: Growable[Widget.Polymorphic[Env, Action, StateGet, StateSet]], private val _bar: Cache, + private val _dropdownId: Option[String], + private val _triggerContent: Growable[Widget], + private val _menuItems: Growable[DropdownMenu.Item[Env, Action]], + private val _alignEnd: Boolean, ) extends PWidget.Deferred[Env, Action, StateGet, StateSet] { private lazy val _built: Widget.Polymorphic[Env, Action, StateGet, StateSet] = - TopBar.itemWidget(_bar).appendChildren(_children) + _dropdownId match { + case Some(id) => + // Trigger reuses the bar-item chrome (height / colors / hover) + caret; panel is the shared DropdownMenu. + DropdownMenu(id, _triggerContent.to[Seq]*) + .items(_menuItems.to[Seq]*) + .align(if _alignEnd then DropdownMenu.Align.End else DropdownMenu.Align.Start) + .caret + .trigger( + create.height := 100.pct, + padding := "0 1rem", + fontSize := S.fontSize._5, + color := _bar.itemFg, + fontWeight := S.fontWeight.medium, + backgroundColor.dynamic.hover := _bar.itemHover, + backgroundColor.dynamic.hoverActive := _bar.itemActive, + ) + case None => + TopBar.itemWidget(_bar).appendChildren(_children) + } override protected def build: PWidget[Env, Action, StateGet, StateSet] = _built private[TopBar] def withBarColors(c: Cache): Item[Env, Action, StateGet, StateSet] = copy(_bar = c) + private[TopBar] def withBarColors(c: Cache, alignEnd: Boolean): Item[Env, Action, StateGet, StateSet] = + copy(_bar = c, _alignEnd = alignEnd) + + /** + * Turn this item into a dropdown / popup menu (OXY-152). `id` must be stable + unique per call site + * (keys the menu's internal open/closed state). Built on the shared [[DropdownMenu]] component so the + * panel behaviour (scrim, keyboard, a11y) is consistent everywhere. + * + * {{{ + * TopBar.item.dropdown("nav-products", "Products")( + * TopBar.menuItem("Overview").onClickPush(OverviewPage.nav()), + * TopBar.menuItem("Pricing").onClickPush(PricingPage.nav()), + * TopBar.menuSeparator, + * ) + * }}} + */ + def dropdown[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( + id: String, + label: String, + )( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): Item[Env2, Action2, StateGet2, StateSet2] = + copy( + _dropdownId = id.some, + _triggerContent = Growable.single(span(label)), + _menuItems = _menuItems ++ Growable.many(addItems), + ) + + /** Dropdown with a leading icon on the trigger. */ + def dropdownWithIcon[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( + id: String, + icon: Icon, + label: String, + )( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): Item[Env2, Action2, StateGet2, StateSet2] = + copy( + _dropdownId = id.some, + _triggerContent = Growable.many(Seq(icon.md, span(label))), + _menuItems = _menuItems ++ Growable.many(addItems), + ) + def apply[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( addChildren: PWidget[Env2, Action2, StateGet2, StateSet2]*, ): TopBar.Item[Env2, Action2, StateGet2, StateSet2] = @@ -187,7 +257,7 @@ object TopBar extends WidgetTypes[TopBar] { } object Item extends WidgetTypes[TopBar.Item] { - val empty: TopBar.Item.Const = Item(Growable.empty, Cache.default) + val empty: TopBar.Item.Const = Item(Growable.empty, Cache.default, None, Growable.empty, Growable.empty, false) def apply(text: String): TopBar.Item.Const = empty.apply(text) diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala index 7febe2e3..f3b6f97c 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala @@ -5,7 +5,6 @@ package oxygen.ui.web // : - CheckBox // : - ContextMenu // : - DateTimeSelect -// : - DropdownMenu // : - Icon // : - Link // : - RadioButtons diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala index fae676df..36d9f007 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala @@ -343,6 +343,143 @@ object OxygenStyleSheet extends StyleSheetBuilder { } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // DropdownMenu + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Action / nav popup menu (OXY-152). A `position: relative` anchor holding a trigger, a full-viewport + * transparent scrim (outside-click), and an absolutely-positioned panel of `menuitem`s. + * Consumed by [[oxygen.ui.web.component.DropdownMenu]] and TopBar dropdown items. + */ + object DropdownMenu extends Class("dropdown-menu") { dm => + + selector( + position.relative, + display.inlineFlex, + alignItems.stretch, + ) + + object Trigger extends dm.Class("trigger") { + selector( + display.inlineFlex, + alignItems.center, + gap := S.spacing._2, + cursor.pointer, + userSelect.none, + ) + Trigger.pc("focus-visible")( + outline := s"2px solid ${S.color.fg.focus}", + Widget.raw.css("outline-offset", "-2px"), + ) + } + + object Caret extends dm.Class("caret") { + selector( + display.inlineFlex, + alignItems.center, + transition := "transform 0.15s ease", + ) + } + + object Scrim extends dm.Class("scrim") { + selector( + position.fixed, + top := 0, + left := 0, + width := 100.vw, + height := 100.vh, + backgroundColor := "transparent", + zIndex := ZIndices.dropdownMenuScrim, + ) + } + + object Panel extends dm.Class("panel") { p => + selector( + position.absolute, + top := 100.pct, + marginTop := S.spacing._1, + minWidth := 12.rem, + maxWidth := 20.rem, + maxHeight := 70.vh, + overflowY.auto, + display.flex, + flexDirection.column, + padding := css(S.spacing._1, 0.px), + backgroundColor := S.color.bg.layerOne, + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + boxShadow := "0 12px 32px rgba(0,0,0,0.24)", + zIndex := ZIndices.dropdownMenuPanel, + animation := "oxy-fade-in var(--oxy-motion-duration-fast) var(--oxy-motion-easing-enter) both", + ) + object AlignStart extends p.Modifier("align-start") { + selector(left := 0) + } + object AlignEnd extends p.Modifier("align-end") { + selector(right := 0) + } + } + + object Item extends dm.Class("item") { i => + selector( + display.flex, + alignItems.center, + gap := S.spacing._2, + width := 100.pct, + padding := css(S.spacing._2, S.spacing._3), + cursor.pointer, + userSelect.none, + whiteSpace.nowrap, + color := S.color.fg.default, + fontSize := S.fontSize._3, + backgroundColor := "transparent", + ) + Item.pc("hover")( + backgroundColor := S.color.bg.layerTwo, + ) + Item.pc("focus")( + backgroundColor := S.color.bg.layerTwo, + outline := "none", + ) + object Disabled extends i.Modifier("disabled") { + selector( + color := S.color.fg.subtle, + cursor := "not-allowed", + opacity := "0.55", + ) + } + } + + object ItemIcon extends dm.Class("item-icon") { + selector( + display.inlineFlex, + alignItems.center, + color := S.color.fg.moderate, + ) + } + + object Separator extends dm.Class("separator") { + selector( + height := 1.px, + margin := css(S.spacing._1, 0.px), + backgroundColor := S.color.fg.subtle, + opacity := "0.5", + ) + } + + object Open extends dm.Modifier("open") + + // Cross-cutting rules (objects above already initialized). + ((DropdownMenu & Open) >> Caret)( + transform := "rotate(180deg)", + ) + ((Item & Item.Disabled).pc("hover"))( + backgroundColor := "transparent", + ) + + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// // Table ////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala index fc0b3adf..3c4d8a22 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala @@ -8,6 +8,11 @@ object ZIndices { val _4: Int = 40 val _5: Int = 50 + // OXY-152: dropdown / popup menus. Below modals so a modal always covers an open menu, + // above normal page content. Panel sits one above its own outside-click scrim. + val dropdownMenuScrim: Int = 8_000 + val dropdownMenuPanel: Int = 8_001 + val modalBehindPageMessages: Int = 9_000 val pageMessages: Int = 10_000 val modalInFrontOfPageMessages: Int = 11_000 diff --git a/report/OXY-152.md b/report/OXY-152.md new file mode 100644 index 00000000..3704619d --- /dev/null +++ b/report/OXY-152.md @@ -0,0 +1,77 @@ +# OXY-152 — Add dropdown menu support to TopBar + +## Goal +Reusable action/nav popup-menu for `TopBar` (and future SideBar / OXY-151 overflow), +with scrim + outside-click/Escape close, a11y roles, keyboard roving, and a showcase. + +## Key decisions (open questions resolved) +- **Reusable component**: built `component/DropdownMenu.scala` (per the `component/package.scala` TODO) + rather than a TopBar-private panel. TopBar delegates to it. Removed `DropdownMenu` from the package TODO. +- **Menu-item model**: typed `DropdownMenu.Item` (label, optional icon, `disabled`, `separator`, activation + effect) — NOT arbitrary Widget. Typed model is what lets us emit correct `role=menuitem` + a11y and keyboard + handling. Activation supports `onClickPush` (nav/page/url), `onSelectAction(a)` (raise action), and + `onSelect(effect)` (arbitrary ZIO). +- **Local state**: menu open/closed is component-local via a per-instance `PageLocalState[Boolean]`, keyed by a + caller-supplied **stable `id: String`** and memoized in a registry. This makes the menu self-contained + (`WidgetEA` via `.attach`) so it drops into `TopBar` without changing TopBar's page-state type. Trade-off: + caller must pass a stable id (documented). Chosen over threading `Widget.state[Boolean]` through page state + (Drawer/Dropdown style) because TopBar menus shouldn't pollute page state. +- **Trigger**: click-to-toggle (not hover). Hover-open is fragile for a11y and touch; documented as future. +- **Levels**: one flat level for v1 (no nested submenus). Documented as future. +- **Positioning**: `position: absolute` panel anchored to a `position: relative` trigger wrapper (mirrors the + existing `Dropdown` component, which works under HolyGrail). Full-viewport **fixed transparent scrim** catches + outside clicks (Drawer-style). Alignment start/end (left/right-anchored). zIndex: added + `ZIndices.dropdownMenuScrim/Panel` (8000/8001) — below modals (9000), above content. + Known limitation: an ancestor with `overflow:hidden` between wrapper and viewport could clip the panel; + a portal/`fixed`+measured approach is future work (noted in Q6). Not observed under HolyGrail top row. +- **A11y**: trigger `role=button`, `aria-haspopup=menu`, `aria-expanded`, `tabindex=0`; panel `role=menu`; + items `role=menuitem`, `aria-disabled`. Keyboard: Enter/Space/ArrowDown open; ArrowUp/Down roving (wrap), + Home/End, Enter/Space activate, Escape closes + restores focus to trigger, Tab closes. Focus first item on + open, restore to trigger on Escape — driven via DOM (`data-oxy-dd`/id) + `requestAnimationFrame`. +- **Caret**: optional chevron indicator (default on for TopBar dropdowns), rotates when open. +- **Stylesheet**: added `O.DropdownMenu` (Trigger/Caret/Scrim/Panel/Item[+Disabled]/ItemIcon/Separator/Open) + to `OxygenStyleSheet` (the `O` object), already registered via `coreOxygenStyleSheets`. Theme tokens only. + +## TopBar integration +- `TopBar.Item` gains an optional dropdown (`_dropdownId` + `_menuItems`). When set, the item renders a + `DropdownMenu` whose trigger reuses the bar-item chrome (height/hover/colors) + caret; else unchanged (flat). +- New API: `TopBar.item.dropdown(id)(items*)`, plus `TopBar.menuItem(...)` / `TopBar.menuSeparator` helpers. +- Right-section dropdowns auto-align to the end (via `withBarColors(cache, alignEnd)`). +- Existing flat call sites unchanged (verified `common.scala` / `ShowcaseLayout` still compile). + +## Showcase / docs +- New `DropdownMenuPage` showcase (left nav dropdown, right user-menu dropdown, disabled item, separator, + standalone DropdownMenu). Registered in `UIMain.pages` + `ShowcaseLayout.sideNav`. +- Brief note added to `docs/docs/ui/agent-guide.md` component list. + +## Files touched +- `modules/ui/web/.../component/DropdownMenu.scala` (new) — the component. +- `modules/ui/web/.../create/OxygenStyleSheet.scala` — added `O.DropdownMenu` classes. +- `modules/ui/web/.../create/ZIndices.scala` — `dropdownMenuScrim`/`dropdownMenuPanel`. +- `modules/ui/web/.../component/TopBar.scala` — `Item.dropdown` / `dropdownWithIcon`, `menuItem`/`menuSeparator`, right-align. +- `modules/ui/web/.../component/package.scala` — removed `DropdownMenu` from TODO. +- `example/apps/ui/.../showcase/pages/DropdownMenuPage.scala` (new) — showcase. +- `example/apps/ui/.../UIMain.scala` + `.../showcase/ShowcaseLayout.scala` — registration + nav. +- `docs/docs/ui/agent-guide.md` — component table row. + +## Verification +- `oxygen-ui-web/compile` ✅ `example-ui-web/compile` ✅ `oxygen-ui-web/test` ✅ (45 passed, incl. Shell chrome). +- NOT verified: live browser run (focus/keyboard behaviour reasoned through, not click-tested in a browser). +- Build note: sbt-git's JGit cannot read a git *worktree* (`NoWorkTreeException`); used a temporary + `zz-git-workaround.sbt` overriding `git.*` settings to build, then deleted it (NOT committed). Building this + worktree with sbt again requires re-adding a similar override, or running from a non-worktree checkout. + +## Progress log +- Explored TopBar, Drawer, Dropdown, stylesheet DSL, event/state/attach APIs. Decisions above. +- Implemented component + stylesheet + TopBar integration + showcase + docs. Compiles + tests green. + +## CONFIDENCE SCORE: 7/10 +Solid on: compiles across lib + example app, unit tests pass, no regression to flat TopBar call sites, +typed a11y roles/keyboard/focus wired, reusable component per the package TODO, self-contained state. +Uncertainty: (1) no live browser test — focus-first-on-open / roving / focus-restore rely on +`requestAnimationFrame` + DOM queries and the assumption that `state.set` re-renders before rAF fires +(reasoned correct for scala.js, unverified visually); (2) `absolute` panel could be clipped by an ancestor +`overflow:hidden` in some layouts (documented, portal variant deferred); (3) menu open-state keyed by a +caller-supplied stable `id` — correct but a new ergonomic contract callers must honour. + +