mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-06 01:25:38 +00:00
refactor(webapp): migrate ds-select to OcelotSelect (#9430)
This commit is contained in:
parent
d62abc524b
commit
cadd0d0286
@ -15,7 +15,7 @@ Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, Os
|
|||||||
Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅
|
Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅
|
||||||
Phase 4: Tier B ██████████ 100% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number→OsNumber✅, ds-radio→HTML✅)
|
Phase 4: Tier B ██████████ 100% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number→OsNumber✅, ds-radio→HTML✅)
|
||||||
Phase 4: Tier B ██████████ 100% (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML) ✅
|
Phase 4: Tier B ██████████ 100% (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML) ✅
|
||||||
Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form entkoppelt✅, ds-input→OcelotInput✅) | Rest ausstehend (OsMenu, OsSelect, OsDropdown, OsAvatar)
|
Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form entkoppelt✅, ds-input→OcelotInput✅, ds-select→OcelotSelect✅) | Rest ausstehend (OsMenu, OsDropdown, OsAvatar)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Statistiken
|
### Statistiken
|
||||||
@ -32,7 +32,8 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
|||||||
| ✅ → OsModal | Modal (7 Nutzungen → OsModal, Focus-Trap, Scroll-Lock, A11y) |
|
| ✅ → OsModal | Modal (7 Nutzungen → OsModal, Focus-Trap, Scroll-Lock, A11y) |
|
||||||
| ✅ ds-input → OcelotInput | Input (23 Dateien → OcelotInput Webapp-Komponente, lokale Imports, formValidation-kompatibel) |
|
| ✅ ds-input → OcelotInput | Input (23 Dateien → OcelotInput Webapp-Komponente, lokale Imports, formValidation-kompatibel) |
|
||||||
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
|
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
|
||||||
| ⬜ → UI-Library | Menu, MenuItem, Select (3) — Tier 2-3 |
|
| ✅ ds-select → OcelotSelect | Select (3 Dateien → OcelotSelect Webapp-Komponente, lokale Imports, click-outside inline) |
|
||||||
|
| ⬜ → UI-Library | Menu, MenuItem (2) — Tier 3 |
|
||||||
| ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
|
| ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
|
||||||
|
|
||||||
### OsButton Migration (Phase 3) ✅
|
### OsButton Migration (Phase 3) ✅
|
||||||
@ -80,7 +81,7 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
|||||||
| 18 | InputError | ✅ → OcelotInput | In OcelotInput integriert |
|
| 18 | InputError | ✅ → OcelotInput | In OcelotInput integriert |
|
||||||
| 19 | InputLabel | ✅ → OcelotInput | In OcelotInput integriert |
|
| 19 | InputLabel | ✅ → OcelotInput | In OcelotInput integriert |
|
||||||
| 20 | Radio | ✅ → HTML | 1 Datei → native `<input type="radio">` + `<fieldset>` (ReportModal) |
|
| 20 | Radio | ✅ → HTML | 1 Datei → native `<input type="radio">` + `<fieldset>` (ReportModal) |
|
||||||
| 21 | Select | ⬜ Tier 4 | 3 Dateien → OsSelect |
|
| 21 | Select | ✅ → OcelotSelect | 3 Dateien → OcelotSelect (Webapp-Komponente, click-outside inline) |
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
| # | Komponente | Status | Notizen |
|
| # | Komponente | Status | Notizen |
|
||||||
@ -370,7 +371,7 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
|||||||
### Basis-Komponenten — UI-Library (ausstehend)
|
### Basis-Komponenten — UI-Library (ausstehend)
|
||||||
- Modal → OsModal ✅
|
- Modal → OsModal ✅
|
||||||
- Input → OcelotInput (Webapp-Komponente) ✅ — langfristig → OsInput in packages/ui
|
- Input → OcelotInput (Webapp-Komponente) ✅ — langfristig → OsInput in packages/ui
|
||||||
- Select → OsSelect
|
- Select → OcelotSelect (Webapp-Komponente) ✅ — langfristig → OsSelect in packages/ui
|
||||||
- Avatar → OsAvatar (falls benötigt)
|
- Avatar → OsAvatar (falls benötigt)
|
||||||
|
|
||||||
### Layout & Typography — → Plain HTML ✅ (Tier A)
|
### Layout & Typography — → Plain HTML ✅ (Tier A)
|
||||||
@ -412,6 +413,7 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
|||||||
| 2026-02-19 | Claude | **Tier A Migration** | 10 ds-* Vue-Wrapper → Plain HTML + CSS, _ds-compat.scss, ~450 Nutzungen in ~90 Dateien |
|
| 2026-02-19 | Claude | **Tier A Migration** | 10 ds-* Vue-Wrapper → Plain HTML + CSS, _ds-compat.scss, ~450 Nutzungen in ~90 Dateien |
|
||||||
| 2026-02-19 | Claude | **Katalog konsolidiert** | Styleguide- und Webapp-Tabellen aktualisiert, veraltete Status korrigiert |
|
| 2026-02-19 | Claude | **Katalog konsolidiert** | Styleguide- und Webapp-Tabellen aktualisiert, veraltete Status korrigiert |
|
||||||
| 2026-03-23 | Claude | **ds-input → OcelotInput** | 23 Dateien migriert, Webapp-Komponente mit lokalen Imports (tree-shakeable), FormItem/Label/Error vereint |
|
| 2026-03-23 | Claude | **ds-input → OcelotInput** | 23 Dateien migriert, Webapp-Komponente mit lokalen Imports (tree-shakeable), FormItem/Label/Error vereint |
|
||||||
|
| 2026-03-23 | Claude | **ds-select → OcelotSelect** | 3 Dateien migriert, Webapp-Komponente, DsSelect+inputMixin+multiinputMixin vereint, Form-Kopplung entfernt, DsChip→OsBadge, DsSpinner→OsSpinner, click-outside inline |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -450,11 +452,11 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
|||||||
20. [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
|
20. [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
|
||||||
21. [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, FormItem/Label/Error vereint, formValidation-kompatibel) ✅
|
21. [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, FormItem/Label/Error vereint, formValidation-kompatibel) ✅
|
||||||
22. [ ] OsMenu / OsMenuItem (17 Dateien)
|
22. [ ] OsMenu / OsMenuItem (17 Dateien)
|
||||||
23. [ ] OsSelect (3 Dateien), OsTable (7 Dateien)
|
23. [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente, click-outside inline, DsChip→OsBadge) ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B ✅ (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML), Tier 2: OsModal ✅, ds-form entkoppelt ✅, ds-input → OcelotInput ✅, Rest ausstehend (OsMenu, OsSelect).**
|
**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B ✅ (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML), Tier 2: OsModal ✅, ds-form entkoppelt ✅, ds-input → OcelotInput ✅, ds-select → OcelotSelect ✅, Rest ausstehend (OsMenu).**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
|
|||||||
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
|
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
|
||||||
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
|
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
|
||||||
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
|
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
|
||||||
Phase 4: ████████░░ 74% (20/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅, ds-radio→HTML ✅ | Tier B ✅, OcelotInput ✅, Tier 2-3 Rest ausstehend
|
Phase 4: ████████░░ 78% (21/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅, ds-radio→HTML ✅ | Tier B ✅, OcelotInput ✅, OcelotSelect ✅, Tier 2-3 Rest ausstehend
|
||||||
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
|
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
|
||||||
───────────────────────────────────────
|
───────────────────────────────────────
|
||||||
Gesamt: ████████░░ 85% (82/96 Aufgaben)
|
Gesamt: ████████░░ 86% (83/96 Aufgaben)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Katalogisierung (Details in KATALOG.md)
|
### Katalogisierung (Details in KATALOG.md)
|
||||||
@ -289,7 +289,7 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
|
|||||||
- [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend
|
- [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend
|
||||||
|
|
||||||
**Verbleibende ds-* Komponenten (6 Typen):**
|
**Verbleibende ds-* Komponenten (6 Typen):**
|
||||||
- Tier C (→ UI-Library): ds-modal (7→✅ OsModal), ds-input (23→✅ OcelotInput), ds-menu/ds-menu-item (17), ds-select (3)
|
- Tier C (→ UI-Library): ds-modal (7→✅ OsModal), ds-input (23→✅ OcelotInput), ds-select (3→✅ OcelotSelect), ds-menu/ds-menu-item (17)
|
||||||
- ✅ ds-form (18 Dateien) → formValidation Mixin (async-validator), vuelidate entfernt
|
- ✅ ds-form (18 Dateien) → formValidation Mixin (async-validator), vuelidate entfernt
|
||||||
|
|
||||||
**Zuvor abgeschlossen (Session 26 - CodeRabbit Review Fixes):**
|
**Zuvor abgeschlossen (Session 26 - CodeRabbit Review Fixes):**
|
||||||
@ -419,7 +419,7 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
|
|||||||
- [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
|
- [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
|
||||||
- [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
|
- [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
|
||||||
- [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem
|
- [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem
|
||||||
- [ ] ds-select → OsSelect
|
- [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente mit lokalen Imports, click-outside inline) ✅
|
||||||
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
|
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
|
||||||
|
|
||||||
**Manuelle Setup-Aufgaben (außerhalb Code):**
|
**Manuelle Setup-Aufgaben (außerhalb Code):**
|
||||||
@ -701,7 +701,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
|
|||||||
- [ ] OsMenuItem (Basis: DsMenuItem, 6 Dateien)
|
- [ ] OsMenuItem (Basis: DsMenuItem, 6 Dateien)
|
||||||
|
|
||||||
**Tier 4: Spezial-Komponenten**
|
**Tier 4: Spezial-Komponenten**
|
||||||
- [ ] OsSelect (3 Dateien)
|
- [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente, click-outside inline, DsChip→OsBadge, DsSpinner→OsSpinner) ✅
|
||||||
- [x] ds-table (7 Dateien) → Plain HTML `<table>` + CSS-Klassen ✅ (kein OsTable nötig)
|
- [x] ds-table (7 Dateien) → Plain HTML `<table>` + CSS-Klassen ✅ (kein OsTable nötig)
|
||||||
- [x] ds-form → formValidation Mixin (async-validator), vuelidate entfernt ✅
|
- [x] ds-form → formValidation Mixin (async-validator), vuelidate entfernt ✅
|
||||||
|
|
||||||
@ -1853,6 +1853,8 @@ Bei der Migration werden:
|
|||||||
| 2026-03-14 | **18 Formulare migriert** | CommentForm, ContributionForm, EnterNonce, GroupForm, Password/Change, PasswordReset (2), Registration (5), Signup, MySomethingList, donations, admin/users, settings (3) |
|
| 2026-03-14 | **18 Formulare migriert** | CommentForm, ContributionForm, EnterNonce, GroupForm, Password/Change, PasswordReset (2), Registration (5), Signup, MySomethingList, donations, admin/users, settings (3) |
|
||||||
| 2026-03-20 | **formValidation Fix** | `handleInput()` vor `$validateForm()` aufrufen (Reihenfolge-Bug: handleInput überschrieb handleInputValid bei synchronem async-validator Callback) |
|
| 2026-03-20 | **formValidation Fix** | `handleInput()` vor `$validateForm()` aufrufen (Reihenfolge-Bug: handleInput überschrieb handleInputValid bei synchronem async-validator Callback) |
|
||||||
| 2026-03-23 | **ds-input → OcelotInput** | Neue Webapp-Komponente `OcelotInput.vue`: vereint DsInput + FormItem + InputLabel + InputError in einer Datei. 23 Vue-Dateien migriert mit lokalen Imports (tree-shakeable). formValidation Mixin voll kompatibel. dot-prop Abhängigkeit durch inline `getNestedValue()` ersetzt. 28 Test-Suites, 210 Tests ✅, 7 Snapshots aktualisiert. |
|
| 2026-03-23 | **ds-input → OcelotInput** | Neue Webapp-Komponente `OcelotInput.vue`: vereint DsInput + FormItem + InputLabel + InputError in einer Datei. 23 Vue-Dateien migriert mit lokalen Imports (tree-shakeable). formValidation Mixin voll kompatibel. dot-prop Abhängigkeit durch inline `getNestedValue()` ersetzt. 28 Test-Suites, 210 Tests ✅, 7 Snapshots aktualisiert. |
|
||||||
|
| 2026-03-23 | **OcelotInput: ds-icon → os-icon** | DsIcon durch OsIcon + resolveIcon() ersetzt. at.svg, envelope.svg, paperclip.svg zu Ocelot-Icons hinzugefügt. Ocelot-Icons Visual Snapshot aktualisiert. |
|
||||||
|
| 2026-03-23 | **ds-select → OcelotSelect** | Neue Webapp-Komponente `OcelotSelect.vue`: vereint DsSelect + inputMixin + multiinputMixin (~420 Zeilen). Form-Validation entfernt (von keinem Consumer genutzt). DsChip→OsBadge, DsSpinner→OsSpinner, DsIcon→OsIcon. vue-click-outside durch inline document.addEventListener ersetzt. 3 Dateien migriert, 16 Tests ✅. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1875,7 +1877,8 @@ Bei der Migration werden:
|
|||||||
| ✅ → UI-Library | Chip, Tag → OsBadge (2), Number → OsNumber (1) — Tier B |
|
| ✅ → UI-Library | Chip, Tag → OsBadge (2), Number → OsNumber (1) — Tier B |
|
||||||
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
|
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
|
||||||
| ✅ ds-input → OcelotInput | Webapp-Komponente (23 Dateien), lokale Imports, FormItem/InputLabel/InputError vereint |
|
| ✅ ds-input → OcelotInput | Webapp-Komponente (23 Dateien), lokale Imports, FormItem/InputLabel/InputError vereint |
|
||||||
| ⬜ → UI-Library | Menu, MenuItem, Select (3) — Tier 2-3 |
|
| ✅ ds-select → OcelotSelect | Webapp-Komponente (3 Dateien), lokale Imports, click-outside inline, DsChip→OsBadge |
|
||||||
|
| ⬜ → UI-Library | Menu, MenuItem (2) — Tier 3 |
|
||||||
| ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
|
| ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
450
webapp/components/OcelotSelect/OcelotSelect.vue
Normal file
450
webapp/components/OcelotSelect/OcelotSelect.vue
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ds-form-item" :class="stateClasses">
|
||||||
|
<label class="ds-input-label" v-show="!!label" :for="id">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="ds-select-wrap"
|
||||||
|
:class="[isOpen && 'ds-select-is-open']"
|
||||||
|
:tabindex="searchable ? -1 : tabindex"
|
||||||
|
@keydown.tab="closeAndBlur"
|
||||||
|
@keydown.self.down.prevent="pointerNext"
|
||||||
|
@keydown.self.up.prevent="pointerPrev"
|
||||||
|
@keypress.enter.prevent.stop="handleEnter"
|
||||||
|
@keyup.esc="close"
|
||||||
|
>
|
||||||
|
<div v-if="resolvedIcon" class="ds-select-icon">
|
||||||
|
<os-icon :icon="resolvedIcon" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="ds-select"
|
||||||
|
@click="openAndFocus"
|
||||||
|
:class="[
|
||||||
|
resolvedIcon && 'ds-select-has-icon',
|
||||||
|
resolvedIconRight && 'ds-select-has-icon-right',
|
||||||
|
multiple && 'ds-select-multiple',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div v-if="multiple" class="ds-selected-options">
|
||||||
|
<div
|
||||||
|
class="ds-selected-option"
|
||||||
|
v-for="(val, index) in innerValue"
|
||||||
|
:key="val[labelProp] || val"
|
||||||
|
>
|
||||||
|
<slot name="optionitem" :value="val">
|
||||||
|
<os-badge removable @remove="deselectOption(index)" variant="primary" :size="size">
|
||||||
|
{{ val[labelProp] || val }}
|
||||||
|
</os-badge>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="search"
|
||||||
|
class="ds-select-search"
|
||||||
|
autocomplete="off"
|
||||||
|
:id="id"
|
||||||
|
:name="name ? name : model"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
:disabled="disabled"
|
||||||
|
v-model="searchString"
|
||||||
|
@focus="openAndFocus"
|
||||||
|
@keydown.tab="closeAndBlur"
|
||||||
|
@keydown.delete.stop="deselectLastOption"
|
||||||
|
@keydown.down.prevent="handleKeyDown"
|
||||||
|
@keydown.up.prevent="handleKeyUp"
|
||||||
|
@keypress.enter.prevent.stop="handleEnter"
|
||||||
|
@keyup.esc="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ds-select-value">
|
||||||
|
<slot v-if="innerValue" name="value" :value="innerValue">
|
||||||
|
{{ innerValue[labelProp] || innerValue }}
|
||||||
|
</slot>
|
||||||
|
<div v-else-if="placeholder" class="ds-select-placeholder">
|
||||||
|
{{ placeholder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-if="!multiple"
|
||||||
|
ref="search"
|
||||||
|
class="ds-select-search"
|
||||||
|
autocomplete="off"
|
||||||
|
:id="id"
|
||||||
|
:name="name ? name : model"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
:disabled="disabled"
|
||||||
|
v-model="searchString"
|
||||||
|
@focus="openAndFocus"
|
||||||
|
@keydown.tab="closeAndBlur"
|
||||||
|
@keydown.delete.stop="deselectLastOption"
|
||||||
|
@keydown.down.prevent="handleKeyDown"
|
||||||
|
@keydown.up.prevent="handleKeyUp"
|
||||||
|
@keypress.enter.prevent.stop="handleEnter"
|
||||||
|
@keyup.esc="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ds-select-dropdown">
|
||||||
|
<div class="ds-select-dropdown-message" v-if="!options || !options.length">
|
||||||
|
{{ noOptionsAvailable }}
|
||||||
|
</div>
|
||||||
|
<div class="ds-select-dropdown-message" v-else-if="!filteredOptions.length">
|
||||||
|
{{ noOptionsFound }} "{{ searchString }}"
|
||||||
|
</div>
|
||||||
|
<ul class="ds-select-options" ref="options" v-else>
|
||||||
|
<li
|
||||||
|
class="ds-select-option"
|
||||||
|
:class="[
|
||||||
|
isSelected(option) && 'ds-select-option-is-selected',
|
||||||
|
pointer === index && 'ds-select-option-hover',
|
||||||
|
]"
|
||||||
|
v-for="(option, index) in filteredOptions"
|
||||||
|
@click="handleSelect(option)"
|
||||||
|
@mouseover="setPointer(index)"
|
||||||
|
:key="option[labelProp] || option"
|
||||||
|
>
|
||||||
|
<slot name="option" :option="option">
|
||||||
|
{{ option[labelProp] || option }}
|
||||||
|
</slot>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="resolvedIconRight" class="ocelot-select-icon-right">
|
||||||
|
<os-icon :icon="resolvedIconRight" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { OsIcon, OsBadge } from '@ocelot-social/ui'
|
||||||
|
import { resolveIcon } from '~/utils/iconRegistry'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OcelotSelect',
|
||||||
|
components: { OsIcon, OsBadge },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Object, Number, Array],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'base',
|
||||||
|
validator: (value) => /^(small|base|large)$/.test(value),
|
||||||
|
},
|
||||||
|
tabindex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
iconRight: {
|
||||||
|
type: String,
|
||||||
|
default: 'angle-down',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
labelProp: {
|
||||||
|
type: String,
|
||||||
|
default: 'label',
|
||||||
|
},
|
||||||
|
searchable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
autoResetSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
type: Function,
|
||||||
|
default: (option, searchString = '', labelProp) => {
|
||||||
|
const value = String(option[labelProp] || option)
|
||||||
|
const searchParts = typeof searchString === 'string' ? searchString.split(' ') : []
|
||||||
|
return searchParts.every((part) => {
|
||||||
|
if (!part) return true
|
||||||
|
return value.toLowerCase().includes(part.toLowerCase())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
noOptionsAvailable: {
|
||||||
|
type: String,
|
||||||
|
default: 'No options available.',
|
||||||
|
},
|
||||||
|
noOptionsFound: {
|
||||||
|
type: String,
|
||||||
|
default: 'No options found for:',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
innerValue: null,
|
||||||
|
error: null,
|
||||||
|
focus: false,
|
||||||
|
searchString: '',
|
||||||
|
pointer: 0,
|
||||||
|
isOpen: false,
|
||||||
|
hadKeyboardInput: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
resolvedIcon() {
|
||||||
|
return resolveIcon(this.icon)
|
||||||
|
},
|
||||||
|
resolvedIconRight() {
|
||||||
|
return resolveIcon(this.iconRight)
|
||||||
|
},
|
||||||
|
isInteractionBlocked() {
|
||||||
|
return this.disabled || this.readonly || this.loading
|
||||||
|
},
|
||||||
|
stateClasses() {
|
||||||
|
return [
|
||||||
|
this.size && `ds-input-size-${this.size}`,
|
||||||
|
this.disabled && 'ds-input-is-disabled',
|
||||||
|
this.readonly && 'ds-input-is-readonly',
|
||||||
|
this.error && 'ds-input-has-error',
|
||||||
|
this.focus && 'ds-input-has-focus',
|
||||||
|
]
|
||||||
|
},
|
||||||
|
filteredOptions() {
|
||||||
|
if (!this.searchString) return this.options
|
||||||
|
return this.options.filter((option) => this.filter(option, this.searchString, this.labelProp))
|
||||||
|
},
|
||||||
|
pointerMax() {
|
||||||
|
return this.filteredOptions.length - 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(value) {
|
||||||
|
this.innerValue = value
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
pointerMax(max) {
|
||||||
|
if (max < this.pointer) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.pointer = max
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
searchString() {
|
||||||
|
this.setPointer(-1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this._clickOutsideHandler = (e) => {
|
||||||
|
if (!this.$el.contains(e.target)) {
|
||||||
|
this.closeAndBlur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', this._clickOutsideHandler, true)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('click', this._clickOutsideHandler, true)
|
||||||
|
clearTimeout(this.hadKeyboardInput)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// --- Input / Value ---
|
||||||
|
input(value) {
|
||||||
|
this.innerValue = value
|
||||||
|
this.$emit('input', value)
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Selection ---
|
||||||
|
selectOption(option) {
|
||||||
|
if (this.multiple) {
|
||||||
|
this.selectMultiOption(option)
|
||||||
|
} else {
|
||||||
|
this.input(option)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectMultiOption(value) {
|
||||||
|
if (!this.innerValue) return this.input([value])
|
||||||
|
const index = this.innerValue.indexOf(value)
|
||||||
|
if (index < 0) return this.input([...this.innerValue, value])
|
||||||
|
this.deselectOption(index)
|
||||||
|
},
|
||||||
|
deselectOption(index) {
|
||||||
|
const newArray = [...this.innerValue]
|
||||||
|
newArray.splice(index, 1)
|
||||||
|
this.input(newArray)
|
||||||
|
},
|
||||||
|
deselectLastOption() {
|
||||||
|
if (this.multiple && this.innerValue && this.innerValue.length && !this.searchString.length) {
|
||||||
|
this.deselectOption(this.innerValue.length - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSelected(option) {
|
||||||
|
if (!this.innerValue) return false
|
||||||
|
if (this.multiple) return this.innerValue.includes(option)
|
||||||
|
return this.innerValue === option
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Select interaction ---
|
||||||
|
handleSelect(option) {
|
||||||
|
if (this.isInteractionBlocked) return
|
||||||
|
if (this.pointerMax < 0) return
|
||||||
|
this.selectOption(option)
|
||||||
|
if (this.autoResetSearch || this.multiple) this.resetSearch()
|
||||||
|
if (this.multiple) {
|
||||||
|
this.$refs.search.focus()
|
||||||
|
this.handleFocus()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetSearch() {
|
||||||
|
this.searchString = ''
|
||||||
|
},
|
||||||
|
openAndFocus() {
|
||||||
|
if (this.isInteractionBlocked) return
|
||||||
|
this.open()
|
||||||
|
if (this.autoResetSearch) this.resetSearch()
|
||||||
|
if (!this.focus || this.multiple) {
|
||||||
|
this.$refs.search.focus()
|
||||||
|
this.handleFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
if (this.autoResetSearch || this.multiple) this.resetSearch()
|
||||||
|
this.isOpen = true
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.isOpen = false
|
||||||
|
},
|
||||||
|
closeAndBlur() {
|
||||||
|
this.close()
|
||||||
|
if (this.$refs.search) this.$refs.search.blur()
|
||||||
|
this.handleBlur()
|
||||||
|
},
|
||||||
|
handleFocus() {
|
||||||
|
this.focus = true
|
||||||
|
},
|
||||||
|
handleBlur() {
|
||||||
|
this.focus = false
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Keyboard navigation ---
|
||||||
|
handleEnter(e) {
|
||||||
|
if (this.isInteractionBlocked) return
|
||||||
|
if (this.pointer >= 0) {
|
||||||
|
this.selectPointerOption()
|
||||||
|
} else {
|
||||||
|
this.setPointer(-1)
|
||||||
|
this.$emit('enter', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleKeyUp() {
|
||||||
|
if (this.isInteractionBlocked) return
|
||||||
|
if (!this.isOpen) {
|
||||||
|
this.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pointerPrev()
|
||||||
|
},
|
||||||
|
handleKeyDown() {
|
||||||
|
if (this.isInteractionBlocked) return
|
||||||
|
if (!this.isOpen) {
|
||||||
|
this.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pointerNext()
|
||||||
|
},
|
||||||
|
setPointer(index) {
|
||||||
|
if (!this.hadKeyboardInput) this.pointer = index
|
||||||
|
},
|
||||||
|
pointerPrev() {
|
||||||
|
if (this.pointer <= 0) {
|
||||||
|
this.pointer = this.pointerMax
|
||||||
|
} else {
|
||||||
|
this.pointer--
|
||||||
|
}
|
||||||
|
this.scrollToHighlighted()
|
||||||
|
},
|
||||||
|
pointerNext() {
|
||||||
|
if (this.pointer >= this.pointerMax) {
|
||||||
|
this.pointer = 0
|
||||||
|
} else {
|
||||||
|
this.pointer++
|
||||||
|
}
|
||||||
|
this.scrollToHighlighted()
|
||||||
|
},
|
||||||
|
scrollToHighlighted() {
|
||||||
|
clearTimeout(this.hadKeyboardInput)
|
||||||
|
if (!this.$refs.options || !this.$refs.options.children.length || this.pointerMax <= -1)
|
||||||
|
return
|
||||||
|
this.hadKeyboardInput = setTimeout(() => {
|
||||||
|
this.hadKeyboardInput = null
|
||||||
|
}, 250)
|
||||||
|
this.$refs.options.children[this.pointer].scrollIntoView({ block: 'nearest' })
|
||||||
|
},
|
||||||
|
selectPointerOption() {
|
||||||
|
this.handleSelect(this.filteredOptions[this.pointer])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* Styles inherited from global styleguide CSS (ds-select, ds-form-item classes).
|
||||||
|
* Once ds-select is fully removed from the styleguide, move the styles here. */
|
||||||
|
|
||||||
|
.ocelot-select-icon-right {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<label class="ds-input-label">
|
<label class="ds-input-label">
|
||||||
{{ `${$t('settings.data.labelCity')}` + locationNameLabelAddOnOldName }}
|
{{ `${$t('settings.data.labelCity')}` + locationNameLabelAddOnOldName }}
|
||||||
</label>
|
</label>
|
||||||
<ds-select
|
<ocelot-select
|
||||||
id="city"
|
id="city"
|
||||||
v-model="currentValue"
|
v-model="currentValue"
|
||||||
:options="cities"
|
:options="cities"
|
||||||
@ -14,13 +14,15 @@
|
|||||||
@input.native="handleCityInput"
|
@input.native="handleCityInput"
|
||||||
/>
|
/>
|
||||||
<os-button
|
<os-button
|
||||||
v-if="locationName !== '' && canBeCleared"
|
v-if="(locationName !== '' && canBeCleared) || loadingGeo"
|
||||||
data-test="clear-location-button"
|
data-test="clear-location-button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
appearance="ghost"
|
appearance="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
circle
|
||||||
|
:loading="loadingGeo"
|
||||||
:aria-label="$t('actions.clear')"
|
:aria-label="$t('actions.clear')"
|
||||||
style="right: -94%; top: -48px"
|
style="position: relative; float: right; top: -48px; right: 4px"
|
||||||
@click="clearLocationName"
|
@click="clearLocationName"
|
||||||
>
|
>
|
||||||
<template #icon><os-icon :icon="icons.close" /></template>
|
<template #icon><os-icon :icon="icons.close" /></template>
|
||||||
@ -30,14 +32,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||||
|
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import { queryLocations } from '~/graphql/location'
|
import { queryLocations } from '~/graphql/location'
|
||||||
|
|
||||||
let timeout
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'LocationSelect',
|
name: 'LocationSelect',
|
||||||
components: { OsButton, OsIcon },
|
components: { OsButton, OsIcon, OcelotSelect },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
@ -62,6 +63,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
currentValue: this.value,
|
currentValue: this.value,
|
||||||
loadingGeo: false,
|
loadingGeo: false,
|
||||||
|
debounceTimeout: null,
|
||||||
cities: [],
|
cities: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -99,11 +101,9 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleCityInput(event) {
|
handleCityInput(event) {
|
||||||
clearTimeout(timeout)
|
const value = event.target ? event.target.value.trim() : ''
|
||||||
timeout = setTimeout(
|
clearTimeout(this.debounceTimeout)
|
||||||
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
|
this.debounceTimeout = setTimeout(() => this.requestGeoData(value), 500)
|
||||||
500,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
processLocationsResult(places) {
|
processLocationsResult(places) {
|
||||||
if (!places.length) {
|
if (!places.length) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="searchable-input" aria-label="search" role="search">
|
<div class="searchable-input" aria-label="search" role="search">
|
||||||
<ds-select
|
<ocelot-select
|
||||||
ref="select"
|
ref="select"
|
||||||
type="search"
|
type="search"
|
||||||
icon="search"
|
icon="search"
|
||||||
@ -48,13 +48,14 @@
|
|||||||
<hc-hashtag :id="option.id" />
|
<hc-hashtag :id="option.id" />
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</ds-select>
|
</ocelot-select>
|
||||||
<os-button
|
<os-button
|
||||||
v-if="isActive"
|
v-if="isActive || loading"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
appearance="ghost"
|
appearance="ghost"
|
||||||
circle
|
circle
|
||||||
size="sm"
|
size="sm"
|
||||||
|
:loading="loading"
|
||||||
:aria-label="$t('actions.clear')"
|
:aria-label="$t('actions.clear')"
|
||||||
@click="clear"
|
@click="clear"
|
||||||
>
|
>
|
||||||
@ -67,6 +68,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||||
|
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
|
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
|
||||||
@ -80,6 +82,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
OsButton,
|
OsButton,
|
||||||
OsIcon,
|
OsIcon,
|
||||||
|
OcelotSelect,
|
||||||
SearchHeading,
|
SearchHeading,
|
||||||
SearchGroup,
|
SearchGroup,
|
||||||
SearchPost,
|
SearchPost,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-select
|
<ocelot-select
|
||||||
class="select-user-search"
|
class="select-user-search"
|
||||||
type="search"
|
type="search"
|
||||||
icon="search"
|
icon="search"
|
||||||
@ -26,18 +26,20 @@
|
|||||||
<user-teaser :user="option" :showPopover="false" :linkToProfile="false" />
|
<user-teaser :user="option" :showPopover="false" :linkToProfile="false" />
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</ds-select>
|
</ocelot-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { searchUsers } from '~/graphql/Search.js'
|
import { searchUsers } from '~/graphql/Search.js'
|
||||||
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
|
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
|
||||||
|
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SelectUserSearch',
|
name: 'SelectUserSearch',
|
||||||
components: {
|
components: {
|
||||||
UserTeaser,
|
UserTeaser,
|
||||||
|
OcelotSelect,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: {
|
id: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user