[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 set path_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-treeview for expand/collapse. New guide guides/tree_of_options.md (on hexdocs and in the package). Demo card on /examples/tree.

  • Badge full title. New full_title_member reads a fully-qualified label supplied with the data (e.g. "Fruit / Pome fruit / Apple"); it is never computed. Set show_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-side searchCallback that 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-index page.

  • Per-node selectability in tree mode. New typed attribute is_selectable_member points at a boolean key in your option data: a falsy value makes that node non-selectable — it renders normally (not greyed out like disabled), 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); use disabled_member for genuinely unavailable options. The JS-only getIsSelectableCallback predicate (which receives the built node and can read its derived hasChildren) has no HEEx attribute — precompute the flag in LiveView instead. Documented in guides/tree_of_options.md (new "Selectable leaves only" section, with a only_leaves_selectable/1 helper); demoed on /examples/tree.

  • Programmatic selection can announce itself — el.setSelected(values, {notify: true}) (via the bundled upstream 1.12.0-rc07). setSelected stays silent by default (so restoring saved state, cascade resets, or server-authoritative corrections don't fire change / loop back), but a deliberate gesture — like a custom action button that sets the selection — can opt into a single aggregate change so 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") and cascade_select_policy ("rolled-up" default | "leaves" | "all"). In cascade mode, checking a node toggles its whole subtree and a partially-selected branch shows a tristate (dash) box. The value policy — the piece @keenmate/web-treeview lacks — decides which values a cascade selection emits (badges / form / change): rolled-up is 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), leaves emits only checked leaves, all emits every fully-checked node. Non-breaking — independent is the default. Documented in guides/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 with enable_option_tooltips={true} to show the full text on hover).

    These are option-level (flat lists too), scoped to web-multiselect or a single instance via the forwarded class/style. Follows the same label-hook approach as @keenmate/web-treeview. Documented in guides/tree_of_options.md and guides/theming.md.

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-thickness variables (override on web-multiselect or an instance).
  • Bundled @keenmate/web-multiselect upgraded to 1.12.0-rc07 (from 1.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/0 now 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). With enable_virtual_scroll + a custom option_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 set user-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; with checkbox_mode="cascade" it now projects through cascade_select_policy, so it matches what clicking a node produces (a fully-selected subtree collapses to its root).

Internal — demo site

  • New /examples/search-index page — externalized search with FlexSearch (loaded client-side, never in the wrapper bundle). Delegates the whole search to a FlexSearch index via searchCallback: 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/tree now 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 new TestApp.Isco08 module from test_app/priv/data/isco08.tsv; the ISCO code doubles as the materialized path (22112.22.221.2211), only unit groups (leaves) are selectable, and it runs with virtual scroll + compact rows (option_height={32}) + title truncation. A custom getBadgeDisplayCallback (attached via JS) shortens each badge to just the leaf title — prefixed .. / when it has ancestors — and getBadgeTooltipCallback puts the full breadcrumb in the badge tooltip. A getSearchValueCallback widens the built-in filter to also match the ISCO code and full breadcrumb (so 2211 or health resolve, 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/tree rebuilt as a 1:1 mirror of the upstream examples-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 custom el.actionButtons action on a cascade tree. Plus a wrapper-only <.keen_card>"Sending paths to the server" — that sets value_member="path" so select / change events carry the materialized paths (and, with cascade rolled-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 the id field its icon renderer reads, so the badge showed "undefined").

  • Floating "server round-trip" monitor on every example page. A ServerEventsPanel LiveComponent — wired once through the shared example_page/1 shell, so it appears on all /examples/* pages without touching each one — pins a small card to the bottom-right that counts and logs every select / deselect / change the 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 a ServerMonitor JS hook (registered in the demo app.js) that forwards the DOM events to the component via pushEventTo. Ported from the sibling keen_web_daterangepicker demo.

  • <.tip> code hints across all example pages, plus <.keen_card> and a shared keen-extras stylesheet. Added tip/1 (a compact one-line "here's the wrapper attribute" hint) and keen_card/1 (dashed-border card + badge for wrapper-only features) to TestAppWeb.Examples.SharedComponents, backed by a new priv/static/assets/examples-keen.css (linked in the demo layout) that also absorbs the server-monitor styles — which restores examples-shared.css to 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 the keen_web_daterangepicker demo.

[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 as var(--base-*, <fallback>), verified against the bundled multiselect.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-in light-dark() fallbacks give a working light/dark theme with nothing set, and --ms-* overrides (scoped to web-multiselect or per-instance via the forwarded class/style) restyle just the multiselect. Also covers the five dark-mode signals, --ms-rem proportional sizing, and the unlayered-reset cascade-layer footgun. Wired into mix.exs extras: (hexdocs) and files: (Hex tarball).
  • LLM / coding-agent docs — an ai/ folder shipped in the package. A flat-text knowledge base modelled on the upstream component's ai/ layout but written for the wrapper (HEEx, the LiveView hook, the helpers): an INDEX.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 a cookbook.txt of 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/3 opts, {value, label} tuple order, the six defaulted member attrs). Ships in the tarball (files: now includes ai and a root AGENTS.md) so a consumer's coding agent can read them from deps/keen_web_multiselect/. AGENTS.md is also surfaced on hexdocs as a Using with AI agents guide (in extras:), and ex_doc's generated llms.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 the links map — 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/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.