feat(controls): Dropdown control + Navigatable.onBack hook - #31
Merged
Conversation
…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
|
📦 PR Preview deployed! Examples are available at: |
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 avaluetext placeholder) and no extra popup symbol.setValues()(same shapes asSelectbox),select(),getValue(), and thevalue/index/valuespropertiesopen(),close(commit),isOpen(); positioned throughlocalToGlobal, so it lands under the symbol even when the view (or any ancestor) is scaled'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 scrollchange(committed value),open,closedeactivate()cancels an open listViews/Navigatable.triggerBacknow offers the press to the active control first:this._current.onBack(actor); when it returnstruethe 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);
Selectboxonly 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, thatdownmoves the highlighted row (pixel-checked), that commit fires exactly onechange, that cancel changes nothing, and thatNavigatable.triggerBackis swallowed while open and passed through when closed. Verified RED before the implementation and by mutating_moveHighlightand the popover placement afterwards.npm run smoke: all example pages, both render modes, pass against the builtdist.PW_CHANNEL(so they can run against a system Chrome) and ignore the browser's ownfavicon.icorequest.No version bump, no publish.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ByQwVfWJtoX87ogrmKDg1M