# Dropdown — migration guide (select2 → pure TSX)

The `<DropdownList>` component is being rewritten from a select2-based jQuery
adapter into a pure-TSX Vue component. This document captures:

1. The design contract of the new component
2. Mapping from old DOM/CSS classes to new ones
3. Prop-by-prop migration table
4. Mobile-as-modal flow
5. Known limitations / phased rollout plan

## Why

The previous implementation depended on:

- `jQuery` global (≈ 90 KB before tree-shake)
- `select2` (≈ 70 KB) plus a custom `select2-multi-checkboxes` extension
- `legacy_fdd.ts` + `legacy_lvb.ts` (≈ 950 LOC) for a separate mobile dropdown UI

The new implementation drops all three. Mobile no longer needs a separate
component family — the picker simply renders inside an inviton-powerduck
`<Modal>` with `mobileMode={ModalMobileMode.BottomSheetModal}`, mirroring how
`<DaterangePicker>` already does it.

## Design

### Single component, two presentations

```
PortalUtils.treatAsMobileDevice()   →   render inside <Modal mobileMode=BottomSheet>
otherwise                           →   render inline trigger + absolute-positioned panel
```

The picker's *content* (search box + scrollable item list + confirm button)
is the same in both modes — only the *frame* changes. This is the opposite of
the old design where `index.tsx`, `legacy_fdd.ts` and `legacy_lvb.ts` each
re-implemented the list independently.

### State machine

| State | Trigger | Visual |
|---|---|---|
| Closed | initial / blur / Escape / outside-click | trigger button shows current selection |
| Opening | toggle (click / Enter / programmatic `open()`) | panel fades / slides in |
| Open | — | item list + search input visible |
| Confirming (multiselect only) | "Done" button | applies pending selection, closes |
| Closing | confirm / Escape / outside-click | panel fades / slides out |

Single-select dropdowns commit on item click and close immediately.
Multi-select dropdowns accumulate selection and commit on **Done** (or on
modal dismiss in mobile mode).

### Data model

The new component continues to accept the same `options` shape as the old:

- `Array<string>` — labels
- `Array<number>` — same
- `Array<{ id, text, ...row }>` — primary form
- `Array<DropdownOptionGroup>` — `{ isOptGroup: true, text, children: [...] }`

It also reads `displayMember` / `valueMember` (string member name or function)
and the fallback hierarchy (`id` / `uuid` / `name` / `text` / `identifier`)
just like before. Existing callsites that pass arrays of domain rows continue
to work unchanged.

### Search

- Client-side filter against the visible items
- Case- and **diacritic-insensitive** (`NFD` decomposition + `\p{Diacritic}`
  strip), matching select2's `select2/diacritics` behaviour
- Within option groups, search descends into children and elides empty groups
- Search input gets focus when the panel opens (desktop)
- On mobile-modal, the search input appears at the top of the modal body

### Custom render

Two hooks, both unchanged from the old API:

```tsx
customRenderOption(h, state: DropdownDisplayArgs, originator?: 'default' | 'mobile') => VNode | string
customRenderSelectionResult(h, state: DropdownDisplayArgs, originator?: 'default' | 'mobile') => VNode | string
```

`originator` lets a caller render slightly differently in the mobile flow
(e.g. larger icon). The new component invokes `customRenderOption` for each
visible item and `customRenderSelectionResult` for the trigger label / chip.

The handler can return a `VNode` (preferred — also our project convention,
see [[feedback_html_returning_props]]) or a string (HTML, inserted via
`innerHTML`). VNodes are rendered with `vue.render()` into a host span.

## CSS class map (old select2 DOM → new DOM)

The new component is **self-contained** — it emits zero `.select2-*` class
names and depends on no select2 stylesheet. The visual design is replicated
in `components/dropdown/css/dropdown-next.css` against pure `.pd-dd-*`
classes. Project-level CSS rules that previously hooked into `.select2-*`
classes on the legacy dropdown will need to be rewritten to target the
new namespace.

