mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-03 08:05:37 +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 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 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
|
||||
@ -32,7 +32,8 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
||||
| ✅ → OsModal | Modal (7 Nutzungen → OsModal, Focus-Trap, Scroll-Lock, A11y) |
|
||||
| ✅ ds-input → OcelotInput | Input (23 Dateien → OcelotInput Webapp-Komponente, lokale Imports, formValidation-kompatibel) |
|
||||
| ✅ 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) |
|
||||
|
||||
### OsButton Migration (Phase 3) ✅
|
||||
@ -80,7 +81,7 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
||||
| 18 | InputError | ✅ → OcelotInput | In OcelotInput integriert |
|
||||
| 19 | InputLabel | ✅ → OcelotInput | In OcelotInput integriert |
|
||||
| 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
|
||||
| # | Komponente | Status | Notizen |
|
||||
@ -370,7 +371,7 @@ Phase 4: Tier 2+ ████████░░ 60% (OsModal✅, ds-form
|
||||
### Basis-Komponenten — UI-Library (ausstehend)
|
||||
- Modal → OsModal ✅
|
||||
- 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)
|
||||
|
||||
### 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 | **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-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 ✅
|
||||
21. [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, FormItem/Label/Error vereint, formValidation-kompatibel) ✅
|
||||
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 2: ██████████ 100% (26/26 Aufgaben) ✅
|
||||
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)
|
||||
───────────────────────────────────────
|
||||
Gesamt: ████████░░ 85% (82/96 Aufgaben)
|
||||
Gesamt: ████████░░ 86% (83/96 Aufgaben)
|
||||
```
|
||||
|
||||
### Katalogisierung (Details in KATALOG.md)
|
||||
@ -289,7 +289,7 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
|
||||
- [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend
|
||||
|
||||
**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
|
||||
|
||||
**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-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
|
||||
- [ ] 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)
|
||||
|
||||
**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)
|
||||
|
||||
**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-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-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 | **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 |
|
||||
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
|
||||
| ✅ 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) |
|
||||
|
||||
---
|
||||
|
||||
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">
|
||||
{{ `${$t('settings.data.labelCity')}` + locationNameLabelAddOnOldName }}
|
||||
</label>
|
||||
<ds-select
|
||||
<ocelot-select
|
||||
id="city"
|
||||
v-model="currentValue"
|
||||
:options="cities"
|
||||
@ -14,13 +14,15 @@
|
||||
@input.native="handleCityInput"
|
||||
/>
|
||||
<os-button
|
||||
v-if="locationName !== '' && canBeCleared"
|
||||
v-if="(locationName !== '' && canBeCleared) || loadingGeo"
|
||||
data-test="clear-location-button"
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
size="sm"
|
||||
circle
|
||||
:loading="loadingGeo"
|
||||
:aria-label="$t('actions.clear')"
|
||||
style="right: -94%; top: -48px"
|
||||
style="position: relative; float: right; top: -48px; right: 4px"
|
||||
@click="clearLocationName"
|
||||
>
|
||||
<template #icon><os-icon :icon="icons.close" /></template>
|
||||
@ -30,14 +32,13 @@
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { queryLocations } from '~/graphql/location'
|
||||
|
||||
let timeout
|
||||
|
||||
export default {
|
||||
name: 'LocationSelect',
|
||||
components: { OsButton, OsIcon },
|
||||
components: { OsButton, OsIcon, OcelotSelect },
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Object],
|
||||
@ -62,6 +63,7 @@ export default {
|
||||
return {
|
||||
currentValue: this.value,
|
||||
loadingGeo: false,
|
||||
debounceTimeout: null,
|
||||
cities: [],
|
||||
}
|
||||
},
|
||||
@ -99,11 +101,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
handleCityInput(event) {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(
|
||||
() => this.requestGeoData(event.target ? event.target.value.trim() : ''),
|
||||
500,
|
||||
)
|
||||
const value = event.target ? event.target.value.trim() : ''
|
||||
clearTimeout(this.debounceTimeout)
|
||||
this.debounceTimeout = setTimeout(() => this.requestGeoData(value), 500)
|
||||
},
|
||||
processLocationsResult(places) {
|
||||
if (!places.length) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="searchable-input" aria-label="search" role="search">
|
||||
<ds-select
|
||||
<ocelot-select
|
||||
ref="select"
|
||||
type="search"
|
||||
icon="search"
|
||||
@ -48,13 +48,14 @@
|
||||
<hc-hashtag :id="option.id" />
|
||||
</p>
|
||||
</template>
|
||||
</ds-select>
|
||||
</ocelot-select>
|
||||
<os-button
|
||||
v-if="isActive"
|
||||
v-if="isActive || loading"
|
||||
variant="primary"
|
||||
appearance="ghost"
|
||||
circle
|
||||
size="sm"
|
||||
:loading="loading"
|
||||
:aria-label="$t('actions.clear')"
|
||||
@click="clear"
|
||||
>
|
||||
@ -67,6 +68,7 @@
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import { isEmpty } from 'lodash'
|
||||
import SearchHeading from '~/components/generic/SearchHeading/SearchHeading.vue'
|
||||
@ -80,6 +82,7 @@ export default {
|
||||
components: {
|
||||
OsButton,
|
||||
OsIcon,
|
||||
OcelotSelect,
|
||||
SearchHeading,
|
||||
SearchGroup,
|
||||
SearchPost,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ds-select
|
||||
<ocelot-select
|
||||
class="select-user-search"
|
||||
type="search"
|
||||
icon="search"
|
||||
@ -26,18 +26,20 @@
|
||||
<user-teaser :user="option" :showPopover="false" :linkToProfile="false" />
|
||||
</p>
|
||||
</template>
|
||||
</ds-select>
|
||||
</ocelot-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isEmpty } from 'lodash'
|
||||
import { searchUsers } from '~/graphql/Search.js'
|
||||
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
|
||||
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||
|
||||
export default {
|
||||
name: 'SelectUserSearch',
|
||||
components: {
|
||||
UserTeaser,
|
||||
OcelotSelect,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user