[1.0.0-rc.2] - 2026-07-02

Documentation completeness pass over the component's attribute table.

Changed

  • Every web_multiselect/1 attribute now carries a doc: 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. multiple defaults to true), plus a short "Attribute defaults" preamble in the web_multiselect/1 @doc explaining the nil-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 to 1.0, which resolves to nothing now that only 1.0.0-rc.* is published — a copy-paste mix deps.get would fail. Corrected to ~> 1.0, with a note on requiring ~> 1.0.0-rc to opt into the current release candidate (a plain ~> 1.0 skips 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 *Callbackon* 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: imports multiselect.js + the hook into assets/js/app.js, registers KeenWebMultiselectHook on the LiveSocket (adds a hooks: key to a default socket, or merges into an existing object-literal one), and imports multiselect.css into assets/css/app.css. Dependency-free (a plain Mix.Task, no Igniter — the friction here is JS/CSS text, not Elixir AST). Conservative by design: a non-object-literal hooks:, a missing app.js (importmap apps), or an unrecognized LiveSocket shape 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 raw push_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: opts leaves 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 :hook attr now accepts a boolean as well as a string: hook={true} resolves to the bundled "KeenWebMultiselectHook", a string still names a custom hook, and false/nil render 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-multiselect v1.12.0-rc05priv/static/multiselect.js / multiselect.css / multiselect.d.ts resync'd from the upstream dist. Keenmate.WebMultiselect.upstream_version/0 now 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-position top/bottom, actions-align, multi-row ActionButton.row); smart built-in action defaults (select-all/clear-all auto-disable when they'd be no-ops, unless the consumer set an explicit isDisabled/getIsDisabledCallback); beforeSelectCallback/beforeDeselectCallback veto interceptors; a new --ms-state-min-height themable 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, so KeenWebMultiselectHook needs no changes.
  • Five new typed attr/3 declarations in Keenmate.WebMultiselect.Components.web_multiselect/1 for the rc05 option-tooltip surface: enable_option_tooltips (boolean), option_tooltip_placement (enum, 12 Floating-UI placements, default top-start), option_tooltip_follow_cursor (boolean), option_tooltip_delay (integer), option_tooltip_offset (integer); plus actions_position (enum top/bottom) and actions_align (enum stretch/left/right/center/space-between) for the action-button layout. Snake→kebab mapping unchanged; same nil-default-means-omit semantics as every other attr.
  • show_select_all typed attr/3 in Keenmate.WebMultiselect.Components.web_multiselect/1 — exposes upstream's built-in Select All action button (show-select-all → internal isSelectAllShown). Boolean, same nil-default-means-omit semantics as the rest; pairs naturally with show_checkboxes. Previously reachable via neither the typed surface nor the :rest global — show_select_all isn't a valid HTML global attribute, so Phoenix dropped it silently (it landed in neither the top-level assigns nor @rest, so OptionHelpers never 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 new examples-tooltips.html and examples-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, the on* property twin, and the beforeSelect/beforeDeselect veto interceptors with live veto demos. It also carries a wrapper-specific Server-side binding (LiveView) section — three hook="KeenWebMultiselectHook" pickers whose select/deselect/change events drive handle_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 pushing web_multiselect:update back 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-placeholder and no-data-placeholder attributes for non-searchable / empty-list states, searchCallback now receives an AbortSignal (in-flight requests are cancelled when superseded), search-debounce attribute collapses keystroke bursts into a single request, batched setAttributes(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/3 declarations in Keenmate.WebMultiselect.Components.web_multiselect/1 covering the rc04 additions: select_placeholder (string), no_data_placeholder (string), search_debounce (integer). Snake→kebab mapping unchanged; same nil-default-means-omit semantics as every other attr.
  • search_event attr — declarative server-side searchCallback over the LV channel<.web_multiselect search_event="github_search" hook="KeenWebMultiselectHook" /> renders data-search-event="github_search" on the element. On mounted/0 the hook reads that attribute and installs el.searchCallback = (query, signal) => pushEventTo(el, "github_search", %{id, query}, replyCallback), resolving the promise with reply.results from the server's {:reply, %{results: [...]}, socket}. Consumers write zero JS — the entire async-search pattern reduces to a handle_event/3 clause. Honors the rc04 AbortSignal: late replies for superseded queries are dropped before reaching the dropdown.
  • web_multiselect:update server→client event in the hookpush_event(socket, "web_multiselect:update", %{id: "cascade-unit", options: new_options, value: []}) mutates element state from the LV process. Filters by payload.id so the broadcast can target a specific multiselect on a page that has several. Necessary because the wrapper's auto-emitted phx-update="ignore" (which protects the component's internally-managed children from morphdom) also blocks LV from morphing attribute changes like data-options — the hook channel is the canonical way to mutate options/selection from the server. Implementation: payload.optionsel.options = ...; payload.valueel.setSelected([...]) (or el.value = fallback), wrapping single values into arrays so the same call shape covers single- and multi-select.
  • Five more typed attr/3 declarationscheckbox_align (enum top|center|bottom), dropdown_max_width (string), remove_button_tooltip_text (string; supports {0} interpolation), badge_height (integer; popover virtual-scroll), and show_debug_info (boolean; flips upstream's .ms__debug-info stats panel reactively without component reinit). All were already in upstream's attribute table or out-of-table special-case map; previously reachable only via :rest global passthrough, now first-class with the same nil-default-means-omit semantics as the rest. Surfaced by an audit against upstream's FEATURES.md.
  • OptionHelpers canonical member-attr defaults are now unconditional and cover all six members. Previously: the wrapper emitted value-member="value" + display-value-member="label" only when :options was set in HEEx. That left async-search demos (searchCallback-driven, no :options= at mount) and JS-side el.options = [...] assignments without any member configuration, so upstream's valueMember stayed undefined and 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 *_member overrides still win (verified by unit tests). search-value-member is intentionally not defaulted — defaulting it would change which text the search matches against, with no obvious canonical key name to point at.
  • FEATURES.md at 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 covering show-debug-info, the bundled enableLogging / disableLogging / setLogLevel / setCategoryLevel exports, LOGGING_CATEGORIES, and the window.components['web-multiselect'] global. Adds a "Wrapper-only features" section for surface that doesn't exist upstream (hook attr, phx-update="ignore" auto-emission, data-ready="" pre-emission, .form polyfill, search_event, web_multiselect:update, FormField integration, tuple option normalization, canonical-member-default emission).

Changed

  • Elixir module namespace renamed KeenWebMultiselectKeenmate.WebMultiselect. All four modules move under the Keenmate. umbrella (Keenmate.WebMultiselect, .Components, .OptionHelpers, .FormHelpers) and their source/test files move to the conventional lib/keenmate/web_multiselect/ path. Unchanged: the OTP app / hex package name (:keen_web_multiselect), the priv/static asset filenames, and the JS hook identifier (KeenWebMultiselectHook) plus its "web_multiselect:*" event names — so asset wiring, importmaps, and app.js need no changes. Consumers only update their import Keenmate.WebMultiselect.Components line (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/changeCallback became the on* events onSelect/onDeselect/onChange, and the ActionButton predicates isVisibleCallback/isDisabledCallback became getIsVisibleCallback/getIsDisabledCallback. These are JS-side config, not HTML attributes, so Keenmate.WebMultiselect.Components (typed attr/3) and the LV hook (which uses the unchanged DOM event names + the unchanged searchCallback) are unaffected. The only wrapper-repo impact was the demo test_app example pages whose inline scripts set actionButtons: action_buttons_live.ex and new_api_live.ex were updated to the getIs*Callback shape (the predicate callbacks would otherwise silently stop firing under rc05), along with their prose/code snippets and the callback-priority list.

Fixed

  • badges_display_mode enum 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" — so badges_display_mode="pills" looked like it worked while doing nothing, and badges_display_mode="badges" (the only correct value) was rejected by the wrapper at compile time. Fix is one line in components.ex; downstream callsites in test_app/lib/test_app_web/live/fixtures/attributes_live.ex, e2e/attributes.spec.ts, and the badge-mode demo card in test_app/lib/test_app_web/live/examples/classic_live.ex (prose said pills (default)) all updated to match.
  • badge_tooltip_placement enum was incomplete. Was the four cardinal sides; upstream accepts 12 Floating-UI placements — the four sides plus -start / -end variants 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-event fixture + e2e/search_event.spec.ts (4 tests) — declarative server-side search via search_event="...". Fixture has a toy fruit catalog and a slow_next toggle. Spec proves data-search-event reaches the element, typed queries arrive at the LV's handle_event/3 with 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-update fixture + e2e/push_update.spec.ts (3 tests) — web_multiselect:update channel. 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-only push_event preserves the child's option list while changing its selection.
  • /test/declarative fixture + e2e/declarative.spec.ts (5 tests) — bare <option> children via :inner_block, <optgroup>, selected, disabled round-trip. Asserts the wrapper does not emit data-options for 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.ts extended with two regression guards: phx-update="ignore" is auto-emitted on every element that has an :id, and data-ready="" is pre-emitted on first render so the placeholder doesn't flash on WS connect.
  • e2e/form.spec.ts extended — a prefilled FormField (value ["banana", "cherry"]) renders the badges post-mount, not just shipping the value in form params. Catches initial-values shape regressions that would let upstream silently drop the pre-selection.
  • Unit tests for the new OptionHelpers member-default behavior: the six canonical attrs emit regardless of :options, explicit overrides win against all six, and search-value-member is never defaulted.
  • /test/virtual-scroll fixture + 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--virtual with fewer than 100 materialized .ms__option nodes while el.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-popover fixture + e2e/badges_popover.spec.ts (3 tests) — partial mode with badges_max_visible=2 and four preselected values renders exactly two badges plus a +2 more overflow badge; clicking it opens the selected-items popover (.ms__selected-popover--visible) listing all four; the close button dismisses it.
  • /test/add-new fixture + e2e/add_new.spec.ts (3 tests) — allow_add_new + a JS addNewCallback: 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 joins el.picker.allOptions for re-selection.
  • e2e/declarative.spec.ts extended — per-option data-icon / data-subtitle flow through the :inner_block light-DOM children and render as .ms__option-icon / .ms__option-subtitle.
  • e2e/attributes.spec.ts extended — the four audit-added typed attrs (checkbox_align, dropdown_max_width, remove_button_tooltip_text, badge_height) render kebab-cased, and show_debug_info={true} both lands as show-debug-info="true" and renders the upstream .ms__debug-info panel. Regression guard so a future enum/typo in components.ex fails CI.
  • e2e/events.spec.ts extended — a single click fires select and change exactly once each (no double-fire) with zero deselect, and select fires before change.
  • Two pre-existing spec bugs caught and fixed while landing the above. push_update.spec.ts read el.allOptionsundefined on 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 to el.picker.allOptions. form.spec.ts matched page.locator('web-multiselect'), which became a Playwright strict-mode violation once the #prefilled form 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 new demo_root layout that loads examples-shared.css (ported verbatim from upstream's examples-shared.css); :fixtures_browser (existing /test/* Playwright fixtures) keeps the minimal root layout and narrow app.css. The two stylesheets don't conflict because they're never loaded together. IndexLive renders the card grid with wrapper 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's examples-*.html pages take. new_api is the showcase for the wrapper's two LV-specific patterns: a three-tier cascading select driven by push_event for 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's examples-shared.css class names so the same prose shapes work across both repos.
  • Port allocation xxx0 main / xxx1 debug — endpoint moved from 12_300 to 4_060; live_debugger pinned to 4_061 via config :live_debugger, port: 4061 in test_app/config/dev.exs. Makefile dev target, playwright.config.ts (baseURL + webServer.url), and the README/CHANGELOG narrative all updated to match.
  • Dev-mode code reloader enabledconfig :test_app, TestAppWeb.Endpoint, code_reloader: true, reloadable_apps: [:test_app, :keen_web_multiselect] in test_app/config/dev.exs, plug Phoenix.CodeReloader in the endpoint, listeners: [Phoenix.CodeReloader] in test_app/mix.exs (silences the Mix-listener warning introduced in Phoenix 1.8.8). code_reloader: false stays the implied default for the :test env so Playwright runs stay deterministic.
  • :inets, :ssl added to extra_applications so the LV-tunneled GitHub search demo can call :httpc.request/4 for the outbound HTTPS. Used by TestAppWeb.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. renderSelectedContentCallback for single-select returns a plain string that goes straight into <input>.value upstream; the demos were returning HTML <span>...</span> strings and rendering them literally as &lt;span&gt;Jane&lt;/span&gt;. 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 via enable_badge_tooltips={true} plus getBadgeTooltipCallback for richer content than the default displayValue. The "options also need tooltips" gap is now called out in the card prose (upstream has no per-option tooltip API — would need title= inside renderOptionContentCallback).
    • Templating + Performance — Rich Content priority badges. customStylesCallback was targeting .ms__badge but upstream's default badge background lives on the inner .ms__badge-text + .ms__badge-remove elements via CSS variables, so the per-priority colors were silently covered. Selectors now target both inner parts (and set border-color to 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 explicit text. 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) and ctx.selectedValues.length (the selectedValues is a Set, no .length). Card 3's Select All button was disabled when empty instead of when full because undefined === undefined is true. Fixed to use ctx.allOptions (the actual array) and ctx.selectedValues.size / .has(String(v)).
    • Action Buttons — Layout nowrap vs wrap. Uniform Action 1..Action 8 labels 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-in Clear) that actually drive setSelected so the picker visibly responds to each.
    • Performance — Virtual Scrolling stats panel. The stats divs (Init / First render / Last search ms) flashed values then reset to because they're inside the LV template and got morphed back on every WS patch. Wrapped the stats container in phx-update="ignore" so the inline <script> updates survive the connected re-render. Also added explicit value_member="value" + display_value_member="label" to perf-15k so 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 / :group keys on data were silently dropped because the wrapper only auto-defaulted value-member and display-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_member attrs from both search-js and search-lv — now redundant after the unconditional-defaults change. Cards read as cleaner showcases of "just set options= and it works".
  • 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 upstream examples-*.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-rows show multi-row action layout (per-button row: 1|2) with actions_position="top"|"bottom", and align-right / align-between show actions_align. Uses the rc05 typed attrs (actions_position / actions_align) declaratively; JS wires the rowButtons (with First 3 / Invert custom actions) plus a code sample and the "Row ordering" note. Verified headless: 2 rows / 4 buttons and the expected ms__actions--top / --bottom / --align-right / --align-space-between classes, 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-face registered at the document level — but the shared demo_root layout never shipped that <link>, so the glyphs rendered as empty [] boxes despite the shadow-injected customStylesCallback @import. Added a conditional Font Awesome <link> to the layout <head>, gated on an assigns[:font_awesome] flag (same per-page-assign mechanism the layout uses for page_title) that action_buttons_live.ex sets in mount. Only that page loads the CDN stylesheet; verified the FA ::before now resolves to font-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 second show-select-all is 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 the on* handlers / beforeSelect/beforeDeselect interceptors, 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's isVisibleCallback/isDisabledCallbackgetIs*Callback renames (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 uses Math.random(), so it can never byte-match) and the wrapper-only cascade "extras" section on new-api were left as intentional, non-mirror differences.

[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 typed attr/3 declarations 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 of options / 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 as data-options (upstream reads data-options; the options property is reserved for JS-side assignment), with value-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 consuming data-options JSON.
  • Keenmate.WebMultiselect.FormHelpersPhoenix.HTML.FormField integration; <.web_multiselect field={@form[:tags]} /> fills in id, name, and feeds the field value into the upstream initial-values attribute. Explicit :id / :name assigns win over the field defaults.
  • LiveView morph compatibility — the wrapper renders phx-update="ignore" (whenever :id is set) so morphdom leaves the upstream component's internally-managed children alone on subsequent renders, plus pre-emits data-ready="" on the element itself so LV's mergeAttrs (which strips data-* attributes on ignore-marked elements that aren't in the server-rendered HTML) doesn't tear down the placeholder visibility flag upstream sets via requestAnimationFrame during connectedCallback. 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 while data-ready is present.
  • Bundled upstream JS + CSS in priv/static/multiselect.js and priv/static/multiselect.css. No npm install required by consumers.
  • Optional LiveView hook in priv/static/keen_web_multiselect_hook.js. Set hook="KeenWebMultiselectHook" on the component and the hook forwards select / deselect / change CustomEvents to the server as "web_multiselect:select" / ":deselect" / ":change" events with {id, value, values} payloads. The hook module also polyfills a .form getter onto <web-multiselect> (returning this.internals?.form); upstream calls attachInternals() for form-association but never exposes .form, and Phoenix LV's phx-change delegation drops CustomEvents whose e.target.form === undefined. The polyfill is installed once at module load via customElements.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-multiselect version (currently "1.12.0-rc03").
  • Keenmate.WebMultiselect.asset_path/1 — absolute on-disk path to a file in priv/static/, for setup tasks that copy assets into a host application's assets/ folder.

Internal

  • Makefile mirroring the upstream @keenmate/web-multiselect Makefile 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 @version shape in mix.exsmake publish-rc refuses unless the version matches X.Y.Z-rc.N; make publish refuses if the version is an rc. Plus make current-version and make last-published for state inspection.
  • .claude/commands/publish.md/publish slash command following the canonical Bliss Framework web-components/publish-command.md structure, adapted for Hex (mix hex.info / mix hex.build / mix hex.publish instead of the npm equivalents). Sections marked [canonical] are byte-identical (modulo npm→Hex substitutions) across all KeenMate component repos.
  • ExUnit test suite covering OptionHelpers attribute encoding, FormHelpers field binding, and Components.web_multiselect/1 rendering (23 tests).
  • Playwright e2e harness — test_app/ is a minimal Phoenix 1.8 + LiveView 1.2 host (Bandit on 127.0.0.1:4060, path dep on the parent, shared deps/ and mix.lock). Four fixture LiveViews under test_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 through phx-change, the LV hook forwarding select/deselect/change to handle_event/3 with {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 at phoenix.mjs, phoenix_live_view.esm.js, and the wrapper's bundled multiselect.js served via three Plug.Static blocks — no esbuild step in test_app/. Specs in e2e/*.spec.ts, driven by playwright.config.ts at the repo root (single chromium project, webServer = mix phx.server with cwd: test_app). Makefile targets: make test-e2e-install / make test-e2e / make test-e2e-ui / make test-e2e-headed.
  • mix.exs package metadata: MIT license, Hex docs config via ex_doc, source URL, package :files whitelist scoped to lib/, priv/, mix.exs, README.md, CHANGELOG.md, LICENSE, .formatter.exs.