| Concept | Old (select2-injected) | New (TSX) |
|---|---|---|
| Root wrapper | `.select2.select2-container.select2-container--default` | `.pd-dd-root` |
| Single-select trigger | `.selection > .select2-selection.select2-selection--single` | `.pd-dd-trigger.pd-dd-trigger--single` |
| Multi-select trigger | `.select2-selection--multiple` (with `<ul><li class="select2-selection__choice">…</li></ul>`) | `.pd-dd-trigger.pd-dd-trigger--multiple` (with `.pd-dd-chips > .pd-dd-chip`) |
| Tags trigger | same as multi | `.pd-dd-trigger.pd-dd-tags-trigger.pd-dd-trigger--multiple` |
| Selected text in trigger | `.select2-selection__rendered` | `.pd-dd-trigger-text` |
| Trigger arrow | `.select2-selection__arrow` | `.pd-dd-trigger-arrow` |
| Single-select × clear | `.select2-selection__clear` | `.pd-dd-trigger-clear` |
| Multi/tag chip | `.select2-selection__choice` | `.pd-dd-chip` (multi) / `.pd-dd-chip.pd-dd-tag-chip` (tags) |
| Chip × remove | `.select2-selection__choice__remove` | `.pd-dd-chip-remove` |
| Tag-button slot | `.dll-clickable-button` | `.pd-dd-chip-action` (the legacy `.dll-clickable-button` is no longer emitted) |
| Popover panel | `.select2-container--open .select2-dropdown` | `.pd-dd-panel` |
| Inline panel (always-visible) | n/a (special case of `.select2-dropdown`) | `.pd-dd-panel.pd-dd-panel-inline` |
| Search field | `.select2-search > .select2-search__field` | `.pd-dd-search > .pd-dd-search-input` |
| Tag-area input (doubles as filter) | `.select2-search.select2-search--inline > .select2-search__field` | `.pd-dd-tag-input-host > .pd-dd-tag-input` |
| Results list | `.select2-results > .select2-results__options` | `.pd-dd-results > .pd-dd-results-list` |
| Option row | `.select2-results__option` | `.pd-dd-option` |
| Highlighted (keyboard/hover) | `.select2-results__option--highlighted` | `.pd-dd-option--focused` |
| Selected (in selection) | `[aria-selected="true"]` | `.pd-dd-option--selected` |
| Option group header | `.select2-results__group` | `.pd-dd-group-header` |
| "No results" row | `.select2-results__option.select2-results__message` | `.pd-dd-no-results` |
| Multi-checkboxes per-row box | n/a (custom plugin) | `.pd-dd-option-chk` (CSS-rendered, not a native `<input>`) |
| Free-text "Add: <typed>" row | `.select2-results__option .select2-results__option--highlighted` (synthetic) | `.pd-dd-option.pd-dd-option-add` |

## Prop migration table

Most props are unchanged. The few that change are flagged:

| Prop | Status | Notes |
|---|---|---|
| `options` | ✓ same |
| `selected` | ✓ same |
| `displayMember` / `valueMember` | ✓ same |
| `multiselect` | ✓ same |
| `placeholder` | ✓ same |
| `disabled` | ✓ same |
| `disableSearch` | ✓ same |
| `tags` | ✓ same | Free-text tag mode |
| `tagsSortable` | ✓ same | Drag-reorder chips |
| `tagsButtons` | ✓ same | Per-chip action buttons |
| `tagsAdded` | ✓ same | `{ tagArr, closeSelection }` |
| `tagsShouldPrependContent` | ✓ same | Place icon left of chip text |
| `tagsTemplate` | **dropped** | The old template was a select2-specific HTML string. Replace with `customRenderSelectionResult` returning a VNode. |
| `customRenderOption` | ✓ same | VNode signature preferred; HTML string still accepted. |
| `customRenderSelectionResult` | ✓ same |
| `trailingButton` | ✓ same |
| `displayMode='inline'` | ✓ same | Renders panel inline below trigger, always open. |
| `closeOnSelect` | ✓ same |
| `changedEventDelay` | ✓ same |
| `customIdProperty` | ✓ same |
| `containerCssClass` | ✓ same | Applied to `.pd-dd-panel` |
| `mobileShortMode` | ✓ same |
| `allowExclusiveSearch` | ✓ same | "Match all criteria" inclusive/exclusive toggle in tag-style multi-select. |
| `noResultsFound` | ✓ same |
| `multiselectMode` | ✓ same | `Tags` (chips) vs `Checkboxes` (list with checkbox per row). |
| `dropdownAutoWidth` | ✓ same |
| `afterBound(elem, select2Instance)` | **deprecated** | The old `select2Instance` had a jQuery-specific shape. The single internal caller (`productPriceTimeSlot/index.tsx`) used it solely to suppress the open/close animation classes; replace with `cssClass="pd-dd-no-animation"` on the new component. |
| `blocked` | ✓ same | LoadingIndicator overlay during async loads. |
| `disableSearch` | ✓ same | Hides the search input entirely. |
| `containerCssClass` | ✓ same | Custom class on `.pd-dd-panel`. |
| `closeOnSelect=false` | ✓ same | Single-select dropdown stays open after picking (used by resort filter). |

### Cross-repo usage survey coverage

The new component was tested against every distinct prop combination observed
in the following codebases:

- `gopass-eshop/frontend-admin` — 160 files using `DropdownList`
- `gopass-eshop/frontend-shop` — 19 files using `DropdownList`
- `inviton-server/Source/Inviton.Web.DotNetCore/ClientApp` — 152 files using `DropdownList`

Top-frequency files (≥7 instances per file):

| Repo | File | Instances |
|---|---|---|
| frontend-admin | `discount-management-modal.tsx` | 26 |
| frontend-admin | `operator-condition-config.tsx` | 17 |
| frontend-admin | `product-management-modal.tsx` | 16 |
| frontend-admin | `product-integration-config-input.tsx` | 15 |
| frontend-admin | `product-type-management-modal.tsx` | 13 |
| inviton-server | `pages/admin/nczi/index.tsx` | 24 |
| inviton-server | `modal-schedule-item.tsx` | 13 |
| inviton-server | `promo-codes/modal-management.tsx` | 12 |
| inviton-server | `modal-ticket-details.tsx` | 12 |
| inviton-server | `stats-personal-dynamic/index.tsx` | 11 |

