Skip to main content

CHANGELOG.md

# Changelog

## [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: 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-rc05** — `priv/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 hook** — `push_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.options` → `el.options = ...`; `payload.value` → `el.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` declarations** — `checkbox_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 `KeenWebMultiselect` → `Keenmate.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.allOptions` — `undefined` 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 enabled** — `config :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`/`isDisabledCallback` → `getIs*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.FormHelpers` — `Phoenix.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.exs` — `make 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`.