Skip to content

feat(controls): Dropdown control + Navigatable.onBack hook - #31

Merged
daedeloth merged 8 commits into
masterfrom
feature/dropdown-control
Sep 8, 2026
Merged

feat(controls): Dropdown control + Navigatable.onBack hook#31
daedeloth merged 8 commits into
masterfrom
feature/dropdown-control

Conversation

@daedeloth

Copy link
Copy Markdown
Member

What

A new easelbone.Controls.Dropdown: a select control that draws its own popover list on the stage, above every layer, so a theme only needs the usual selectbox-style symbol (anything with a value text placeholder) and no extra popup symbol.

  • values: setValues() (same shapes as Selectbox), select(), getValue(), and the value / index / values properties
  • popover: open(), close(commit), isOpen(); positioned through localToGlobal, so it lands under the symbol even when the view (or any ancestor) is scaled
  • input: 'a'/'start' opens or commits, 'up'/'down' move the highlight (scrolling the list when the selection would leave the window), 'back'/'b' cancels; mouse: click the symbol to open, a row to commit, outside to cancel, wheel to scroll
  • events: change (committed value), open, close
  • deactivate() cancels an open list

Views/Navigatable.triggerBack now offers the press to the active control first: this._current.onBack(actor); when it returns true the press is consumed and the view's back callback is not called. An open dropdown swallows the back press and closes without committing; a closed one lets it through as before.

Why

The QuizWitz settings screen needs a real select for long option lists (languages, resolutions); Selectbox only steps through values one at a time, and back should close an open list rather than leave the screen.

How tested

  • npm run test:dropdown (new): drives the example page headlessly — asserts the popover lands exactly under the symbol in stage pixels against the example's 800→1024 view scale, paints opaquely on top and disappears on close, that down moves the highlighted row (pixel-checked), that commit fires exactly one change, that cancel changes nothing, and that Navigatable.triggerBack is swallowed while open and passed through when closed. Verified RED before the implementation and by mutating _moveHighlight and the popover placement afterwards.
  • npm run smoke: all example pages, both render modes, pass against the built dist.
  • Both harnesses now accept PW_CHANNEL (so they can run against a system Chrome) and ignore the browser's own favicon.ico request.

No version bump, no publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M

…ble.onBack hook

Controls/Dropdown binds to any symbol with a `value` text placeholder and
draws its own popover list straight on the stage (above every layer), so no
extra theme symbol is needed. Keyboard/gamepad: 'a'/'start' opens or commits,
'up'/'down' move the highlight, back cancels; mouse: click to open, click a
row to commit, click outside to cancel, wheel to scroll. Emits 'change',
'open' and 'close'.

Views/Navigatable.triggerBack now offers the press to the active control
first (`onBack(actor)`); a control that returns true swallows it, so an open
list closes instead of leaving the view.

The controls example grows a second selectbox symbol driven by the new
control, and tools/dropdown-test.js drives it headlessly: popover placement
against the scaled example view, on-top painting, highlight movement, commit,
cancel and the triggerBack hand-off. Both Playwright harnesses now honour
PW_CHANNEL and ignore the browser's own favicon request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

📦 PR Preview deployed!

Examples are available at:

daedeloth and others added 7 commits September 8, 2026 12:11
…n its view is removed

The popover's position and width already came through the ancestor transform,
but rowHeight, padding, the border and the corner radius were drawn as raw
stage pixels: on a scaled view (the game fits a 1920x1080 design onto the
canvas) the panel matched the symbol's width while its rows were off by the
scale factor. open() now derives that factor from the same two localToGlobal
points (`(topRight.x - topLeft.x) / bounds.width`, guarded) and _renderPanel
scales every metric with it; `this._rowPx` is the one row height used by the
renderer, the outside-click hit test and the panel height. A `width` option,
when given, stays an absolute stage-pixel width (documented in the header).

The panel is a stage child and the mousedown handler lives on the stage, so a
screen change left both orphaned along with the wheel callback. open() now
watches for removal and closes the list; EaselJS dispatches 'removed' only on
the object actually removed and never on its descendants, so the listener goes
on every link between the symbol and the stage. _onStageDown and _renderPanel
additionally self-close when the symbol is no longer on the stage they opened
against.

The example exposes visibleRows/stageChildren/removeView and a scaled panel
height; the test asserts the painted panel is (rows x rowHeight x scale) tall
- inside the bottom edge opaque, just past it empty - and that tearing the
view off the display list closes the list and leaves no panel on the stage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
…r inside the canvas

