[1.0.0-rc.4] - 2026-07-07 [PUBLISHED]
First unreleased RC after the published rc.3; bundles tree mode and everything built on top of it, plus the earlier demo-site polish.
Added
Tree of options.
<.web_multiselect>can now render its options as an always-expanded hierarchy, indented by depth. Give each option a materialized dot-path ("1","1.1","1.1.1", …) and setpath_member— tree mode auto-enables and parent/level are derived from the path. Every node (branch or leaf) stays a normal, selectable option; search filters the hierarchy to matches plus their ancestors. New typed attributes:path_member,parent_path_member,level_member,has_children_member,tree_path_separator. There is no collapse — use@keenmate/web-treeviewfor expand/collapse. New guideguides/tree_of_options.md(on hexdocs and in the package). Demo card on/examples/tree.Badge full title. New
full_title_memberreads a fully-qualified label supplied with the data (e.g."Fruit / Pome fruit / Apple"); it is never computed. Setshow_badge_full_title={true}and badges render that full title instead of the display value (falling back to the display value for options without one). Pairs naturally with tree mode — show the full breadcrumb in the badge.External search now composes with tree mode (via the bundled upstream
1.12.0-rc07). A JS-sidesearchCallbackthat returns matching options now rebuilds the tree from those matches, keeping their ancestors — so you can externalize the whole search over a hierarchy. Demoed on the new/examples/search-indexpage.Per-node selectability in tree mode. New typed attribute
is_selectable_memberpoints at a boolean key in your option data: a falsy value makes that node non-selectable — it renders normally (not greyed out likedisabled), but has no checkbox, is skipped by keyboard focus, and cannot be toggled or picked by Select All. Ideal for structural branch rows (e.g. a leaves-only tree); usedisabled_memberfor genuinely unavailable options. The JS-onlygetIsSelectableCallbackpredicate (which receives the built node and can read its derivedhasChildren) has no HEEx attribute — precompute the flag in LiveView instead. Documented inguides/tree_of_options.md(new "Selectable leaves only" section, with aonly_leaves_selectable/1helper); demoed on/examples/tree.Programmatic selection can announce itself —
el.setSelected(values, {notify: true})(via the bundled upstream1.12.0-rc07).setSelectedstays silent by default (so restoring saved state, cascade resets, or server-authoritative corrections don't firechange/ loop back), but a deliberate gesture — like a custom action button that sets the selection — can opt into a single aggregatechangeso it reaches the same LiveView handler a manual pick does (demoed by the "All fruit" action on/examples/tree§11, visible in the server round-trip panel).Cascade checkboxes + a cascade value policy in tree mode. Two new typed attributes:
checkbox_mode("independent"default |"cascade") andcascade_select_policy("rolled-up"default |"leaves"|"all"). Incascademode, checking a node toggles its whole subtree and a partially-selected branch shows a tristate (dash) box. The value policy — the piece@keenmate/web-treeviewlacks — decides which values a cascade selection emits (badges / form /change):rolled-upis the minimal cover (a fully-selected subtree collapses to its root; partial branches emit their individually-checked descendants, rolling to the nearest selectable descendant when the root is non-selectable),leavesemits only checked leaves,allemits every fully-checked node. Non-breaking —independentis the default. Documented inguides/tree_of_options.md(new "Cascade checkboxes and the value policy" section); three-policy demo on/examples/tree.Adjustable row height & long-title handling for options (incl. tree nodes) — via CSS variables, no new attributes. Deep tree indentation makes long labels likely, and by default a title wraps to multiple lines. Now you can:
- set a consistent row height with
--ms-option-min-height(rows still grow if content is taller; virtual-scroll rows keep the fixed--ms-option-height); and/or - truncate long titles to one line with an ellipsis by setting
--ms-option-title-white-space: nowrap,--ms-option-title-overflow: hidden,--ms-option-title-text-overflow: ellipsis(pair withenable_option_tooltips={true}to show the full text on hover).
These are option-level (flat lists too), scoped to
web-multiselector a single instance via the forwardedclass/style. Follows the same label-hook approach as@keenmate/web-treeview. Documented inguides/tree_of_options.mdandguides/theming.md.- set a consistent row height with
Changed
- More pronounced option checkboxes. The unchecked box now draws its border from a dedicated mid-grey token (
--ms-checkbox-border-color, default#8f8f8f) instead of the faint generic border, with a slightly bolder checkmark, so it reads clearly as a clickable control. Themeable via new--ms-checkbox-border-width/--ms-checkbox-border-color/--ms-checkbox-checkmark-thicknessvariables (override onweb-multiselector an instance). - Bundled
@keenmate/web-multiselectupgraded to1.12.0-rc07(from1.12.0-rc05) — the upstream release that adds tree mode, per-node selectability, cascade checkboxes + value policy, badge full title, the row-height/long-title CSS variables, and the more-pronounced checkboxes.priv/static/multiselect.{js,css,d.ts}re-bundled;Keenmate.WebMultiselect.upstream_version/0now reports"1.12.0-rc07".
Fixed
- Row height jumped as filtering crossed the virtual-scroll threshold (fixed in the bundled upstream
1.12.0-rc07). Withenable_virtual_scroll+ a customoption_height, rows switched between the fixed virtual height and natural content height as the result count crossed the threshold (e.g. 32px → ~34px while typing). Non-virtual rows now use the same fixed height when virtual scroll is enabled. - Tree mode blanked out after clearing a search (fixed in the bundled upstream
1.12.0-rc07). Clearing the search with Escape — or reopening the dropdown after a search — left a virtual-scrolled tree with reserved height but no rows. It now restores the full tree. - Option rows no longer text-select on click-drag (fixed in the bundled upstream
1.12.0-rc07). Clicking a row — or dragging the mouse across the dropdown — highlighted the option labels as if they were selectable text, which reads as broken for a pure selection gesture (most visible on tree rows). Options now setuser-select: none. - Cascade Select All now emits the rolled-up value set (fixed in the bundled upstream
1.12.0-rc07). The built-in Select All added every selectable node's value directly; withcheckbox_mode="cascade"it now projects throughcascade_select_policy, so it matches what clicking a node produces (a fully-selected subtree collapses to its root).
Internal — demo site
New
/examples/search-indexpage — externalized search with FlexSearch (loaded client-side, never in the wrapper bundle). Delegates the whole search to a FlexSearch index viasearchCallback: fuzzy/partial/accent-insensitive/ranked matching on a flat list of accented city names, plus the full ISCO-08 tree (index covers title + code + breadcrumb; matches keep ancestors). Gallery card + route added./examples/treenow includes a real-data card: the full ISCO-08 occupation classification (619 groups — 10 major → 43 sub-major → 130 minor → 436 unit). Loaded at compile time by a newTestApp.Isco08module fromtest_app/priv/data/isco08.tsv; the ISCO code doubles as the materializedpath(2211→2.22.221.2211), only unit groups (leaves) are selectable, and it runs with virtual scroll + compact rows (option_height={32}) + title truncation. A customgetBadgeDisplayCallback(attached via JS) shortens each badge to just the leaf title — prefixed.. /when it has ancestors — andgetBadgeTooltipCallbackputs the full breadcrumb in the badge tooltip. AgetSearchValueCallbackwidens the built-in filter to also match the ISCO code and full breadcrumb (so2211orhealthresolve, not just titles). Demonstrates tree mode,is_selectable_member, cascade checkboxes, custom badge/tooltip/search callbacks, and the row-height/truncation variables together at scale. (Source: ISCO-08 © International Labour Organization, demo data.test_app/is excluded from the Hex package.)/examples/treerebuilt as a 1:1 mirror of the upstreamexamples-tree.html— same eleven numbered sections in the same order (Basic Tree → Single/Checkboxes → Search → Separator → Full breadcrumb → Leaves-only → Indentation → Row height → ISCO → Cascade → Action Buttons), same prose/demos/data, so the two demo sites line up side by side. Section 11 shows built-in Select All / Clear All plus a customel.actionButtonsaction on a cascade tree. Plus a wrapper-only<.keen_card>— "Sending paths to the server" — that setsvalue_member="path"soselect/changeevents carry the materialized paths (and, with cascaderolled-up, one parent path per full branch), logged live by the server-round-trip panel. Also fixed the performance page's rich-render demo (product data was missing theidfield its icon renderer reads, so the badge showed "undefined").Floating "server round-trip" monitor on every example page. A
ServerEventsPanelLiveComponent — wired once through the sharedexample_page/1shell, so it appears on all/examples/*pages without touching each one — pins a small card to the bottom-right that counts and logs everyselect/deselect/changethe LiveView server receives from any<web-multiselect>on the page. It's visible proof the wrapper does a real client → server → re-render round-trip, not just standalone web-component state. Driven by aServerMonitorJS hook (registered in the demoapp.js) that forwards the DOM events to the component viapushEventTo. Ported from the siblingkeen_web_daterangepickerdemo.<.tip>code hints across all example pages, plus<.keen_card>and a shared keen-extras stylesheet. Addedtip/1(a compact one-line "here's the wrapper attribute" hint) andkeen_card/1(dashed-border card + badge for wrapper-only features) toTestAppWeb.Examples.SharedComponents, backed by a newpriv/static/assets/examples-keen.css(linked in the demo layout) that also absorbs the server-monitor styles — which restoresexamples-shared.cssto a verbatim upstream mirror. Applied ~73 tips across the 12/examples/*pages: each demo card now leads with the key<.web_multiselect>attribute(s) it showcases (e.g.badges_display_mode="count",enable_virtual_scroll={true},actions_position="top"), JS-only callbacks are labelled as such rather than mislabelled as attributes, and the three server-driven cards on the events-callbacks page became<.keen_card>s. Mirrors the note/hint conventions from thekeen_web_daterangepickerdemo.
[1.0.0-rc.3] - 2026-07-04 [PUBLISHED]
Documentation-focused release — a dedicated theming guide (the primary source of knowledge for how the component is styled) and package links to the live examples site. No component code or behaviour change.
Added
- Theming guide —
guides/theming.md, shipped in the package and rendered on hexdocs. The wrapper had no theming documentation, yet the component is styled entirely through CSS custom properties, so this is core knowledge. The guide documents the two-tier cascade (each--ms-*component token is defined asvar(--base-*, <fallback>), verified against the bundledmultiselect.css) and walks the three integration paths a consumer hits: (1) with pure-admin — it populates the--base-*tokens the component already reads, so the multiselect matches palette and dark mode with zero configuration; (2) with other KeenMate components, no pure-admin — you own the--base-*layer as a single source of truth (the layer is the components' own token contract and predates pure-admin); (3) standalone — the built-inlight-dark()fallbacks give a working light/dark theme with nothing set, and--ms-*overrides (scoped toweb-multiselector per-instance via the forwardedclass/style) restyle just the multiselect. Also covers the five dark-mode signals,--ms-remproportional sizing, and the unlayered-reset cascade-layer footgun. Wired intomix.exsextras:(hexdocs) andfiles:(Hex tarball). - LLM / coding-agent docs — an
ai/folder shipped in the package. A flat-text knowledge base modelled on the upstream component'sai/layout but written for the wrapper (HEEx, the LiveView hook, the helpers): anINDEX.txt(file overview + keyword index + common-questions + features-by-category) plus topic files for getting-started, the<.web_multiselect>attribute reference, LiveView events,push_update/3,search_event, forms, option data shapes, theming, dead-view/SSR, and the installer — and acookbook.txtof 12 copy-paste recipes (single-select, grouped, form field, server-authoritative max-N, grouped cascade, async search, reset, lazy load, custom member keys, dead-view POST, per-instance theming). Grounded in the real API (exact event names/payloads,push_update/3opts,{value, label}tuple order, the six defaulted member attrs). Ships in the tarball (files:now includesaiand a rootAGENTS.md) so a consumer's coding agent can read them fromdeps/keen_web_multiselect/.AGENTS.mdis also surfaced on hexdocs as a Using with AI agents guide (inextras:), and ex_doc's generatedllms.txt(served by hexdocs, the llms.txt convention) indexes every page — so agents get a machine-readable table of contents for free.
Changed
- Package metadata now links the live examples site. Added
homepage_url(renders as the package Homepage on hex.pm) and an"Examples site"entry in thelinksmap — both pointing at https://keen-web-multiselect.keenmate.dev — plus a short Theming section in the README linking to the new guide.
[1.0.0-rc.2] - 2026-07-02 [PUBLISHED]
Documentation completeness pass over the component's attribute table.
Changed
- Every
web_multiselect/1attribute now carries adoc:description. rc.1 documented the 24 non-obvious attrs (hook,search_event, the option-tooltip family, the placeholder overrides, etc.) but left 46 — the "obvious from the name" behaviour / badge / search / member / virtual-scroll attrs — with no inline docs, so they rendered in the generated attributes table with an empty Description column. All 46 now have concise one-line descriptions (noting the relevant upstream default, e.g.multipledefaults totrue), plus a short "Attribute defaults" preamble in theweb_multiselect/1@docexplaining thenil-means-omit convention. No code or behaviour change — hexdocs only.
Fixed
- README install snippet pinned the wrong major. It still read
{:keen_web_multiselect, "~> 0.1"}from before the jump to1.0, which resolves to nothing now that only1.0.0-rc.*is published — a copy-pastemix deps.getwould fail. Corrected to~> 1.0, with a note on requiring~> 1.0.0-rcto opt into the current release candidate (a plain~> 1.0skips pre-releases).
[1.0.0-rc.1] - 2026-07-02 [PUBLISHED]
Tracks @keenmate/web-multiselect v1.12.0-rc05 (option hover tooltips, action-button positioning/alignment, smart built-in action defaults, beforeSelect/beforeDeselect veto interceptors, and the breaking *Callback→on* notification rename — on top of rc04's placeholder ergonomics for non-searchable pickers and cascade multiselects, batch setAttributes, debounced async search, and AbortSignal in searchCallback), plus two wrapper-level extensions to support patterns the core phx-update="ignore" semantics can't reach: a server→client update channel for option/selection mutation, and a declarative server-side searchCallback. A subsequent code-level audit against upstream's FEATURES.md produced a wave of typed-attr completions, two enum bug fixes, an unconditional canonical-member-default change in OptionHelpers, and a major e2e expansion targeting wrapper-only behaviour. A later visual-parity pass then walked every /examples/* page against its upstream examples-*.html mirror (diffing the pages changed since rc04): it ported the rc05 demos that hadn't been mirrored (action-button positioning/rows/alignment, the classic→events cross-link), added one more typed attr (show_select_all) surfaced by the diff, and shipped the document-level Font Awesome <link> the icon demo's own instructions require.
Added
mix keen_web_multiselect.install— a one-command installer for esbuild Phoenix apps. Idempotently wires the assets: importsmultiselect.js+ the hook intoassets/js/app.js, registersKeenWebMultiselectHookon theLiveSocket(adds ahooks:key to a default socket, or merges into an existing object-literal one), and importsmultiselect.cssintoassets/css/app.css. Dependency-free (a plainMix.Task, no Igniter — the friction here is JS/CSS text, not Elixir AST). Conservative by design: a non-object-literalhooks:, a missingapp.js(importmap apps), or an unrecognizedLiveSocketshape is left untouched and printed as a manual step rather than risking a bad edit. Supports--dry-run. The text transforms are pure functions with unit coverage for the default-socket, existing-hooks, non-literal-hooks, no-socket, and idempotent re-run cases.Keenmate.WebMultiselect.push_update/3— a first-class helper for the server→client update channel. Wraps the rawpush_event(socket, "web_multiselect:update", %{id: ..., options: ..., value: ...})so consumers no longer hand-write the magic event name or payload shape.push_update(socket, "region", options: opts, value: [])swaps options and clears selection; only the keys you pass are sent (value: []clears without touching options;options: optsleaves the selection to the component). The underlying event and hook behavior are unchanged — purely an ergonomic surface over the existing channel. Documented with cascade and server-authoritative-rule examples in the README and demonstrated on the events-callbacks page.hook={true}shorthand on<.web_multiselect>. The:hookattr now accepts a boolean as well as a string:hook={true}resolves to the bundled"KeenWebMultiselectHook", a string still names a custom hook, andfalse/nilrender no hook. Removes the stringly-typed footgun where a typo in the hook name was a silent no-op. Attr type widened:string→:any; resolution happens in the component body (resolve_hook/1).- Bundled upstream bumped to
@keenmate/web-multiselectv1.12.0-rc05 —priv/static/multiselect.js/multiselect.css/multiselect.d.tsresync'd from the upstream dist.Keenmate.WebMultiselect.upstream_version/0now returns"1.12.0-rc05". rc05 changes summary: hover tooltips on dropdown options (enable-option-tooltips+option-tooltip-placement/-follow-cursor/-delay/-offset,getOptionTooltipCallback,--ms-option-tooltip-*vars); action-button positioning (actions-positiontop/bottom,actions-align, multi-rowActionButton.row); smart built-in action defaults (select-all/clear-allauto-disable when they'd be no-ops, unless the consumer set an explicitisDisabled/getIsDisabledCallback);beforeSelectCallback/beforeDeselectCallbackveto interceptors; a new--ms-state-min-heightthemable var; and two bug fixes (single-select stale-filter on reopen, dropdown height swap between empty/loading states). The DOM event names (select/deselect/change) are unchanged, soKeenWebMultiselectHookneeds no changes. - Five new typed
attr/3declarations inKeenmate.WebMultiselect.Components.web_multiselect/1for the rc05 option-tooltip surface:enable_option_tooltips(boolean),option_tooltip_placement(enum, 12 Floating-UI placements, defaulttop-start),option_tooltip_follow_cursor(boolean),option_tooltip_delay(integer),option_tooltip_offset(integer); plusactions_position(enumtop/bottom) andactions_align(enumstretch/left/right/center/space-between) for the action-button layout. Snake→kebab mapping unchanged; samenil-default-means-omit semantics as every other attr. show_select_alltypedattr/3inKeenmate.WebMultiselect.Components.web_multiselect/1— exposes upstream's built-in Select All action button (show-select-all→ internalisSelectAllShown). Boolean, samenil-default-means-omit semantics as the rest; pairs naturally withshow_checkboxes. Previously reachable via neither the typed surface nor the:restglobal —show_select_allisn't a valid HTML global attribute, so Phoenix dropped it silently (it landed in neither the top-level assigns nor@rest, soOptionHelpersnever kebab-cased it and nothing reached the element). Surfaced by the visual-parity pass: the performance page's 15k-option filter demo was missing the button upstream shows.- Two new mirrored example pages (
/examples/tooltips,/examples/events-callbacks) — card-for-card mirrors of upstream's newexamples-tooltips.htmlandexamples-events-callbacks.html, plus hub-index cards and router entries. The tooltips page covers default/custom option tooltips, virtual-scroll support, full-width placement/follow-cursor, narrow-control side placement, independent--ms-option-tooltip-*styling, and badge tooltips. The events page demonstrates the DOM events, theon*property twin, and thebeforeSelect/beforeDeselectveto interceptors with live veto demos. It also carries a wrapper-specific Server-side binding (LiveView) section — threehook="KeenWebMultiselectHook"pickers whoseselect/deselect/changeevents drivehandle_event/3: live server-derived state (price + total computed from a catalog the browser never sees), a server-stamped event log, and a server-authoritative "max 3" rule enforced by pushingweb_multiselect:updateback to correct the element — the practical answer to "you can't veto over the wire" (allow optimistically, then correct). - Bundled upstream rc04 baseline (carried forward) — Upstream changes summary:
select-placeholderandno-data-placeholderattributes for non-searchable / empty-list states,searchCallbacknow receives anAbortSignal(in-flight requests are cancelled when superseded),search-debounceattribute collapses keystroke bursts into a single request, batchedsetAttributes(attrs)method, and the search-disabled default placeholder changed from"Search..."to"Pick an option...". All additive at the wrapper boundary — existing consumers see no surface change. - Three new typed
attr/3declarations inKeenmate.WebMultiselect.Components.web_multiselect/1covering the rc04 additions:select_placeholder(string),no_data_placeholder(string),search_debounce(integer). Snake→kebab mapping unchanged; samenil-default-means-omit semantics as every other attr. search_eventattr — declarative server-sidesearchCallbackover the LV channel —<.web_multiselect search_event="github_search" hook="KeenWebMultiselectHook" />rendersdata-search-event="github_search"on the element. Onmounted/0the hook reads that attribute and installsel.searchCallback = (query, signal) => pushEventTo(el, "github_search", %{id, query}, replyCallback), resolving the promise withreply.resultsfrom the server's{:reply, %{results: [...]}, socket}. Consumers write zero JS — the entire async-search pattern reduces to ahandle_event/3clause. Honors the rc04AbortSignal: late replies for superseded queries are dropped before reaching the dropdown.web_multiselect:updateserver→client event in the hook —push_event(socket, "web_multiselect:update", %{id: "cascade-unit", options: new_options, value: []})mutates element state from the LV process. Filters bypayload.idso the broadcast can target a specific multiselect on a page that has several. Necessary because the wrapper's auto-emittedphx-update="ignore"(which protects the component's internally-managed children from morphdom) also blocks LV from morphing attribute changes likedata-options— the hook channel is the canonical way to mutate options/selection from the server. Implementation:payload.options→el.options = ...;payload.value→el.setSelected([...])(orel.value =fallback), wrapping single values into arrays so the same call shape covers single- and multi-select.- Five more typed
attr/3declarations —checkbox_align(enumtop|center|bottom),dropdown_max_width(string),remove_button_tooltip_text(string; supports{0}interpolation),badge_height(integer; popover virtual-scroll), andshow_debug_info(boolean; flips upstream's.ms__debug-infostats panel reactively without component reinit). All were already in upstream's attribute table or out-of-table special-case map; previously reachable only via:restglobal passthrough, now first-class with the samenil-default-means-omit semantics as the rest. Surfaced by an audit against upstream'sFEATURES.md. OptionHelperscanonical member-attr defaults are now unconditional and cover all six members. Previously: the wrapper emittedvalue-member="value"+display-value-member="label"only when:optionswas set in HEEx. That left async-search demos (searchCallback-driven, no:options=at mount) and JS-sideel.options = [...]assignments without any member configuration, so upstream'svalueMemberstayedundefinedand every row fell through to[N/A]. Now: the wrapper emits all six canonical attrs (value-member,display-value-member,icon-member,subtitle-member,group-member,disabled-member) on every render. Safe across all option-loading paths — upstream's declarative<option>parser produces objects with the same key names, so the explicit defaults are no-ops there. Explicit*_memberoverrides still win (verified by unit tests).search-value-memberis intentionally not defaulted — defaulting it would change which text the search matches against, with no obvious canonical key name to point at.FEATURES.mdat the repo root — full feature inventory cross-referenced with the wrapper's surface and e2e coverage. Mirrors upstream's section structure plus the new §15 Developer tooling — debug & logging coveringshow-debug-info, the bundledenableLogging/disableLogging/setLogLevel/setCategoryLevelexports,LOGGING_CATEGORIES, and thewindow.components['web-multiselect']global. Adds a "Wrapper-only features" section for surface that doesn't exist upstream (hookattr,phx-update="ignore"auto-emission,data-ready=""pre-emission,.formpolyfill,search_event,web_multiselect:update,FormFieldintegration, tuple option normalization, canonical-member-default emission).
Changed
- Elixir module namespace renamed
KeenWebMultiselect→Keenmate.WebMultiselect. All four modules move under theKeenmate.umbrella (Keenmate.WebMultiselect,.Components,.OptionHelpers,.FormHelpers) and their source/test files move to the conventionallib/keenmate/web_multiselect/path. Unchanged: the OTP app / hex package name (:keen_web_multiselect), thepriv/staticasset filenames, and the JS hook identifier (KeenWebMultiselectHook) plus its"web_multiselect:*"event names — so asset wiring, importmaps, andapp.jsneed no changes. Consumers only update theirimport Keenmate.WebMultiselect.Componentsline (and any direct module references). Pre-1.0, no deprecation shim. - rc05 upstream callback renames propagated through the demo site (breaking upstream, no aliases). Two JS-side rename waves landed in rc05: the fire-and-forget notifications
selectCallback/deselectCallback/changeCallbackbecame theon*eventsonSelect/onDeselect/onChange, and theActionButtonpredicatesisVisibleCallback/isDisabledCallbackbecamegetIsVisibleCallback/getIsDisabledCallback. These are JS-side config, not HTML attributes, soKeenmate.WebMultiselect.Components(typedattr/3) and the LV hook (which uses the unchanged DOM event names + the unchangedsearchCallback) are unaffected. The only wrapper-repo impact was the demotest_appexample pages whose inline scripts setactionButtons:action_buttons_live.exandnew_api_live.exwere updated to thegetIs*Callbackshape (the predicate callbacks would otherwise silently stop firing under rc05), along with their prose/code snippets and the callback-priority list.
Fixed
badges_display_modeenum had the wrong values. Declared as["pills", "count", "compact", "partial", "none"]. Upstream accepts["badges", "count", "compact", "partial", "none"](default"badges") and silently falls back to default when given"pills"— sobadges_display_mode="pills"looked like it worked while doing nothing, andbadges_display_mode="badges"(the only correct value) was rejected by the wrapper at compile time. Fix is one line incomponents.ex; downstream callsites intest_app/lib/test_app_web/live/fixtures/attributes_live.ex,e2e/attributes.spec.ts, and the badge-mode demo card intest_app/lib/test_app_web/live/examples/classic_live.ex(prose saidpills (default)) all updated to match.badge_tooltip_placementenum was incomplete. Was the four cardinal sides; upstream accepts 12 Floating-UI placements — the four sides plus-start/-endvariants for each. Now declares the full set.
Internal — e2e expansion
The wrapper's e2e suite went from 12 specs to 41 across 10 files, focused on what only the wrapper can break plus the headline upstream behaviors the audit flagged as unverified.
/test/search-eventfixture +e2e/search_event.spec.ts(4 tests) — declarative server-side search viasearch_event="...". Fixture has a toy fruit catalog and aslow_nexttoggle. Spec provesdata-search-eventreaches the element, typed queries arrive at the LV'shandle_event/3with the right payload shape, fresh queries replace previous results, and (the wrapper-only one) the rc04 AbortSignal contract is honored: a slow stale reply doesn't overwrite a newer fast result./test/push-updatefixture +e2e/push_update.spec.ts(3 tests) —web_multiselect:updatechannel. Fixture has parent/child/sibling triple plus a manual preselect button. Spec proves the parent-on-change-cascade pushes new options to child, sibling is untouched by id-filtered updates (snapshot-compared, not just length), and a value-onlypush_eventpreserves the child's option list while changing its selection./test/declarativefixture +e2e/declarative.spec.ts(5 tests) — bare<option>children via:inner_block,<optgroup>,selected,disabledround-trip. Asserts the wrapper does not emitdata-optionsfor the declarative path. After the unconditional-defaults change, also asserts all six canonical member attrs ARE emitted (the previous "must be absent" assertion was the proximate cause of the silently-failing async-search demos).e2e/selection.spec.tsextended with two regression guards:phx-update="ignore"is auto-emitted on every element that has an:id, anddata-ready=""is pre-emitted on first render so the placeholder doesn't flash on WS connect.e2e/form.spec.tsextended — a prefilledFormField(value["banana", "cherry"]) renders the badges post-mount, not just shipping the value in form params. Catchesinitial-valuesshape regressions that would let upstream silently drop the pre-selection.- Unit tests for the new
OptionHelpersmember-default behavior: the six canonical attrs emit regardless of:options, explicit overrides win against all six, andsearch-value-memberis never defaulted. /test/virtual-scrollfixture +e2e/virtual_scroll.spec.ts(3 tests) — proves virtual scrolling actually windows the DOM, not just that the attrs render kebab-cased. A 500-option picker renders.ms__options--virtualwith fewer than 100 materialized.ms__optionnodes whileel.picker.allOptions.length === 500; scrolling to the bottom recycles early rows out and late rows in; a non-virtual control with identical data renders all 500./test/badges-popoverfixture +e2e/badges_popover.spec.ts(3 tests) —partialmode withbadges_max_visible=2and four preselected values renders exactly two badges plus a+2 moreoverflow badge; clicking it opens the selected-items popover (.ms__selected-popover--visible) listing all four; the close button dismisses it./test/add-newfixture +e2e/add_new.spec.ts(3 tests) —allow_add_new+ a JSaddNewCallback: typing an unknown term and pressing Enter creates the option, selects it, renders the badge with the original-cased label, clears the input, and the new option joinsel.picker.allOptionsfor re-selection.e2e/declarative.spec.tsextended — per-optiondata-icon/data-subtitleflow through the:inner_blocklight-DOM children and render as.ms__option-icon/.ms__option-subtitle.e2e/attributes.spec.tsextended — the four audit-added typed attrs (checkbox_align,dropdown_max_width,remove_button_tooltip_text,badge_height) render kebab-cased, andshow_debug_info={true}both lands asshow-debug-info="true"and renders the upstream.ms__debug-infopanel. Regression guard so a future enum/typo incomponents.exfails CI.e2e/events.spec.tsextended — a single click firesselectandchangeexactly once each (no double-fire) with zerodeselect, andselectfires beforechange.- Two pre-existing spec bugs caught and fixed while landing the above.
push_update.spec.tsreadel.allOptions—undefinedon the custom element (the option array lives on the internal picker), so(el.allOptions || [])silently returned[], the first assertion passed trivially, and the real assertions timed out; switched toel.picker.allOptions.form.spec.tsmatchedpage.locator('web-multiselect'), which became a Playwright strict-mode violation once the#prefilledform was added to that page; scoped to#basket_fruits.
Internal — demo site (test_app/)
test_app/is now a deployable demo gallery, not just an e2e harness. Two router pipelines split the surface::demos_browser(gallery at/, ten example LiveViews under/examples/<name>) uses a newdemo_rootlayout that loadsexamples-shared.css(ported verbatim from upstream'sexamples-shared.css);:fixtures_browser(existing/test/*Playwright fixtures) keeps the minimalrootlayout and narrowapp.css. The two stylesheets don't conflict because they're never loaded together.IndexLiverenders the card grid withwrapper v#{Application.spec(:keen_web_multiselect, :vsn)}+upstream v#{Keenmate.WebMultiselect.upstream_version()}badges so deployments show both versions inline.- Ten demo LiveViews under
test_app/lib/test_app_web/live/examples/—classic,new_api,performance,templating,action_buttons,sizes,base_variables,theming,logging,positioning. Pages with elaborate callbacks (templating render callbacks, performance metrics, action-button visibility/text/class callbacks, positioning drift detection, logging API surface) wire JS via inline<script type="module">blocks that import from the importmap — same approach upstream'sexamples-*.htmlpages take.new_apiis the showcase for the wrapper's two LV-specific patterns: a three-tier cascading select driven bypush_eventfor option mutation, and side-by-side async-search cards (browser → GitHub vs browser → LiveView → GitHub) demonstrating when to route through the server (auth tokens, response transformation, CORS-restricted backends). - Shared HEEx primitives in
TestAppWeb.Examples.SharedComponents—<.example_page>,<.card>,<.note>,<.code_block>,<.output_panel>,<.form_group>,<.grid>. Match upstream'sexamples-shared.cssclass names so the same prose shapes work across both repos. - Port allocation
xxx0main /xxx1debug — endpoint moved from12_300to4_060;live_debuggerpinned to4_061viaconfig :live_debugger, port: 4061intest_app/config/dev.exs. Makefiledevtarget,playwright.config.ts(baseURL+webServer.url), and the README/CHANGELOG narrative all updated to match. - Dev-mode code reloader enabled —
config :test_app, TestAppWeb.Endpoint, code_reloader: true, reloadable_apps: [:test_app, :keen_web_multiselect]intest_app/config/dev.exs, plugPhoenix.CodeReloaderin the endpoint,listeners: [Phoenix.CodeReloader]intest_app/mix.exs(silences the Mix-listener warning introduced in Phoenix 1.8.8).code_reloader: falsestays the implied default for the:testenv so Playwright runs stay deterministic. :inets, :ssladded toextra_applicationsso the LV-tunneled GitHub search demo can call:httpc.request/4for the outbound HTTPS. Used byTestAppWeb.Examples.NewApiLive.github_search_users/1— the demo's own helper, not part of the wrapper.- Demo bug fixes from the audit pass — these were all
test_app/lib/test_app_web/live/examples/*issues that surfaced once the demo gallery went live and someone clicked through every page:- Templating — Single-select with compact closed display.
renderSelectedContentCallbackfor single-select returns a plain string that goes straight into<input>.valueupstream; the demos were returning HTML<span>...</span>strings and rendering them literally as<span>Jane</span>. Fixed by returning plain strings; card prose now states the contract explicitly. - Templating — Badge rendering. No tooltips were showing because the demo never set
enable_badge_tooltips. Now opt-in viaenable_badge_tooltips={true}plusgetBadgeTooltipCallbackfor richer content than the defaultdisplayValue. The "options also need tooltips" gap is now called out in the card prose (upstream has no per-option tooltip API — would needtitle=insiderenderOptionContentCallback). - Templating + Performance — Rich Content priority badges.
customStylesCallbackwas targeting.ms__badgebut upstream's default badge background lives on the inner.ms__badge-text+.ms__badge-removeelements via CSS variables, so the per-priority colors were silently covered. Selectors now target both inner parts (and setborder-colorto match). - Action Buttons — Built-in actions. Passing bare strings (
['select-all', 'clear-all']) made the buttons render"undefined"labels because upstream has no name lookup for the action — every entry needs an explicittext. Same fix applied to the Static properties and Dynamic visibility / disabled cards. - Action Buttons — Dynamic callbacks. Three cards (cards 3, 4, 5) were reading
ctx.options.length(the picker's config object, not its options array) andctx.selectedValues.length(the selectedValues is aSet, no.length). Card 3's Select All button was disabled when empty instead of when full becauseundefined === undefinedistrue. Fixed to usectx.allOptions(the actual array) andctx.selectedValues.size/.has(String(v)). - Action Buttons — Layout nowrap vs wrap. Uniform
Action 1..Action 8labels collapsed to a tidy single row even in wrap mode, hiding the layout difference. Replaced with mixed-width labels (Pick 3 random/Even-indexed/Odd/First half/Last/A–M only/Invert selection/ built-inClear) that actually drivesetSelectedso the picker visibly responds to each. - Performance — Virtual Scrolling stats panel. The stats divs (
Init / First render / Last searchms) flashed values then reset to—because they're inside the LV template and got morphed back on every WS patch. Wrapped the stats container inphx-update="ignore"so the inline<script>updates survive the connected re-render. Also added explicitvalue_member="value"+display_value_member="label"toperf-15kso the JS-loaded 15k options didn't render as[N/A]— this was before the unconditional-defaults change above and is now redundant but harmless. - Classic — Rich Content. The
:icon/:subtitle/:groupkeys on data were silently dropped because the wrapper only auto-defaultedvalue-memberanddisplay-value-member. Was the symptom that motivated the unconditional-defaults change. Card prose now lists all six defaulted members. - New API — async-search cards. Removed the three explicit
value_member/display_value_member/subtitle_memberattrs from bothsearch-jsandsearch-lv— now redundant after the unconditional-defaults change. Cards read as cleaner showcases of "just setoptions=and it works".
- Templating — Single-select with compact closed display.
- Visual-parity pass — completing the rc05 demo mirror. After the two new example pages landed, a page-by-page diff of every
/examples/*LiveView against its upstreamexamples-*.html(scoped to the five pages upstream touched since rc04) closed the remaining gaps:- Action Buttons — §13 "Positioning, Rows & Alignment" ported. Upstream's largest rc05 demo addition (four pickers in a
grid-2) had never been mirrored — the page jumped from §12 straight to the Summary. Added it:pos-top-rows/pos-bottom-rowsshow multi-row action layout (per-buttonrow: 1|2) withactions_position="top"|"bottom", andalign-right/align-betweenshowactions_align. Uses the rc05 typed attrs (actions_position/actions_align) declaratively; JS wires therowButtons(withFirst 3/Invertcustom actions) plus a code sample and the "Row ordering" note. Verified headless: 2 rows / 4 buttons and the expectedms__actions--top/--bottom/--align-right/--align-space-betweenclasses, no console errors. - Action Buttons — §11 Font Awesome now actually renders. The demo (and its own note) says font icons in a Shadow DOM component need the
@font-faceregistered at the document level — but the shareddemo_rootlayout never shipped that<link>, so the glyphs rendered as empty[]boxes despite the shadow-injectedcustomStylesCallback@import. Added a conditional Font Awesome<link>to the layout<head>, gated on anassigns[:font_awesome]flag (same per-page-assign mechanism the layout uses forpage_title) thataction_buttons_live.exsets inmount. Only that page loads the CDN stylesheet; verified the FA::beforenow resolves tofont-family: "Font Awesome 6 Free"with a real glyph. - Performance — Select All button. Added
show_select_all={true}to the 15k-option filter demo to match upstream (see the new typed attr above). The upstream page's secondshow-select-allis inside a<!-- Temporarily hidden -->block, so this is correctly a single-picker change. - Classic — cross-link to the events page. Mirrored the small rc05
<small>note at the foot of the Event Handling card pointing at theon*handlers /beforeSelect/beforeDeselectinterceptors, using<.link navigate="/examples/events-callbacks">(the LiveView equivalent of upstream's<a href>). - Hub blurb.
IndexLive's Action Buttons card description now mentions position/rows/alignment. - No drift on the other pages. The diff confirmed the remaining rc04→rc05 example changes were already handled:
new_api_live.ex'sisVisibleCallback/isDisabledCallback→getIs*Callbackrenames (done in the rc05 pass, 0 old names remain), and the two wholly-new pages. The randomized-vs-deterministic option data on the performance page (upstream usesMath.random(), so it can never byte-match) and the wrapper-only cascade "extras" section on new-api were left as intentional, non-mirror differences.
- Action Buttons — §13 "Positioning, Rows & Alignment" ported. Upstream's largest rc05 demo addition (four pickers in a
[0.1.0] - 2026-06-23
Initial release. Wraps @keenmate/web-multiselect v1.12.0-rc03 as a Phoenix LiveView
function component.
Added
Keenmate.WebMultiselect.Components.web_multiselect/1—<.web_multiselect>function component with typedattr/3declarations for every documented upstream attribute (booleans, enums, integers, JSON-encoded option lists). Snake_case in HEEx maps to kebab-case on the rendered element.Keenmate.WebMultiselect.OptionHelpers— option-list normalization, JSON encoding ofoptions/initial-values, explicit"true"/"false"rendering for boolean attributes (the upstream component reads explicit strings, not bare HTML attribute presence). Phoenix.Component bookkeeping atoms (:__given__,:__changed__) are filtered before key encoding so they never reach the rendered element as--given--="..."attributes. Option lists are emitted asdata-options(upstream readsdata-options; theoptionsproperty is reserved for JS-side assignment), withvalue-member="value"/display-value-member="label"defaulted in the same step so[%{value: ..., label: ...}]works out-of-the-box — upstream's declarative member defaults only apply when parsing<option>children, not when consumingdata-optionsJSON.Keenmate.WebMultiselect.FormHelpers—Phoenix.HTML.FormFieldintegration;<.web_multiselect field={@form[:tags]} />fills inid,name, and feeds the field value into the upstreaminitial-valuesattribute. Explicit:id/:nameassigns win over the field defaults.- LiveView morph compatibility — the wrapper renders
phx-update="ignore"(whenever:idis set) so morphdom leaves the upstream component's internally-managed children alone on subsequent renders, plus pre-emitsdata-ready=""on the element itself so LV'smergeAttrs(which stripsdata-*attributes onignore-marked elements that aren't in the server-rendered HTML) doesn't tear down the placeholder visibility flag upstream sets viarequestAnimationFrameduringconnectedCallback. Without this, the placeholder text "Search..." flashes for one frame after upgrade then disappears on every LV render, because the CSS rule:host([data-ready]) .ms__input::placeholder { opacity: ... }(controls.css:52) only matches whiledata-readyis present. - Bundled upstream JS + CSS in
priv/static/multiselect.jsandpriv/static/multiselect.css. Nonpm installrequired by consumers. - Optional LiveView hook in
priv/static/keen_web_multiselect_hook.js. Sethook="KeenWebMultiselectHook"on the component and the hook forwardsselect/deselect/changeCustomEvents to the server as"web_multiselect:select"/":deselect"/":change"events with{id, value, values}payloads. The hook module also polyfills a.formgetter onto<web-multiselect>(returningthis.internals?.form); upstream callsattachInternals()for form-association but never exposes.form, and Phoenix LV'sphx-changedelegation drops CustomEvents whosee.target.form === undefined. The polyfill is installed once at module load viacustomElements.whenDefined("web-multiselect")so it covers consumers who never opt into the hook itself. Keenmate.WebMultiselect.upstream_version/0— runtime accessor that returns the bundled@keenmate/web-multiselectversion (currently"1.12.0-rc03").Keenmate.WebMultiselect.asset_path/1— absolute on-disk path to a file inpriv/static/, for setup tasks that copy assets into a host application'sassets/folder.
Internal
Makefilemirroring the upstream@keenmate/web-multiselectMakefile verb pattern:make publish-rc/make publish/make publish-dry/make build/make test/make clean/make help. The rc/release split is enforced by checking the@versionshape inmix.exs—make publish-rcrefuses unless the version matchesX.Y.Z-rc.N;make publishrefuses if the version is an rc. Plusmake current-versionandmake last-publishedfor state inspection..claude/commands/publish.md—/publishslash command following the canonical Bliss Frameworkweb-components/publish-command.mdstructure, adapted for Hex (mix hex.info/mix hex.build/mix hex.publishinstead of the npm equivalents). Sections marked[canonical]are byte-identical (modulo npm→Hex substitutions) across all KeenMate component repos.- ExUnit test suite covering
OptionHelpersattribute encoding,FormHelpersfield binding, andComponents.web_multiselect/1rendering (23 tests). - Playwright e2e harness —
test_app/is a minimal Phoenix 1.8 + LiveView 1.2 host (Bandit on127.0.0.1:4060, path dep on the parent, shareddeps/andmix.lock). Four fixture LiveViews undertest_app/lib/test_app_web/live/fixtures/mirror upstream's test pages but assert what only the wrapper can break:field={@form[:fruits]}round-tripping throughphx-change, the LV hook forwardingselect/deselect/changetohandle_event/3with{id, value, values}, snake_case→kebab-case attribute mapping reaching the rendered element. Asset delivery uses an<script type="importmap">block in the root layout pointing atphoenix.mjs,phoenix_live_view.esm.js, and the wrapper's bundledmultiselect.jsserved via threePlug.Staticblocks — no esbuild step intest_app/. Specs ine2e/*.spec.ts, driven byplaywright.config.tsat the repo root (single chromium project,webServer = mix phx.serverwithcwd: test_app). Makefile targets:make test-e2e-install/make test-e2e/make test-e2e-ui/make test-e2e-headed. mix.exspackage metadata: MIT license, Hex docs config viaex_doc, source URL, package:fileswhitelist scoped tolib/,priv/,mix.exs,README.md,CHANGELOG.md,LICENSE,.formatter.exs.