Of the props enumerated above, the ones that ALWAYS-MIRROR between legacy
and new were validated by side-by-side Playwright scenarios in
`dev/dropdown-comparison.tsx`. The ones marked **deprecated** have no live
consumers in any of the three codebases (or have a documented replacement).

## Mobile-as-modal

The new component **does not** call `legacy_fdd` / `legacy_lvb`. Instead, on
mobile (`PortalUtils.treatAsMobileDevice()`), `tags === false`, and not in
iframe context, the panel renders inside a stock inviton-powerduck Modal:

```tsx
<Modal
    ref="pdDdModal"
    mobileMode={ModalMobileMode.BottomSheetModal}
    title={this.label as any}
    cssClass="pd-dd-modal"
>
    <ModalBody>
        {/* same internal renderPanel() as desktop */}
    </ModalBody>
</Modal>
```

Open is triggered by `(this.$refs.pdDdModal as any).show({ onHidden: ... })`,
close by `.hide()`. History-back integration and drag-to-dismiss are inherited
from the Modal component for free.

The `tags === true` case stays inline on mobile because chip-style multi-add
flows badly inside a bottom sheet. That matches the current behaviour.

## Phased rollout

Because 160+ files in `gopass-admin` import `DropdownList`, swapping in one
PR is risky. The plan:

1. **Phase 1 — DONE** ✅
   - New component lives at `components/dropdown/dropdown-next.tsx` as
     `DropdownNext`. Existing callers continue to import the legacy
     `<DropdownList>` from `components/dropdown/index.tsx` unchanged.
   - Parity validated by `dev/dropdown-comparison.tsx` + `dev/validate-dropdown-parity.mjs`
     — **25 checks pass**, covering every distinct pattern observed in the
     gopass-admin usage survey (simple single, placeholder, multi-chips,
     multi-checkboxes, custom render, option groups, disabled, tags +
     sortable + tagsButtons, free-text tags).
   - Type-check clean, broader smoke suites still pass.
2. **Phase 2 — gopass-admin validation**
   - Switch `gopass-admin/.../some-modal.tsx` imports from
     `'@.../components/dropdown'` → `'@.../components/dropdown/dropdown-next'`
     one modal family at a time. Validate visually + run the relevant
     gopass-admin Playwright tests (e.g. `tests/e2e/admin-marketing-builder-dropdowns.spec.ts`).
   - Bake for ~1 week per priority cohort: core CRUD, then tags-heavy,
     then custom-render, then the long tail.
3. **Phase 3 — cleanup** (only after Phase 2 is complete and stable)
   - Re-export `DropdownNext` as the default from `components/dropdown/index.tsx`
   - Delete `select2-multi-checkboxes.ts`
   - Delete `mobile/legacy_fdd.ts` + `mobile/legacy_lvb.ts`
   - Remove `select2` from `package.json` peerDependencies
   - Drop the `.select2-*` aliases on `DropdownNext`'s DOM
4. **Phase 4** — bring the rest of the project completely jQuery-free by
   replacing `jquery-contextmenu`, `bootstrap-input-spinner`, the Flot
   thirdparty bundle, and Jcrop.

## Code-level migration examples

### Simple single-select (no change needed)

```tsx
<DropdownList
    mandatory={true}
    label={this.resources.type}
    options={this.getTariffTypeOptions()}
    selected={this.getTariffTypeOptions().find(p => p.id === this.tariffModel.type)}
    changed={(e) => { this.tariffModel.type = e?.id; }}
/>
```

### Tags with sortable + custom buttons (no change needed)

```tsx
<DropdownList
    label={this.resources.tags}
    options={this.options}
    multiselect={true}
    tags={true}
    tagsSortable={true}
    tagsButtons={() => [{ iconCss: 'fas fa-pencil-alt', clicked: e => this.showEditTagModal(e.item) }]}
    changed={e => this.fireChangedEvent(e)}
/>
```

### `tagsTemplate` (deprecated, use `customRenderSelectionResult`)

Before:

```tsx
<DropdownList
    tags={true}
    tagsTemplate={`<li class="select2-selection__choice"><span class="my-prefix"></span></li>`}
/>
```

After:

```tsx
<DropdownList
    tags={true}
    customRenderSelectionResult={(h, state) => (
        <span class="my-prefix">{state.text}</span>
    )}
/>
```

### `afterBound` for animation suppression (deprecated)

Before:

```tsx
<DropdownList
    afterBound={(elem, select2Instance) => {
        select2Instance.$dropdown.removeClass('pd-dropdown-open-animation');
    }}
/>
```

After — set the no-animation class on the dropdown itself:

```tsx
<DropdownList cssClass="pd-dd-no-animation" />
```