The wheel was borrowed from the Utilities/Mousewheel singleton, which a
ScrollArea host re-arms on mouseover and clears on mouseout - and the
popover is a stage child outside that subtree, so moving the cursor onto
the list wiped the dropdown's callback and the wheel went dead. The
control now owns a DOM 'wheel' listener (legacy 'mousewheel' fallback) on
the stage's canvas for as long as the list is open, acting (and
preventing the default) only while the pointer is over the panel.

The popover also never flipped or clamped, so a symbol low on the screen
opened a list that ran off the canvas. Its x/y are now clamped to the
canvas, so every visible row stays on screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
…ght, scrollbar, long-list scrolling test

An open Dropdown now behaves like a native <select>: it captures the whole
navigation axis, so the focus cannot leave it until the list is committed or
cancelled. Navigatable.next/previous give the active control first refusal the
same way triggerBack already did - a control whose capturesNavigation() returns
true receives the press as 'right'/'left' key input instead of the focus moving
on. An open list ignores those; a CLOSED list opens on 'up'/'down' rather than
stepping the value behind the reader's back.

The popover also highlights the row under the cursor and, when the list is
longer than maxRows, draws a scrollbar inside its right edge: a thumb sized and
positioned by the visible window, and a track that pages the list when clicked
above or below the thumb.

The controls example gets a second, 40 option dropdown with a 6 row window (and
the stage mouseover tracking the hover highlight needs), and the dropdown test
covers the capture, the closed up/down open, keyboard/wheel/scrollbar scrolling
of the long list, the hover highlight, and the thumb's size, position and paint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
A captured navigation press was handed to the control as a hard-coded
'right'/'left'. In a VERTICAL Navigatable the physical up/down keys are the
navigation axis, so an open Dropdown swallowed them and the reader had to move
the highlight with left/right - the opposite of the native <select> behaviour
the capture was meant to give.

next()/previous() now forward this._controls.navigation[1]/[0]: a horizontal
view still forwards 'right'/'left' (which an open list ignores), a vertical one
forwards 'down'/'up' so its own arrow keys move the highlight. Either way the
focus stays on the dropdown until the list is committed or cancelled.

The example exposes the view's navigation control names and the dropdown's
value count, and the capture test asserts what physically happens in the
example's (vertical) orientation - two captured next() presses move the
highlight by two and leave currentIndex alone - while still proving that
'left'/'right' on an open list do nothing at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
…rows; click-to-commit test

The hover highlight called _renderPanel(), which removeAllChildren()s and
rebuilds every row. EaselJS dispatches 'click' only when the object under the
mouseup is the same object that was under the mousedown
(Stage._handlePointerUp: `if (target == oTarget)`), and the hover handler fires
from the enableMouseOver interval - 5 Hz in the game - so a tick landing inside
a press swapped the row's Shape out and the click was never delivered: the row
did not commit and the list stayed open.

The visible rows (their fill Shape and their BigText) are now kept alive for the
panel's lifetime. A highlight change goes through _paintHighlight(old, new),
which redraws just those two fills, sets the two texts' colours with
BigText.setColor and repositions the scrollbar thumb via _paintThumb. Only a new
window on the list - open, or a scroll - still rebuilds, and _moveHighlight
picks between the two by whether _scrollTop actually moved.

The example runs enableMouseOver at 5 Hz (the game's rate) so the race is
reproducible, and the test now presses on a row, wanders to its neighbour and
back while holding the button, and releases: with the rebuild in place the row
committed nothing, with the repaint it commits normally. A click on empty canvas
outside the panel is asserted to cancel without firing 'change'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
Selectbox and Dropdown each carried their own copy of the same value
model: the setValues() shape conversion, the selected index/value, the
text/value lookups and the value/index/values accessors. Both now extend
a new abstract Controls/Choice, which owns that model and the `value`
text placeholder; the subclasses keep only how the choice is made (the
spinner's buttons and next/previous, the dropdown's popover).

Choice throws its own message for a missing placeholder, but both
subclasses check element.value themselves first, so their existing error
strings are unchanged.

The one deliberate behaviour change: the dropdown's collapsed value is
now painted with style.font AND style.text (the colour was never applied
before). Selectbox.getValue()/.value/.text answer null instead of
throwing when no values were ever set, as the Dropdown already did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
It was kept as a compatibility shim, but it is a trap rather than one:
TextPlaceholder refuses to wrap the same element twice (the
easelPlaceholderInitialized guard logs an error and returns early), so a
second call would leave the selectbox with a half-built container. Choice'
constructor already builds the text element, and there are no callers in
easelbone or in the game.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M
@daedeloth
daedeloth merged commit 4d2c1f8 into master Sep 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant